iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
AI Engineering

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

Day 07|先量再修:一天之內,我自己的佇列有五格是錯的

  • 分享至 

  • xImage
  •  

系列背景:私有自持的 AI agent 網格 spectyn-mesh
Day 06 把單機版裝進 .app,並收掉了「停止說謊」到「權限批准」四個階段。
今天接著跑最後兩個階段 —— 而這一天真正的主題不是那些修法,是修之前先量

母規則同前:任何檢查必須先示範它會紅才算數。綠燈不是證據。
今天多學到一條:佇列也不是證據。

記分板

佇列是我前一天寫的,基於當天的觀察。今天照它往下做之前,先把每一項的現況
實際量一次——每項一個唯讀 agent,發現再交給一組獨立的懷疑者駁倒。

兩輪、24 個 agent、12 個題目。五個翻案。

一、五個翻案

翻案表

翻案的不是「我做錯了」。是題目是一天前寫的,而那一天之後東西動過了 ——
其中兩項在我自己前一天的修復裡就已經好了,我卻還在佇列上留著它們。

照字面往下做的代價很具體:兩項會去修沒壞的東西,一項會刪掉一道刻意的安全閘,
一項會為不存在的缺口補測試。

二、翻案一:「成本永遠 $0.00」——修一個完全正確的東西

成本

測量 agent 做了一次 A/B:同一棵樹、同一個 mock provider、同一個模型,
線上餵同一份 usage(1,000,000 prompt + 500,000 completion,gpt-4o 費率 = $7.50)。

非串流路徑算出 $7.50,ledger 有寫。 CostTracker、價格表、持久化、
四處顯示——全部正確。

壞的只有串流路徑的 usage 傳遞,而且斷在三個地方:每個 synthetic return
寫死 "usage": {};SSE 解析迴圈只看 choices / delta / type,而 usage frame
帶的是 choices: [];include_usage 只在有工具時才向供應商索取。

成本餵食點的守衛是 if prompt_tokens > 0 || completion_tokens > 0 ——
兩個都是 0,所以 record() 從來沒有被呼叫過。不是「記成 0」,
是連 ledger 檔都不會被建立。

如果照佇列去「修成本追蹤」,我會在一段完全正確的程式碼裡找一個不存在的 bug。

三、翻案二:「serve 要能綁 tailnet」——照字面修會開一個洞

它現在拒絕,而那是刻意的:外部複查之後加的 fail-closed 閘。
照字面「修好」——把 guard 刪掉——會把 22 條未驗證的 /api/rpc 路由
(/api/todos/api/sessions/api/cost/api/governance/log/rpc/peers……)
publish 到 tailnet 或區網上。

這一項我沒有動,列成裁定點交回去。這是安全決策,不是我該替操作者做的。

一個佇列項目寫成「今天拒絕」,讀起來像缺陷。實際上「拒絕」就是它的功能。

四、翻案三、四、五:兩項早就好了,一項題目寫錯

macOS 換錯目錄uninstall 移不掉舊 label,測量都回報
already_fixed——前者在 commit 40219efe,後者兩個 label 都處理。
照佇列做會把已經修好的程式碼改回舊 bug。

「唯一碰精靈的測試斷言精靈不要跑」 這一條更直接:題目本身是錯的。
現在有六個檔在測那個精靈,其中 onboarding_asks_before_it_acts 在真的 PTY 上
驅動它並斷言正向行為。那是前一天才補的——題目比修復舊了一天。

五、真正壞的那條:半截答案

半截答案

這是今天主題最純粹的一次。同一份內容,伺服器兩種死法:

stdoutstderr、退出碼——逐位元無法區分,兩次 stderr 都是空的。
同一個埠上 curlexit 56 Connection reset by peer

一條 CI 流水線讀那份 JSONL,收到的是一個肯定的完成事件,而且沒有任何東西
可以據以判斷。

原因是五個離開串流迴圈的出口——[DONE]、body EOF、傳輸錯誤、閒置逾時、
使用者中斷——全部是一個裸的 break。等到有人去看,一條被 RST 砍斷的連線
和一次乾淨的完成已經是同一個事件。唯一的守衛 if full_content.is_empty(),
被一個位元組的內容解除

