AI エージェント開発実践 #1 エージェントループを堅牢にする

読了 5分

LLM アプリ開発 第10回で、はじめてエージェントを作りました。複数のツールと目標を与えると、Claude が自分で順序を決めて仕事を終えるループでした。あのループは動きますが、実際のサービスに組み込むには穴があります。このシリーズではその穴を一つずつ埋めて、長い作業を任せても崩れないエージェントを作ります。

シリーズは全7回です。ループの堅牢化(第1回)、ツール設計(第2回)、計画と自己修正(第3回)、コンテキスト管理(第4回)、サブエージェント(第5回)、MCP サーバー製作(第6回)を経て、最終の第7回でイシュートリアージエージェントを完成させます。モデルは、エージェントのように複数の段階を自分で判断する作業に最も強い claude-opus-4-8 を使います。

最小ループの穴 #

第10回のループを見直すと、応答の stop_reasonend_turn かツール呼び出しのどちらかだと仮定しています。

minimal_loop.py
if response.stop_reason == "end_turn":
    return next(b.text for b in response.content if b.type == "text")
# そうでなければツールを実行して続ける

実際には他のケースがあります。応答が max_tokens にかかって途中で切れることもありますし、安全上の理由で refusal が返ることもあります。さらにツール関数の中で例外が起きると、ループ全体が落ちます。エージェントは一度回って終わるコードではなく、何十回も繰り返すコードなので、一周あたり 1% の事故もループ全体で見ればよく起きる出来事になります。

stop_reason をすべて処理する #

ループが出会いうる stop_reason を整理するとこうなります。

stop_reason意味ループですること
tool_useツールを呼びたいツールを実行し、結果を返して続ける
end_turn答えを終えた最終テキストを返して終了
max_tokens出力上限にかかって切れた上限を増やして再試行するか、エラーとして処理
refusal安全上の理由で拒否した同じリクエストを繰り返さず終了

分岐をコードに移すとこうなります。

handle_stop_reason.py
if response.stop_reason == "end_turn":
    return final_text(response)

if response.stop_reason == "max_tokens":
    raise RuntimeError("応答が途中で切れました。max_tokens を増やしてください。")

if response.stop_reason == "refusal":
    return "リクエストを処理できません。"

# 残るのは tool_use。ツールを実行してループを続ける。

max_tokens をエラーとして処理するのには理由があります。切れた応答をそのまま会話に積んで回し続けると、Claude は切れた自分の言葉を引き継いで、だんだんおかしな方向へ進みます。切れたという事実を知るほうが、中途半端に続けるよりましです。

ツールのエラーを結果として返す #

ツール関数で例外が出るとループが落ちるのがデフォルトの動作です。より良い方法は、例外を捕まえて、エラーもツールの結果として返すことです。tool_resultis_error の印を付けると、Claude がエラーを読んで別の方法を試します。

tool_error_as_result.py
def execute_tool(block) -> dict:
    try:
        result = run_tool(block.name, block.input)
        return {
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": result,
        }
    except Exception as e:
        return {
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": f"ツールの実行に失敗しました: {e}",
            "is_error": True,
        }

たとえばファイル読み取りツールが「ファイルがありません」というエラー結果を受け取ると、Claude は一覧取得ツールでパスを確認し直すといった形で自力で復旧します。例外で落ちていたら起きなかったことです。

一時的な API エラーは SDK がリトライします #

ループが長くなると、その間にネットワーク障害や一時的な過負荷(429、5xx)に遭う確率も上がります。これは自分で実装する必要がありません。Anthropic SDK がデフォルトで2回まで指数バックオフのリトライを行い、回数は max_retries で調整します。

client_retries.py
client = anthropic.Anthropic(max_retries=4)

完成したループ #

ここまでの内容を合わせたループです。どのツールをどの入力で呼んだかを記録するログも入れました。エージェントがおかしな動きをしたとき、原因を探すにはこのログが事実上唯一の手がかりです。

robust_agent_loop.py
import logging
import anthropic

logger = logging.getLogger("agent")
client = anthropic.Anthropic(max_retries=4)

def run_agent(goal: str, max_steps: int = 20) -> str:
    messages = [{"role": "user", "content": goal}]

    for step in range(max_steps):
        response = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=16000,
            tools=tools,
            messages=messages,
        )

        if response.stop_reason == "end_turn":
            return next(b.text for b in response.content if b.type == "text")
        if response.stop_reason == "max_tokens":
            raise RuntimeError("応答が max_tokens にかかって切れました。")
        if response.stop_reason == "refusal":
            return "リクエストを処理できません。"

        messages.append({"role": "assistant", "content": response.content})

        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                logger.info("step=%d tool=%s input=%s", step, block.name, block.input)
                tool_results.append(execute_tool(block))
        messages.append({"role": "user", "content": tool_results})

    raise RuntimeError(f"{max_steps}ステップ以内に作業を終えられませんでした。")

第10回からの変更点を挙げると、stop_reason の4種類をすべて処理し、ツールの例外はループを落とす代わりに is_error の結果として返し、最大ステップ超過を黙って流さずエラーで知らせます。構造は同じですが、どんな状況でも「なぜ止まったか」が分かるループになりました。

もう一つ、Claude は一つの応答で複数のツールを同時に呼ぶことがあります。上のコードのように response.contenttool_use ブロックをすべて巡回し、ブロックごとに一つずつ tool_result を作って一つのメッセージで返さなければなりません。一つでも欠けると、API が次のリクエストを拒否します。

よくつまずくところ #

  • 切れた応答をそのまま続けるmax_tokens で切れた応答を会話に積んで回し続けると、出力がだんだん崩れます。切れたらエラーとして処理し、上限を増やします。
  • tool_result の数が合わない — 並列呼び出しで一部のツールの結果だけ返すと、「tool_use_id に対応する結果がない」という 400 エラーが出ます。エラーになったツールも is_error の結果で埋めて数を合わせます。
  • ログなしで回す — エージェントが見当違いの結論を出したとき、ログがなければどの段階でずれたのか知るすべがありません。ツール名と入力だけでも記録します。

まとめ #

今回は、最小のエージェントループを実戦レベルに引き上げました。

  • stop_reasontool_useend_turn のほかに max_tokensrefusal まですべて分岐します。
  • ツールの例外はループを落とす代わりに is_error の結果として返し、Claude に自力で復旧させます。
  • 一時的な API エラーは SDK の max_retries に任せ、ツール呼び出しのログを残します。

ループが堅牢になったので、次はその上で動くツールの番です。次回の「AI エージェント開発実践 #2 良いツールを設計する」では、同じループでもエージェントの性能を一変させるツール設計を扱います。

X