iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

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

Day03 CLI 要怎麼併進 app?我的答案是不要併

  • 分享至 

  • xImage
  •  

本文重點

  • CLI 不併進 app。能力收斂到那個已經在跑的 daemon,CLI 和 app 都只是它的前端。
  • 一致性不靠紀律:一份命令表 → 五個 renderer,表外的命令編不過。
  • 實作日誌:釘住 89 項現有行為 → 建表 → 從表長出 HTTP 端點,缺口 7 → 0
  • 拿當天蓋好的端點去問一台空機器,量出「有本機模型卻拿不到回應」並修掉。

全文分四部:① 推導——為什麼不該併 ② 設計——一份表,五個 renderer ③ 實作日誌——今天實際動的手 ④ 收束


〔第一部 · 推導〕

前言:從我實際會怎麼用往回推

前兩天我把 spectyn 的架構攤開,也對照過五家商業級專案。今天要處理一個我拖了很久的問題。

拖很久是因為它聽起來很簡單,做起來卻不知道從哪開始:我之後主要會用的是 app 版,各個平台的 app。那我這一整套 CLI 該怎麼辦?

61 個 subcommand、兩萬行的入口檔、還有一個一萬行的終端機介面。這些東西要怎麼「併進」app?

我想了幾種做法,最後的結論是:這個問題本身問錯了。

「併進去」的直覺會製造第三套實作

我原本的想法是「把 CLI 的功能搬進 app 的介面」。

但前天盤點的時候我已經量過了,現在的狀況是這樣:

一套 另一套 誰活著
4,268 行的供應商鷹架 8,727 行的供應商層 後者
10,630 行的終端機介面(行程內) 11,195 行的 HTTP daemon 兩套都活
173 個桌面 IPC 命令 34 個 HTTP 端點 兩套都活,網頁端只拿得到後者

我已經有兩套了。把 CLI 再「搬進」app,就是第三套。

所以正確的問法不是「CLI 怎麼併進 app」,而是:

能力應該住在哪裡,才能讓 CLI 和 app 都只是它的前端?

答案是那個已經在跑的 daemon。

圖 1:終局架構

圖 1 終局架構:一個安裝物、一份能力面、幾個薄前端。發行 app 化,執行不 app 化。

六個面向,從安裝到使用

一、安裝:一個平台一個安裝物

這是解掉「安裝零零落落」的正解。

app bundle 內含 spectyn 執行檔。使用者裝的是 app(.dmg / .msi / App Store / Play Store),CLI 跟著一起進來。

想用 CLI 的人,app 提供一個「安裝 shell 命令」的按鈕,把它 symlink 到 PATH——就像 VS Code 的 code 命令。不想用的人根本不需要知道底下有這東西。

這一步的好處是複利的:自動更新、程式簽章、公證,全部只做一次而不是兩次。 而且 app 可以 per-user 安裝,剛好對上我工作電腦沒有管理員權限的限制。

二、生命週期:daemon 要活得比 app 久

平台 daemon 怎麼活
Mac / Windows / Linux app 首次啟動時註冊到系統的自啟機制。app 關掉之後 daemon 繼續跑
Android 前景服務
iOS 只能在 app 前景時活著

第三行是硬限制,不是我偷懶。iOS 不讓你跑常駐背景程序,所以 iOS 上的 spectyn 預設不是自足節點,是連到我桌面節點的 client

這件事要寫進設計文件,因為它跟我原本「單機就是硬底線」的假設衝突——那條在 iOS 上不成立。

三、設定與金鑰:一份,不是兩份

CLI 和 app 必須讀寫同一個地方。這點現在是對的,不要動。

要改的是金鑰去處。目前使用者在 UI 貼的 API key 會落到明文設定檔——因為我的 keystore 工單四張(Mac / Windows / iOS / Android)一張都沒做。

在沒有終端機的世界裡,UI 是使用者唯一能貼 key 的地方。所以 keystore 從「以後再說」升級成安裝流程的前置條件

四、能力面:這是最大的一塊工,但性質是機械的

我量過現在的 IPC 命令,173 個註冊、前端只叫了 19 個。逐個分類之後:

  • 14 個必須留原生:keychain、感測器、開系統設定頁、開外部連結、啟動 daemon、OAuth 深連回呼
  • 158 個可以搬到 HTTP 端點,其中 25 個已經有對應端點可以直接刪

搬完的效益是複利的:一個端點同時餵桌面 app、手機 app、瀏覽器、CLI、終端機介面五個表面。

判準很簡單:需要 app 自己的行程脈絡或作業系統整合的,留原生;其餘一律搬。 注意 daemon 本身也是原生程式,所以「瀏覽器做不到」不等於「必須是原生 IPC」。

