iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI Engineering

從單一agent 到多agent 集群的開發流水帳以及應用系列 第 24

Day 20|一行紀錄,和一個指標自己打臉的反例

  • 分享至 

  • xImage
  •  

系列:「一人艦隊:用一群 AI CLI 打造跨平台代理 mesh」— 第 20 篇
紀錄日期:2026-09-17

今天的一件事

昨天做完子任務重用率之後,留了一句:那個數字只顯示在手機畫面上,App 一關就沒了。今天就做這件事——讓每次 fan-out 留下一行紀錄

coordinator(Z13)今天第三天不在 Tailscale 網路上。第一天我為此寫了一整段感想,第二天把手機指向這台 Mac 就繼續做,今天連提都不想提了。這大概就是備援的真正價值:它讓一件本來會毀掉一天的事,變成一行環境說明。

把參與者縮到三個

今天刻意把凍結的名單縮成三個:iPad 上的 app-local 模型、這台 Mac 上的 claude 和 agy。

昨天卡住的那台 m1 被我拿掉了。 它的 daemon 活著、API 有回應,但 attempt 永遠停在排隊。那是別人的機器,我昨天查到「可達但不接手」就停手了。今天要跑兩輪驗收,不想每一輪都被它拖到卡在「停止中」。

這件事要說清楚,因為它會影響等一下那個 100% 的完成率:那不是系統變好了,是我挑了容易的路。 這種話如果不寫在數字旁邊,三個月後看到「完成率 100%」的人會誤會。

一次跑一行

每次合併完成,就往裝置的瀏覽器儲存空間寫一筆:

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; considered; ratio; shingle; threshold } | null;
}

四個刻意的決定:

一、它不是收據。 這條線我劃得很清楚。收據是給 coordinator 的、要簽章、要能被別人驗證;這份紀錄是發起端自己的記帳,不簽章、不上傳。一旦本機記帳混進收據,別人就得連我這台裝置的時鐘和我自己定義的指標一起信任——那是在賣一個我付不起的保證。

二、把量尺跟數字存在一起。 shingle(片段長度)和 threshold(門檻)跟著每一筆走。因為門檻是我拍腦袋定的 15%,之後一定會改,而改的那一天如果只存了比例,所有歷史就全部變成不可比的垃圾。

三、上限 50 筆,同一個任務重複記會覆寫。 手機不是檔案庫。而「先用三份輸出合併、後來又有一份回來再合併一次」是同一次跑,不是兩次。

四、頭條用中位數。 樣本個位數的時候,一次 0% 或 100% 會把平均拉走二十個百分點。

還有一個看起來很小、但我認為是今天最重要的欄位設計:ratiocompletion_rate 在沒東西可量的時候回 null,不是 0「量過了,結果是零」和「沒得量」是兩件事,把它們合併成 0,指標會在資料最少的時候最不可信——正好是你最需要它的時候。

什麼時候寫這一筆

寫入的時機我改了兩次。第一版寫在「任務終止」時,但昨天才剛學到教訓:任務可能永遠不終止(一台不回報,整件事卡在「停止中」)。那樣的話最需要被記錄的那幾次——出事的那幾次——反而一筆都留不下來。

第二版改成「合併產出答案時」。理由是:有答案可看,這一次對使用者來說就算數了,不管系統內部收乾淨了沒有。紀錄裡的 task_state 欄位會誠實寫下當時的狀態,所以事後分得出來哪幾次是提早合併的。

const key = `${taskId}:${adoption?.considered ?? -1}`;
if (recordedRef.current === key) return;
recordedRef.current = key;
setRuns(appendRun(buildRunRecord({ … })));

那個 key 帶上「這次合併看到幾份可量測的輸出」,是為了處理一個具體情境:先用三份合併、過一會兒第四份回來了、使用者再按一次合併。第二次應該覆寫第一次的紀錄(同一個任務),但也確實應該被重新記錄一次(數字變了)。用 task_id 當去重鍵、用 considered 當變更偵測,兩件事就都對了。

三個測試在測「壞掉的時候」

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);
});

