iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
AI Engineering

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

Day 19|三個修正,還有一個停不下來的任務

  • 分享至 

  • xImage
  •  

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

今天的三張待辦

昨天那一晚(Day 18)在測手機進背景多久算離線的時候,順手記了三張待辦。今天全部動手:

  1. 手機停在「設定」分頁時完全不 pull,coordinator 三十秒後把它標成離線。
  2. coordinator 不可達時,畫面還留著一排綠色的 online 0s,看起來像艦隊還活著。
  3. 「拆出去的子任務有幾份真的被用到」還是用嘴巴講的,不是數字。

第一張是昨天在真機 iPad 上撞到的,第二張是昨天自己看截圖時發現的,第三張是前天欠下的。三張都做完了,而且在做完之後又撞到第四件事,那件事才是今天真正的收穫。

先講環境:coordinator(Z13)今天整天仍然不在 Tailscale 網路上。昨天我還為這件事寫了一大段感想,今天就只是換一台繼續做——把手機指向這台 Mac 自己的 daemon。同一顆 binary,換個網址而已。這種「昨天的災難今天變成例行公事」的感覺,其實就是工程進展的樣子。

修正一:pull 迴圈搬出分頁

病因:程式綁畫面,契約綁前景

契約第 6 節寫的是:手機不跑常駐程式,只在 App 在前景的時候,每秒去 coordinator 拉一次任務。這是 iOS 的限制決定的,沒有討論空間。

但我的程式綁的是畫面。執行迴圈寫在 Mesh 畫面(集群分頁)的元件裡,隨著這個元件掛載而啟動、卸載而停止。前幾天為了讓 demo 順手,我加了一個「App 重開時回到上次離開的分頁」的行為。兩件事湊在一起就出事了:

使用者上次停在「設定」分頁 → App 重開回到設定 → Mesh 畫面沒掛載 → 執行迴圈不存在 → 手機不 pull → coordinator 三十秒後標它離線。

昨天在真機 iPad 上就是這樣:裝置解鎖著、App 明明開著、畫面亮著,可是 coordinator 的名冊上完全沒有它。當時我還以為是網路問題,切到「集群」分頁之後十三秒它就上線了,才知道是自己的架構問題。

這是那種「兩個各自合理的決定湊出一個洞」的典型。回到上次分頁是好行為;把迴圈放在畫面裡在單畫面 App 也是好行為。錯的是它們碰在一起之後,沒有人去重讀契約那句「在前景」。

改法:把整組身分和迴圈上移

新開一個檔案 meshExecutorContext.tsx,把下面這些東西整組搬出 Mesh 畫面:

  • device_id(這台手機在艦隊裡的名字)
  • BYOK adapter 清單(從本機金鑰推出來的可用模型)
  • 傳輸層(帶簽章的 HTTP client)
  • 開機時的 env 預種(從 app 沙箱的設定檔讀 coordinator 網址和金鑰)
  • §6 的 pull 迴圈本身

然後由手機外殼(那個管上下分頁的元件)掛一次:

return (
  <MeshExecutorProvider>
  <div className="flex flex-col h-[100dvh] bg-spectyn-bg">
    …header…
    <main><Outlet /></main>
    …tab bar…
  </div>
  </MeshExecutorProvider>
);

外殼在兩個分頁之間切換時不會卸載,所以迴圈也不會。

一個小設計:不要為了重構去改測試

Mesh 畫面的單元測試是直接 render(<MobileMesh />) 的,沒有外殼。如果我讓畫面硬性依賴 provider,四個測試全部要改成包一層 wrapper。改測試本身不難,但那等於用「測試配合程式」換「程式好看」,而且一旦哪天有人在別的地方單獨渲染這個畫面,它就靜靜地不動了。

所以取用的 hook 寫成這樣:

/** 外殼提供的執行器狀態;沒有外殼時退回畫面自己開一個
 *  (單獨渲染、單元測試都還能跑)。 */
export function useMeshExecutor(): MeshExecutorState {
  const fromShell = useContext(Ctx);
  const local = useExecutorState(fromShell === null);
  return fromShell ?? local;
}

