OpenAI「Agents SDK」⑦ハンドオフ
以下の続き
マルチエージェント関連のところを。
- ハンドオフ
- 複数のエージェントのオーケストレーション
ハンドオフ
あるエージェントから別のエージェントにタスクを委譲できるようにする機能が「ハンドオフ」。以下のようなユースケースで使える。
- 異なる専門分野を持つエージェントが複数存在する
- 例: カスタマーサポートアプリ
- 注文状況は「注文エージェント」
- 返金は「返金エージェント」
- FAQ は「FAQエージェント」
で、このハンドオフ、実際には「ツール」として表現される。例えば
- "Triage Agent" は "Refund Agent" へのハンドオフができるとした場合
- "Triage Agent" は
transfer_to_refund_agent
というツールを持つことになる
となり、このツール名はどうやら Agent
のname
パラメータから生成されている様子。
これが重要なのは、エージェント名を日本語で設定して、かつ、その文字数が同じ場合、ツール名がおなじになってしまうということ。一応、エージェントの説明についてはそれぞれ適用されるとは思うのだがツール名としては同じものになってしまうので、おそらくこれが原因で上手くハンドオフされない、ということが起きるように思える。
エージェント名は英語で設定しておくのが良いと思う。
ハンドオフの作成
エージェントへのハンドオフの設定は handoffs
パラメータで行う。handoffs
パラメータに渡せるのは以下。
-
Agent
を直接渡す - 細かくハンドオフのカスタマイズを行った
Handoff
オブジェクトを渡す
2つ目の Handoff
オブジェクトは、handoff()
関数で作成できる。これを使うとハンドオフ先のエージェントだけでなく、オプションを上書きしたり、入力フィルタを指定したり、などのカスタマイズができる。
基本的な使用方法
handoffs
パラメータにエージェントを直接指定する基本的なハンドオフ
from agents import Agent, Runner
import asyncio
billing_agent = Agent(
name="Billing Support",
instructions="支払いに関する問い合わせを処理する。",
)
refund_agent = Agent(
name="Refund Support",
instructions="返品に関する問い合わせを処理する。",
)
cs_agent = Agent(
name="Customer Service",
instructions="あなたは、カスタマーサポートの受付エージェントです。お客様の問い合わせに応じて専門のエージェントにタスクを委譲します。",
# ハンドオフを設定
handoffs=[
billing_agent,
refund_agent,
]
)
async def main():
# 例1: 支払いに関する問い合わせ
input = "私の注文の支払いはどうなっていますか?"
result = await Runner.run(cs_agent, input)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
print("-" * 20)
# 例2: 返品に関する問い合わせ
input = "注文を返品したいのですが・・・。"
result = await Runner.run(cs_agent, input)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
if __name__ == "__main__":
asyncio.run(main())
ユーザ: 私の注文の支払いはどうなっていますか?
Billing Support: 支払いの詳細についてお調べいたしますので、少々お待ちください。お手数ですが、注文番号を教えていただけますか?
--------------------
ユーザ: 注文を返品したいのですが・・・。
Refund Support: 返品をご希望ですね。手続きを進めるために、注文番号や購入日などの詳細を教えていただけますか?
handoff()
関数によるハンドオフのカスタマイズ
handoff()
関数を使うと、より細かくハンドオフをカスタマイズした Handoff
オブジェクトを作成して、これを エージェントのhandoffs
パラメータに渡すことができる。
パラメータは以下。
パラメータ名 | 型・説明 |
---|---|
agent |
ハンドオフ先のAgentオブジェクト |
tool_name_override |
ツール名の上書き(デフォはtransfer_to_<agent_name> ) |
tool_description_override |
ツール説明の上書き(デフォは自動生成) |
on_handoff |
ハンドオフ時に呼ばれるコールバック関数 |
input_type |
ハンドオフ時に渡す入力データの型(例:BaseModelなど) |
input_filter |
入力データをフィルタリングする関数 |
is_enabled |
ハンドオフの有効/無効(boolまたはbool返す関数、動的切り替えOK) |
一つ前のコードを書き換えるとこうなる。
from agents import Agent, Runner, RunContextWrapper, handoff
import asyncio
# ハンドオフ時に呼び出されるコールバック
def on_handoff(ctx: RunContextWrapper[None]):
print("ハンドオフが行われました")
billing_agent = Agent(
name="Billing Support",
instructions="支払いに関する問い合わせを処理する。",
)
refund_agent = Agent(
name="Refund Support",
instructions="返品に関する問い合わせを処理する。",
)
billing_handoff_obj = handoff(
agent=billing_agent,
on_handoff=on_handoff,
tool_name_override="billing_operation_handoff",
tool_description_override=(
"支払いに関しての全ての問い合わせを処理する、支払処理専門のエージェント。"
),
)
refund_handoff_obj = handoff(
agent=refund_agent,
on_handoff=on_handoff,
tool_name_override="refund_operation_handoff",
tool_description_override=(
"返品に関しての全ての問い合わせを処理する、返品処理専門のエージェント。"
),
)
cs_agent = Agent(
name="Customer Service",
instructions="あなたは、カスタマーサポートの受付エージェントです。お客様の問い合わせに応じて専門のエージェントにタスクを委譲します。",
handoffs=[
billing_handoff_obj,
refund_handoff_obj,
]
)
async def main():
# 例1: 支払いに関する問い合わせ
input = "私の注文の支払いはどうなっていますか?"
result = await Runner.run(cs_agent, input)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
print("-" * 20)
# 例2: 返品に関する問い合わせ
input = "注文を返品したいのですが・・・。"
result = await Runner.run(cs_agent, input)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
if __name__ == "__main__":
asyncio.run(main())
ハンドオフが行われました
ユーザ: 私の注文の支払いはどうなっていますか?
Billing Support: 支払い状況について詳しく調べるために、注文番号や関連情報を教えていただけますか?
--------------------
ハンドオフが行われました
ユーザ: 注文を返品したいのですが・・・。
Refund Support: 返品をご希望ですね。返品に関する詳細をお手伝いいたしますので、以下の情報を教えていただけますか?
1. 注文番号
2. 商品名
3. 商品が未使用・未開封か
4. 返品理由
これらの情報をもとに、返品手続きを進めさせていただきます。
トレースを見てみると、ツール名や説明が書き換わっているのがわかる。
ハンドオフの入力
ハンドオフを呼び出す際に、LLM を使ってデータを渡すことができる。例えば以下のようなユースケース。
- 「エスカレーション エージェント」へのハンドオフする
- 記録のために理由もセットで提供する
from pydantic import BaseModel
from agents import Agent, Runner, RunContextWrapper, handoff
import asyncio
refund_agent = Agent(
name="Refund Support",
instructions="返品に関する問い合わせを処理する。",
)
# ハンドオフで入力として渡すデータのモデル
class EscalationData(BaseModel):
reason: str
# ハンドオフ時に呼び出されるコールバックで、入力データ受け取る
async def on_handoff(ctx: RunContextWrapper[None], input_data: EscalationData):
print(f"refund agentにハンドオフされました。理由: {input_data.reason}")
# ハンドオフの設定
refund_handoff = handoff(
agent=refund_agent,
on_handoff=on_handoff,
input_type=EscalationData,
)
cs_agent = Agent(
name="Customer Service",
instructions="あなたは、カスタマーサポートの受付エージェントです。お客様の問い合わせに応じて専門のエージェントにタスクを委譲します。",
handoffs=[refund_handoff],
)
async def main():
# 例1: 支払いに関する問い合わせ
input = "私の注文の支払いはどうなっていますか?"
result = await Runner.run(cs_agent, input)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
print("-" * 20)
# 例2: 返品に関する問い合わせ
input = "注文を返品したいのですが・・・。"
result = await Runner.run(cs_agent, input)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
if __name__ == "__main__":
asyncio.run(main())
ユーザ: 私の注文の支払いはどうなっていますか?
Customer Service: ご注文の詳細を確認し、支払い状況をお調べします。注文番号を教えていただけますか?
--------------------
返品エージェントにハンドオフされました。理由: お客様が注文を返品したいとおっしゃっています。
ユーザ: 注文を返品したいのですが・・・。
Refund Support: 返品の件でお手伝いしますね。注文番号を教えていただけますか?また、返品の理由もお伺いしたいです。
入力フィルター
ハンドオフが行われると、ハンドオフ先の新しいエージェントには元の会話が引き継がれるので、過去の会話履歴全体を参照することができるが、入力フィルターを使えばこれを変更することができる。よくあるパターンとしては、会話履歴からツール呼び出し部分を除去するというもの。
from agents import Agent, Runner, handoff, function_tool, SQLiteSession
from agents.extensions import handoff_filters
import asyncio
# 注文番号から注文情報を参照するダミーの関数
@function_tool
def get_info_from_order_id(order_id: str) -> str:
"""注文番号から注文情報を取得する"""
return (
"注文番号: " + order_id + "\n"
"注文日: 2025-01-01\n"
"注文内容: 商品A 1本\n"
"注文金額: 1000円\n"
"発送状況: 入荷待ち\n"
)
# 注文番号から注文情報を参照するダミーの関数
@function_tool
def cancel_order_from_order_id(order_id: str) -> str:
"""注文番号から注文をキャンセルする"""
return "注文をキャンセルしました。"
cancel_agent = Agent(
name="Cancel Agent",
instructions="キャンセルに関する問い合わせを処理する。",
tools=[cancel_order_from_order_id],
)
# 入力フィルタを設定したCancel Agentへのハンドオフを定義
handoff_cancel = handoff(
agent=cancel_agent,
input_filter=handoff_filters.remove_all_tools,
)
cs_agent = Agent(
name="Customer Service",
instructions="あなたは、カスタマーサポートの受付エージェントです。お客様の問い合わせに応じて専門のエージェントにタスクを委譲します。",
handoffs=[handoff_cancel],
tools=[get_info_from_order_id],
)
async def main():
session = SQLiteSession("user_123")
input = "私の注文はどうなっていますか?"
result = await Runner.run(cs_agent, input, session=session)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
print("-" * 20)
input = "注文番号は001です。"
result = await Runner.run(cs_agent, input, session=session)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
print("-" * 20)
# 例2: 返品に関する問い合わせ
input = "入荷がわからないのであれば、注文をキャンセルしたいのですが・・・。"
result = await Runner.run(cs_agent, input, session=session)
print(f"ユーザ: {input}")
print(f"{result.last_agent.name}: {result.final_output}")
if __name__ == "__main__":
asyncio.run(main())
ユーザ: 私の注文はどうなっていますか?
Customer Service: 注文番号を教えていただけますか?確認いたします。
--------------------
ユーザ: 注文番号は001です。
Customer Service: 注文番号001について確認しました。
- **注文日**: 2025-01-01
- **注文内容**: 商品A 1本
- **注文金額**: 1000円
- **発送状況**: 入荷待ち
その他のご質問があれば教えてください。
--------------------
ユーザ: 入荷がわからないのであれば、注文をキャンセルしたいのですが・・・。
Cancel Agent: 注文番号001のキャンセルが完了しました。その他にお手伝いできることがあれば教えてください。
トレースを見ると、最初に入力を受け取ったCustomer Serviceエージェントはツールを実行している。
Cancel Agentにハンドオフ後された場合の履歴を見ると、ツール呼び出しなどは会話履歴に残っていないので、おそらく入力フィルタが削除したのだろうと思われる。
入力フィルタの設定は、Handoff
オブジェクトを作成して、input_filter
パラメータに関数を指定する。この関数は、既存の入力を HandoffInputData
経由で受け取って、新しい HandoffInputData
を返すというものになる。
上記のように、よくあるパターン向けには、あらかじめフィルタロジックが定義されたagents.extensions.handoff_filters
を使うことができる。といっても現状は remove_all_tools
しかないんだけど、中身はこんな感じ。
推奨プロンプト
ハンドオフはツールとして実装されているので、ハンドオフが適切に行われるか?は、ツール呼び出しが適切に呼び出されるか?と同じこと、つまり、プロンプトが重要になる。
このための推奨プロンプトが用意されている。agents.extensions.handoff_prompt.RECOMMENDED_PROMPT_PREFIX
もしくは agents.extensions.handoff_prompt.prompt_with_handoff_instructions()
を使えば良い。
from agents import Agent
from agents.extensions.handoff_prompt import RECOMMENDED_PROMPT_PREFIX
billing_agent = Agent(
name="Billing agent",
instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
お客様の請求に関する問い合わせを処理する。""",
)
print(billing_agent.instructions)
from agents import Agent
from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
billing_agent = Agent(
name="Billing agent",
instructions=prompt_with_handoff_instructions(
"お客様の請求に関する問い合わせを処理する。"
),
)
print(billing_agent.instructions)
どちらの場合も以下のようなプロンプトになる。
# System context
You are part of a multi-agent system called the Agents SDK, designed to make agent coordination and execution easy. Agents uses two primary abstraction: **Agents** and **Handoffs**. An agent encompasses instructions and tools and can hand off a conversation to another agent when appropriate. Handoffs are achieved by calling a handoff function, generally named `transfer_to_<agent_name>`. Transfers between agents are handled seamlessly in the background; do not mention or draw attention to these transfers in your conversation with the user.
お客様の請求に関する問い合わせを処理する。
日本語訳(DeepL)
# システムコンテキスト
あなたは、エージェントの協調と実行を容易にするために設計されたマルチエージェントシステム「Agents SDK」の一部です。Agentsは、エージェントとハンドオフという2つの主要な抽象化概念を使用しています。エージェントは指示とツールを包含し、適切なタイミングで他のエージェントに会話を引き渡すことができます。ハンドオフは、通常transfer_to_<agent_name>
という名前のハンドオフ関数を呼び出すことで実現されます。エージェント間の転送はバックグラウンドでシームレスに処理されます。ユーザーとの会話において、これらの転送について言及したり、注意を引いたりしないでください。
複数エージェントのオーケストレーション
マルチエージェントの場合に、どのエージェントを使うか?どの順序で実行するか?次に何をするか?など、意思決定の流れを決めるのがオーケストレーションで、このオーケストレーションをどう定義するのか?について、以下の2パターンが挙げられている。
- LLMに意思決定させる
- コードで意思決定しゅる
組み合わせパターンはいろいろあって、メリデメなどもある。Dia でざっくりまとめてもらった。
観点/項目 | LLMによるオーケストレーション | コードによるオーケストレーション |
---|---|---|
決定方法 | LLMが計画・推論して、ツールやハンドオフを使いながら自律的にタスクの流れを決める。 | コードでエージェントの流れを決定。 出力を次のエージェントの入力にしたり、分岐や連鎖、並列実行も可能。 |
代表的なパターン例 | ・Web検索やファイル取得 ・コンピュータ操作 ・コード実行 ・専門エージェントへのハンドオフ |
・structured outputsで分岐 ・エージェント連鎖 ・評価とフィードバックのループ ・並列実行 |
トレードオフ | ・良いプロンプト設計が重要 ・反復改善や内省が必要 ・専門エージェントを用意すると良い |
・速度・コスト・性能が決定的 ・予測可能性が高い ・設計や分岐の管理が必要 |
評価(evals) | 評価に投資することで、エージェントのタスク遂行能力を高められる。 | 評価とフィードバックのループで、出力が基準を満たすまで繰り返すことができる。 |
これらのいろいろなエージェントパターンについては、サンプルコードが以下にあるので、参考にすると良い。
まとめ
ここまでで、ドキュメントにある基本的なところは一通りカバーできたと思う。個人的に前身のSwarmを過去に試していて、ある程度のイメージは持っていたので比較的すんなり理解できたように思う。
フルスタックなエージェントフレームワークに比べると機能的には足りないとは思うのだけども、つい先日までいろいろなエージェントフレームワークを試していて、フルスタックなフレームワークだと、そこそこ学習コストも発生したり、いろいろ融通効かなかったり、などもあって、個人的にはもっと薄いフレームワークが良いと感じていた。
で、それを踏まえて、OpenAI Agents SDKを試してみたのだけど、個人的にはこれで必要十分、足りないものは自分で追加、ぐらいで十分小回り効くように思えて、正直もうこれでいいなという風に感じた。マルチエージェントじゃなくてもFunction Calling使うなら、とりあえずAgents SDK使えばいい、というぐらいの汎用的に使えそうなイメージを持っている。
より細かく制御したいみたいなケースでは物足りないということもあるかもしれない。たとえば、Human-in-the-loopはAgents SDK単体では実現できなさそう。それが重要なのであれば、別のフレームワークを使うなり、何らかのインテグレーションで実現するなり、したほうがいいかもしれないが、個人的にはまだそういうユースケースに出会っていないので、その時にまた考えようと思う。
あと今回試してないけど、音声なども含めた、音声エージェント・リアルタイムエージェントについてもドキュメントがあるので、気が向いたら確認してみようと思う。