修法的三個陷阱,測量都先講了:

不能「沒有 [DONE] 就算斷線」。 Anthropic 的串流就是靠 body EOF 結束的——
這個迴圈根本不解析 message_stop。所以我只標記兩個無歧義的死法:
傳輸錯誤、閒置逾時。EOF 刻意不標,理由寫進程式碼。

不能轉成 Err 讓 provider 鏈重試。 這個函式同時位在 failover 迴圈
多輪 agent 迴圈裡。那一輪若已發出 tool_calls,重跑會再執行一次
file_writeshell一次網路抖動不可以變成操作者磁碟上的重複副作用。

這段程式碼就是已安裝的 daemon 在跑的。 所以全程沒有重建到已安裝的產物。

順帶:閒置逾時從寫死 30 秒改成可設定。不是為了彈性——最常見的死法
(wifi 斷了但 socket 沒關)走的就是那條路,而沒有測試付得起半分鐘
,
所以行為最誤導的那條路完全沒有覆蓋。

六、另一條:一個填了 R2 也修不好的 404

404

self-update 沒有可用來源。但比題目更糟的是——就算操作者把 R2 填滿,
CLI 要的那個名字仍然會 404

線上伺服器把兩種失敗分得很清楚:404 是 manifest 的答案,503 才是 bucket 的。
大家會去做的那個修法(上傳 binary),根本不可能生效。而
scripts/publish-mac.sh 用 OS-ARCH 名字 curl 驗證自己的上傳,會回報成功,
同時 self-update 依然壞著。

第三個獨立斷點:.sha256 在 Worker 裡出現零次。更新器沒有 digest 就
fail-closed——那個拒絕是對的,它是下載唯一的完整性檢查——所以名字對了、
bucket 填了,還是裝不起來。

修法是把 Worker 的 manifest 補上 triple 形式別名、加上 sidecar 服務,
並新增一條從 Rust 讀 TypeScript 的測試:CLI 與 install.sh 能請求的每個名字,
都必須在 manifest 裡。請求名與儲存名住在不同語言,先前沒有任何東西檢查它們一致。

危險提示也很具體:別放寬 https 守衛、別讓 sidecar 變成可選。
來源是空的,守衛是對的。

七、我發表的文章裡有一句不真的話

前一天我寫了一個 mDNS advertiser 的回收器,並在 commit 訊息、當日實錄、
以及發出去的兩篇文章裡都說它「關閉時會收掉」。

測量回報:呼叫點零個。

保留 Child 那半落地了,收掉那半從來沒接上。而守衛本身就是空綠:

assert!(src.contains("pub fn stop_mdns_advertising"), ...);

它斷言那個字串存在。一個沒有人呼叫的函式完全滿足它——而那正是實際出貨的狀態。

能力不是行為。

實機證據:那台機器上有三天前(8/26、8/27)開始廣播的 dns-sd,到今天還活著。

八、紅證的第二種用途

紅證

母規則我用了六天:任何檢查先示範它會紅才算數。今天學到它還有第二種用途。

修完串流成本之後,我跑了六條紅證,五條精準命中。第六條——把 cli_session
回傳點改回丟棄 usage——每一條測試都還是綠的

那不是空綠。那是那個位置根本沒有東西守著。 紅證沒有證明檢查有效,
它指出了哪裡從來沒有檢查。

同一天還有一次更繞的:停滯測試的紅證是「把逾時改回寫死 30 秒」,結果測試照樣綠。
我以為抓到空綠,細看才發現——「寫死 30 秒」根本不是缺陷,它只是讓測試變慢,
行為仍然正確。正確的隔離證明是把 Timeout 從判定裡拿掉。

紅證選錯標的,本身也是一種空綠。

