iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
AI Engineering

邊做邊補:用一個 AI 代理 mesh 專案,補齊 AI 工程師該有的能力系列 第 4

Day 04|評估|做:讓數字活過一次關機,然後它馬上給了我一個反例

  • 分享至 

  • xImage
  •  

系列:「邊做邊補:用一個 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 看、要簽章、要能被別人驗證的東西。這份紀錄是發起端自己的記帳,不簽章、不上傳,記的是「這支手機觀察到什麼」。把兩者分清楚很重要:一旦本機記帳混進收據,別人就得信任我這台裝置的時鐘和我自己寫的指標。

shinglethreshold 跟著每一筆走。 因為門檻是我拍腦袋定的,之後一定會改。如果只存 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: "…三台完成、一台不回報,該給使用者合併答案還是什麼都不給?給一個建議和最強的反對理由…" },
];

三題刻意不同形狀:一題要一段合併的散文,一題要清單(很好合併),一題要一個判斷(很難合併,因為規劃者得決定「誰來下結論」)。畫面上多一排按鈕直接帶入。

為什麼挑這三種形狀,而不是三題同類型的?因為我想要的不只是「可比」,還要「會在不同地方壞掉」:

  • 散文題(E1) 考的是合併。三份 200 字的解釋要壓成一份,必然要取捨,逐字重用會偏低。
  • 清單題(E2) 考的是去重。三份五行清單合併時,重複的項目要合成一條,而且清單的句子短、結構固定,合併時最容易原樣搬過去——這也是今天兩輪都用它的原因:它讓指標的訊號最強。
  • 判斷題(E3) 考的是規劃。「該不該給使用者不完整的答案」這種題目沒辦法拆成三份平行工作,planner 要嘛讓三台各自表態再合併,要嘛指定一台下結論。它會直接暴露規劃者在面對「不可分割的問題」時的行為。

這還不是真正的評估環境——參與者仍然是「今天誰活著」——但至少題目不再漂移。一次只固定一個變數,是我現在能做到的。

然後它馬上打了我一巴掌

做完之後跑兩輪驗收。同一題(E2 清單)、同一組三個參與者、只換拆解模式。

第一輪:拆成子任務

三台全部完成。重用率:0/3

ipad-sim 15%、m5/agy 3%、m5/claude 3%,三個全部被畫上刪除線。

我第一個反應是指標壞了。去看子任務才發現不是:

  • ipad-sim:寫出最終的五行清單。
  • m5/agy:從 coordinator 的角度複核這五個失敗模式,產出一份排序清單,「讓操作者的最終答案可以對照」。
  • m5/claude:從 worker 的角度做同一件事。

給 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,離線分析就是幾行的事。我腦中排了三個問題,每一個都需要十筆以上才有意義:

  1. 拆成子任務 vs 同一題,哪個的最終答案比較好? 今天量到的是重用率差很多,但重用率高不等於答案好。要回答這題,需要另外一個指標(例如讓一個沒參與的模型盲測兩份答案),而那個指標的資料量要求更高。
  2. 參與者數量的邊際效益在哪裡崩掉? 三台、五台、十二台,完成率和重用率各自怎麼變。如果第四台之後重用率就不動了,那擴大規模就是純粹燒錢。
  3. 哪一台的輸出最常被丟掉? 如果某一台長期重用率墊底,那不是它壞了,通常是 planner 一直派給它不該派的工作,或者它的模型和合併者的風格差太遠。

這三個問題我現在一個都答不出來,但至少它們從「模糊的感覺」變成了「等資料夠了就能算」。這就是今天這段工作真正的產出:不是那條歷史線,是把三個問題放進了可回答的隊列。

一個我沒做、但想清楚了的選擇

做到一半有個念頭:既然要存紀錄,何不順便把每次的完整輸出也存起來?那樣事後要重算任何指標都不必重跑。

