🦊

会話履歴を保持するChatGPTクローンをFastAPI・WebSocket・LangChain・Reactで作る

2023/03/11に公開

先日ChatGPTに使用されているGPTモデルであるGPT3.5-turboのAPIが公開されました。 以前のGPT3と比べ、コストが1/10となっていたり、体感速度が向上していたりと、サービスに使用するにあたってかなりハードルが下がったように思います。
前回記事ではGPT3のAPIについて、LangChainで使用し、FastAPIとReactでAIとチャットするシンプルなアプリケーションを作成しました。前回作成したものは、会話の履歴を保持する仕組みを持たなかったため、ChatGPTと異なり、AIが会話の文脈を理解してくれませんでした。
https://zenn.dev/ktechb/articles/langchain-react-fastapi

今回は前回作成したReact/FastAPI製のChatアプリについて、GPT3.5に変更し、さらにLangChainのMemory機能を用いて会話履歴をもたせる変更を行います。

本記事の内容

やること

  • LangChainを用いたGPT3.5-turboの導入
  • Memory機能を用いた会話履歴の追加
  • WebSocketを用いたBackend-Frontend間の通信

やらないこと

  • React, FastAPIの解説
  • 商用に耐えうる細かな設計
  • GPT3.5APIのstream機能の使用
  • LangChainのChatの使用
  • 前回記事の内容のおさらい(前回記事を読んでいる前提での内容となります)

注意点として著者はWebSocketの経験が浅いので、何らかバッドプラクティスを含む可能性があります。コメント等で改善点などを教えていただけると嬉しく思います。

完成物

もととなるレポジトリ

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

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

完成物

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

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

会話の記憶をAIに持たせる

基本的にGPT3.5を始めとするLLM(大規模言語モデル)は記憶する仕組みを持ちません。そのため、会話の文脈を理解させるためには、これまでの会話を毎度の入力する必要があります。
これを現在の構成で実現する方法として2つ考えられます。

  1. Backend側で状態を保持する
  2. Frontend側で会話履歴を保持し、会話履歴を毎度リクエストに含める

本記事ではLangChainで作成したLLMアプリをそのままサービス化するという方針であるため、LangChainのMemmory機能を用いて1の方針で進めます。backend側でユーザごとに状態を保持する方法はいくつかあると思われますが、AIとのリッチなインタラクションへの拡張を見越してここではWebSocketを採用します。

ただし、Frontendで保持するメリットとしてはバックエンド側で状態を保持する必要がなく、ステートレスなAPIを維持することができる点は魅力的です。LangChainがクライアント側でも使えれば比較的スムーズに実装が進むのですが、Langchain.jsは現在はnode.jsのみでの対応でありがブラウザでは実行できないようなので、対応次第試してみたいと思っています。

GPT3.5と会話履歴保持機能の追加

GPT3.5は、GPT3と異なり、OpenAIChatというクラスを用います。

llm = OpenAIChat(model_name="gpt-3.5-turbo")

GPT3同様実行時と同様
export OPENAI_API_KEY="OpenAIのアクセストークン"
を実行して環境変数OPENAI_API_KEYにOpenAIで発行したアクセストークンを設定して実行する必要があることに注意してください。

つづいて、会話履歴を保持するMemoryを作成します。メモリーとしては以下のようなものがあります.

  • Buffer型: 単純に以前の履歴をそのまま入力するもの
  • Summary型:履歴をLLMを用いて要約しながら保持するもの
  • Entity Memory型:履歴をLLMに入力し{Sam:10歳の少年で昨日テニスをした。}のように構造化して保持

今回はシンプルにしたいので,直近K個の会話を保持するConversationalBufferWindowMemoryを使用します。
mermoryオブジェクトはLLMChainによって保持され、memory_keyで指定した値でpromptに入力されます。

具体的な実装は以下のようになります。(ChatGPTと同じだと面白くないのでプロンプトは関西弁で返してくれるように変更してみました。)

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

from langchain.chains.conversation.memory import ConversationBufferWindowMemory

def create_conversational_chain():
    llm = OpenAIChat(model_name="gpt-3.5-turbo")
    template = """あなたは関西弁を巧みに使いこなす親切で気のいい狐です。人間と会話をしています。

{chat_history}
人間: {input}
狐:"""
    prompt = PromptTemplate(
        input_variables=["chat_history", "input"], template=template
    )
    memory = ConversationBufferWindowMemory(k=5, memory_key="chat_history")
    chain = LLMChain(
        llm=llm,
        prompt=prompt,
        verbose=True,
        memory=memory,
    )

    return chain

