日立製作所製の運用統合サービスであるOps IのWebマニュアルを、スクレイピングして、その結果をGPTに与えて、Ops Iユーザ向けAIアシスタントとして動くLLMアプリをLangChainとLangServeで作ってみた話。

前回の記事でLangChainのRAGは作ったので、それをLangServeでWebサーバにする。

LangServeとは

LangServeは、LangChainで作ったRunnableやchainをREST APIで呼び出せるサーバを簡単に書けるライブラリ。 具体的には、add_routesという関数が提供され、それにRunnableやchainを与えるとFastAPIのAPIを定義してくれる。

FastAPIがPydanticに対応してるので、add_routesで定義したAPIは入力のバリデーションしてくれたり、Swagger UIで見れたりして便利。

定義したAPIは、LangServeのPlaygroundというGUIで手軽に試すこともできる。

Ops Iとは

Ops IはJP1 Cloud Service/Operations Integrationの略で、日立製作所製の運用統合サービス。

Operations as Codeが特長で、運用自動化コードやワークフローをGitで集約管理して、ハイブリッド環境における様々なシステム運用の自動化と統合を推進できる。



Ops Iエージェントサーバ

ここから、Ops Iユーザ向けAIアシスタントとして動くLLMアプリケーションサーバであるOps Iエージェントサーバを作っていく。 使う主なモジュールは以下。

  • LangChain 0.2.1
  • LangServe 0.2.1
  • FastAPI 0.111.0
  • Uvicorn 0.29.0

完成したコードはGitHubに置いた。

LangChainのchainをLangServeに乗せる

前回の記事で作ったchainをLangServeでWeb APIから呼べるようにする。

まずはLangServeと、それが依存するFastAPIをインストールする。 FastAPIはASGIアプリのフレームワークで、動かすにはASGIサーバが必要なので、Uvicornも入れる。

$ pip install langserve[server] fastapi uvicorn[standard]

あとは、FastAPIのインスタンスを作って、chainをadd_routes()して、uvicorn.run()するだけ。

import nest_asyncio
import uvicorn
from fastapi import FastAPI
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.indexes import VectorstoreIndexCreator
from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders.sitemap import SitemapLoader
from langchain_community.vectorstores.inmemory import InMemoryVectorStore
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langserve import add_routes


nest_asyncio.apply()

text_splitter = CharacterTextSplitter(
    separator = "\n",
    chunk_size = 400,
    chunk_overlap = 0,
    length_function = len,
)

loader = SitemapLoader(web_path="https://itpfdoc.hitachi.co.jp/manuals/JCS/JCSM71020002/sitemap.xml")

index = VectorstoreIndexCreator(
    vectorstore_cls=InMemoryVectorStore,
    embedding=OpenAIEmbeddings(),
    text_splitter=text_splitter,
).from_loaders([loader])

retriever = index.vectorstore.as_retriever()

prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        """contextに基づいて、Ops Iの質問になるべく頑張って答えてください。ただし、Ops Iと関係ない質問に対しては、知るかボケと回答してもいいです:

<context>
{context}
</context>
"""
    ),
    (
        "human",
        "質問: {input}"
    )
])
llm = ChatOpenAI(model="gpt-3.5-turbo")
chain = create_retrieval_chain(retriever, create_stuff_documents_chain(llm, prompt))

app = FastAPI(
    title="Ops I Assistant",
    version="1.0",
    description="Ops I Assistant",
)
add_routes(
    app,
    chain,
    path="/opsi",
)

uvicorn.run(app, host="localhost", port=8080)

(前回の記事のコードからの差分はこれ)

これを実行すると、Ops Iエージェントサーバが起動する。

$ export OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
$ python main.py
Fetching pages: 100%|################################################################| 122/122 [00:08<00:00, 14.21it/s]
INFO:     Started server process [9380]
INFO:     Waiting for application startup.

 __          ___      .__   __.   _______      _______. _______ .______     ____    ____  _______
