Closed5

OpenAI「Agents SDK」⑦ハンドオフ

kun432kun432

ハンドオフ

https://openai.github.io/openai-agents-python/ja/handoffs/

あるエージェントから別のエージェントにタスクを委譲できるようにする機能が「ハンドオフ」。以下のようなユースケースで使える。

  • 異なる専門分野を持つエージェントが複数存在する
  • 例: カスタマーサポートアプリ
    • 注文状況は「注文エージェント」
    • 返金は「返金エージェント」
    • FAQ は「FAQエージェント」

で、このハンドオフ、実際には「ツール」として表現される。例えば

  • "Triage Agent" は "Refund Agent" へのハンドオフができるとした場合
  • "Triage Agent" は transfer_to_refund_agent というツールを持つことになる

となり、このツール名はどうやら Agentnameパラメータから生成されている様子。

https://github.com/openai/openai-agents-python/blob/7dda9d8ecaefe65db7e1ff9adf332e8e0569e60c/src/agents/handoffs.py#L132-L141

https://github.com/openai/openai-agents-python/blob/7dda9d8ecaefe65db7e1ff9adf332e8e0569e60c/src/agents/util/_transforms.py

これが重要なのは、エージェント名を日本語で設定して、かつ、その文字数が同じ場合、ツール名がおなじになってしまうということ。一応、エージェントの説明についてはそれぞれ適用されるとは思うのだがツール名としては同じものになってしまうので、おそらくこれが原因で上手くハンドオフされない、ということが起きるように思える。

エージェント名は英語で設定しておくのが良いと思う。


ハンドオフの作成

エージェントへのハンドオフの設定は handoffs パラメータで行う。handoffs パラメータに渡せるのは以下。

  1. Agent を直接渡す
  2. 細かくハンドオフのカスタマイズを行った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しかないんだけど、中身はこんな感じ。

https://github.com/openai/openai-agents-python/blob/e8d311bf9c9efec7df1f009265e10172d47fef32/src/agents/extensions/handoff_filters.py


推奨プロンプト

ハンドオフはツールとして実装されているので、ハンドオフが適切に行われるか?は、ツール呼び出しが適切に呼び出されるか?と同じこと、つまり、プロンプトが重要になる。

このための推奨プロンプトが用意されている。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> という名前のハンドオフ関数を呼び出すことで実現されます。エージェント間の転送はバックグラウンドでシームレスに処理されます。ユーザーとの会話において、これらの転送について言及したり、注意を引いたりしないでください。

kun432kun432

複数エージェントのオーケストレーション

https://openai.github.io/openai-agents-python/ja/multi_agent/

マルチエージェントの場合に、どのエージェントを使うか?どの順序で実行するか?次に何をするか?など、意思決定の流れを決めるのがオーケストレーションで、このオーケストレーションをどう定義するのか?について、以下の2パターンが挙げられている。

  1. LLMに意思決定させる
  2. コードで意思決定しゅる

組み合わせパターンはいろいろあって、メリデメなどもある。Dia でざっくりまとめてもらった。

観点/項目 LLMによるオーケストレーション コードによるオーケストレーション
決定方法 LLMが計画・推論して、ツールやハンドオフを使いながら自律的にタスクの流れを決める。 コードでエージェントの流れを決定。
出力を次のエージェントの入力にしたり、分岐や連鎖、並列実行も可能。
代表的なパターン例 ・Web検索やファイル取得
・コンピュータ操作
・コード実行
・専門エージェントへのハンドオフ
・structured outputsで分岐
・エージェント連鎖
・評価とフィードバックのループ
・並列実行
トレードオフ ・良いプロンプト設計が重要
・反復改善や内省が必要
・専門エージェントを用意すると良い
・速度・コスト・性能が決定的
・予測可能性が高い
・設計や分岐の管理が必要
評価(evals) 評価に投資することで、エージェントのタスク遂行能力を高められる。 評価とフィードバックのループで、出力が基準を満たすまで繰り返すことができる。

これらのいろいろなエージェントパターンについては、サンプルコードが以下にあるので、参考にすると良い。

https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns

kun432kun432

まとめ

ここまでで、ドキュメントにある基本的なところは一通りカバーできたと思う。個人的に前身のSwarmを過去に試していて、ある程度のイメージは持っていたので比較的すんなり理解できたように思う。

https://zenn.dev/kun432/scraps/30a5c55db75fb9

フルスタックなエージェントフレームワークに比べると機能的には足りないとは思うのだけども、つい先日までいろいろなエージェントフレームワークを試していて、フルスタックなフレームワークだと、そこそこ学習コストも発生したり、いろいろ融通効かなかったり、などもあって、個人的にはもっと薄いフレームワークが良いと感じていた。

で、それを踏まえて、OpenAI Agents SDKを試してみたのだけど、個人的にはこれで必要十分、足りないものは自分で追加、ぐらいで十分小回り効くように思えて、正直もうこれでいいなという風に感じた。マルチエージェントじゃなくてもFunction Calling使うなら、とりあえずAgents SDK使えばいい、というぐらいの汎用的に使えそうなイメージを持っている。

より細かく制御したいみたいなケースでは物足りないということもあるかもしれない。たとえば、Human-in-the-loopはAgents SDK単体では実現できなさそう。それが重要なのであれば、別のフレームワークを使うなり、何らかのインテグレーションで実現するなり、したほうがいいかもしれないが、個人的にはまだそういうユースケースに出会っていないので、その時にまた考えようと思う。

このスクラップは14日前にクローズされました