Closed7

LangChain DynamoDBChatMessageHistoryを用いてマルチターン会話をやってみる

tanimontanimon

やりたいこと

  • LangChainを用いてマルチターン会話を実現する
  • LLMの実行にはBedrockを利用する
  • アプリケーション全体はLambda上で動かす
  • インフラはCDKで管理する
tanimontanimon

まずはローカルでBedrockとシングルターン会話してみる

依存関係インストール

 poetry -C server add langchain

ハンドラ関数

multi_turn_conversation_handler.py
from typing import Any
from langchain_community.chat_models import BedrockChat
from langchain_core.messages import HumanMessage

chat = BedrockChat(
  model_id="anthropic.claude-v2:1",
  region_name="us-east-1",
  model_kwargs={
    "temperature": 0,
  },
  verbose=True,
)

def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
  question = event["question"]
  print(f"Question: {question}")

  messages = [HumanMessage(content=question)]
  response = chat.invoke(messages)

  print(f"Response: {response}")

エントリポイント

main.py
from multi_turn_conversation_handler import handler

def main():
  handler({"question": "あなたは何ができますか?"}, None)

if __name__ == "__main__":
    main()

実行結果

❯ poetry -C server run python server/main.py
Question: あなたは何ができますか?
Response: content=' ご質問ありがとうございます。\n\n私はAnthropicが開発した対話型AIアシスタントClaudeです。\n\n以下のことができます:\n\n- 基本的な会話\n- 簡単な質問への回答\n- ユーザーの要求に基づいた情報の検索\n\n私の能力には限界がありますが、会話を通じてユーザーの役に立つことを目指しています。\nご不明な点があれば遠慮なくお問い合わせください。'

tanimontanimon

CDKでLambda関数を作成して実行してみる

CDKコード

import { PythonFunction } from "@aws-cdk/aws-lambda-python-alpha";
import { Duration, Stack, StackProps } from "aws-cdk-lib";
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";

export class MainStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const multiTurnConversationFn = new PythonFunction(
      this,
      "MultiTurnConversationFn",
      {
        entry: "../server",
        index: "multi_turn_conversation_handler.py",
        runtime: Runtime.PYTHON_3_12,
        architecture: Architecture.ARM_64,
        bundling: {
          assetExcludes: [".venv", "__pycache__", "tests", "README.md"],
        },
        timeout: Duration.minutes(15),
      },
    );
    multiTurnConversationFn.addToRolePolicy(
      new PolicyStatement({
        effect: Effect.ALLOW,
        actions: ["bedrock:InvokeModel"],
        resources: ["*"],
      }),
    );
  }
}

デプロイしてマネコンから実行

イベントJSON

{
  "question": "あなたは何ができますか?"
}

実行結果ログ

START RequestId: 0d94cf6f-c5bf-4716-87e2-e0abadc43ea8 Version: $LATEST
Question: あなたは何ができますか?
Response: content=' ご質問ありがとうございます。\n\n私はAnthropicが開発した対話型AIアシスタントClaudeです。\n\n以下のことができます:\n\n- 基本的な会話\n- 簡単な質問への回答\n- ユーザーの要求に基づいた情報の検索\n\n私の能力には限界がありますが、会話を通じてユーザーの役に立つことを目指しています。\nご不明な点があれば遠慮なくお問い合わせください。'
END RequestId: 0d94cf6f-c5bf-4716-87e2-e0abadc43ea8
REPORT RequestId: 0d94cf6f-c5bf-4716-87e2-e0abadc43ea8	Duration: 5566.85 ms	Billed Duration: 5567 ms	Memory Size: 128 MB	Max Memory Used: 122 MB	
tanimontanimon

ローカルでインメモリに会話履歴を保持してマルチターン会話をやってみる

ハンドラ関数

multi_turn_conversation_handler.py
import pprint
from typing import Any
from langchain_community.chat_models import BedrockChat
from langchain.memory import ConversationBufferMemory
from langchain.chains import LLMChain
from langchain.prompts import (
    ChatPromptTemplate,
    MessagesPlaceholder,
    SystemMessagePromptTemplate,
    HumanMessagePromptTemplate,
)

