Bedrock Agentsが無限ループした話——8ヶ月本番運用で踏んだ状態管理の地雷

「なぜかAgentがループする」「前の文脈を忘れる」——Bedrock Agentsのマルチステップ設計、思ってたより全然動かなくて困りませんでしたか?8ヶ月の本番運用で掴んだ知見を正直に書きます。

マルチステップAgentで「なぜかループする」問題が起きた話

先日、社内のカスタマーサポート自動化基盤でBedrock Agentsのマルチステップオーケストレーションを本格導入してから8ヶ月が経った。最初の3ヶ月は正直地獄で、本番でAgentが無限ループに入ったり、前のステップの文脈を忘れてユーザーに同じ質問を繰り返したり、一見動いているのに最終ステップに到達しなかったりと、想定外の挙動のオンパレードだった。

そもそも「Agentが複数ステップを自律的に判断して動く」という設計は、実装した当初の期待通りにはほぼ動かない。これはBedrockの問題というより、マルチステップAgentの設計思想をちゃんと理解していなかった自分たちの問題だったと今では思っている。同じような痛みを踏んでいる人は多いと思うので、うちのチームが8ヶ月かけて掴んだ知見を正直に書いていく。

なお、Bedrock Flows全体のワークフロー設計については過去にBedrock Flows本番運用3ヶ月で気づいた、LLMワークフロー自動化の地味だけど痛い落とし穴でも触れているので、そちらも合わせて読んでほしい。


うちのアーキテクチャ構成(2026年時点)

現在の本番構成はこんな感じ。マルチステップAgentが複数のAction GroupとKnowledge Baseを跨いで動く設計になっている。

graph TB
  subgraph Internet
    User([ユーザー / Slack / Web])
  end

  subgraph AWS_Cloud[AWS Cloud]
    subgraph API_Layer[API Layer]
      APIGW[API Gateway v2\nHTTP API]
      Lambda_Entry[Lambda\nEntrypoint\nPython 3.13]
    end

    subgraph Agent_Core[Bedrock Agent Core]
      Agent[Bedrock Agent\nClaude 3.7 Sonnet]
      Orchestrator[Orchestration Engine\nマルチステップ制御]
      MemoryStore[Agent Memory\nSession Context]
    end

    subgraph Action_Groups[Action Groups]
      AG_Search[Action Group\nSearch & Retrieve]
      AG_DB[Action Group\nDB Operations]
      AG_Notify[Action Group\nNotification]
      AG_Escalate[Action Group\nHuman Escalation]
    end

    subgraph Knowledge_Base[Knowledge Base]
      KB[Bedrock Knowledge Base\nRAG基盤]
      OSS[(OpenSearch\nServerless)]
      S3_KB[(S3\nドキュメント格納)]
    end

    subgraph Lambda_Actions[Action Lambda群]
      L_Search[Lambda\nSearch]
      L_DB[Lambda\nDB Query]
      L_Notify[Lambda\nSES/SNS]
      L_Escalate[Lambda\nEscalation]
    end

    subgraph Data_Layer[Data Layer]
      RDS[(Aurora PostgreSQL\nカスタマーDB)]
      DDB[(DynamoDB\nセッション状態管理)]
      S3_Log[(S3\nAgentトレースログ)]
    end

    subgraph Observability[Observability]
      CW[CloudWatch\nLogs & Metrics]
      XRay[X-Ray\nトレーシング]
      CW_Dash[CloudWatch\nDashboard]
    end
  end

  User --> APIGW
  APIGW --> Lambda_Entry
  Lambda_Entry --> Agent
  Agent --> Orchestrator
  Orchestrator --> MemoryStore
  Orchestrator --> AG_Search
  Orchestrator --> AG_DB
  Orchestrator --> AG_Notify
  Orchestrator --> AG_Escalate
  Orchestrator --> KB
  KB --> OSS
  KB --> S3_KB
  AG_Search --> L_Search
  AG_DB --> L_DB
  AG_Notify --> L_Notify
  AG_Escalate --> L_Escalate
  L_Search --> RDS
  L_DB --> RDS
  L_Notify --> CW
  L_Escalate --> DDB
  MemoryStore --> DDB
  Agent --> S3_Log
  Lambda_Entry --> CW
  Agent --> XRay
  CW --> CW_Dash

ポイントは、セッション状態をDynamoDBで外部管理している点と、AgentのトレースログをS3に全量保存していること。最初はこの外部状態管理をサボっていたのが、後述するループ問題の一因になった。「どうせAgentが文脈を持ってくれるでしょ」という甘い考えが全ての始まりだった。


マルチステップ設計の主な落とし穴と対策

落とし穴1:InstructionとAction Groupの粒度が粗すぎる問題

