iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI Engineering

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

Day 21|把角色記進每一行,然後指標第一次給了分

  • 分享至 

  • xImage
  •  

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

今天的一件事

昨天結尾我寫了一句承諾:

讓 planner 在拆題時標註每個子任務是「產出」還是「複核」。指標只對產出型計算重用率。

今天做完了,但做完之後看到的東西跟我預期的差很多。預期是「排掉複核型,數字會變好看一點」。實際發生的是三件事:指標第一次給了分(而且其中一輪高到 78%)我的分類器被自己的統計抓包只讀得懂九份子任務裡的四份、以及 planner 拆同一題的方式根本是隨機的

還附帶撿到一個全新的失敗模式,是這個專案寫了三週以來第一次看到。

Z13 今天第四天不在 Tailscale 網路上。coordinator 一樣是這台 Mac 的 daemon,凍結名單一樣是 f1524c32:iPad 上的 app-local 模型、這台機器上的 claude 和 agy。m1 一樣被排除在外,一樣是債,這點等一下會再提。

先補昨天的洞:分類器有了,但沒人記得它說過什麼

昨天我加了一個叫 classifySubtask 的東西,從 planner 自己寫的子任務文字判斷這份工作是「產出」還是「複核」。複核型不列入重用率的分母——因為一份複核型輸出不出現在最終答案裡才是做對了,用逐字重用率去打它的分數,等於懲罰規劃者做了正確的拆解。

問題是:那個判斷結果只活在畫面上。合併一跑完、畫面一換,它就沒了。

這跟前天那個「採用率只顯示不儲存」的毛病一模一樣,而我昨天才剛修過那個毛病。差別只在於,昨天我修的是「數字」,今天要修的是「這個數字是怎麼算出來的前提」。

如果我要回答「這個分類器到底是不是裝飾品」,我需要的不是某一輪的畫面,是跨輪的分布。 九份子任務裡,有幾份被讀成產出、幾份被讀成複核、幾份根本讀不懂?這個比例本身就是分類器的成績單。

所以今天的程式改動只有一句話可以描述:每一行紀錄多帶一個角色分布

export type RoleCounts = Record<SubtaskRole, number>;

export interface RunRecord {
  // …原本的欄位
  /** 這一輪各角色的份數;沒有子任務文字可讀時為 null */
  roles: RoleCounts | null;
}

又一次 null 和 0 的差別

寫這段的時候踩到跟前天一樣的坑,只是換了個位置。

buildRunRecord 收到的是一串 attempt。我把它的型別從 { state } 改成 { state; task? },然後對每一份跑分類器。但這裡有兩種「沒有角色」長得很像:

  1. 呼叫端沒有把子任務文字傳進來(比如舊版程式、或某個我還沒改到的呼叫點)。
  2. planner 真的沒有拆子任務——使用者選「同一題給全部」的時候,每台拿到的就是原題,沒有子任務可言。

第一種是「我不知道」,第二種是「我知道,答案是零」。如果兩種都寫成 {produce: 0, review: 0, unknown: 0},那這個欄位以後完全不能拿來分析,因為你永遠分不出「這輪真的沒拆」和「這輪的資料漏了」。

所以:

// 「沒人傳 task」和「planner 沒拆子任務」是兩件事,
// 只有後者值得記一個零。
const knowsTasks = input.attempts.some((a) => a.task !== undefined);
const roles = knowsTasks
  ? input.attempts.reduce((acc, a) => {
      acc[classifySubtask(a.task ?? null)] += 1;
      return acc;
    }, { ...ZERO_ROLES })
  : null;

同一個原則在跨輪加總那邊也要守住:昨天以前寫下的紀錄沒有 roles 這個欄位,它們必須被排除在加總之外,而不是被當成三個零。不然我會看到一個被稀釋過的分布,然後對著它下錯結論。

const withRoles = runs.filter((r) => !!r.roles);
// …
runs_with_roles: withRoles.length,
roles: withRoles.length ? roles : null,

這件事我這三天寫了三次,位置都不一樣:採用率的 ratio、完成率的 completion_rate、今天的 roles。三次都是同一個問題:平均值和比率天生會把「沒資料」偽裝成「資料是零」,而零看起來非常像一個結論。

畫面上多一句話:

最近 6 次 · 重用率中位數 42%(6 次可量測)· 完成率 94%(17/18)· 子任務 3 產出 / 1 複核 / 5 未分類

測試三支檔案 42 個綠、tsc 零錯。(全庫另外有 15 個既有的失敗測試,跟這次改動無關——我在改動前後各跑了一次確認數字相同,這種事必須自己先確認過再寫出來。)

三個測試,全部用真的字串

新加的三個測試我刻意不自己編字串,一律用 coordinator 這幾天實際派出去過的子任務原文當 fixture:

it("reads the split off the planner's own wording", () => {
  const r = buildRunRecord({
    // …
    attempts: [
      { state: "completed", task: "Write the deliverable: a ranked list of five items with one sentence each." },
      { state: "completed", task: "Review the other answers for factual errors and list any you find." },
      { state: "completed", task: "Consider whether the ordering holds." },
    ],
    adoption: null,
  });
  expect(r.roles).toEqual({ produce: 1, review: 1, unknown: 1 });
});

第三句 Consider whether … 就是我故意留成 unknown 的那種——它既不是明確的產出也不是明確的複核,而我寧可讓它掛在未分類,也不要用一條「猜起來像產出」的規則把它塞進分母。自己編的測試字串會讓分類器看起來比實際準,因為我編字串的時候用的是跟寫規則同一顆腦袋。

另外兩個測試分別守住上面說的 null 與全 unknown 的區別、以及跨輪加總時舊紀錄要被排除。第三個測試的註解我寫的是 // recorded before Day 21——三個月後看到這行,至少知道那個 null 不是 bug。

三輪實測:一次把三種題型都跑過

前天我固定了一組三題的評估題組:E1 解釋型、E2 清單型、E3 判斷型。固定題目的理由前天寫過:兩次跑分只有在問題一樣的時候才可比。今天第一次把三題各跑一輪,同一組參與者、同一個 manifest、都選「拆成子任務」。

題型 task 狀態 重用率 各台 角色分布
E2 清單 6783d7ea 全部完成 1/2(50%) ipad-sim 29% ✓、m5/agy 2%、m5/claude 複核·不計 1 產出 / 1 複核 / 1 未分類
E1 解釋 f2da70f6 失敗 1/2(50%) m5/claude 78% ✓、m5/agy 1% 1 產出 / 0 複核 / 2 未分類
E3 判斷 5c4606f1 全部完成 1/3(33%) ipad-sim 23% ✓、m5/agy 13%、m5/claude 3% 1 產出 / 0 複核 / 2 未分類

E2 清單型那輪:子任務文字重用 1/2,複核型不列入計算

E2 清單型,task 6783d7ea。三台全部完成;m5/claude 被判為複核型,退出分母。

上面這張是 E2 那輪的畫面。看那行小字:

子任務文字重用 1/2(50%;門檻 15% 的 12 字元片段。改寫不算,故為下限) 另有 1 份是複核型子任務,不列入計算
ipad-sim/app-local-groq 29% m5/agy 2% m5/claude 複核·不計

這是這個指標第一次不是零。前天和昨天連續兩輪 0/3,昨天我已經寫下「合併模型傾向重寫而不是引用」當成結論。今天這一輪就把那個結論削掉了一半。

發現一:指標會不會給分,跟題型有關

最極端的是 E1 那輪:

E1 解釋型那輪:m5/claude 重用率 78%

E1 解釋型,task f2da70f6。m5/claude 78%——合併模型幾乎整段引用。同一輪也是唯一一次 incomplete_receipt

m5/claude 78%。這台的輸出有將近八成的字元片段原封不動出現在最終答案裡——合併模型幾乎是整段抄過去的。

回頭看題目就懂了。E1 是「用白話跟新同事解釋這個 mesh 怎麼跑」,輸出是一段散文。散文型的答案,合併的時候最省力的作法就是挑最好的那一份、微調、送出。而 E2 是「列出五個最可能的失敗方式,一行一個」,清單型的答案,合併的時候必須把三份清單去重、重排、重寫成一致的語氣——每一條都會被改寫,逐字重用自然趨近於零。

所以昨天那句「合併在重寫」不是通則,是清單型題目的行為