これにより、以下のようにLLMChainを作成し会話が可能です。

chain = create_conversational_chain()
print(chain.predict(input=question))

なお、GPT3.5からは今までのような文字列のプロンプトではなくて、誰が(role)、何を発話したのか(content)ということを1つのMessageとして、そのリストを渡す形式に変わりました。これは、内部的にはChat Markup Language (“ChatML”)という文字列形式に変換してモデルに入力され、これにより拡張性を確保したり、プロンプトインジェクションを抑止することを目的としているようです。
https://github.com/openai/openai-python/blob/main/chatml.md

本来LangChainでGPT3.5を用いる場合は、Chatという概念に合わせたPromptやMessageを作成することでこのroleやcontentを正しく入力することができます。ただし、本記事は複雑化しないために一旦割愛します(時間があれば別記事で紹介します)。

backend、frontendにwebsocketを追加する

会話履歴を保持するChainの作成が完了しました。しかし、通常のRestfulAPIではサーバー側で状態を持たないため、アクセスごとに会話の履歴を保存することができません。そのため、以下では各接続に対してLlmChainを発行し、一連の会話が終わるまでWebSocketで接続する実装を行います。

backend

WebSocketのAPIはFastAPIの機能でかんたん実装できます。各コネクションごとにchainを前章で実装したcreate_conversational_chain()でインスタンス化して会話履歴を保持します。

main.py

@app.websocket("/chat")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()
    # create agent
    chain = create_conversational_chain()
    while True:
        try:
            # Receive and send back the client message
            question = await ws.receive_text()
            answer = chain.predict(input=question)
            resp = LLMResponse(text=answer)
            await ws.send_json(resp.dict())
        except WebSocketDisconnect:
            break
        except Exception as e:
            logging.error(e)
            resp = LLMResponse(
                text="Error happern.",
            )
            await ws.send_json(resp.dict())

Frontend

Frontendは以下のように以前のRestfulAPIとの通信と同じ入出力で実装しました。WebSocket部分はこちらの記事を参考にさせていただきました。このあたりは書き方が色々ありそうですが、深入りはしないこととします。

import { useCallback, useEffect, useRef, useState } from 'react'

import { Message } from './components/Dialog'

export const useChatSocket = () => {
  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]
  )

  // cite 
  const socketRef = useRef<WebSocket>()
  useEffect(() => {
    const websocket = new WebSocket('ws://localhost:8000/chat')
    socketRef.current = websocket

    const onMessage = (event: MessageEvent<string>) => {
      const text = JSON.parse(event.data).text ?? ''
      addMessage(0, text)
    }
    websocket.addEventListener('message', onMessage)

    return () => {
      websocket.close()
      websocket.removeEventListener('message', onMessage)
    }
  }, [])
  const onSubmit = useCallback(() => {
    setMessages((ms) => [...ms, { speakerId: 1, text: inputText }])
    socketRef.current?.send(inputText)
    setInputText('')
  }, [addMessage, setInputText, inputText])
  return { inputText, setInputText, messages, onSubmit }
}

成果物

あとは前回同様backendは uvicorn llm_server.main:app --reload frontendはyarn dev を実行し、localhost:5173 から以下のように実行できます。

おわりに

本記事ではGPT3.5とLangChainで作成したMemory付きのLLMアプリケーションについてWebSocketを用いることでチャットごとの状態を保持する実装を行いました。これを拡張し、LangChainで使用するMemmoryを変更したり、toolを使わせて検索機能を追加するなども可能です。

本記事としてはシンプルな実装を行いましたが、改善点としては以下のようなことがあります。

  • ChatMLにあわせてPromptを作成する (これをしないせいか今のままだと変な応答がでたりします)
  • 他のメモリーバッファーを使用する
  • 外部情報にアクセスするようなtoolを使用する
  • プロンプトの改善
  • websocket周りの改善
  • フロント側で会話履歴をもたせたRestAPI形式での実装

また、今後はLLMはマルチモーダルの方向に進歩していくことは間違いないので、画像の入力、出力などそういったことも取り入れていきたいと思っています。

Discussion