AIエージェント本番6ヶ月で地獄を見た|プロンプト忘却と状態管理の失敗から学んだ構成

本番運用で痛い目を見たAIエージェント開発の実体験。プロンプト忘却、メモリ管理地獄、マルチエージェント調整の落とし穴を具体例とともに解説。

AIエージェント本番運用6ヶ月で火を噴いた話|マルチエージェント設計の失敗と今の構成

先日プロジェクトでAIエージェントを本番投入したんですが、半年で地獄を見ました。当初は「LLMなら何でもできる」くらいの甘い考えでいたんですよ。実装も割と簡単だし、デモも動く。なのに本番に乗せた瞬間から、毎日のように予期しない挙動に悩まされてる。

今日は、その6ヶ月間で踏んだ地雷と、ようやく落ち着いた構成について書きます。同じ轍を踏んでる人がいたら参考になるはず。

本番投入時点での設計は「こんなもんでしょ」だった

うちのチームは3月にLangChainベースのシンプルなエージェント構成で本番化しました。ユーザーのクエリを受け取ったら、複数の外部APIを呼び出して、最終的に回答をまとめるというやつです。流れはこんな感じだったんですよ:

  1. ユーザー入力を受け取る
  2. Claude 3.5で意図分類
  3. 該当ツール(API)を複数実行
  4. 結果をまとめて返却

当時は「これくらいで十分」って思ってた。なぜなら、社内テスト環境では99%の確率で正しく動いてたから。

でも6月くらいに最初のでかい問題が発生したんです。

プロンプト忘却問題:LLMが前後の文脈を次々と忘れる

これが地獄だった。会話の流れの中で、ユーザーが「さっき言った条件で」みたいに指示するわけです。当然、エージェントは前の会話を参考にすべきなんですが、メモリ管理が甘い実装だと、LLMが「何のことですか?」を繰り返し始める。マジでイライラした。

うちの初期実装は、会話履歴を単純にテキスト連結してプロンプトに突っ込んでた。

# 初期実装:単純な履歴連結
conversation_history = "\n".join([
    f"User: {msg['content']}" for msg in messages
])

prompt = f"""You are a helpful assistant.

Conversation History:
{conversation_history}

Current query: {current_query}
"""

これ、トークン数が伸び続けるし、古い情報とかぶるし、そもそもLLM側で「どの情報が重要か」判断できてないんですよ。3月のテストではユーザーの会話が数十件だったから問題なかったけど、本番で100件200件って積み重なると、トークンオーバーで切り詰められるし、LLMが混乱する。もう何度も同じ説明をさせられました。

4月中旬から対策を打ちました。

Bedrock Agentsに乗り換えて、状態管理をAWS側に任せることにしたんです。Bedrockは会話履歴の要約を内部でやってくれるから、単純なテキスト連結より安定してる。

# Bedrock Agentsへの乗り換え
import boto3

bedrock_agent_runtime = boto3.client('bedrock-agent-runtime')

response = bedrock_agent_runtime.invoke_agent(
    agentId='your-agent-id',
    agentAliasId='YFSL7VKQHW',
    sessionId=user_id,  # ユーザーごとのセッション管理
    inputText=current_query
)

sessionIdで管理することで、エージェント側が自動的に前後の文脈を保持してくれるようになった。これ地味に便利です。

マルチエージェント調整の地獄:エージェント同士が喧嘩し始める

5月になって、ユースケースが複雑になって、1つのエージェントじゃ捌ききれなくなった。そこで「複数のスペシャリストエージェント + オーケストレーターエージェント」という構成を導入したんです。

イメージとしてはこんな感じだったんですよ:

graph TD
    A[User Query] --> B[Orchestrator Agent]
    B --> C[Data Query Agent]
    B --> D[Report Agent]
    B --> E[Analysis Agent]
    C --> F[返却]
    D --> G[返却]
    E --> H[返却]
    F --> I[Orchestrator Agent<br/>結果をまとめる]
    G --> I
    H --> I
    I --> J[User Response]

これが予想外の問題を起こした。複数のエージェントに同じクエリを送ったら、それぞれが独立してAPI呼び出しをして、同じデータに複数回アクセスしちゃう。エラーハンドリングも各エージェントでバラバラで、一つが失敗したら全体が失敗する。状態追跡も地獄。

