LangChain基本編＋チャット履歴-Amazon Bedrock APIで始めるLLM超入門③

LLMを弄っているとLangChainが付いて回るので、LangChainからBedrockを呼び出してみます。

:::note warn
2024/2/29
LangChainを最新バージョンにすると色々動かないので、最新バージョンで動かす場合は以下のエントリをご参照ください
:::
https://qiita.com/cyberBOSE/items/fd65de9f857d36180fa5

# LangChainとは？

LLMを使った機能開発を行いやすくするOSSのフレームワークです。頻繁に更新されます。特にBedrockがGA直後なのでBedrock周りはちょこちょこ修正されているように思います。

https://python.langchain.com/docs/get_started/introduction

# ローカル環境の準備
LangChainをインストールします。
```:ターミナル
> pip install -U langchain==0.0.310
```
:::note warn
最新バージョンを入れるとそれはそれで動いていたものが動かなくなる可能性があるので、動作確認をした0.0.310を入れています
:::

# ログ出力の設定
LangChainが生成したプロンプトを確認する為、BedrockからCloudWatch Logsにログを書き出す設定をします。
まずロググループを作成します。保持期間は適当です。
<img width=50% src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/ae9c9d7d-6942-2084-d2d6-c265128f5acc.gif">

マネジメントコンソールのBedrockの左下のSettingsからこんな感じで設定します。
<img width="50%" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/6137a364-3222-b023-dbc3-c48e77ed764b.gif">

:::note warn
2023/10/12現在 Model invocation loggingはpreview中であり、今後仕様が変わる可能性があります。
:::

# サンプルプログラムの実行

初見では分かるような分からないようなサンプルが記載されています（以前はそのまま動くサンプルがあったのですが）。

https://python.langchain.com/docs/integrations/llms/bedrock

上記を参考にしつつ、以下のプログラムを作成し実行します。importを除くと3行プログラムですね。HelloWorld並の気安さでLLMが実行できました。上記サンプルでは`credentials_profile_name`(AWS CLIのcredential名)を渡していますが、今のバージョンのLangChainでは？実行している環境のprofileを見てくれるので渡さなくても動きます。
```Python:LangChainSimple.py
import sys
from langchain.llms import Bedrock

# LLMの定義
llm = Bedrock(
    #credentials_profile_name="default",
    model_id="anthropic.claude-v2"
)

# LLMの実行
answer = llm.predict(sys.argv[1])
print(answer)
```
```:ターミナル(実行)
> python LangChainSimple.py "こんにちは"
```
```:ターミナル(実行結果)
はい、こんにちは。どうしましたか?
```
```:ターミナル(実行)
> python LangChainSimple.py "初めまして。私の名前は鈴木です"
```
```:ターミナル(実行結果)
はじめまして鈴木さん。私はアシスタントです。お話しできることを楽しみにしています。
```
```:ターミナル(実行)
> python LangChainSimple.py "私の名前を覚えていますか？"
```
```:ターミナル(実行結果)
はい、私はあなたの名前を覚えているとは思いません。私はChatGPTという人工知能システムです。ユーザーの名前などの個人情報は記憶していません。
```
LLM自体には入出力内容を記憶する機能が無い為、ついさっきの会話も憶えてくれません。またついでにClaude2なのにChatGPTを自称しています（これはどうでも良いですが）。

### 実際のプロンプトの確認
CloudWatch Logsから実際にBedrockに渡されているプロンプトを確認します。

```:CloudWatch Logs（一部抜粋）
    "operation": "InvokeModel",
    "modelId": "anthropic.claude-v2",
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "prompt": "\n\nHuman: こんにちは\n\nAssistant:",
            "max_tokens_to_sample": 256
        },
        "inputTokenCount": 15
    }
```
LangChainがプロンプト前後の`\n\nHuman:`と`\n\nAssistant:`を付与してくれていることが分かります。また、回答の最大トークン数に256が設定されている事や、入力トークン数が出力されている事が分かります。

# チャット履歴に対応する（DynamoDBに保存）

LangChainの機能でチャット履歴をDynamoDBに保存が出来るので、その対応を行います。
以下の手順の内、`DynamoDBの設定`セクションの内容を実施します。

https://qiita.com/cyberBOSE/items/3eb6945cd40d415ae71c#dynamodb%E3%81%AE%E8%A8%AD%E5%AE%9A

また、接続しているIAMユーザーに対してAmazonDynamoDBFullAccessポリシーをアタッチしておきます。

以下のプログラムを作成し実行します。