chat = BedrockChat(
  model_id="anthropic.claude-v2:1",
  region_name="us-east-1",
  model_kwargs={
    "temperature": 0,
  },
  verbose=True,
)
prompt = ChatPromptTemplate(
  messages=[
    SystemMessagePromptTemplate.from_template(
            "You are a nice chatbot having a conversation with a human."
        ),
        # The `variable_name` here is what must align with memory
        MessagesPlaceholder(variable_name="chat_history"),
        HumanMessagePromptTemplate.from_template("{question}")
  ]
)
memory = ConversationBufferMemory(memory_key="chat_history",return_messages=True)
conversation = LLMChain(
  llm=chat,
  prompt=prompt,
  memory=memory,
  verbose=True,
)

def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
  question = event["question"]
  print(f"Question: {question}")

  response = conversation.invoke({"question": question})
  pprint.pprint(response)

エントリポイント

main.py
from multi_turn_conversation_handler import handler

def main():
  handler({"question": "おすすめのお肉料理の候補を3つ挙げてください"}, None)
  handler({"question": "1のレシピを教えてください"}, None)

if __name__ == "__main__":
    main()

実行結果

poetry -C server run python server/main.py
Question: おすすめのお肉料理の候補を3つ挙げてください


> Entering new LLMChain chain...
Prompt after formatting:
System: You are a nice chatbot having a conversation with a human.
Human: おすすめのお肉料理の候補を3つ挙げてください

