LangChain v0.3　その８ ~ChatBot２ trimming~

# 今日の目的
前回、過去の会話履歴を参照できる`ChatBot`ができました。
過去の会話履歴をなるべく長く残しておけばすごく便利な一方、ロングコンテキスト化が進んでいる昨今ではAPI資料料金も怖い。笑
ということで、ある程度の過去の履歴までしか参照しないようにするというのが二つ目の目的です。

- 過去の履歴をある一定以上は**参照**しない方法

<br>
<br>
<br>


# ここまでの経過
プロンプトテンプレートや、LCEL、LangGraph、会話履歴を参照できるChatBotをやってきました。いきなりここにきちゃった方は過去の記事を参照くださいませ。

<br>
<br>
<br>



# バージョン関連
Python 3.10.8
langchain==0.3.7
python-dotenv
langchain-openai==0.2.5
langgraph>0.2.27
langchain-core

※LLMのAPIはAzureOpenAIのgpt-4o-miniを使いました

<br>
<br>
<br>


# ライブラリのインポートと環境変数の設定
これはいつも通り

```python:ライブラリ
import os
from dotenv import load_dotenv
from langchain_openai import AzureChatOpenAI, AzureOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, trim_messages
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, END, MessagesState, StateGraph
```

```python:環境変数
load_dotenv('.env')

os.environ["LANGCHAIN_TRACING_V2"]="true"
os.environ["LANGCHAIN_ENDPOINT"]="https://api.smith.langchain.com"
os.environ["LANGCHAIN_API_KEY"]=os.getenv("LANG_SMITH_API")
os.environ["LANGCHAIN_PROJECT"]="langchain_test"
```

```python:モデルの準備
model = AzureChatOpenAI(
    azure_deployment='gpt-4o-mini',
    azure_endpoint=os.getenv("END_POINT"),
    api_version=os.getenv("API_VERSION"),
    api_key=os.getenv("API_KEY"),
    max_tokens=150
)
```

<br>
<br>
<br>


# 落とし穴
2024/11現在では後でやる参照内容を制限するところ（trim）でエラーが出ます。
`LangChain`のバグらしいです。GitHubに解決方法が書いてありましたので最初に設定しておきます。

```python
model.model_name= 'gpt-4o-mini' # GitHubから追加
```

<br>
<br>
<br>


# trimmingする方法１
なんかね、ドキュメント見てたらこんな書き方してました。
これって、関数を変数に入れた感じなんかな？汗（よくわからん）

```python:trimmingする・・・関数？
trimmer = trim_messages(
    max_tokens=400, # 効果がわかりやすいように小さな値にしてます
    strategy="last", # 最後の情報を残す
    token_counter=model, # このモデルのトークナイザを使って数えるのかな
    include_system=True,
    allow_partial=False,
    start_on="human",
)
```

このトリマーを使って、グラフを作っていきましょう。

<br>
<br>
<br>


```python:graphを作る
def call_model(state: MessagesState):
    chain = trimmer | model
    response = chain.invoke(state["messages"])
    return {"messages": response}


workflow3 = StateGraph(state_schema=MessagesState)

workflow3.add_node("model", call_model)
workflow3.add_edge(START, "model")
workflow3.add_edge("model", END)

memory = MemorySaver()
graph3 = workflow3.compile(checkpointer=memory)


########### graphの可視化
from IPython.display import Image, display

try:
    display(Image(graph3.get_graph().draw_mermaid_png()))
except Exception:
    # This requires some extra dependencies and is optional
    pass
```
![output.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655320/042af7a3-8c0d-415c-8d7d-710055cf25da.jpeg)

<br>
<br>
<br>

idはまた別個に起こします。
```python:会話のconfig
config3 = {"configurable": {"thread_id": "message_trim_111"}}
```

```python:最初のプロンプト
query = "僕は海賊の村上です。瀬戸内で暴れてます。"

input_messages = [
    SystemMessage(content='日本語で回答してください。分からない時はわからないと回答してください。'),
    HumanMessage(query)
    ]
input_messages
# [SystemMessage(content='日本語で回答してください。分からない時は・・・),
#  HumanMessage(content='僕は海賊の村上です。瀬戸内で暴れてます。'),・・・] 
```

```python:modelに入れて推論
output = graph3.invoke({"messages": input_messages}, config3)
output
# {'messages': [
#     SystemMessage(content='日本語で回答してください。分からない時は・・・),
#     HumanMessage(content='僕は海賊の村上です。瀬戸内で暴れてます。',・・・),
#     AIMessage(content='海賊の村上さん、こんにちは！瀬戸内海での冒険は楽しそう・・・),
# ]}
```