五、終端機介面:不用移植,因為它已經做好了

這是今天最意外的發現。

我以為「怎麼把終端機介面弄進 app」是個大工程。翻程式碼才發現,daemon 裡已經有一條 PTY-over-WebSocket 的橋,而且做得比我記憶中完整:

  • 命令白名單
  • 跨機:可以經 SSH 把終端機開到遠端節點上,目標來自我自己維護的節點清單,不吃使用者輸入
  • 認證:本機呼叫豁免,其餘要簽章,而且簽章涵蓋 query string

最後一項是修過的漏洞:舊版只簽 body,所以一個從別的端點抓來的簽章可以被重放成 ?cmd=shell。現在補上了。

前端也在——xterm.js 和 fit addon 都烘進執行檔裡,還有一個 504 行的網頁終端機頁面。

所以答案是:終端機介面不用進 app。它跑在 PTY 裡,app 裡就是一個分頁。

但有個天花板要先說清楚:那是把畫面搬到瀏覽器,不是原生介面。手機上用終端機介面會很痛苦,而且 app 讀不懂畫面內容——對它來說那只是一堆像素。

所以:桌面用這條就夠,手機端要真正的原生畫面,而且只做手機真正需要的那幾個,不要整套搬過去。

六、使用時的分工

表面 角色
app 主力,日常九成的事
CLI 腳本、自動化、CI,以及 app 起不來時的救援
終端機介面 進階模式,在 app 的分頁裡或獨立終端機裡

那乾脆完全 app 化,不要 CLI 了?

我認真想過。好處很實在:只有一個安裝物、沒有 PATH 要管、沒有版本不一致、免管理員權限。

但這會直接殺掉我整個艦隊。

依賴 CLI 的東西 為什麼 GUI 取代不了
多終端派工協定 全部是 headless 呼叫
包裝各家 AI CLI 的 wrapper 腳本 靠 subprocess
驅動別家 AI CLI 的那一層 spawn 子行程是它的本質
把自己接進併審輪替 本質是被程式呼叫
MCP stdio server 必須是能被別的程式 spawn 的行程
doctor / selftest app 起不來的時候要靠它們
headless Linux / CI / SSH 跑不了 GUI

第三項特別要命。前天對照完五家之後,我把「我領先的東西」從五樣修正成兩樣半,而剩下最主要的那一樣就是**「讓別家 AI CLI 在我的治理下工作」**——那件事天生活在 shell 的世界裡。

把自己 app 化到沒有 CLI,等於親手拆掉自己唯一的差異化。

那合併成「單一執行檔,兩張臉」呢?

技術上可行:看啟動參數,有 subcommand 就走 CLI、沒有就開 GUI。

我也不建議。艦隊一天要呼叫 CLI 幾百次,每次都載入一個帶 webview 的執行檔太慢。CLI 應該小而快,GUI 免不了要帶 webview。

(雖然我機器上的 claude 是 345MB、codex 是 210MB,說明大執行檔在實務上可以忍——但那是每次開一個 session,不是每分鐘幾十次的派工。)


〔第二部 · 設計〕

但「都是前端」還不夠——它們怎麼保證長得一樣?

上面那張圖解決了「能力住哪裡」,但沒解決另一半:Windows、Linux、Mac 上的行為要一致,而且 app 的介面要跟 CLI 一致。

這件事我不能靠自律,因為前面已經證明過我做不到。

現在的狀況是四份手寫:兩萬行的 CLI 入口檔手寫 61 個子命令、桌面殼手寫 173 個 IPC、daemon 手寫 34 個端點、終端機介面手寫選單。

四份手寫,所以永遠對不齊,而且沒人知道哪份是對的。

一份命令表,五個 renderer

圖 2:一份命令表,五個 renderer

圖 2 介面不是「做得像」,是同一份表的五種畫法;不一致就編不過。

命令不再手寫在任何一個地方,而是宣告在一份表裡:

Command {
    name         "goals.create"          命名空間化,不是 61 個平面名字
    surfaces     CLI | API | TUI | APP    這個命令出現在哪些面
    args         [{ name, 型別, 必填, 說明 }]
    returns      結構化回傳,不是自由文字
    side_effect  唯讀 | 會改東西 | 對外動作
    permission   權限類別
    platform     全部 | 只有 Mac
}

沒有任何 renderer 可以有表以外的命令。 所以 app 的介面跟 CLI 一樣,不是因為我「做得像」,是因為它們是同一份表的兩種畫法

有了 side_effectplatform 這兩個欄位,很多事會自動發生:

  • 唯讀的命令在 app 上不用確認直接跑
  • 對外動作的自動走手機核准
  • 只有 Mac 的命令在 Windows 上根本不出現,而不是出現之後才報錯

