オブジェクトの広場はオージス総研グループのエンジニアによる技術発表サイトです

AI

はじめての自然言語処理

第29回 LangChain でマルチターン&タスクの Agent を作る
オージス総研 技術部 データエンジニアリングセンター
鵜野 和也
2023年10月24日

今回は LangChain のお話です。LangChain は LLM を用いたアプリケーションを作る為のフレームワークで非常に活発に開発が行われており、抽象化された部品を組み上げるスタイルで拡張性と柔軟性に優れています。機能も豊富で流行りモノだけに紹介記事も多数公開されてますので、本記事は少し違った角度から書いてみたいと思います。

1. はじめに

今回は LangChain を使って簡単なアプリを作ってみたいと思います。

logo

LangChain に関しては前述のとおり優良な情報が多数公開されているので、あまり詳しくは紹介しません。 この記事で扱うのは以下のようなコンポーネントになりますが一通り理解できている前提で記述します。

  • PromptTemplate : プロンプト文字列のテンプレート
  • LLM : ChatGPT 等をラップして抽象化
  • Chain : PromptTemplate と LLM を対にしたもの
  • Tool : Agent がネットや社内文書を利用するための道具
  • Memory : 会話の履歴を保持する箱
  • Agent : LLM, Tool を組み合わせて複雑な処理を実行

この辺りを押さえられていない方は Andrew Ng 先生の DeepLearning.AI が無料のショートコース1 2を公開してくださっているので、 そちらを見てみると良いかと思います(私も最初はコレを見て勉強しました)。

上記の中で皆さんが一番気になるのが Agent ではないかと思います。 ChatGPT に代表される LLM は豊富な知識をその膨大なパラメータの中に保持していますが、その知識は学習が完了した時点で止まっています。昨日のスポーツの試合の結果等の最新の出来事は把握できていません。 また、「2569 + 4425 x 22 / 5 は?」のような数値計算も苦手です。

それを補う為、必要に応じて Web 検索や数値計算用の関数等の外部機能を利用し、 その結果を LLM への入力に加えて回答を生成する手法が用いられるようになりました。ReAct3 がその代表格でしょうか。

LangChain の Agent はこの考え方を実装したものです。 Agent は LLM と Tool(“Web検索や数値計算用の関数等の外部機能"に相当) を保持しており、 LLM に Tool を使う必要性を判断させ、必要であれば Tool を実行して、その結果を LLM に投入する。ということを行ってくれます4

「詳しくは紹介しない」と言っておいてなんなのですが、Agent の内部の動きは見ていると面白いので、 本題に入る前に少しだけ紹介します。

2. Agent の内部的な挙動

それではここから、実際に LangChain を動かしていきます。今回も Colab で動かすのでノートブックを開いてください。 アクセラレータは不要です。LLM には ChatGPT を使うので OpenAI でサインアップして OpenAI の API KEY を取得しておいてください。

!pip install langchain==0.0.279 pydantic==1.10.12
!pip install openai

import os
from getpass import getpass
os.environ["OPENAI_API_KEY"] = getpass("openai API KEY: ")
# ...
# openai API KEY: ··········

内部的な挙動の題材として ConversationalChatAgent の動きを見てみることにしましょう。

ただ ConversationalChatAgent は振る舞いの紹介に使うだけで、アプリを作る時は別のタイプの Agent を使います。 なので肩の力を抜いて、ごゆるりとご覧ください。

まずはライブラリを import して、

from langchain.memory import ConversationBufferMemory
from langchain.chat_models import ChatOpenAI
from langchain.agents import AgentType, initialize_agent, tool
import langchain

そして ChatGPT の API を叩く LLM (のラッパー)です。特に指定しないと gpt-3.5-turbo が使用されるみたいです。 temperature は出力テキストの内容を安定させる為の設定ですね。

chat = ChatOpenAI(temperature=0)
chat.model_name
# gpt-3.5-turbo

次に会話履歴を保持するクラスです。memory_key に "chat_history” を設定しているのを覚えておいてください。

memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)

Agent が使う Tool の定義です。お試しなので「単語の文字数を数える関数」とします。

@tool
def get_word_length(word: str) -> int:
    """Returns the length of a word."""
    return len(word)

最後に用意した部品を組み上げて Agent の初期化です。AgentType は色々あるのですが ChatGPT のような会話形式で ReAct 的な振る舞いをする CHAT_CONVERSATIONAL_REACT_DESCRIPTION を使ってみます。

tools = [get_word_length]
agent = initialize_agent(tools, chat, agent=AgentType.CHAT_CONVERSATIONAL_REACT_DESCRIPTION, verbose=True, memory=memory)

それでは、この Agent が使用するプロンプトがどんな感じか見てみましょう。

プロンプトの構造

会話形式の Agent なのでプロンプトのクラスは ChatPromptTemplate になっています。

type(agent.agent.llm_chain.prompt)
# langchain.prompts.chat.ChatPromptTemplate

プロンプトのテンプレートに値を埋め込むプレースホルダとして定義されているのは以下の内容です。

agent.agent.llm_chain.prompt.input_variables
# ['input', 'chat_history', 'agent_scratchpad']

ChatPromptTemplate は複数メッセージのリスト構造になっています。

for message in agent.agent.llm_chain.prompt.messages:
  print(type(message))  
# <class 'langchain.prompts.chat.SystemMessagePromptTemplate'>
# <class 'langchain.prompts.chat.MessagesPlaceholder'>
# <class 'langchain.prompts.chat.HumanMessagePromptTemplate'>
# <class 'langchain.prompts.chat.MessagesPlaceholder'>

4つ要素が入っているので最初から順番に見ていきましょう。見やすいように適当に改行する出力関数を作っておきます。

import textwrap
def print_lines(text, w=80):
  for line in textwrap.wrap(text, width=w, replace_whitespace=False):
    print(line)

1. システムプロンプト

最初はシステムプロンプトです。ChatGPT を使うときの「あなたは〇〇の専門家です。」的なヤツですね。 思ったより沢山書いてあって引きました(トークン数の課金が…)。

print_lines(agent.agent.llm_chain.prompt.messages[0].prompt.template)

# Assistant is a large language model trained by OpenAI.
#
# Assistant is designed to
# be able to assist with a wide range of tasks, from answering simple questions to
# providing in-depth explanations and discussions on a wide range of topics. As a
# language model, Assistant is able to generate human-like text based on the input
# it receives, allowing it to engage in natural-sounding conversations and provide
# responses that are coherent and relevant to the topic at hand.
#
# Assistant is
# constantly learning and improving, and its capabilities are constantly evolving.
# It is able to process and understand large amounts of text, and can use this
# knowledge to provide accurate and informative responses to a wide range of
# questions. Additionally, Assistant is able to generate its own text based on the
# input it receives, allowing it to engage in discussions and provide explanations
# and descriptions on a wide range of topics.
# 
# Overall, Assistant is a powerful
# system that can help with a wide range of tasks and provide valuable insights
# and information on a wide range of topics. Whether you need help with a specific
# question or just want to have a conversation about a particular topic, Assistant
# is here to assist.

2. 会話履歴

次は会話の履歴ですね。variable_name に ‘chat_history’ が設定されています。 この 'chat_history’ をキーにして( memory_key に 'chat_history’ を設定した) ConversationBufferMemory を引っ掛け、 memory が保持しているメッセージを展開する仕組みです。

agent.agent.llm_chain.prompt.messages[1]
# MessagesPlaceholder(variable_name='chat_history')

3. ユーザー入力

3つめは LLM から見るとユーザー入力に相当する部分ですが、こちらも色々書いてあります。

書いてある内容的には、

  • Agent が利用可能な Tool の一覧とその説明(TOOLS)。
  • 出力フォーマットの指示(RESPONSE FORMAT INSTRUCTIONS)。"action" と “action_input” を属性とする json にせよとのこと。
    • Tool の利用が必要な場合: action は利用する Tool の名前、action_input はその入力パラメータ。
    • Tool の利用が必要でない場合: action は最終的な回答文であることを示す “Final Answer"、action_input は最終的な回答文。
  • ユーザー入力の内容(USER’S INPUT)。実際のユーザー入力文字列が ”{input}“ 部分にはめ込まれることになります。
