DeepSeek Harness 的 Typert:一套自己写的远程调用是为了解决什么
先把话说在前面:DeepSeek Harness 仓库根目录的 README.md 第 9 行起有一节标题就叫 Developer preview,正文里用加粗写着会有破坏兼容性的变更。本文提到的每一个字段名、endpoint 形状和默认行为都可能在下个 rc 里改掉,别把它当成稳定契约来抄。当前快照的版本号是 0.1.0-rc.5——我把 packages/typert 下四个 package.json 都翻了一遍,generator、loader、protocol、registry 写的都是这个版本。
从一个很土的问题开始
假设你在 Host 侧的某个 Cordis Service 上加了一个方法,现在想让浏览器端能调到它。老路子是什么,仓库里 .agents/notes/implemented/architecture/ 下那份 2026-08-02 的 Typert Gateway 架构 note 在 Problem 一节自述得很直白:Host API Proxy 同时扛着直接方法调用、带状态交互和 Session 事件流三件事,它们的生命周期、路由语义和客户端编程界面都不一样,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型互相咬死。这份 note 还写明,业务开发者应该只声明哪些方法可以远程调用,而不必再同步维护中央 API 接口、路由表、参数转换表、客户端 stub 和 Zod schema。
注意这里的措辞——这是文档自述的动机,不是我替作者推断的。Typert 这一套只覆盖「一次请求对应一次结果」的定向方法调用;Permission、Approval 这类带状态交互和 Session 事件流,note 里明说仍是独立设计,不在这个决策范围内。
第一站:装饰器只记了一件很小的事
业务侧的入口是 @Remote 和 @RemoteScope(key),加上继承 TypertRemoteService(super(ctx, serviceKey, options?)),或者对已经有别的基类的 Service 用 bindTypertRemote(this, serviceKey, options?)。这几个东西全在 packages/typert/protocol 里,那个包的 README.md 自述它不做 TypeScript 分析、也不注册具体 Cordis 服务。
值得注意的是装饰器没做什么。protocol 的 README 写明:装饰器初始化器把标记存在以 Service 原型为键的模块私有 WeakMap 里,不在 constructor 上加 symbol,不加原型属性、参数元数据或运行时反射字段。架构 note 里也重复了同一句。所以你去实例上翻是翻不出注册痕迹的——TypertRemoteService 暴露的那个 public readonly typertRemote 绑定,才是运行时能看见的部分。
装饰器参数是对外方法名,不是实现名。note 里给的例子是 @Remote('create') remoteExportCreate(...):对外叫 create,Host 上真正被调用的成员叫 remoteExportCreate。这个区分会一路带到 descriptor 里。
第二站:descriptor 里到底有什么
InvocationDescriptor 定义在 packages/typert/protocol/src/types.ts,我数了一遍,一共 11 个字段:id、service、namespace、method、implementation?、invocation、scope?、parameters、cancellation?、result、sourceLocation?。其中 method 是 endpoint 和消费端用的对外短名,implementation 是 Host receiver 上的真实成员名,两者相同时 implementation 可以省。namespace 的 JSDoc 写的是「defaulting to the service key」——默认取 Cordis service key,只有协议 namespace 确实要和 service key 不同时才另传。
每个参数用 InvocationParameterDescriptor 描述,6 个字段:name(源码里的参数名)、wire(wire args 对象里的必需 key)、source(只有 'json' | 'lookup' 两种)、lookup?、codec、acceptsUndefined?。最后那个字段的注释值得抄下来:只有显式声明成 T | undefined 的参数,缺失的 wire 字段才会被解码成 undefined。换句话说,别指望「我没传这个字段它会自己变成 undefined」。
最关键的一句在 docs/subsystems/typert.md 的 Invocation descriptors 一节(英文版从第 39 行起):InvocationDescriptor 是本地反射信息,不是 wire message。Host 和消费端各自从同一个 Typert 模型生成彼此对应的 descriptor,请求上只发 endpoint 和具名 args。架构 note 里给的当前物理映射是 POST /api/<namespace>/<method>,payload 是具名 JSON 对象而不是位置数组,形如 { "args": { "agentId": ..., "request": {...} } }。
第三站:Agent 是怎么被换成一个字符串的
Host 方法签名里可能带 agent: Agent 这种活对象。它显然不能上 wire。Typert 的做法是让业务对象所属的包同时提供两侧:静态侧用声明合并往 TypertLookupMap 里加一条(比如把 Agent 关联到 SessionId),运行时侧调 ctx.typert.lookups.register('agent', { parameter: 'agent', wire: 'agentId', resolve })。parameter 和 wire 这两个字段就是「源码参数名」到「wire 字段名」的改写规则。note 写明缺任一侧时,LIB 构建或最早能解析的运行时注册会直接失败。
这里有两条硬限制,反直觉但很实在:
第一,lookup 对象只能各自占一个顶层参数位。note 明写不支持 request.agent、对象解构、对象数组、嵌套 lookup,也不支持从任意复杂结构里搜 ID。普通 JSON request 可以作为另一个完整参数传,但你别想把 agent 塞进 request 里。
第二,也是我觉得整套设计里最该记住的一条:resolver 卸载后,wire 声明还留着。packages/typert/protocol/src/types.ts 里有个 TypertLookupDefinition,注释直接写的是「Stable wire declaration retained after a lookup provider unloads」,5 个字段(key、parameter、wire、hostTypeSymbol、wireTypeSymbol)。子系统文档解释了这个保留的后果:SRC 发现过程会继续把该参数归类为 lookup,并因不可用而失败,而不会把 wire 值当成普通业务对象接受。所以你卸掉一个 provider 之后看到的是调用失败,不是「那个 ID 字符串被当成业务参数传进去了」。
第四站:取消信号为什么不进 args
支持协作式取消的 Host 方法,把 signal: AbortSignal 声明成最后一个参数。descriptor 里对应的是 cancellation?: { parameter: 'signal' }——注意这是个字面量类型,源码里就写死成 'signal'。子系统文档的说法是:取消通过带外 carrier signal 表达,它在业务参数之后注入,绝不进入 args。
SRC 和 LIB 两种模式对这个参数的校验强度不一样。protocol 的 README 写明:SRC 认末位参数名,严格生成还会额外校验它是不是全局 AbortSignal 类型。架构 note 的「后果」一节把这件事的现实含义写清楚了:取消仍然是协作式的,没保留末位参数的方法收到 abort 也会继续跑;收到 signal 的方法必须自己把它传下去或者自己观测它。这不是「加了就能停」。
第五站:hasSeen——改代码时最容易被咬的一处
ctx.typert 拆成四个 registry:local、remotes、lookups、contexts(TypertRegistryContract 就这四个成员)。变更通知的 kind 是 5 种:local、remote、lookup、host-context、client-context。
TypertLocalRegistry 上有个不太起眼的方法 hasSeen(endpoint)。我去 packages/typert/registry/src/service.ts 翻了实现,就一行 return this.history.has(endpoint),读的是一份历史集合而不是当前条目表。它的语义在接口注释里写着:只要这个 endpoint 在本次 Typert Service 生命周期内注册过一次,即便后来被撤回,它也返回 true。
架构 note 把这条规则的效果说得更直接:strict endpoint 一旦出现过,即使随后撤回对应 descriptor,Gateway 仍会继续认领这个 endpoint 并报不可用,不会回退到 SRC 弱 descriptor;重新注册 strict descriptor 才恢复,只有重启 Typert 注册表才会忘掉这段历史。note 自己举的场景就是 HMR。如果你在开发中改着改着发现某个 endpoint 一直报不可用而 SRC 路径看着明明还在,先想想是不是撞在这里。
第六站:错误码在跨进程时会丢细节
Gateway 侧的 TypertGatewayErrorCode 我逐条数了 packages/api/gateway/src/types.ts,是 17 个:ambiguous-endpoint、arguments-invalid、binding-invalid、context-failed、context-not-found、context-unavailable、definition-unavailable、input-invalid、invocation-unavailable、lookup-failed、lookup-not-found、lookup-unavailable、method-unavailable、provider-mismatch、result-invalid、service-unavailable、signature-invalid。
排查时要留意的是:这 17 个码只在进程内保留。子系统文档和架构 note 都写明,普通异常会被 RPC 适配器归并成传输层的 internal 错误码,诊断信息只通过 message 跨 Connection 传递;唯一例外是 resolver 通过 TypertLookupFailure 携带的既有 RPC error,那类会原样返回(note 举的例子是 agent-busy 这个 subagent ownership fence)。所以消费端拿到 internal 不代表 Host 侧没有更细的分类,得回 Host 日志看。
四个包为什么这么切
packages/typert/README.md 里那张表把职责写得很干净:registry/ 存运行时包反射和 schema,占 ctx.typert;loader/ 发现 Loader 条目并注册生成的宿主产物,它自己不提供注册表,用别人的 ctx.loader 和 ctx.typert;generator/ 是构建时库。加上业务包唯一依赖的轻量 protocol/,一共四个。
loader 有两条限制值得单独记:它的 README 写明包解析结果和已导入的 manifest 会在整个进程生命周期内缓存,因此给一个包新增 ./typert 导出后必须重启进程;另外发现机制只导入宿主侧产物,嵌套在别的 Loader 条目下的插件得显式写进 packages 配置,否则不会被发现。
生成侧同样有会让构建直接红掉的地方。generator 的 README 写明:FaceModelEmitter 遇到不支持的 Zod 投影时生成失败,不会展平或弱化源类型;泛型 schema 声明、以条件类型或映射类型为 schema 根的构造都会失败。架构 note 那句更狠——复杂类型无法生成严格 codec 时 LIB 构建失败,不降级为 unknown 或无校验 JSON。
顺手记几个会绊人的边界
- 消费端类型来自生成的
lib声明,note 的「后果」一节明说系统没有 Remote contract 的增量 watch:Host Remote 签名改了以后要重新跑 lib build,再启动或重启 Web。 import type {} from '<业务包>/remote'只扩展静态类型,运行时会被擦除、不加载 JS、不触发任何注册。真要调用必须用普通 value import 拿到 contribution,再交给ctx.remote.$mount()。- 生成的 namespace interface 名是把 namespace 的 UTF-8 字节编成 hex,note 里给的例子是
goals稳定得到TypertRemoteNamespace$676f616c73。你在生成的.d.ts里看到一串 hex 别慌,那是这么来的。 - 安全边界要照实说:note 的「后果」一节写明 Remote endpoint 用的是 Connection 的
trusted-hostauthority,默认接受 loopback,LAN 调用方必须通过显式 trusted-host 配置接入,而且这一层不增加逐方法的调用方授权——每个 trusted host 都能调已挂载的 Remote endpoint。note 另有一句:Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。这些都得你自己在上层解决。 - WebSocket 迁移、TUI runtime 与 carrier、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等、跨版本协议兼容,note 明确列为不属于本决策的后续工作。文档里出现过这些词,不等于现在能用。
回到最初那个土问题:Host 上加一个方法要让浏览器调到,Typert 给出的路径是「加装饰器 + 声明 lookup + 跑 lib build + 在 assembly 里 $mount」,代价是你得接受生成产物的构建顺序约束和上面这一串边界。这套东西成立与否,取决于你的项目是不是真的被「一个方法要在五个地方同步声明」这件事拖住过。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。