|  |        /   \     |  \ |  |  /  _____|    /       ||   ____||   _  \    \   \  /   / |   ____|
|  |       /  ^  \    |   \|  | |  |  __     |   (----`|  |__   |  |_)  |    \   \/   /  |  |__
|  |      /  /_\  \   |  . `  | |  | |_ |     \   \    |   __|  |      /      \      /   |   __|
|  `----./  _____  \  |  |\   | |  |__| | .----)   |   |  |____ |  |\  \----.  \    /    |  |____
|_______/__/     \__\ |__| \__|  \______| |_______/    |_______|| _| `._____|   \__/     |_______|

LANGSERVE: Playground for chain "/opsi/" is live at:
LANGSERVE:  │
LANGSERVE:  └──> /opsi/playground/
LANGSERVE:
LANGSERVE: See all available routes at /docs/

LANGSERVE: ⚠️ Using pydantic 2.7.1. OpenAPI docs for invoke, batch, stream, stream_log endpoints will not be generated. API endpoints and playground should work as expected. If you need to see the docs, you can downgrade to pydantic 1. For example, `pip install pydantic==1.10.13`. See https://github.com/tiangolo/fastapi/issues/10360 for details.

INFO:     Application startup complete.
INFO:     Uvicorn running on http://localhost:8080 (Press CTRL+C to quit)


Pydanticがv1じゃないとOpenAPIドキュメントが生成されないといった警告が出てるけど、API実行には影響ないのでほっておく。

chainは/opsiというパスに追加したので、それをinvokeするには/opsi/invokeにchainの入力パラメータをPOSTしてやればいい。 入力パラメータはリクエストボディのJSONのinputキーにセットする。

$ curl -XPOST -H 'Content-Type:application/json' -d '{"input": {"input": "Ops Iの機能を教えて"}}' http://localhost:8080/opsi/invoke
Internal Server Error

サーバエラーになった。

サーバのコンソールには以下のメッセージが出ている。

INFO:     ::1:51421 - "POST /opsi/invoke HTTP/1.1" 500 Internal Server Error
ERROR:    Exception in ASGI application

(snip)

  File "/home/kaitoy/.venv/lib/python3.12/site-packages/langchain/chains/retrieval.py", line 61, in <lambda>
    retrieval_docs = (lambda x: x["input"]) | retriever
                                ~^^^^^^^^^
KeyError: 'input'

chainの最初のRetrieverに渡された入力に、inputというキーがなかったというエラー。

確かに、Playgroundを見ても、inputという入力パラメータが表示されない。

playground1.png


chainからは入力パラメータの型がPydanticスキーマとしてとれて、FastAPIがPydanticのスキーマからAPIのリクエストボディを推論してくれるので、基本はchainをそのままFastAPIに渡せば期待通りに動くんだけど、今回のchainからは正しい型が取れないっぽい。 どうも、chainを作るときに使ったcreate_retrieval_chainが内部でRunnablePassthroughを使っていて、それが原因の模様

LangChainのchainに入力型を付ける

chain(というかRunnable)には型を明示的に付けるインターフェースが用意されてるので、それを使う。

import nest_asyncio
import uvicorn
from fastapi import FastAPI
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.indexes import VectorstoreIndexCreator
from langchain.pydantic_v1 import BaseModel
from langchain.text_splitter import CharacterTextSplitter
from langchain_community.document_loaders.sitemap import SitemapLoader
from langchain_community.vectorstores.inmemory import InMemoryVectorStore
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langserve import add_routes


nest_asyncio.apply()

text_splitter = CharacterTextSplitter(
    separator = "\n",
    chunk_size = 400,
    chunk_overlap = 0,
    length_function = len,
)

loader = SitemapLoader(web_path="https://itpfdoc.hitachi.co.jp/manuals/JCS/JCSM71020002/sitemap.xml")

index = VectorstoreIndexCreator(
    vectorstore_cls=InMemoryVectorStore,
    embedding=OpenAIEmbeddings(),
    text_splitter=text_splitter,
).from_loaders([loader])

retriever = index.vectorstore.as_retriever()

prompt = ChatPromptTemplate.from_messages([
    (
        "system",
        """contextに基づいて、Ops Iの質問になるべく頑張って答えてください。ただし、Ops Iと関係ない質問に対しては、知るかボケと回答してもいいです:

<context>
{context}
</context>
"""
    ),
    (
        "human",
        "質問: {input}"
    )
])
llm = ChatOpenAI(model="gpt-3.5-turbo")
chain = create_retrieval_chain(retriever, create_stuff_documents_chain(llm, prompt))

class ChainInput(BaseModel):
    input: str
app = FastAPI(
    title="Ops I Assistant",
    version="1.0",
    description="Ops I Assistant",
)
add_routes(
    app,
    chain.with_types(input_type=ChainInput),
    path="/opsi",
)

uvicorn.run(app, host="localhost", port=8080)

(前節コードからの差分はこれ)

上のコードで、ChainInputというPydanticスキーマを定義して、chainのwith_types()に渡すことでchainの入力型を明示している。

これで起動して、invoke APIを呼んだらちゃんと動いた。

$ curl -XPOST -H 'Content-Type:application/json' -d '{"input": {"input": "Ops Iの機能を教えて"}}' http://localhost:8080/opsi/invoke
{"output":{"input":"Ops Iの機能を教えて","answer":"Ops Iの機能は、基本的な画面構成、アカウント管理、タスク、リクエスト、サービスカタログ、ワークフロー、チケットなどがあります。これらの機能を活用することで、システム運用に必要な作業の管理や要員のスケジュール管理が効率的に行えます。"},"metadata":{"run_id":"d9d19e5b-05de-44c6-8cd8-a5f597a15a70","feedback_tokens":[]}}


Playgroundにもinput入力欄が表示されて、クエリ実行できた。

playground1.png