ReAct 論文と共に読み解く strands-agents/sdk-python の実装

に公開

こんにちは、AWS Japan で Solutions Architect をしている yoheikikuta です。

2025 年は AI エージェントの開発が盛んになっており、多くの人が何かしらの AI エージェントを作ってみたことがあるのではないでしょうか。

一方で、LLM を用いたエージェントとしてどのような枠組みに基づいて効果的に振る舞っているのか、その枠組みがどのように実装されているのか、という観点で深掘りをしたことがある人はそれほどいないかもしれません。

本ブログでは、AI エージェントを作る人がその土台となる部分の理解を深めるための一助として、LLM がエージェントとして効果的に振る舞うための枠組みを示した 2022 年の論文 ReAct: Synergizing Reasoning and Acting in Language Models https://arxiv.org/abs/2210.03629v3 と、AI エージェント実装のための SDK である https://github.com/strands-agents/sdk-python について解説します。

本ブログは AWS AI Agent ブログ祭り(Zenn: #awsaiagentblogfes, X: #AWS_AI_AGENT_ブログ祭り)の第 16 日目です。

ReAct: Synergizing Reasoning and Acting in Language Models

これは 2022 年に公開された論文で、本ブログ執筆時点で Google Scholar では 5,400 件程度の引用がなされており、様々なエージェント開発用 SDK にもこの論文で提唱された枠組みが実装されています。

タイトルにある通り Reasoning と Acting を組み合わせたものであり、reasoning は Chain-of-Thought のような推論を指し、acting はタスク特有の行動を指します。
これらはもともと別個のトピックとして研究されてきましたが、例えば料理においては「パン生地はどう作るのか?知らないのでインターネットで調べよう」と言語を用いて推論した後に実際にインターネットで調べる行動をするなど、推論と行動は互いに密接に関係してタスクを遂行していくものです。
ReAct は LLM を活用することで推論と行動を組み合わせる汎用的なパラダイムを提唱しました。

定式化を見てみましょう。以下のように記号を定義します。

  • t: time step (1 つの time step で観測と行動をそれぞれ 1 回ずつ)
  • a_t \in \mathcal{A}: 行動 (例えばインターネットでレシピを調べる)
  • o_t \in \mathcal{O}: 環境からの観測 (例えばレシピの情報が与えられる)
  • \pi (a_t \mid c_t): コンテキスト c_t = (o_1, a_1, \dots, o_{t-1}, a_{t-1}, o_t) が与えられた時にエージェントがどのような行動を取るかのポリシー

コンテキストから行動へのマッピングは暗黙的であるため、AI エージェントに期待するような複雑な状況では良いポリシーを得るのは難しくなります。

例えば、以下のような QA タスクを実行する場合を考えます。
質問に対する答えはキーボードですが、Chain-of-Thought ではハルシネーションが生じて(正しくは Apple Remote は Front Row を操作するために開発されたもの)しまっています。
また、行動と観測のみでは一連の流れについて複雑な推論ができないので、仮に観測の中に正しい情報が含まれていたとしても正しい最終結果を生成できていません。

原論文の Figure 1 から一部抜粋した図
https://arxiv.org/abs/2210.03629 の Figure 1 から一部抜粋

これに対する ReAct のアプローチはシンプルで、言語空間 \mathcal{L} での行動 \hat{a}_t \in \mathcal{L} も加えてエージェントの行動空間を \hat{\mathcal{A}} = \mathcal{A} \cup \mathcal{L} と拡張します。

言語空間での行動とは、論文では思考(thought)や推論の軌跡(reasoning trace)と呼ばれています。
これは、自然言語による行動計画の作成(例えば、生地の作り方をインターネットで調べる)、観測からの情報抽出(例えば、レシピから材料の部分だけ抜き出す)、常識知識の注入(例えば、適量と書いてあった場合にどの程度かを持っている知識から定める)などが含まれます。

言語空間での行動 \hat{a}_t は環境との相互作用がないので観測が得られるものではありませんが、現在のコンテキスト c_t を基に推論することで有用なコンテキスト c_{t+1} = (c_t, \hat{a}_t) を構築し、将来より良い行動を取れるようにする役割を担います。

ReAct のアプローチはシンプルですが、LLM 登場以前であれば言語空間 \mathcal{L} のような広大すぎる空間も含めて学習するのは無謀でした。
LLM の登場により、高い汎用性や様々な事前知識を有するモデルが現実のものとなったので、事前学習済みの LLM を用いる(論文では PaLM-540B を使用)ことで ReAct のアプローチが現実的なものとなりました。
c_{t+1} = (c_t, \hat{a}_t) というコンテキストの構築も LLM においては簡単で、最もシンプルなものとしてはこれまでの情報をプロンプトに含めて推論し、その推論の結果を次のプロンプトに含めていくというだけです。実際に論文でもそのように構築しています。

先ほどと同様の QA タスクを ReAct で解いた場合が以下となります。
行動と観測のみではなく思考も陽に含まれており、思考をして計画を立て、行動をし、観測から情報を抜き出し、問題が発生した場合に情報を付与して行動を改善し、それら一連の情報を踏まえて適切に終了できています。

原論文の Figure 1 から一部抜粋した図
https://arxiv.org/abs/2210.03629 の Figure 1 から一部抜粋

実際に QA タスクを解く際には以下のようなセットアップをしています。

  • a_t は Wikipedia の Web API を使った検索 search[entity](entity のページの最初の 5 文を扱う)と、得られた検索情報を辿っていく lookup[string](string を含む次の文章を返す)と、タスクを終了して最終的な回答をする finish[answer] から成る
  • o_t は上記 a_t の結果得られるテキスト情報やもしくは失敗した場合の情報(search の場合は類似する entity の候補名テキストで lookup の場合は見つからなかったというテキスト)から成る
  • \hat{a}_t は LLM の出力テキストで、ReAct の枠組みに沿って出力がなされるように、タスク開始時に few-shot learning として質問に対して 思考 → 行動 → 観測 が繰り返される理想的なステップをプロンプトに含める

本ブログではこのセットアップの詳細や具体的なタスクに対する性能までは言及しませんが、原論文の時点では驚くほど高い性能を発揮するには至っていません。
しかしながら、ReAct パラダイムが有用であること、LLM 自体の性能向上に加えて Tool Use により行動の幅と質が向上していること、などに伴い、現在では広く活用されています。

strands-agents/sdk-python の実装

ReAct の枠組みがどのように実装されているのかを、AWS が提供している OSS のエージェント開発用 SDK である strands-agents/sdk-python https://github.com/strands-agents/sdk-python/tree/ccc3a8b を読み解いて理解を深めてみましょう。
本ブログではコードを一部だけ取り出して説明しているため読みづらいところもあると思いますので、興味があればソースコード全体をご覧ください。

ちなみに strands-agents が ReAct に大きく影響を受けていることは公式ブログ https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/ でも言及されています。

strands-agents では、概念的には以下のように Agent が LLM による推論や Tool の実行を繰り返しながらタスクを遂行していきます。

AWS ブログから図の引用
https://aws.amazon.com/blogs/opensource/introducing-strands-agents-an-open-source-ai-agents-sdk/ から引用

本ブログでは、以下の Agent クラスの __call__ メソッドを辿ることで、この Agentic loop がどのように実装されていて、ReAct の枠組みがどのように実現されているかを読み解いていきます。

src/strands/agent/agent.py#L393-L432
class Agent:
    # 省略
    def __call__(
        self,
        prompt: AgentInput = None,
        *,
        invocation_state: dict[str, Any] | None = None,
        structured_output_model: Type[BaseModel] | None = None,
        **kwargs: Any,
    ) -> AgentResult:
        # 省略
        return run_async(
            lambda: self.invoke_async(
                prompt, invocation_state=invocation_state, structured_output_model=structured_output_model, **kwargs
            )
        )

self.invoke_async の中では、イベントループを実行して最終的な結果を生成する self.stream_async というメソッドを呼びます。
この self.stream_async は、以下のようにイベントループを実行して最後の stop イベントで AgentResult を生成します(分散トレーシング用の処理や内部用/外部用のイベントを分けるコールバックについてはここでは無視します)。

src/strands/agent/agent.py
    async def stream_async(
        # 省略
        # Process input and get message to add (if any)
        messages = await self._convert_prompt_to_messages(prompt)

        self.trace_span = self._start_agent_trace_span(messages)

        with trace_api.use_span(self.trace_span):
            try:
                events = self._run_loop(messages, merged_state, structured_output_model)

                async for event in events:
                    event.prepare(invocation_state=merged_state)

                    if event.is_callback_event:
                        as_dict = event.as_dict()
                        callback_handler(**as_dict)
                        yield as_dict

                result = AgentResult(*event["stop"])
                callback_handler(result=result)
                yield AgentResultEvent(result=result).as_dict()

具体的なイベントループの中身を理解するために self.run_loop を辿っていくと、イベントループのサイクルを規定するための本丸である関数 event_loop_lifecycle(event_loop.py で定義されているもの)に行き着きます。

この event_loop_lifecycle は以下のような処理を実行します。

  • ツール実行をする場合は LLM の呼び出しはスキップして _handle_tool_execution でツールを実行
  • ツール実行をしない場合は _handle_model_execution で LLM による推論を実行
  • recurse_event_loop で再帰実行(ツール実行の場合は _handle_tool_execution の中で recurse_event_loop を呼ぶように実装されている)
  • (最大トークン数に達成するなど例外が発生しない場合は)再帰チェーンの最後で EventLoopStopEvent を生成

部分的に切り出しているので情報が不足しているところもありますが、実際のコードは以下となります。

src/strands/event_loop/event_loop.py
async def event_loop_cycle(
    # 省略
    # Skipping model invocation if in interrupt state as interrupts are currently only supported for tool calls.
    if agent._interrupt_state.activated:
        stop_reason: StopReason = "tool_use"
        message = agent._interrupt_state.context["tool_use_message"]
    # Skip model invocation if the latest message contains ToolUse
    elif _has_tool_use_in_latest_message(agent.messages):
        stop_reason = "tool_use"
        message = agent.messages[-1]
    else:
        model_events = _handle_model_execution(
            agent, cycle_span, cycle_trace, invocation_state, tracer, structured_output_context
        )
        async for model_event in model_events:
            if not isinstance(model_event, ModelStopReason):
                yield model_event

        stop_reason, message, *_ = model_event["stop"]
        yield ModelMessageEvent(message=message)

    try:
        if stop_reason == "max_tokens":
            # 省略
            raise MaxTokensReachedException(
                message=(
                    "Agent has reached an unrecoverable state due to max_tokens limit. "
                    "For more information see: "
                    "https://strandsagents.com/latest/user-guide/concepts/agents/agent-loop/#maxtokensreachedexception"
                )
            )

        if stop_reason == "tool_use":
            # Handle tool execution
            tool_events = _handle_tool_execution(
                stop_reason,
                message,
                agent=agent,
                cycle_trace=cycle_trace,
                cycle_span=cycle_span,
                cycle_start_time=cycle_start_time,
                invocation_state=invocation_state,
                tracer=tracer,
                structured_output_context=structured_output_context,
            )
            async for tool_event in tool_events:
                yield tool_event

            return

        # 省略
        events = recurse_event_loop(
            agent=agent, invocation_state=invocation_state, structured_output_context=structured_output_context
        )
        # 省略
    yield EventLoopStopEvent(stop_reason, message, agent.event_loop_metrics, invocation_state["request_state"])

LLM の推論やツールの実行の処理の詳細までは踏み込んでいませんが、これが Agentic loop の大まかな実装となります。

これで ReAct の構成要素として必要な、思考・行動・観測は実行できます(観測については触れていませんが、ツール実行の結果を受け取ることに相当します)。
ReAct の枠組みではこれらの要素が連携して賢くタスクを解く必要がありますが、これは原論文でも使われていたように各要素の情報を逐次保持して LLM が思考する際に渡すことで実現できます。

具体的には、Agent は Messages というデータ型で思考・行動・観測の情報を保持し、これは以下のように各ステップで追加していくのみです。
次のコードにおいて、上でモデル推論(思考)、下でツール実行(行動)とその結果(観測)、を追加して格納していることが分かります。

https://github.com/strands-agents/sdk-python/blob/ccc3a8b/src/strands/event_loop/event_loop.py#L403-L405

https://github.com/strands-agents/sdk-python/blob/ccc3a8b46d71d11531c85277f815049cc1760bb4/src/strands/event_loop/event_loop.py#L504-L510

このように逐次保持した Messages の情報を使って LLM が推論をすることで、次に必要なツールを実行したり観測結果から情報を抜き出したりという賢い振る舞いが可能となります。

Messages の定義

コードをそのまま眺めるだけではイメージが持ちづらいので、最後に典型的な例でこれらの処理の流れとエージェントが保持する Messages を見てみましょう。
例えば、ユーザーが「パン生地の作り方を知りたい」と入力した場合に、AI エージェントがウェブ検索を計画してウェブ検索を実行し結果を整理して返す、という処理をイベントループに沿って追ってみます。

  • self.stream_async における self._convert_prompt_to_messages で Messages に追加される
    Messagesに追加される要素
    {
      "role": "user",
      "content": [{"text": "パン生地の作り方を知りたい"}]
    }
    
    • 1 つ目の event_loop_cycle において _handle_model_execution で LLM が上記 Messages を受けてツールでウェブ検索をすることを計画する
      Messagesに追加される要素
      {
        "role": "assistant",
        "content": [
            {"text": "レシピを調べるためにウェブ検索を行います。"},
            {"toolUse": {"toolUseId": "tooluse_001", "name": "web_search", "input": {"query": "パン生地 レシピ"}}}
        ]
      }
      
    • ツール使用条件により _handle_tool_execution が呼ばれてウェブ検索の結果を返す
      Messagesに追加される要素
      {
        "role": "user",
        "content": [
            {
                "toolResult": {
                    "toolUseId": "tooluse_001",
                    "content": [{"text": '{"results": [{"title": "パン生地のレシピ", ...}]}'}]
                }
            }
        ]
      },
      
      • _handle_tool_execution の中の recurse_event_loop で 2 つ目の event_loop_cycle が開始され、これまでの Messages を受けて_handle_model_execution で LLM が情報をまとめる
        Messagesに追加される要素
          {
              "role": "assistant",
              "content": [
                  {
                      "text": "検索結果から、パン生地のレシピをご紹介します:\n【材料】\n..."
                  }
              ]
          }
        
      • LLM がタスクが終了したと判断して EventLoopStopEvent が生成されて 2 つ目のイベントループが終了
    • _handle_tool_execution の return で 1 つ目のイベントループが終了
  • self.stream_async が AgentResultEvent を生成

このような形で 思考 → 行動 → 観測 が繰り返される ReAct の枠組みが実現しています。

まとめ

本ブログでは、AI エージェントの土台となっている ReAct 論文と、その観点に基づいて strands-agents/sdk-python を読み解きました。
他のエージェント開発 SDK には触れていませんが、例えば https://github.com/openai/openai-agents-python も同様の実装となっています。

普段実装している AI エージェントが、なぜうまく動いていて、どのように実装されているのか、を知るのは楽しいですね。

アマゾン ウェブ サービス ジャパン (有志)

Discussion