跨平台:差異只准住在一個地方

圖 3:跨平台分層與六處分岔

圖 3 不是「三個系統寫三份」,是「一份,加一層薄薄的落地層」。

我的樹裡真正會分岔的只有六處:

分岔源 現況
資料目錄路徑 有唯一出口的函式,但有呼叫點繞過它
服務安裝 三個系統三份實作
金鑰保管 三個系統三份,而且一份都沒做
shell 種類 ✅ 已經在 PTY 白名單裡處理好
PTY ✅ 已經用條件編譯隔離
終端機能力 顏色、字寬

第一行是現行的傷口。那個「唯一出口」的函式,它自己的註解寫著「其他臨時呼叫點要遷移到這裡」——而我前幾天才發現,有一個呼叫點自己硬寫路徑繞過了它,結果是設了環境變數之後,資料寫一個地方、log 寫另一個地方

模式在,紀律不在。

防復發:每一條不變量配一條會紅的檢查

這才是重點。

我的問題從來不是不知道規則。四大支柱、七種事件、單一真相來源——這些規則我都寫過。問題是規則漂走的時候,沒有任何東西會叫

所以每條規則都要配一條會紅的檢查:

不變量 守衛(CI 會紅)
只能有一份命令表 renderer 裡出現表以外的命令 → 失敗
平台差異只在平台模組裡 條件編譯出現在白名單外的檔案 → 失敗
資料路徑只從那個函式來 任何檔案自己拼路徑 → 失敗
每個宣告的 surface 都要有 表說有 API 但路由不存在 → 失敗
沒有死程式碼 模組沒有任何非測試呼叫者 → 警告
檢查本身不准空綠 任何檢查必須先示範它會紅,才算數

倒數第二條會當場抓到我那個斷路器——369 行、有測試、唯一的使用者是一個不會被執行的檔案

最後一條是母規則。如果只能加一條,加它。

而且這些大多是 grep 等級的檢查,不用寫框架。我今天就修了一個現成的例子:自測框架的 feature 篩選,打錯名字時原本回「0 通過 0 失敗 0 跳過」而退出碼是 0,現在改成退出碼 2 並列出所有合法名稱。


〔第三部 · 實作日誌〕

重構順序:第一步不要動程式碼

一次重寫兩萬行是自殺。但更重要的是——在行為被釘住之前動手,就是在賭。

這兩天我已經付過兩次學費:兩次動了不在執行路徑上的程式碼,第二次還連帶污染了不相關的測試。所以順序是這樣:

圖 4:重構六步

圖 4 六個步驟的順序,以及每一步的產出。第 0 步不動任何程式碼。

第 0 步 · 釘住「現在的行為」

不是「應該的行為」,是現在實際的行為,包含 bug

spectyn --help                全部 61 個子命令的完整輸出
spectyn <每個子命令> --help    參數、預設值、說明字
spectyn doctor --json         schema
各命令的退出碼                 0 / 1 / 2 的分界

存成 golden master。之後任何重構讓這些變了就是紅燈——除非我刻意要改,那就更新 golden。

我已經有一半:自測框架 20 個功能項、doctor --json 的 schema 回歸閘。缺的是 --help 全表和退出碼。

這一步的價值不是程式碼變好,是從此以後「我沒弄壞東西」不是感覺,是可證明的。

第 1 步 · 選對第一組

我原本打算從目標管理那組開始,因為它是最大的一塊 CRUD。但那是錯的——那組目前只存在於桌面 IPC,CLI 根本沒有。統一它證明不了「CLI 跟 app 一致」這件事。

provider 才是對的第一組,因為它是整個病的縮影:

表面 覆蓋
CLI 4 個(其中一個是會印警告的已棄用別名)
桌面 IPC 約 14 個
HTTP 端點 1 個

同一件事,三個表面,覆蓋率 4 / 14 / 1。 這就是為什麼網頁端做不到 app 做得到的事。

而且它剛好落在我已經排定的第一階段(LLM 導入)路徑上——重構和進度是同一件事,不用二選一。

第 2 步 · 表先建,程式碼不動

把那組宣告成一份表。這一步不改任何行為,純粹是把散在三個地方的知識寫成資料。

寫的過程本身就有價值:我會被迫回答「偵測本機模型服務」和「測試 ollama」是不是同一件事——而現在沒人知道答案。

第 3 步 · 生成 → 逐字 diff

從表生成 CLI parser,然後跟 golden master 逐字比對。

不一樣就是錯,不管新的看起來多合理。 這一步把「重寫」變成「等價轉換」。

第 4 步 · 長出 HTTP 端點,這時才有收益

