日々のログからシステム異常の有無をLLMエージェントに判定させる監視ツールを動かしている。日次バッチの中でプロンプトを投げ、エージェントループが返したJSON結果を記録する仕組みだ。
ある週の記録を見返すと、9月14日から9月18日の5日間のうち、実に3日も判断結果が残っていなかった。
丸ごと落ちていた。
最初はプロンプトの解釈が崩れたか、通信が切れたのかと思った。だがログの底を洗うと、壊れ方は全く別の2箇所で同時に起きていた。
5日中3日の判断未達と2つの原因

5日中3日の欠損はコード柵による構文エラーとターン枯渇の2系統に分かれていた
欠損の内訳をログから追うと、驚くほど綺麗に原因が割れていた。片方は回答の解釈で死に、もう片方は回答へ辿り着く前に死んでいる。
| 日付 | 発生した現象 | 内部の停止理由 |
|---|---|---|
| 9/15 | 唯一のターンでツール呼び出しを実行 | 設定により拒否され回答ゼロで終了 |
| 9/16 | コード柵(```json)付きで回答 | 構文エラーとして応答を全破棄 |
| 9/18 | 別モデルでもコード柵付きで回答 | 同じく構文エラーとして破棄 |
9月16日はモデルが正しい判断内容を出力していた。しかし親切心を出したのか、全体を```jsonというコードフェンスで囲んでいた。
受け取り側のPythonコードは、中身を直接標準のjson.loadsへ流し込んでいた。マークダウンの記号が混ざった文字列を標準関数が解釈できるはずもない。Pythonのjson.loadsはマークダウンラッパーを処理できないため、構文エラーを起こして応答全体を壊れたデータとして捨てていた。
中身は無傷のJSONなのに、外側の囲いだけでゴミ箱行きになっていたわけだ。
ツール拒否によるターン消費の罠

最大1ターンではツールの拒否メッセージを受け取った瞬間にループが強制停止する
一方で9月15日の欠損は、パース以前の問題だった。
設定で最大ターン数を1に制限していたところ、モデルが最初に外部ツールの呼び出しを試みてしまった。エージェントループは1回の呼び出しを1ターンとして数えるため、その行動自体が1ターンをまるまる食いつぶす。
この環境ではそのツール呼び出しが設定で拒否される構成になっていた。モデルは拒否された事実を受け取っただけで、次の回答を出力する枠が残っていない。
[1ターン目開始]
-> モデル: ツール呼び出しを要求
-> システム: 呼び出しを拒否
[ターン上限(1)到達により終了]
-> 最終回答: なし(欠損)
max_turnsの上限に達すると結果フィールドは空のまま返る。
回答を吐き出すための余白が最初から無かった。
いくらパーサーを寛容に作り替えても、これでは手前で弾かれて欠損は1日も減らない。
コード柵抽出パスとターン数の倍増

フェンス内JSONの抽出パスと最大2ターンの確保で確実な出力を担保する
どちらか片方だけ直しても穴が残る。パースとループ設定の両方を同時に直すことにした。
まず受け取り処理に、フェンスで囲まれたJSONを取り出す専用パスを入れた。文字列中からJSONとして解釈可能な部分を抽出する手法を参考にしつつ、フェンスの内側だけを確実に切り出してパースへ回す。
フェンスのない適当な散文は従来どおり弾く。
なんでも許容するガバガバな判定にすると、壊れたテキストを誤って採用する危険があるからだ。外側の余計な装飾だけを剥がし、芯のJSONだけを拾い上げる。
同時に、モデルの最大ターン数を1から2へ引き上げた。これでツール呼び出しが拒否されても、もう1ターン使って素直にテキスト回答を書き直せる。
渡す資料もプロンプトの指示内容も一切変えていない。
回帰ピン留めと既存設定の拒否

実機ログによる回帰防止ピンと新旧設定の安全な切り替えを検証した
修正を確実なものにするため、9月16日に落ちた実機の応答データをそのまま使ってテストを書いた。旧実装では例外を吐いて落ち、新実装では綺麗にJSONとして抜けることを確認する。
実応答を使った回帰防止のピンを2件追加し、関連する96件のテストをすべて通過させた。
さらに、コード側のインターフェースが変わったことで、古い設定ファイルは設計どおりバリデーションで弾かれる。
意図した検証失敗だ。
設定の切り替えが中途半端なまま動く事故をこれで完全に防げる。
プロンプトで「JSONだけを出せ」といくら叫んだところで、モデルは気まぐれに柵を立ててくる。外枠を剥がすコード数行とターン上限を2に書き換えてから、プロンプトの文句を削ってモデルにお願いする時間を全部やめた。中身が無傷なら、囲いごと受け止めて手元で剥く方がよほど確実で早い。