useExecutorState(active) 裡面所有副作用都用 active 開關,所以有 provider 的時候,那個 fallback 只是一組空殼狀態,不會重複去讀金鑰、不會重複跑迴圈。Hook 規則(每次渲染都要呼叫一樣的 hooks)也沒有被違反。

順帶修好的一件事

env 預種本來也掛在 Mesh 畫面上。也就是說:一支開在「設定」分頁的手機,連設定都不會被種進去。我之前推 env 檔給 iPad 的時候一直覺得「怎麼有時候要重開兩次才生效」,現在知道原因了。它跟迴圈一起搬家之後,這個症狀也一起消失。

驗證:從 coordinator 那一側量

這種修正最容易騙自己:打開 App、看畫面上寫「pull 中」、覺得好了。但畫面上那行字是 App 自己說的。

所以我完全不看畫面,改從 coordinator 那一側量:把模擬器停在「設定」分頁,然後從 coordinator 每十三秒抓一次名冊,抓十二次,只看那支手機的 presence。

https://ithelp.ithome.com.tw/upload/images/20260916/20092056Lc0ZH6t8Gy.png
(手機停在「設定」分頁,Mesh 畫面根本沒有掛載)

{"tab":"settings","state":"online","age_s":0.0}
{"tab":"settings","state":"online","age_s":0.0}
{"tab":"settings","state":"online","age_s":0.0}
…共 12 筆,約 156 秒,全部 online、age 0 秒

age_s 是「距離最後一次 pull 幾秒」,全程 0 代表 coordinator 每次被問到的時候,那支手機都剛剛才報到過。舊版在三十秒後就會變 offline。

我喜歡這個驗證,因為它跟我的信念無關:coordinator 是另一個行程,它只知道有沒有收到 HTTP 請求。

修正二:讓快照承認自己是快照

昨天那張截圖的問題

昨天 Z13 從網路上消失之後,我截了一張手機畫面。上面兩行紅字很誠實地寫著名冊拉不到、pull 逾時。但下面整排裝置還在,m1 和 m5 旁邊是綠色的 online 0s

那個 0s 是拍快照那一瞬間的年齡,而畫面不會隨時間往上加。所以十分鐘後,它還是綠色的 online 0s

一個只掃一眼綠字的人會以為艦隊活得好好的。這比沒有資訊更糟:它用一個曾經為真的事實,製造一個現在為假的印象。

改法