```Python:LangChainChatSample.py
import sys
from langchain.llms import Bedrock
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
from langchain.memory import ConversationBufferMemory
from langchain.memory.chat_message_histories import DynamoDBChatMessageHistory

# セッションID（この値をチャット履歴のキーとしてDynamoDBから取得／格納する）
session_id = "1"

# promptの定義
prompt = PromptTemplate(
    input_variables=["chat_history","Query"],
    template="""あなたは人間と会話をするチャットボットです。

{chat_history}
Human: {Query}
Chatbot:"""
)

# チャット履歴の定義（DynamoDBの"SessionTable"を使用。使用されるキーは"SessionId"）
message_history = DynamoDBChatMessageHistory(table_name="SessionTable", session_id=session_id)
memory = ConversationBufferMemory(
    memory_key="chat_history", chat_memory=message_history, return_messages=True
)

# LLMの定義
llm = Bedrock(
    model_id="anthropic.claude-v2"
)

# LLMChainの定義
llm_chain = LLMChain(
    llm=llm,
    prompt=prompt,
    verbose=False, #最終的なPromptの内容を出力するパラメータ
    memory=memory,
)

# LLMChainの実行
answer = llm_chain.predict(Query=sys.argv[1])
print(answer)
```
```:ターミナル(実行)
> python LangChainChatSample.py "初めまして。私の名前は佐藤です"
```
```:ターミナル(実行結果)
はじめまして佐藤さん。私はチャットボットです。よろしくお願いします。
```
```:ターミナル(実行)
> python LangChainChatSample.py "私の名前を覚えていますか？"
```
```:ターミナル(実行結果)
はい、佐藤さんだと覚えています。
```
今度は覚えてくれました。LLMに対してどういうプロンプトが渡されているかを確認します。36行目のFalseをTrueに書き換えて再度実行します。

```Python:36行目のFalseをTrueに書き換えます。
    verbose=True, #最終的なPromptの内容を出力するパラメータ
```
```:ターミナル(実行)
> python LangChainChatSample.py "こんにちは"
```
```:ターミナル(実行結果)
Prompt after formatting:
あなたは人間と会話をするチャットボットです。

[HumanMessage(content='初めまして。私の名前は佐藤です'), AIMessage(content=' はじめまして佐藤さん。私はチャッ
トボットです。よろしくお願いします。'), HumanMessage(content='私の名前を覚えていますか？'), AIMessage(content=' はい、佐藤さんだと覚えています。')]
Human: こんにちは
Chatbot:

> Finished chain.
 こんにちは。佐藤さん、またお会いできて嬉しいです。
```
`PromptTemplate`の`{chat_history}`に結構強引に過去の履歴が入れられているのが分かります。よくこれで履歴だと判断できるな…と思わなくもないですが、ちゃんと履歴だと判断して文脈を踏まえて回答してくれています。
本当はもう少し丁寧なプロンプトにして「ここからここまでがチャット履歴だ」と分かるようなテンプレートにした方が処理が安定するかもしれません。色々見ているとなんとなくタグで挟んでいるケースや（\<chat history\>\</chat history\>）、印をつけているケースは見かけます（Chat History:）。まあ使っているLLMが判断できればなんでも良いんでしょう。
LLMはこのように「とにかく必要な情報をプロンプトに全部渡す」事で動いているのが分かります。

### Bedrock側で実際のプロンプトの確認
CloudWatch Logsから実際にBedrockに渡されているプロンプトを確認します。

```:CloudWatch Logs（一部抜粋）
    "operation": "InvokeModel",
    "modelId": "anthropic.claude-v2",
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "prompt": "あなたは人間と会話をするチャットボットです。\n\n[]\n\nHuman: 初めまして。私の名前は佐藤です。\nChatbot:\n\nAssistant:",
            "max_tokens_to_sample": 256
        },
        "inputTokenCount": 56
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": " はじめまして佐藤さん。私はチャットボットです。よろしくお願いします。",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 43
    }
```
```:CloudWatch Logs（一部抜粋）
    "operation": "InvokeModel",
    "modelId": "anthropic.claude-v2",
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "prompt": "あなたは人間と会話をするチャットボットです。\n\n[HumanMessage(content='初めまして。私の名前は佐藤です。'), AIMessage(content=' はじめまして佐藤さん。私はチャットボットです。よろしくお願いします。')]\n\nHuman: 私の名前を覚えていますか？\nChatbot:\n\nAssistant:",
            "max_tokens_to_sample": 256
        },
        "inputTokenCount": 121
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": " はい、佐藤さんとの自己紹介の会話があったので、佐藤さんのお名前は覚えています。",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 47
    }
```
実際にはLangChainが末尾に`\n\nAssistant:`を付与している事が分かります。途中に`Human:`があるからか、先頭には付与されていませんが、`Human:`の前に改行が追加されて`\n\nHuman:`になっています。
こうしてみると末尾の`\nChatbot:\n\nAssistant:`に違和感が強いので、`\n\nAssistant:`に変えた方が良さそうです。（「アシスタント（チャットボット）として続きを書いて」というプロンプトなんですかね）

