LCELとStreamlitでチャットWebアプリを作る - Amazon Bedrock APIで始めるLLM超入門⑩

LCELでBedrockとDynamoDBを使ったチャット機能を組んで、Streamlitでガワを被せてみます。

※Claude3版は以下

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

# 作ったもの
ログイン画面付き

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/ef0d6270-3b5b-6ebe-6d6e-3b643cd94679.png)

チャット履歴を管理

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/bf39c9bb-b65e-edcc-2f4e-17460724e3ef.png)

ログインユーザーごとの履歴管理

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/02e8d41e-f2a8-c3aa-4fe3-04288f1bfcc5.png)

サイドバーの見た目がイマイチなのは気にしないでください。

# 前提作業
- DynamoDBに`SessionTable`を作成してPython実行環境からの権限を付与しておく
- 以下のパッケージのインストール
```
pip install -U boto3 langchain langchain-community streamlit streamlit_authenticator
```

# パーツを作っていく

PromptTemplateとChatPromptTemplate、通常のBedrockとBedrockChatモデルの違いが良く分かっていなかったので、個々のパーツの動作を確認しながら進めます。

## ChatPromptTemplateとは何なのか

チャット機能を作る時にはPromptTemplateではなくChatPromptTemplateを使われている例が多いので、ChatPromptTemplateコンポーネントを単独で実行して動きを確認します。
分かり易さの為に既にある程度のチャット履歴がDynamoDBに格納されている状態で確認します。

```Python
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版

```Python
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のログを確認してみます。
```JSON:CloudWatch logs
    "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してみます。

```Python
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のログを確認してみます。
```JSON:CloudWatch logs
    "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で記載する場合はどうも自分で更新する必要がありそうです。

というわけで、以下の記述を追加します。

```Python
# DynamoDBの更新
message_history.add_user_message(user_input)
message_history.add_ai_message(result.content)
```

# 出来上がり

## Streamlitでガワを被せる

作ったパーツにガワを被せます。
認証情報としては user1/pass、user2/pass、user3/pass の3アカウントをハードコーディングしています。
ネストが多めでちょっと汚いプログラムですが…

```Python:chat_web.py
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時間で削除されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/3663c1fe-ccb9-ac31-f672-6755d2a2d6af.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3573242/6d5bd738-4396-e34d-6398-ce1c4f1b8ac5.png)

Instantだからかプロンプトが手抜き過ぎたのか、ちょっとバカっぽいですが…
username毎にDynamoDBに履歴登録されるので見てみてください。

## インターネットに公開する

インターネットに公開したい場合は以下ご参照ください。

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

# 改善余地
ストリーミングで出力した方がそれっぽいのですがちょっと億劫でサボりました。

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


# 参考ページ

https://blog.serverworks.co.jp/langchain-dynamodb-chat-history