系列:「邊做邊補:用一個 AI 代理 mesh 專案,補齊 AI 工程師該有的能力」— 第 4 天
紀錄日期:2026-09-17
能力區:第 9 區 評估 | 類型:做
昨天做了第一個指標:一份輸出裡有多少文字活到最終合併的回答裡。今天要做的是評估的另外兩個要素——讓數字留下來,以及讓兩次結果可以比較。
ai-agent-book 第 7 章把評估拆成評估環境、指標、統計顯著性三件事。昨天只碰了中間那個,而且碰得很淺:數字顯示在手機畫面上,App 一關就沒了。一個不會累積的指標,跟沒有指標的差別只在於「今天心情好一點」。
更根本的是第一個要素:評估環境。昨天那次是「今天哪幾台活著就用哪幾台」,題目是我當場想的。這種資料就算存下來,兩筆之間也不能相減——題目不同、參與者不同、連 coordinator 都不同。
所以今天兩件事:把每次跑的結果寫成一行紀錄存在裝置上;固定一組題目,讓「同一題」這個前提至少成立。
昨天量到的數字是 1/1,五個位置只有一份成品。那個數字太難看,難看到我想知道「它是常態還是那天特別倒霉」。
而我當時沒有辦法回答這個問題,因為除了我腦中的印象之外,沒有任何東西記得前幾次的結果。
這就是評估最現實的價值:它不是為了給系統打分數,是為了讓「上次是不是也這樣」變成一個可以回答的問題。
我以為這件事的難點是「要存到哪」。手機端沒有檔案系統可以隨便寫,收據是給 coordinator 的、不該塞私人筆記,而搬一個資料庫進 App 又太重。
實際做下去發現,難點根本不在儲存,而在決定要記什麼、以及用什麼數字當頭條。存在哪裡是十行程式的事;「這一行要有哪些欄位、總結該用平均還是中位數」才是會影響結論的決定。
每次合併完成,就往裝置的瀏覽器儲存空間寫一筆紀錄:
export interface RunRecord {
task_id: string; // 完整的,不是畫面上那八碼
at_unix: number;
coordinator: string;
manifest_sha8: string | null;
plan_mode: "auto" | "split" | "same";
dispatched: number; // 派出去幾份
completed: number;
failed: number;
pending: number; // 寫這筆時既沒完成也沒失敗的
task_state: string; // 合併當下的任務狀態
merged: boolean;
adoption: { adopted: number; considered: number; ratio: number | null;
shingle: number; threshold: number } | null;
}
幾個刻意的決定:
它不是收據。 收據是給 coordinator 看、要簽章、要能被別人驗證的東西。這份紀錄是發起端自己的記帳,不簽章、不上傳,記的是「這支手機觀察到什麼」。把兩者分清楚很重要:一旦本機記帳混進收據,別人就得信任我這台裝置的時鐘和我自己寫的指標。
shingle 和 threshold 跟著每一筆走。 因為門檻是我拍腦袋定的,之後一定會改。如果只存 ratio,改完門檻的那天,歷史資料就全部變成不可比的垃圾。把參數存在紀錄裡,至少事後知道哪幾筆是同一把尺量的。
上限 50 筆、同一個 task 重複記會覆寫。 手機不是檔案庫;十次就足以看出趨勢。而「同一個任務先用三份輸出合併、後來又有一份回來再合併一次」是一次跑,不是兩次。
覆寫那段就是一行:
export function appendRun(rec: RunRecord, s?: Storage): RunRecord[] {
const next = [rec, ...loadRuns(s).filter((r) => r.task_id !== rec.task_id)].slice(0, RUNLOG_CAP);
if (st) { try { st.setItem(RUNLOG_KEY, JSON.stringify(next)); } catch { /* 配額 / 無痕 */ } }
return next;
}
為什麼是瀏覽器儲存而不是資料庫。 這個 App 的畫面跑在 WebView 裡,手機端本來就沒有可以隨便寫的檔案系統。要嘛用瀏覽器的 key-value 儲存,要嘛開一個 IndexedDB,要嘛透過原生橋接寫檔。五十筆 JSON 大概二十 KB,用最簡單的那個就夠;等到哪天要存幾千筆再說。工程上最貴的不是選錯儲存,是為了「以後可能需要」先付一筆現在用不到的複雜度。
不過瀏覽器儲存有個必須正面處理的特性:它會失敗,而且失敗得很安靜。無痕模式、使用者清掉網站資料、配額滿了、某些情況下連讀都會直接丟例外。所以每一個讀寫都包在 try/catch 裡,讀不到就當作「沒有歷史」。
const median = sorted.length === 0 ? null
: sorted.length % 2 ? sorted[(sorted.length - 1) / 2]
: (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2;
樣本只有個位數的時候,一次 0% 或 100% 會把平均拉走二十個百分點。中位數比較不會騙人。平均我還是算了,但放在後面。
完整的總結長這樣:
export function summarizeRuns(runs: RunRecord[]): RunSummary {
const ratios = runs.map((r) => r.adoption?.ratio).filter((x): x is number => typeof x === "number");
const dispatched = runs.reduce((n, r) => n + r.dispatched, 0);
const completed = runs.reduce((n, r) => n + r.completed, 0);
return {
runs: runs.length,
measured: ratios.length,
dispatched, completed,
completion_rate: dispatched ? completed / dispatched : null,
mean_reuse: ratios.length ? ratios.reduce((a, b) => a + b, 0) / ratios.length : null,
median_reuse: median,
};
}
completion_rate 在沒派出任何東西的時候回 null 而不是 0。這跟昨天 ratio 的處理是同一條原則:「量過了,結果是零」和「沒得量」必須是兩個不同的值,把它們合併成 0 是統計上最常見的一種說謊。
還有一個小地方:可量測的次數(measured)和總次數(runs)分開存。一次「合併了但沒有任何輸出長到能量測」不是 0%,是沒得量。把沒得量的算成 0,指標會在資料稀疏時系統性偏低——這跟昨天那條「無法量測不進分母」是同一個原則,只是放大到跨次數的層級。
it("treats unreadable storage as no history instead of breaking", () => {
expect(loadRuns(throwing)).toEqual([]); // 開無痕視窗
expect(() => appendRun(rec("x"), throwing)).not.toThrow();
expect(loadRuns(mem("not json at all"))).toEqual([]); // 存到一半被砍
expect(loadRuns(mem(JSON.stringify({ not: "an array" })))).toEqual([]);
expect(loadRuns(mem(JSON.stringify([{ junk: 1 }, rec("ok")])))).toHaveLength(1);
});
紀錄壞掉不能讓畫面跟著壞。這種「輔助功能不准拖垮主功能」的分寸,在自己一個人寫的專案裡特別容易忘記。
export const EVAL_SET = [
{ id: "E1", label: "解釋", text: "…用白話解釋這個 mesh 怎麼運作,200 字以內…" },
{ id: "E2", label: "清單", text: "…列出五個最可能的失敗模式,一行一個,最可能的排前面…" },
{ id: "E3", label: "判斷", text: "…三台完成、一台不回報,該給使用者合併答案還是什麼都不給?給一個建議和最強的反對理由…" },
];
三題刻意不同形狀:一題要一段合併的散文,一題要清單(很好合併),一題要一個判斷(很難合併,因為規劃者得決定「誰來下結論」)。畫面上多一排按鈕直接帶入。
為什麼挑這三種形狀,而不是三題同類型的?因為我想要的不只是「可比」,還要「會在不同地方壞掉」:
這還不是真正的評估環境——參與者仍然是「今天誰活著」——但至少題目不再漂移。一次只固定一個變數,是我現在能做到的。
做完之後跑兩輪驗收。同一題(E2 清單)、同一組三個參與者、只換拆解模式。

三台全部完成。重用率:0/3。
ipad-sim 15%、m5/agy 3%、m5/claude 3%,三個全部被畫上刪除線。
我第一個反應是指標壞了。去看子任務才發現不是:
給 m5/agy 的子任務原文是這樣寫的(節錄):
Review the top five practical failure modes of a fan-out … from the angle of the coordinator side … Produce a ranked list of at most five one-line failure modes from this coordinator perspective … so the operator's final answer can be cross-checked against it.
最後那句 "so the operator's final answer can be cross-checked against it" 講得再清楚不過:這份輸出的用途是被拿來對照,不是被抄進去。
planner 拆得很好。好到我昨天抱怨的那個毛病(假設每台都能讀專案檔案)這次完全沒出現。它安排了一個產出者和兩個複核者,而且明確告訴複核者「你是用來交叉檢查的」。
而複核者的文字,本來就不應該出現在最終答案裡。 複核的價值在於它改變了產出者寫什麼,不在於它自己的句子被抄進去。
所以 0/3 不是系統失敗,是指標的定義撞到了系統的正確行為。

三台各自寫一份五行清單。重用率:2/3(67%),m5/agy 36%、m5/claude 23%、ipad-sim 0%。
三份輸出的角度其實很不一樣。m5/claude 的第一條是:
Partial failure: one machine's slice errors or times out while the rest succeed, and the caller either blocks forever or silently drops that shard's results.
而 ipad-sim 的第一條是:
Clock drift and lack of synchronization leading to inconsistent read-after-write views.
最終合併的第四條保留了 m5/claude 那條的骨架(「一台出錯或逾時,呼叫方永遠等下去或默默丟掉」),而 ipad-sim 的時鐘漂移完全沒進去——所以它是 0%。這不是 ipad-sim 寫得不好,是合併的模型在五個名額裡選了別的。指標在這裡量到的東西是對的:它的內容確實沒有被採用。
同一題、同一組機器、同一個合併模型,只因為拆解方式不同,指標從 0% 跳到 67%。
| 拆成子任務 | 同一題給全部 | |
|---|---|---|
| 完成 | 3/3 | 3/3 |
| 文字重用 | 0/3(0%) | 2/3(67%) |
| planner 的角色分配 | 一個產出、兩個複核 | 三個都產出 |
| 這個數字代表 | 指標的限制 | 真的有被採用 |
如果我今天沒有跑第二輪,只看到第一輪的 0/3,很可能會得出「拆成子任務比較差」這個完全錯誤的結論,然後去改一個沒有壞的 planner。
一個指標最危險的時候,不是它給出錯的數字,是它在系統做對事情的時候給出難看的數字。

畫面上多了一行:
最近 2 次 · 重用率中位數 33%(2 次可量測)· 完成率 100%(6/6) 匯出
按匯出會把 JSONL 複製到剪貼簿,同時顯示在可以選取的區塊裡(iOS 的剪貼簿權限不保證成功,所以兩條路都留):
{"task_id":"b668765e-4c39-4e71-b2c3-f3be6dabf131","at_unix":1789603256,"coordinator":"http://127.0.0.1:7878","manifest_sha8":…
{"task_id":"ccc1ca66-3bca-410d-a7fe-b2be55f60458","at_unix":1789603449,"coordinator":"http://127.0.0.1:7878","manifest_sha8":…
這裡意外解掉一個困擾很久的操作問題:畫面上的任務編號只有八碼,而 coordinator 沒有「列出所有任務」的端點,所以事後想撈某一次的詳細資料,只有發起端知道完整編號,而發起端也沒把它記下來。現在它就在紀錄的第一個欄位裡。
功能之間常常這樣互相償還。 我做這個紀錄是為了指標,順手把一個跟指標無關的痛點補掉了。
歷史那行放在哪。 我一度想開一個獨立的「統計」分頁。後來放棄,理由是:一個要特地點進去才看得到的數字,等於不存在。它現在就貼在完整回答的正下方——你每次拿到答案,都會順眼看到「這是最近幾次裡的第幾好」。指標要跟它想影響的那個決定放在同一個畫面上,否則它只是裝飾。
匯出為什麼同時做兩件事。 按下去會試著複製到剪貼簿,同時把 JSONL 印在一個可以手動選取的區塊裡。因為 iOS 的 WebView 對剪貼簿的授權不保證成功,而且失敗時通常不會告訴你。與其做一個「有時候會靜靜失敗」的按鈕,不如永遠把東西攛在那裡,複製成功只是加分。
這跟前幾天那條原則是同一個家族:降級要看得見。快照要標「(快照)」,不完整的合併要標「(其餘未回報)」,複製可能失敗就把原文也印出來。
現在有了 JSONL,離線分析就是幾行的事。我腦中排了三個問題,每一個都需要十筆以上才有意義:
這三個問題我現在一個都答不出來,但至少它們從「模糊的感覺」變成了「等資料夠了就能算」。這就是今天這段工作真正的產出:不是那條歷史線,是把三個問題放進了可回答的隊列。
做到一半有個念頭:既然要存紀錄,何不順便把每次的完整輸出也存起來?那樣事後要重算任何指標都不必重跑。
沒做,理由有三個:
只存數字、不存內容,是今天這個設計裡我最有把握的一個決定。
第 9 區的自評從「弱」往上挪半格:現在有指標、有紀錄、有固定題目,但沒有固定參與者,也沒有統計。
昨天我寫「指標最危險的時候不是它不準,是別人以為它準」。今天遇到的是更細的一層:
指標會在系統做對事情的時候給你難看的數字,而那個難看的數字看起來跟真的問題一模一樣。
分辨這兩者的唯一方法,是回去看原始資料——今天是去讀那三個子任務的內容。如果我只看儀表板,我會改錯地方。
所以我給自己加一條:任何指標在第一次給出極端值(0% 或 100%)的時候,都必須回頭看一次原始資料,才准把它寫進結論。 中間值可以相信趨勢,極端值幾乎總是在告訴你「你的定義和現實對不上」。
今天這段的可搬用部分,整理成四條:
Day 05|多 Agent|做。把第 2 點做掉:讓 planner 在拆題時標註每個子任務是「產出」還是「複核」,一來指標只對產出型計算,二來使用者在畫面上就能看懂為什麼某台的輸出沒進最終答案。這條同時修好指標和體驗,是今天的兩輪比較直接指出來的。
參考:ai-agent-book 第 7 章「Agent 的評估」;hello-agents 第十二章「智能體性能評估」。