最初に実装したSystem Promptはこんな感じだった。

あなたはカスタマーサポートAgentです。
ユーザーの問題を解決するために、適切なツールを使って情報を収集し、
最終的に解決策を提示してください。

これが地雷。Claudeはかなり賢いのでそれなりに動くんだけど、どのステップで何のAction Groupを呼ぶべきか、判断が曖昧になってループが発生した。具体的には「情報が不足している → 検索する → まだ不足 → また検索する」という無限サイクルに入りやすかった。個人的には「賢いモデルに任せれば大丈夫」という過信が一番の失敗だったと思っている。

2026年5月時点の修正後のSystem Promptはこう変えた。

あなたはカスタマーサポートAgentです。以下のステップで厳密に動作してください。

## 必須ステップ順序
Step 1: ユーザーの問題カテゴリを特定する(search_knowledgebaseを1回のみ呼ぶ)
Step 2: 該当カテゴリが「アカウント系」の場合のみget_customer_infoを呼ぶ
Step 3: Step 1,2の結果を統合して解決策を生成する
Step 4: 解決策が不明な場合のみescalate_to_humanを呼ぶ

## 制約
- 同一のAction Groupを連続して2回以上呼ぶことは禁止
- Step 3に到達したら必ずユーザーに返答する
- 情報が不完全でも推測で補完してStep 3に進む

「同一Action Groupの連続呼び出し禁止」という制約を明示したことで、ループ発生率がほぼゼロになった。地味だけどマジで効いた。自由度を与えすぎず、Agentの動きを「型にはめる」発想の転換が大きかった。

落とし穴2:Session Contextのサイズ上限問題

2026年Q1時点のBedrock Agentsは、セッションコンテキストのトークン制限に引っかかるとAgentがサイレントに前の文脈を切り捨てる。これが厄介で、前のステップでユーザーから取得した情報を後のステップで「知らない」かのように動作する原因になった。エラーが出るわけでもなく、ただ静かに情報が消えるのがたちが悪い。

うちのチームは以下の実装でこれを回避している。

import boto3
import json
from typing import Any

