多入口与 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,改一处签名全端同步。