JavaScript版のLangChainを触る機会があったので、備忘録も兼ねて簡単なチャットBOTを作ってみます。
構成
- さくっと動かしたいのでNext.jsのAPI Routes上でLangChainを使います。
- 簡略化のためにチャット履歴はメモリ上に保存します。※Nextを再起動するとチャット履歴がリセットされます。
- エラーハンドリングはほとんど行っていません。
LangChain周り
LangChainには大きく分けて6つの機能がありますが、今回は下記の3つを使用します。
Model
各種LLM等を使いやすく抽象化してくれているもの
今回は「ChatOpenAI」を使用します
Memory
過去の対話履歴を記録するための機能
下記のように記録形式が異なるMemoryが用意されています。
- 過去の対話を全て記録
- 過去の対話をn回分記録
- 過去の対話を要約して記録
今回は過去の対話をn回分記録する「BufferWindowMemory」を使用します
Chain
各種機能を組み合わせて一連の処理として実行できる機能。
今回は対話用Chainの「ConversationChain」を使用します。
サンプル
今回作った物のリポジトリです。
https://github.com/HiroKb/langchain-next-chat
create next app & LangChainのインストール
npx create-next-app@latest ✔ What is your project named? … langchain-chat ✔ Would you like to use TypeScript? … Yes ✔ Would you like to use ESLint? … Yes ✔ Would you like to use Tailwind CSS? … Yes ✔ Would you like to use `src/` directory? … Yes ✔ Would you like to use App Router? (recommended) … Yes ✔ Would you like to customize the default import alias? … Yes ✔ What import alias would you like configured? … @/* cd langchain-chat npm install -S langchain npm run dev
チャットメッセージの取得と表示
LangChainが絡まない部分を先に作ってしまいます。
API
メッセージの取得処理を作成します。
/src/app/api/messages/route.ts
import {NextResponse} from "next/server"; export const Role = { Assistant: 'assistant', User: 'user' } as const export type Message = { id: number content: string role: typeof Role[keyof typeof Role] } // メモリ上にチャット履歴を保持する。ひとまず仮データ const messages: Message[] = [ {id: 1, content: 'User Message1', role: Role.User}, {id: 2, content: 'Assistant Message1', role: Role.Assistant}, {id: 3, content: 'User Message2', role: Role.User}, {id: 4, content: 'Assistant Message2', role: Role.Assistant}, ] // GET /api/messagesにマッピングされる export async function GET() { return NextResponse.json({messages: messages}) }
Client
メッセージの取得/表示部分と、メッセージの入力フォームを作成します
/src/app/(chat)/page.tsx
import {Message} from "@/app/api/messages/route"; import ChatMessageForm from "@/app/(chat)/ChatMessageForm"; import Messages from "@/app/(chat)/Messages"; const getMessages = async () => { const res = await fetch('http://localhost:3000/api/messages', { cache: 'no-store' }) if (!res.ok) throw new Error('Fetch Error') const json = await res.json() return json.messages as Message[] } export default async function Home() { const messages = await getMessages() return ( <main className="flex min-h-screen justify-center"> <div className="w-2/3 relative"> <div className="py-10 mb-14"> <Messages messages={messages}/> </div> <div className="absolute w-full bottom-0 pb-5"> <hr className="border-gray-300"/> <MessageForm messages={messages}/> </div> </div> </main> ) }
/src/app/(chat)/Messages.tsx
import {Message, Role} from "@/app/api/messages/route"; type Props = { messages: Message[] } export default function Messages({messages}: Props) { return ( <ul className="space-y-4"> {messages.map((message) => ( message.role === Role.Assistant ? <li className="flex justify-start" key={message.id}> <div className="px-4 py-2 text-gray-700 bg-white rounded shadow"> <pre className="whitespace-pre-wrap">{message.content}</pre> </div> </li> : <li className="flex justify-end" key={message.id}> <div className="px-4 py-2 text-gray-900 bg-gray-300 rounded shadow"> <pre className="whitespace-pre-wrap">{message.content}</pre> </div> </li> ))} </ul> ); }
/src/app/(chat)/MessageForm.tsx
'use client' import React, {createRef, useEffect, useState} from "react"; import {Message} from "@/app/api/messages/route"; type Props = { messages: Message[] } export default function MessageForm({messages}: Props){ const formRef = createRef<HTMLFormElement>() const [message, setMessage] = useState<string>('') const [sending, setSending] = useState<boolean>(false) useEffect(() => { formRef?.current?.scrollIntoView({behavior: 'smooth'}) },[messages]) const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault() if (sending || message === '') return setSending(true) setMessage('') setSending(false) } return ( <form className="flex items-center justify-between w-full border-gray-300 my-3 space-x-3.5" onSubmit={handleSubmit} ref={formRef} > <input value={message} onChange={(e) => setMessage(e.target.value)} type="text" placeholder="Message" className="block w-full py-2 px-4 bg-gray-100 rounded-full outline-none focus:text-gray-700" name="message" required /> { !sending ? <button type="submit" > <svg className="w-5 h-5 text-gray-500 origin-center transform rotate-90" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" > <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" /> </svg> </button> : <div className="animate-spin h-5 w-5 border-4 border-gray-700 rounded-full border-t-transparent"></div> } </form> ); }
元々存在した/src/app/page.tsx
は削除します。
ここまででhttp://localhost:3000/にアクセスすると以下のようなチャット画面が表示されるかと思います。
LangChainを介してOpenAIに回答を生成してもらう
まずはOpenAIのAPI keyを環境変数に設定します
.env
OPENAI_API_KEY=<your-api-key>
API
回答の生成とチャット履歴の保存処理を追加します。
/src/app/api/messages/route.ts
import {BufferWindowMemory, ChatMessageHistory} from "langchain/memory"; import {AIMessage, HumanMessage} from "langchain/schema"; import {ConversationChain} from "langchain/chains"; import {ChatOpenAI} from "langchain/chat_models/openai"; // POST /api/messagesにマッピングされる export async function POST(req: Request) { // 入力内容の取得 const {message: userMessageContent} = await req.json() // 対話履歴をLangChain用に変換 const chatMessageHistory = new ChatMessageHistory(messages.map(message => { return message.role === Role.Assistant ? new AIMessage(message.content) : new HumanMessage(message.content) })) const memory = new BufferWindowMemory({ k: 4, // 過去4回分の対話を使用する chatHistory: chatMessageHistory, }) // 生成用のmodelを作成する。 // openAIApiKeyを指定しない場合、環境変数内のOPENAI_API_KEYに設定した値を読み取ってくれる const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo", // openAIApiKey: process.env.OPENAI_API_KEY, }) // 対話用Chainに過去の対話履歴を埋め込んで作成し、呼び出す const chain = new ConversationChain({ llm: model, memory: memory, verbose: false // verboseをtrueにすると処理内容などが出力される }); const chainResult = await chain.call({input: userMessageContent}); const assistantMessageContent = chainResult.response // メモリ上のチャット履歴にメッセージを追加 const userMessage = { id: messages.length + 1, content: userMessageContent, role: Role.User } messages.push(userMessage) const assistantMessage = { id: messages.length + 1, content: assistantMessageContent, role: Role.Assistant } messages.push(assistantMessage) return new Response() }
messages配列に入れていた仮データは削除しておきます。
// メモリ上にチャット履歴を保持する const messages: Message[] = []
Client
フォームコンポーネントにメッセージの登録処理を追加します。
/src/app/(chat)/MessageForm.tsx
import {useRouter} from "next/navigation"; ~~~~省略~~~~ const router = useRouter() const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => { event.preventDefault() if (sending || message === '') return setSending(true) // メッセージの追加後、全メッセージを再取得する await fetch('http://localhost:3000/api/messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({message}), }) router.refresh() setMessage('') setSending(false) }
チャットしてみる
ここまでで最低限チャットできるようになりました。
以下のように過去の対話履歴を記憶した状態で応答してくれます。
おわりに
実はこの程度のチャットBOTであれば、LangChainではなくOpenAI公式のライブラリを使用したほうが簡単に実装できたりします。
ただ、もっと複雑なことをしたい場合等はLangChainが複雑な部分を隠蔽してよしなにやってくれるので便利だなーという印象でした。
作るものによって使い分けていけると良いですね。