沒做,理由有三個:

  1. 體積。 一次十二台、每台幾百字,一次就是幾十 KB。五十筆就逼近瀏覽器儲存的舒適區上限,而超過之後的失敗是靜悄悄的。
  2. 那是別人的內容。 每份輸出背後是某台機器、某個模型、某把 API key。把它們原封不動囤在我的手機上,等於單方面決定了一份我沒有跟任何人討論過的保存政策。
  3. 它會讓我懶得現在把指標想清楚。 「反正資料都存著,之後再算」聽起來很安全,實際上是把「我現在不知道要量什麼」包裝成「我保留了彈性」。真正的彈性來自想清楚要量什麼,不是來自囤積。

只存數字、不存內容,是今天這個設計裡我最有把握的一個決定。

還缺什麼

  1. 兩次資料什麼都證明不了。 中位數 33% 這個數字現在唯一的用途是證明「這條管線會動」。要十次以上、三種題型都跑過,才有資格談任何結論。
  2. 逐字重用不能當唯一指標——今天有了確鑿的反例。 下一步是讓 planner 標註子任務的類型(產出 / 複核 / 稽核),指標只對「產出型」計算重用率。這比換成語意相似度更值得先做,因為它同時修好了指標和拆題的可解釋性。
  3. 參與者還在漂移。 今天是三台,昨天是五台,前天是十二台。真正的評估環境要固定參與者,這需要一組隨時都在的機器,而我的艦隊裡有一半是別人的電腦和會上鎖的手機。
  4. 完成率 100% 是因為我把最容易失敗的那台排除了。 今天為了跑得動,manifest 裡刻意不放昨天卡住的那台機器。所以 6/6 不是系統變好了,是我挑了容易的路。這點必須寫出來,否則下次看到這個數字的人會誤會。
  5. 沒有統計。 兩次跟十次的差別在哪、多少差距才算真的差距,我還是答不出來。

第 9 區的自評從「弱」往上挪半格:現在有指標、有紀錄、有固定題目,但沒有固定參與者,也沒有統計。

今天真正學到的

昨天我寫「指標最危險的時候不是它不準,是別人以為它準」。今天遇到的是更細的一層:

指標會在系統做對事情的時候給你難看的數字,而那個難看的數字看起來跟真的問題一模一樣。

分辨這兩者的唯一方法,是回去看原始資料——今天是去讀那三個子任務的內容。如果我只看儀表板,我會改錯地方。

所以我給自己加一條:任何指標在第一次給出極端值(0% 或 100%)的時候,都必須回頭看一次原始資料,才准把它寫進結論。 中間值可以相信趨勢,極端值幾乎總是在告訴你「你的定義和現實對不上」。

如果你也要替指標加上歷史

今天這段的可搬用部分,整理成四條:

  1. 先存參數,再存數字。 門檻、片段長度、模型版本——任何會影響數字的設定,都要跟數字存在同一筆。不然第一次調參數就會讓所有歷史失效,而你通常會在調完兩週後才發現。
  2. 頭條用中位數。 除非你的樣本數已經到幾十,否則平均只是在放大最極端那一次。
  3. 把「沒得量」和「量到零」分開。 這是兩件事,合併它們會讓指標在資料最少的時候最不可信——正好是你最需要它的時候。
  4. 輔助功能壞掉不准拖垮主功能。 存不進去、讀出來是壞的、根本沒有儲存權限,都要能安靜降級。我為這件事寫了三個測試,比寫功能本身還久。

明天

Day 05|多 Agent|做。把第 2 點做掉:讓 planner 在拆題時標註每個子任務是「產出」還是「複核」,一來指標只對產出型計算,二來使用者在畫面上就能看懂為什麼某台的輸出沒進最終答案。這條同時修好指標和體驗,是今天的兩輪比較直接指出來的。

參考:ai-agent-book 第 7 章「Agent 的評估」;hello-agents 第十二章「智能體性能評估」。


上一篇
Day 03|評估|做:把「拆出去的子任務有幾份真的用到」變成一個會跑的數字
下一篇
Day 05|多 Agent|做:讓指標看懂「誰是產出、誰是複核」,然後它露出第二層問題
系列文
邊做邊補:用一個 AI 代理 mesh 專案,補齊 AI 工程師該有的能力7
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言