あと、オーケストレーター自体もLLMで判断させてたから、「どのエージェントに処理を委譲するか」の判断で遅延が発生する。複数エージェントが並列で走れば速いはずなのに、実際は逆に遅くなってた。レイテンシは3倍になってましたよ。

ここから本気で改良を始めました。

現在(2026年版):落ち着いた構成

現在(7月)の構成は、エージェント数を減らして、むしろ単一のパワフルなエージェント + ツール定義の工夫に切り替えました。これで一気に安定した。

graph TB
  subgraph Client["ユーザー層"]
    UserInput["ユーザー入力"]
  end

  subgraph Orchestration["オーケストレーション"]
    SessionMgmt["セッション管理<br/>Redis"]
    StateStore["状態ストア<br/>DynamoDB"]
  end

  subgraph Agent["メインエージェント層"]
    BedrockAgent["Bedrock Agents<br/>Claude 3.5"]
    ToolDef["ツール定義<br/>ReAct"]
  end

  subgraph Tools["外部ツール層"]
    DataAPI["Data API"]
    ReportAPI["Report API"]
    AnalysisAPI["Analysis API"]
    Cache["結果キャッシュ<br/>Redis"]
  end

  subgraph Monitoring["監視・ログ"]
    CloudWatch["CloudWatch"]
    TraceLog["エージェント思考ログ<br/>S3"]
  end

  UserInput -->|query + sessionId| SessionMgmt
  SessionMgmt -->|履歴取得| BedrockAgent
  BedrockAgent -->|tool_use| ToolDef
  ToolDef -->|リクエスト+ caching key| DataAPI
  ToolDef -->|リクエスト| ReportAPI
  ToolDef -->|リクエスト| AnalysisAPI
  DataAPI -->|レスポンス| Cache
  ReportAPI -->|レスポンス| BedrockAgent
  AnalysisAPI -->|レスポンス| BedrockAgent
  Cache -->|キャッシュHIT| BedrockAgent
  BedrockAgent -->|最終回答| StateStore
  StateStore -->|状態保存| SessionMgmt
  BedrockAgent -->|thinking + actions| TraceLog
  BedrockAgent -->|メトリクス| CloudWatch

キーポイントは3つですね:

1. 複数エージェント廃止:単一エージェント + 豊富なツールセット

マルチエージェント調整のコストが高すぎました。管理しきれない。今はBedrock Agentsで1つの強力なエージェントを運用して、その配下に複数のツールを定義する方式に統一。

# ツール定義例
tools = [
    {
        "toolSpec": {
            "name": "query_database",
            "description": "データウェアハウスを検索する",
            "inputSchema": {
                "json": {
                    "type": "object",
                    "properties": {
                        "query": {"type": "string"},
                        "filters": {"type": "object"}
                    },
                    "required": ["query"]
                }
            }
        }
    },
    {
        "toolSpec": {
            "name": "generate_report",
            "description": "フォーマットされたレポートを生成する",
            "inputSchema": {
                "json": {
                    "type": "object",
                    "properties": {
                        "data": {"type": "object"},
                        "format": {"type": "string", "enum": ["pdf", "excel"]}
                    },
                    "required": ["data"]
                }
            }
        }
    }
]

ベッドロックが自動的に「どのツールを使う?」「ツールAの後にBを使う?」とかをLLMで判断してくれます。複数エージェント間の同期とかないから、シンプルになった。

2. 状態管理の一元化:DynamoDB + Redis

エージェントの思考過程や中間結果はDynamoDBに記録します。テーブル設計はこんな感じなんですよ:

# DynamoDB テーブル設計
state_table = {
    "PK": "SESSION#<user_id>#<session_id>",
    "SK": "STEP#<timestamp>",
    "attributes": {
        "user_id": "user_123",
        "session_id": "sess_abc123",
        "step_number": 5,
        "agent_thinking": "ユーザーの意図は...",
        "tool_calls": [
            {"tool": "query_database", "input": {...}},
            {"tool": "generate_report", "input": {...}}
        ],
        "tool_results": [...],
        "intermediate_answer": "...",
        "created_at": "2026-07-11T10:30:00Z",
        "ttl": 1720694400
    }
}

こうすることで、エージェントが異常終了しても、前のステップから復旧できるようになった。TTLで古いセッションは自動削除される。便利ですよね。

API呼び出し結果はRedisにキャッシュして、同じクエリが来たら再計算しない。

