エージェントの評価を CI に入れた初日、私は自分の評価コードを疑いました。コードを一文字も変えていないのに、朝のビルドでは緑、昼のビルドでは赤。同じテストケースが通ったり落ちたりするのです。
最初は「フレーキーなテストを直そう」と考えました。でも、これは直すべきバグではありませんでした。確率的なシステムを二値の合否で測ろうとしたこと自体が、設計のずれだったのです。
個人開発で Antigravity のエージェントを書いていると、評価そのものより「評価を信用できる状態にする」ことのほうがずっと難しい、と私は感じています。ここでは、揺れる評価ゲートを落ち着かせ、本物の劣化だけを赤にするための合否設計を、実装の手触りごと共有します。
揺らぎを「直す」のではなく「測る」
確率的なエージェントの出力は、毎回少しずつ違います。temperature を 0 に落としても、ツール呼び出しの順序、外部 API の応答、コンテキストの詰まり具合で挙動は揺れます。
ここで二値の passed: true / false を使うと、判定が境界線の上で振動します。スコア 0.79 と 0.81 のあいだに 0.8 の閾値を置けば、実体は同じエージェントなのに、試行ごとに合否が反転する。これは情報の損失です。連続量を無理やり一点で切ったために、揺らぎがそのまま合否のノイズになっています。
解決の方向はひとつ。1回の合否を捨て、複数試行のスコア分布で判断することです。同じケースを n 回走らせ、平均と散らばりを見る。散らばりが小さければ自信を持って閾値で切れますし、散らばりが大きければ「このケースはそもそも不安定だ」という別の情報が得られます。
// eval-types.ts
// 1回の試行ではなく、複数試行の集約を評価の最小単位にする
export interface TrialResult {
score: number; // 0.0 - 1.0(部分点を許容する連続スコア)
latencyMs: number;
toolCallCount: number;
failures: string[];
}
export interface CaseStats {
caseId: string;
trials: number;
meanScore: number;
stdDev: number; // 散らばり = 不安定さの指標
ciLow: number; // 平均スコアの95%信頼区間 下限
p95LatencyMs: number; // 平均ではなく裾を見る
}
export function aggregate(caseId: string, results: TrialResult[]): CaseStats {
const n = results.length;
const scores = results.map((r) => r.score);
const mean = scores.reduce((a, b) => a + b, 0) / n;
const variance = scores.reduce((a, s) => a + (s - mean) ** 2, 0) / n;
const stdDev = Math.sqrt(variance);
// 標準誤差から95%信頼区間の下限を出す(z=1.96)
const stdErr = stdDev / Math.sqrt(n);
const ciLow = mean - 1.96 * stdErr;
const latencies = results.map((r) => r.latencyMs).sort((a, b) => a - b);
const p95LatencyMs = latencies[Math.min(latencies.length - 1, Math.floor(n * 0.95))];
return { caseId, trials: n, meanScore: mean, stdDev, ciLow, p95LatencyMs };
}ここで ciLow(信頼区間の下限)を採用しているのが要点です。平均が 0.85 でも散らばりが大きければ下限は 0.7 まで落ちる。「運が良ければ通る」エージェントを通さないためには、楽観値である平均ではなく、悲観的な下限でゲートを引くほうが安全です。私は試行回数を 5 回以上に取り、信頼水準は 95% で運用することを推奨しています。試行が 3 回程度だと信頼区間が広がりすぎて、ゲートがほとんど機能しません。
ノイズによる失敗と、本物の劣化を切り分ける
複数試行で散らばりを測れるようになると、次の問いが立ちます。「赤くなったとき、それはノイズなのか、本当に悪くなったのか」。これを取り違えると、ノイズに振り回されて本物の劣化を見逃します。本番環境に出してから「実はあの変更で落ちていた」と気づくのは、評価ゲートの一番大きな落とし穴です。
私が使っているのは、絶対閾値ではなくベースライン比較です。main ブランチの最新スコアを基準として保存しておき、新しい変更がそこから有意に下がったときだけ赤にする。固定の 0.8 という線ではなく、「前回より悪化したか」を見るわけです。
// regression-gate.ts
// 絶対閾値ではなく、ベースラインからの有意な悪化を検出する
import type { CaseStats } from "./eval-types";
export interface GateVerdict {
pass: boolean;
reason: string;
regressions: string[];
flaky: string[];
}
export function judgeAgainstBaseline(
current: CaseStats[],
baseline: Record<string, { meanScore: number; stdDev: number }>,
): GateVerdict {
const regressions: string[] = [];
const flaky: string[] = [];
for (const cur of current) {
const base = baseline[cur.caseId];
// 散らばりが大きすぎるケースは「不安定」として隔離する。
// ゲートを赤にはせず、別枠で可視化して改善対象にする。
if (cur.stdDev > 0.2) {
flaky.push(`${cur.caseId}: stdDev=${cur.stdDev.toFixed(2)}(不安定・要改善)`);
continue;
}
if (!base) continue; // 新規ケースはベースラインなし
// 悪化の判定: 信頼区間の下限が、ベースライン平均から
// ノイズ幅(ベースラインの標準偏差)を超えて下回ったか。
const noiseBand = Math.max(0.05, base.stdDev);
if (cur.ciLow < base.meanScore - noiseBand) {
regressions.push(
`${cur.caseId}: ${base.meanScore.toFixed(2)} → ${cur.meanScore.toFixed(2)}(CI下限 ${cur.ciLow.toFixed(2)})`,
);
}
}
const pass = regressions.length === 0;
return {
pass,
reason: pass ? "ベースラインからの有意な劣化なし" : `${regressions.length}件の回帰を検出`,
regressions,
flaky,
};
}この設計には実用上の効きどころが二つあります。ひとつは noiseBand。ベースライン自身の散らばりをノイズ幅として使うので、もともと揺れやすいケースには甘く、安定しているケースには厳しく判定が効きます。もうひとつは、不安定なケースをゲートから隔離して flaky に逃がしていること。不安定さを理由にビルドを赤にしない。代わりに「このケースは設計を見直す対象」として別枠で見えるようにします。赤を本物の劣化だけに予約することで、赤の信号が信用される状態を保てます。