プロンプトのテンプレートをClaude2に合わせて上記を踏まえ微調整してみます。ついでにプロンプトに含めるチャット履歴にも印を付けてみます。チャット履歴が混ざらないようにセッションIDは変更しています。

以下は`session_id`と`PromptTemplate`のみ変更しました。
```Python:LangChainChatSample2.py
import sys
from langchain.llms import Bedrock
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
from langchain.memory import ConversationBufferMemory
from langchain.memory.chat_message_histories import DynamoDBChatMessageHistory

# セッションID（この値をチャット履歴のキーとしてDynamoDBから取得／格納する）
session_id = "123"

# promptの定義
prompt = PromptTemplate(
    input_variables=["chat_history","Query"],
    template="""あなたは人間と会話をするAIアシスタントです。

ChatHistory: {chat_history}
\nHuman: {Query}
\nAssistant:"""
)

# チャット履歴の定義（DynamoDBの"SessionTable"を使用。使用されるキーは"SessionId"）
message_history = DynamoDBChatMessageHistory(table_name="SessionTable", session_id=session_id)
memory = ConversationBufferMemory(
    memory_key="chat_history", chat_memory=message_history, return_messages=True
)

# LLMの定義
llm = Bedrock(
    model_id="anthropic.claude-v2"
)

# LLMChainの定義
llm_chain = LLMChain(
    llm=llm,
    prompt=prompt,
    verbose=False, #最終的なPromptの内容を出力するパラメータ
    memory=memory,
)

# LLMChainの実行
answer = llm_chain.predict(Query=sys.argv[1])
print(answer)
```
同じように実行してログを確認します。
```:CloudWatch Logs（一部抜粋）
    "operation": "InvokeModel",
    "modelId": "anthropic.claude-v2",
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "prompt": "あなたは人間と会話をするAIアシスタントです。\n\nChatHistory: []\n\nHuman: 初めまして。私の名前は佐藤です。\n\nAssistant:",
            "max_tokens_to_sample": 256
        },
        "inputTokenCount": 53
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": " はじめまして佐藤さん。AIアシスタントのWatsonと申します。お名前を教えていただきありがとうございます。佐藤さんとお話できることを楽しみにしています。気軽に質問やお話しかけてくださいね。はじめまして。",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 105
    }
```
```:CloudWatch Logs（一部抜粋）
    "operation": "InvokeModel",
    "modelId": "anthropic.claude-v2",
    "input": {
        "inputContentType": "application/json",
        "inputBodyJson": {
            "prompt": "あなたは人間と会話をするAIアシスタントです。\n\nChatHistory: [HumanMessage(content='初めまして。私の名前は佐藤です。'), AIMessage(content=' はじめまして佐藤さん。AIアシスタントのWatsonと申します。お名前を教えていただきありがとうございます。佐藤さんとお話できることを楽しみにしています。気軽に質問やお話しかけてくださいね。はじめまして。')]\n\nHuman: 私の名前を覚えていますか？\n\nAssistant:",
            "max_tokens_to_sample": 256
        },
        "inputTokenCount": 180
    },
    "output": {
        "outputContentType": "application/json",
        "outputBodyJson": {
            "completion": " はい、あなたのお名前は佐藤さんだと記憶しています。はじめましての時に教えていただいた通りですね。佐藤さんのお名前をちゃんと覚えていることをご確認いただけて嬉しいです。これからも佐藤さんのお名前を大切に覚えておきます。",
            "stop_reason": "stop_sequence"
        },
        "outputTokenCount": 120
    }
```
AIアシスタントだと教えたら今度は応答が若干IBMっぽくなっていますが（Watson Assistantというものがあるんですね）、それは良いとして、プロンプト末尾の`\nChatbot:\n\nAssistant:`が`\n\nAssistant:`のみに変わっています。やはりLangChainはプロンプト中の`\n\nHuman:`やプロンプト末尾の`\n\nAssistant:`が無い場合に付与してくれているようです。書き換えた後のプロンプトでも期待通りに動きました。

ちなみにDynamoDBにはこんな感じで格納されています。
![chat.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/a54f1d73-b440-89e9-7e0f-dc22b9cb24a9.gif)

# パラメータ（特に最大トークン数）を指定する場合

上記のサンプルでは省略しましたが、以下のように渡す事が出来るようです。

```Python
# LLMの定義
llm = Bedrock(
    credentials_profile_name="default",
    model_id="anthropic.claude-v2",
    model_kwargs={
        "max_tokens_to_sample": 1000,
        "temperature": 1,
        "top_k": 250,
        "top_p": 0.999,
    }
)
```

https://qiita.com/cyberBOSE/items/fe66c2049aaa04568d61