同一份表生成端點,那組從 1 個變成 14 個。這一刻網頁端和手機端立刻獲得它們。

第一次證明「一組做完,五個表面同時獲得」不是理論。

第 5 步 · 刪手寫版 + 上守衛

生成版綠了之後才刪手寫的。然後加守衛:這組出現表以外的命令就紅。

沒有守衛的重構,只是把債換個地方放。

那我就先做了第 0 步

寫完上面那段之後我就去做了,因為它不需要等任何決定。

一支腳本,把 CLI 的現況存成快照並比對三件事:每個子命令的 --help 逐字輸出、--help 的退出碼、doctor --json 的 key 集合。三種模式:比對、重產快照、以及驗證這支腳本自己會紅

目前 91 項全部相符,連跑兩次穩定。 而一行 production 程式碼都還沒改,它就抓到四件事。

一、打錯子命令會花錢

helloupdateversion 這三個不是真的子命令。打下去會落到「當成提問丟給 LLM」的那條 fallthrough。

spectyn update --help 的實際輸出:

● file_read({"path":"docs/ironman-2026/day03-app-and-cli-endgame.md"})
  ✓ # CLI 要怎麼併進 app?我的答案是不要併

agent 真的去讀檔了。 我打一個不存在的子命令,它花了我一次模型呼叫,還順便把今天正在寫的這篇文章讀了進去。

對一個整套系統的出發點就是「只負擔得起一個訂閱」的人來說,這不是小事。

二、我兩次拿錯「有哪些命令」的答案

要做快照,得先知道有哪些子命令。我拿了三次來源,前兩次都錯:

第幾次 來源 結果
1 原始碼裡那個看起來像命令表的常數 ❌ 註解明寫「它從不決定實際派發」——那是打錯字時的建議清單,裡面就有 updateversion 這種不存在的
2 靜態分析找 match 分支 ❌ 得出「37 個沒有處理」,但 doctorserveexec 明顯有。數字錯,撤回
3 拿一個空的資料根實跑每一個 52 個真的 / 3 個落到 LLM / 6 個常駐跳過

第二次特別值得記,因為它看起來很有說服力——有程式碼分析、有具體數字。但它是錯的,而如果我沒有順手驗證幾個明顯的例子,我會把那個數字寫進文章裡。

連「有哪些命令」都沒有機器可讀的答案。

這正是「一份宣告式命令表」非做不可的理由——不是為了漂亮,是因為現在連盤點都得靠實跑去猜。

三、--help 的退出碼不一致

34 個回 0、7 個回 2、3 個回 1。

回 1 的那三個更糟——它們把 --help 當成未知的子命令,錯誤訊息直接指名它:「未知的 peer 子命令:--help」。

快照刻意保留這些缺陷。修的時候要同時更新快照,那是一次刻意的決定,不是順手。

四、我自己的檢查有兩個 bug,而且都是它自己跑出來抓到的

第一個:比對函式用 diff 快照 - 從標準輸入讀實際輸出。但外層是一個 while read 迴圈——diff 把迴圈的標準輸入吃掉了

症狀:只汙染一份快照,卻報三個失敗。

第二個:那個「驗證自己會紅」的模式,一開始是空綠的。環境變數覆寫寫在檢查之後,所以子行程還是讀原本的快照,汙染了也照樣通過。

而它誠實地報告了自己失敗——「快照被汙染但比對仍然通過,這支腳本是空綠的」。

母規則的價值在這裡變具體了:如果我沒有強迫「先示範它會紅」,這支腳本會帶著一個永遠通過的自檢上線,而我會以為自己有守衛。

第 0 步的價值不是程式碼變好,是它會先幫你找到 bug。 三個燒錢的假命令、一組退出碼不一致、我自己檢查裡的兩個 bug——全部在動任何 production 程式碼之前。

然後我修了第一個——花了半小時,而它讓我看懂整棵樹的病

三個假命令裡最刺眼的是「打錯字要花錢」,所以先修它。

先寫會紅的測試(四條,其中一條是安全欄:真提問必須放行,拒太多會弄壞 spectyn "為什麼我的 build 這麼慢")。然後才動程式碼。

根因不是漏寫,是防護信任了一份不真實的清單

翻到那段程式碼的時候我愣了一下——防護本來就在,而且註解寫得很清楚:

打錯字守衛:單獨一個 spectyn <某個字>,如果它是某個真命令的近似拼法,否則會落到隱含提問並悄悄發出一次模型呼叫

作者(也就是幾個月前的我)已經想到這個問題了。但守衛有兩個洞:

洞一:判斷「是不是打錯字」的函式,對已經在清單裡的字回傳「不是打錯字」。所以 update 因為在清單裡,被判定為「正常命令」,直接放行。