print_lines(agent.agent.llm_chain.prompt.messages[2].prompt.template)
# TOOLS
# ------
# Assistant can ask the user to use tools to look up information that
# may be helpful in answering the users original question. The tools the human can
# use are:
# 
# > get_word_length: get_word_length(word: str) -> int - Returns the
# length of a word.
# 
# RESPONSE FORMAT INSTRUCTIONS
# ----------------------------
# When responding to me, please output a response in one of two formats:
#
# **Option
# 1:**
# Use this if you want the human to use a tool.
# Markdown code snippet
# formatted in the following schema:
# 
# ```json
# {{
#     "action": string, \ The
# action to take. Must be one of get_word_length
#     "action_input": string \ The
# input to the action
# }}
# ```
# 
# **Option #2:**
# Use this if you want to respond
# directly to the human. Markdown code snippet formatted in the following schema:
# ```json
# {{
#     "action": "Final Answer",
#     "action_input": string \ You should
# put what you want to return to use here
# }}
# ```
# 
# USER'S INPUT
# --------------------
# Here is the user's input (remember to respond with a
# markdown code snippet of a json blob with a single action, and NOTHING else):
# {input}

4. スクラッチパッド

4 つめはスクラッチパッドです。これはユーザーと Agent のやり取りの内容ではなく、 Agent と LLM の内部的なやり取りの内容を書き込む領域です。どんな内容が書き込まれるかは、これから具体例を示しますね。

agent.agent.llm_chain.prompt.messages[3]
MessagesPlaceholder(variable_name='agent_scratchpad')

では実際に動かして挙動を確認してみましょう。

動作確認

とりあえず会話してみます。

print(agent.run("Hello."))
# Hello! How can I assist you today?

print(agent.run("Please tell me about you."))
# I am Assistant, a large language model trained by OpenAI. 
# I am designed to assist with a wide range of tasks and provide information on various topics. 
# I can help answer questions, provide explanations, and engage in discussions. 
# How can I assist you today?

とくに当たり障りのないやりとりですね。

では次に Tool を使うような質問をしてみます。内部の挙動がわかるように langchain.debug = True にしておきましょう (ログ出力は繰り返しの内容を一部省略、見やすいように適当に改行を調整してます)。

langchain.debug =True
print(agent.run("I want to know the length of word 'beautiful'."))
# [chain/start] [1:chain:AgentExecutor] Entering Chain run with input:
# [inputs]
# [chain/start] [1:chain:AgentExecutor > 2:chain:LLMChain] Entering Chain run with input:
# [inputs]
# [llm/start] [1:chain:AgentExecutor > 2:chain:LLMChain > 3:llm:ChatOpenAI] Entering LLM run with input:
# {
#   "prompts": [
#     "System: Assistant is a ...省略... 'beautiful'." ★①
#   ]
# }
...
# [chain/end] [1:chain:AgentExecutor > 2:chain:LLMChain] [783ms] Exiting Chain run with output:
# {
#   "text": "{
#      \"action\": \"get_word_length\",
#      \"action_input\": \"beautiful\"
#   }" ★②
# }
# [tool/start] [1:chain:AgentExecutor > 4:tool:get_word_length] Entering Tool run with input: ★③
# "beautiful"
# [tool/end] [1:chain:AgentExecutor > 4:tool:get_word_length] [0ms] Exit Tool run with output: ★④
# "9"
# [chain/start] [1:chain:AgentExecutor > 5:chain:LLMChain] Entering Chain run with input:
# [inputs]
# [llm/start] [1:chain:AgentExecutor > 5:chain:LLMChain > 6:llm:ChatOpenAI] Entering LLM run with input:
# {
#   "prompts": [
#     "System: Assistant is a ...省略... and NOTHING else." ★⑤
#   ]
# }
...
# [chain/end] [1:chain:AgentExecutor > 5:chain:LLMChain] [971ms] Exiting Chain run with output:
# {
#   "text": "{
#     \"action\": \"Final Answer\",
#     \"action_input\": \"The length of the word 'beautiful' is 9.\"
#   }" ★⑥
# }
# [chain/end] [1:chain:AgentExecutor] [1.76s] Exiting Chain run with output:
# {
#   "output": "The length of the word 'beautiful' is 9."
# }
# The length of the word 'beautiful' is 9.

"The length of the word 'beautiful’ is 9.” と回答されました。正解ですね。

ユーザー視点では「"beautiful" は何文字ですか?」と聞いて Agent から「9文字です。」と回答される1往復ですが、 内部的には Agent と LLM の間で以下のような2往復のやり取りになっています。

  • ① : 1回目の LLM 呼び出し。
  • ② : LLM の回答。get_word_length を “beautiful” で呼び出すよう指示。
  • ③ : ② を受けて Agent が Tool を引数 “beautiful” で実行。
  • ④ : Tool の応答(“9”)。
  • ⑤ : ③、④ の Tool の入出力を踏まえて2回目の LLM 呼び出し。
  • ⑥ : LLM の回答。action として “Final Answer” が得られたので処理終了。

ここで、⑤の二回目の LLM への入力を見てみましょう (繰り返しの内容を一部省略、見やすいように適当に改行を調整してます)。

System: Assistant is a large language model ...省略... Assistant is here to assist.

Human: Hello.
AI: Hello! How can
I assist you today?
Human: Please tell me about you.
AI: I am Assistant, a large
language model trained by OpenAI. I am designed to assist with a wide range of
tasks, from answering questions to providing explanations and discussions on
various topics. I can help with information retrieval, generate text, and engage
in natural-sounding conversations. How can I assist you today?

Human: TOOLS
------
Assistant can ask the user to use tools to look up information that may
...省略...
USER'S INPUT
--------------------
Here is the
user's input (remember to respond with a markdown code snippet of a json blob
with a single action, and NOTHING else):

I want to know the length of word 'beautiful'.

AI: {
    "action": "get_word_length",
    "action_input": "beautiful"
}
Human: TOOL RESPONSE: 
---------------------
9

USER'S INPUT
--------------------

Okay, so what is the response to my last comment? If using
information obtained from the tools you must mention it explicitly without
mentioning the tool names - I have forgotten all TOOL RESPONSES! Remember to
respond with a markdown code snippet of a json blob with a single action, and
NOTHING else.

最初から順にみていきましょう。

  • 緑の文字はシステムプロントの固定文字列ですね。
  • 栗色(maroon)は、ここまでの会話履歴が展開された内容になっています。
  • 青がユーザ入力部分ですね。テンプレートの固定文字列の末尾にあった {input} プレースホルダに “I want to know the length of word 'beautiful’.” が展開されているのが確認できます。

赤がスクラッチパッドの内容ですが、少しトリッキーですね。

  • 1回目の LLM 呼び出しの回答(②)で得られた get_word_length を “beautiful” で実行しろという内容が AI(LLM) の回答として入れられています。
  • 次に Human (LLM 側からの視点でユーザー)が Tool の実行結果が 9 であったことを告げた上で、以下のような内容を付加しています。
    • 「で、私の最後のコメントについての回答は?」
    • 「Tool で得た情報を使うなら明示的に結果に言及してほしいけど Tool の名前は言わないで」
    • 「私は Tool の応答については忘れてしまったよ!」
    • 「json の markdown コードスニペットで回答するのを忘れないで!」

そんな訳なので LLM 側から見ると「僕(LLM)が 『get_word_length を使え』と言い、その結果は“9"だったらしいが、その内容("9"であったこと)を忘れたらしいので、それを踏まえて最後のコメント ( "I want to know the length of word 'beautiful’” ) への回答を json で答えないといけないのね。。。」となって、

{
  "action": "Final Answer",
  "action_input": "The length of the word 'beautiful' is 9."
}"

を出力した、という流れになります。

