今日の目的
前回、過去の会話履歴を参照できるChatBot
ができました。
過去の会話履歴をなるべく長く残しておけばすごく便利な一方、ロングコンテキスト化が進んでいる昨今ではAPI資料料金も怖い。笑
ということで、ある程度の過去の履歴までしか参照しないようにするというのが二つ目の目的です。
- 過去の履歴をある一定以上は参照しない方法
ここまでの経過
プロンプトテンプレートや、LCEL、LangGraph、会話履歴を参照できるChatBotをやってきました。いきなりここにきちゃった方は過去の記事を参照くださいませ。
バージョン関連
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を使いました
ライブラリのインポートと環境変数の設定
これはいつも通り
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
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"
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
)
落とし穴
2024/11現在では後でやる参照内容を制限するところ(trim)でエラーが出ます。
LangChain
のバグらしいです。GitHubに解決方法が書いてありましたので最初に設定しておきます。
model.model_name= 'gpt-4o-mini' # GitHubから追加
trimmingする方法1
なんかね、ドキュメント見てたらこんな書き方してました。
これって、関数を変数に入れた感じなんかな?汗(よくわからん)
trimmer = trim_messages(
max_tokens=400, # 効果がわかりやすいように小さな値にしてます
strategy="last", # 最後の情報を残す
token_counter=model, # このモデルのトークナイザを使って数えるのかな
include_system=True,
allow_partial=False,
start_on="human",
)
このトリマーを使って、グラフを作っていきましょう。
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
idはまた別個に起こします。
config3 = {"configurable": {"thread_id": "message_trim_111"}}
query = "僕は海賊の村上です。瀬戸内で暴れてます。"
input_messages = [
SystemMessage(content='日本語で回答してください。分からない時はわからないと回答してください。'),
HumanMessage(query)
]
input_messages
# [SystemMessage(content='日本語で回答してください。分からない時は・・・),
# HumanMessage(content='僕は海賊の村上です。瀬戸内で暴れてます。'),・・・]
output = graph3.invoke({"messages": input_messages}, config3)
output
# {'messages': [
# SystemMessage(content='日本語で回答してください。分からない時は・・・),
# HumanMessage(content='僕は海賊の村上です。瀬戸内で暴れてます。',・・・),
# AIMessage(content='海賊の村上さん、こんにちは!瀬戸内海での冒険は楽しそう・・・),
# ]}
トークン数がオーバーして、trimmingされるように会話をしていきましょう。
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
さて、そろそろ、名前でも聞いてみましょう。
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して忘却してしまうボケたオッサンができてしまいました。<-言葉遣い
trimmingする方法2
チュートリアルからコピペ
上のoutputを渡してみましょう。
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='ところで、僕の名前は?'),]
なるほど。これも関数みたいに使えるのね。それに、システムメッセージはしっかり残ってます。
うむうむ
ということで、下のグラフを作ってみました。
というか、call_model
関数に会話履歴をトリミングしたものを使ってinvoke
するだけ。
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)
グラフの絵としては上と同じなので、早速会話をしていきましょう。
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='村上さん、海賊としての冒険はどんな感じですか?・・・),
# ]}
前回と同じように会話を重ねていきましょう
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='リラックスする時間を作るためのいくつかの方法を紹介します・・・),
# ]}
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='申し訳ありませんが、あなたの名前はわかりません。お名前を・・),
# ]}
はい!またしてもしっかり参照範囲がトリミングされて忘却おじさんができました。
まさに「俺のクローン」(違っ・・・ててほしい
trimmingの落とし穴
ここまではうまくいった方法を書いてきました。
実は今回はなるべくLangChain
のドキュメントを頑張って読むようにしています。
だから失敗もあるんです。その失敗をご紹介したいと思います。
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)
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
違い、わかりますかね?
トリミングノードとモデルを実行するノードを分けて、これをエッヂで繋いでます。
前回は一つのノードの中で、直接トリミングした会話履歴(state)を渡してました。
今回はトリミングした後にエッヂを介して次のcall_model
にstate
を渡しています。
この状態で確認していきましょう。
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='村上さん、海賊の気分はいかがですか?瀬戸内海は美しい・・・),
# ]}
上でトリミングできた会話と同じ会話を続けてみた後に名前を聞いてみましょう。
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料金を抑えたいときなどは気をつけるべきポイントかもしれません。
ここからは推測です。
trimmingは履歴を削除するわけではなく、抽出してモデルに送るような挙動だと思います。ですから同じノードの中で処理をしないと全部の履歴をモデルに渡してしまうことになりそうです。
エッヂ上には全履歴が乗ってます。
終わりに
今回はチャットボットの発展系として、trimmingをやってみました。
チャットの履歴には会話が残っていますが、モデルには送られないような仕様のようです。
そろそろ最初にやっていたことを忘れそうです。
特にプロンプトの書き方はたくさんあって忘れそうですよね。
ChatPromptTemplate
やグラフの時に使ったstate
、これにはBaseModel
とTypedDict
の2種類がありました。
ま、使いながらなんとか慣れていくんでしょうね。
他の書き方などあるかもしれないので見つけ次第更新していきたいけど、英語が💦)
ではまた。