金曜の夜に、無人で回している自動処理のエージェント指示を一行だけ直しました。「成果物が長すぎるときは要約する」という、良かれと思った追記です。土曜の朝にログを開くと、エージェントは要約を優先するあまり、本来そのまま残すべき数値表まで畳んでいました。壊れてはいません。ただ、静かにずれていたのです。
私は個人開発で、複数のサイト運用や AdMob 周りの集計を無人の自動処理に任せています。単発の API 呼び出しなら、出力を一度目視すれば済みます。ところが Managed Agents は隔離サンドボックスの中で計画・推論・ツール実行・ファイル操作までを自分で進めるため、同じ入力でも通る経路が毎回わずかに変わります。「一度動いたから大丈夫」が最も効かない相手です。
だから賢くする話に入るより手前で、エージェントの挙動を本番に上げる前に固定シナリオで測る層 を敷く設計を共有したいと思います。単発プロンプトのテストとは考え方から変える必要があります。
エージェントは単発プロンプトのようにはテストできない
単発のプロンプトなら、評価は素直です。入力を固定し、出力を期待値と突き合わせ、差分を見る。多少の揺れは温度や表現の問題で、目視か軽い一致判定で捕まえられます。
エージェントでは前提が三つ崩れます。
第一に、多段 であること。最終成果物が同じでも、途中でどのツールをどの順に呼んだかが変わります。禁止したはずの外部書き込みを一度だけ挟んでいても、成果物だけ見ていれば気づけません。
第二に、環境状態 を持つこと。エージェントはサンドボックス内のファイルや会話履歴を読みながら進みます。初期状態が一行違えば、同じ指示でも別の経路に入ります。テストは「入力」だけでなく「初期環境」ごと固定しなければ再現しません。
第三に、非決定性 。温度をゼロに寄せても、ツール応答の順序やタイミングで分岐が揺れます。一回通ったことは、次も通る保証になりません。一致判定を一度きりで下すと、たまたまの成功を昇格させてしまいます。
ここから導かれる方針は明確です。出力の完全一致ではなく、満たすべき不変条件を複数回の反復で合格率として測る 。この一点に評価設計を寄せます。
固定シナリオを「初期環境 + タスク + 不変条件」で定義する
回帰スイートの最小単位を、私は次の三点セットで持っています。初期環境のスナップショット、与えるタスク、そして満たすべき不変条件。出力そのものは書きません。書けないからです。
一つのシナリオを JSON で表すと、こうなります。
{
"id" : "summarize-keep-tables-001" ,
"seed_files" : {
"input/report.md" : "seed/report_with_tables.md"
},
"task" : "input/report.md を読み、長すぎる本文は要約すること。ただし数値表はそのまま保持し、output/result.md に書き出すこと。" ,
"invariants" : {
"artifact_exists" : [ "output/result.md" ],
"must_contain_table" : true ,
"forbidden_tools" : [ "web_write" , "external_egress" ],
"max_steps" : 12 ,
"max_cost_usd" : 0.04
},
"repeats" : 5
}
seed_files がこのシナリオの「初期環境」です。実運用で一度事故った入力ほど良い種になります。私は冒頭の「表まで畳まれた」ケースを、そのまま report_with_tables.md として固定しました。回帰スイートは、過去に一度でも転んだ場所を二度と踏まないための記録でもあります。
invariants には、出力の中身ではなく性質 だけを書きます。「表が残っていること」「禁止ツールを呼んでいないこと」「12ステップ以内」「0.04ドル以内」。表現が毎回変わっても、この性質が保たれていれば合格とみなします。
何を測るか — 出力一致ではなく軌跡と不変条件
測る対象を、成果物・軌跡・コストの三層に分けて考えると設計が締まります。
層 測る不変条件 判定のしきい値(例)
成果物 必須ファイルの生成/必須要素の保持(表・見出し等) 合格率 ≥ 0.9
軌跡 使用ツールが許可リスト内/禁止ツールの呼び出しゼロ 禁止呼び出し率 = 0
コスト ステップ数・トークン・サンドボックス稼働時間 p95 ステップ ≤ 上限
要点は、軌跡を「完全一致」で縛らないことです。「ステップ 3 で必ずツール A を呼ぶ」と書くと、非決定性のせいでほぼ毎回落ちます。代わりに許可リスト方式 を採ります。呼んでよいツールの集合を定義し、その外を一度でも触れたら不合格。順番や回数は問わない。これなら揺れを許しつつ、越えてはいけない一線だけを守れます。
禁止呼び出し率だけは、しきい値をゼロに置きます。ここは合格率で丸めてはいけない一線です。外部書き込みや scope 外の egress は、5回に1回でも起きたら本番に出せません。
評価ハーネスを実装する(Python)
シナリオを一つ受け取り、指定回数だけエージェントを走らせ、run trace から不変条件を判定し、合格率に畳む。この一連を素朴な Python で組みます。API 呼び出しの細部は環境で変わるので、launch_agent_run と fetch_run_trace は薄いアダプタとして切り出しています。
import json, statistics
from pathlib import Path
from dataclasses import dataclass
@dataclass
class RunResult :
artifacts: set[ str ]
tools_used: list[ str ]
steps: int
cost_usd: float
output_text: str
def evaluate_invariants (inv: dict , r: RunResult) -> dict :
checks = {}
checks[ "artifact_exists" ] = all (a in r.artifacts for a in inv.get( "artifact_exists" , []))
if inv.get( "must_contain_table" ):
checks[ "table_kept" ] = "<table" in r.output_text or "|---" in r.output_text
forbidden = set (inv.get( "forbidden_tools" , []))
checks[ "no_forbidden_tool" ] = forbidden.isdisjoint( set (r.tools_used))
checks[ "within_steps" ] = r.steps <= inv.get( "max_steps" , 10 ** 9 )
checks[ "within_cost" ] = r.cost_usd <= inv.get( "max_cost_usd" , 10 ** 9 )
return checks
def run_scenario (scn: dict , launch_agent_run, fetch_run_trace) -> dict :
per_run, forbidden_hits, steps_all, cost_all = [], 0 , [], []
for _ in range (scn.get( "repeats" , 5 )):
run_id = launch_agent_run( task = scn[ "task" ], seed_files = scn[ "seed_files" ])
r = fetch_run_trace(run_id) # RunResult に整形して返すアダプタ
checks = evaluate_invariants(scn[ "invariants" ], r)
per_run.append( all (checks.values()))
if not checks[ "no_forbidden_tool" ]:
forbidden_hits += 1
steps_all.append(r.steps); cost_all.append(r.cost_usd)
n = len (per_run)
return {
"scenario" : scn[ "id" ],
"pass_rate" : sum (per_run) / n,
"forbidden_rate" : forbidden_hits / n,
"p95_steps" : sorted (steps_all)[ int ( 0.95 * (n - 1 ))],
"mean_cost_usd" : round (statistics.mean(cost_all), 4 ),
}
def run_suite (suite_dir: str , launch_agent_run, fetch_run_trace) -> list[ dict ]:
results = []
for p in sorted (Path(suite_dir).glob( "*.json" )):
scn = json.loads(p.read_text( encoding = "utf-8" ))
results.append(run_scenario(scn, launch_agent_run, fetch_run_trace))
return results
fetch_run_trace は、Managed Agents の実行トレースを RunResult に写すだけの薄い層にしておきます。ここを薄く保つと、API の形が変わってもハーネス本体を触らずに済みます。私は run trace のツール名を tools_used に、サンドボックスの稼働時間を分単位でコストに換算して cost_usd に載せています。
合格率としきい値で昇格を決める
スイートの結果を、人の判断を挟まず一つの合否に落とします。判定は保守側に倒します。一つでも致命的なしきい値を割ったら、全体を不合格にする。
def gate (results: list[ dict ],
min_pass = 0.9 , max_forbidden = 0.0 , step_ceiling = 12 ) -> tuple[ bool , list[ str ]]:
reasons = []
for r in results:
if r[ "forbidden_rate" ] > max_forbidden:
reasons.append( f ' { r[ "scenario" ] } : 禁止ツール呼び出し { r[ "forbidden_rate" ] :.0% } ' )
if r[ "pass_rate" ] < min_pass:
reasons.append( f ' { r[ "scenario" ] } : 合格率 { r[ "pass_rate" ] :.0% } < { min_pass :.0% } ' )
if r[ "p95_steps" ] > step_ceiling:
reasons.append( f ' { r[ "scenario" ] } : p95 ステップ { r[ "p95_steps" ] } > { step_ceiling } ' )
return ( len (reasons) == 0 , reasons)
数字の置き方には理由があります。合格率のしきい値を 0.9 にしているのは、非決定性を織り込んだうえで「10回に9回は不変条件を満たす」を最低ラインにしたいからです。反復回数 5 は、私の手元では合格率の推定が実用的に安定し始める下限でした。ここを 3 に落とすと、たまたま全部通って昇格させてしまう事故が増えます。逆に禁止ツールのしきい値だけは 0 に固定し、合格率とは別枠で見ます。安全に関わる一線は、平均で丸めないという判断です。
影 → カナリア → 全面の段階昇格と自動ロールバック
ゲートを通っても、いきなり全面には出しません。段階を踏みます。
影実行 : 新しい構成を本番と並走させ、成果物は破棄する。ログだけ取り、実運用の入力分布でも不変条件を満たすかを確認します。
カナリア 10% : 実際の処理のうち10%だけを新構成へ回します。残り9割は現行のまま。逸脱が出ても影響が限定されます。
全面 : カナリアの合格率が本番でも維持されていれば、全量を切り替えます。
この切り替えを一箇所で握るために、役割から構成への間接層を挟みます。呼び出し側は「要約役」を指すだけで、実体がどの構成かを知りません。
AGENT_BINDINGS = {
"summarizer" : "cfg_2026_07_01" , # 現行
"summarizer_candidate" : "cfg_2026_07_06" ,
}
CANARY = { "summarizer" : 0.10 } # 候補へ回す割合
def resolve_config (role: str , bucket: float ) -> str :
if bucket < CANARY .get(role, 0.0 ):
return AGENT_BINDINGS [ f " { role } _candidate" ]
return AGENT_BINDINGS [role]
ロールバックは、この辞書を一行戻すだけで完了します。CANARY をゼロにすれば、次の処理から全量が現行構成へ帰ります。デプロイを巻き戻す必要も、コードを直す必要もありません。無人で回している以上、事故ったときに人手を介さず即座に戻せること が、賢さより先に効いてきます。私はカナリア中の合格率を定期的にゲートへ通し、割れた瞬間に CANARY を自動でゼロへ落とすようにしています。
運用して見えた、ドキュメントに書かれていないこと
しばらく回して分かったのは、公式の手順書には出てこない勘所がいくつかあることです。
反復回数はコストと相談になります。 シナリオ1本を5回、スイートに20本あれば、一回の評価で100回のエージェント実行が走ります。サンドボックスの稼働時間も積み上がるので、評価そのものに予算境界が要ります。私は変更の重さで反復回数を変え、安全に関わる構成変更だけ 5 回、軽微な文言調整は 3 回に落として、評価コストを月あたりの上限内に収めています。
シードは腐ります。 初期環境のスナップショットは、モデルや外部データが更新されると前提がずれます。シードにもバージョンを振り、四半期に一度は「このシナリオはまだ意味があるか」を棚卸ししないと、通っているのに現実を測れていない状態に陥ります。
軌跡の許可リストは、最初は広めに始めます。 いきなり狭く縛ると、正当な回り道まで不合格になり、回帰スイートを信じられなくなります。まず観測して実際に使われたツールを集計し、そこから安全に外せるものだけを禁止側へ移す。順番を逆にすると運用が続きません。
成果物の受け入れとは層を分けます。 一回一回の出力を通す受け入れゲートは、本番運用時のオンラインの防波堤です。ここで書いた回帰スイートは、構成やプロンプトを変えたときに事前に 挙動を測るオフラインの層です。役割が違うので、両方を持って初めて安心して手を入れられます。
次の一手
まずは、無人で回している自動処理を一つだけ選んでください。そのうち過去に一度でも事故った入力を種にして、シナリオを3本だけ書く。不変条件は完全一致ではなく、「禁止ツールを呼ばない」「必須の成果物が残る」の二つから始めれば十分です。
現行構成でベースラインの合格率を測っておけば、次にプロンプトを一行直したとき、その一行が挙動を良くしたのか静かに壊したのかを、朝のログを見て青ざめる前に知れます。私自身、この層を挟んでから、無人の処理に手を入れる指が軽くなりました。
同じように無人の自動処理を抱える方の、夜の安心につながれば幸いです。最後までお読みいただき、ありがとうございました。