【TypeScript】langchainの公式チュートリアルを実践する-Basics#2

チャットボットを構築する

## はじめに
langchainの公式チュートリアル(TypeScript版)を実践しています。
[前回の第1回](https://qiita.com/ameG-it/items/a419146cc58f23f7bb07)に引き続き、今回は基礎の第2回である[Build a Chatbot](https://js.langchain.com/docs/tutorials/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](https://qiita.com/ameG-it/items/a419146cc58f23f7bb07)で扱ったため省略します。

```text:.env
LANGCHAIN_TRACING_V2="true"
LANGCHAIN_API_KEY="xxxxx"
```

## コーディング

### 基本的な考え方

会話履歴全体をモデルに渡すことで、会話履歴を考慮したやり取りが可能です。
これがチャットボットの基本形になります。

```typeScript:chatbot.ts
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);
})();

```

履歴を考慮して回答を生成することがきました。

```:実行結果
ネズミの捕まえ方にゃ？ネズミには罠を使うにゃ！
```

ちなみに、LangSmith の実行結果は以下の通りです。
![chat-app-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3754827/979e5f7a-97c1-cc26-7f55-8db36acdab10.png)

### メッセージを永続化

会話履歴を考慮して回答を得られるようになりましたが、
このメッセージは永続化されていないので、チャットモデルを呼び出す毎に全ての会話履歴をインプットする必要があります。
そこでLangGraph を使って永続化してみます。

```typescript:chat_memory.ts
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です",
    },
  ];
  //１回目
  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`は以下のコードで定義されています。

```typescript
// graphを定義
const workflow = new StateGraph(MessagesAnnotation)
  .addNode("model", callModel)
  .addEdge(START, "model")
  .addEdge("model", END);
```

図で表すと以下のような構造です。

```mermaid
graph TD
  Start([Start])-->CallModel
  CallModel--> End([End])
```

LangGraph ではこのワークフロー全体を`Graph`
CallModel のような作業を示す部分を`Node`
矢印を`Edge`と呼んでいます。
全体の状態は`State`に保持されます。

`CallModel`をノードに追加したあと、`Start`と`End`
それぞれにエッジを追加し、一直線のワークフローを定義しています。
`MassageAnnotation`は状態のデータ構造を示しており、メッセージ履歴を配列に持つという、状態の共通パターンを実装するヘルパーの役割を担っています。

状態を保存するためにメモリを追加します
以下がそのコードです。

```typescript
// メモリを追加
const memory = new MemorySaver();
const app = workflow.compile({ checkpointer: memory });
```

MessagesAnnotation.State に会話の履歴が保存、蓄積され、
その履歴を引数にチャットモデルが呼び出されています。

```typescript
// モデルを呼び出す関数を定義
const callModel = async (state: typeof MessagesAnnotation.State) => {
  const response = await llm.invoke(state.messages);
  return { messages: response };
};
```


### スレッドを分ける
ここまでで、チャットを永続化することができました。
新しいチャットを始める方法ですが、
チャットは`thread_id`毎に保存されているため
`thread_id`を切り替えることで新しいチャットを始めたり
元の会話に戻ってきたり、といったことが可能になります。

```typescript
const config = { configurable: { thread_id: uuidv4() } };
```

### プロンプトテンプレート

次にプロンプトテンプレートを追加してみます。

```typescript:
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: "英語",
  };
  //１回目
  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.
```

プロンプトテンプレートを追加したチャットモデルができました。

以下でプロンプトテンプレートを定義して、モデル呼び出しの処理に接続しています。

```typescript
//テンプレート
const prompt = ChatPromptTemplate.fromMessages([
  [
    "system",
    "あなたは親切なAIエージェントです。私は日本語で話します、あなたは {language} の言語で回答してください。",
  ],
  new MessagesPlaceholder("messages"),
]);
```

```typescript
// モデルを呼び出す関数を定義
const callModel = async (state: typeof GraphAnnotation.State) => {
  const chain = prompt.pipe(llm);
  const response = await chain.invoke(state);
  return { messages: [response] };
};
```

以下では、状態に`language`を追加するために、状態のデータ構造を作成しています。

```typescript
//状態のデータ構造を定義
const GraphAnnotation = Annotation.Root({
  ...MessagesAnnotation.spec,
  language: Annotation<string>(),
});
```

### トリマー

チャットボットを構築する際、チャットモデルに渡すメッセージの大きさを制限する必要があります。
会話が長く続くとメッセージが無限に大きくなり LLM のコンテキストウィンドウをオーバフローする可能性があるためです。

#### trimMessages

メッセージを許容内にトリムする方法として、LangChain には`trimMessages`があります。

```typescript
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`入力をプロンプトに渡す前にトリマーを実行します。

```typescript
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 上ではこのようになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3754827/93a269fd-610f-4051-5e1c-c2692b391caf.png)


## まとめ

LangChain を使いチャットボットを構築することができました。
LangChain を学ぶとプロンプトやトークンサイズなど LLM アプリ関連の知識を併せて学習できて良いですね。
以下について理解することができました。

- チャットモデルで会話する際の IF の基本形
- LangGraph を使った会話の永続化
- 会話のトークンサイズの管理方法