🐕

LangChain・FastAPI・ReactでGPTチャットアプリを作成する

2023/02/18に公開


都内でAIエンジニアをやっています、Buchioです。
近年話題のChatGPTやBingAIは非常に強力なツールで知的生産に役立つものだと思います。ただ、あくまで誰でも無料で使える汎用的なアプリケーションであり、専門的な文章を読み込ませてたり、ドキュメント丸々を要約させるなどの柔軟なことはできません。そのため、特定のドメインやシナリオに合わせてカスタマイズできる雛形を手元に置いておきたいと考えました。

LangChainは、OpenAIのAPIにアクセスして任意のモデルを利用できるだけでなく、プロンプトの管理、独自の質問応答ロジックや検索機能も提供してくれるため、自分用のチャットボットを作成するのに非常に便利なツールです。

この記事では、LangChainを使ってAIチャットボットを作成し、FastAPIとReactでWebUIとして扱いやすい形式にするテンプレート的なものを作成します。

前提条件

本記事ではLangChainのUI部分を作成する全体感を伝えることに主眼におくため、LangChain自体の具体的な解説や細かな環境構築は省きます。また、実サービスを前提とした設計としてはいろいろ穴があると思われるので、簡易的なプロトタイプとして捉えてください。

やること

  • FastAPI上でLangChainを用いたAPIを作成
  • OpenAPIを用いたフロントのAPIClientの自動生成
  • React-typescript-tailwind.cssを用いた簡易的なUIの作成

やらないこと

  • LangChainの具体的な使用方法解説
  • web socketを用いた会話履歴を考慮したChatbot

前提とする環境

  • Linux or Mac
  • Dockerがインストール済み(docker composeが実行可能)
  • yarn, poetryがインストール済み
  • OpenAIのアカウントを保有

完成物

backend
https://github.com/KtechB/llm-server/tree/v0.1.0

frontend
https://github.com/KtechB/llm-interface-react/tree/v0.0.0

backend

backendは以下のように構成します。

  • poetry
  • FastAPI
  • LangChain

poetryはpythonの仮想環境や依存関係を管理するためのツールであり、javascriptでいうnpmのようなものです。近年は、Python開発において、poetryが広く採用されるようになっています。

FastAPIはRESTful APIを開発するためのモダンで高速なWebフレームワークです。FastAPIはPythonの型ヒントに基づいてデータのバリデーション、シリアライズ、デシリアライズを行い、自動的にOpenAPIドキュメントを生成します。また、ASGIサーバに対応しており、非同期処理やWebSocketなどもサポートしています。また作成したAPIのスキーマを、OpenAPI形式で出力可能です。

LangChainは、大規模言語モデル(LLM)と外部リソース(データソースや言語処理系など)を組み合わせたアプリケーションの開発支援を目的としたPythonライブラリです。LangChainでは、LLMにプロンプトを与えてテキスト生成や質問応答などのタスクを実行できます。また、チェーンという機能を使って、複数のLLMや外部リソースを連携させることもできます。

FastAPIを環境を構築

こちらの記事を参考にFastAPIの環境を構築しました。(一部省略)
以下ではpoetryがインストールされている前提で進めます。

poetry new --src llm-server
poetry config virtualenvs.path ".venv" --local
poetry config virtualenvs.in-project true --local

poetry add --dev mypy black flake8
poetry add fastapi uvicorn gunicorn

langchainの追加

LangChainを用いてAIモデルを動かします。ここではシンプルにOpenAIのGPT3に対してQ,Aのプロンプトを与えて回答するようにしています。この部分を別のLLMに置き換えることでOpenAPIのサーバを叩かず自前のLLMを用いることも可能です。

依存関係の追加

poetry add langchain
poetry add openai

実装
src/llm_server/simple_agent.py

from langchain import PromptTemplate, LLMChain
from langchain.llms import OpenAI


def ask_question(question: str) -> str:
    llm = OpenAI(temperature=0.9)
    template = """Question: {question}

    Answer:"""

    prompt = PromptTemplate(template=template, input_variables=["question"])
    llm_chain = LLMChain(prompt=prompt, llm=llm)

    answer = llm_chain.run(question)
    return answer