<br>
<br>
<br>


トークン数がオーバーして、trimmingされるように会話をしていきましょう。

```python:会話を重ねていく
query = "船酔いがひどいから対策を教えて。"
input_messages = [HumanMessage(query)]
output = graph3.invoke({"messages": input_messages}, config3)

query = "筋肉痛も酷くてさ。腰も痛いんだ。どうしたらいい？"
input_messages = [HumanMessage(query)]
output = graph3.invoke({"messages": input_messages}, config3)

query = "え～っと、僕ってどこが痛いんだっけ？"
input_messages = [HumanMessage(query)]
output = graph3.invoke({"messages": input_messages}, config3)
output
# {'messages': [
#     SystemMessage(content='日本語で回答してください。分からない・・・),
#     AIMessage(content='海賊の村上さん、こんにちは！瀬戸内海での冒険・・・'),
#     HumanMessage(content='船酔いがひどいから対策を教えて。', ・・・),
#     AIMessage(content='船酔いはつらいですよね。以下の対策を試し・・・),
#     HumanMessage(content='筋肉痛も酷くてさ。腰も痛いんだ。どうしたらいい？'),
#     AIMessage(content='筋肉痛や腰痛にはいくつかの対策があります。以下を試し・・・),
#     HumanMessage(content='え～っと、僕ってどこが痛いんだっけ？', ・・・),
#     AIMessage(content='あなたは筋肉痛がひどく、特に腰が痛いとおっしゃって・・・),
# ]}
```
最後、ちゃんと会話履歴を参照して、「筋肉痛がひどい、腰が痛い」っておっさんみたいな村上さんについて回答してくれました。
それにしても弱気な内容を・・・www
さて、そろそろ、名前でも聞いてみましょう。

<br>
<br>
<br>



```python:最初の会話について聞いてみる・・・前にアイスクリーム食べたい
query = "アイスクリームを食べたいんだけど、どこが良いと思う？"
input_messages = [HumanMessage(query)]
output = graph3.invoke({"messages": input_messages}, config3)

query = "ところで、僕の名前は？"
input_messages = [HumanMessage(query)]
output = graph3.invoke({"messages": input_messages}, config3)
output
# {'messages': [
#     SystemMessage(content='日本語で回答してください。分からない・・・),
#     AIMessage(content='海賊の村上さん、こんにちは！瀬戸内海での冒険・・・'),
#     HumanMessage(content='船酔いがひどいから対策を教えて。', ・・・),
#     AIMessage(content='船酔いはつらいですよね。以下の対策を試し・・・),
#     HumanMessage(content='筋肉痛も酷くてさ。腰も痛いんだ。どうしたらいい？'),
#     AIMessage(content='筋肉痛や腰痛にはいくつかの対策があります。以下を試し・・・),
#     HumanMessage(content='え～っと、僕ってどこが痛いんだっけ？', ・・・),
#     AIMessage(content='あなたは筋肉痛がひどく、特に腰が痛いとおっしゃって・・・),
#     HumanMessage(content='アイスクリームを食べたいんだけど、どこが・・・),
#     AIMessage(content='アイスクリームを楽しむ場所は色々ありますね。以下の・・・),
#     HumanMessage(content='ところで、僕の名前は？'), 
#     AIMessage(content='ごめんなさい、あなたの名前はわかりません。お名前を・・・),
# ]}

```

会話履歴をtrimmingして忘却してしまう**ボケたオッサン**ができてしまいました。<-言葉遣い

<br>
<br>
<br>




# trimmingする方法２
チュートリアルからコピペ
上のoutputを渡してみましょう。

```python:trimming二つ目の方法
trim_messages(
    output['messages'], # ここだけ変更
    # Keep the last <= n_count tokens of the messages.
    strategy="last",
    # Remember to adjust based on your model
    # or else pass a custom token_encoder
    token_counter=model,
    # Most chat models expect that chat history starts with either:
    # (1) a HumanMessage or
    # (2) a SystemMessage followed by a HumanMessage
    # Remember to adjust based on the desired conversation
    # length
    max_tokens=55,
    # Most chat models expect that chat history starts with either:
    # (1) a HumanMessage or
    # (2) a SystemMessage followed by a HumanMessage
    start_on="human",
    # Most chat models expect that chat history ends with either:
    # (1) a HumanMessage or
    # (2) a ToolMessage
    end_on=("human", "tool"),
    # Usually, we want to keep the SystemMessage
    # if it's present in the original history.
    # The SystemMessage has special instructions for the model.
    include_system=True,
    allow_partial=False,
)
# [SystemMessage(content='日本語で回答してください。分からない・・・),
#  HumanMessage(content='ところで、僕の名前は？'),]
```
なるほど。これも関数みたいに使えるのね。それに、システムメッセージはしっかり残ってます。
うむうむ