名冊刷新失敗、而手上還有上一份成功的快照時:

  • 整塊淡化(opacity-50
  • presence 從綠/黃/紅一律轉成灰色,後面加註「(快照)」
  • 頂部一行黃字:「coordinator 不可達;以下是 N 秒前的快照,presence 不再更新。」N 每秒往上加
  • 「凍結 roster」按鈕停用

程式上就是記住最後一次成功的時間,然後在失敗期間每秒重算一次年齡:

const rosterStale = rosterErr !== null && roster !== null;

useEffect(() => {
  if (!rosterStale) return;
  const h = setInterval(() => setStaleTick((n) => n + 1), 1000);
  return () => clearInterval(h);
}, [rosterStale]);

const snapshotAgeS = useMemo(
  // staleTick 是讓這個計算每秒重跑一次的相依
  () => (rosterStale && rosterOkAt !== null
    ? Math.max(0, Math.round((Date.now() - rosterOkAt) / 1000))
    : null),
  [rosterStale, rosterOkAt, staleTick],
);

為什麼連凍結按鈕都要停用

「凍結 roster」是把當下看到的艦隊組成寫成一份 manifest,之後每次派工都要跟它逐 byte 相符。如果讓人在 coordinator 已經不可達的時候凍結一份死掉的快照,等於把一組過期的參與者變成之後所有派工的硬性要求——接下來每一次 fan-out 都會被 fail-closed 擋下來,而且錯誤訊息會指向參與者不符,沒有人會想到根因是十分鐘前凍錯了。

寧可當下按不下去,也不要事後查半小時。

測試先於真機

這個行為用畫面測試釘住:讓名冊先正常載入,然後把 coordinator 換成會逾時的版本,斷言三件事。

it("keeps the last good roster but marks it a snapshot once the coordinator stops answering", async () => {
  render(<MobileMesh />);
  await pollUntil(() => expect(screen.getByTestId("presence-m1")).toHaveTextContent("online"));
  expect(screen.queryByTestId("roster-stale")).toBeNull();

  // coordinator 消失(Z13 離開 tailnet,2026-09-15)
  mocks.get.mockImplementation(async (p: string) => {
    if (p === "/rpc/mesh/roster") throw new Error("The request timed out.");
    return ok({ snapshot_revision: 1043, attempts: [] });
  });

  await pollUntil(() => expect(screen.getByTestId("roster-stale")).toBeInTheDocument());
  // 裝置留在畫面上——但再也不會被當成活的 online
  expect(screen.getByTestId("presence-m1")).toHaveTextContent("(快照)");
  expect(screen.getByLabelText("凍結 roster")).toBeDisabled();
});

然後在真的 App 上再驗一次

單元測試證明的是「我的元件在假的失敗下會這樣」。我還想看到真的 App 在真的斷線下的樣子。

問題是:要製造「本來連得上、突然連不上」這個轉折,不能直接把手機指向一個死掉的網址(那樣它從頭到尾沒有成功過,也就沒有快照可留)。我需要一條可以中途拔掉的線。

做法是在 coordinator 前面架一個三十行的 TCP 轉送程式,聽 7999、轉給 7878,然後把手機指向 7999。等名冊正常載入之後,把轉送程式殺掉。對手機來說,這就是 coordinator 突然人間蒸發。

import socket, threading
def pipe(a, b):
    try:
        while True:
            d = a.recv(65536)
            if not d: break
            b.sendall(d)
    except Exception: pass
    finally:
        for s in (a, b):
            try: s.close()
            except Exception: pass

srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("127.0.0.1", 7999)); srv.listen(64)
while True:
    c, _ = srv.accept()
    u = socket.create_connection(("127.0.0.1", 7878))
    threading.Thread(target=pipe, args=(c, u), daemon=True).start()
    threading.Thread(target=pipe, args=(u, c), daemon=True).start()

這種「可以中途拔掉的線」在測分散式系統的時候很好用,比改設定檔或關服務乾淨:服務本身完全沒有被動到,其他使用它的東西照常運作,只有這一條線斷掉。整段測試前後,這台 Mac 上的 daemon 一秒都沒有重啟過。

拔線前:

https://ithelp.ithome.com.tw/upload/images/20260916/20092056V2ZyQ03q40.png

拔線十三秒後:

https://ithelp.ithome.com.tw/upload/images/20260916/20092056ujpHPESz3D.png

黃色那行寫著「coordinator 不可達;以下是 13 秒前的快照,presence 不再更新。」每一台裝置後面都掛著灰色的「(快照)」,整塊淡掉,凍結鈕變成不能按。這正是我要的:畫面把自己知道的和不知道的分開講。

修正三:把 3/12 變成會跑的數字

這一段的細節寫在今天另一個系列那篇(Day 03|評估|做),這裡只講結論和動機。

前天那次 12 台的 fan-out,我在文章裡寫「planner 拆出 12 份子任務,最後只有 3 份進了回答」。那個 3/12 是我自己翻畫面數出來的。沒有定義、沒有來源、下次也不會自動重算。

今天把它變成程式:把每份輸出切成十二個字元一段的片段,扣掉和題目重疊的部分,看有多少比例出現在最終合併的回答裡,超過 15% 就記為被重用。三個單元測試釘住語意。

它只測逐字重用,改寫不算,所以永遠是下限。這句話我寫在函式註解裡,也寫在手機畫面上——指標最危險的時候不是它不準,是別人以為它準。

然後遇到一個停不下來的任務

修完三件事,跑一次真的 fan-out 驗收。

五個參與者:m1 上的 claude 和 agy、m5 上的 claude 和 agy、iPad 上的 app-local 模型。題目是「用白話跟新同事解釋這個 mesh 怎麼把一個 prompt 跑遍多台機器,200 字以內」。拆解模式選「拆成子任務」。