APIの作成

FastAPIではAPIを作成するために以下の3つを用意します。

  • リクエストボディ
  • レスポンスボディ
  • APIメソッドに対応する関数

今回は単純なtextを受け取り、先ほど作成したask_questionに渡して、返り値をtextを返せばよいので、以下のようになります。

main.py

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field

from llm_server.simple_agent import ask_question

app = FastAPI()

origins = ["http://localhost:5173"]# Frontendのオリジンをここに書く
app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


class Message(BaseModel):
    text: str = Field(title="Request message to LLM.", max_length=1000)


class LLMResponse(BaseModel):
    text: str


@app.get("/healthcheck")
def healthcheck():
    return {}


@app.post("/llm")
async def run_llm(message: Message) -> LLMResponse:
    answer = ask_question(message.text)
    return LLMResponse(text=answer)

以下のコマンドで起動します。 OpenAIのアクセストークンはOpenAIのサイトから発行できます。(一定を超えると有料になるので注意)

export OPENAI_API_KEY="OpenAIのアクセストークン"
uvicorn llm_server:main:app --reload

ブラウザでlocalhost:8000/docsを開くと、APIドキュメントが表示されるので、以下のようにtryoutでAPIを叩いて動作を確認します。

Frontend

フロントエンドは以下のような構成で実装します。

  • React
  • typescript
  • tailwind.css
  • vite
  • storybook

設定周りは以下の記事を参考にさせていただきました。
https://zenn.dev/alesion/articles/132483d3fb6949

yarn create vite llm-interface-react --template react-ts
yarn add -D prettier eslint eslint-config-prettier eslint-plugin-{import,prettier,react,react-hooks}
yarn add -D @typescript-eslint/{parser,eslint-plugin}
yarn add -D npm-run-all
touch .eslintrc .prettierrc .eslintignore .prettierignore

tailwind.css導入

yarn add -D tailwindcss postcss autoprefixer
yarn tailwindcss init -p

storybook追加

npx storybook init

yarn add -D @storybook/addon-postcss

諸々の設定などは完成物を参照ください。
https://github.com/KtechB/llm-interface-react/tree/v0.0.0

frontend側のClientの自動生成

次にFrontend側のAPIアクセス部分をFastAPIから生成されるスキーマファイルから自動生成します。
スキーマ駆動開発では以下のように、スキーマに対してそれぞれが依存するため、schemaファイルや、生成コードはbackendやfrontendの外側に置くべきです。


しかし、FastAPIではbackendコードからスキーマが生成されるのでshemaはbackendに依存すると言えます。

この理由から、ここでは生成用のコードなどはfrontend側におくこととします。

自動生成は以下の記事を参考にしつつおこないました。
https://qiita.com/whitphx/items/9278f948520c8d5b7b5a

まず、 前章の手順でbackendを立ち上げてlocalhost:8000/openapi.jsonにアクセスし、openapi.jsonをfrontendのルートディレクトリに置きます。
次に以下のようなdocker-compose.ymlをフロントエンドのルートディレクトリに作成、docker compose upを実行して自動生成を行います。

version: "3.7"

services:
  openapi-generator:
    image: openapitools/openapi-generator-cli
    volumes:
      - ./:/frontend
    working_dir: /frontend
    command:
      - generate
      - -g
      - typescript-axios
      - -i
      - ./openapi.json
      - -o
      - /frontend/src/api-client
      - --additional-properties=supportsES6=true,modelPropertyNaming=original

これによりsrc/api-client以下にクライアントアプリが生成できました。

フロントエンドの実装

chatbotの基本的な動作としては以下のような動作になります。

  • メッセージログの表示
  • メッセージの入力
  • chatbotへのメッセージの送信、レスポンスをログに追加

ログの表示としては以下のようにMessageオブジェクトのリストからログを表示するコンポーネントを作成します。
Messageオブジェクトはテキストの内容と発言者のIDを持ち、発言者のIDが0だと左、それ以外は右になるようにしています。

import clsx from 'clsx'

export type DialogProps = {
  messages: Message[]
}
export type Message = {
  speakerId: number
  text: string
}