> Finished chain.
{'chat_history': [HumanMessage(content='おすすめのお肉料理の候補を3つ挙げてください'),
                  AIMessage(content=' はい、おすすめのお肉料理の候補を3つ挙げさせていただきます。\n\n1. ステーキ\nジューシーな肉の旨味を味わえる定番のお肉料理です。上質な肉を
使い、火加減がポイントになります。\n\n2. ハンバーグ\n具だくさんのハンバーグは子供から大人まで人気のメニュー。自分好みの具材を入れてオリジナルのハンバーグが作れます。\n\n3. 焼 き鳥\n骨付き鳥肉を使った焼き鳥は、肉の旨味とジューシーさが楽しめます。タレや塩レモンなど自分の好みの味付けがおすすめ。\n\nいかがでし')],                                        'question': 'おすすめのお肉料理の候補を3つ挙げてください',
 'text': ' はい、おすすめのお肉料理の候補を3つ挙げさせていただきます。\n'
         '\n'
         '1. ステーキ\n'
         'ジューシーな肉の旨味を味わえる定番のお肉料理です。上質な肉を使い、火加減がポイントになります。\n'
         '\n'
         '2. ハンバーグ\n'
         '具だくさんのハンバーグは子供から大人まで人気のメニュー。自分好みの具材を入れてオリジナルのハンバーグが作れます。\n'
         '\n'
         '3. 焼き鳥\n'
         '骨付き鳥肉を使った焼き鳥は、肉の旨味とジューシーさが楽しめます。タレや塩レモンなど自分の好みの味付けがおすすめ。\n'
         '\n'
         'いかがでし'}
Question: 1のレシピを教えてください


> Entering new LLMChain chain...
Prompt after formatting:
System: You are a nice chatbot having a conversation with a human.
Human: おすすめのお肉料理の候補を3つ挙げてください
AI:  はい、おすすめのお肉料理の候補を3つ挙げさせていただきます。

1. ステーキ
ジューシーな肉の旨味を味わえる定番のお肉料理です。上質な肉を使い、火加減がポイントになります。

2. ハンバーグ
具だくさんのハンバーグは子供から大人まで人気のメニュー。自分好みの具材を入れてオリジナルのハンバーグが作れます。

3. 焼き鳥
骨付き鳥肉を使った焼き鳥は、肉の旨味とジューシーさが楽しめます。タレや塩レモンなど自分の好みの味付けがおすすめ。

いかがでし
Human: 1のレシピを教えてください

> Finished chain.
{'chat_history': [HumanMessage(content='おすすめのお肉料理の候補を3つ挙げてください'),
                  AIMessage(content=' はい、おすすめのお肉料理の候補を3つ挙げさせていただきます。\n\n1. ステーキ\nジューシーな肉の旨味を味わえる定番のお肉料理です。上質な肉を
使い、火加減がポイントになります。\n\n2. ハンバーグ\n具だくさんのハンバーグは子供から大人まで人気のメニュー。自分好みの具材を入れてオリジナルのハンバーグが作れます。\n\n3. 焼 き鳥\n骨付き鳥肉を使った焼き鳥は、肉の旨味とジューシーさが楽しめます。タレや塩レモンなど自分の好みの味付けがおすすめ。\n\nいかがでし'),                                                          HumanMessage(content='1のレシピを教えてください'),
                  AIMessage(content=' ステーキのおすすめの調理法として、以下のように提案させていただきます。\n\n材料(2人分)\n・ステーキ用肉(サーロインやリブアイなど) 400g\n・
塩 少々\n・こしょう 適量  \n\n作り方\n1. 肉から余分な脂身をそぎ落とす。\n2. 室温に30分ほど戻しておく。\n3. フライパンを強火で熱し、肉の脂身の部分を下にして置く。\n4. 30秒ほど したら肉を裏返し、塩とこしょうをふる。\n5. 焼き加減を見ながら裏表2~3分ずつ調理する。\n6. 皿に移し、アルミホイルで包んで5~10分程')],                                             'question': '1のレシピを教えてください',
 'text': ' ステーキのおすすめの調理法として、以下のように提案させていただきます。\n'
         '\n'
         '材料(2人分)\n'
         '・ステーキ用肉(サーロインやリブアイなど) 400g\n'
         '・塩 少々\n'
         '・こしょう 適量  \n'
         '\n'
         '作り方\n'
         '1. 肉から余分な脂身をそぎ落とす。\n'
         '2. 室温に30分ほど戻しておく。\n'
         '3. フライパンを強火で熱し、肉の脂身の部分を下にして置く。\n'
         '4. 30秒ほどしたら肉を裏返し、塩とこしょうをふる。\n'
         '5. 焼き加減を見ながら裏表2~3分ずつ調理する。\n'
         '6. 皿に移し、アルミホイルで包んで5~10分程'}

参考

https://python.langchain.com/docs/modules/memory/

tanimontanimon

DynamoDBChatMessageHistoryを使ってみる

以下を参考に会話履歴を保存してみて、どんなデータが作成されるのかを確認する

https://python.langchain.com/docs/integrations/memory/aws_dynamodb

CDKでテーブルを作成する

MainStack に以下を追加

    new aws_dynamodb.Table(this, "SessionsTable", {
      partitionKey: {
        name: "id",
        type: aws_dynamodb.AttributeType.STRING,
      },
      billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST,
    });

DynamoDBChatMessageHistoryを使ってメッセージを保存する

依存関係をインストール

poetry add langchain-community -C server

main 関数を変更

main.py
from langchain_community.chat_message_histories.dynamodb import DynamoDBChatMessageHistory

def main():
  history = DynamoDBChatMessageHistory(
    table_name="MainStack-SessionsTablexxxxxxxx-xxxxxxxxxxxx",
    primary_key_name="id",
    session_id="dummy-session-id",
  )
  history.add_user_message("こんにちは。私は人間です。")
  history.add_ai_message("こんにちは。私はAIです。")

if __name__ == "__main__":
    main()

作成されたデータを確認

{
 "id": "dummy-session-id",
 "History": [
  {
   "data": {
    "id": null,
    "additional_kwargs": {},
    "content": "こんにちは。私は人間です。",
    "example": false,
    "name": null,
    "type": "human"
   },
   "type": "human"
  },
  {
   "data": {
    "id": null,
    "additional_kwargs": {},
    "content": "こんにちは。私はAIです。",
    "example": false,
    "name": null,
    "type": "ai"
   },
   "type": "ai"
  }
 ]
}
tanimontanimon

DynamoDBに会話履歴を保持してマルチターン会話をやってみる

ハンドラ関数を更新

  • ドキュメント を参考にChainにDynamoDBChatMessageHistoryを組み込む
  • 会話履歴保存用のテーブル名は環境変数から読み込む
  • セッションIDはLambdaのイベントから指定できるようにする
multi_turn_conversation_handler.py
import pprint
from typing import Any
from langchain_community.chat_models import BedrockChat
from langchain_community.chat_message_histories.dynamodb import DynamoDBChatMessageHistory
from langchain.chains import LLMChain
from langchain.prompts import (
    ChatPromptTemplate,
    MessagesPlaceholder,
)
from langchain_core.runnables.history import RunnableWithMessageHistory
import os

chat = BedrockChat(
  model_id="anthropic.claude-v2:1",
  region_name="us-east-1",
  model_kwargs={
    "temperature": 0,
  },
  verbose=True,
)

prompt = ChatPromptTemplate.from_messages(
  [
    ("system", "You are a helpful assistant."),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{question}"),
  ]
)

chain = prompt | chat

table_name = os.environ.get("SESSIONS_TABLE_NAME")

chain_with_history = RunnableWithMessageHistory(
  chain,
  lambda session_id: DynamoDBChatMessageHistory(
    table_name=table_name,
    primary_key_name="id",
    session_id=session_id,
  ),
  input_messages_key="question",
  history_messages_key="history",
)

def handler(event: dict[str, Any], context: Any) -> dict[str, Any]:
  session_id = event["sessionId"]
  question = event["question"]
  print(f"Session ID: {session_id}")
  print(f"Question: {question}")

  config = {"configurable": {"session_id": session_id}}
  response = chain_with_history.invoke({"question": question}, config=config)
  pprint.pprint(response)

CDKコードを更新

  • SessionsTableのテーブル名をLambdaの環境変数に設定
  • LambdaにSessionsTableの読み書き権限を付与
import { PythonFunction } from "@aws-cdk/aws-lambda-python-alpha";
import { Duration, Stack, StackProps, aws_dynamodb } from "aws-cdk-lib";
import { Effect, PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";

export class MainStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const sessionsTable = new aws_dynamodb.Table(this, "SessionsTable", {
      partitionKey: {
        name: "id",
        type: aws_dynamodb.AttributeType.STRING,
      },
      billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST,
    });

    const multiTurnConversationFn = new PythonFunction(
      this,
      "MultiTurnConversationFn",
      {
        entry: "../server",
        index: "multi_turn_conversation_handler.py",
        runtime: Runtime.PYTHON_3_12,
        architecture: Architecture.ARM_64,
        environment: {
          SESSIONS_TABLE_NAME: sessionsTable.tableName,
        },
        bundling: {
          assetExcludes: [".venv", "__pycache__", "tests", "README.md"],
        },
        timeout: Duration.minutes(15),
      },
    );
    multiTurnConversationFn.addToRolePolicy(
      new PolicyStatement({
        effect: Effect.ALLOW,
        actions: ["bedrock:InvokeModel"],
        resources: ["*"],
      }),
    );
    sessionsTable.grantReadWriteData(multiTurnConversationFn);
  }
}

実行結果

1ターン目の会話

イベントJSON

{
  "sessionId": "dummy-session-id-2",
  "question": "おすすめのお肉料理の候補を3つ挙げてください"
}

ログ

START RequestId: ff9ac05a-cf74-49aa-b777-0f29aae5a668 Version: $LATEST
Session ID: dummy-session-id-2
Question: おすすめのお肉料理の候補を3つ挙げてください
AIMessage(content='おすすめのお肉料理の候補として、以下の3つを挙げます。\n\n1. ステーキ\n肉のうまみが存分に味わえる定番のお肉料理です。レア~ミディアムレアがおすすめです。\n\n2. ハンバーグ\n具だくさんのハンバーグは子供から大人まで人気のメニュー。卵とパン粉でとじてジューシーな味わいが楽しめます。\n\n3. 焼き鳥\n骨付き鳥肉を炭火で焼いた焼き鳥は味も香りも最高。塩・タレがついてとってもおいしい一品です。')
END RequestId: ff9ac05a-cf74-49aa-b777-0f29aae5a668
REPORT RequestId: ff9ac05a-cf74-49aa-b777-0f29aae5a668	Duration: 12820.07 ms	Billed Duration: 12821 ms	Memory Size: 128 MB	Max Memory Used: 128 MB	Init Duration: 5422.57 ms	

2ターン目の会話

イベントJSON

{
  "sessionId": "dummy-session-id-2",
  "question": "1のレシピを教えてください"
}

ログ

START RequestId: 49aaefa0-bb34-441c-8e0b-65292a4b033f Version: $LATEST
Session ID: dummy-session-id-2
Question: 1のレシピを教えてください
AIMessage(content='ステーキのレシピをご紹介します。\n\n【材料】(2人分)\n・牛肉のロース肉  400g\n・塩  少々\n・こしょう  少々\n・オリーブオイル  大さじ2\n・バター  20g\n\n【作り方】\n1. 肉から筋や余分な脂身をそぎ落とし、表面の水分をふき取る。\n2. 塩・こしょうを振りかける。\n3. フライパンを強火で熱し、オリーブオイルを入れる。\n4. 2cm厚のステーキに切って、脂がきつね色になるまで片面を焼く。\n5. 焼けた面にバターをのせて裏返し、お好みの焼き加減に焼き上げる。\n\nご家庭の味で楽しんでください。レア~ミディアムレアがおすすめです。')
END RequestId: 49aaefa0-bb34-441c-8e0b-65292a4b033f
REPORT RequestId: 49aaefa0-bb34-441c-8e0b-65292a4b033f	Duration: 11322.72 ms	Billed Duration: 11323 ms	Memory Size: 128 MB	Max Memory Used: 128 MB	

DynamoDBテーブルに保存された会話履歴

{
 "id": "dummy-session-id-2",
 "History": [
  {
   "data": {
    "id": null,
    "additional_kwargs": {},
    "content": "おすすめのお肉料理の候補を3つ挙げてください",
    "example": false,
    "name": null,
    "type": "human"
   },
   "type": "human"
  },
  {
   "data": {
    "id": null,
    "additional_kwargs": {},
    "content": "おすすめのお肉料理の候補として、以下の3つを挙げます。\n\n1. ステーキ\n肉のうまみが存分に味わえる定番のお肉料理です。レア~ミディアムレアがおすすめです。\n\n2. ハンバーグ\n具だくさんのハンバーグは子供から大人まで人気のメニュー。卵とパン粉でとじてジューシーな味わいが楽しめます。\n\n3. 焼き鳥\n骨付き鳥肉を炭火で焼いた焼き鳥は味も香りも最高。塩・タレがついてとってもおいしい一品です。",
    "example": false,
    "name": null,
    "type": "ai"
   },
   "type": "ai"
  },
  {
   "data": {
    "id": null,
    "additional_kwargs": {},
    "content": "1のレシピを教えてください",
    "example": false,
    "name": null,
    "type": "human"
   },
   "type": "human"
  },
  {
   "data": {
    "id": null,
    "additional_kwargs": {},
    "content": "ステーキのレシピをご紹介します。\n\n【材料】(2人分)\n・牛肉のロース肉  400g\n・塩  少々\n・こしょう  少々\n・オリーブオイル  大さじ2\n・バター  20g\n\n【作り方】\n1. 肉から筋や余分な脂身をそぎ落とし、表面の水分をふき取る。\n2. 塩・こしょうを振りかける。\n3. フライパンを強火で熱し、オリーブオイルを入れる。\n4. 2cm厚のステーキに切って、脂がきつね色になるまで片面を焼く。\n5. 焼けた面にバターをのせて裏返し、お好みの焼き加減に焼き上げる。\n\nご家庭の味で楽しんでください。レア~ミディアムレアがおすすめです。",
    "example": false,
    "name": null,
    "type": "ai"
   },
   "type": "ai"
  }
 ]
}
tanimontanimon

ついでにLangSmith Tracingと連携してみる

環境変数を設定するだけでLangSmith Tracingを利用できるようになる
アプリケーション側のコードを変更する必要はない

https://docs.smith.langchain.com/tracing/quick_start

LangSmith有効化のためにLambda関数に環境変数を設定

  • SSM Parameter StoreにAPIキーを保存しておく
  • CDKでSSM Parameter StoreからAPIキーを読み込んでLambda関数の環境変数に設定する
main-stack.ts
+    const langChainApiKey = aws_ssm.StringParameter.valueForStringParameter(
+    this,
+      "LangChainApiKey",
+    );
 
     const multiTurnConversationFn = new PythonFunction(
       this,
       "MultiTurnConversationFn",
       {
         entry: "../server",
         index: "multi_turn_conversation_handler.py",
         runtime: Runtime.PYTHON_3_12,
         architecture: Architecture.ARM_64,
         environment: {
           SESSIONS_TABLE_NAME: sessionsTable.tableName,
+          LANGCHAIN_TRACING_V2: "true",
+          LANGCHAIN_API_KEY: langChainApiKey,
         },
         bundling: {
           assetExcludes: [".venv", "__pycache__", "tests", "README.md"],
         },
         timeout: Duration.minutes(15),
       },
     );
     multiTurnConversationFn.addToRolePolicy(
       new PolicyStatement({
         effect: Effect.ALLOW,
         actions: ["bedrock:InvokeModel"],
         resources: ["*"],
       }),
     );

Lambda関数を実行してトレースを確認する

こちら と同様の会話を行ったあとのトレースを確認
会話履歴を読み込んで出力を生成していることが分かる

https://smith.langchain.com/public/c9218438-8252-41a3-b632-2f9bbeae472d/r

このスクラップは2ヶ月前にクローズされました