# キャッシュキーの生成
import hashlib

def get_cache_key(tool_name: str, input_params: dict) -> str:
    param_str = json.dumps(input_params, sort_keys=True)
    hash_val = hashlib.md5(param_str.encode()).hexdigest()
    return f"tool:{tool_name}:{hash_val}"

# 実行時
cache_key = get_cache_key("query_database", {"query": "SELECT..."})
cached_result = redis_client.get(cache_key)

if cached_result:
    result = json.loads(cached_result)
else:
    result = await execute_tool(tool_name, input_params)
    redis_client.setex(cache_key, 3600, json.dumps(result))  # 1時間キャッシュ

3. 監視・デバッグの強化

ここが3月時点で完全に抜け落ちていた部分です。エージェントが何を考えて、どのツールを呼び出して、なぜ失敗したのか——が黒箱だった。マジで困った。

今はReAct(Reasoning + Acting)の思考プロセスをすべてS3に記録しています。

# エージェントの思考ログをS3に保存
import json
from datetime import datetime

def log_agent_trace(session_id: str, trace_data: dict):
    timestamp = datetime.utcnow().isoformat()
    s3_key = f"agent-traces/{session_id}/{timestamp}.jsonl"
    
    trace_entry = {
        "timestamp": timestamp,
        "thinking": trace_data.get("thinking"),  # LLMの思考過程
        "action": trace_data.get("action"),  # 実行するアクション
        "tool_name": trace_data.get("tool_name"),
        "tool_input": trace_data.get("tool_input"),
        "observation": trace_data.get("observation"),  # ツール実行結果
        "tokens_used": trace_data.get("tokens_used"),
        "latency_ms": trace_data.get("latency_ms")
    }
    
    s3_client.put_object(
        Bucket="agent-logs",
        Key=s3_key,
        Body=json.dumps(trace_entry),
        ContentType="application/json"
    )

これでユーザーが「なぜこんな回答なの?」って言った時に、トレースログを見て「ステップ3でこのツール呼び出しが失敗して、フォールバック動作した」とか判断できるようになった。

実装上の地雷と対策

地雷1:ツール呼び出しが並列化されない

Bedrockのデフォルトだと、ツールAツールBツールCってシーケンシャルに実行されます。本番で複数のAPI呼び出しが必要な場合、これだけで数秒遅延する。ユーザーからのクレームがすごかった。

対策:ツール呼び出しを明示的にバッチ化する。

# ツール呼び出しのバッチ化
async def execute_tools_parallel(tools_to_call: list) -> dict:
    tasks = []
    for tool in tools_to_call:
        task = execute_tool_with_timeout(
            tool["name"],
            tool["input"],
            timeout=5
        )
        tasks.append(task)
    
    results = await asyncio.gather(*tasks, return_exceptions=True)
    return {tool["name"]: result for tool, result in zip(tools_to_call, results)}

地雷2:無限ループ/思考の暴走

初期実装で、エージェントが同じツールを何度も呼び出す現象が発生しました。LLM側で「これでいいか?」という判断ができてなくて、延々と試行錯誤を繰り返すんです。マジで焦りましたよ。

対策:ステップ数上限とタイムアウトを厳格に設定する。

response = bedrock_agent_runtime.invoke_agent(
    agentId='your-agent-id',
    agentAliasId='YFSL7VKQHW',
    sessionId=user_id,
    inputText=current_query,
    # 重要:最大ステップ数を制限
    maxIterations=8,  # デフォルトだと制限がない
)

あと、Bedrockの外側でタイムアウトも設定:

import asyncio

try:
    response = await asyncio.wait_for(
        bedrock_agent_runtime.invoke_agent(...),
        timeout=30  # 30秒でタイムアウト
    )
except asyncio.TimeoutError:
    # フォールバック回答
    return {"answer": "処理に時間がかかってます。後でやり直してください"}

地雷3:プロンプトインジェクション + セキュリティ

本番投入直後、ユーザーが「システムプロンプトを無視して…」みたいな指示を試してくる。LLMなのでうっかり乗っかっちゃう。あーもう。

対策:ユーザー入力に対してサニタイズと検証を複層化する。

from langchain.agents import Tool
from typing import Optional
import re