而那份清單的說明自己寫著:「它從不決定實際派發」。它只是拼字建議表。

清單說「這是已知命令」,派發說「我沒有這個」,兩邊從來沒有人對帳

洞二:守衛只在「參數剛好兩個」時執行。所以 spectyn update --help(三個參數)根本不經過它。

修法

新增一個判斷函式,回傳三種結果之一:近似打錯字(建議正確的)、宣告了但沒被派發、或者這真的是提問。

第二種的錯誤訊息我刻意寫得很直白:

error: 'update' 被列為指令但沒有任何處理常式 — 這是 spectyn 自己的缺陷,不是你打錯。

因為它確實是。使用者沒做錯任何事,是我的派發表跟建議表對不上。

順帶:我先前的判斷有一個是錯的

修完之後端到端一跑,spectyn hello --help 印出了正常的用法說明。

hello 其實有 help handler,只是裸打 spectyn hello 才會落到提問。我先前把它跟另外兩個歸為同類,那是不精確的。這件事寫進了提交訊息,因為我不想讓一個錯誤的分類留在紀錄裡。

快照的第一次真實用途

改完之後跑比對,它正確抓到三項行為變更——正是我刻意改的那三個。

然後我更新快照,退出碼分布從 34/7/3 變成 33/7/3。

這就是我要的樣子:它不是攔住我改東西,是逼我知道自己改了什麼。

而全套回歸測試的失敗數,改動前後完全一樣——我先 git stash 讓樹回到動手之前跑了一次,確認那些失敗本來就在。在說「不是我弄壞的」之前,先證明它。

第 1 步:建表,然後讓缺口自己說話

修完那個 bug 之後,就是建表。第一組選 provider——理由前面說過,它同時存在於三個表面,是整個病的縮影。

先精確盤點(不靠印象,逐個從程式碼數出來):

表面 數量
CLI 4
桌面 IPC 16
HTTP 端點 1

4 / 16 / 1。 這就是為什麼網頁端做不到桌面 app 做得到的事——不是缺功能,只是缺一個沒人去寫的表面。

圖 5:provider 能力面盤點與缺口

圖 5 盤點結果與收斂後的表:九個命令、三個表面,缺口 7 → 0,兩個空格是刻意的。

表裡有三個欄位是刻意的

surfaces 不預設「處處可用」。 少一個表面必須明確寫下來。因為另一個選項——表面之間悄悄分岔——正是這張表要防的事。

effect(唯讀 / 會改東西 / 對外動作)讓每個 renderer 自己就能決定要不要先問使用者。唯讀的到處都不用確認,對外的一律升級到手機核准。規則寫一次,不用每個表面各自重新發明。

參數的 secret 標記寫在宣告處,不寫在每個呼叫點。因為「記得要遮蔽」不是策略。

然後我寫了一條刻意紅的測試

7 of 8 commands declare an HTTP surface but have no route:
  provider.list
  provider.models
  provider.set_key
  provider.validate_key
  provider.detect_local
  provider.detect_free
  provider.config_status

它的失敗訊息就是待辦清單。 每補一條路由就短一行,全補完就解除 ignore。

而 ignore 的理由字串我寫死了一句:「不准放寬斷言讓它過」。因為那是這種測試唯一會死掉的方式。

另外還有一條測試只做一件事——把現況印成數字:

provider group — declared 8 | CLI today 2 | IPC today 7 | HTTP today 1

放在測試輸出裡,而不是放在一份沒人會重讀的文件裡。文件會腐爛,測試不會——因為它每次都跑。

建表當場又逼出一個發現

要把命令寫進表,就得先確定「這個命名空間到底有哪些命令」。我把兩份說明並排看:

說明來源 列出的命令
spectyn --help provider list · provider **models**
spectyn provider --help providers list · providers **priority**

兩份說明對同一個命名空間各講一套。 models 只出現在主說明,priority 只出現在子說明,兩份都不完整——而且子說明整頁還在用已經棄用的複數形。

provider priority 是真的能跑的(我實測,它會印出那個 agent 的 provider 失效順序)。但它從來沒進過主說明。

如果我照主說明生成 CLI,它會被無聲刪掉。

它之所以進得了表,只因為建表強迫我把兩份文字並排。所以我又加了一條測試:今天能跑的每一個 CLI 形式都必須在表裡,否則生成的時候會靜默少一個。

(順帶還查到棄用別名的指引訊息是錯的:打 providers priority master,它提示你「現在改叫 provider list」——動詞掉了。行為本身是對的,訊息是錯的。)

這一步真正的產出

不是那九行宣告。是缺口從軼事變成了機器可讀的數字

provider group — declared 9 | CLI today 3 | IPC today 7 | HTTP today 1

