多入口與 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,改一處簽名全端同步。