bedrock_agent = boto3.client('bedrock-agent-runtime', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb')
state_table = dynamodb.Table('agent-session-state')

def invoke_agent_with_state_management(
    session_id: str,
    user_message: str,
    agent_id: str,
    agent_alias_id: str
) -> str:
    """
    外部DynamoDBでセッション状態を管理しながらAgentを呼び出す
    Contextサイズ上限対策として重要情報をsessionStateで明示的に渡す
    """
    # 前のステップで抽出した重要情報をDDBから取得
    existing_state = get_session_state(session_id)
    
    # session_attributesで重要情報を明示的にAgentに渡す
    # Agentのcontext windowに依存しない形で情報を保持
    session_state = {
        'sessionAttributes': {
            'customer_id': existing_state.get('customer_id', ''),
            'issue_category': existing_state.get('issue_category', ''),
            'step_count': str(existing_state.get('step_count', 0)),
            'already_searched': existing_state.get('already_searched', 'false')
        },
        'promptSessionAttributes': {
            # こちらはSystem Promptに埋め込まれる形で渡される
            'current_step': existing_state.get('current_step', 'Step1'),
            'context_summary': existing_state.get('context_summary', '未取得')
        }
    }
    
    response = bedrock_agent.invoke_agent(
        agentId=agent_id,
        agentAliasId=agent_alias_id,
        sessionId=session_id,
        inputText=user_message,
        sessionState=session_state,
        enableTrace=True  # 本番でもトレースは必ずON
    )
    
    # レスポンス処理とトレース保存
    completion = ''
    trace_data = []
    
    for event in response['completion']:
        if 'chunk' in event:
            completion += event['chunk']['bytes'].decode('utf-8')
        if 'trace' in event:
            trace_data.append(event['trace'])
    
    # トレースをS3に保存(デバッグ必須)
    save_trace_to_s3(session_id, trace_data)
    
    return completion

def get_session_state(session_id: str) -> dict:
    try:
        response = state_table.get_item(
            Key={'session_id': session_id}
        )
        return response.get('Item', {}).get('state', {})
    except Exception:
        return {}

def save_trace_to_s3(session_id: str, trace_data: list) -> None:
    s3 = boto3.client('s3')
    s3.put_object(
        Bucket='agent-trace-logs',
        Key=f'traces/{session_id}/{int(__import__("time").time())}.json',
        Body=json.dumps(trace_data, default=str)
    )

Action Group側(Lambda)では、処理結果をDynamoDBに書き戻す実装も必要。このひと手間を省くと、Agentが「まだその操作やってない」と思い込んで同じAction Groupを何度も呼び出す羽目になる。

def update_session_state(session_id: str, updates: dict) -> None:
    """Action Group Lambdaから呼び出して状態を更新する"""
    existing = get_session_state(session_id)
    existing.update(updates)
    
    state_table.put_item(
        Item={
            'session_id': session_id,
            'state': existing,
            'ttl': int(__import__('time').time()) + 3600  # 1時間でTTL
        }
    )

def lambda_handler(event: dict, context: Any) -> dict:
    """Action Group Lambda - search_knowledgebaseの実装例"""
    agent = event['agent']
    parameters = event.get('parameters', [])
    session_id = agent['sessionId']
    
    # パラメータ取得
    query = next(
        (p['value'] for p in parameters if p['name'] == 'query'),
        ''
    )
    
    # 検索処理(省略)
    results = search_internal_kb(query)
    
    # 重要:検索済みフラグを外部状態に保存
    # これがないとAgentが「まだ検索してない」と誤判断してループする
    update_session_state(session_id, {
        'already_searched': 'true',
        'current_step': 'Step2',
        'context_summary': results[:500]  # 要約して保存
    })
    
    return {
        'messageVersion': '1.0',
        'response': {
            'actionGroup': event['actionGroup'],
            'apiPath': event['apiPath'],
            'httpMethod': event['httpMethod'],
            'httpStatusCode': 200,
            'responseBody': {
                'application/json': {
                    'body': json.dumps({'results': results, 'status': 'searched'})
                }
            }
        }
    }

落とし穴3:エラーハンドリングの非対称性

Action GroupのLambdaがタイムアウトやエラーを返したとき、Agentの挙動が結構カオスになる。エラーレスポンスをちゃんと返さないと、AgentはそのAction Groupを「使えない」と判断して別のAction Groupで代替しようとする。これが意図しない挙動を生む。正直、エラーの形式をサボったことで一番多くの時間を溶かした気がする。

正しいエラーレスポンス形式はこう。

def lambda_handler(event: dict, context: Any) -> dict:
    try:
        result = process_request(event)
        return build_success_response(event, result)
    except TimeoutError as e:
        # タイムアウトは明示的にAgentに伝える
        return build_error_response(
            event,
            status_code=408,
            error_message=f"処理がタイムアウトしました。再試行してください: {str(e)}"
        )
    except PermissionError as e:
        # 権限エラーはエスカレーションを促す
        return build_error_response(
            event,
            status_code=403,
            error_message="権限が不足しています。human_escalateアクションを使用してください。"
        )
    except Exception as e:
        return build_error_response(
            event,
            status_code=500,
            error_message=f"内部エラー: {str(e)}"
        )

def build_error_response(event: dict, status_code: int, error_message: str) -> dict:
    return {
        'messageVersion': '1.0',
        'response': {
            'actionGroup': event['actionGroup'],
            'apiPath': event['apiPath'],
            'httpMethod': event['httpMethod'],
            'httpStatusCode': status_code,
            'responseBody': {
                'application/json': {
                    'body': json.dumps({
                        'error': error_message,
                        'should_escalate': status_code in [403, 500]
                    })
                }
            }
        }
    }

エラーレスポンスにshould_escalateフィールドを含めてAgentに判断材料を与えるのが地味に効く。System Promptに「responseBodyにshould_escalateがtrueの場合はescalate_to_humanを呼ぶ」と明記することで、エラー後の挙動が安定した。Agentに「次どうすればいい?」を教えてやるイメージ。


本番8ヶ月の指標変化

実際にどれくらい改善したか、数値で見てみる。

xychart-beta
  title "Bedrock Agentsマルチステップ 品質指標推移"
  x-axis ["2025-11", "2025-12", "2026-01", "2026-02", "2026-03", "2026-04", "2026-05", "2026-06"]
  y-axis "スコア (%)" 0 --> 100
  line [41, 48, 55, 67, 74, 81, 87, 92]
  bar [58, 51, 44, 32, 25, 18, 13, 8]

※折れ線:タスク完了率(目標ステップに到達した割合)、棒グラフ:ループ発生率

導入当初はタスク完了率41%、ループ発生率58%という惨状だった。8ヶ月後の現在は完了率92%、ループ発生率8%まで改善できている。ただ正直に言うと、残り8%はまだ解決できていない。エッジケースが絡む複合エラー時に発生しやすく、現在も検証中だ。完全ゼロには程遠い。


2026年時点でのモデル別マルチステップ性能比較

うちのチームで検証した結果をまとめると以下の通り。数値はあくまで自社ユースケース(カスタマーサポート自動化、平均4ステップ構成)での実測値なので参考程度に。

モデルタスク完了率平均ステップ数ループ発生率コスト/1000リクエスト備考
Claude 3.7 Sonnet92%3.88%$12.4現在本番採用
Claude 3.5 Haiku81%4.214%$3.1コスト重視なら
Nova Pro78%4.618%$5.8日本語やや弱い
Nova Lite61%5.131%$1.2単純タスクのみ
Llama 3.3 70B69%4.922%$4.2要命令チューニング

Claude 3.7 Sonnetが一番安定している。コストは高いが、ループ発生率の差が運用コスト(エンジニアの対応工数)で十分に回収できると判断した。Nova ProはAWS公式の推しモデルだけど、マルチステップの複雑な判断ではまだSonnetに劣る印象がある。日本語でのニュアンス解釈が少し甘いと感じるシーンもあった。ここは予算感や要件で変わってくるかもしれない。

モデル評価の落とし穴についてはBedrock モデル評価を本番で半年やって気づいた、比較の落とし穴と実務的な選び方にも書いているので参考にしてほしい。


オーケストレーション設計で今やっていること

8ヶ月運用してたどり着いた、マルチステップAgentの設計フローはこんな構造になっている。

flowchart TD
  Start([ユーザーリクエスト受信]) --> ValidateInput{入力バリデーション\n& セッション初期化}
  ValidateInput -->|新規セッション| InitState[DynamoDB\nセッション状態初期化]
  ValidateInput -->|継続セッション| LoadState[DynamoDB\n既存状態ロード]
  InitState --> InvokeAgent
  LoadState --> InvokeAgent

  InvokeAgent[Bedrock Agent\nInvoke] --> TraceCapture[トレース収集\n& S3保存]
  
  TraceCapture --> StepJudge{AgentのOrchestration\nTrace解析}
  
  StepJudge -->|Action Group呼び出し| ActionExec[Action Group Lambda実行]
  ActionExec --> StateUpdate[DynamoDB\n状態更新]
  StateUpdate --> LoopDetect{ループ検出\n同一AG連続呼び出し?}
  
  LoopDetect -->|検出| ForceEscalate[強制エスカレーション\n& アラート送信]
  LoopDetect -->|正常| InvokeAgent
  
  StepJudge -->|Knowledge Base検索| KBSearch[Bedrock KB\nRetrieve & Generate]
  KBSearch --> InvokeAgent
  
  StepJudge -->|最終回答生成| FinalAnswer[レスポンス生成]
  FinalAnswer --> QualityCheck{品質チェック\n回答に具体情報含む?}
  
  QualityCheck -->|NG| FallbackEscalate[ヒューマンエスカレーション]
  QualityCheck -->|OK| ResponseUser[ユーザーへ返答]
  
  ForceEscalate --> AlertOps[CloudWatch\nアラート + SNS通知]
  FallbackEscalate --> AlertOps
  ResponseUser --> CleanupState[セッション状態\nクリーンアップ]
  CleanupState --> End([終了])
  AlertOps --> End

「ループ検出」をオーケストレーション層(Agent呼び出し側のLambda)でも独立して実装しているのがポイント。Agentのトレースログを解析して、同一Action Groupが連続3回以上呼ばれたら強制的に処理を打ち切る仕組みだ。これがないと、System Promptでいくら制約を書いても、エッジケースでループが発生したとき延々と課金され続けるリスクがある。「Agentを信用しすぎない」という姿勢でフェイルセーフを二重に持つのが、8ヶ月運用して出た結論だった。

またRAGの精度についてはKnowledge Bases側の設計も重要で、Bedrock Knowledge Bases RAGを本番運用して6ヶ月、「なんか惜しい」を乗り越えた話が参考になると思う。


まとめ

8ヶ月の本番運用を通じて痛感した要点を整理するとこうなる。

#ポイント効果
1System Promptでステップ順序と制約を明示的に定義ループ発生率が劇的に低下
2セッション状態はDynamoDBで外部管理文脈の欠落によるループが解消
3Action GroupのエラーレスポンスにAgentへのヒントを含めるエラー後の挙動が安定
4トレースログは本番でもS3に全量保存原因調査の工数が大幅に削減
5ループ検出はAgentに頼らず外部Lambdaで実装無限課金リスクを排除

これから着手するなら、まずSystem Promptのステップ定義と制約の見直しが今日からできる一番手軽なアクションだ。enableTrace: Trueでトレースを眺めてみると、Agentが何を考えて動いているかが見えてきて、問題の所在がわかりやすくなる。外部状態管理はコードの変更が必要だが、それでも2〜3日あれば試せるはず。

皆さんのチームでBedrockのマルチステップAgent、どう設計してますか?まだループが収まらない、状態管理に困っているという方は気軽にコメントで教えてください。同じ問題で頭を抱えているエンジニアが世界中にいるはずなので。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事