在這之前,「網頁端功能比較少」是一種感覺。現在它是一個數字,我知道是哪幾個,而且每補一個,數字就會自己變

第 3 步:同一份表長出第二個表面

表建好之後,就是從它生成 HTTP 端點。

但我先修了一個設計錯誤

原本的缺口測試讀表裡的一個欄位——「這個命令有沒有路由」。寫完之後我才意識到:那又是一個需要人手更新的欄位。

它會像它要取代的那四個手寫表面一樣漂走。我會寫好 handler,然後忘記回去把欄位改掉,然後測試會說還有缺口,而我會開始不相信那個數字。

改成:缺口由「有沒有 handler」自動判定。

寫一個 handler,缺口就自動關閉一個,不用記得去改任何宣告。另外加一條測試釘住「報告的」和「實際接上的」不得分歧。

路徑也一樣是推導的,不是手寫的:命令叫 provider.list,路徑就是 /api/provider/list。改名的命令不可能留下一條指向空無的孤兒路由。

而舊路徑是額外註冊,不是取代——既有的呼叫端不能因為我們整理 URL 而壞掉。

三個端點,兩個刻意的取捨

寫了三個唯讀的 handler,全部只用 daemon 已經有的東西。跑起來:

/api/provider/list           HTTP 200  441B
/api/provider/config_status  HTTP 200   57B
/api/provider/priority       HTTP 200  166B

兩個取捨值得說:

列出 provider 時只回「金鑰存不存在」和來源,永遠不回金鑰本身。 表裡標了輸入側的 secret,這是同一條規則的輸出側。

設定狀態不回設定檔的絕對路徑。 在一個可能被截圖或共享的儀表板上,家目錄路徑是沒有任何好處的資訊外洩。

然後數字自己動了

之前   declared 9 | CLI 3 | IPC 7 | HTTP 1
之後   declared 9 | CLI 3 | IPC 7 | HTTP 4

紅測試的清單從七行變五行。而我沒有改動任何欄位。

這是我今天做的事情裡最有感覺的一刻——不是因為多了三個端點,是因為進度變成了一個會自己走的數字

收尾時被自己的檢查絆了一下

提交完之後跑比對,它報了一項不符。

查下去是 doctor 說明裡的 git commit hash 和一個檔案系統快照的時間戳——兩個都是每次跑本來就會變的東西,不是行為變更。

我補上遮蔽規則,然後在註解裡寫了一句給未來的自己:

每加一條遮蔽都要問「這真的是天生會變的嗎」。遮錯了等於偷偷放行一個行為變更。

因為這是這種檢查唯一的死法。不是被刪掉,是被一條一條遮蔽規則慢慢挖空,直到它什麼都不管了。

再兩個端點,而這次它們順手回答了一個我沒問的問題

剩下五個裡,我先做偵測本機模型和偵測免費層——因為它們直接服務「零設定導入」:一台已經跑著本機模型的機器,設定精靈不該再問使用者要 API 金鑰。

跑起來:

detect_local → 1 個(ollama,3 個模型)
detect_free  → 登錄簿 4 家 · 現在就能用 0 家

第二行讓我停了一下。

四家免費層全部免信用卡,而我一把金鑰都沒有?不對——我明明在用 Gemini 和 Groq。

翻一下就懂了:我的金鑰是直接寫在設定檔裡的,不在環境變數,所以這個端點看不到。

這不是 bug,是兩種存放方式沒有對齊。但它剛好就是設定精靈需要知道的那個落差——「你有四個免費選項,而你目前一個都沒接上」。我不是為了找它才寫這個端點的,是端點寫完之後它自己浮出來。

三個刻意的設計

偵測免費層時,回傳的是編譯進來的登錄簿,不做存活探測。

那些網址是硬編在程式碼裡的,而免費層會無預警改。所以「這家還在不在」是另一件事,必須真的打出去才知道。把兩者混在一起,會讓一份過期的登錄簿看起來很健康——那比不知道更危險。

只回「金鑰存不存在」和環境變數的名稱。 名稱可以露,值永遠不行。

本機模型清單只取前八個。 一台拉了四十個模型的機器,不該讓回應被模型名塞爆——而挑選器也用不到那麼多。

還有一個小地方:偵測本機模型這個命令,我在表裡標成「對外動作」,雖然它從來不離開這台機器。因為它會開 socket、會阻塞。一個把「非唯讀就顯示進度」當規則的介面,因此自動拿到正確的行為——不用有人記得為這個命令特別處理。

數字

HTTP 端點   1 → 4 → 6
紅測試清單  7 → 5 → 3

剩下三個:列出模型、設定金鑰、驗證金鑰。後兩個會碰到金鑰保管,那是另一場仗。