這件事對指標的意義比對系統的意義大:

  • 跨題型比較重用率是沒有意義的,就像不能拿短跑和馬拉松的秒數比。
  • 中位數比平均值更重要這點又被驗證了一次。今天三輪是 50/50/33,中位數 50%;六輪連同前面兩個 0 一起算,中位數掉到 42%。如果我用平均值,前面那兩個 0 會把今天的訊號壓掉一大半。

六輪彙總:重用率中位數 42%、完成率 94%、子任務 3 產出 / 1 複核 / 5 未分類

六輪累積下來的那一行。最後一段「子任務 3 產出 / 1 複核 / 5 未分類」是今天新加的。

E3 那輪:三台都有貢獻,但都只有一點點

E3 是判斷型:「一個任務卡住了,三台完成、一台永遠不回報。該給發起人一份合併的答案,還是什麼都不給直到全部回報?給一個建議,以及反對它的最強論點,120 字以內。」

這題有趣在它逼出一個結構完全固定的答案:一句建議 + 一句反論。三台的重用率分別是 ipad-sim 23%(過門檻)、m5/agy 13%、m5/claude 3%,合計 1/3。

去讀原始輸出就會發現這個 33% 其實低估得很嚴重。planner 這次把題目拆成「寫最終交付」「建立支持合併的論據」「建立反對合併的論據」,最終答案的兩句話分別來自後兩台的立場——支持方的「避免無界等待、更早給出可行動的資訊」和反對方的「部分視圖可能被誤當成完整或權威」都完整進了答案,只是被重寫成一句話。

換句話說,這一輪三台全部有實質貢獻,指標說 1/3。這正好是昨天那個「逐字重用是下限」的結論再一次實證,而且這次不是猜的——我能指著最終答案的第二句話說出它是誰的論點。

也因為這樣,這一輪讓我更確定明天該做的是條目層級的比對,而不是把門檻從 15% 調低。調門檻只會讓 13% 和 3% 這兩個數字跨過一條更低的線,那不是量到了,是把標準放寬。

發現二:我的分類器只讀得懂九份裡的四份

六輪跨輪彙總那行裡,真正讓我停下來的是最後一段:3 產出 / 1 複核 / 5 未分類

九份子任務,有五份我的分類器讀不出來它到底是要產出還是複核。超過一半。

這正是昨天沒有把角色記下來、只看單輪畫面時看不到的事。昨天那輪三份全是產出型,我當時的感想是「分類器運作正常」。今天把分布攤開來才發現,正常的是那一輪,不是分類器。

去讀那五份的原文,原因非常一致:

  • Read this project's source code and docs to find the actual coordinator/planner logic … Produce a short factual reference …
  • Provide supporting evidence for the operator's question about how a fan-out fails in practice. Inspect the current working directory …
  • Build the case FOR showing a merged partial answer: in under 80 words of plain prose, list the concrete benefits …

三句都是先給研究指示、交付動詞擺在句子中段。而我的規則 3 寫的是「開頭是產出動詞(write/draft/produce/…)」。

規則本身沒寫錯,是看錯了位置。planner 寫子任務的習慣是「先說去哪裡找材料,再說要交出什麼」,所以真正定義角色的那個動詞幾乎不會在第一個字。

我今天沒有急著改這條規則,理由有兩個。第一,我想先讓這個分布多累積幾輪,確認「交付動詞在中段」是穩定的模式,而不是這三輪的巧合——改規則很容易,但改完之後我就失去了「改之前長什麼樣」的對照組。第二,更根本地說,這件事的正解不是讓我的正則表達式更聰明,而是讓 planner 自己在拆題的時候標註角色。它知道自己的意圖,我只能猜;而且各家發起端各猜各的,猜出來的數字彼此不可比。這是我前天就交出去的契約備註 (14),今天多了九份樣本當佐證。

不過有一點值得記在旁邊:分類器讀不懂的時候,那份輸出仍然計入指標。這是昨天刻意設計的保守行為——寧可把一份複核型誤算成產出型(拉低分數),也不要把一份產出型誤排除(虛報分數)。今天的五份未分類全部進了分母,而三輪還是都有非零的採用,所以這個保守選擇沒有把訊號蓋掉。

發現三:同一題,planner 每次的拆法都不一樣

E2 這一題,三天內我用同樣的參與者、同樣的模式跑了三次,拆出來是三種:

  • 9/16:1 份產出 + 2 份複核。
  • 9/17:3 份產出,各自負責一個面向(基礎設施、協調、正確性)。
  • 9/18(今天):1 份產出 + 1 份複核 + 1 份未分類。