收尾:今天帶走的幾句話

  1. 佇列不是證據。 它是某一天的觀察,而觀察會過期。修之前先量。
  2. 問「照字面做會發生什麼」。 五個翻案裡,這個問題擋下了兩次修沒壞的東西、
    一次刪掉安全閘。
  3. 一個寫成「今天拒絕」的項目,「拒絕」可能就是它的功能。
  4. 能力不是行為。 一個函式存在,不代表有人呼叫它。
  5. 修法的危險要先問清楚。 三個陷阱都是測量先講的,不是我踩到才知道。
  6. 紅證全綠不一定是空綠——也可能是那裡從來沒有檢查。
  7. 選錯紅證標的,和沒做紅證一樣。

以下是當天實錄,原文按時序未改


下半部:當日實錄(原文)

Phase 4 —— 日常使用的保真度,以及兩個被翻案的題目

這一階段做法換了:先用 14 個 agent 平行測量七項現況,再動手
每一項一個唯讀 agent(不准改檔、不准碰真實資料根、不准在區網放 daemon),
發現再交給一組獨立的懷疑者駁倒。17 分鐘。

值得的地方不是省時間,是兩項被翻案——照題目字面去修,一項會修一個沒壞的東西,
另一項會開一個安全洞。

翻案一:「成本顯示不得結構性永遠 $0.00」——題目寫錯了

量測 agent 做了一次 A/B:同一棵樹、同一個 mock provider、同一個模型、
線上同一份 usage(1,000,000 prompt + 500,000 completion,gpt-4o 費率 = $7.50)。

路徑 結果
非串流(serverun_tracked) cost_usd: 7.5,ledger 有寫
串流(run_with_callbacks,TUI 與 exec 走的那條) $0.00,而且連 ledger 檔都沒產生

也就是說 CostTracker、價格表、持久化、四處顯示全部是對的
照題目去「修成本追蹤」,會去修一個沒壞的東西。

壞的只有一條鏈,而且斷在兩個地方:

  1. call_with_streaming 的每個 synthetic return 都寫死 "usage": {},
    而 SSE 解析迴圈只看 choices / delta / type ——
    usage frame 帶的是 choices: [],所以它到了線上,被走過去了。
  2. stream_options.include_usage 只在有工具時才要。純聊天串流從一開始就
    沒跟供應商要那個 frame,所以解析得再對也沒東西可讀。

成本餵食點的守衛是 if prompt_tokens > 0 || completion_tokens > 0 ——
兩個都是 0,所以 record() 從來沒有被呼叫過。不是「記成 0」,是根本沒呼叫:
連檔案都不會被建立。

修的時候還撞到第三個:cli_session 那條(codex/opencode/agy)回的是
{input_tokens, output_tokens},而成本讀的是 {prompt_tokens, completion_tokens}
兩種拼法、一個讀取端 —— 跟這一整天其他缺陷同一個形狀。所以光把
Ok((text, _usage)) 改成不丟掉還不夠,得先正規化。收斂成一個
normalize_usage(),是唯一調和它們的地方。

翻案二:「serve 要能在有 Tailscale 的機器上啟動」——這個修法是危險的

它現在拒絕,而那是刻意的:外部複查後加的 fail-closed 閘。照字面「修好」——
把 guard 刪掉——會把 22 條未驗證的 /api/rpc 路由(/api/todos
/api/sessions/api/cost/api/governance/log/rpc/peers……)publish
到 tailnet 或區網上。

列為新裁定點,不猜。可選:維持拒絕但把訊息改成可行動的、加驗證後才允許非 loopback、
或明確的 --unsafe-lan 旗標加大聲警告。這是安全決策,不是我該替操作者做的。

我今天發表的文章裡有一句不真的話

第六項「mDNS 廣播改為明示,且 advertiser 關閉時要回收」被判只做一半

stop_mdns_advertising() 早上寫好了。呼叫點零個

commit 訊息寫著「保留 Child 並在關閉時收掉」——保留那半落地了,收掉那半
從來沒接上。而 Day 6 實錄、以及今天發出去的兩篇文章,都重複了那個說法。

更難看的是守衛本身就是空綠:

assert!(src.contains("pub fn stop_mdns_advertising"), ...);

