メインコンテンツへスキップ
見出し画像

OpenAI Agents SDK 入門 (3) - Tool ・ Handoff ・ Tracing

    「OpenAI Agents SDK」の「Tool」「Handoff」「Tracing」についてまとめました。

    ・Tools
    ・
    Handoffs
    ・
    Tracing
    ・
    Context management

    前回

    1. Tool

    1-1. Tool

    「Tool」は「Agent」にアクションを実行させるものです。データの取得、コード実行、外部API呼び出し、Computer useなどが含まれます。

    「Agent SDK」には、次の3つの「Tool」のクラスがあります。

    ・Hosted tools
    LLMサーバ上でAIモデルと一緒に実行されます。OpenAIは、検索機能、Web検索、「Computer use」を「Hosted tools」として提供しています。
    ・Function Calling
    任意のPython関数をToolとして利用できます。
    ・Agents as tools
    AgentをToolとして活用でき、Agent同士が直接引き継ぐことなく他のAgentを呼び出すことが可能になります。

    1-2. Hosted tools

    OpenAIResponsesModel を使用する際に、いくつかの組み込みToolが提供されています。

    ・WebSearchTool
    Web検索を実行できるTool。

    ・FileSearchTool
    ベクトルストアから情報を取得できるTool。

    ・ComputerTool
    コンピューターの操作を自動化できるTool。

    from agents import Agent, FileSearchTool, Runner, WebSearchTool
    
    # Agent
    agent = Agent(
        name="Assistant",
        tools=[ # Tool
            WebSearchTool(),
            FileSearchTool(
                max_num_results=3,
                vector_store_ids=["VECTOR_STORE_ID"],
            ),
        ],
    )
    
    async def main():
        result = await Runner.run(agent, "私の好みと今日のサンフランシスコの天気を考慮すると、どのコーヒーショップに行くべきでしょうか?")
        print(result.final_output)

    1-3. Function tools

    Pythonの関数をToolとして使用できます。「Agents SDK」は「Tool」を自動的にセットアップします。

    ・Toolの名前はPython関数の名前になります(または任意の名前を指定可能)。
    ・Toolの説明は関数のドキュメント文字列(docstring)から取得されます(または任意の説明を指定可能)。
    ・関数の引数から自動的に入力スキーマが作成されます。
    ・各入力の説明は、関数のドキュメント文字列から取得されます(無効化も可能)。

    Pythonの inspect モジュールを使用して関数のシグネチャを抽出し、griffe でdocstringを解析し、pydantic でスキーマを作成します。

    import json
    from typing_extensions import TypedDict, Any
    from agents import Agent, FunctionTool, RunContextWrapper, function_tool
    
    
    class Location(TypedDict):
        lat: float
        long: float
    
    # Tool
    @function_tool  
    async def fetch_weather(location: Location) -> str:
        
        """指定された場所の天気を取得します。
    
        Args:
            location: 天気を取得する場所。
        """
        # 実際には、天気APIから天気情報を取得します。
        return "sunny"
    
    # Tool
    @function_tool(name_override="fetch_data")  
    def read_file(ctx: RunContextWrapper[Any], path: str, directory: str | None = None) -> str:
        """ファイルの内容を読み取ります。
    
        Args:
            path: 読み取るファイルへのパス。
            directory: ファイルを読み込むディレクトリ。
        """
        # 実際には、ファイルシステムからファイルを読み取ります
        return "<file contents>"
    
    # Agent
    agent = Agent(
        name="Assistant",
        tools=[fetch_weather, read_file], # Tool 
    )
    
    for tool in agent.tools:
        if isinstance(tool, FunctionTool):
            print(tool.name)
            print(tool.description)
            print(json.dumps(tool.params_json_schema, indent=2))
            print()

    1-4. Custom function tools

    場合によっては、Python関数をToolとして使用したくないこともあります。その場合、FunctionTool を直接作成することができます。作成する際には、以下の情報を提供する必要があります。

    ・name
    Toolの名前

    ・description
    Toolの説明

    ・params_json_schema
    引数のJSONスキーマ

    ・on_invoke_tool
    非同期関数。コンテキストと引数をJSON文字列として受け取り、Toolの出力を文字列として返す必要があります。

    from typing import Any
    from pydantic import BaseModel
    from agents import RunContextWrapper, FunctionTool
    
    
    def do_some_work(data: str) -> str:
        return "done"
    
    
    class FunctionArgs(BaseModel):
        username: str
        age: int
    
    
    async def run_function(ctx: RunContextWrapper[Any], args: str) -> str:
        parsed = FunctionArgs.model_validate_json(args)
        return do_some_work(data=f"{parsed.username} is {parsed.age} years old")
    
    
    tool = FunctionTool(
        name="process_user",
        description="抽出されたユーザーデータを処理します。",
        params_json_schema=FunctionArgs.model_json_schema(),
        on_invoke_tool=run_function,
    )

    1-5. Automatic Argument と Docstring Parsing

    関数のシグネチャを自動的に解析してToolのスキーマを抽出し、Docstringを解析してToolや各引数の説明を取得します。以下の点に注意してください。

    ・シグネチャの解析は inspect モジュールを使用して行われます。型アノテーションを利用して引数の型を理解し、それに基づいて Pydantic モデルを動的に構築し、全体のスキーマを表現します。Python の基本型、Pydantic モデル、TypedDict など、多くの型をサポートしています。

    ・Docstringの解析には griffe を使用します。サポートされているドックストリング形式は Google、Sphinx、Numpy です。Docstringの形式は自動検出されますが、これはベストエフォートであり、function_tool を呼び出す際に明示的に設定することも可能です。また、use_docstring_infoを False に設定することで、ドックストリングの解析を無効化することもできます。

    スキーマ抽出のコードは agents.function_schema. にあります。

    1-6. Agents as tools

    一部のワークフローでは、制御を引き渡すのではなく、中央のAgentが専門的なAgentのネットワークをオーケストレーション(調整)するようにしたい場合があります。このような場合、AgentをToolとしてモデル化することで実現できます。

    from agents import Agent, Runner
    import asyncio
    
    # Spanish Agent
    spanish_agent = Agent(
        name="Spanish agent",
        instructions="ユーザーのメッセージをスペイン語に翻訳します。",
    )
    
    # French Agent
    french_agent = Agent(
        name="French agent",
        instructions="ユーザーのメッセージをフランス語に翻訳します。",
    )
    
    # Orchestrator Agent
    orchestrator_agent = Agent(
        name="orchestrator_agent",
        instructions=(
            "あなたは翻訳Agentです。与えられたToolを使用して翻訳を行います。"
            "複数の翻訳を要求された場合は、関連するToolを呼び出します。"
        ),
        tools=[
            spanish_agent.as_tool(
                tool_name="translate_to_spanish",
                tool_description="ユーザーのメッセージをスペイン語に翻訳する。",
            ),
            french_agent.as_tool(
                tool_name="translate_to_french",
                tool_description="ユーザーのメッセージをフランス語に翻訳する。",
            ),
        ],
    )
    
    # メイン
    async def main():
        result = await Runner.run(orchestrator_agent, input="スペイン語で「こんにちは、お元気ですか?」と言いましょう。")
        print(result.final_output)

    1-7. Handling Error と Function Tool

    @function_tool を使用して「Function Tool」を作成する際に、failure_error_function を渡すことができます。これは、Toolの呼び出しがクラッシュした場合にLLMにエラーレスポンスを提供する関数です。

    ・デフォルトでは(つまり、何も渡さなかった場合)、default_tool_error_function が実行され、LLM にエラーが発生したことを通知します。
    ・独自のエラー関数を渡した場合は、それが実行され、そのレスポンスが LLM に送信されます。
    ・明示的に None を渡した場合、Toolの呼び出しエラーはそのまま再スローされるため、ユーザーが処理する必要があります。このエラーには、モデルが無効な JSON を生成した場合の ModelBehaviorErrorや、コードがクラッシュした場合の UserError などが含まれます。
    ・FunctionTool オブジェクトを手動で作成する場合は、on_invoke_tool 関数内でエラーを処理する必要があります。

    2. Handoff

    2-1. Handoff

    「Handoff」を使用すると、Agentが別のAgentにタスクを委任できます。これは、異なるAgentがそれぞれ特定の分野を専門としている場合に特に有用です。
    例えば、カスタマーサポートアプリでは、注文状況、返金対応、FAQ などのタスクをそれぞれ専門的に処理するAgentが存在することがあります。

    Handoffは LLM に対してToolとして表現されます。
    そのため、例えば「Refund Agent」にHandoffする場合、そのToolは transfer_to_refund_agent という名前で呼び出されます。

    2-2. Handoff の作成

    すべてのAgentには handoffs パラメータがあり、これは Agent を直接受け取るか、カスタマイズ可能な Handoff オブジェクトを受け取ることができます。

    「Agents SDK」には、Handoffを作成するための handoff() が用意されています。この関数を使用すると、Handoff先のAgentを指定できるほか、オプションでオーバーライド設定や入力フィルタを追加することも可能です。

    ・基本的な使い方
    簡単なHandoffを作成する方法は次のとおりです。

    from agents import Agent, handoff
    
    billing_agent = Agent(name="Billing agent")
    refund_agent = Agent(name="Refund agent")
    
    
    triage_agent = Agent(name="Triage agent", handoffs=[billing_agent, handoff(refund_agent)])

    ・handoff() によるHandoffのカスタマイズ
    handoff() を使用すると、カスタマイズできます。

    ・agent
    Handoffの受け渡し先となるAgent。

    ・tool_name_override
    デフォルトでは Handoff.default_tool_name() が使用され、transfer_to_<agent_name> というTool名に解決されるが、これを上書き可能。

    ・tool_description_override
    Handoff.default_tool_description() によるデフォルトのTool説明を上書き可能。

    ・on_handoff
    Handoffが実行された際に呼び出されるコールバック関数。
     ・Handoffの開始時にデータ取得を実行する場合などに役立つ。
     ・この関数はAgentのコンテキストを受け取り、オプションで LLM が生成した入力も受け取ることができる。
     ・受け取る入力データは input_type パラメータによって制御される。

    ・input_type(オプション)
    Handoffで受け取る入力の種類。

    ・input_filter
    次のAgentが受け取る入力をフィルタリングするためのパラメータ。

    from agents import Agent, handoff, RunContextWrapper
    
    # Handoffのコールバック関数
    def on_handoff(ctx: RunContextWrapper[None]):
        print("Handoff called")
    
    # Agent
    agent = Agent(name="My agent")
    
    # Handoff
    handoff_obj = handoff(
        agent=agent,
        on_handoff=on_handoff,
        tool_name_override="custom_handoff_tool",
        tool_description_override="Custom description",
    )

    2-3. Handoff の入力

    特定の状況では、LLM がHandoffを呼び出す際にデータを提供する必要があります。

    例えば、「Escalation Agent」へのHandoffを想定すると、その理由を提供することで記録を残せるようにしたい場合があります。

    from pydantic import BaseModel
    from agents import Agent, handoff, RunContextWrapper
    
    class EscalationData(BaseModel):
        reason: str
    
    # Handoffのコールバック
    async def on_handoff(ctx: RunContextWrapper[None], input_data: EscalationData):
        print(f"Escalation agent called with reason: {input_data.reason}")
    
    # Agent
    agent = Agent(name="Escalation agent")
    
    # Handoff
    handoff_obj = handoff(
        agent=agent,
        on_handoff=on_handoff,
        input_type=EscalationData,
    )

    2-4. Input Filters

    Handoffが発生すると、新しいAgentが会話を引き継ぎ、これまでの会話履歴全体を確認できる状態になります。この動作を変更したい場合は、input_filter を設定できます。input_filter は、HandoffInputData を受け取り、新しい HandoffInputData を返す関数です。

    また、一般的なパターン(例えば、履歴からすべてのTool呼び出しを削除するなど)は、agents.extensions.handoff_filters にあらかじめ実装されています。

    from agents import Agent, handoff
    from agents.extensions import handoff_filters
    
    # Agent
    agent = Agent(name="FAQ agent")
    
    # Handoff
    handoff_obj = handoff(
        agent=agent,
        input_filter=handoff_filters.remove_all_tools, 
    )

    2-5. 推奨プロンプト

    LLM がHandoffを正しく理解できるようにするため、AgentにHandoffに関する情報を含めることを推奨します。

    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
    
    # Agent
    billing_agent = Agent(
        name="Billing agent",
        instructions=f"""{RECOMMENDED_PROMPT_PREFIX}
        <Fill in the rest of your prompt here>.""",
    )

    3. Tracing

    3-1. Tracing

    「Agents SDK」には、Agentの実行中に発生するイベント(LLMの生成、Toolの呼び出し、Handoff、Guardrail、カスタムイベント)を包括的に記録する組み込みのTrace機能が含まれています。Tracesダッシュボードを使用すると、開発中および本番環境でワークフローをデバッグ、可視化、監視できます。

    3-2. TraceとSpan

    「Trace」は、「ワークフロー」の単一のエンドツーエンドの操作を表します。「Trace」は「Spans」で構成されます。「Trace」には以下のプロパティがあります。

    ・workflow_name
    論理的なワークフローやアプリケーションの名前。
    例:「コード生成」や「カスタマーサービス」など。

    ・trace_id
    Traceの一意のID。指定しない場合は自動生成されます。
    形式は trace_<32桁の英数字> である必要があります。

    ・group_id(オプション)
    同じ会話内の複数のTraceを関連付けるためのグループID。
    例えば、チャットスレッドIDを使用することができます。

    ・disabled
    True に設定すると、Traceは記録されません。

    ・metadata(オプション)
    Traceに付与できるメタデータ。

    「Span」は、開始時間と終了時間を持つ操作を表します。「Span」には以下のプロパティがあります。

    ・started_at ・ ended_at
    開始時刻と終了時刻のタイムスタンプ。

    ・trace_id
    Spanが属するTraceを示すID。

    ・parent_id
    Spanの親Spanを指すID(親Spanがある場合)。

    ・span_data
    Spanに関する情報。
    例: AgentSpanData はAgentに関する情報を含み、GenerationSpanData は LLM の生成に関する情報を含む。

    3-3. デフォルトのTrace

    デフォルトでは、以下の処理をTraceします。

    ・Runner.{run, run_sync, run_streamed}() 全体が trace() でラップ。
    ・Agentが実行されるたびに agent_span() でラップ。
    ・LLM の生成処理は generation_span() でラップ。
    ・Function Toolの呼び出しは、それぞれ function_span() でラップ。
    ・Guardrailsは guardrail_span() でラップ。
    ・Handoffsは handoff_span() でラップ。

    デフォルトでは、Traceの名前は "Agent trace" となります。ただし、trace を使用することでこの名前を設定でき、RunConfig を使用すると名前やその他のプロパティを構成できます。

    3-4. 上位レベルのTrace

    場合によっては、複数回の run() 呼び出しを1つのTraceとしてまとめたいことがあります。その場合、コード全体を trace() でラップすることで実現できます。

    from agents import Agent, Runner, trace
    
    async def main():
        agent = Agent(name="Joke generator", instructions="Tell funny jokes.")
    
        with trace("Joke workflow"): 
            first_result = await Runner.run(agent, "Tell me a joke")
            second_result = await Runner.run(agent, f"Rate this joke: {first_result.final_output}")
            print(f"Joke: {first_result.final_output}")
            print(f"Rating: {second_result.final_output}")

    3-5. Traceの作成

    trace() 関数を使用してTraceを作成できます。Traceは 開始(start) と 終了(finish) が必要です。これを行う方法は2つあります。

    (1) 推奨方法
    trace(...) as my_trace のように コンテキストマネージャ として使用する。これにより、適切なタイミングで自動的にTraceが開始・終了されます。

    (2) 手動で管理
    trace.start() と trace.finish() を手動で呼び出す。

    現在のTraceは Python の contextvar を使用して追跡 されるため、並行処理(concurrency)でも自動的に機能します。ただし、手動でTraceを開始・終了する場合は、start()/finish() に mark_as_current と reset_current を渡して、現在のTraceを更新する必要があります。

    3-6. Spanの作成

    さまざまな *_span() を使用してSpanを作成できます。
    通常、Spanを手動で作成する必要はありませんが、カスタムSpan情報を追跡するための custom_span() 関数が用意されています。

    ・Spanは自動的に 現在のTraceの一部 となる。
    ・最も近い現在のSpanの下にネストされる

    3-7. 機密データ

    一部のSpanは、機密情報を含む可能性があります。

    ・generation_span() → LLM の入力/出力 を保存
    ・function_span() → 関数の入力/出力 を保存

    これらのデータに機密情報が含まれる場合があります。
    データの記録を無効にしたい場合は、RunConfig.trace_include_sensitive_data を使用して制御できます。

    3-8. カスタムTraceプロセッサ

    Traceの高レベルアーキテクチャは以下のようになっています。

    (1) 初期化時 に、グローバルな TraceProvider を作成し、Traceの生成を管理する。
    (2) TraceProvider を構成 し、BatchTraceProcessor を設定する。BatchTraceProcessor は、TraceやSpanをバッチ処理し、BackendSpanExporter に送信する。BackendSpanExporter は、SpanとTraceを OpenAI のバックエンドにバッチでエクスポートする。

    デフォルトの設定を変更し、別のバックエンドにTraceを送信 したり、エクスポートの動作を変更 する方法は2つあります。

    (1) add_trace_processor() を使用
    追加のTraceプロセッサ を登録し、TraceやSpanを受け取るようにする。これにより、OpenAI のバックエンドに送信する + 独自の処理を追加 できる。
    (2) set_trace_processors() を使用
    デフォルトのプロセッサを置き換える(カスタムのTraceプロセッサを設定)。OpenAI のバックエンドにTraceを送信しなくなる ため、必要なら TracingProcessor を追加する必要がある。

    3-9. 外部Traceプロセッサリスト

    ・Arize-Phoenix
    ・
    MLflow
    ・
    Braintrust
    ・
    Pydantic Logfire
    ・
    AgentOps
    ・
    Scorecard
    ・
    Keywords AI
    ・
    LangSmith
    ・
    Maxim AI
    ・
    Comet Opik

    4. コンテキスト管理

    4-1. ローカルコンテキスト

    RunContextWrapper クラスと、その中の context プロパティによって表されます。動作のしくみは次の通りです。

    (1) 任意の Python オブジェクトを作成します。一般的なパターンとして、dataclass や Pydantic オブジェクトを使用します。
    (2) そのオブジェクトをさまざまな run メソッドに渡します。
    (例:Runner.run(..., **context=whatever**))
    (3) すべてのToolの呼び出し、ライフサイクルフックなどには RunContextWrapper[T] というラッパーオブジェクトが渡されます。ここで T はコンテキストオブジェクトの型を表し、wrapper.context を通じてアクセスできます。

    特定のAgent実行に関わるすべてのAgent、Tool関数、ライフサイクルなどは 同じ型のコンテキストを使用する必要があります。

    コンテキストの使用例としては、以下のようなものがあります。

    ・実行時のコンテキストデータ(例:ユーザー名 / UID などのユーザー情報)
    ・依存関係(例:ロガーオブジェクト、データフェッチャーなど)
    ・ヘルパー関数

    import asyncio
    from dataclasses import dataclass
    from agents import Agent, RunContextWrapper, Runner, function_tool
    
    @dataclass
    class UserInfo:  
        name: str
        uid: int
    
    # Tool
    @function_tool
    async def fetch_user_age(wrapper: RunContextWrapper[UserInfo]) -> str:  
        return f"ユーザー {wrapper.context.name} は47歳です"
    
    # メイン
    async def main():
        user_info = UserInfo(name="ジョン", uid=123)  
    
        # Agent
        agent = Agent[UserInfo](  
            name="Assistant",
            tools=[fetch_user_age],
        )
    
        # Agentの実行
        result = await Runner.run(
            starting_agent=agent,
            input="ユーザーの年齢は何歳ですか?",
            context=user_info,
        )
    
        print(result.final_output)  
    
    # メインの実行
    if __name__ == "__main__":
        asyncio.run(main())

    4-2. Agent / LLMコンテキスト

    LLMが呼び出される際、参照できるデータは会話履歴のみ です。つまり、新しいデータを LLM に利用させたい場合、そのデータを 会話履歴に組み込む 必要があります。これを実現する方法はいくつかあります。

    (1) Agentの指示に追加
    これは「システムプロンプト」または「開発者メッセージ」とも呼ばれます。
    システムプロンプトは、静的な文字列として設定することもできますし、コンテキストを受け取って動的に文字列を生成する関数として設定することも可能です。
    常に有用な情報(例:ユーザー名や現在の日付)を提供するのに適した方法です。

    (2) Runner.run 関数の呼び出し時に入力として追加
    これは Agentの指示(instructions)を渡す方法と似ていますが、より階層が低いメッセージ を渡すのに適しています。

    (3) Function Toolを介してデータを公開
    オンデマンドで必要なデータを取得する場合に便利な方法 です。
    LLM が特定のデータを必要としたタイミングで、Toolを呼び出して取得できます。

    (4) RetrievalやWeb searchを活用
    ・Retrieval : ファイルやデータベースから関連データを取得
    ・Web Search : インターネット上の最新情報を取得

    次回



     
     

    npaka

     
     
    プログラマー。iPhone / Android / Unity / ROS / AI / AR / VR / RasPi / ロボット / ガジェット。年2冊ペースで技術書を執筆。アニソン / カラオケ / ギター / 猫 twitter : @npaka123

    あなたへのおすすめ