昨天我在報告裡寫「這次 planner 給的是三份產出型」,語氣是把它當成那一題的性質。今天這一輪直接證明那是錯的:拆法本身是隨機變數。

這件事會污染我到目前為止做的每一個跨輪比較。今天 E2 的 1/2 和昨天 E2 的 0/3,差的不只是合併行為,還差在一份被排除的複核型、以及分母從 3 變成 2。兩個數字放在同一條趨勢線上,其實比較的不是同一件事。

補救方式不是消除隨機性(我也控制不了 planner),而是把變因記下來——這恰好就是今天加的 roles 欄位存在的理由。今天早上我加它的動機是「回答分類器是不是裝飾」,到了晚上它變成「讓跨輪比較有機會被校正」的東西。這種事在這個專案裡發生過好幾次了:一個為了 A 加的欄位,真正的價值在 B。

撿到一個全新的失敗模式

E1 那輪 task 狀態是失敗,十八次 attempt 裡唯一的一次失敗,原因是這個:

incomplete_receipt: nonce not echoed in the output

iPad 上的 app-local executor 有跑、有產出答案、也回報了,但輸出裡沒有帶回 coordinator 發下來的 nonce,於是依契約 §6 被拒收。

這是三週以來第一次看到 §6 的 nonce 檢查在真實執行中生效。在此之前它每次都通過,通過到我幾乎忘記有這個檢查。今天它擋下了一份答案——而且擋得對:手機端的執行是 BYOK、推論跑在雲端 provider,把 nonce 原樣帶回輸出,是唯一能證明「這份輸出確實是為這次 attempt 產生的」的機制。沒有它,一份快取的舊答案跟一份新答案長得一樣。

但發起端的體驗不好:畫面上整個 task 變成「失敗」,而失敗原因只在那一格 attempt 裡。從使用者的角度,他看到的是「這題失敗了」,不是「有一台的回報格式不合格,另外兩台的答案還在」。這兩件事該不該重試的答案完全相反——格式問題重試同一台還是會失敗。

所以今天多交一條契約備註出去:

  • 新備註 (16):§6 建議區分「回報格式不合格(incomplete_receipt)」與「執行失敗(failed)」。前者對發起端而言是這一台這一版程式的問題,重試無益;後者才值得重試或改派。目前兩者在 task 層級都收斂成 failed,發起端無從判斷。

順帶一提,合併照樣完成了——因為前天修好的那件事:合併不再等 task 進入終止狀態,有幾份可用就先給答案。如果是四天前的版本,今天這輪會是「一台格式錯 → 整題失敗 → 使用者什麼都拿不到」。修一個看起來像 UI 細節的東西,四天後救了一輪實驗。

匯出之後長什麼樣

這個紀錄從第一天就是可匯出的(一行一個 JSON 物件,最舊的在前,任何日誌工具都吃得下)。今天多了 roles 之後,一行大概長這樣:

{"task_id":"6783d7ea…","at_unix":1758204…,"coordinator":"http://127.0.0.1:7878",
 "manifest_sha8":"f1524c32","plan_mode":"split","dispatched":3,"completed":3,
 "failed":0,"pending":0,"task_state":"completed","merged":true,
 "roles":{"produce":1,"review":1,"unknown":1},
 "adoption":{"adopted":1,"considered":2,"reviewers":1,"ratio":0.5,"shingle":12,"threshold":0.15}}

這一行裡有三組數字,分別回答三個不同的問題:

  • dispatched / completed / failed / pending這次派出去的工作,有多少真的回來了(系統健康度)。
  • roles規劃者把這題拆成什麼形狀(今天新增,也是唯一一個描述「輸入」而不是「結果」的欄位)。
  • adoption回來的東西有多少進了答案(貢獻度)。

刻意把三組放在同一行,是因為它們只有擺在一起才有意義。完成率 100% 但採用率 0/3,跟完成率 60% 但採用率 2/2,是兩種完全不同的狀況,而任何一個單獨的數字都會把它們說成同一件事。今天多加的 roles 又補上第三個維度:分母本身是怎麼被決定的