最後三個裡,我決定有兩個不做

剩下列出模型、設定金鑰、驗證金鑰。第一個直接寫了,後兩個我停下來查了一下金鑰現在到底存在哪

答案是:明文,存在一個設定目錄底下的檔案裡。

於是問題變成:我要不要開一個「透過 HTTP 寫入金鑰」的端點?

不要。兩個理由:

第一,daemon 綁在所有網卡上,而且 /api 不需要任何憑證。 我今天早些時候才量過——同一個 Wi-Fi 上的任何裝置都能打進來,//rpc/ping 都回 200。在那上面開一個會寫入金鑰的端點,等於讓隔壁的人改寫我的 provider 設定。

第二,金鑰目前是明文存的。 在明文儲存上再加一個表面,是把缺陷擴散,不是把它收斂。

驗證金鑰也一樣要等——它不洩漏金鑰,但每次呼叫都花我的錢,而今天任何人都能無憑證觸發它。

但「不做」要寫下來

我沒有就這樣把它們留白。我在表裡把這兩個命令標成「不提供 HTTP 表面」,並且把理由寫在宣告的正上方

因為:

「刻意不做」和「還沒做」,對下一個人不能長得一樣。

留白的話,三個月後的我(或任何接手的人)只會看到一個缺口,然後「順手補上」——把兩個問題一起帶進生產環境。寫下來,它就變成一個有條件的決定:daemon 有了認證、金鑰有了安全的去處,把開關打開就好。

然後端到端一跑,我自己的模組打了自己一巴掌

七條路由實跑,其中一條回 404

provider.health——那個表裡唯一「本來就有 HTTP 端點」的命令。我的函式說它 served,但實際上只有舊的路徑存在,新的規範路徑我根本沒註冊。

「報告的」和「實際接上的」分歧了。

而那正是這整個模組存在的理由。它不能從它自己身上開始。

修完之後我又加了一條測試:每個被報告為「已服務」的名字,都必須真的有一條規範路徑被註冊。

缺口歸零

HTTP 端點   1 → 4 → 6 → 7
紅測試清單  7 → 5 → 3 → 0

那條刻意寫紅的測試,今天解除了 ignore。從現在起它是守衛,不是待辦。

而它的註解裡留了一句給下一個看到的人:兩個命令不在計數裡是刻意的,不是疏漏,理由寫在宣告處。

然後我拿它去測一台全新的機器——結果最有價值的一次

七個端點蓋完,我想知道一件事:它們真的服務到那個目標了嗎?

所以我開了一個空目錄當資料根,模擬一台什麼都沒有的機器,把 daemon 跑起來,然後用剛蓋好的端點問它四個問題。

圖 6:空機器冷啟的四個探問

圖 6 缺口不是憑印象排出來的優先序,是拿當天蓋好的工具量出來的。

config_status → 有 1 個 provider
detect_local  → 偵測到 ollama,3 個模型
detect_free   → 登錄簿 4 家,0 家就緒
list          → anthropic,key_source: none,usable: false

第四行讓我停下來。

那唯一的 provider 是 anthropic,沒有金鑰,用不了。 而同一台機器上,一個能用的本機模型正跑著三個模型,被完全忽略。

我試著拿一個回應:

$ spectyn exec "說 hi"
錯誤:找不到 agents.toml — 執行 `spectyn onboarding`
→ exit 2

一台裝好、而且有可用本機模型的機器,拿不到第一個回應。

而它給的指引是「執行 spectyn onboarding」——一個在 app 裡的使用者永遠看不到這句話。他只會看到什麼都不動。

這個缺口是我今天蓋的工具量出來的

我沒有為了找它去查任何東西。我只是把七個端點蓋完之後,拿去問一台空機器四個問題,答案自己拼出了問題的形狀。

而且範圍比我原本估的窄很多。我在計畫裡寫的是「讓 daemon 能零設定啟動」——它本來就能。真正斷的只有一件事:零設定時的預設 provider,指向了一個不可能運作的東西。

修法:讓預設選一個真的能用的

改動很小,但有三個地方我刻意想過:

第一,選擇要是純函式。 偵測留在呼叫端,「選誰」只吃一份已經偵測到的清單。這樣它可測、不碰網路,而且空清單時的結果與原本完全相同——既有的每一條路徑都不會被影響。

第二,優先挑 coder 模型。 這條規則不是猜的,是前一天實測出來的:同一台機器上,一般模型在「讀個檔說出 bug」這題上從沒嘗試呼叫工具,而 coder 模型正確發出了呼叫。照清單順序取第一個會挑到比較差的那個——伺服器回報的順序只反映拉取順序,不帶任何能力訊號。