結果:

參與者 結果
m5/claude 完成
m5/agy 完成
iPad 的 app-local 模型 失敗:incomplete_receipt: nonce not echoed
m1/claude 從頭到尾排隊
m1/agy 從頭到尾排隊

先看 planner 這次怎麼拆。同一題「用白話解釋這個 mesh 怎麼運作、200 字以內」,它派出去的子任務是:

  • iPad 的模型:寫一份 200 字以內的白話草稿,涵蓋協調者做什麼、工人做什麼、答案怎麼合回來,不要術語,可以用簡單比喻。
  • m1/agy:檢查目前專案目錄裡的檔案,找出協調者實際上怎麼運作,寫一段 60 字以內的白話摘要,並引用你依據的檔名。
  • m1/claude:同上,但寫工人那一段。
  • m5/agy:同上,但寫「答案怎麼合回來」那一段。
  • m5/claude:當編輯,把白話草稿拿去對照專案檔案檢查正確性,產出最終版本,三段、200 字以內,最後報告字數。

拆得其實不差:一個寫草稿、三個去查證、一個當編輯。問題在於它假設每個工人都能讀到專案檔案,而這次派工的 scope 是唯讀且不給工具(read-only-no-tools)。m5/claude 的回答第一句就是「我沒辦法對照專案檔案,這個工作階段禁止瀏覽檔案,而且草稿本文也沒有放進訊息裡」——它照樣寫出了一份好答案,但它是照著題目描述寫的,不是照著程式碼寫的。

換句話說,planner 拆出來的五份工作裡,有四份的前提在派工當下就不成立。這件事昨天的 12 台那輪也發生過(那次是拆出「字數稽核」「投影片大綱」這類 meta 工作)。planner 不知道工人手上有什麼權限,是這個系統現在最具體的一個缺口。

失敗一:我自己的兩個決定撞在一起

iPad 那份的內容其實寫得很好,用了一個派對籌備的比喻解釋協調者和工人。但它被判失敗,錯誤是「收據不完整:輸出裡沒有回顯 nonce」。

原因是兩個各自合理的決定:

  • 手機端的輸出上限設成 256 token。這是前幾天調的,因為四支手機共用一把 Groq 免費 key,每分鐘輸出 token 有上限,一支手機寫太長就會餓死下一支。
  • 收據驗證要求模型把一串隨機字串(nonce)原樣寫進輸出。這是用來證明「這份輸出真的是這次任務產生的,不是回放的舊資料」。

答案寫滿了 256 token,nonce 在結尾,被截掉了。於是一份正確的回答被判為造假。

這條我不自己偷改,寫成給契約的備註:nonce 要嘛允許放在輸出開頭,要嘛另外開一個欄位回傳,不要要求它出現在一段長度受限的自由文字的尾巴。

失敗二:m1 的兩個從沒開始

m1 的 daemon 是活的,/api/version 有回應,名冊上它是 online。但那兩個 attempt 就停在排隊,一動也不動。

那是別人的機器。我查到「可達但不接手」就停手,寫進報告交給那個節點自己查。跨機除錯最容易出事的就是熱心。

失敗三:整個任務停不下來——今天真正的發現

我按了停止。契約第 4.3 條寫得很清楚:停止指令送出後,畫面只能寫「停止中」,要等到終止事件才准寫「已停止」;超過三十秒沒有終止事件的 attempt 標成「未知」。

於是畫面變成:任務「停止中」,m1 那兩個 attempt 顯示「未知 · process tree 尚未退出」。行為完全正確,契約沒有錯,實作也沒有錯。

但結果是:三份已經完成、內容正確的輸出,使用者一份整合回答都拿不到。

因為我的合併是在任務「終止」時才觸發的。而任務永遠不會終止,因為有一個節點安靜了。

一個節點安靜,另外三個節點的成果全部作廢。這不是契約的錯,是我的介面把「有沒有答案可看」綁死在「任務有沒有收乾淨」上。這兩件事在分散式系統裡本來就不該綁在一起——前者是使用者的需求,後者是系統的內務。

當場修掉

任務還沒完成、但已經有可合併的輸出時,畫面出現一顆按鈕:

先用已完成的 N 份輸出彙整(其餘未回報)

按下去就用手上有的東西產出完整回答,括號裡誠實寫著有幾份沒回報。

{synth.status === "idle" && mergeableCount > 0 && shownTask !== "completed" && (
  <button onClick={() => void synthesize()} data-testid="merge-now">
    先用已完成的 {mergeableCount} 份輸出彙整(其餘未回報)
  </button>
)}

按鈕上那句「(其餘未回報)」是刻意寫進去的。使用者按下這顆按鈕拿到的東西,和五台全部回報之後拿到的東西,在畫面上長得一模一樣——如果不標示,兩者就分不出來了。系統可以降級,但不能讓降級的結果看起來像完整的結果,這跟修正二的「(快照)」是同一條原則。

再跑一次同樣的題目,按鈕如期出現,按下去,完整回答出來了,下面帶著今天做的重用率:

https://ithelp.ithome.com.tw/upload/images/20260916/20092056MpgMlnDtgN.png

子任務文字重用 1/1(100%;門檻 15% 的 12 字元片段。改寫不算,故為下限)
m5/claude 63%

五個位置,一份成品。數字很難看。但它是程式算的,不是我數的,而且下次會自動再算一次。

今天的帳

項目 結果
pull 迴圈脫離分頁 已驗證:設定分頁停 156 秒,coordinator 全程看到 online、age 0 秒
名冊快照誠實化 已驗證:畫面測試 + 真 App 拔線實測(13 秒前快照)
子任務重用指標 已驗證:3 個單元測試 + 真跑一次
卡在「停止中」拿不到回答 當天發現、當天修
測試 30 個全綠
型別檢查 0 錯
commit eb2012b6
coordinator 仍是本機 daemon;Z13 整天離線

給契約的三條備註

  • 第 11 條(昨天提的:手機停在別的分頁不 pull)由 app 端修掉了,契約不需要改。這條記錄下來是為了讓別台機器知道:如果他們的手機端也把迴圈綁在畫面上,會有同樣的病。
  • 第 12 條nonce not echoed 對手機端是可預期的失敗模式,因為輸出上限會截掉尾端。建議允許 nonce 放在輸出開頭,或另立欄位回傳。
  • 第 13 條:任務卡在「停止中」目前沒有 coordinator 端的收斂機制。建議停止指令送出後給一個最長等待(例如 60 秒),逾時就由 coordinator 自己標成已取消。否則發起端只能像我今天這樣自救,而每個客戶端都要自己發明一次自救方式。

今天真正學到的

三個修正裡,最有價值的不是任何一個修正本身,而是它們的共同形狀:每一個都是「程式做的事」和「契約說的事」之間的縫

  • 契約說「在前景時 pull」,程式做的是「畫面開著時 pull」。
  • 契約說 presence 來自最近一次 pull,程式做的是把上次的數字繼續顯示。
  • 契約說停止後要等終止事件,程式做的是把使用者能不能看到答案也一起等下去。

三次都不是有人寫錯 code,是實作在契約沒有明說的地方自己補了一個假設,而那個假設在正常情況下看不出來,只有在某個節點安靜、某台機器離線、某個使用者停在別的分頁的時候才會現形。

所以今天結束時我加了一條個人規則:每次寫「什麼時候要做 X」的條件式,先回去看契約那句話的主詞是什麼。 是 App?是畫面?是任務?是裝置?主詞錯了,程式就會在邊角崩塌。

明天

Z13 回來的話,用同一組腳本對它重跑一次,把數字補進 Day 18 那篇。不回來就繼續在本機做下一件事:讓每次 fan-out 把「派幾份、完成幾份、重用幾份」寫成一行紀錄存下來,這樣重用率才有機會累積成趨勢,而不是每次都是孤零零的一個數字。


上一篇
Day 18|coordinator 掛掉的一晚:手機進背景多久算離線,以及沒有 failover 是什麼感覺
下一篇
Day 20|一行紀錄,和一個指標自己打臉的反例
系列文
從單一agent 到多agent 集群的開發流水帳以及應用28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言