export default function Dialog({ messages }: DialogProps) {
  return (
    <ul className=' flex flex-col h-full w-full gap-2'>
      {messages.map((message) => (
        <li
          className={clsx(
            'flex ',
            message.speakerId === 0 ? 'justify-start' : 'justify-end'
          )}
          key={message.text}
        >
          <div
            className={clsx(
              'relative max-w-[80%] px-4 py-2  rounded shadow dark:bg-gray-800'
            )}
          >
            <span className='block'>{message.text}</span>
          </div>
        </li>
      ))}
    </ul>
  )
}

全体のアプリケーションとしては以下のようになります。

import clsx from 'clsx'

import { DefaultApi, Configuration } from './api-client'
import Button from './components/Button'
import Dialog from './components/Dialog'
import Header from './components/Header'
import { useChat } from './useChat'

const config = new Configuration({ basePath: 'http://localhost:8000' }) // TODO: This is for dev
export const apiClient = new DefaultApi(config)

function App() {
  const { inputText, setInputText, messages, onSubmit } = useChat(apiClient)
  return (
    <div className='flex flex-col h-full w-full justify-center'>
      <Header />
      <div className='flex flex-col h-full w-full items-center'>
        <div
          className={clsx(
            'flex flex-col justify-center h-full w-5/6 max-w-screen-md gap-5 p-5'
          )}
        >
          <Dialog messages={messages} />
          <div id='input_form' className={clsx('flex flex-row gap-3')}>
            <input
              className='w-full'
              value={inputText}
              onChange={(e) => setInputText(e.target.value)}
            />
            <Button onClick={onSubmit} />
          </div>
        </div>
      </div>
    </div>
  )
}

export default App

ここでバックエンドとの通信やログの管理のようなチャットのロジックとなる部分はuseChatとしてまとめています。
バックエンドへのアクセスは自動生成されたapiClientオブジェクトが各種APIに対応するメソッドを持つため、これを利用します。ここではapiClient.runLlmLlmPost({text:inputText})のように実行しています。(エラーキャッチは省略)

import { useCallback, useState } from 'react'

import { DefaultApi } from './api-client'
import { Message } from './components/Dialog'

export const useChat = (apiClient: DefaultApi) => {
  const [inputText, setInputText] = useState<string>('')
  const [messages, setMessages] = useState<Message[]>([
    { speakerId: 0, text: 'Hello!' },
  ])
  const addMessage = useCallback(
    (speakerId: number, text: string) => {
      setMessages((ms) => [...ms, { speakerId: speakerId, text: text }])
    },
    [setMessages]
  )
  const onSubmit = useCallback(() => {
    setMessages((ms) => [...ms, { speakerId: 1, text: inputText }])
    apiClient.runLlmLlmPost({ text: inputText }).then((x) => {
      addMessage(0, x.data.text)
    })
    setInputText('')
  }, [addMessage, setInputText, inputText])
  return { inputText, setInputText, messages, onSubmit }
}

異常の工程でフロントエンドが完成しました。以下のコマンドでフロントエンドを立ち上げます。

yarn dev

動作確認

標準だとlocalhost:5173にアクセスするとフロントエンドが見れると思います。実行すると以下のようになりました。GPTくんはO型であり、人狼ではないらしいです。

おわりに

本記事ではLangChainで作成したAIbotをFastAPIとReact経由でアプリケーション化するということを行いました。 LangChainには、AIbotに検索エンジンをしようさせたり、DBのデータを知識として活用させるなどの機能があるため、これをベースにプロンプトの工夫次第でさまざななアプリケーションが作成できます。

しかしながら、ここでは単純なRestAPIを作成しており、会話の履歴情報はこのままだと保持が難しいです。これに対しては、各チャット接続に対してソケット通信をしたり、会話履歴はフロントで管理させるなどが必要になります。ちょうどこれを作成した日に、LangChainのTypescript実装も発表されたので、近々触ってみたいとおもいます。

最後になりますが、本記事は何かと間違いやbad practiceを含んでいる可能性があります。有識者の方は是非コメントでフィードバックをいただければ嬉しいです。

Discussion