第三,雲端留在鏈上,不當主要。 這樣哪天環境裡出現一把金鑰,它會自己接手,使用者不用回來改任何東西。

還有一個決定是「不做什麼」:我沒有替 AgentsConfig 加上序列化能力來產生設定檔。那個結構存著 API 金鑰,給它序列化能力,等於讓「把設定寫出去」離「把金鑰寫到磁碟」只差一行。 改成直接產出最小的設定文字,波及範圍就固定在那幾個欄位。

配了兩條測試盯著:產出的文字必須解析回跟結構建構器一樣的選擇(否則兩邊會分岔,而其中一個在說謊),以及產出的文字絕不能含金鑰

結果

$ spectyn exec "只回三個字:你好世界"
提示:沒有 agents.toml — 改用偵測到的本機模型(ollama / qwen2.5-coder:7b)。
      執行 `spectyn onboarding` 可寫入永久設定。

從「exit 2,去讀一句你看不到的指引」變成「自動用能用的那個,並且說清楚它做了什麼」。

那句提示是刻意的。一份悄悄出現的設定,是使用者搞不清楚「到底是誰回答我」的開始。

這一段對我的意義

今天前面做的都是基礎工程——釘住行為、建表、生成端點。它們沒有一個是使用者看得到的。

而這一步是第一個使用者真的會遇到的改變。

更重要的是,它不是我憑印象排出來的優先序。是我蓋好的工具拿去問一台空機器,然後它自己指出來的。

不要重構的四樣

為什麼
七種事件語彙 唯一沒有分岔過的東西——因為它小到不可能偷偷長出第二套。動它就毀了唯一的錨
agent 迴圈本身 它是對的
反幻覺掃描器、行程沙盒 剩下的兩樣半差異化
終端機介面改成 client ⚠️ 最後才做。風險最高、立即價值最低,而且網頁終端機已經給了桌面一條可用的路

最後一項要特別忍住。一萬行的終端機介面很刺眼,但它現在能用,而且改它不會讓任何使用者拿到新東西。



〔第四部 · 收束〕

結論:發行 app 化,執行不 app 化

一個安裝物  →  app bundle(GUI 殼 + CLI 執行檔 + 首次啟動起 daemon)
一份能力面  →  daemon 的 HTTP / WebSocket
幾個薄前端  →  app、瀏覽器、CLI、終端機介面

一條不能破的規則

app 不得包含任何 daemon 沒有的能力。
任何 renderer 不得有命令表以外的命令。
任何檢查必須先示範它會紅,才算數。

前兩條防止雙實作再長出來,第三條防止前兩條漂走。

我已經為這個病付了很多代價:四份手寫的介面、一個接在死線上的斷路器、五個「綠燈但什麼都沒做」的檢查。這三條規則就是不讓它再發生一次。

倒推出來的依賴鏈

使用者裝一個 app、打開、就能用
  ← app 要能啟動並管理 daemon
    ← daemon 要能在零設定下啟動並送出設定精靈
      ← 能力要在 HTTP 端點而不是原生 IPC
        ← 要先知道哪一層供應商程式碼是活的

最後一項前天已經確認了。而這條鏈的底端——「零設定啟動」——剛好就是我排在最前面的那一關。

那不是巧合。零設定啟動同時是「把安裝做好」和「把整套 app 化」的共同前提,所以不管從哪個方向倒推,都會落在同一個點上。

今日頭條數字

89 項行為被釘住 · 1 個燒錢的 bug 被修掉 · 1 張命令表 · 而缺口從「感覺網頁端功能比較少」變成一個會自己走的數字:7 → 5 → 3 → 0。而拿這七個端點去問一台空機器之後,一台有本機模型卻拿不到回應的全新機器,現在拿得到了。

小結與明日預告

今天最有用的不是那張架構圖,是把問題重新問了一次。

「CLI 怎麼併進 app」問不出答案,因為它預設了「要有一個東西被塞進另一個東西」。換成「能力該住在哪裡」之後,答案就只有一個:住在那個已經在跑的 daemon 裡,其他全部是它的前端。

而最後那條規則——任何檢查必須先示範它會紅——是這三天裡我唯一覺得可以直接照抄給別人的東西。它不需要架構、不需要重構,今天就能加,而且它會立刻告訴你有多少「綠燈」其實是空的。

明天回到最前面那一關:一台什麼都沒有的機器,daemon 到底起不起得來。 這一關過不了,上面所有的推導都是紙上作業。


上一篇
Day2 把自家 AI CLI 拆開,對照五家商業級專案
下一篇
Day 04(上):一個不准猜的 CLI — 訊號說謊、母規則與免費層的半衰期
系列文
從單一agent 到多agent 集群的開發流水帳以及應用18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言