它斷言那個字串存在。一個沒有人呼叫的函式完全滿足它 —— 而那正是實際出貨的狀態。
能力不是行為。

實機證據:這台機器上有 8/26、8/27 起就在廣播的 dns-sd -R spectyn-spectyn,
活到 8/30 還在。

修法:接進 serve 的關閉序列(兩條分支都經過,且在 serve_result? 之前,
所以 serve 出錯也不會跳過),守衛改成要求 spectyn.rs非註解的呼叫。
紅證兩條:拿掉呼叫會紅、只留被註解掉的呼叫會紅。

一條自我打臉的註解

TUI 的 /tasks 會 Tab 補全,然後回「unknown command」。而 SLASH_COMMANDS_TUI
正上方的註解白紙黑字寫著:

Keep this list in sync with the match arms in handle_tui_slash. Adding a
command here without a handler makes Tab completion suggest it, then submit
it, then have the handler bail with 'unknown command' — which is the worst of
both worlds.

/tasks唯一違反它自己這條註解的項目。

而既有的 ratchet 反過來保護這個壞狀態:它斷言補全清單等於註冊表,
卻從不斷言 arm 存在。所以測試套件保證了 /tasks 會被補全,而沒有任何東西
保證它會被派發。

新守衛切出 handle_tui_slash 的函式體,對每個 TUI 指令斷言帶引號的字面值
帶引號是刻意的:contains("/task") 會被 "/tasks" 滿足 ——
那種斷言會被它該抓的東西滿足。紅證兩條:拿掉 arm 紅、把 arm 改名成前綴 /task 也紅。

同一個目錄,兩個 session 檔

session id 不是一個 bug,是三個疊在一起:

tui.rs bin/spectyn.rs
前綴 cwd- pm_
雜湊的東西 Path(cwd.hash()) 字串(cwd.to_string_lossy().hash())
寬度 {:x} {:016x}

Pathstr 的 Hash 實作不同,所以只對齊前綴會看起來修好了而仍然是壞的

而兩個介面寫進同一個命名空間。實測在操作者真實的 store 上:同一個 repo 目錄
有兩個各自長大的檔 —— cwd-f09d… 20 行、pm_6bbe… 88 行。在 REPL 停下的地方
打開 TUI,歷史就是不在那裡;而模型從該介面挑到的那個檔被餵得好好的。

pm_ 格式,因為既有歷史都在那(36 個檔 vs 2 個)。舊 id 的 session 不會失聯 ——
/resume 走前綴比對 —— 只是不再自動被撿起。

空白畫面,而模型記得一切

重啟、/resume/fork,三個都是空白畫面。而下一輪把完整歷史送給模型。

量測 agent 把這件事用實驗證明了,不是讀程式碼推的:它架了一個假的
OpenAI-compat provider,攔下外送的 POST body —— 裡面有 8 則訊息,含四個
marker 字串,逐字都在。同一輪,螢幕上一個都沒有。

所以 agent 表現得像記得一段操作者看不見的對話,而空 session 和 88 輪的 session
長得一模一樣

共用一個 replay_view() 做「該顯示什麼」的決定(過濾 tool 流量與空白、
取最後 20 則、回報略過幾則),兩個介面都讀它 —— 否則它們會對同一個 session
顯示不同的過去。

ws://auto:PORT/ws

banner 印的是設定檔裡那個字,而 host 預設就是字面字串 "auto",從不被重新賦值。
量測 agent 直接用 curl 證明:

$ curl -sS -m 5 "http://auto:39873/healthz"
curl: (6) Could not resolve host: auto
$ curl -sS -m 5 "http://127.0.0.1:39873/healthz"
ok

同一個行程,一個死一個活。從 banner 複製一行出去的人,拿到的是死的端點。
而且還有第二處:spectyn coordinatorlisten : http://auto:PORT

修的時候我自己引入了一個問題,被守衛抓到:resolve_serve_bind_hosts 會在
stderr 印警告,而 serve 路徑呼叫了三次(LAN 閘、banner、bind)——
那句「只綁 loopback、對所有 peer 隱形」的警告會印三次。改成整條路徑解析一次,
banner 印的就是 bind 用的那一份