瀏覽器儲存會失敗,而且失敗得很安靜:無痕模式、使用者清掉資料、配額滿了,有些情況連讀都直接丟例外。輔助功能壞掉不准拖垮主功能——我為這件事寫的測試比寫功能本身還久。

固定題組

順手做了評估環境的一半:三題固定的題目,畫面上一排按鈕直接帶入。

  • E1 解釋:用白話講這個 mesh 怎麼運作,200 字以內。考合併。
  • E2 清單:列五個最可能的失敗模式,一行一個。考去重。
  • E3 判斷:三台完成、一台不回報,該不該給使用者合併答案?給一個建議和最強的反對理由。考規劃——因為這題根本沒辦法拆成三份平行工作。

三題刻意不同形狀,不是為了「可比」而已,是為了讓它們在不同的地方壞掉

E3 是我最期待的一題,因為它問的正好是我前天遇到的真實處境:

A task is stuck: three workers finished, one never reports back. Should the requester be shown a merged answer from the three, or nothing at all until every worker reports? Give one recommendation and the single strongest argument against it. Under 120 words.

前天我自己的答案是「給」,而且當天就把那顆按鈕做了出來。把同一個問題丟回給艦隊,看它們會不會給出我沒想到的反對理由,本身就是一種對系統的使用方式——這比「幫我寫一段文案」更接近我想要這個 mesh 做的事。

(今天還沒跑 E3,因為兩輪 E2 已經把時間用完了。明天補。)

參與者還是漂移的(今天三台、昨天五台、前天十二台),所以這還不是真的評估環境。但一次固定一個變數,已經是我現在能做到的。

順便看一下 planner 這次的表現

跑之前我對「拆成子任務」這個模式其實有點成見。前天十二台那輪,planner 拆出「做字數稽核」「寫投影片大綱」「寫講者提示」這類跟正文無關的工作;昨天五台那輪,它假設每台都能讀專案檔案,而當天派工的權限是唯讀不給工具,四份工作的前提當場不成立。

今天這輪完全沒有這兩個毛病。同一個 planner、同樣的拆解模式,差別只在題目形狀:前兩次是開放式的「解釋一件事」,今天是「列五個東西」。

題目愈有結構,規劃者愈不容易亂跑。 這是一個可以驗證的假設,而且正好可以用固定題組來驗:E2(清單)應該最穩,E1(散文)中間,E3(判斷)最容易失控,因為它根本不該被平行拆開。等三題各跑幾次,這個假設就有數字可以對。

這也是為什麼我把三題設計成不同形狀,而不是三題同類型的——評估題組的價值不只在可比,還在於它能同時當實驗的自變數。

然後它馬上打了我一巴掌

做完跑兩輪驗收。同一題(E2)、同一組三台機器、只換拆解模式。

第一輪:拆成子任務 → 重用率 0/3

三台全部完成,重用率 0/3。三個百分比(15%、3%、3%)全部被畫上刪除線。

我第一個反應是指標寫壞了。去看 planner 拆出來的子任務才知道不是。給其中一台的原文是:

Review the top five practical failure modes of a fan-out … from the angle of the coordinator side … Produce a ranked list … so the operator's final answer can be cross-checked against it.

planner 安排了一個產出者(寫最終清單)和兩個複核者(一個從 coordinator 角度、一個從 worker 角度),而且明確告訴複核者「你是用來給最終答案做交叉檢查的」。

三份子任務並排看更清楚:

參與者 被指派的工作 重用率
iPad 的模型 寫出最終答案:五個失敗模式,一行一個,最可能的排前面,不要前言 15%
m5/agy coordinator 側複核,產出最多五條排序清單,供最終答案對照 3%
m5/claude worker 側複核,同上 3%

而複核者交出來的東西品質很好。m5/agy 的第一條是:

Misconfigured timeouts: premature deadlines fire against normal tail-latency variance, unnecessarily aborting healthy requests.

這句話完全沒有進最終答案,但它不是垃圾——它是一個排序意見。指標看得到文字,看不到影響。

複核者的文字,本來就不該出現在最終答案裡。 複核的價值在於它改變了產出者寫什麼,不在於它自己的句子被抄進去。

所以 0/3 不是系統失敗,是指標的定義撞到了系統的正確行為