<br>
<br>
<br>


ということで、下のグラフを作ってみました。
というか、`call_model`関数に会話履歴をトリミングしたものを使って`invoke`するだけ。

```python:graph

def call_model(state: MessagesState):
    response = model.invoke(
        trim_messages(
            state['messages'],
            strategy="last",
            token_counter=model,
            max_tokens=55,
            start_on="human",
            end_on=("human", "tool"),
            include_system=True,
            allow_partial=False,
        )
    )
    return {"messages": response}


workflow4 = StateGraph(state_schema=MessagesState)
workflow4.add_node("call_model", call_model)
workflow4.add_edge(START, "call_model")
workflow4.add_edge("call_model", END)

memory = MemorySaver()
graph4 = workflow4.compile(checkpointer=memory)
```
グラフの絵としては上と同じなので、早速会話をしていきましょう。

<br>
<br>
<br>


```python:最初の会話
config4 = {"configurable": {"thread_id": "message_trim_222"}}
query = "僕は海賊の村上です。瀬戸内で暴れてます。"

input_messages = [
    SystemMessage(content='日本語で回答してください。分からない時はわからないと回答してください。'),
    HumanMessage(query)
    ]
output = graph4.invoke({'messages': input_messages}, config4)
output

# {'messages': [
#     SystemMessage(content='日本語で回答してください。分からない時は・・・),
#     HumanMessage(content='僕は海賊の村上です。瀬戸内で暴れてます。'),
#     AIMessage(content='村上さん、海賊としての冒険はどんな感じですか？・・・),
# ]}
```

前回と同じように会話を重ねていきましょう

<br>
<br>
<br>



```python:会話を重ねていく
query = "眠いんだけど、睡眠不足なのかな？"

input_messages = [HumanMessage(query)]
output = graph4.invoke({"messages": input_messages}, config4)

query = "なるほど、リラックスする時間を作るいい方法を教えてください"
input_messages = [HumanMessage(query)]
output = graph4.invoke({"messages": input_messages}, config4)
output
# {'messages': [
#    SystemMessage(content='日本語で回答してください。分からない時・・・),
#    AIMessage(content='村上さん、海賊としての冒険はどんな感じですか？・・・),
#    HumanMessage(content='眠いんだけど、睡眠不足なのかな？'),
#    AIMessage(content='眠いと感じるのは、睡眠不足が原因の一つかもしれません。・・・),
#    HumanMessage(content='なるほど、リラックスする時間を作るいい方法を・・・),
#    AIMessage(content='リラックスする時間を作るためのいくつかの方法を紹介します・・・),
# ]}
```

```python:会話から名前を聞いてみる
query = "ところで、僕の名前は何？"
input_messages = [HumanMessage(query)]
output = graph4.invoke({"messages": input_messages}, config4)
output
# {'messages': [
#    SystemMessage(content='日本語で回答してください。分からない時・・・),
#    AIMessage(content='村上さん、海賊としての冒険はどんな感じですか？・・・),
#    HumanMessage(content='眠いんだけど、睡眠不足なのかな？'),
#    AIMessage(content='眠いと感じるのは、睡眠不足が原因の一つかもしれません。・・・),
#    HumanMessage(content='なるほど、リラックスする時間を作るいい方法を・・・),
#    AIMessage(content='リラックスする時間を作るためのいくつかの方法を紹介します・・・),
#    HumanMessage(content='ところで、僕の名前は何？'),
#    AIMessage(content='申し訳ありませんが、あなたの名前はわかりません。お名前を・・),
# ]}
```

はい！またしてもしっかり参照範囲がトリミングされて忘却おじさんができました。
まさに「俺のクローン」（違っ・・・ててほしい

<br>
<br>
<br>




# trimmingの落とし穴
ここまではうまくいった方法を書いてきました。
実は今回はなるべく`LangChain`のドキュメントを頑張って読むようにしています。
だから失敗もあるんです。その失敗をご紹介したいと思います。