守衛數了代理指標,而不是那件事本身

banner 那條的守衛我改寫過一次。第一版數「視窗內呼叫 resolve_serve_bind_hosts
幾次」——而修好之後視窗內本來就只剩一次,所以把 banner 改回獨立解析,測試照樣綠

它量的是代理指標。改成直接斷言 banner_host(&host, &bind_hosts) ——
把 banner 綁在 bind 用的那個值上 —— 才會紅。

紅證抓到一個覆蓋缺口

cost 那批六條紅證,五條精準命中,第六條沒有:我把 cli_session 回傳點改回丟棄 usage,
每一條測試都還是綠的

不是空綠,是那個位置根本沒有東西守著。那條路要 shell out 到外部 CLI,
CI 上不存在,所以沒有 hermetic 的驅動方式。補了一條 ratchet 並老實標明它是 ratchet:
斷言那個 arm 沒有 Ok((text, _usage))、而且有 normalize_usage(&usage)

紅證的用途不只是「證明這條檢查有效」,還包括指出哪裡根本沒有檢查

順手量到一個沒人量過的稅:45 分鐘的 Gatekeeper

今天全量測試跑了幾次,每次都覺得「卡住了」。實際量:

編譯        7m39s
--list     ~45m      ← 這裡
實跑         98s

nextest 在真正跑測試之前,會對每個測試 binary 執行一次 --list。而這個專案有
180 個測試 binary。列舉階段每個行程都是 0% CPU 在等 —— 我一度以為是磁碟 IO。

ps 給了答案:

%CPU   COMMAND
37.3   /usr/libexec/syspolicyd

macOS 的 Gatekeeper 正在對每個剛連結出來的 binary 做首次執行評估。
180 個 × 十幾秒 ≈ 45 分鐘。這也解釋了為什麼列舉之後真正跑測試只要 98 秒:
那時每個 binary 都已經評估過了。

換句話說,一次完整重建之後的全量測試,有 95% 的時間不在編譯也不在測試

修法在系統設定那邊(把終端機加進「開發者工具」豁免),不是我能替操作者按的 ——
但值得記下來,因為它比今天早上那個 55 GB 的建置稅貴得多,而且完全隱形:
沒有任何輸出會告訴你它在等 Gatekeeper。


Phase 5 —— 又翻案兩項,和今天最純粹的一次說謊

同樣先平行測量五項,再動手。五項裡兩項早就修好了、一項題目本身是錯的。

項目 裁決
self-update 有可用來源 確認,而且比題目更糟
macOS 上換對目錄 早就修好了(commit 40219efe)
service uninstall 移得掉舊 label 早就修好了
「唯一碰精靈的測試斷言它不要跑」 題目寫錯——今天已經有六個檔在測,其中一個在 PTY 上真的驅動它
網路中途斷線把半截答案當完整回傳 確認,而且機器可讀的那條通道更糟

如果照佇列字面往下做,五項有三項會做白工或做錯。

spectyn exec --json 對一個被砍斷的串流說「done」

量測 agent 架了一個假的 OpenAI-compat SSE 伺服器,兩次執行只差在伺服器怎麼結束:

乾淨:  {"type":"done","output":"…1) open the file, 2) edit the line, 3) save."}   exit 0
砍斷:  {"type":"done","output":"…1) open the file, 2) edit the li"}               exit 0
                                                                ↑ 斷在字中間

stdout、stderr、退出碼逐位元無法區分,而且兩次 stderr 都是空的。
同一個埠上 curl 說:

curl: (56) Recv failure: Connection reset by peer

curl 說 56,spectyn 說 exit 0,還發了一個 done 事件。

一條 CI 流水線讀那份 JSONL,收到的是一個肯定的完成事件。

原因在五個離開串流迴圈的出口——[DONE]、body EOF、傳輸錯誤、閒置逾時、
使用者中斷——全部是一個裸的 break。等到有人去看的時候,一條被 RST 砍斷的連線
和一次乾淨的完成已經是同一個事件了。唯一的守衛是
if full_content.is_empty(),一個位元組的內容就把它解除了

