
字节笔记本
2026年10月6日 · 约 8 分钟读完
拆解 Typert 类型契约:远程调用的两端如何对齐
DeepSeek 在 GitHub 上开源的 agent 框架 DeepSeek Harness 采用 MIT 协议,以 Cordis 容器与「一切皆插件」的思路组织运行时。当 Client 进程需要调用 Host 进程里的业务能力时,它没有选择裸 HTTP 接口,而是走一套名为 Typert 的类型化 RPC 体系。项目文档里专门有一页「Typert 远程调用」参考,记录 dsh-typert-protocol 与 dsh-api-gateway 两个包的公共类型约定:生成的 Remote 产物、Host Gateway 与消费方 API 装配三方共用这同一组类型。这一页更接近一份「字段合同」:对象怎么过网线、调用怎么描述、失败归谁负责,都先在类型层写死。本文把它逐段拆开。

一、Lookup 与上下文声明:宿主对象怎么过网线
复杂对象不能直接序列化进网络请求,Typert 用两个声明合并的空 map 解决这个问题。TypertLookupMap 把一种 Host 对象类型关联到它的 wire identity;TypertContextMap 把一种作用域上下文类别关联到 wire identity。生成的 descriptor 只引用这些 key,运行时提供方负责提供活对象的解析行为,类型层与运行层就此分开。
每条 lookup 声明由五个字段组成:key 是合并声明的键,parameter 是 SRC 弱解析器识别的源参数名,wire 是替换宿主参数的 wire 字段,hostTypeSymbol 与 wireTypeSymbol 分别是严格生成使用的宿主与 wire 规范类型符号。
这里有一个值得留意的失败设计:lookup 的 resolver 卸载之后,注册表仍然保留 wire 声明。SRC 发现过程会继续把该参数归类为 lookup,并因不可用而直接失败,而不是把 wire 值当作普通业务对象接受。宁可报错,也不做静默降级,这是整套协议反复出现的原则。
二、调用描述符:本地反射信息,不是 wire 消息
InvocationDescriptor 是整套契约的核心。文档特别强调,它是本地反射信息,不是 wire message:请求上线时只携带 endpoint 与具名 args,其余约束都留在两端各自的运行时里。
描述符的头部字段刻画了「调用谁」:id 是全局稳定的生成身份,service 是拥有方法的 Cordis 服务 key,namespace 是 wire 命名空间(默认取服务 key),method 是公开实例方法名,implementation 则在导出方法名只是别名时指向真正的服务成员。invocation 字段区分 direct 与 context 两种接收者选择模式,后者附带上下文类别、wire 字段与 codec;scope 字段允许某个 direct lookup 参数改由消费方 Context 的身份投影填充。
每个业务参数也有独立描述符:name、wire、source(json 或 lookup)、lookup key 与 codec。其中 acceptsUndefined 的语义最容易被忽略:只有显式声明为 T | undefined 的参数,wire 缺字段时才解码成 undefined,其余情况一律按校验失败处理。可选性因此成了契约的一部分,而不是解码器的猜测。
三、双档编解码与带外取消
TypertCodec 分两档。strict 档携带生成的 typeSymbol 与 schema,做结构化校验;src-json 档是开发期的保守回退,不恢复结构类型,只强制值能安全表示为 JSON。两档可以按参数粒度混用在同一个描述符里。
取消信号的处理同样克制:支持取消的方法把 AbortSignal 声明为最后一个 Host 参数,描述符里只记录 cancellation 元数据。传输层把 signal 在业务参数之后注入,绝不让它进入 args。wire 上因此永远只有业务字段,取消始终是 carrier 的职责。
四、注册表:四区分离与 effect 生命周期
ctx.typert 把当前环境的状态分成四区保存:local 存描述符,remotes 存显式选择的 Remote contribution,lookups 与 contexts 分别存两类提供方。lookup 提供方拥有稳定的 wire 声明与默认 resolver,Host 组合可以为同一个 key 配置 effect 作用域的同步或异步 resolver,配置卸载后自动恢复默认策略。每项注册都是 Cordis 持有的 effect,返回可等待的 disposer,生命周期跟随发起调用的 fiber。
生成的消费方声明则把 direct namespace 合并进 TypertClientRemote 继承的 map,新方法的类型会随着产物导入自动长出来。
五、Host Gateway:解码之后、业务之前
Connection 先解码 carrier envelope,再调用 ctx.typertGateway。InvokeRemoteRequest 只有四个字段:namespace、method、args 与可选 signal,args 的字段必须与描述符精确匹配。
失败被分成三档处理。基础设施与边界失败走网关的进程内错误分类体系,共十七个错误码,从 ambiguous-endpoint、arguments-invalid 一路列到 service-unavailable、signature-invalid;普通异常由 RPC 适配器归并为传输层的 internal 码,不附带详细信息;lookup 策略通过 TypertLookupFailure 携带的既有 RPC error 则原样返回,冷恢复失败之类的策略拒绝得以保住原始错误码。

六、消费方 Remote:只长出导入过的方法
ctx.remote 只暴露由已导入 /remote 产物贡献的 namespace。$mount() 把生成的 descriptor 与具体方法作为一项由 fiber 持有的操作统一注册;每个 namespace 都是可追踪的 remote.<namespace> Cordis 子服务,生命周期覆盖已挂载的方法。消费方里既没有 JavaScript Proxy,也没有 Host 业务服务类型,方法查找就是普通对象与函数。
事件转发是这条面的另一半:$on 订阅 Host 转发的事件,投递单向、按注册顺序进行,抛错的监听器会被隔离;$dispatch 属于持有帧 sink 的载体,把解码后的帧交进订阅表,无人订阅的事件名直接丢弃。
结语:先写字段合同,再谈传输
回看这一页参考文档,Typert 的设计取向很统一:哪些参数能过网线由 lookup 声明决定,缺省是否合法由 acceptsUndefined 决定,失败归谁由错误码分类决定,资源何时回收由 effect disposer 决定。对需要在多进程之间做类型安全 RPC 的项目来说,这种「先写字段合同、再谈传输」的思路,比先定协议再补类型的路径更值得参考。