個人的には、何というか、よく考えたというか、回りくどいというか。。。

皆さんはご存じかと思いますが、Open AI の ChatGPT には LangChain の Agent 的な機能として Function Calling5 があります。 そして LangChain には Function Calling ベースで実装された OpenAIFunctionsAgent があり、こちらの方が振る舞いがシンプルなので、 ここからは OpenAIFunctionsAgent で進めていきます。

さて、ここから今回の本題です。まずはマルチターンの方からやってみましょう。

3. マルチターンの Agent

先程から「マルチターン」という言葉を使っているので、その説明から始めましょう。

LangChain の Agent は様々なタイプ6が存在しますが、会話形式のものも含めて全て、 「ユーザーに何か要求されたら、自分の知識と与えられた Tool を使って出来る限り回答する。」 という動きになっています7

つまりユーザーの要求への対応を 1-Shot というかシングルターンで解決する動きです。

ですが、実際の人間とのやり取りでは

  • 「さっきの資料を鈴木さんに送っといて。」
  • 「ええっと、どこの鈴木さんですか?」
  • 「経理チームの鈴木さんです。」

のようなやり取りが頻繁に発生しており、これを Agent でやってみようという話です。

そこで、どう実現しようか考えたのですが今回はシンプルにシステムプロンプトに仕込むことにしました。 お題は星占いです。

スイマセン、追記です!

この記事を書いた後で気付いたのですが、Human as a tool8 という人間を Tool として使うパターンもあるみたいですね、 これを使うともっとイイ感じで実現できるかもしれません。 ですが Agent と対話している人間との1対1のやりとりを想定すると、会話の経路が

  • agent.run() : Human → AI
  • HumanInputRun : AI → Human

の二系統になるので UI の作りが複雑になるかもしれませんね。。。(話をもとに戻します)。

星占い Agent

星占い Agent は単純に今日の運勢を占ってくれます。ただし星占いなので誕生日がわからない場合は、 「誕生日を教えてください」と応答するようにします9

前述のとおり、OpenAIFunctionsAgent ベースで実装します。システムプロンプトを確認すると、こんな感じです。

# https://github.com/langchain-ai/langchain/blob/v0.0.279/libs/langchain/langchain/agents/openai_functions_agent/base.py#L325
325            content="You are a helpful AI assistant."

先程の ConversationalChatAgent に比べると、すごくシンプルになりましたね。 ここに今回望む振る舞いをするようにシステムプロンプトを仕込んでいきます10

HOROSCOPE_SYSTEM_PROMPT='''あなたは星占いの専門家です。
星占いをしてその結果を回答します。

ただし星占いには誕生日が必要です。
もし誕生日が分からない場合は、誕生日を予測や仮定をせずに「星占いをするので誕生日を教えてください。」と回答して下さい。

誕生日がわかる場合は、例えば"4月24日"であれば"04/24"の形式に変換した上で horoscope 関数を使って占いを行って下さい。
'''

次に Tool の定義です。占いのロジックには Web ad Fortune 無料 API11 を利用させて頂きました。 誕生日の MM/DD 形式の文字列を受け取り、外部 API をコールしてその結果を文字列で返します。

外部 API 呼び出しの結果を LLM で編集せずに、そのまま Agent の回答とすることにしたので、return_direct=True を設定しました12

from pydantic import BaseModel, Field
from langchain.tools import tool
import datetime
import requests
import json

class HoroscopeInput(BaseModel):
    birthday: str = Field(description="'mm/dd'形式の誕生日です。例: 3月7日生まれの場合は '03/07' です。")

@tool("horoscope", return_direct=True, args_schema=HoroscopeInput)
def horoscope(birthday):
  """星占いで今日の運勢を占います。"""
  birthday = "02/28" if birthday == "02/29" else birthday
  yday = datetime.datetime.strptime(birthday, '%m/%d').timetuple().tm_yday
  sign_table = {
     20: '山羊座', 50: '水瓶座', 81: '魚座', 111: '牡羊座',  142: '牡牛座',
    174: '双子座', 205: '蟹座', 236: '獅子座',  267: '乙女座', 298: '天秤座',
    328: '蠍座', 357: '射手座', 999: '山羊座',
  }
  for k, v in sign_table.items():
    if yday < k:
      sign = v
      break

  t_delta = datetime.timedelta(hours=9)
  JST = datetime.timezone(t_delta, 'JST')
  today = datetime.datetime.now(JST).strftime('%Y/%m/%d')
  url = f"http://api.jugemkey.jp/api/horoscope/free/{today}"
  response = requests.get(url)
  horoscope = json.loads(response.text)["horoscope"][today]
  horoscope = {h["sign"]:h for h in horoscope}
  horoscope[sign]
  content = \
f'''今日の{sign}の運勢は...
・{horoscope[sign]["content"]}
・ラッキーアイテム:{horoscope[sign]["item"]}
・ラッキーカラー:{horoscope[sign]["color"]}'''
  return content

では、上記の Tool を使って Agent を組み立てます。

マルチターンで動作させるには LLM 呼び出し時のプロンプトに会話履歴を展開する必要があるので、 それを明示的に追加設定しています13

from langchain.prompts.chat import MessagesPlaceholder, SystemMessage
verbose = True
langchain.debug = verbose

gpt35 = ChatOpenAI(temperature=0, model="gpt-3.5-turbo-0613", model_kwargs={"top_p":0.1})
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
chat_history = MessagesPlaceholder(variable_name='chat_history')

horoscope_tools = [horoscope]

agent_kwargs = {
    "system_message" : SystemMessage(content=HOROSCOPE_SYSTEM_PROMPT),
    "extra_prompt_messages": [chat_history]
}
horoscope_agent = initialize_agent(
                    horoscope_tools, 
                    gpt35, 
                    agent=AgentType.OPENAI_FUNCTIONS,
                    verbose=verbose, 
                    agent_kwargs=agent_kwargs, 
                    memory=memory
)

では動かしてみましょう。

horoscope_agent.run("私の今日の運勢を教えて。")
# 星占いをするためには、あなたの誕生日が必要です。誕生日を教えてください。

想定通りの結果ですね。システムプロンプトが効いているので horoscope() を呼び出さずに Agent が返答してきました。 会話を続けてみましょう。

horoscope_agent.run("私の誕生日は3月3日です。")
# 今日の魚座の運勢は...
# ・細かいことを面倒に思ってサボっていると、足元をすくわれる予感。今日はとにかく、地道な努力を忘れないようにしましょう。
# ・ラッキーアイテム:マスカラ
# ・ラッキーカラー:レッド

だそうです。イイ感じですね。

langchain.debug = verbose にしておいたので以下のような詳細ログが出力されます。

  "generations": [
    [
      {
        "text": "",
        "generation_info": {
          "finish_reason": "function_call"
        },
        "message": {
          "lc": 1,
          "type": "constructor",
          "id": [
            "langchain",
            "schema",
            "messages",
            "AIMessage"
          ],
          "kwargs": {
            "content": "",
            "additional_kwargs": {
              "function_call": {  ...★
                "name": "horoscope",
                "arguments": "{\n  \"birthday\": \"03/03\"\n}"
              }
            }
          }
        }
      }
    ]
  ],

★ の “function_call” のところで、horoscope() 関数の呼び出しとその引数 { “birthday”: “03/03”} が LLM から提案されているのが確認できますね。 これを OpenAIFunctionsAgent が解釈・実行して、「今日の魚座の運勢は…」が得られた訳です。

return_direct = False の場合は、この実行結果を FunctionMessage にして LLM へのプロンプトに追加し再度 LLM を呼び出すことで、 最終的な出力テキストを得るか、更なる Tool 利用を提案されるか、という流れになります。

次はマルチタスクの話をしたいのですが、星占いの他に、もう一つタスクが必要になりますね。 今度はもう少し複雑な処理をしてみましょう。

プラモデル部品注文 Agent