這一條的修法有三個陷阱,測量都先講了

一、「沒有 [DONE] 就算斷線」會弄壞每一次 Anthropic 執行。
Anthropic 的串流就是靠 body EOF 結束的——這個迴圈根本不解析 message_stop
所以對那個格式而言,健康的結束和被關掉的 socket 逐位元相同

所以我只標記兩個無歧義的死法:傳輸錯誤、閒置逾時。EOF 刻意不標,
並在程式碼裡把理由寫清楚。兩次量到的失敗都屬於這兩種。

二、不能轉成 Err 讓 provider 鏈重試。 這個函式同時位在 failover 迴圈
多輪 agent 迴圈裡面。如果那一輪已經發出了 tool_calls,重跑會再執行一次
file_writeshell一次網路抖動不可以變成操作者磁碟上的重複副作用。

所以是「標記」不是「重試」:走既有的 Notice 通道(detect_truncation_notice
早就在用它報 max_tokens 截斷),再把旗標帶到 AgentResultDone 事件上,
exec 拒絕回 exit 0。內容照樣印出來——那是真的工作成果——只是退出碼
不再宣稱它完成了。

三、這段程式碼就是已安裝的 daemon 在跑的。 所以整個過程沒有重建到已安裝的
產物、沒有跑 self-update、沒有碰真實 prefix。

一個 30 秒的逾時,讓最常見的死法無法被測

「wifi 斷掉但 socket 沒關」是最常見的一種,而它走的是 30 秒閒置逾時——
寫死的。沒有測試付得起半分鐘,所以行為最誤導的那條路完全沒有覆蓋
改成可設定之後才寫得出測試。

今天第四次:測試證明的不是它自己的名字

停滯那條測試第一版斷言的是「truncated 為真」。紅證把逾時改回寫死 30 秒,
測試照樣綠——只是花了 20 秒。

因為我的假伺服器睡 20 秒之後執行緒回到 accept 迴圈、socket 被丟掉,
所以它是從傳輸錯誤那條路過的,只是晚了 20 秒。名字說它在測逾時,
實際上它在測另一個機制。

改成斷言理由(notice 裡要出現 "no data for"),並讓假伺服器把連線
一直握著,逾時才是唯一的出路。

然後我發現「寫死 30 秒」這個變異本來就不該紅——那不是缺陷,只是讓測試變慢,
行為仍然正確。正確的隔離證明是把 Timeoutis_truncation 拿掉:
只有停滯那條紅,另外兩條照常綠。紅證選錯標的,本身也是一種空綠。

self-update:即使操作者去補了檔案,它還是會 404

這一項確認壞掉,而且比題目寫的更糟——有三套互相矛盾的命名:

要的檔名
spectyn.rs / install.sh spectyn-aarch64-apple-darwin
線上 Worker 的 manifest spectyn-darwin-arm64
serve.rs 的 allowlist 兩個都不是

線上伺服器把兩種失敗分得很清楚:

GET /dist/spectyn-aarch64-apple-darwin  →  404  {"error":"unknown binary …"}
GET /dist/spectyn-darwin-arm64          →  503  {"error":"binary … missing in R2 bucket"}

也就是說,就算操作者去把 R2 填滿,CLI 要的那個名字仍然會 404。

而且 --dry-run假綠:它 exit 0 說「would download but not install」,
卻從來不去探那個來源——任何人用 --dry-run 驗證修法,都會在一個仍然壞掉的
來源上拿到通過。

危險提示也很具體:別把 https 守衛放寬、別讓 sha256 sidecar 變成可選——
那些是 plain-http fallback 上唯一擋住 MITM 換掉 binary 的東西。來源是空的,
守衛是對的。

命名收斂可以在程式碼裡做完;填 R2 需要 wrangler login,是操作者的事。


上一篇
Day 06(下)|同一個 Ask,三個介面三種意思 —— 兩支釘子與七次空綠
下一篇
Day 08|打勾,和真的成立是兩件事:五個翻案,和一支三天沒送到的 binary
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言