LCELでBedrockとDynamoDBを使ったチャット機能を組んで、Streamlitでガワを被せてみます。
※Claude3版は以下
作ったもの
ログイン画面付き
チャット履歴を管理
ログインユーザーごとの履歴管理
サイドバーの見た目がイマイチなのは気にしないでください。
前提作業
- DynamoDBに
SessionTable
を作成してPython実行環境からの権限を付与しておく - 以下のパッケージのインストール
pip install -U boto3 langchain langchain-community streamlit streamlit_authenticator
パーツを作っていく
PromptTemplateとChatPromptTemplate、通常のBedrockとBedrockChatモデルの違いが良く分かっていなかったので、個々のパーツの動作を確認しながら進めます。
ChatPromptTemplateとは何なのか
チャット機能を作る時にはPromptTemplateではなくChatPromptTemplateを使われている例が多いので、ChatPromptTemplateコンポーネントを単独で実行して動きを確認します。
分かり易さの為に既にある程度のチャット履歴がDynamoDBに格納されている状態で確認します。
from langchain.globals import set_debug
set_debug(False) # debug時はTrue
import sys
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_community.chat_message_histories import DynamoDBChatMessageHistory
# ユーザー入力
user_input=sys.argv[1]
# セッションID(DynamoDBのKey)
session_id = "user2"
# DynamoDBの会話履歴(テーブル名"SessionTable"、TTL=3600秒)
message_history = DynamoDBChatMessageHistory(table_name="SessionTable", session_id=session_id, ttl=3600)
# プロンプトテンプレートを作成
prompt = ChatPromptTemplate.from_messages(
[
("system","あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。"),
MessagesPlaceholder(variable_name="messages"), #ここにDynamoDBから取得した会話履歴を入れる
("human", "{input}")
]
)
# chainの定義
chain = prompt
# chainの実行
result = chain.invoke({"messages": message_history.messages, "input": user_input})
print(result)
messages=[
SystemMessage(content='あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。'),
HumanMessage(content='私の事はジローと呼んでください'),
AIMessage(content=' はい、ジローさん。'),
HumanMessage(content='好きな食べ物はラーメンです'),
AIMessage(content=' はい、ジローさんの好きな食べ物がラーメンだと聞きました!ラーメンは美味しい食べ物の一つだと思います。'),
HumanMessage(content='私の事を覚えていますか?'),
AIMessage(content=' はい、覚えています。 あなたの名前はジローさんで、好きな食べ物がラーメンだという情報を前回の会話から覚えています。名前と 好きな食べ物は会話のコンテキストから記憶しています。'),
HumanMessage(content='こんにちは')
]
messages
という配列の中に、SystemMessage
とHumanMessage
とAIMessage
という形式で会話履歴が格納されています。最後のHumanMessage
が最終的にLLMに問い合わせをする内容になります。
プロンプトテンプレート上で
("system","あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。")
と記載した部分が、プロンプトでは
SystemMessage(content='あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。')
と展開されています。
同様に、テンプレートの
MessagesPlaceholder(variable_name="messages")
と記載した部分は、invoke時の"messages": message_history.messages
がDynamoDBから展開されます。
テンプレート最後の
("human", "{input}")
については、invoke時の"input": user_input
が展開され、
HumanMessage(content='こんにちは'
となっています(ユーザー入力として「こんにちは」を与えています)。
LLMは賢いのでこのまま渡しても動きそうな気もしますが、Claudeの形式(Human/Assistant)と異なるなど、若干のモヤっと感を抱えたままLLMとchainしてみます。
ChatPromptTemplate+非Chat Bedrock
先ほどのプロンプトにLLMをchainしてみます。
LangChainにはBedrockとBedrockChatという2つのクラスがある一方、boto3のAPI的にはそんな区別が無いので、何が違うのかひとつずつ確認してみます。
まずは非Chat版
from langchain.globals import set_debug
set_debug(False) # debug時はTrue
import sys
from langchain_community.llms import Bedrock
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_community.chat_message_histories import DynamoDBChatMessageHistory
# ユーザー入力
user_input=sys.argv[1]
# セッションID(DynamoDBのKey)
session_id = "user2"
# DynamoDBの会話履歴(テーブル名"SessionTable"、TTL=3600秒)
message_history = DynamoDBChatMessageHistory(table_name="SessionTable", session_id=session_id, ttl=3600)
# プロンプトテンプレートを作成
prompt = ChatPromptTemplate.from_messages(
[
("system","あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。"),
MessagesPlaceholder(variable_name="messages"), #ここにDynamoDBから取得した会話履歴を入れる
("human", "{input}")
]
)
# LLMの定義
LLM = Bedrock(
model_id="anthropic.claude-instant-v1",
model_kwargs={"max_tokens_to_sample": 1000},
)
# chainの定義
chain = prompt | LLM
# chainの実行
result = chain.invoke({"messages": message_history.messages, "input": user_input})
print(result)
UserWarning: Error: Prompt must alternate between '
Human:' and '
Assistant:'. Received
warnings.warn(ALTERNATION_ERROR + f" Received {input_text}")
こんにちはジローさん。前回の会話であなたの名前とラーメンが好きだと聞いたので、覚えています。今日は元気に過ごされていますか。
LLM自体は動いていますが、なんかワーニングが出ています。Bedrockのログを確認してみます。
"modelId": "anthropic.claude-instant-v1",
"input": {
"inputContentType": "application/json",
"inputBodyJson": {
"max_tokens_to_sample": 1000,
"prompt": "
System: あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。\n\n
Human: 私の事はジローと呼んでください\n
AI: はい、ジローさん。\n\n
Human: 好きな食べ物はラーメンです\n
AI: はい、ジローさんの好きな食べ物がラーメンだと聞きました!ラーメンは美味しい食べ物の一つだと思います。\n\n
Human: 私の事を覚えていますか?\n
AI: はい、覚えています。あなたの名前はジローさんで、好きな食べ物がラーメンだという情報を前回の会話から覚えています。名前と好きな食べ物は会話のコンテキストから記憶しています。\n\n
Human: こんにちは\n\n
Assistant:"
},
"inputTokenCount": 282
},
"output": {
"outputContentType": "application/json",
"outputBodyJson": {
"completion": " こんにちはジローさん。前回の会話であなたの名前とラーメンが好きだと聞いたので、覚えています。今日は元気に過ごされていますか。",
"stop_reason": "stop_sequence",
"stop": "\n\nHuman:"
},
"outputTokenCount": 67
}
まず、元のプロンプトの内容がそのまま渡されず、LangChain内で加工されてBedrockに渡されているのが分かります。
LangChainがどこでワーニングを返してきたか細かくは見ていないですが、最終的なプロンプトはHumanとAIの会話の最後にAssistant
を付けたような形になっており、このあたりでワーニングが出ているんだろうなぁという気がします。
ChatPromptTemplate+BedrockChat
今度はChat版のBedrockChatモデルをchainしてみます。
from langchain.globals import set_debug
set_debug(True) # debug時はTrue
import sys
from langchain_community.chat_models import BedrockChat
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_community.chat_message_histories import DynamoDBChatMessageHistory
# ユーザー入力
user_input=sys.argv[1]
# セッションID(DynamoDBのKey)
session_id = "user2"
# DynamoDBの会話履歴(テーブル名"SessionTable"、TTL=3600秒)
message_history = DynamoDBChatMessageHistory(table_name="SessionTable", session_id=session_id, ttl=3600)
# プロンプトテンプレートを作成
prompt = ChatPromptTemplate.from_messages(
[
("system","あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。"),
MessagesPlaceholder(variable_name="messages"), #ここにDynamoDBから取得した会話履歴を入れる
("human", "{input}")
]
)
# LLMの定義
LLM = BedrockChat(
model_id="anthropic.claude-instant-v1",
model_kwargs={"max_tokens_to_sample": 1000},
)
# chainの定義
chain = prompt | LLM
# chainの実行
result = chain.invoke({"messages": message_history.messages, "input": user_input})
print(result)
content=' こんにちは、ジローさん。みなさん元気ですか。'
ワーニングが出なくなりました。Bedrockのログを確認してみます。
"operation": "InvokeModel",
"modelId": "anthropic.claude-instant-v1",
"input": {
"inputContentType": "application/json",
"inputBodyJson": {
"max_tokens_to_sample": 1000,
"prompt": "
あなたはAIアシスタントです。HumanとAssistantの会話履歴を参考に、最後の質問に回答してください。\n\n
Human: 私の事はジローと呼んでください\n\n
Assistant: はい、ジローさん。\n\n
Human: 好きな食べ物はラーメンです\n\n
Assistant: はい、ジローさんの好きな食べ物がラーメンだと聞きました!ラーメンは美味しい食べ物の一つだと思います。\n\n
Human: 私の事を覚えていますか?\n\n
Assistant: はい、覚えています。あなたの名前はジローさんで、好きな食べ物がラーメンだという情報を前回の会話から覚えています。名前と好きな食べ物は会話のコンテキストから記憶しています。\n\n
Human: こんにちは\n\n
Assistant:"
},
"inputTokenCount": 282
},
"output": {
"outputContentType": "application/json",
"outputBodyJson": {
"completion": " こんにちは、ジローさん。みなさん元気ですか。",
"stop_reason": "stop_sequence",
"stop": "\n\nHuman:"
},
"outputTokenCount": 27
}
}
親の顔より見たClaude形式のプロンプトになりました。
システムプロンプト部分も、Claude的にはHumanとAssistantの外側に書くと良いとされているので、余計なSystem
も付かなくなっています。
と、いうわけで、チャット履歴を含む場合は、ChatPromptTemplateとBedrockChatの組み合わせがスマートに書けそうです。
チャット履歴のDynamoDBへの登録
LCEL以前のchainでは明示しなくてもDynamoDBを更新してくれたのですが、LCELで記載する場合はどうも自分で更新する必要がありそうです。
というわけで、以下の記述を追加します。
# DynamoDBの更新
message_history.add_user_message(user_input)
message_history.add_ai_message(result.content)
出来上がり
Streamlitでガワを被せる
作ったパーツにガワを被せます。
認証情報としては user1/pass、user2/pass、user3/pass の3アカウントをハードコーディングしています。
ネストが多めでちょっと汚いプログラムですが…
from langchain.globals import set_debug
set_debug(False) # debug時はTrue
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_community.chat_message_histories import DynamoDBChatMessageHistory
from langchain_community.chat_models import BedrockChat
import streamlit as st
import streamlit_authenticator as sa
# 認証情報を定義
authenticator = sa.Authenticate(
credentials={"usernames":{
"user1":{"name":"user1","password":"pass"},
"user2":{"name":"user2","password":"pass"},
"user3":{"name":"user3","password":"pass"}}},
cookie_name="streamlit_cookie",
key="signature_key",
cookie_expiry_days=1
)
# ログイン画面描画
authenticator.login()
if st.session_state["authentication_status"] is False: #ログイン失敗
st.error('Username/password is incorrect')
elif st.session_state["authentication_status"] is None: #未入力
st.warning('Please enter your username and password')
else: #ログイン成功
authenticator.logout(location="sidebar") #ログアウトボタンをサイドバーに表示
# ログイン直後っぽい時はセッション上の過去のメッセージをクリアする
if "FormSubmitter:Login-Login" in st.session_state:
if "messages" in st.session_state:
st.session_state["messages"].clear()
# usernameをDynamoDBのKeyとする
session_id = st.session_state["username"]
# DynamoDBの会話履歴(テーブル名"SessionTable"、TTL=3600秒)
message_history = DynamoDBChatMessageHistory(table_name="SessionTable", session_id=session_id, ttl=3600)
# プロンプトテンプレートを作成
prompt = ChatPromptTemplate.from_messages(
[
("system","あなたはAIアシスタントです。最後の質問に回答してください。"),
MessagesPlaceholder(variable_name="messages"), #ここにDynamoDBから取得した会話履歴を入れる
("human", "{input}")
]
)
# LLMの定義
LLM = BedrockChat(
model_id="anthropic.claude-instant-v1",
model_kwargs={"max_tokens_to_sample": 1000},
)
# chainの定義
chain = prompt | LLM
# 画面描画用のセッション上のチャット履歴を初期化する
if "messages" not in st.session_state:
# 辞書形式で定義
st.session_state["messages"] = []
# セッションからこれまでのチャット履歴を全て画面に表示する
for message in st.session_state.messages:
with st.chat_message(message["role"]):
st.markdown(message["content"])
# 入力を求める
if input_text := st.chat_input("会話しましょう"):
# 画面にユーザの入力を追加表示する
with st.chat_message("user"):
st.write(input_text)
# chainの実行
result = chain.invoke({"messages": message_history.messages, "input": input_text})
# 画面にBedrockの返答を追加表示する
with st.chat_message("assistant"):
st.write(result.content)
# セッション上のチャット履歴の更新(画面の再描画用)
st.session_state.messages.append({"role": "user", "content": input_text})
st.session_state.messages.append({"role": "assistant", "content": result.content})
# DynamoDBの更新(次回の入力用)
message_history.add_user_message(input_text)
message_history.add_ai_message(result.content)
実行する
python -m streamlit run chat_web.py
ログイン画面が表示されるので、user1/pass、user2/pass、user3/passの何れかでログインしてください。
尚、DynamoDBのチャット履歴は1時間で削除されます。
Instantだからかプロンプトが手抜き過ぎたのか、ちょっとバカっぽいですが…
username毎にDynamoDBに履歴登録されるので見てみてください。
インターネットに公開する
インターネットに公開したい場合は以下ご参照ください。
改善余地
ストリーミングで出力した方がそれっぽいのですがちょっと億劫でサボりました。
参考ページ