チャットボットを構築する
はじめに
langchainの公式チュートリアル(TypeScript版)を実践しています。
前回の第1回に引き続き、今回は基礎の第2回であるBuild a Chatbotを実践した結果を記事にしています
チャットボット
前回の Build a Simple LLM Application with LCELでは、過去の入力が結果に影響することはありませんでした。
今回作成するチャットボットでは、過去のやり取りを記憶し、会話をすることができます。
前提
公式チュートリアルの内容をこなすにあたり、自分のリソースに合わせてアレンジしている部分があります。
(例えば、環境変数は dotenv を利用する。モデルは AzureOpenAI リソースを使うなど)
langchain 中心の記事なので Azure のリソース作成といった操作には触れていません。
環境
OS:macOS Sonoma14.3
node:v21.6.1
チャットモデル:AzureOpenAI
dotenv: 16.4.5,
langchain: 0.3.2,
typescript: 5.6.2
準備
必要なモジュールをインストールします。
npm i @langchain/core @langchain/langgraph uuid
パッケージのインストール
- @langchain/core:langchain のコア機能が入っています
- @langchain/langgraph:エージェント同士
- uuid:UUID の生成に使います
- @langchain/openai
LangSmith
LangSmith のキーを環境変数に設定します。
登録については#1で扱ったため省略します。
LANGCHAIN_TRACING_V2="true"
LANGCHAIN_API_KEY="xxxxx"
コーディング
基本的な考え方
会話履歴全体をモデルに渡すことで、会話履歴を考慮したやり取りが可能です。
これがチャットボットの基本形になります。
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { StringOutputParser } from "@langchain/core/output_parsers";
const llm = new ChatOpenAI({
azureOpenAIApiDeploymentName: process.env.AZURE_OPENAI_GPT_DEPLOYMENT_NAME,
azureOpenAIApiVersion: process.env.AZURE_OPENAI_GPT_API_VERSION,
temperature: 0,
});
const parser = new StringOutputParser();
(async () => {
const res = await llm.pipe(parser).invoke([
{
role: "user",
content: "30文字以内で語尾に「にゃ」をつけて話してください",
},
{
role: "assistant",
content: "了解にゃ!では、どんな話をしましょうかにゃ?",
},
{ role: "user", content: "ネズミの捕まえ方を教えて" },
]);
console.log(res);
})();
履歴を考慮して回答を生成することがきました。
ネズミの捕まえ方にゃ?ネズミには罠を使うにゃ!
メッセージを永続化
会話履歴を考慮して回答を得られるようになりましたが、
このメッセージは永続化されていないので、チャットモデルを呼び出す毎に全ての会話履歴をインプットする必要があります。
そこでLangGraph を使って永続化してみます。
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import {
START,
END,
MessagesAnnotation,
StateGraph,
MemorySaver,
} from "@langchain/langgraph";
import { v4 as uuidv4 } from "uuid";
const llm = new ChatOpenAI({
azureOpenAIApiDeploymentName: process.env.AZURE_OPENAI_GPT_DEPLOYMENT_NAME,
azureOpenAIApiVersion: process.env.AZURE_OPENAI_GPT_API_VERSION,
temperature: 0,
});
// モデルを呼び出す関数を定義
const callModel = async (state: typeof MessagesAnnotation.State) => {
const response = await llm.invoke(state.messages);
return { messages: response };
};
// graphを定義
const workflow = new StateGraph(MessagesAnnotation)
.addNode("model", callModel)
.addEdge(START, "model")
.addEdge("model", END);
// メモリを追加
const memory = new MemorySaver();
const app = workflow.compile({ checkpointer: memory });
const config = { configurable: { thread_id: uuidv4() } };
(async () => {
const input = [
{
role: "user",
content: "こんにちは私の名前はameGです",
},
];
//1回目
const output = await app.invoke({ messages: input }, config);
console.log(output.messages[output.messages.length - 1].content);
const input2 = [
{
role: "user",
content: "今までの会話を要約してください",
},
];
//2回目
const output2 = await app.invoke({ messages: input2 }, config);
console.log(output2.messages[output2.messages.length - 1].content);
})();
2 回目の呼び出しでは過去の会話を渡していませんが、
1 回目の会話を考慮して回答を生成していることがわかります。
LangGraph を使ってメッセージ履歴を自動的に保存することができているようですが、
いまいちピンとこないので LangGraph についても少し調べてみました。
LangGraphについて補足
LangChain の処理は一方向に進むような単純なワークフローを簡単に実装できますが、ループが発生するような複雑なワークフローには向いていません。
LangGraph を使うことで、グラフ構造のようなワークフロー(Graph
)を定義できます。
このコードのGraph
は以下のコードで定義されています。
// graphを定義
const workflow = new StateGraph(MessagesAnnotation)
.addNode("model", callModel)
.addEdge(START, "model")
.addEdge("model", END);
図で表すと以下のような構造です。
LangGraph ではこのワークフロー全体をGraph
CallModel のような作業を示す部分をNode
矢印をEdge
と呼んでいます。
全体の状態はState
に保持されます。
CallModel
をノードに追加したあと、Start
とEnd
それぞれにエッジを追加し、一直線のワークフローを定義しています。
MassageAnnotation
は状態のデータ構造を示しており、メッセージ履歴を配列に持つという、状態の共通パターンを実装するヘルパーの役割を担っています。
状態を保存するためにメモリを追加します
以下がそのコードです。
// メモリを追加
const memory = new MemorySaver();
const app = workflow.compile({ checkpointer: memory });
MessagesAnnotation.State に会話の履歴が保存、蓄積され、
その履歴を引数にチャットモデルが呼び出されています。
// モデルを呼び出す関数を定義
const callModel = async (state: typeof MessagesAnnotation.State) => {
const response = await llm.invoke(state.messages);
return { messages: response };
};
スレッドを分ける
ここまでで、チャットを永続化することができました。
新しいチャットを始める方法ですが、
チャットはthread_id
毎に保存されているため
thread_id
を切り替えることで新しいチャットを始めたり
元の会話に戻ってきたり、といったことが可能になります。
const config = { configurable: { thread_id: uuidv4() } };
プロンプトテンプレート
次にプロンプトテンプレートを追加してみます。
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import {
ChatPromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import {
START,
END,
MessagesAnnotation,
Annotation,
StateGraph,
MemorySaver,
} from "@langchain/langgraph";
import { v4 as uuidv4 } from "uuid";
//状態のデータ構造を定義
const GraphAnnotation = Annotation.Root({
...MessagesAnnotation.spec,
language: Annotation<string>(),
});
//テンプレート
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
"あなたは親切なAIエージェントです。私は日本語で話します、あなたは {language} の言語で回答してください。",
],
new MessagesPlaceholder("messages"),
]);
//チャットモデル
const llm = new ChatOpenAI({
azureOpenAIApiDeploymentName: process.env.AZURE_OPENAI_GPT_DEPLOYMENT_NAME,
azureOpenAIApiVersion: process.env.AZURE_OPENAI_GPT_API_VERSION,
temperature: 0,
});
// モデルを呼び出す関数を定義
const callModel = async (state: typeof GraphAnnotation.State) => {
const chain = prompt.pipe(llm);
const response = await chain.invoke(state);
return { messages: [response] };
};
// graphを定義
const workflow = new StateGraph(GraphAnnotation)
.addNode("model", callModel)
.addEdge(START, "model")
.addEdge("model", END);
// メモリを追加
const memory = new MemorySaver();
const app = workflow.compile({ checkpointer: memory });
const config = { configurable: { thread_id: uuidv4() } };
(async () => {
const input = {
massages: [
{
role: "user",
content: "こんにちは私の名前はameGです",
},
],
language: "英語",
};
//1回目
const output = await app.invoke(input, config);
console.log(output.messages[output.messages.length - 1].content);
const input2 = {
messages: [
{
role: "user",
content: "今までの会話を要約してください",
},
],
language: "英語",
};
//2回目
const output2 = await app.invoke(input2, config);
console.log(output2.messages[output2.messages.length - 1].content);
})();
Of course! I'm here to help. What would you like to talk about?
You mentioned that you speak Japanese, and I confirmed that I would respond in English. You then asked for a summary of our conversation so far.
プロンプトテンプレートを追加したチャットモデルができました。
以下でプロンプトテンプレートを定義して、モデル呼び出しの処理に接続しています。
//テンプレート
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
"あなたは親切なAIエージェントです。私は日本語で話します、あなたは {language} の言語で回答してください。",
],
new MessagesPlaceholder("messages"),
]);
// モデルを呼び出す関数を定義
const callModel = async (state: typeof GraphAnnotation.State) => {
const chain = prompt.pipe(llm);
const response = await chain.invoke(state);
return { messages: [response] };
};
以下では、状態にlanguage
を追加するために、状態のデータ構造を作成しています。
//状態のデータ構造を定義
const GraphAnnotation = Annotation.Root({
...MessagesAnnotation.spec,
language: Annotation<string>(),
});
トリマー
チャットボットを構築する際、チャットモデルに渡すメッセージの大きさを制限する必要があります。
会話が長く続くとメッセージが無限に大きくなり LLM のコンテキストウィンドウをオーバフローする可能性があるためです。
trimMessages
メッセージを許容内にトリムする方法として、LangChain にはtrimMessages
があります。
import "dotenv/config";
import {
SystemMessage,
HumanMessage,
AIMessage,
trimMessages,
} from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";
const llm = new ChatOpenAI({
azureOpenAIApiDeploymentName: process.env.AZURE_OPENAI_GPT_DEPLOYMENT_NAME,
azureOpenAIApiVersion: process.env.AZURE_OPENAI_GPT_API_VERSION,
temperature: 0,
});
const trimmer = trimMessages({
maxTokens: 40, //保持するトークン数。
strategy: "last", //firstだと最初からmaxToken個、lastだと最後の会話からmaxToken個が保持される。
tokenCounter: llm, //トークン数のカウント方法。LLMを適用するとLLMのルールでカウントできる
includeSystem: true, //システムメッセージを必ず保持したい場合はtrueにする。
startOn: "human", //システムメッセージを除く、最初のメッセージがstartOnから始まるようにできる。
});
const messages = [
new SystemMessage("あなたは親切なAIアシスタントです"),
new HumanMessage("こんにちは私はameGです"),
new AIMessage("こんにちは!"),
new HumanMessage("私はラーメンが好きです"),
new AIMessage("いいですね"),
new HumanMessage("2 + 2は?"),
new AIMessage("4"),
new HumanMessage("ありがとう"),
new AIMessage("どういたしまして"),
new HumanMessage("たのしんで!"),
new AIMessage("もちろん!"),
];
(async () => {
const trimMassages = await trimmer.invoke(messages);
console.log(trimMassages);
})();
このコードでは
LLM(tokenCounter: llm
)のカウントルールで、後ろ(strategy: "last"
)から 40(maxTokens: 40
)トークン分を保持する。
尚、システムメッセージは必ず保持(includeSystem:true
)し、メッセージ本体は human のメッセージから始まる(startOn: "human"
)ようにする。
といった条件でトリムしています。
ちなみにチャットモデルにはgpt-4o-mini
を使用しています
[
SystemMessage {
"content": "あなたは親切なAIアシスタントです",
"additional_kwargs": {},
"response_metadata": {}
},
HumanMessage {
"content": "ありがとう",
"additional_kwargs": {},
"response_metadata": {}
},
AIMessage {
"content": "どういたしまして",
"additional_kwargs": {},
"response_metadata": {},
"tool_calls": [],
"invalid_tool_calls": []
},
HumanMessage {
"content": "たのしんで!",
"additional_kwargs": {},
"response_metadata": {}
},
AIMessage {
"content": "もちろん!",
"additional_kwargs": {},
"response_metadata": {},
"tool_calls": [],
"invalid_tool_calls": []
}
]
tokenCounter
について、公式のサンプルコードではtokenCounter: (msgs) => msgs.length,
とすることで、
一つのメッセージを 1 トークンと換算するようなカウントルールを適用していました。
チェーン内で使用する
これをチェーン内で使用するには、
massages
入力をプロンプトに渡す前にトリマーを実行します。
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import {
ChatPromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import {
SystemMessage,
HumanMessage,
AIMessage,
trimMessages,
} from "@langchain/core/messages";
import {
START,
END,
MessagesAnnotation,
Annotation,
StateGraph,
MemorySaver,
} from "@langchain/langgraph";
import { v4 as uuidv4 } from "uuid";
//状態のデータ構造を定義
const GraphAnnotation = Annotation.Root({
...MessagesAnnotation.spec,
language: Annotation<string>(),
});
//テンプレート
const prompt = ChatPromptTemplate.fromMessages([
[
"system",
"あなたは親切なAIエージェントです。私は日本語で話します、あなたは {language} の言語で回答してください。",
],
new MessagesPlaceholder("messages"),
]);
//チャットモデル
const llm = new ChatOpenAI({
azureOpenAIApiDeploymentName: process.env.AZURE_OPENAI_GPT_DEPLOYMENT_NAME,
azureOpenAIApiVersion: process.env.AZURE_OPENAI_GPT_API_VERSION,
temperature: 0,
});
//トリム関数
const trimmer = trimMessages({
maxTokens: 40,
strategy: "last",
tokenCounter: llm,
includeSystem: true,
startOn: "human",
});
// モデルを呼び出す関数を定義
const callModel = async (state: typeof GraphAnnotation.State) => {
//トリム実行
const trimmedMessages = await trimmer.invoke(state.messages);
const chain = prompt.pipe(llm);
const response = await chain.invoke({
messages: trimmedMessages,
language: state.language,
});
return { messages: [response] };
};
// graphを定義
const workflow = new StateGraph(GraphAnnotation)
.addNode("model", callModel)
.addEdge(START, "model")
.addEdge("model", END);
// メモリを追加
const memory = new MemorySaver();
const app = workflow.compile({ checkpointer: memory });
const config = { configurable: { thread_id: uuidv4() } };
const messages = [
new SystemMessage("あなたは親切なAIアシスタントです"),
new HumanMessage("こんにちは私はameGです"),
new AIMessage("こんにちは!"),
new HumanMessage("私はラーメンが好きです"),
new AIMessage("いいですね"),
new HumanMessage("2 + 2は?"),
new AIMessage("4"),
new HumanMessage("ありがとう"),
new AIMessage("どういたしまして"),
new HumanMessage("たのしんで!"),
new AIMessage("もちろん!"),
];
(async () => {
const input = {
messages: [...messages, new HumanMessage("私の名前を教えてください")],
language: "英語",
};
const output = await app.invoke(input, config);
console.log(output.messages[output.messages.length - 1].content);
})();
名前の部分の会話が破棄され、名前について聞いても回答を得ることができなくなっています。
I don't know your name yet. What is your name?
ちなみに LangSmith 上ではこのようになっています。
まとめ
LangChain を使いチャットボットを構築することができました。
LangChain を学ぶとプロンプトやトークンサイズなど LLM アプリ関連の知識を併せて学習できて良いですね。
以下について理解することができました。
- チャットモデルで会話する際の IF の基本形
- LangGraph を使った会話の永続化
- 会話のトークンサイズの管理方法