順帶一提,thresholdshingle 也一起寫進每一行。這兩個是指標的參數——門檻 15%、片段長度 12 個字元。哪天我把門檻調成 10%,舊的紀錄仍然知道自己是用哪個門檻算出來的,不會被新參數追溯汙染。這是前天做這個紀錄時唯一一個「以後一定會慶幸」的設計,今天第一次真的用到:我今天差點就為了 E3 那三個 23/13/3 去調門檻。

還是沒解決的:m1 和 Z13

m1 今天一樣是 offline,一樣不在名單裡。今天的完成率 94%(17/18)、昨天的 100%,都建立在「把那台拿掉」這個前提上。這句話我每天都寫一次,不是因為我喜歡自首,是因為完成率這種數字最容易被單獨引用

Z13 第四天不在。欠著的真機測試也就欠了第四天。我已經把腳本準備好了,它一回來就能重跑;但「準備好了」不等於「驗過了」,這條也得照實寫著。

為什麼還不直接換成語意相似度

寫到這裡一定會有人想問:既然逐字重用這麼不準,為什麼不直接用 embedding 算語意相似度就好?

我想過,今天決定先不做,理由有三個,寫下來備查:

一,它會把「可解釋」換掉。 現在畫面上那個 29%,我可以告訴使用者它的意思是「這台輸出裡有 29% 的 12 字元片段原封不動出現在答案裡」。這句話任何人都能自己驗證——複製兩段文字、用眼睛找就行。換成 cosine similarity 0.71 之後,沒有人能驗證它,包括我自己。在一個要給別人看的儀表板上,可驗證比準確更重要,至少在早期是。

二,它會把成本和不確定性帶進量測本身。 算 embedding 要嘛跑本地模型(手機上不行)、要嘛打 API(要金鑰、要配額、要網路)。也就是說,量測這件事本身會開始失敗、會有延遲、會因為換了模型版本而讓歷史數字不可比。我現在這個指標的優點是它永遠可算、算出來永遠一樣——一段純函式,離線、確定性、零成本。這種性質在累積趨勢的時候價值很高。

三,我還沒把便宜的方法用完。 條目層級的比對(把兩邊都切成條目,再看有沒有對應)介於逐字和語意之間,仍然是確定性的、可解釋的,而且對清單型題目幾乎一定比字元片段準。先把它做完,如果還是不夠,再談 embedding。

這三條合起來其實是同一個判斷:在觀測工具上,我願意用精確度換確定性。 一個永遠能算、算法透明、但偏保守的指標,比一個更準但偶爾失效、沒人能驗證的指標,更適合拿來累積三十天的趨勢。

今天真正學到的

昨天我寫:指標會在系統做對事情的時候給你難看的數字。今天學到的是反過來的那一面:

指標的成績單,要用另一個指標來看。

我昨天做的分類器,用「它在某一輪的表現」去判斷是沒有意義的——那一輪三份全是產出型,它一份都不用分類就「正確」了。真正讓它現形的,是九份樣本的分布:4 讀得懂、5 讀不懂。

換句話說,我為了量測 mesh 而做的東西,本身也需要被量測;而且量測它的方法跟量測 mesh 是同一套:累積、記錄、看分布、回頭讀原始資料。

這大概是這個系列做到第三週最具體的一個體會。一開始我以為「做觀測」是一件做完就結束的事——加一個數字、顯示在畫面上、完成。實際上它是遞迴的:你加的每一層觀測,都會變成下一層要被觀測的對象。停在哪裡是選擇,但至少要知道自己停在第幾層。

明天

兩件事。

第一,把規則 3 從「開頭的動詞」改成「整句裡的交付語」,然後拿今天這九份當回歸樣本,看未分類能從 5 降到幾。這是今天刻意沒做的那一步,因為我想先有對照組。

第二,開始做條目層級的比對:清單型題目的每一條,是否有對應的條目進入最終答案。這才是逐字重用率真正該被取代的地方——今天 E1 的 78% 和 E2 的 29% 差距這麼大,有很大一部分只是「散文比清單容易被原句抄走」,跟貢獻多寡沒有關係。

Z13 如果回來,先重跑欠著的真機測試。


上一篇
Day 20|一行紀錄,和一個指標自己打臉的反例
下一篇
Day 22|我修好了分類器,然後它在下一輪拿了零分
系列文
從單一agent 到多agent 集群的開發流水帳以及應用28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言