多入口與 Typert:一個核心,五張面孔
Web、headless、ACP、SDK、HTTP 共享同一個核心。
先玩再講。下面左邊是入口,右邊是核心。五個標籤是五張面孔,選一個,點播放:看這個入口披著什麼皮、發出什麼樣的報文,再看核心的會話日誌裡落下什麼事件。然後換一個入口重播一遍,盯住右邊那排事件,這就是本課要講的全部。
examples/headless-agent/cordis.yml、examples/jsonrpc-agent/cordis.yml、examples/acp-agent/cordis.yml 與 docs/api-gateway.zh.md 整理,共用核心外掛取三份配置的交集。上一批課講過,DSH 的一切功能都是 Cordis 外掛,行程啟動時按一份 cordis.yml 把外掛掛成一棵樹。這個設計在本課收穫回報:所謂「入口」,就是一份不同的 cordis.yml。倉庫的 examples/ 目錄下躺著現成的三份:headless、JSON-RPC、ACP,翻開對比會發現它們大同小異,DeepSeek 轉接器、bash 執行器、JSONL 會話持久化、壓縮、檔案系統工具這些核心外掛三份全有,差異集中在最上面幾行:JSON-RPC 入口多掛一個 sdk-jsonrpc-server,ACP 入口多掛一個 acp-demo 協議橋和沙箱策略,headless 乾脆什麼伺服器都不掛,行程本身就是入口。
Web 面孔的皮厚一點,但仍然是外掛:host-webserver 是個純粹的 node:http 載體,文件明說它不屬於 agent loop、不瞭解任何 harness 概念(docs/subsystems/web-server.zh.md);frontend-static 認領回退席位當 SPA 伺服器;client-modules 用 tapIndex 往 index.html 裡注入啟動清單 window.__DSH_BOOT__,瀏覽器端照單載入各外掛的前端模組。HTTP API 面孔則是一條鏈:api-remotes 做身份解析,api-gateway 做參數解碼和方法呼叫,connection 獨佔 /api 路由的 RPC 信封,最後落回同一個 webserver(docs/api-gateway.zh.md)。
Python SDK 最能說明「皮」有多薄。pip install deepseek-harness-sdk 會連帶裝一個平臺 wheel,裡面是單檔案可執行的 dsh-jsonrpc-agent;SDK 啟動它當子行程,透過 DSH_CORDIS_CONFIG 注入預設組合,然後在 stdio 上說 JSON-RPC(python/sdk/README.zh.md)。所以 Python SDK 和 JSON-RPC 入口是同一張面孔的兩種穿法,Python 這層只是把協議包成了 harness.run("…")。
大綱裡那道邊界條件題的答案就藏在配置註釋裡。JSON-RPC 示例的第 2 行寫著 stdout 保留給 JSON-RPC,禁止加 console logger 或終端 UI;ACP 示例同樣宣告整棵樹不掛 stdout 日誌和 HMR,因為 stdout 載著 ACP 的 JSON-RPC(兩份 cordis.yml 的開頭註釋)。道理一句話:這兩種協議把 stdout 當傳輸線,往上面混打一行日誌,對端的解析器就斷線了。日誌走 ctx.logger 另尋出路,這是協議入口的鐵律。
入口 = 一份 cordis.yml三個示例入口共享同一批核心外掛,差異是頂部那幾行協議橋。加一張新面孔約等於寫一個翻譯外掛加一份配置。
stdout 歸協議JSON-RPC 與 ACP 入口的配置明令禁掛 console logger:stdout 是傳輸線,混入一行日誌對端就解析斷線。
方法不標記就不存在Typert 只匯出 @Remote 標記的方法,未標記的既不進 Client 型別,也無法經 ctx.remote 呼叫。
翻開 examples/jsonrpc-agent/cordis.yml,第 1 行註釋說明這是給捆綁執行時用的無人值守部署,第 2 行就是那條鐵律的原文:stdout 保留給 JSON-RPC,不要加 console logger 或終端 UI。往下看,協議橋 sdk-jsonrpc-server 只是外掛列表裡普通的第一項,連它的配置都走環境變數注入。一個入口的全部家當就這麼多:一份配置,頂部幾行是它的臉,其餘全是和別的入口共用的核心。
出處:examples/jsonrpc-agent/cordis.yml 第 1 至 7 行,核對日期 2026-08-13。
五張面孔裡,Web 和 HTTP API 這兩張要跨行程呼叫 Host 裡的業務方法,這就需要一層 RPC。DSH 沒用現成框架,自研了 Typert。業務開發者要做的事少到只剩一個裝飾器:在服務方法上標 @Remote('create'),構建時 Typert 分析 TypeScript 型別圖,生成三樣東西:校驗參數的 Zod schema、描述呼叫的 descriptor、給瀏覽器端用的型別宣告。不需要手寫路由表、參數轉換、客戶端 stub,改一處方法簽名,重新構建後所有端的呼叫約定同步更新(docs/subsystems/typert.zh.md、packages/typert/generator/README.zh.md)。
為什麼現成方案不行?因為要跨 wire 的東西裡有 Cordis 特有的概念,通用 schema 生成器沒有詞彙描述它們。三個例子。第一,Host 和瀏覽器是兩個獨立的 TypeScript Program,同名的 Cordis Context 在兩邊的型別合併結果不一樣,一份 schema 餵兩邊行不通。第二,業務方法的參數可能是 Agent 這樣的活物件,它不能被序列化過 wire,Typert 用 lookup 機制把 agent 參數改寫成 wire 欄位 agentId,Gateway 收到請求後先把 id 解析回活物件再調方法。第三,客戶端的 ctx.remote.goals 是隨外掛掛載解除安裝的活服務,最後一個方法撤回,整個 namespace 跟著解除安裝,這是 OpenAPI 那種靜態描述表達不了的生命週期(Typert Gateway Agent Note 2026-08-02)。
還有一條值得記住的紀律:descriptor 是本地反射資訊,不上 wire。Host 和客戶端各自在構建時生成彼此對應的 descriptor,請求裡只發 endpoint 和具名參數;取消訊號作為帶外的 carrier signal 注入,絕不混進業務參數(docs/subsystems/typert.zh.md 的呼叫 descriptor 一節)。生成器還很固執:遇到表達不了的型別投影直接報錯,絕不把源型別展平弱化了矇混過去。這和前兩課的「拒絕解讀」一脈相承,說不清楚的事寧可不做。
Goal 服務的真實程式碼。一個裝飾器加一個薄適配,JSDoc 裡那句「從 wire identity 解析出的活 Agent」就是 lookup 機制在業務側的樣子:
/**
* Create one Goal through the remote boundary.
* @param agent - exact live Agent resolved from the wire identity.
* @param request - objective and optional round cap.
* @returns the created Goal identity.
*/
@Remote('create')
remoteExportCreate(agent: Agent, request: CreateGoalRequest): CreateGoalResult {
const view = this.create(agent, request)
return { ref: { id: view.id, revision: view.revision } }
}
deepseek-harness-master,核對檔案 packages/goal/goal/src/index.ts,核對日期 2026-08-13。程式碼塊保留原始碼原文。Grok Build
兩家在 ACP 這張面孔上正面相遇。Grok Build 有獨立的 Rust 實現 xai-acp-lib(crates/codegen/xai-acp-lib/):stdin 行讀取器、雙向通道、閘道收發器,同樣跑在 stdio 的 JSON-RPC 上,所以同樣得遵守「stdout 歸協議」的紀律。編輯器接編碼 agent,ACP 正在變成事實標準。
差別在面孔的數量和長法:Grok Build 以桌面端和 CLI 為主體,ACP 是給編輯器的介面;DSH 把五張面孔全部攤平成配置差異,核心對入口一無所知。站內 Grok 專題拆過它的整體架構,可對照著看。
Claude Code
路線相反:TUI-first。終端 CLI 是主體,headless 是同一個可執行檔案的 -p 模式,Agent SDK 再往外包一層,多張面孔從同一個 CLI 衍生。一個行程一個使用者介面,不需要 RPC 層,也就沒有 Typert 要解決的問題。
DSH 是 Web-first,發布時甚至沒有傳統的終端互動入口,發布討論裡最響的聲音就是「我的 CLI 呢」。這是入口取捨的產品決策:先把核心和面孔解耦的架構立住,缺哪張皮補哪張。兩條路線沒有對錯,成本結構不一樣:TUI-first 加 Web 面孔要補一整層 RPC;Web-first 加 CLI 面孔,理論上是一份新的 cordis.yml 加一個驅動器。
設計第六張面孔
假設要給 DSH 加一個聊天軟體機器人入口:使用者在群裡 @機器人 說話,回覆流回群裡。照著本課的路子列清單:哪些東西不用寫?(核心外掛、會話持久化、壓縮、工具,全部照抄現有 cordis.yml。)哪些東西必須寫?(一個協議橋外掛,把群訊息翻成會話 prompt、把會話事件翻成群回覆。)再想一個細節:這個橋有沒有 stdout 互斥問題?如果沒有,它的「傳輸線紀律」等價物是什麼?(提示:群訊息有速率限制和訊息長度限制,事件流得節流合併。)
cordis.yml 加一個協議翻譯外掛,同一個操作從哪張臉進來,會話日誌裡落下的事件一字不差。協議入口守「stdout 歸協議」的鐵律,日誌絕不混進傳輸線。跨 wire 呼叫交給自研的 Typert:@Remote 一個裝飾器,型別圖生成 schema、descriptor 和客戶端型別,活物件經 lookup 換成 wire id,改一處簽名全端同步。