10分で壊れたエージェントの解剖
RTX 4060 (8GB) + Qwen2.5-7Bで、こんなタスクを投げた: 「プロジェクト内の未使用ファイルを見つけて整理して」。
最初の3分は順調だった。file_searchでディレクトリを走査し、read_fileで中身を確認し、「これは使われていない」と正確に判断した。4分目、判断が怪しくなった。直前に読んだファイルの内容を忘れたかのように、同じファイルを再度読み始めた。7分目、突然web_searchを呼び出した。ファイル整理と無関係。10分目、「整理が完了しました」と報告したが、実際には何も整理されていなかった。
フレームワークのバグだと思った。LangChainからCrewAIに変えた。同じ場所で壊れた。llama.cppの素のAPIで書き直した。やはり同じ。
壊れ方が一致している以上、原因はフレームワークではない。コンテキスト管理だ。
あの10分間に何が起きていたかを分解すると、5つの崩壊パターンが重なっていた。3分目の「前の結果を忘れる」はパターン①(中間結果の埋没)。4分目の「同じツールを再呼び出し」はパターン④(自己参照ループ)の初期兆候。7分目の「無関係なツール呼び出し」はパターン⑤(目的の忘却)とパターン②(ツール定義の干渉)の合流。そしてこれら全体の進行を加速していたのがパターン③(KVキャッシュ枯渇)。
この記事では、この5つの崩壊パターンを個別に分解し、それぞれの検知方法と対処法を書く。8GBでは「ごまかしが効かない」ため崩壊が早く、原因の切り分けがしやすい。クラウドの大型モデルでも同じことは起きる——ただし100ステップ目で。
崩壊パターン①: 中間結果の埋没 (Lost in the Middle)
症状: エージェントループの3-4回目のツール呼び出し結果が参照されなくなる。最初の指示と最新の結果だけが使われる。
原因: Liu et al. (TACL 2024) が報告した「Lost in the Middle」現象。LLMはコンテキストの先頭と末尾に注意が偏り、中間の情報を事実上無視する。エージェントの場合、初期のツール結果がちょうど中間に押し出される。
8GBでの発現タイミング: 7Bモデルで5-8ステップ(約5,000-7,000トークン)。大型モデルではもう少し遅れるが、MicrosoftとSalesforceの研究(arXiv:2505.06120)ではマルチターン会話(エージェントワークフローに類似)で6つの生成タスクの平均39%性能低下を報告している。メカニズムは「中間情報の忘却」だけでなく「初期の誤った回答への固執」も含むが、コンテキスト蓄積に伴う劣化という点では同根だ。
対処法:
def build_context_with_recency_boost(history, max_items=5):
"""中間結果の埋没を防ぐ: 最新N件のみコンテキストに含める"""
system = history[0] # システムプロンプトは常に先頭
recent = history[-max_items:] # 直近N件
# 古い結果は要約して1エントリに圧縮
if len(history) > max_items + 1:
old_summary = summarize(history[1:-max_items])
return [system, {"role": "system", "content": f"過去の作業要約: {old_summary}"}] + recent
return [system] + recent
崩壊パターン②: ツール定義の干渉 (Tool Definition Overload)
症状: 正しいツールが選ばれない。存在しないツールを呼ぼうとする。ツールの引数が別のツールのスキーマと混ざる。
原因: ツール定義自体がコンテキストを消費する。10ツール×300トークン=3,000トークンがツール定義だけで消費される。7Bモデルでは実質的な「思考に使える」コンテキストが半減する。さらに、似た機能のツールがあると選択精度が落ちる。Berkeleyのfunction-calling leaderboardでもツール数増加に伴う精度低下が確認されている。
8GBでの閾値: 7Bモデルで5ツールが実用上限。10ツール以上では選択ミスが頻発。
対処法:
TOOL_GROUPS = {
"search": ["web_search", "file_search", "grep_code"],
"code": ["run_python", "read_file", "write_file"],
"data": ["sql_query", "csv_parse", "visualize"],
}
def select_tools(query: str, all_tools: dict, max_tools: int = 5) -> list:
"""クエリのカテゴリに応じてツールサブセットを動的選択"""
# 軽量分類: キーワードマッチで十分
if any(w in query.lower() for w in ["検索", "探", "find", "search"]):
group = "search"
elif any(w in query.lower() for w in ["コード", "実行", "run", "python"]):
group = "code"
else:
group = "data"
selected_names = TOOL_GROUPS.get(group, list(all_tools.keys())[:max_tools])
return [all_tools[name] for name in selected_names if name in all_tools]
崩壊パターン③: KVキャッシュ枯渇によるサイレント劣化
症状: エラーは出ないが、応答が急に短くなる、または繰り返しが増える。「正常に動いているように見えるが品質が落ちている」のが厄介。
原因: KVキャッシュがVRAMの上限に近づくと、llama.cppは古いKVエントリを破棄して新しいトークンの空間を確保する。エラーにはならないが、破棄されたコンテキストは参照できなくなる。
8GBでの発現: Qwen2.5-7B Q4_K_M全層GPUの場合、KVキャッシュに使えるのは約2.5-3GB。FP16 KVキャッシュでctx 8192は約1-2GB消費するため即座にOOMにはならないが、エージェントのツール呼び出しでトークンが蓄積すると圧迫が始まる。--cache-type-k q8_0でKVキャッシュを圧縮すると余裕が増える。
検知方法:
import requests
def check_kv_cache_pressure(server_url="http://localhost:8080"):
"""llama-serverのKVキャッシュ使用状況を確認"""
resp = requests.get(f"{server_url}/health")
if resp.ok:
data = resp.json()
slots = data.get("slots", [])
for slot in slots:
n_ctx = slot.get("n_ctx", 0)
n_past = slot.get("n_past", 0)
if n_ctx > 0:
usage = n_past / n_ctx
if usage > 0.8:
print(f"⚠ KVキャッシュ使用率 {usage:.0%} — コンテキストリセット推奨")
return True
return False
崩壊パターン④: 自己参照ループ (Self-Reference Loop)
症状: エージェントが自分の前の応答を「ツール結果」として扱い、それを根拠にさらに応答を生成する無限ループ。実際のツール実行は行われず、ハルシネーションが自己増殖する。
原因: コンテキスト内のロール区別が曖昧になる。特にassistantメッセージとtool resultメッセージの境界が長いコンテキストで溶ける。7Bモデルでは8ステップ以降でこの現象が顕著。
対処法: ツール実行の有無を外部で検証する。
class AgentLoop:
def __init__(self):
self.executed_tools = [] # 実際に実行されたツールのログ
def step(self, llm_response):
if llm_response.tool_call:
# ツールを実際に実行
result = self.execute(llm_response.tool_call)
self.executed_tools.append({
"tool": llm_response.tool_call.name,
"timestamp": time.time(),
"result_hash": hash(str(result))
})
return result
# ツール呼び出しなしの応答が3回連続 → ループ検知
recent_no_tool = sum(1 for r in self.history[-3:] if not r.get("tool_call"))
if recent_no_tool >= 3:
print("⚠ 自己参照ループ検知 — コンテキストリセット")
self.reset_context()
崩壊パターン⑤: 目的の忘却 (Goal Drift)
症状: エージェントが元のタスクと無関係なツールを呼び始める。「ファイルを整理して」と頼んだのに、途中からウェブ検索を始めるなど。
原因: コンテキストが長くなると、システムプロンプトの「元のタスク指示」に対する注意重みが相対的に低下する。特にツール結果が大量のテキストを返す場合、タスク指示がコンテキスト全体の1%以下になることもある。
8GBでの発現: 7Bモデルで10ステップ以降。32Bクラスでも長期タスクでは発生する。
対処法: 目的をコンテキストに定期的に再注入する。
def inject_goal_reminder(context, original_goal, interval=3):
"""N手ごとに元の目的をコンテキストに再注入"""
step_count = sum(1 for msg in context if msg["role"] == "assistant")
if step_count > 0 and step_count % interval == 0:
context.append({
"role": "system",
"content": f"【リマインダー】元のタスク: {original_goal}\n"
f"現在{step_count}ステップ目。目的に沿って次のアクションを選択してください。"
})
return context
5パターンに共通する構造
5つの崩壊パターンを並べると、根底にある問題は1つ:
コンテキストウィンドウは「記憶」ではなく「注意の予算」である。
記憶なら入れた情報は保持される。しかしTransformerのコンテキストウィンドウは注意機構を通じて情報を参照するため、情報量が増えると1情報あたりの注意量が希釈される。
| パターン | 注意の希釈が起きる場所 |
|---|---|
| ①中間結果の埋没 | 位置バイアスで中間部への注意が減る |
| ②ツール定義の干渉 | ツール定義が注意予算を食い、判断に使える注意が減る |
| ③KVキャッシュ枯渇 | 物理的にコンテキストが切り詰められる |
| ④自己参照ループ | ロール境界への注意が減り、区別がつかなくなる |
| ⑤目的の忘却 | タスク指示への相対的注意が減る |
対処法も共通の原則に収束する:
- コンテキストを短く保つ — 要約して持ち越す、古い情報を捨てる
- 重要な情報を再注入する — 目的リマインダー、ツール定義の動的選択
- 外部で監視する — KVキャッシュ使用率、ループ検知、ツール実行検証
フレームワークの選択より、この3原則を徹底する方がエージェントの安定性に効く。
8GBだから見えること
クラウドの128Kコンテキストモデルでは、これらの崩壊が「100ステップ目」で起きる。8GBの7Bモデルでは「5ステップ目」で起きる。現象は同じ。発現のタイミングが違うだけ。
8GB環境は「エージェント設計のストレステスト環境」として優秀だ。ここで動くエージェントは、クラウドに移しても壊れない。逆は真ではない。
この記事のテーゼ: LLMエージェントの安定性を決めるのは、モデルサイズでもフレームワークでもなく、コンテキストの品質管理。コンテキストウィンドウを「無限の記憶」ではなく「有限の注意予算」として設計した瞬間、エージェントは壊れなくなる。
参考
- "Lost in the Middle: How Language Models Use Long Contexts" (Liu et al., TACL 2024)
- "LLMs Get Lost In Multi-Turn Conversation" (Microsoft Research & Salesforce, arXiv:2505.06120)
- Berkeley Function-Calling Leaderboard: https://gorilla.cs.berkeley.edu/leaderboard.html
- llama.cpp server health endpoint: https://github.com/ggerganov/llama.cpp/blob/master/examples/server/README.md