第二輪:同一題給全部 → 重用率 2/3

三台各自寫一份五行清單,重用率 2/3(67%)

三份的角度差很多。一台的第一條是「一台出錯或逾時,其他成功,呼叫方要嘛永遠等下去、要嘛默默丟掉那一片的結果」;另一台的第一條是「時鐘漂移導致讀寫視圖不一致」。最終合併保留了前者的骨架,後者完全沒進去——所以它是 0%。

這次指標量到的東西是對的:那份內容確實沒有被採用。合併的模型在五個名額裡選了別的。

並排

拆成子任務 同一題給全部
完成 3/3 3/3
文字重用 0/3(0%) 2/3(67%)
planner 的角色分配 一個產出、兩個複核 三個都產出
這個數字代表 指標的限制 真的有被採用

同一題、同一組機器、同一個合併模型,只因為拆解方式不同,指標從 0% 跳到 67%。

如果今天只跑第一輪,我很可能會下「拆成子任務比較差」這個完全錯誤的結論,然後去改一個沒有壞的 planner。

歷史線與匯出

完整回答下面多了一行:

最近 2 次 · 重用率中位數 33%(2 次可量測)· 完成率 100%(6/6)    匯出

按匯出會把 JSONL 複製到剪貼簿,同時印在一個可以手動選取的區塊裡:

{"task_id":"b668765e-4c39-4e71-b2c3-f3be6dabf131","at_unix":1789603256,…
{"task_id":"ccc1ca66-3bca-410d-a7fe-b2be55f60458","at_unix":1789603449,…

之所以兩條路都做,是因為 iOS 的 WebView 對剪貼簿授權不保證成功,而且失敗時通常不吞聲。與其做一個「有時候會靜靜失敗」的按鈕,不如永遠把東西攛出來。

這跟前幾天那條原則同一家族:降級要看得見。快照標「(快照)」、不完整的合併標「(其餘未回報)」、複製可能失敗就把原文也印出來。

順手還掉一筆舊債

畫面上的任務編號只有八碼,而 coordinator 沒有「列出所有任務」的端點。所以事後想撈某一次的細節,只有發起端知道完整編號——而發起端也沒把它記下來。我前幾天還把這件事寫成給契約的建議。

現在它就在紀錄的第一個欄位裡。我做這條紀錄是為了指標,順手把一個跟指標無關的痛點補掉了。

為什麼中位數這件事今天就有感

兩筆資料,一筆 0%、一筆 67%。

  • 平均是 33.5%
  • 中位數也是 33.5%

兩筆的時候當然一樣。但把它想成三筆:如果明天再跑一輪拿到 70%,平均變成 45.7%,中位數變成 67%——中位數會誠實地說「多數情況接近 67%,有一次例外」,平均則會給出一個從來沒發生過的 45.7%。

在我這種樣本數永遠只有個位數的場景,這個差別不是學術問題。我現在唯一會拿這個數字做的決定是「要不要去改 planner」,而那個決定應該基於「多數情況長什麼樣」,不是基於一個被異常值拉走的平均。

兩個介面上的決定

歷史那行放哪裡。 一度想開一個獨立的「統計」分頁,後來放棄。一個要特地點進去才看得到的數字,等於不存在。它現在貼在完整回答的正下方,你每次拿到答案都會順眼看到「這是最近幾次裡的第幾好」。指標要跟它想影響的那個決定放在同一個畫面上,否則只是裝飾。

匯出為什麼同時做兩件事。 剪貼簿和可選取的原文都給。因為 iOS 的 WebView 對剪貼簿授權不保證成功,失敗時通常不吞聲。與其做一顆「有時候會靜靜失敗」的按鈕,不如永遠把東西攛在那裡,複製成功只是加分。

這些資料接下來要拿來做什麼

有了 JSONL,離線分析就是幾行的事。腦中排了三個問題,每一個都要十筆以上才有意義:

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

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

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

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

沒做。三個理由:

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

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

今天的帳

項目 結果
每次 fan-out 一行紀錄 已驗證:兩輪都寫入,匯出可讀
固定題組 E1/E2/E3 已上畫面
兩輪對照(只換拆解模式) 0/3 vs 2/3,完成率都是 3/3
新增測試 8 個(含 3 個測儲存壞掉)
全專案測試 99 個全綠
型別檢查 0 錯
commit b64ef034
coordinator 本機 daemon;Z13 連續第三天離線
誠實註記 完成率 100% 是因為我把會卡住的那台排除在名單外

為什麼今天沒有動 core

今天所有改動都在手機端的程式裡,Rust 那顆 binary 一行都沒碰。這是刻意的。

紀錄、指標、題組——這三樣都是「發起端想知道什麼」的問題,不是「艦隊之間怎麼對話」的問題。一旦把它們寫進 core,就等於要求每台機器都同意我的指標定義、我的門檻、我選的三道題。而我今天才剛證明我的指標在某些情況下會誤導自己。

還在變的東西不要往契約裡放。 等到某個指標連續十次都給出可解釋的數字、我也能說清楚它什麼時候會錯,那時候再提議把它變成契約的一部分。在那之前,它待在我自己的手機上,錯了只有我承擔。

這條界線這幾天被反覆用到:昨天那個 nonce 被截斷的問題我沒有自己偷改驗證規則,而是寫成備註;今天這些東西我沒有塞進 core。契約是大家的,實驗是自己的。

一個沒解決的問題

那台不接 attempt 的 m1,今天被我從名單裡拿掉,不是修好。

我很清楚這是債。今天的 100% 完成率買在這筆債上,而且每多繞過一次,就多一次「其實這個系統在四台以上的規模下沒有被驗證過」的空白。前天那次十二台的 fan-out 至今仍是規模最大的一次,而那次的成績是 12/13。

真正要修它,得能看 m1 那台的日誌,而那是別人的機器。所以今天做的是:把「為什麼被排除」寫進報告、把備註交出去、在文章裡標明數字的來源條件。在多人的艦隊裡,誠實標註自己繞過了什麼,比偷偷修好別人的機器更重要。

給契約的兩條備註

  • 備註 (9) 補充:如果 coordinator 之後要做合併,請一併回傳「每份貢獻是否被採用」的判定依據。否則每個發起端都得自己發明一套代理指標,而那些數字彼此不可比。
  • 新備註 (14)逐字重用不適合當唯一指標。 當 planner 指派「複核/稽核」型子任務時,正確行為就是不出現在最終答案裡,逐字重用會把它判成沒貢獻。要嘛由 planner 標註子任務類型(產出/複核),要嘛改用語意相似度。前者比較值得先做,因為它同時修好指標和可解釋性。

今天真正學到的

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

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

分辨這兩者的唯一方法是回去看原始資料——今天是去讀那三個子任務的原文。如果我只看那行摘要,我會改錯地方,而且會很有信心地改錯。

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

三天下來的一個模式

從 9/15 到今天,每一天的發現都長得很像:

  • 9/15:契約說「App 在前景時 pull」,程式做的是「畫面開著時 pull」。
  • 9/16:契約說停止後要等終止事件,程式把「使用者能不能看到答案」也一起等下去。
  • 9/17:指標說「文字有沒有被重用」,我讀成了「這台有沒有貢獻」。

三次都是定義和現實之間有一條沒人明說的縫,而且三次都是在系統正常運作、沒有任何錯誤訊息的情況下現形的。沒有例外堇出、沒有紅字、測試全綠。

這類問題只有兩種方法會被抓到:真的去用它,或者真的去讀原始資料。自動化測試抓不到,因為測試只會驗證我已經想到的那個定義。

明天

讓 planner 在拆題時標註每個子任務是「產出」還是「複核」。指標只對產出型計算重用率,畫面上也能直接告訴使用者「這台是複核,本來就不會出現在答案裡」。這條是今天兩輪比較直接指出來的,而且一次修好兩件事。

Z13 如果回來,先用同一組腳本把前兩天欠的真機測試對它重跑一次。


上一篇
Day 19|三個修正,還有一個停不下來的任務
下一篇
Day 21|把角色記進每一行,然後指標第一次給了分
系列文
從單一agent 到多agent 集群的開發流水帳以及應用28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言