今度はプラモデルの部品注文の受付です。完全に個人の趣味の世界ですね。。。 最近はネット通販的な注文もできたりしますけど、部品代を郵便小為替にして郵送とか、けっこう面倒くさいんですよ14

「部品注文したい商品と欲しい部品の番号、個数と送付先を指定し、最後に『この内容でよいですか?』 と確認して OK 出したら注文する。」という仕様で行きたいと思います。

最初は先程の星占いと同じノリでやってみたのですが、どうもうまくいきません。 注文に必要な情報が不足している場合に「この情報も必要ですよ」という内容をシステムプロンプトを駆使して LLM に生成させたのですが、 出力される内容とフォーマットが不安定になってしまって(ちなみに gpt-3.5-turbo-0613 しばりでトライしてます)。

仕方ないので作戦変更し、LLM は会話内容から部品注文に関する情報を切り出すことに集中させて、 実際の処理は Python の関数の中で全て行う方式としました。

この辺りの判断は今後の LLM の発展、実装したい仕様、コストとの兼ね合いでいろいろと変わっていくところかもしれませんね。

という訳でシステムプロンプトです。注文に必要な情報を定義して、会話内容から抽出させますが、 わからない場合は "***" とするよう指示します。gpt-3.5-turbo-0613 のレベルだと「わからない場合に使う値」 を明示的に提示してあげるのが具合よさそうでした。