def sanitize_user_input(user_input: str) -> Optional[str]:
    # 1. 長さチェック
    if len(user_input) > 2000:
        raise ValueError("Input too long")
    
    # 2. パターンマッチング:システムプロンプト埋め込みの検出
    dangerous_patterns = [
        r"system prompt",
        r"ignore instructions",
        r"你是",  # 中国語で「あなたは」
        r"كن",  # アラビア語
    ]
    
    for pattern in dangerous_patterns:
        if re.search(pattern, user_input, re.IGNORECASE):
            raise ValueError("Potentially malicious input detected")
    
    # 3. トークンリミットチェック
    token_count = len(user_input.split())
    if token_count > 500:
        raise ValueError("Input too verbose")
    
    return user_input

# ツール定義内でも検証
tools = [
    Tool(
        name="query_database",
        func=lambda query: execute_query(sanitize_user_input(query)),
        description="..."
    )
]

コスト最適化:本番6ヶ月で見えた落とし穴

実装の次に発覚した問題がコスト。Bedrock Agentsを使うと、API呼び出しだけで月50万〜100万円いく場合があります。これはマジでやばかった。

トークン削減の工夫

個人的には、以下の3つが効果的だったんですよ:

  • システムプロンプトの最適化:冗長な説明を削り、必要な情報に絞る
  • ツール定義の簡潔化:複雑なinputSchemaより、シンプルな説明文で済ます
  • キャッシング戦略:同じクエリは絶対に2回実行しない
# ツール定義を簡潔に(トークン節約)
# ❌ 悪い例:冗長な説明
{
    "name": "query_database",
    "description": "This tool queries the data warehouse. It accepts SQL-like queries and returns results. You should use this tool whenever you need to fetch data from the database. The tool supports various filters and aggregations. Make sure to validate your query before sending it."
}

# ✅ 良い例:簡潔
{
    "name": "query_database",
    "description": "Query data warehouse. Returns JSON results."
}

モデル選択:本当にClaude 3.5 Sonnetが必要か?

試しにclaude-3-haikuに落としたら、複雑なマルチステップ判定で失敗率が上がったけど、単純なクエリ処理なら遜色ない。ユースケース分岐で使い分けるのが、実は最適解だと気づきました。

def select_model_for_query(query: str) -> str:
    # 簡単なクエリ: Haiku
    if len(query) < 100 and query.count("and") < 2:
        return "claude-3-5-haiku-20241022"
    
    # 中程度: Sonnet
    elif len(query) < 500:
        return "claude-3-5-sonnet-20241022"
    
    # 複雑: Opus
    else:
        return "claude-3-opus-20240229"

実際のコスト推移を見ると、改善効果がわかりやすいんですよね:

時期月額コスト主な施策
5月(初期)約80万円マルチエージェント、検証不足
6月中旬約35万円キャッシング導入、モデル最適化
7月(現在)約25万円(予想)プロンプト最適化、ツール定義削減

半年で55万円削減できる軌跡。これは無視できない。

まとめ

AIエージェント開発で学んだリアルな話をまとめるとこんな感じです:

マルチエージェントは見た目より難しい エージェント間の同期、状態管理、エラーハンドリングが複雑化しすぎる。単一の強力なエージェント + 豊富なツールセットの方が、本番では安定するんだなあってわかった。

状態管理と監視は最初から入れるべき プロンプト忘却は、セッション管理の不備から始まる。ReAct トレースログがあれば、デバッグが一気に楽になる。3月時点で入れてればと何度も後悔しました。

コスト最適化は後付けじゃ間に合わない トークン削減、キャッシング、モデル選択を設計段階から盛り込む。本番で月100万円超が確定してからでは遅い。

本番での想定外は必ず起こる テスト環境で99%動いても、ユーザー数が増えると新しい使い方が出現する。ツール呼び出しの順番、無限ループ、セキュリティ——すべて予想外だったんですよ。

Bedrock Agentsはようやく実用的になった(2026年時点) 昨年までは不安定だったけど、セッション管理やツール定義の改善で、本番運用できるレベルに来た。ただし、設定次第で地獄を見る。

正直、今もまだ試行錯誤中です。でも、3月時点の「こんなもんでしょ」から、ようやく「本番で使える」レベルに来た感覚。同じハマり方をしてる人がいたら、参考になれば幸い。

そして、これからAIエージェント導入を考えてるなら、設計段階で状態管理と監視の仕組みを用意してください。後付けはマジで苦しいです。

U

Untanbaby

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

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

関連記事