```python:失敗グラフ
def call_model(state: MessagesState):
    response = model.invoke(state['messages'])
    return {"messages": response}

def trimming(state: MessagesState):
    return {"messages": trim_messages(
        state['messages'],
        strategy="last",
        token_counter=model,
        max_tokens=55,
        start_on="human",
        end_on=("human", "tool"),
        include_system=True,
        allow_partial=False,
    )}


workflow5 = StateGraph(state_schema=MessagesState)
workflow5.add_node("call_model", call_model)
workflow5.add_node("trimming", trimming)

workflow5.add_edge(START, "trimming")
workflow5.add_edge("trimming", "call_model")
workflow5.add_edge("call_model", END)

memory = MemorySaver()
graph5 = workflow5.compile(checkpointer=memory)
```

```python:グラフを可視化
from IPython.display import Image, display

try:
    display(Image(graph5.get_graph().draw_mermaid_png()))
except Exception:
    # This requires some extra dependencies and is optional
    pass
```
![output.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3655320/2bb5df2c-2a20-bd9b-e77e-94b73d6ea9cd.jpeg)

違い、わかりますかね？
トリミングノードとモデルを実行するノードを分けて、これをエッヂで繋いでます。
前回は一つのノードの中で、直接トリミングした会話履歴（state）を渡してました。
今回はトリミングした後にエッヂを介して次の`call_model`に`state`を渡しています。
この状態で確認していきましょう。

<br>
<br>
<br>


```python
config5 = {"configurable": {"thread_id": "message_trim_333"}}

query = "僕は海賊の村上です。瀬戸内で暴れてます。"

input_messages = [
    SystemMessage(content='日本語で回答してください。分からない時はわからないと回答してください。'),
    HumanMessage(query)
    ]

output = graph5.invoke({'messages': input_messages}, config5)
output
# {'messages': [
#     SystemMessage(content='日本語で回答してください。分からない時・・・),
#     HumanMessage(content='僕は海賊の村上です。瀬戸内で暴れてます。)',
#     AIMessage(content='村上さん、海賊の気分はいかがですか？瀬戸内海は美しい・・・),
# ]}
```

上でトリミングできた会話と同じ会話を続けてみた後に名前を聞いてみましょう。

<br>
<br>
<br>



```python:検証
query = "眠いんだけど、睡眠不足なのかな？"

input_messages = [HumanMessage(query)]
output = graph5.invoke({"messages": input_messages}, config5)

query = "なるほど、リラックスする時間を作るいい方法を教えてください"
input_messages = [HumanMessage(query)]
output = graph5.invoke({"messages": input_messages}, config5)

query = "ところで、僕の名前は何？"
input_messages = [HumanMessage(query)]
output = graph5.invoke({"messages": input_messages}, config5)
output
# {'messages': [
#     SystemMessage(content='日本語で回答してください。分からない時は・・・),
#     HumanMessage(content='僕は海賊の村上です。瀬戸内で暴れてます。'),
#     AIMessage(content='村上さん、海賊の気分はいかがですか？瀬戸内海は美・・・),
#     HumanMessage(content='眠いんだけど、睡眠不足なのかな？'), 
#     AIMessage(content='眠いと感じるのは、睡眠不足が原因かもしれませんね。・・・),
#     HumanMessage(content='なるほど、リラックスする時間を作るいい方法を・・・),
#     AIMessage(content='リラックスする時間を作るためのいくつかの方法を・・・),
#     HumanMessage(content='ところで、僕の名前は何？'),
#     AIMessage(content='あなたの名前は「村上」とおっしゃっていましたね。・・・),
# ]}
```

あちゃー
村上って答えちゃいましたね。
「すっとぼけたbotを作りたい・・・」ことはないと思いますが、API料金を抑えたいときなどは気をつけるべきポイントかもしれません。

<br>

**ここからは推測です。**
trimmingは履歴を削除するわけではなく、抽出してモデルに送るような挙動だと思います。ですから同じノードの中で処理をしないと全部の履歴をモデルに渡してしまうことになりそうです。
エッヂ上には全履歴が乗ってます。

<br>
<br>
<br>



# 終わりに
今回はチャットボットの発展系として、trimmingをやってみました。
チャットの履歴には会話が残っていますが、モデルには送られないような仕様のようです。

そろそろ最初にやっていたことを忘れそうです。
特にプロンプトの書き方はたくさんあって忘れそうですよね。
`ChatPromptTemplate`やグラフの時に使った`state`、これには`BaseModel`と`TypedDict`の2種類がありました。
ま、使いながらなんとか慣れていくんでしょうね。
他の書き方などあるかもしれないので見つけ次第更新していきたいけど、英語が💦）

ではまた。