PARTS_ORDER_SYSTEM_PROMPT='''あなたはプラモデルの部品の個別注文を受け付ける担当者です。
部品を注文するには以下の注文情報が全て必要です。


注文情報
--------
* 注文される方のお名前 :
* 注文される方のお名前(カタカナ) :
* 部品送付先の郵便番号 :
* 部品送付先の住所 :
* 注文される方のお電話番号 :
* 注文される方のメールアドレス :
* 部品注文の対象となる商品の名称 :
* 部品注文の対象となる商品の番号 :
* 注文する部品とその個数 :


部品送付先の住所に関する注意
----------------------------
また、住所に関しては番地まで含めた正確な情報が必要です。
「東京都」や「大阪府豊中市」などあいまいな場合は番地まで必要であることを回答に含めて下さい。


あなたの取るべき行動
--------------------
* 注文情報に未知の項目がある場合は予測や仮定をせず、"***" に置き換えた上で、
  把握している注文情報を parts_order 関数に設定し confirmed = false で実行して下さい。
* あなたの「最終確認です。以下の内容で部品を注文しますが、よろしいですか?」の問いかけに対して、
  ユーザーから肯定的な返答が確認できた場合のみ parts_order 関数を confirmed = true で実行し部品の注文を行って下さい。
* ユーザーから部品の注文の手続きをやめる、キャンセルする意思を伝えられた場合のみ、
  parts_order 関数を canceled = true で実行し、あなたはそれまでの部品の注文に関する内容を全て忘れます。

parts_order 関数を実行する際の注文する部品とその個数の扱い
----------------------------------------------------------
また、parts_order 関数を実行する際、注文する部品とその個数は part_no_and_quantities に設定して下さい。

part_no_and_quantities は注文する部品とその個数の表現する dict の list です。
list の要素となる各 dict は key として 'part_no' と 'quantity' を持ちます。
'part_no' の value が部品名称の文字列、'quantity'の value が個数を意味する数字の文字列です。
以下は部品'J-26'を2個と部品'デカールC'を1枚注文する場合の part_no_and_quantities です。

\u0060\u0060\u0060
[{"part_no": "J-26", "quantity": "2"}, {"part_no": "デカールC", "quantity": "1"}]
\u0060\u0060\u0060

'''
  • 補足: 上記の “\u0060” は “`” です。Markdown の原稿をHTMLにレンダリングする都合で Unicode コードポイントで記述してますが、普通に書いてもらって大丈夫です。

つぎに Tool の定義です。parts_order 関数は注文情報と最終確認の有無、キャンセル意思の有無を引数とします。 これで注文処理における全ての状態が把握できるので、あとはロジックで処理してしまう感じです。

from pydantic import BaseModel, Field
from typing import Union
from langchain.tools import tool
import datetime
import requests
import json

class PartsOrderInput(BaseModel):
    name: str = Field(description="注文される方のお名前です。")
    kana: str = Field(description="注文される方のお名前(カタカナ)です。")
    post_code: str = Field(description="部品送付先の郵便番号です。")
    address: str = Field(description="部品送付先の住所です。")
    tel: str = Field(description="注文される方のお電話番号です。")
    email: str = Field(description="注文される方のメールアドレスです。")
    product_name: str = Field(description="部品注文の対象となる商品の名称です。例:'PG 1/24 ダンバイン'")
    product_no: str = Field(description="部品注文の対象となる商品の箱や説明書に記載されている6桁の数字の文字列です。")
    part_no_and_quantities: list[dict[str, str]] = Field(description=(
        '注文する部品とその個数の表現する dict の list です。\n'
        'dict は key "part_no"の value が部品名称の文字列、key "quantity"の value が個数を意味する整数です。\n'
        '例: 部品"J-26"を2個と部品"デカールC"を1枚注文する場合は、\n'
        '\n'
        '[{"part_no": "J-26", "quantity": 2}, {"part_no": "デカールC", "quantity": 1}]\n'
        '\n'
        'としてください。'))
    confirmed: bool = Field(description=(
        "注文内容の最終確認状況です。最終確認が出来ている場合は True, そうでなければ False としてください。\n"
        "* confirmed が True の場合は部品の注文が行われます。 \n"
        "* confirmed が False の場合は注文内容の確認が行われます。")
    )
    canceled: bool = Field(description=(
        "注文の手続きを継続する意思を示します。\n"
        "通常は False としますがユーザーに注文の手続きを継続しない意図がある場合は True としてください。\n"
        "* canceled が False の場合は部品の注文手続きを継続します。 \n"
        "* canceled が True の場合は注文手続きをキャンセルします。")
    )


@tool("parts_order", return_direct=True, args_schema=PartsOrderInput)
def parts_order(
    name: str,
    kana: str,
    post_code: str,
    address: str,
    tel: str,
    email: str,
    product_name: str,
    product_no: str,
    part_no_and_quantities: list[dict[str, str]],
    confirmed : bool,
    canceled : bool,
) -> str:
  """プラモデルの部品を紛失、破損した場合に必要な部品を個別注文します。注文の内容確認にも使用します"""
  if canceled:
    return "わかりました。また部品の注文が必要になったらご相談ください。"

  def check_params(name, kana, post_code, address, tel, email, product_name,
                   product_no, part_no_and_quantities):

    for arg in [name, kana, post_code, address, tel, email, product_name, product_no]:
      if arg is None or arg == "***" or arg == "":
        return False
    if not part_no_and_quantities:
        return False

    for p_and_q in part_no_and_quantities:
      part_no = p_and_q.get('part_no', '***')
      quantity = p_and_q.get('quantity', '***')
      if part_no == '***':
        return False
      if quantity == '***':
        return False
    return True

  has_required = check_params(name, kana, post_code, address, tel, email, product_name,
                              product_no, part_no_and_quantities)

  if part_no_and_quantities:
    part_no_and_quantities = "\n    ".join(
      [f"{row.get('part_no','***')} x {row.get('quantity','***')}"
      for row in part_no_and_quantities]
    )
  else:
    part_no_and_quantities = "    ***"

  # 注文情報のテンプレート
  order_template=(
    f'・お名前: {name}\n'
    f'・お名前(カナ): {kana}\n'
    f'・郵便番号: {post_code}\n'
    f'・住所: {address}\n'
    f'・電話番号: {tel}\n'
    f'・メールアドレス: {email}\n'
    f'・商品名: {product_name}\n'
    f'・商品番号: {product_no}\n'
    f'・ご注文の部品\n'
    f'    {part_no_and_quantities}'
  )

  # 追加情報要求のテンプレート
  request_information_template=(
    f'ご注文には以下の情報が必要です。"***" の項目を教えてください。\n'
    f'\n'
    f'{order_template}'
  )

  # 注文確認のテンプレート
  confirm_template=(
    f'最終確認です。以下の内容で部品を注文しますが、よろしいですか?\n'
    f'\n{order_template}'
  )

  # 注文完了のテンプレート
  complete_template=(
    f'以下の内容で部品を注文しました。\n'
    f'\n{order_template}\n'
    f'\n2営業日以内にご指定のメールアドレスに注文確認メールが届かない場合は、\n'
    f'弊社HPからお問い合わせください。'
  )

  if has_required and confirmed:
    # TODO invoke order here!
    return complete_template
  else:
    if has_required:
      return confirm_template
    else:
      return request_information_template

ポイントとしては以下の二点でしょうか。

  • 未指定の情報("***") がある場合に情報の提示を求めるのですが、parts_order 関数の出力をそのままユーザーに渡したいので、return_direct=True にしています。
  • 最後の最終確認の処理ですが confirmed == True であっても注文情報に "***" が残存している場合は情報の提示を求める処理に流しています。LLM の判断にはゆれや間違いが普通に入るので、「少々踏み外しても出来るだけ意図通りに動く」ことを意識した実装が重要そうです15

今回は必ず parts_order 関数を呼んで欲しいので、function_call パラメータに関数名を仕込んでおきます。

parts_order_tools = [parts_order]

memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)

gpt35_po = ChatOpenAI(temperature=0, model="gpt-3.5-turbo-0613",
                      model_kwargs={"top_p":0.1, "function_call": {"name": "parts_order"}})

gpt35_po._default_params
# {'model': 'gpt-3.5-turbo-0613',
#  'request_timeout': None,
#  'max_tokens': None,
#  'stream': False,
#  'n': 1,
#  'temperature': 0.0,
#  'top_p': 0.1,
#  'function_call': {'name': 'parts_order'}}

先程と同様に Agent を組み上げます。

agent_kwargs = {
    "system_message" : SystemMessage(content=PARTS_ORDER_SYSTEM_PROMPT),
    "extra_prompt_messages": [chat_history]
}
parts_order_agent = initialize_agent(
                      parts_order_tools, 
                      gpt35_po,
                      agent=AgentType.OPENAI_FUNCTIONS,
                      verbose=verbose,
                      agent_kwargs=agent_kwargs,
                      memory=memory
)

この状態で試すとイマイチ思った通りに動きません。会話が長くなってくるとシステムプロンプトの効きが悪くなる気がしたので、 反則技かもしれませんが、LLM に投入するプロンプトの最後に「追いシステムプロンプトw」を入れました。

PARTS_ORDER_SSUFFIX_PROMPT='''

重要な注意事項
--------------
注文情報に未知の項目がある場合は予測や仮定をせず "***" に置き換えてください。

parts_order 関数はユーザーから部品の注文の手続きをやめる、キャンセルする意思を伝えられた場合のみ canceled = true で実行して、
それまでの部品の注文に関する内容を全て忘れてください。。

parts_order 関数は次に示す例外を除いて confirmed = false で実行してください。

あなたの「最終確認です。以下の内容で部品を注文しますが、よろしいですか?」の問いかけに対して、
ユーザーから肯定的な返答が確認できた場合のみ parts_order 関数を confirmed = true で実行して部品を注文してください。

最終確認に対するユーザーの肯定的な返答なしで parts-order 関数を confirmed = true で実行することは誤発注であり事故になるので、固く禁止します。
'''

messages = []
messages.extend(parts_order_agent.agent.prompt.messages[:3])
messages.append(SystemMessage(content=PARTS_ORDER_SSUFFIX_PROMPT))
messages.append(parts_order_agent.agent.prompt.messages[3])
parts_order_agent.agent.prompt.messages = messages

最終的なプロンプトの状態はこんな感じです。

for m in parts_order_agent.agent.prompt.messages:
  print(type(m))
# <class 'langchain.schema.messages.SystemMessage'>             # システムプロンプト
# <class 'langchain.prompts.chat.MessagesPlaceholder'>          # 会話履歴
# <class 'langchain.prompts.chat.HumanMessagePromptTemplate'>   # ユーザー入力
# <class 'langchain.schema.messages.SystemMessage'>             # 追いシステムプロンプト
# <class 'langchain.prompts.chat.MessagesPlaceholder'>          # スクラッチパッド

では動かしてみましょう。

response = parts_order_agent.run("PG 1/24 ダンバインの部品を紛失したので注文したいです。")
print(response)
# ご注文には以下の情報が必要です。"***" の項目を教えてください。
# 
# ・お名前: ***
# ・お名前(カナ): ***
# ・郵便番号: ***
# ・住所: ***
# ・電話番号: ***
# ・メールアドレス: ***
# ・商品名: PG 1/24 ダンバイン
# ・商品番号: ***
# ・ご注文の部品
#         ***

検出された項目のみ値が入り、残りは"***" なので想定通りですね。 必要な情報を追加してみます。

response = parts_order_agent.run("名前が座間 翔でフリガナがザマ ショウ、商品番号は12345です。")
print(response)
# ご注文には以下の情報が必要です。"***" の項目を教えてください。
# 
# ・お名前: 座間 翔
# ・お名前(カナ): ザマ ショウ
# ・郵便番号: ***
# ・住所: ***
# ・電話番号: ***
# ・メールアドレス: ***
# ・商品名: PG 1/24 ダンバイン
# ・商品番号: 12345
# ・ご注文の部品
#         ***

先程までの内容に今回の情報がマージされて出力されています。LLM に投入されるプロンプトには会話履歴が展開されているので、 そこから商品名の情報を抽出する形になります。

残りの情報も全部追加してみます。

response = parts_order_agent('''以下のとおりです。
・郵便番号: 999-9999
・住所: 大阪市西区千代崎3丁目南2番37号
・電話番号: 99-9999-9999
・メールアドレス: sho.zama@example.com
・部品と個数: J-14を1個、A-2を2個
''')
print(response)
# 最終確認です。以下の内容で部品を注文しますが、よろしいですか?
# 
# ・お名前: 座間 翔
# ・お名前(カナ): ザマ ショウ
# ・郵便番号: 999-9999
# ・住所: 大阪市西区千代崎3丁目南2番37号
# ・電話番号: 99-9999-9999
# ・メールアドレス: sho.zama@example.com
# ・商品名: PG 1/24 ダンバイン
# ・商品番号: 12345
# ・ご注文の部品
#     J-14 x 1
#     A-2 x 2"

部品の種類と個数の処理が気になっていましたが、ちゃんと認識されています。ちゃんと最終確認も挟んでくれました。

あとは最終確認ですが、素直に OK せずに少し意地悪しましょう。「もう一つ」というのは少し難しいはずです。

response = parts_order_agent.run("すみません、J-14 をもう一つお願いします。")
print(response)
# 最終確認です。以下の内容で部品を注文しますが、よろしいですか?
# 
# ・お名前: 座間 翔
# ・お名前(カナ): ザマ ショウ
# ・郵便番号: 999-9999
# ・住所: 大阪市西区千代崎3丁目南2番37号
# ・電話番号: 99-9999-9999
# ・メールアドレス: sho.zama@example.com
# ・商品名: PG 1/24 ダンバイン
# ・商品番号: 12345
# ・ご注文の部品
#     J-14 x 1
#     J-14 x 1
#     A-2 x 2

おや、"J-14 x 1" が二回でてますね。なるほど、そうきましたか。。。 この対応は parts_order 関数の中で同じ部品をマージして “J-14 x 2” で出力するようにした方が簡単そうですね。 LLM の能力が足らないところをロジックでフォローできるような作りが良さそうです。

とはいえ、ちゃんと「最終確認での注文内容の修正を受けて再度確認する」という望ましい振る舞いが出来ています。 内容的には問題ないので最終確認に OK を出しましょう。

response = parts_order_agent.run("それでお願いします。")
print(response)
# 以下の内容で部品を注文しました。
# 
# ・お名前: 座間 翔
# ・お名前(カナ): ザマ ショウ
# ・郵便番号: 999-9999
# ・住所: 大阪市西区千代崎3丁目南2番37号
# ・電話番号: 99-9999-9999
# ・メールアドレス: sho.zama@example.com
# ・商品名: PG 1/24 ダンバイン
# ・商品番号: 12345
# ・ご注文の部品
#     J-14 x 1
#     J-14 x 1
#     A-2 x 2
# 
# 2営業日以内にご指定のメールアドレスに注文確認メールが届かない場合は、
# 弊社HPからお問い合わせください。

とりあえず意図したようには動いていますね。 お金の話はどうするんだって話はありますが、そこは注文確認メールに請求関係の情報が入っている脳内設定でクリアーです。

これでマルチターンのタスクが二つできました。ですが、本来やりたかったのは一つの入り口で複数のタスクに対応するマルチタスクの Agent です。

そこで、もう一工夫してみましょう。

4. マルチターン&マルチタスクの Agent

さて、もともとのお題である「マルチターン&マルチタスクの Agent」で目指していたのは、

  • 単一の入り口で複数のタスクに対応する。
  • 単一のタスクはユーザと複数回のやり取り(マルチターン)をすることがある。
  • あるタスクのやりとりの間に別タスクの処理を挟んでも自然に対応し、元タスクに復帰できる。
  • 対応するタスクの数を増やした場合に既存タスクの動作の安定性を損ねない。

といった内容です。

まず「単一の入り口」にする為、ここまでに用意した複数のシングルタスク Agent をひとまとめにしないといけません。 一番シンプルなのは単一 Agent に horoscope 関数と parts_order 関数の二つの Tool を使い分けさせるパターンだと思うのですが、 幾つか気になることがあります。

  • システムプロンプトに各タスク向けの内容を列挙して想定どおり動くのか?
  • プラモデル部品注文 Agent は LLM を「必ず parts_order 関数を呼ぶ」設定とした。タスク毎に LLM の設定を変えたい場合はどうしたらよいのか?
  • 新たなタスクを追加したくなったとき、既存タスクの処理に悪影響がでないか?

これらの懸念を回避する為、今回は以下のようなアーキテクチャを考えました。

architecture

ざっくり説明すると、

  • サポートするタスク毎に専用の Agent(便宜上、これを Worker Agent と呼びます)を用意する。
  • 前段に会話内容に応じて適切な Worker Agent に処理を委譲する Dispatcher Agent を配置する。
  • ユーザーとの会話履歴は Dispatcher Agent が一元的に管理する。
  • 各 Worker Agent は適切な対応をするために会話履歴の全体を把握する必要がある為、Dispatcher Agent が管理する memory の読み取り専用コピーを参照する。
  • 会話内容に適した Worker Agent がなければ、デフォルトの Chain で対応する。

といった具合です。これなら新規タスクを追加した時に既存への影響は最小限に抑えられますし、タスク毎に異なる設定の LLM を使えますし、システムプロンプトも書き放題です。

最初の試作では、Dispatcher Agent とデフォルトの Chain を単一の OpenAIFunctionsAgent で実装していたのですが、 gpt-3.5-turbo-0613 だと期待どおりに動いてくれないので、分割し、Worker Agent への振り分け専用のカスタム Agent を自作して、 Dispatcher Agent としました。

とりあえず、細かい部品を一旦作り直しましょう。ReadOnlySharedMemory が新登場ですが名前で想像つきますね。

from langchain.memory import ReadOnlySharedMemory
gpt35 = ChatOpenAI(temperature=0, model="gpt-3.5-turbo-0613", model_kwargs={"top_p":0.1})
memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True)
readonly_memory = ReadOnlySharedMemory(memory=memory)
chat_history = MessagesPlaceholder(variable_name='chat_history')

ここまでに作った Worker Agent が参照するメモリを読み取り専用のものに置き換えるので、 改めて初期化しなおします。memory=readonly_memory になっているのが変更点(★)ですね。

agent_kwargs = {
    "system_message" : SystemMessage(content=HOROSCOPE_SYSTEM_PROMPT),
    "extra_prompt_messages": [chat_history]
}
horoscope_agent = initialize_agent(
                    horoscope_tools, 
                    gpt35, 
                    agent=AgentType.OPENAI_FUNCTIONS,
                    verbose=verbose, 
                    agent_kwargs=agent_kwargs, 
                    memory=readonly_memory # ★
)

agent_kwargs = {
    "system_message" : SystemMessage(content=PARTS_ORDER_SYSTEM_PROMPT),
    "extra_prompt_messages": [chat_history]
}
parts_order_agent = initialize_agent(
                      parts_order_tools, 
                      gpt35_po,
                      agent=AgentType.OPENAI_FUNCTIONS,
                      verbose=verbose,
                      agent_kwargs=agent_kwargs, 
                      memory=readonly_memory # ★
)

messages = []
messages.extend(parts_order_agent.agent.prompt.messages[:3])
messages.append(SystemMessage(content=PARTS_ORDER_SSUFFIX_PROMPT))
messages.append(parts_order_agent.agent.prompt.messages[3])
parts_order_agent.agent.prompt.messages = messages

星占いとプラモデル部品注文以外の会話に対応するための Chain も定義しておきましょう。

from langchain.chains.llm import LLMChain
from langchain.prompts.chat import ChatPromptTemplate, HumanMessagePromptTemplate, PromptTemplate

DEFAULT_SYSTEM_PROMPT='''あなたはAIのアシスタントです。
ユーザーの質問に答えたり、議論したり、日常会話を楽しんだりします。
'''

chat_prompt_template = ChatPromptTemplate.from_messages([
    SystemMessage(content=DEFAULT_SYSTEM_PROMPT),
    chat_history,
    HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['input'], template='{input}'))
])

default_chain = LLMChain(llm=gpt35, prompt=chat_prompt_template, memory=readonly_memory, verbose=verbose)

ではここから Dispatcher Agent をカスタム Agent として実装していきます。

Dispatcher Agent の実装

Dispatcher Agent は以下のような実装としました。

from langchain.agents import BaseSingleActionAgent,  Tool,  AgentExecutor
from langchain.chat_models.base import BaseChatModel
from langchain.schema import (
    AgentAction,
    AgentFinish,
    BaseOutputParser,
    OutputParserException
)
from pydantic import Extra, BaseModel, Field
from typing import Any, List, Tuple, Set

ROUTER_TEMPLATE='''あなたの仕事はユーザーとあなたとの会話内容を読み、
以下の選択候補からその説明を参考にしてユーザーの対応を任せるのに最も適した候補を選び、その名前を回答することです。
あなたが直接ユーザーへ回答してはいけません。あなたは対応を任せる候補を選ぶだけです。

<< 選択候補 >>
名前: 説明
{destinations}

<< 出力形式の指定 >>
選択した候補の名前のみを出力して下さい。
注意事項: 出力するのは必ず選択候補として示された候補の名前の一つでなければなりません。
ただし全ての選択候補が不適切であると判断した場合には "DEFAULT" とすることができます。

<< 回答例 >>
「あなたについて教えて下さい。」と言われても返事をしてはいけません。
選択候補に適切な候補がないケースですから"DEFAULT"と答えて下さい。

'''

ROUTER_PROMPT_SUFFIX='''<< 出力形式の指定 >>
最後にもう一度指示します。選択した候補の名前のみを出力して下さい。
注意事項: 出力は必ず選択候補として示された候補の名前の一つでなければなりません。
ただし全ての選択候補が不適切であると判断した場合には "DEFAULT" とすることができます。
'''

class DestinationOutputParser(BaseOutputParser[str]):
  destinations: Set[str]
  class Config:
    extra = Extra.allow

  def __init__(self, **kwargs):
    super().__init__(**kwargs)
    self.destinations_and_default = list(self.destinations) + ["DEFAULT"]

  def parse(self, text: str) -> str:
    matched = [int(d in text) for d in self.destinations_and_default]
    if sum(matched) != 1:
      raise OutputParserException(
        f"DestinationOutputParser expected output value includes "
        f"one(and only one) of {self.destinations_and_default}. "
        f"Received {text}."
    )

    return self.destinations_and_default[matched.index(1)]

  @property
  def _type(self) -> str:
    return "destination_output_parser"

class DispatcherAgent(BaseSingleActionAgent):

  chat_model: BaseChatModel
  readonly_memory: ReadOnlySharedMemory
  tools: List[Tool]
  verbose: bool = False

  class Config:
    extra = Extra.allow

  def __init__(self, **kwargs):
    super().__init__(**kwargs)
    destinations = "\n".join([f"{tool.name}: {tool.description}" for tool in self.tools])
    router_template = ROUTER_TEMPLATE.format(destinations=destinations)
    router_prompt_template = ChatPromptTemplate.from_messages([
      SystemMessage(content=router_template),
      MessagesPlaceholder(variable_name='chat_history'),
      HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['input'], template='{input}')),
      SystemMessage(content=ROUTER_PROMPT_SUFFIX)
    ])
    self.router_chain = LLMChain(
      llm=self.chat_model,
      prompt=router_prompt_template,
      memory=self.readonly_memory,
      verbose=self.verbose
    )

    self.route_parser = DestinationOutputParser(
        destinations=set([tool.name for tool in self.tools])
    )

  @property
  def input_keys(self):
    return ["input"]

  def plan(
    self, intermediate_steps: List[Tuple[AgentAction, str]], **kwargs: Any
  ) -> Union[AgentAction, AgentFinish]:

    router_output = self.router_chain.run(kwargs["input"])
    try:
      destination = self.route_parser.parse(router_output)
    except OutputParserException as ope:
      destination = "DEFAULT"

    return AgentAction(tool=destination, tool_input=kwargs["input"], log="")

  async def aplan(
    self, intermediate_steps: List[Tuple[AgentAction, str]], **kwargs: Any
  ) -> Union[AgentAction, AgentFinish]:

    router_output = await self.router_chain.arun(kwargs["input"])
    try:
      destination = self.route_parser.parse(router_output)
    except OutputParserException as ope:
      destination = "DEFAULT"

    return AgentAction(tool=destination, tool_input=kwargs["input"], log="")

処理の実体は適切な Worker Agent を選択する LLMChain です。

LLMChain の出力は horoscope, parts_order のような名称のみではなく、 「horoscope が良いでしょう。」のように余分な情報が付きがちなので、 ある程度寛容な味付けにした OutputParser も用意しました。

処理を委譲する Worker Agent (destination) が決まれば、 AgentAction の Tool に設定し、tool_input はユーザー入力そのものとして return すれば OK です。

Worker Agent に input 以外の入力がある場合はどうするか? 今回は、面倒なので無視していますが、 self.tools から掘り起こして、input_keys() の返り値は最大公約数的に、 AgentAction の tool_input は選択された Worker Agent に合わせて動的に制御するとかですかね。

あと、非同期の実装(aplan)も書きましたが全く試してません!(スミマセン)

Dispatcher Agent の初期化

ようやく必要な部品の準備ができたので、組み立てましょう。

各種 Worker Agent の run() を Dispatcher Agent の Tool として初期化します。

class HoroscopeAgentInput(BaseModel):
  user_utterance: str = Field(description="星占いの専門家に伝達するユーザーの直近の発話内容です。")

class PartsOrderAgentInput(BaseModel):
  user_utterance: str = Field(description="プラモデルの部品の個別注文の担当者に伝達するユーザーの直近の発話内容です。")

class DefaultAgentInput(BaseModel):
  user_utterance: str = Field(description="一般的な内容を担当する担当者に伝達するユーザーの直近の発話内容です。")

tools = [
  Tool.from_function(
        func=horoscope_agent.run,
        name="horoscope_agent",
        description="星占いの担当者です。星占いに関係する会話の対応はこの担当者に任せるべきです。",
        args_schema=HoroscopeAgentInput,
        return_direct=True
  ),
  Tool.from_function(
        func=parts_order_agent.run,
        name="parts_order_agent",
        description="プラモデルの部品の個別注文の担当者です。プラモデルの部品注文やキャンセルに関係する会話の対応はこの担当者に任せるべきです。",
        args_schema=PartsOrderAgentInput,
        return_direct=True
  ),
  Tool.from_function(
        func=default_chain.run,
        name="DEFAULT",
        description="一般的な会話の担当者です。一般的で特定の専門家に任せるべきでない会話の対応はこの担当者に任せるべきです。",
        args_schema=DefaultAgentInput,
        return_direct=True
  ),
]

dispatcher_agent = DispatcherAgent(chat_model=gpt35, readonly_memory=readonly_memory, tools=tools, verbose=verbose)

agent = AgentExecutor.from_agent_and_tools(
    agent=dispatcher_agent, tools=tools, memory=memory, verbose=verbose
)

それでは、実際に動かしてみます。

5. 動作確認

まずは日常会話からはじめます。とりあえず自己紹介してもらいましょう。

  • 以後、ログの改行は見やすさ優先で修正してます。
  • Worker Agent の出力は、星占いが赤、部品注文が紫、デフォルトが青で色分けしています。
response = agent.run("こんにちは。あなたについて教えて下さい。")
# ...
# chain/end] [1:chain:LLMChain] [666ms] Exiting Chain run with output:
# {
#   "text": "DEFAULT"
# }
# ...
print_lines(response)
# こんにちは!私はAIのアシスタントです。あなたの質問に答えたり、議論したり、日常会話を楽しん
# だりすることができます。私は自然言語処理と機械学習の技術を使っています。どのようにお手伝い
# できますか?

ログを確認すると Dispatcher Agent が “DEFAULT” を選択したのがわかります。 最後の回答は default_chain の出力そのものですね。

次に星占いをしてみましょう。

response = agent.run("私の今日の運勢が知りたいです。")
# ...
# [chain/end] [1:chain:LLMChain] [631ms] Exiting Chain run with output:
# {
#   "text": "horoscope_agent"
# }
# [tool/start] [1:chain:AgentExecutor > 2:tool:horoscope_agent] Entering Tool run with 
# input:
# "私の今日の運勢が知りたいです。"
# [chain/start] [1:chain:AgentExecutor > 2:tool:horoscope_agent > 3:chain:AgentExecutor] 
# Entering Chain run with input:
# [inputs]
# [llm/start] [1:chain:AgentExecutor > 2:tool:horoscope_agent > 3:chain:AgentExecutor > 
# 4:llm:ChatOpenAI] Entering LLM run with input:
# {
#   "prompts": [
#     "System: あなたは星占いの専門家です。...省略...horoscope 関数を使って占いを行って下さい。
#      Human: こんにちは。あなたについて教えて下さい。
#      AI: こんにちは!私はAIのアシスタントです。...どのようにお手伝いできますか?
#      Human: 私の今日の運勢が知りたいです。"
#   ]
# }
# ...
print_lines(response)
# 了解しました。それでは、あなたの誕生日を教えていただけますか?

horoscope_agent が選択されています。horoscope_agent が LLM を呼び出すプロンプトに展開されている会話履歴をチェックすると、 最初の「あなたについて教えて下さい。」の default_chain に振られたやり取りも含まれており、 horoscope_agent が会話全体を参照できていることがわかります。

とりあえず、占いを完結させましょう。

response = agent.run("私の誕生日は3月18日です。")
print_lines(response)
# 今日の魚座の運勢は...
# ・人のために、何かをしてあげたい気持ちが高まる日。困っている人を見つけたら積極的にお手伝いを。
# ・ラッキーアイテム:ジャムパン
# ・ラッキーカラー:ゴールド

大丈夫そうですね。さらに別の話題を振ってみます。

response = agent.run("今日は天気がよくありませんね。")
print_lines(response)
# そうですね、天気が悪い日は気分も少し沈みがちですよね。でも、天気に左右されずに楽しいこと
# を見つけることもできますよ。例えば、家で好きな本を読んだり、映画を観たり、美味しい食べ物
# を作ったりするのも良いですね。また、雨の日はゆっくりと過ごすのにも最適です。リラックスし
# て、自分の好きなことに時間を使ってみてください。

なるほどですね。ここで話を運勢に戻します。

response = agent.run("今日は友達が大事な試験です。なので彼の今日の運勢が気になります。")
print_lines(response)
# 了解しました。友達の誕生日を教えていただけますか?

ちなみに、これは上手く動いたパターンで何回か試すとしじくる時もありました16

response = agent.run("友達の誕生日は8月21日です。")
print_lines(response)
# 今日の獅子座の運勢は...
# ・どうすればいいか迷った時には、過去を振り返って。経験が、ピンチの打開策を教えてくれます。
# ・ラッキーアイテム:豆乳クッキー
# ・ラッキーカラー:ブルー

再び世間話に戻します。

response = agent.run("タイ焼きって好きですか?")
print_lines(response)
# 私はAIなので、味覚を持っていないので好き嫌いはありません。ただし、タイ焼きは多くの人に
# 人気のある料理です。甘さともちもちした食感が特徴で、美味しいと評判です。あなたはタイ焼
# きが好きですか?

問いかけをガン無視して、なくしたガンプラの部品を注文します。

response = agent.run("そんなことより、プラモデルの部品をなくしたので注文したいです。"
                     "MG ゼータガンダム ver Ka の F-28 を一個お願いします。")
print(response)
# ご注文には以下の情報が必要です。"***" の項目を教えてください。
# 
# ・お名前: ***
# ・お名前(カナ): ***
# ・郵便番号: ***
# ・住所: ***
# ・電話番号: ***
# ・メールアドレス: ***
# ・商品名: MG ゼータガンダム ver Ka
# ・商品番号: ***
# ・ご注文の部品
#     F-28 x 1

商品名と部品の種類、個数がちゃんと認識されました。残りの情報も伝えましょう。

response = agent.run('''残りの情報は以下のとおりです。
・名前: 山田 一郎
・フリガナ: ヤマダ イチロウ
・郵便番号: 999-9999
・住所: 大阪市西区千代崎3丁目南2番37号
・電話番号: 99-9999-9999
・メールアドレス: sho.zama@example.com
・商品番号:24156
''')
print(response)
# 最終確認です。以下の内容で部品を注文しますが、よろしいですか?
# 
# ・お名前: 山田 一郎
# ・お名前(カナ): ヤマダ イチロウ
# ・郵便番号: 999-9999
# ・住所: 大阪市西区千代崎3丁目南2番37号
# ・電話番号: 99-9999-9999
# ・メールアドレス: sho.zama@example.com
# ・商品名: MG ゼータガンダム ver Ka
# ・商品番号: 24156
# ・ご注文の部品
#     F-28 x 1

最終確認に対して OK を出さずに脇道にそれてみます。

response = agent.run("しかし大事な部品をなくすなんて。。。私の今日の運勢はどうなんだっけ?")
print_lines(response)
# 今日の魚座の運勢は...
# ・人のために、何かをしてあげたい気持ちが高まる日。困っている人を見つけたら積極的にお手伝いを。
# ・ラッキーアイテム:ジャムパン
# ・ラッキーカラー:ゴールド

ちゃんと私の誕生日を会話履歴から認識して、魚座の運勢を回答してくれました。

最後に本筋への復帰です。

response = agent.run("そうそう、部品注文の件はさっきの内容でOKなので、よろしくお願いします。")
print(response)
# 以下の内容で部品を注文しました。
# 
# ・お名前: 山田 一郎
# ・お名前(カナ): ヤマダ イチロウ
# ・郵便番号: 999-9999
# ・住所: 大阪市西区千代崎3丁目南2番37号
# ・電話番号: 99-9999-9999
# ・メールアドレス: sho.zama@example.com
# ・商品名: MG ゼータガンダム ver Ka
# ・商品番号: 24156
# ・ご注文の部品
#     F-28 x 1
# 
# 2営業日以内にご指定のメールアドレスに注文確認メールが届かない場合は、
# 弊社HPからお問い合わせください。

注文完了です!とりあえず意図どおりに動きましたね。

6. おわりに

今回は LangChain を試してみました。使用する LLM にもよるのでしょうが、 LLM に任せるところとロジックで押さえるところの棲み分けがポイントな気がします。 ただ、進歩が速い領域なので、この辺りの感覚もどんどんアップデートしていかないといけないですね。 次回は Elyza さんの日本語 Llama2 でも触ってみるか、LangChain の続きにするか。。。


  1. https://www.deeplearning.ai/short-courses/langchain-for-llm-application-development/ 

  2. https://www.deeplearning.ai/short-courses/langchain-chat-with-your-data/ 

  3. https://arxiv.org/abs/2210.03629 

  4. LangChain 的に細かいことを言うと Agent は LLM に対するラッパー的な存在で Agent Executor がハブになって Agent や Tool とデータをやりとりする構造です。本文はわかりやすさ優先のぼやかした表現になってます。 

  5. https://openai.com/blog/function-calling-and-other-api-updates 

  6. https://python.langchain.com/docs/modules/agents/agent_types/ 

  7. 会話形式の Agent であれば、Human:「アレやっといて」、AI:「こうしときました。」、Human:「あぁ、そうじゃなくて…」みたいな複数ターンの解決はありえるのですが、そういうパターンは除外させてください。 

  8. https://python.langchain.com/docs/integrations/tools/human_tools 

  9. いきなり「私は双子座なんだけど…」と言われるケースは無視してます。 

  10. LangChain を使うときは既定のプロンプトが全部英語問題の取り扱いがポイントになるかと思います。今回はシンプルに「全部日本語で書き直す」戦略としました。プロンプトは全部英語にして流れるデータを日本語にする、入り口で英語に翻訳して出口で日本語にする等いろいろ戦略はありそうですが、今回そこまで確認しきれていません。単純にプロンプトを日本語にしといたほうが、読者の食いつきが良さ..いえ、読者さまに伝わりやすそうというだけですね。 

  11. http://jugemkey.jp/api/waf/ アクセスすると「接続はプライベートではありません。」との警告がでるので、そこは at your own risk でお願いします。また商用利用は有料です。利用条件を守ってご利用ください。 

  12. 外部 API の出力をそのまま返しとけばいいよねっとお気楽に return_direct=True にしましたが、よく考えたら「僕は1/3生まれ、彼女は12/7生まれ、僕と彼女の今日の運勢は?」みたいなパターンには綺麗に対応できませんね。。。 

  13. OpenAI 的には temperature と top_p の同時設定は非推奨だったと思いますが、そこはなんとなく。お好みでどうぞ。 

  14. とはいえ便利にすると、「LED仕込んであるビームサーベルをもう一つ!」みたいな「欲しい部品のバラ売りサービス」と勘違いする人が増えそうなので、個人的にはこのままでよいと思います。製造はランナー単位だと思うので「なくした」「壊した」でなくて「欲しい」を許容すると問題がでそうな。フライドチキンの部位指定お断りと同じような話かと。ちなみに部品注文で自分史上一番の神対応をしてくれたのはドイツレベル!(ありがとうございました)あと、1/60 PGU ガンダム用の武器セットを是非!(ハイパーバズーカは2本、可動指のハンドパーツ、パッケージアートは懐かしのアレのオマージュで)それと 1/24 PG ダンバイン 欲しいです!新規造形の HG ダンバインは完売で買えなかったし、ちょっと小さいかな。放映40周年があと2か月で終わってし…(以下、無限に続くので省略、追記: HG ダンバインは2次で買えた!) 

  15. これでも全然、完璧ではないのですが、人間でもミスをするので。。。例外フロー対応まで含めた全体での効率改善が目標値に届いていれば OK くらいの心が大切ですね。 

  16. Langchain のアプリ開発で悩ましいのは temperature = 0 で同じプロンプトでも回答が安定しないところですよね。。。