DeepSeek Harness 架构解析

DeepSeek dsh 架构解析

上一篇拆解的 Codex 有一个唯一的 core 特权内核,ext 扩展挂载于其侧。dsh 恰好构成一组对照,它把一切皆插件推到了连主循环都可以被替换的程度^1^。这一差异不是风格差异,而是两种关于可替换性的取舍,本文的目的之一就是把这条取舍的代价与收益都摆出来。

架构分析

由于 dsh 的底座并非自研,而是 vendoring 进来的 Cordis 插件框架,其设计另有论文交代^2^。因此本文的结构与上一篇不同,必须先讲底座的几个概念,否则后面的每一处机制都无从解释。

dsh 的顶层结构与 Codex 有一处根本差别。Codex 的顶层可归纳为七个部分,其中一部分是 core 特权内核;而 dsh 的顶层是一份 package 分组清单,没有哪一组具有特权地位。代码仓库根目录的构成如下所示。

  1. vendor/ :vendoring 进来的九个 Cordis 相关包,附带记录上游 commit 的清单与同步流程。这些包被重新划入 @deepseek-ai 作用域,而 @deepseek-ai/cordis 是每一个 harness 包的 peerDependency^3^。
  2. packages/ :五十一个能力分组,共二百四十七个包,每个分组下是若干 @deepseek-ai/dsh- 前缀的 workspace 包。这一层是本文的主要拆解对象。
  3. native/@deepseek-ai/node-addon-landlock-run 的源码所在,即 Linux 上以 landlock 实施沙箱的原生扩展。
  4. python/:Python SDK 与运行时。
  5. examples/ :可直接运行的 cordis.yml 叶子配置,叠在 packages/examples 的 bundle 之上。
  6. docs/scripts/website/:架构文档与生成的目录、仓库门禁与生成器、以及文档站的 VitePress 投影。
  7. .agents/:Agent 工作流与 Agent Notes。这一目录的存在本身值得注意,仓库把 "供 Agent 阅读的工程纪律" 当作一等公民来维护。

packages/ 下的分组即 dsh 的功能清单。仓库自身的工程纪律文件列出了如下分组,每一组下再分若干包。

分组 职责
core/ 产品 API 主干,含 session、system-prompt、tools、agent、agent-loop
api/ Remote BFF 装配与 Typert RPC 网关
typert/ 类型图生成器、加载器与运行时注册表
llm/ LLM 能力,含服务定义、消费方与 DeepSeek 提供方
compaction/ 上下文压缩能力与基础提供方
shell/ bash 能力,含服务定义、local 与 pwsh 提供方、以及 shell 消费方
subprocess/ 子进程能力与本地进程树提供方
terminal/ 持久终端会话
fs/ 文件系统能力与策略
lsp/ 语言服务器能力
web/ web 能力,含搜索与抓取提供方、以及工具消费方
skill/ 技能提供方注册表、本地实现、目录与加载工具
subagent/ 子 Agent 能力,含服务定义、提供方与委派消费方
workflow/ 工作流能力、worker 线程提供方与工具消费方
e2b/ E2B 沙箱与 FS、子进程适配器
session/ 持久会话数据,含持久化、投影、标题与遥测
context/ 请求上下文插件
todo/ todo_write 工具
plan/ 以日志状态实现的 plan 模式
preset/ 由 preset 的 cordis.yml 装配的按会话 agent 组合
guard/ 循环卫生与工具超时插件
self-modification/ Agent 自行检视并挂载自己的插件
hooks/ Claude Code 与 Codex 的 hook 桥接及线路协议库
acp/ 仅供自动化使用的 ACP 服务端
interaction/ 审批与交互能力,含权限、命令、ask-user
settings/ 用户设置能力与文件提供方
credentials/ 凭据与授权能力,含 env 与 .env 提供方
identity/ 匿名身份
bundle/ 可安装的 dsh --profile patch 层组合包
boot/ 共享的 app-bin 粘合层
sdk/ JSON-RPC 协议、服务端与 TypeScript 客户端
examples/ 演示组合包,含 CLI、ACP、JSON-RPC 入口
experimental/ 排除在官方发行之外的私有原型
support/ 开发与测试基础设施
util/ 零依赖工具库

需要说明的是,这份清单并不完整。官方的能力目录里还出现了 session-query/storage/sandbox/jobs/spill/attachment/code-runtime/feedback/workspace/goal/host/extensions/runtime-diagnostics/test-support/ 等未被上表收录的分组^4^,把这些补齐之后才是前述的五十一个分组。

Cordis:底座的五个概念

dsh 的一切机制都建立在 Cordis 之上。代码仓库的 Cordis 入门文档把它归纳为五个概念,以下逐条展开,并在每条之后指出它在 dsh 里的落点^5^。

插件是实现 Service 的对象

一个 Cordis 插件可以是带有可选 injectapply(ctx) 字段的函数,也可以是一个 Service 子类,由 Cordis 将其生命周期挂载到当前上下文之中。这一点决定了 dsh 里 "产品的每一部分都是插件" 不是一句口号,模型适配器、工具注册表、会话日志乃至 agent loop 本身都以同一种形态存在,因此都可以从配置替换。

上下文是 Service 的仓库

一项 Service 从上下文中占据一个稳定的 ctx. 键,例如 ctx.toolsctx.llmctx.sessions,其他插件通过键找到服务,而不是 import 某个具体实现。这条规则是整套架构得以解耦的根本。放到自研 Agent 上,它的可参考之处在于,模块之间的引用一旦从 import 具体实现改为按键查找,替换实现就不再需要改动任何调用方的代码。

依赖用 inject 声明,加载顺序由此推导

一个插件把自己所需的 Service 写进 inject,Cordis 便让它等待这些服务出现之后再启动。于是加载顺序是由服务需求表达出来的,而不是由一段手写的启动序列排定。这一条解决的是 harness 里一类相当常见的顽疾,即:启动顺序的隐式依赖,任何一处新增初始化都可能打乱既有次序。

通信用类型化事件,分派模式是契约的一部分

Service 通过 TypeScript 的声明合并来声明事件名,再按监听器是观察、包裹、扇出还是顺序执行,选择四种分派方式之一。

分派模式 是否 await 分派顺序 有返回值
emit 散发 按注册顺序观察
waterfall 瀑布 按注册顺序观察
parallel 并行 所有监听器并行观察
serial 串行 按注册顺序观察

值得注意的是分派模式被明确定义为事件公开契约的一部分。新增的 harness 事件要用 @mode 标签把它写进文档,生成的目录会据此校验声明与实际分派点是否一致。这是一处很小但很见功力的设计,它把 "这个事件能不能否决" 这种口头知识固化成了可被机器检查的声明。

插件注册是可逆的

提示词片段、工具 schema、适配器、提供方与监听器,一律通过 ctx.effect()ctx.on() 安装,从而在重载与拆除时可以按预期回退。代码把这一条写成了硬性要求,即每一处贡献都要走 ctx.effect()ctx.on(),而一个注册表的 register() 必须返回 disposer。

放到自研 Agent 上,这五条里最值得直接照搬的是第五条。多数项目的插件机制只解决了装载,没解决卸载,于是热重载与运行时禁用一项能力这两件事都做不到。把注册统一收进一个返回 disposer 的原语,成本不高,收益是整个扩展体系从此可以双向操作。

插件契约:一个插件的形状

上一节讲的是概念,这一节交代代码长什么样。这里有一个值得先说的结论,即 dsh 的插件就是原生的 Cordis 插件,没有自己的基类、装饰器或注册宏。

契约只有五个字段

插件的契约定义在 vendored 的注册表源码里,其基础接口只有五个可选字段。

ts 复制代码
export interface Base<T = any> {
  /** 用于 fiber 诊断与 logger 名的显示名 */
  name?: string
  /** 插件启动前用于校验 config 的 standard-schema 校验器 */
  Config?: StandardSchemaV1<any, T>
  /** 插件所需的服务,仅当全部可用时才加载 */
  inject?: Inject
  /** 插件提供的服务名 */
  provide?: string | string[]
  /** 插件声明自己消费哪些服务的 intercept 配置 */
  intercept?: Dict<boolean>
}

需要提醒一处版本差异,即这里没有 reusable 字段。那是 Cordis 3 的写法,在 Cordis 4 的插件契约里已经不存在,参照旧资料写插件会在这一点上出错。

插件本身有三种形态,即函数、构造器,以及带 apply 方法的对象。注册表的判定逻辑简单到可以一句话说清,即它是函数,或者它的 apply 是函数。

依赖声明的类型是一个联合,即字符串数组或对象。数组形式只表达 "我需要这些服务",对象形式则可以为每个服务附带 intercept 配置。运行时行为有一条值得记住,即所需服务发生变化时,该回调会被卸载并重新执行,而不是继续持有一个已经失效的引用。此外 ctx.inject(deps, callback) 只是 ctx.plugin({ inject, apply: callback }) 的简写。

服务注册有两条路径。其一是继承 Service 基类并在构造器里调用 super(ctx, name),此时服务立即注册,并随拥有它的 fiber 自动移除;其二是底层的 ctx.provide(name, value),它返回一个反注册的 disposer,且在同一 scope 内重复 provide 会直接抛错。

一个最小插件与一处条件注入

下面这个插件来自仓库,它的职责只有一件事,即声明该 agent 的模型看到哪一种形态的工具集。之所以选它做例子,是因为它足够短,而且包含一处很讲究的处理。

ts 复制代码
export const name = 'tool-presentation'

/** 所需服务。注意 codeRuntime 不在这里。 */
export const inject = ['tools']

export interface Config {
  mode: ToolPresentationMode
}

export const Config: z<Config> = z.object({
  mode: z.union(['native', 'ptc', 'both'] as const).required(),
})

export function apply(ctx: Context, config: Config): void {
  if (config.mode === 'native') {
    ctx.tools.presentAs('native')
    return
  }
  // 这个等待本身就是那次响亮的失败
  ctx.inject(['codeRuntime'], (runtimeCtx: Context) => {
    runtimeCtx.tools.presentAs(config.mode)
  })
}

讲究的地方在于 codeRuntime 没有写进顶层的 inject,而是在 apply 内部按条件注入。原因是 native 这一档不需要代码运行时,把它写进顶层 inject 会导致一个只用 native 的部署因为缺少运行时而挂不上这一行;而另外两档确实需要运行时,此时那个内部的等待就成了一次可被观察的失败,它会以 "这一行始终未激活" 的形式出现在 preset 的激活审计里,而不是在第一次对话时才报错。

这一处细节值得单独提炼,因为它回答的是一个相当普遍的设计问题,即依赖是按配置变化的时候,依赖声明该写在哪一层。写在顶层就把最严的那一档强加给了所有档位,写在内部则需要一个机制让未满足的等待变得可见。dsh 选了后者,并为此提供了激活审计。

另有一处工程细节值得记录。这个插件只有具名导出,没有默认导出,而这不是风格选择,是一份复盘的结论,即 Loader 对默认导出的解包会丢掉插件的 Config schema。仓库把这条结论落成了编号复盘文档,而不是一句口头约定。

配置也是插件契约的一部分

上面的例子里 Config 出现了两次,一次是 TypeScript 的接口,一次是运行时的 schema,二者同名。这个双重声明是 Cordis 的约定,即类型给编译期,schema 给加载期,插件启动之前它的配置先过一遍校验。

配套的还有一条工程纪律,它把这件事推到了更强的位置,即插件里不允许出现硬编码的可调参数。凡是随部署而变的选择都必须是经过校验的 Config 字段,而且可以从 cordis.yml 改写;一个名为 DEFAULT_* 的常量或者一个测试钩子不算可配置性。协议常量、外部规范与安全不变量则相反,它们必须固定。

这条纪律划的那条线值得学。它区分的不是 "这个值会不会变",而是 "这个值该由谁决定"。部署者该决定的,做成配置字段;协议与安全该固定的,写成常量。两者混在一起的典型后果是安全参数被暴露成可配置项,或者部署差异被写死在代码里。

能力接缝:dsh 的组织单位

如果说 Cordis 提供的是机制,那么能力接缝提供的是 dsh 组织自身的单位。理解了这一节,才能理解为什么一个不设特权内核的系统不会散成一盘沙。

一个接缝由三种角色构成

官方文档对接缝的定义相当严格。一个 seam(接缝)是一项可替换能力,包含三种角色,即:声明接口的 Service Definition、实现它的 Service Provider,以及使用它的 Consumer,而后者通常是面向模型的工具。

一个包可以合并承担多个角色,但单一角色本身不是接缝,添加一项能力意味着把三者一并设计。仓库的工程纪律把这句话写成了硬性条款,即接缝是完整的,从不是单一角色,只有当三种角色开始各自独立演进时才拆分。

这条定义的价值在于它排除了一种常见的伪扩展点,即只声明一个接口、却没有第二个实现、也没有明确的消费方。这类接口在代码里看着像扩展点,实际上无法替换,因为没有人验证过第二种实现能不能真的装进去。

接缝清单

官方生成的能力目录列出了每个 ctx 键的角色、所属包、已知实现与直接消费方。以下是其中最能说明系统结构的部分。

ctx 角色 已知实现
ctx.llm seam llm-deepseekllm-pi-aillm-replay
ctx.tools core 无实现,本身即注册表与带把关的执行流水线
ctx.systemPrompt core 无实现,收集提示词片段与工具 schema
ctx.sessions core 无实现,持有只追加的会话与事件流
ctx.sessionPersistence seam session-persistence-jsonlsession-persistence-sqlite
ctx.sessionQuery seam session-query-sqlite
ctx.fs seam fs-localfs-sandboxfs-e2b
ctx.subprocess seam subprocess-localsubprocess-e2b
ctx.shell seam bash-localbash-sandboxpwsh-local
ctx.terminals seam terminal-bash
ctx.sandbox seam sandbox-local
ctx.approval seam acp
ctx.subagents seam 六个提供方,含 subagent-codexsubagent-claude-code
ctx.web seam web-search-exaweb-search-perplexityweb-search-deepseekweb-fetch-http
ctx.compaction seam compaction-basic
ctx.codeRuntime seam code-runtime-worker
ctx.lsp seam lsp-local
ctx.skills seam skill-badgeskill-filesystem
ctx.storage seam storage-jsonstorage-sqlite
ctx.jobs seam jobs-local
ctx.spillStore seam spill-local
ctx.agentLoop bundle 唯一的具体循环插件

这张表里有三行需要单独辨析。

第一行是 ctx.agentLoop。它的角色被标为 bundle,而目录里的注解写明,它是唯一的具体循环插件,扩展包依赖的是 dsh-agent 的事件与服务,而不是这个包本身。这一句才是 "连主循环都是插件" 的确切含义,即主循环之所以可替换,不是因为它被写得特别灵活,而是因为没有任何扩展依赖它。

第二行是 ctx.approval。审批被实现为一次性的权限决策,经 approval/request 瀑布式事件分派,应答方是监听器,而在没有任何应答方时,该接缝默认失败为 unavailable。这是一处默认安全的设计,即缺少审批能力时的行为是拒绝,而不是放行。

第三行是 ctx.fsctx.subprocess 这一对。官方文档用它们解释接缝的收益,文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 与 LSP 一并搬了过去,无需为每个消费方单独 fork。仓库里 e2b/ 分组下的 fs-e2bsubprocess-e2b 正是这条路径的实现。

这份目录是生成的

值得注意的是这份能力目录的维护方式。文档末尾自陈其维护模式为混合式,服务从 Cordis 的声明中发现,而接口、实现与消费方三种角色的分类写在 scripts/gen-doc-graphs.ts 里,并带一个完备性守卫。

据此可以推断,dsh 对架构文档采取的是与代码同源的策略,即文档中的结构性事实由代码生成,人工只维护判断性内容。这一条对自研 Agent 同样成立,且成本比想象的低,即:把 "有哪些扩展点、各自被谁实现、被谁使用" 这三个问题的答案交给生成器,人工只写为什么。

Agent 循环与轮次流程

这一节是 dsh 与 Codex 差异最大的地方。Codex 把粒度切成线程、轮次与步三层,并用两级冻结保证一步之内的配置不变;dsh 的粒度只有轮次与步骤两层,但它在这两层之间插入了大量可拦截的扩展点。

轮次与步骤的定义

官方文档给出的定义相当精确:

  • 一个步骤是一次模型请求加上它调用的工具;
  • 一个轮次包含零个或多个步骤,它在领取首条输入之前打开,并在不再欠下任何工作时关闭。

值得注意的是 "零个步骤" 这一情形被明确允许。后文会看到,这不是边界条件,而是一条被设计出来的路径。

完整的轮次流程如下所示。

三个事件域

dsh 把事件分成三个域,而选对事件域被官方称为大多数改动的第一个决定。

  1. 会话事件 :追加到日志并通过 session/event 广播的持久事实。当某个事实必须在重新加载之后仍然存在时,使用它。上面流程里的 turn/*step/*user/messageassistant/*tool/* 都属于这一类。

  2. Agent 事件 :以 agent/ 为前缀,携带活跃的 Agent 句柄,覆盖 inbox、步骤、状态、请求、验证与续跑。要观察或拦截进行中的工作时,使用它。

  3. 能力事件 :以能力名为前缀,例如 fs/*tools/*telemetry/*,作用是无需 import 循环即可向某个接缝附加策略与适配器。

这个划分解决的是一个具体问题,即扩展作者面对一个需求时,第一个要回答的问题不是 "写在哪个文件里",而是 "这件事需不需要在重新加载之后仍然存在"。答案是需要,就必须落成会话事件;答案是不需要,就走 Agent 事件或能力事件。把这个判断前置到事件域的选择上,比事后发现状态丢失再补持久化要便宜得多。

瀑布式事件与短路语义

上述事件里有一批是瀑布式的,即 agent/pre-stepagent/requestllm/stream 与三个 tools/* 事件,其监听器必须调用 next() 才能委托下去;而 agent/turn-stopping 是 serial 事件,没有 next()

瀑布式事件的语义值得单独交代,它是环绕式中间件。监听器收到 (...args, next),调用 next() 把可能已被包裹的结果委托给下一个服务,不调用 next() 而直接返回则短路整条链,值通过 next() 的返回值传播。协作型监听器通常修改一个共享的请求或决策对象之后再委托;而对于单一决策的事件,短路正是设计意图,即一个持有该决策的策略监听器可以不调用 next() 直接返回,而只做标注或观察的监听器必须委托。

仓库的工程纪律为此单列了一条硬性要求,即瀑布式监听器必须调用 next(),返回而不调用它就会短路整条链。据此可以推断,这条纪律之所以要写成硬性条款,是因为漏调 next() 造成的故障形态相当隐蔽,即链路后半段的策略与适配器全部静默失效,而没有任何报错。

agent/pre-step 决定模型看到什么

在所有扩展点里,agent/pre-step 的地位最特殊。官方文档的表述是它决定模型看到什么,监听器可以改写已领取的消息,也可以直接拒绝它们;而首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。

这条设计里真正值得学的是最后半句。一次被拒绝的请求没有产生任何模型调用,按朴素的实现完全可以什么都不记;而 dsh 选择仍然写一个空轮次进日志。这个选择的收益在排查时才显现,即用户提出过一次请求、系统决定不予执行,这件事本身是可观测的,而不是一段没有痕迹的沉默。

输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它,而注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。这一条区分了两种输入的语义,即主动请求与被动补充,前者推动循环前进,后者只在下一次前进时被捎带上。

工具执行流水线:十个环节与一条单调性

工具执行是 dsh 里被打磨得最细的一处,也是本文认为最值得完整搬走的一章。它由六个 tools/* 事件与若干注册式环节组成,全链路如下表。

环节 名称 分派模式 这一环能做什么
0 tool/call 会话事件 执行之前先落盘,UI 同时渲染一张待执行的卡片
1 tools/pre-execute waterfall 返回允许、拒绝或要求审批。可以否决,不能改写参数
2 ctx.approval waterfall 一次性权限决策,只有明确的单次授予才继续
3 ctx.tools.guard() 注册式,逐个求值 单调性守卫,只能拒绝,不能放行
4 tools/execute waterfall 环绕式包裹真正的执行,超时、重试与度量在这里
5 工具体 直接调用 真正干活,可延迟追加上下文,可结束本轮
6 tools/post-execute waterfall 改写结果内容或结果值,或者判定为失败
7 归一化 注册表内部 流水线与结果快照抛出的错误统一变成失败结果
8 finalizeContent() 同步、恰好一次 内容定稿,失败路径也会经过这里
9 tools/result emit 观测已冻结的权威结果,不能改写
10 tool/result 会话事件 落盘,随后按先进先出注入附加上下文

为什么前置策略不能改写参数

第一环的这条限制有一句明确的理由,即参数已经被记录并呈现,因此排除了输入改写。

这句话必须结合上一章那条不变量来读才知道它的分量。工具调用在进入策略环节之前已经作为 tool/call 事件落进日志,并已经在界面上渲染成一张卡片。此时若允许某个监听器悄悄改写参数,日志与界面记录的就不是真正执行的那一次,那条 "模型可见即已记录" 的不变量随即被破坏。

于是这里出现了一个很有意思的因果,即一条关于持久化的纪律,反过来决定了策略层的能力边界。想改参数的需求并没有被否定,只是它必须发生在落盘之前,也就是由生成这次调用的那一侧负责,而不是由策略层偷偷完成。

放到自研 Agent 上,这一条给出的判断是,先确定哪些事实是已记录的,再据此划定各拦截点的权限。顺序倒过来的话,就会得到一个能力很强、但会让日志失真的钩子体系。

单调性守卫:把不可推翻编码进返回类型

第三环是本章最值得抄的一处设计。守卫的签名是这样的。

ts 复制代码
type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined

返回一个字符串即拒绝,字符串就是拒绝理由;返回 undefined 表示不改变既有决定。注意它没有 "放行" 这个返回值。

这个签名带来的性质是拒绝不可被推翻。由于没有任何返回值能表达放行,后注册的守卫无法把前一个守卫的拒绝翻回允许,于是守卫的注册顺序不影响最终结果。这就是单调性的含义,即决策只能朝更严的方向移动。

对照常见的实现方式就能看出这个设计的价值。常见做法是让每个钩子返回一个布尔值或者一个允许与拒绝的枚举,于是顺序立刻变得重要,而顺序又取决于插件加载次序,最终得到一个 "谁最后说话谁算" 的权限系统。dsh 的做法是让类型系统从一开始就不允许表达那个危险的返回值。

守卫的位置也经过考虑,它在全部 tools/pre-execute 监听器之后、工具体之前求值,此时要求审批那一档已经解析完毕。作用域上,用普通上下文注册的守卫全局生效,而通过某个 agent 的上下文注册的只对该 agent 生效。

审批缺席即拒绝

第二环的审批是一次性的权限决策,经由一个瀑布式事件分派,应答方是监听器。这里有一条默认值需要单独记住,即在没有任何应答方时,该接缝失败为不可用,而不可用被当作拒绝。

这是默认安全的标准形态。一个只装了基础组合包、没有装任何交互前端的部署,其行为是所有需要审批的操作全部被拒绝,而不是全部放行。

中间三环各自能改什么

第四环 tools/execute 是环绕式的,它包裹真正的执行,超时、重试与度量都写在这里。它还有一项独占能力,即它是唯一可以替换取消信号的一环,而注册表在调用工具体之前会把调用方原本的信号重新合并回去。这个安排让超时这类需求可以由中间件实现,同时不夺走调用方的取消能力。调用身份则始终不可变。

第六环 tools/post-execute 可以改写结果,但选择是互斥的,即要么替换面向模型的内容,要么替换结果值并让系统重新校验并重算内容,两者不能同时替换。它也可以直接判定为失败并给出反馈。抛出异常的工具同样会以失败的形式抵达这一环,因此这一环是策略层看到全部结果的地方。

第八环 finalizeContent() 是内容的唯一收口。它是同步的、恰好执行一次、只能改内容、必须对所有输入都有定义、且不得抛出异常。最关键的一点是那些绕过了 tools/post-execute 的失败路径也会走到这里,因此它是唯一一个能保证 "任何结果都被同一段代码定稿" 的位置。

第九环 tools/result 只能观察。结果在这一环已经深度冻结,监听器的失败被隔离,不影响主流程。

这三环的分工可以概括成一句话,即改写发生在有明确契约的两个位置,观察发生在结果冻结之后,而两者之间有一个不可跳过的定稿点。

附加上下文的注入时机

工具执行的结果之外,tools/post-execute 还能带回附加上下文。它们的注入时机被规定得很具体,即在已经记录的工具结果之后,按先进先出的顺序作为用户消息注入。

这个规定解决的是一个实际问题。工具执行过程中系统可能想对模型补充说明,例如提示输出已被截断、或者某个文件已被外部修改。这类补充如果混进工具结果本身,模型会误以为那是工具的输出;如果延后到下一轮,又可能太晚。把它作为紧随其后的一条用户消息,语义上最接近实际情况,即这是系统在工具结果之后追加的说明。

两个刻意的例外

六个事件里有两个的处理与其余四个不同,而两处都是有意为之。

其一是 Code Mode 的调度日志。模型所写的程序每完成一次子调用,都会在写入日志之前经过一个专门的瀑布式事件,而这一环的监听器只能改写日志副本。程序本身已经拿到完整的返回值,模型两者都看不到。溢出策略正是这一环的典型使用者,它把过大的内容换成一份预览加一个定位符。

其二是工具注册表的变更通知。它在工具注册、反注册或作用域限制变化时发出,而文档明确说明它是不做作用域过滤的注册表主体通知,这一点是故意的。

前一个例外的意义在于它把 "程序看到的" 与 "日志记录的" 分开,从而让一次批量子调用不至于把上下文冲爆;后一个例外的意义在于它承认有些通知天生是全局的,强行按作用域过滤反而会让订阅者收不到本该收到的变更。

作用域过滤是生成的

六个事件里有五个是按作用域过滤分派的,其路由主体统一是载荷的第一个参数上的 agent 字段。值得注意的是这份对应关系写在一个生成的文件里,该文件列出了全部二十六个按作用域过滤的事件。

这又是一次同样的选择,即把 "哪些事件按什么键过滤" 这类结构性事实交给生成器,而不是靠人工维护一张表。到这里可以把 dsh 在文档与元数据上的策略总结成一句话,即凡是能从代码推导出来的,一律生成;凡是需要判断的,才由人写。

会话日志:事件溯源与一条运行时不变量

Codex 的会话持久化是只追加日志加可重建索引,而 dsh 走得更远一步,它把日志确立为模型所见上下文的唯一来源,并用一条运行时不变量把这件事钉死。

模型可见即已记录

官方文档的原句是,抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点;因此新增一项模型可见输入就需要新增一个会话事件,即扩展 SessionEventMap 并从日志渲染。仓库的工程纪律把这条写成了双向蕴含,即模型可见与已记录二者等价。

这条规则的力量在于它把一类难查的故障从可能变成了不可能。那类故障的形态是这样的,某个提示词片段在运行时被注入模型,但没有落进日志,于是回放同一个会话得到的历史与当初真正发送的历史不一致,而这种不一致在正常路径上完全不可见,只在恢复、分叉或复盘时才暴露出来。把它约束成一条被断言的不变量,等于让这类偏差在开发期就崩掉,而不是在事故复盘时才被发现。

放到自研 Agent 上,这一条的迁移成本比看起来低。它不要求一次性做出完整的事件溯源体系,只要求把 "拼装模型请求" 这一步的输入全部来自日志投影,而不是来自内存里的临时变量。

表面:只有三种事件能进入模型历史

日志里有五十余种事件,而能进入模型历史的只有三种。这个收窄由一个叫做表面(surface)的机制完成,其类型定义相当克制。

ts 复制代码
export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result'
export type SurfaceOp = 'append' | { op: 'replace'; start: number; end: number }
export interface SurfaceIntent {
  surfaceOp: SurfaceOp
  sourceEventSeqs?: number[]
}

即事件要么追加到表面末尾,要么替换表面上一段连续区间。替换不删除任何东西,被替换的事件仍然留在日志里,只是不再被投影为模型消息,因此这个动作的准确说法是遮蔽而非删除。

配套的校验函数把这套语义收得很紧。assertProvenance 要求 sourceEventSeqs 只能引用更早的 seq、不得重复、且必须覆盖所有被遮蔽的节点;assertToolResultRewrite 进一步要求 tool/result 的替换只能遮蔽一个当前节点,且只允许改写 content。

据此可以推断,这一层校验的目的是让遮蔽这一动作可审计。一次替换必须交代它遮蔽了谁、依据是什么,于是任何一次历史改写都能被反查,而不是留下一段来历不明的新内容。

值得注意的是有两个事件类型被刻意排除在表面之外,即 tool/code-dispatchtool/code-dispatch-start。它们是纯日志事件,deriveMessages() 完全跳过,因此 Code Mode 里由模型所写程序发起的子调用结果永远不会重新进入模型上下文。这是一处有意的设计,即让模型写的那段程序自己消化中间结果,只把最终结论交回上下文。

deriveMessages 与两份保真度

会话日志承担两项职责,而它们的精度要求并不相同。deriveMessages() 从日志中投影出模型历史,而原始的 assistant/chunk 事件则用于保证回放与 UI 的保真。

据此可以推断,dsh 在这里做了一个有意的区分。模型历史需要的是规整后的消息,流式过程中的分块对模型毫无意义;而 UI 与回放需要的恰恰是分块的原始序列,因为用户看到的是逐字出现的过程。把两者都从同一条事件流派生,而不是各存一份,避免了两份数据不一致这一常见问题。fork、恢复、文本记录、遥测与持久化都派生自这同一条事件流。

投影的实现有两处细节值得记录。其一,deriveMessages() 带缓存,它记住已投影的节点数与一个 replaceGeneration 世代号,一次调用的开销只与新增节点数成正比;而一旦发生表面替换,世代号递增,整份投影重建。其二,逐事件的投影规则是一个纯函数 deriveEventMessage(),其分支相当短:user/message 逐字返回,assistant/message 在 content 为空时返回 null,tool/result 返回其消息,其余一律返回 null。

第一个分支的注释交代了一条纪律,即投影阶段不得为用户消息补任何框架,例如包一层标签之类,框架属于生产者的责任。这条纪律的意义在于它保证了投影是可逆推的,日志里存的就是模型看到的,中间没有一层隐形的加工。

第二个分支解释了一种反直觉的记录,即 content 为空的助手消息为什么存在。它的存在只为承载达到 max-tokens 的那一步的 usage 统计,本身不进入模型历史。

落盘:一个会话一个文件,逻辑 JSONL 与物理 zstd 分帧

JSONL 后端的落盘路径形如下面这样,一个会话一个文件。

text 复制代码
$DSH_HOME/sessions/--<项目路径 slug>--/<编码后的 sessionId>/session.jsonl.zstd

$DSH_HOME 的解析优先级是显式配置高于环境变量 DSH_HOME,再高于默认的 ~/.dsh,而空串或纯空白的环境变量视为未设置。

这里有一处值得单独记录的决定,即 JSONL 后端的 root 配置项是必填且没有默认值。源码注释交代了理由,它不默认取 process.cwd(),因为 Agent 执行的 bash 与子进程会改变工作目录,一旦以 cwd 作默认值,会话文件就会被撒到各处。这是一个很小的选择,但它体现的判断值得照搬,即对一个会执行任意命令的程序而言,进程的当前目录不是一个可信的默认值。

路径的两段各有专门的编码函数。项目段由 projectKey() 生成,形如 --<slug>--,其中路径分隔符与冒号折成单个连字符,不在 [A-Za-z0-9._-] 之内的码元转为 ~XXXX 形式,slug 截断到 251 字符;工作目录缺失时改用 _no-cwd。会话段由 encodeSegment() 编码,把 . 转为 ~002E,用途是防止目录穿越与命名冲突。

文件的内部结构是逻辑 JSONL 加物理 zstd 分帧,默认压缩为 zstd,逻辑文件名恒为 session.jsonl,后缀只标记物理编码。第一行是 header 记录,其余每行是一个事件。帧的组织有三条约束,即第一帧必须恰好只含 header 那一行、每个持久化批次压成一个独立可解码且带 checksum 的帧追加到文件尾、解码时每 500 毫秒让出一次事件循环。

分帧带来的性质是可以只读第一帧。会话列表的实现正是如此,parseHeaderMeta() 只读第一行,因此会话选择器的开销随会话数增长,而不随对话大小增长。这一条与前面提到的冷读阶梯是同一个思路的两次应用,即让常用的读路径不必触碰全量数据。

存储层还做了两处针对性压缩。其一,连续的 assistant/chunk 增量被打包成 text-chunksreasoning-chunkstool-call-chunks 三类存储行,源码注释记录的实测效果是约小 60%,而读路径无条件解包,因此这个开关不改变逻辑布局。其二,provenance 里连续的 seq 序列被编码成区间,三个以上连续的序号折成一对起止值。

一个事件类型不认识就拒绝整个日志

会话事件的版本机制值得单独一节,因为它处理的是一个所有持久化格式最终都要面对的问题,即旧版本程序读到新版本写的数据该怎么办。

dsh 的规则有三层。第一层,SessionEventMap 的成员默认是 required-on-read,即不知道某个事件类型的构建会拒绝该日志;第二层,除非该事件在信封里带了 ignorable: true,此时它可以被安全跳过;第三层,只有结构性的格式变更才会提升 SESSION_FORMAT_VERSION。而该常量当前的取值是 0,仓库同时声明它不提供任何兼容性承诺,SQLite 侧则使用单调递增的 SCHEMA_VERSION,后端直接拒绝旧的磁盘格式。

这套设计的取向很清楚,即默认从严,逐事件放宽。它与常见的做法正好相反,常见做法是默认忽略不认识的字段以求向前兼容,代价是旧版本程序可能在缺失关键事实的情况下继续运行,并给出看起来正常的错误结果。dsh 选择让它直接拒绝,再由事件的作者显式声明哪一类事件是可以忽略的。

值得注意的是这三层规则里最容易被忽略的是第二层。ignorable: true 写在信封上而不是写在读取方的白名单里,意味着一个事件是否可忽略由它的定义者决定,且这个决定随数据一起流动,而不依赖读取方的版本知识。

落到实现上,拒绝分成两道闸门,各管一件事。

  • 格式版本闸门refuseForeignFormatVersion() 比较 header 里的 version 与本 build 的 SESSION_FORMAT_VERSION,不相等即抛 SessionFormatUnsupportedError
  • 事件词汇表闸门KNOWN_SESSION_EVENT_TYPES 是本 build 认识的全部事件类型,由 scripts/gen-persistence-catalog.ts 生成,读路径遇到集合之外的类型直接拒绝重建。

两道闸门的分工决定了一件很实际的事,即新增一种普通事件类型不需要提升格式版本,由第二道闸门兜住;只有结构性的格式变更才动版本号。而是否需要提升版本,取决于写方发出什么,而不取决于新的读方能接受什么。

真正精巧的是第一道闸门的位置。版本比较发生在 header 的结构校验之前,也发生在任何事件行解码之前。理由是未来格式的 header 形状可能完全不同,若先做结构校验,用户会看到一条 "日志损坏" 的报错,而正确的提示应当是 "请升级 harness"。为此这两类错误被明确区分开,格式不支持与内容损坏不是同一件事。

这一处顺序上的讲究值得单列出来说,因为它属于那种不写出来就一定会被写错的细节。校验顺序的常见直觉是先便宜后昂贵、先结构后语义,而这里的正确顺序恰好相反,先读那个决定 "我该不该继续解析" 的字段。

崩溃之后的修复

只追加日志加分帧压缩会带来一个具体问题,即进程崩溃时最后一帧可能是撕裂的。dsh 对此有一套明确的恢复流程。

扫描器在增量扫描时返回一个 committedBytes 偏移,它标出的是可以安全追加或截断的位置。修复时先按该偏移截断撕裂的尾巴,再把从尾帧中还能解出的完整事件回填,最后追加一批 closer 事件,把中断时打开的轮次与工具调用补齐,其中工具的结局用两个专门的错误码表达,即未开始与结局未知,轮次则以 turn/end 收尾并注明原因是被中断。

这套处理的取向与 Codex 那一篇里的 revert 一致,即宁可留下一段明确标注为异常结束的历史,也不留下一段看起来正常但实际残缺的历史。区别在于 dsh 连 "这个工具当时到底有没有开始执行" 都用两个不同的错误码分开表达,而这个区分对复盘是有用的,未开始意味着没有副作用,结局未知意味着可能有。

持久化是接缝,查询与投影各有归属

只追加日志有一处固有短板,即查询困难。Codex 的解法是在旁边挂一个 SQLite 索引,并把索引明示为可从日志重建的派生物。dsh 面对同一个问题,给出的是一套分工更细的方案,共四层。

  • ctx.sessionPersistence :持久化接缝。两个后端 session-persistence-jsonlsession-persistence-sqlite 持久化的是同一套 SessionEvent 词汇表,而应用在装配时选择其中之一。
  • ctx.sessionQuery:查询接缝。接口提供精确读取、过滤与追踪,而其 SQLite 后端在此之上补充全文对齐、排序、片段摘录与游标世代。
  • ctx.sessionProjections:投影单元。各领域注册由状态驱动的 fold 单元,由一个 eager 驱动维护每个会话的水位线状态。
  • ctx.sessionProjectionCache :投影缓存。它按会话持久地为投影单元状态打检查点,检查点分为限流触发与强制触发两类,后者发生在 turn/end 与 detach 这两个时机。

第四层的收益值得单独说明。该缓存服务的是一条被官方称为冷读阶梯的路径,即一次冷读由缓存行加上持久化尾部的重放共同完成,因此列表页永远不需要加载完整日志。

这一条与 Codex 的做法构成一组很有意思的对照。两者都承认只追加日志不适合查询,但补救的位置不同。Codex 在日志之外另建一个可重建的索引库,索引的内容是会话级的元数据;dsh 则把补救放在投影这一层,缓存的是 fold 的中间状态,再靠重放日志尾部补齐到最新。前者的优点是索引结构可以为查询自由设计,后者的优点是缓存与投影逻辑同源,不存在索引语义与投影语义漂移的可能。

会话标题这件小事也照同一套规则处理。ctx.sessionTitle 拥有一个确定性的兜底、一个取最新值的 fold,以及唯一一个可选的异步提供方注册位,而实际的两个提供方 session-title-first-prompt-llmsession-title-all-prompts-llm 分别按首条提示词与全部提示词生成标题。一个自动生成标题的功能被拆成兜底、fold 与提供方三段,这个粒度乍看过细,但它带来的性质是标题永远有值,且换一种生成策略不影响其余两段。

上下文压缩:阈值、真前缀与收敛门

压缩这一节单独成章,因为 dsh 在这里的实现比常见做法细得多,且有三处判断值得直接迁移。

两个触发时机

压缩只有两个触发时机,对应一个只有两个取值的类型。

  • 压力触发 :在 agent/pre-step 上检查,条件是估算的总 token 数达到 contextWindow × thresholdRatio,默认比例为 0.8
  • 溢出触发 :在 agent/request-error 上检查,条件是错误码为上下文窗口超限。这条路径绕过阈值与尾部保留策略,压缩成功后直接让请求重试。

两个默认比例的关系被写成了加载期的校验。尾部逐字保留的比例默认为 0.16,而 retainRatio 大于或等于 thresholdRatio 会在插件加载时直接报错,同时 retainRatio 与绝对值 retainTokens 互斥。这是前面提到的那条纪律的一次具体应用,即配置错误应当在加载期就大声失败,而不是等到运行时才表现为一次莫名其妙的压缩失败。

压缩请求是上一次请求的真前缀

这是全篇我认为最值得单独抄走的一处设计。

压缩需要调一次模型来生成摘要,而这次调用的请求形状不是随手拼的。它的 system 段与 tools 段直接复用会话自己的那一份,messages 段是被遮蔽区间派生出的消息,然后在末尾追加一条承载压缩指令的用户消息。源码注释交代了这样做的理由,即如此一来这次辅助调用是最后一次路由请求的真前缀,于是服务端的 KV cache 得以复用,而不是失效。

这一条的价值在于它把一个纯粹的性能问题转化成了一个请求构造问题。常见做法是为摘要单独准备一套精简的 system 提示词,直觉上更省 token;而实际结果是这次调用与主线请求没有公共前缀,缓存全部失效,总开销反而更高。判断依据从 "这次请求本身有多大" 换成 "这次请求与上一次共享多长的前缀",结论就反过来了。

摘要的产出格式也被约束得很死,它要求模型输出八个固定的 Markdown 段落,空段落必须写明为空,并且要求把上一轮的摘要块合并进来,而不是逐字向前搬运。最后一条约束防的是摘要在多轮压缩之后累积膨胀。

收敛门与稳定性检查

压缩之后还有两道检查。

其一是收敛门,即成帧后的摘要 token 数若大于或等于被遮蔽内容的 token 数,直接报错,理由写在错误信息里,摘要不比它遮蔽的内容更小。这道门看似多余,实则必要,因为一段本就简短的历史被摘要之后完全可能变长,而这种情况下压缩不仅无益,还会让后续的压力判断陷入循环。

其二是稳定性检查。自动压缩要求整个表面在压缩期间未发生变化,手动压缩只要求所选区间稳定,不满足则抛出表面已变更的错误。这一区分对应两种不同的使用场景,自动压缩发生在循环内部,此时表面本不应变动;手动压缩由用户在会话进行中触发,要求整个表面不变过于苛刻。

区间的选取也有一处细节,它从尾部累加节点的 token 数直到满足保留量,然后往前退到一个不会劈开工具调用与工具结果配对的边界。这一条属于那种不做就会出错、做了却没人会注意的处理。

所有阈值都建立在一个估算之上

这一节需要一处诚实的交代。上述全部阈值判断都不基于真实分词,而是基于一个固定密度的估算,即四个字符约等于一个 token,每个内容块加四个 token 的结构开销,每条消息再加四个 token 的角色开销。源码注释说明这是在需要精确分词之前的权宜之计。

据此可以推断,dsh 当前在上下文管理上的精度是有意放低的。这个选择在工程上说得过去,因为阈值本身就是一个带余量的经验值,估算误差被余量吸收;但它也意味着接近窗口上限时的行为不完全可预测。对自研 Agent 而言,这里的可迁移判断是,token 计量应当被封装成一项服务而不是散落的工具函数,因为它迟早要从估算换成真实分词,而换的时候不应该牵动任何调用方。

Profile 与组合包:启动时的分层装配

前面反复说 dsh 的每一部分都可以从配置替换,而这一节交代 "从配置替换" 具体是怎么发生的。

一棵插件树,由若干层叠加而成

运行中的 dsh 是一棵插件树,由启动时按序叠加的各层组合而成。参与叠加的有两种单位。

  • 组合包(bundle):Cordis 配置项及其挂载代码的分发格式。它插入的内容始终可以被位于其上的各层 patch。
  • Profile :存放在 Harness home 中的具名组装。它列出自己要叠放哪些组合包,存放自己安装的树外插件,并保存用户自己的 cordis.patch.yml

两者都在各自的 package.json 里通过一个 dsh 字段声明自己,即 dsh.profile 列出一个 profile 的组合包,而 dsh.bundle 指向一个组合包的 patch 文件。随发行版交付的 profile 模板有两个,即 webheadless

组合包的分工也很清楚。dsh-base 是每个 profile 的第一层,内含模型适配器、工具、持久化、沙箱与审批策略、设置、凭据与遥测;dsh-web-app 在此之上增加浏览器应用;dsh-headless 增加一次性运行器,且完全不带服务器。

叠加顺序与 patch 的粒度

各层按固定顺序应用在一个空的条目列表之上,即先按 profile 列出的顺序应用每个组合包,然后是 profile 自己的 cordis.patch.yml,然后是 Harness home 级别的那一份,最后是命令行上任意的 --patch overlay。

一条 patch 的粒度是这样定义的,它按 id 定位某个条目并替换其整个 config,或者插入一个新条目。下面是 dsh-base 里的一行,它同时说明了条目的形状与配置的写法。

yaml 复制代码
- id: session-persistence-jsonl
  name: '@deepseek-ai/dsh-session-persistence-jsonl'
  config:
    root: !!js dshHomePath('sessions')

!!js 是 Cordis 的 Include 插件提供的表达式节点,它只允许出现在插件的 config 与条目的 disabled 两个位置,其余元数据保持字面量。两处的求值时机不同,config 在该插件声明的注入激活之后、以该插件的上下文为环境求值,而 disabled 在每一次挂载决策时、以 loader 的上下文为环境求值。

这个区分不是细枝末节。它意味着一个条目可以根据环境决定自己是否挂载,而这个判断在每次挂载决策时重新做;而条目的配置只在它真正要启动时才求值,此时它依赖的服务已经就位。

要查看某台机器上实际生效的配置树,官方给的办法是打印出来。

sh 复制代码
dsh --profile web --dump-config

而官方文档紧接着补了一句,它打印出的任何条目,都可以由你自己的 patch 替换。这句话是整套机制的意义所在,即可替换性不是靠预留扩展点实现的,而是靠让全部装配都以同一种可寻址的条目形式存在。

放到自研 Agent 上,这一节的可迁移之处有两点,且都不要求引入 Cordis。第一点是把启动装配表达为一份有序的条目列表,每个条目有稳定 id、有插件名、有配置,而不是写成一段调用序列。第二点是提供一个把最终生效装配打印出来的命令。第二点的成本极低而收益极高,因为多层配置叠加最常见的故障就是使用者不知道最终生效的是哪一层。

Host 与 Client:被类型系统逼出来的边界

dsh 有一处 Codex 完全没有对应物的设计,即 Host 与 Client 两个平面的分离。它值得单独一章,不是因为分层本身新鲜,而是因为这条边界的成因很少见。

一个 program 不能同时看见两侧

仓库根目录有三个 tsconfig,其中 tsconfig.host.jsontsconfig.client.json 是两个独立的类型检查单位,而 tsconfig.json 只是一个把两者串起来的 solution 文件,其 files 为空数组,因此它自身不构成任何 program。

前一个文件的注释交代了理由,即两侧在同一批键上合并 cordis 的 Context,因此一个 program 不能同时看见两者。solution 文件的注释把这条约束写成了禁令,即空的 files 让它保持无 program 状态,从而 host 与 client 的 Context 合并永不相遇,并要求永远不要向该文件添加 include 或 files 条目、永远不要把这个 solution 摊平成单个 program。

这条成因值得停下来辨析。绝大多数项目里前后端分离的理由是代码组织、构建产物或部署形态,而这里的理由是类型层面的冲突。浏览器侧与宿主侧都用声明合并往 ctx 上挂服务,且用的是同一批键名,于是同一个 TypeScript program 里会出现两份互相矛盾的类型定义。解决办法不是改键名,而是让两侧根本不在同一个 program 里被看见。

据此可以推断一件更一般的事情,即当一个系统把扩展机制建立在类型层的声明合并之上时,声明合并的作用域就成了架构边界,这一点在设计之初通常想不到。

测试文件的归属靠后缀区分,即 packages/client 之下,*.client.* 属于 Client 单位,*.host.spec.ts 属于 Host 单位,两个后缀互斥。

分面包与共享的线路类型叶子

如果两侧不能相遇,那么必须回答一个问题,即两侧之间传递的数据类型定义在哪里。dsh 的答案是分面包。

一个分面包带三份 tsconfig,即一份无 program 的 solution、一份 host 面、一份 client 面。关键在于两面共享的线路类型叶子文件被同时编进两个 program,例如 src/types.tssrc/remote-events.ts。这是整套设计能够成立的地方,即共享的只是不带任何服务注册的纯类型模块,而带服务注册的实现文件各归各面。

分面是稀有的例外而非常态,这一点从数量上看得很清楚。整个仓库只有六个包分面,即 api/gatewayapi/remotesapi/session-controllerapi/workspace-controllersession-query/session-log-exportexperimental/inspectorpackages/host/ 下的七个包无一分面,packages/client/ 下的四十二个包只有 connection 一个分面。

顺带一提命名约定,它也被这条边界改写了。默认规则是包名为 @deepseek-ai/dsh- 加目录名,分组目录名不进包名;而 host 与 client 两组是例外,它们的包名要带上分组前缀,例如 @deepseek-ai/dsh-host-webserver@deepseek-ai/dsh-client-connection。目录层级的形状被一个门禁脚本强制为恰好两级,即 packages/<分组>/<包>

边界不靠 lint 规则,靠图检查与构建期门禁

这一节是本章最值得迁移的部分。仓库里并不存在一条 "client 不得 import host" 的 lint 规则,两个平面的隔离由两道机器检查保证,而它们的形式都不是语法层面的禁令。

第一道是 Project Reference 的面隔离。一个脚本从两个 aggregate 各自出发做广度优先遍历,为遍历到的每一份配置算出它属于哪一面,规则是单配置的项目是中性的,可以参与任一张图;而一旦某个包同时声明了两份 face 配置,那么从某个 aggregate 出发可达的每一条引用都必须指名与该 aggregate 匹配的那一片叶子。违规时报错信息会直接给出应当改成什么,例如从一个 Client 配置进入分面项目时,提示引用其 tsconfig.client.json。这个检查挂在 workspace 约束门禁里,进入了多条 CI 流水线。

第二道是客户端 bundle 的纯度门,实现为构建期的打包插件。任何不在白名单内的 @deepseek-ai/* 运行时 import 直接抛错,而白名单只有三类,即少数几个内联安全的线路层包、两个 vendored 的通用库、以及形如包名加 /remote 的生成契约。错误信息把正确做法一并写了出来,即跨插件的值 import 是被禁止的,应当声明一个非默认的模块请求,或者通过 cordis 服务协作,并特别注明纯类型的 import 会被擦除,因此永远不会触发这道门。

配套的散文规则写在客户端目录自己的工程纪律里,即一个特性插件不得运行时 import 或再导出另一个特性插件的值,也不得靠声明外部依赖的方式绕道取得;共享声明使用 import type,行为经注入的 Cordis 服务跨包,而 UI 经 slot 跨包。

这三者合起来构成一个完整的答案。规则用散文写清楚,语义用类型系统表达(纯类型 import 天然被擦除),而违规由构建期的门禁拦下。值得注意的是这里没有用 lint 规则,理由据此可以推断,即 lint 检查的是语法形态,而这条边界要判断的是 "这个 import 在运行时是否真的产生依赖",后者是打包器才知道的事实。

两个平面还有一个唯一合法的会合点,即一份由脚本生成的 slot 目录。它的自我说明写得很清楚,作为两个平面唯一的会合处,它只携带字符串,从不携带 client 侧的 import。于是宿主侧的工具能够认识浏览器侧的 slot 表面,而不必 import 任何客户端模块。

两个协议载体都在 /api 之下

两个平面之间的通信有两个载体,且归属不同的包。

一元调用 走 HTTP POST,归 client/connection。路由前缀有单一来源,即一个导出为 API_PATH 的常量,取值为 /api。封套是一对判别式联合。

ts 复制代码
export interface ClientRequest {
  readonly type: 'client-request'
  readonly rpcId: RpcId
  readonly method: string
  readonly payload: unknown
}
export interface ServerResponse {
  readonly type: 'server-response'
  readonly rpcId: RpcId
  readonly result: ConnectionRpcResult<unknown>
}
export type ConnectionRpcResult<T> =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: ConnectionRpcFailure }

错误码是一个封闭集合,共十个取值,包括 bad-requestcancelledsession-not-foundagent-busyinternal 以及若干与 agent preset 相关的取值。封套的校验用 zod,而宿主侧的拒绝是分档的,即非 POST 或路径不可解析返回 404,content-type 非 JSON 返回 415,body 非 JSON 返回 400,封套 schema 不通过返回 bad-request,方法名与端点不一致同样是 bad-request,handler 抛错返回 500。请求体上限为 300 MB,超限返回 413 并关闭连接。

/api 这个前缀被设为保留,特性插件不能直接接管它,只能通过拦截或注册具体路由的方式进入。这是一处很小的收口,作用是让通道的所有者唯一。

流式调用 走 WebSocket 多路复用,而它归 api/gateway 而不是归 connection。路径常量为 /api/remote.mux,服务端用 noServer 模式的 WebSocket 服务器接管升级请求。帧是两组判别式联合,客户端侧为 opencancel,服务端侧为 itemerrorend。它不协商任何 WebSocket 子协议,宿主的 web 服务器只是在升级事件上按精确路径派发,而路径字面量由 gateway 提供。

重连策略的默认值也在代码里写明,即退避基数 500 毫秒、倍数 2、上限 10 秒,另有一个 3 秒的就绪超时。

顺带记录一处结构上的洁癖。承载这一切的 web 服务器包只用裸的 node:http,不引入任何 HTTP 框架,依赖只有三个,且 WebSocket 库并不在其中。它本身不注册任何路由字面量,只提供精确表、最长前缀与兜底三档匹配,路由由各传输插件自己注册。这种设计让 "哪个包拥有哪条路径" 这个问题永远有确定答案。

没有协议版本号

这里需要一处诚实的交代。跨七个相关文件核对之后,可以确认这套协议不存在任何版本常量,也不做版本协商,封套只靠两个 type 字面量判别。附近唯一的版本常量属于鉴权 cookie 的内部格式,与协议本身无关。

据此可以推断这是预发布阶段的有意省略,且与仓库自陈的立场一致。该立场写在工程纪律的开头,即在没有外部消费者的阶段,优先做出正确的地基而不是兼容层,可以自由重命名与重新分包,条件是把每一处引用一并更新。

这条立场本身是可迁移的判断,但它有一个前提,即必须清楚自己何时会失去这个自由。仓库对此的处理是把该节标注为在第一个正式发布时删除。把 "这条规则何时失效" 写在规则旁边,比事后争论要省力得多。

一处附带的观察:生成的文档不漂移,手写的会

本章的材料来自源码与配置注释,而在核对过程中出现了三处仓库内部的不一致,即一条注释引用了一个已经不存在的文件、一处对某包依赖状况的描述与其 package.json 不符、以及一份复盘散文里残留了一个已被移除的插件提法。

这三处都不影响功能,但它们与前面那节形成了一组对照。dsh 的能力目录、事件目录与配置目录都是生成的,因此不会漂移;而这三处漂移全部发生在手写的散文与注释里。

这条观察的可迁移之处很直接,即凡是结构性事实,都应当由生成器从代码产出;而人工只写判断与理由,因为判断不会因为代码改动而失效,事实会。

子 Agent:同一个接口之后可以是另一个产品

这一章很短,但它是 "一切皆插件" 推到底之后才可能出现的形态,因此不能省略。

子 Agent 在 dsh 里是一个接缝,其提供方有六个。

提供方 形态
subagent-spawn-in-process 在同一进程内新建一个子 agent
subagent-fork-in-process 在同一进程内从当前会话分叉
subagent-acp 经 ACP 协议委派给进程外的实现
subagent-codex 委派给 Codex
subagent-claude-code 委派给 Claude Code
subagent-dsh-sdk 经 dsh 自己的 SDK 委派

官方文档对这份清单的概括是,同一个接口之后的实现千差万别,从新建一个子 agent,到把一个轮次委派给另一个产品。

值得注意的是后三个提供方与前两个的性质完全不同。进程外的实现通过 ctx.subprocess 启动,也就是说 Codex 与 Claude Code 在 dsh 里的地位与本地 bash 执行器是同一类,都是某个接缝下的一个提供方。这件事本身可以作为接缝设计有效性的一次验证,即接口抽象得足够干净的话,它下面装的是本进程的一个函数,还是另一家公司的一个 CLI,对消费方来说没有区别。

消费方一侧分成三个包,即选择一次性还是可续的委派、投递后续消息、以及一个固定的循环式工作流。这个拆分对应三种不同的使用意图,而它们共用同一个提供方集合。

小结:值得迁移的设计决策

通读整个仓库之后,本文认为最值得迁移到自研 Agent 的是以下七处,按优先级排列。

  1. 注册即可逆副作用。每一处贡献都通过一个统一的原语安装,注册表的注册方法返回 disposer。这是整套插件体系可以双向操作的前提,而多数项目的插件机制只解决了装载,没解决卸载。
  2. 模型可见即已记录。凡抵达模型请求的内容都必须能从会话日志重建,并由一条运行时不变量断言。这条纪律还会反过来约束别处的设计,例如前置策略之所以不能改写工具参数,正是因为参数已经落盘。
  3. 能力接缝必须三角色齐备。声明接口、实现它、使用它,三者缺一就不算接缝。这条判据排除了只有一个实现、没有明确消费方的伪扩展点。
  4. 把不可推翻编码进返回类型。工具守卫只能返回拒绝理由或者不表态,没有任何返回值可以表达放行,因此拒绝不可被后来的守卫翻回,注册顺序也就不再影响结果。
  5. 结构性事实一律生成。能力目录、事件目录、模块依赖图与作用域过滤表都由脚本从代码产出,人工只写判断。本文核对出的三处文档漂移全部发生在手写散文里,而生成的目录一处未漂。
  6. 装配表达为可寻址的有序条目,并提供打印命令 。每个条目有稳定 id、插件名与配置,patch 按 id 定位并替换整个配置,而 --dump-config 让最终生效的装配随时可见。
  7. 辅助模型调用要做成主线请求的真前缀。压缩用的那次调用复用会话自己的 system 与 tools,只在末尾追加指令,从而命中服务端的 KV cache。判断依据不是这次请求本身有多大,而是它与上一次共享多长的前缀。

不设特权内核的代价

开篇说过要把这条取舍的两面都摆出来,收益前面已经写足,代价有四处。

第一是概念负担。读懂 dsh 的任何一处机制之前,必须先掌握 Cordis 的五个概念,否则连一个插件的形状都看不明白。Codex 的读者可以直接从 core 目录开始读,dsh 的读者不能。

第二是边界会出现在意想不到的位置。Host 与 Client 之所以必须切成两个类型检查单位,是因为两侧在同一批 ctx 键上做声明合并,而这个约束来自类型系统而非业务需求。把扩展机制建立在类型层的声明合并之上,就要接受声明合并的作用域成为架构边界。

第三是装配的可见性依赖工具。装配由多层叠加而成,任何一层都可以 patch 任何条目,因此 "最终生效的到底是什么" 这个问题在没有 --dump-config 的情况下无法回答。这个成本 Codex 那种内核加扩展的形态是没有的。

第四是兼容承诺更难给。没有稳定的语义中心,意味着可替换的边界特别多,而每一条边界都是潜在的兼容负担。dsh 当前的处理方式是直接声明不提供兼容承诺,并把这条立场标注为在第一个正式发布时删除。这是诚实的做法,但它也说明代价是真实存在的,只是暂时被开发者预览阶段的身份豁免了。

两家的取舍因此可以并排放在一起看。Codex 保留一个特权内核以换取演进速度,代价是内核本身不可替换;dsh 取消特权内核以换取彻底的可替换性,代价是概念负担、意外的边界、对工具的依赖,以及更难给出的兼容承诺。它们不是优劣关系,而是同一道题的两个解,选哪一个取决于你打算把哪一部分长期钉死。

参考引用

github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... github.com/deepseek-ai... arxiv.org/abs/2608.25...

Footnotes

  1. github.com/deepseek-ai...

  2. arxiv.org/abs/2608.25...

  3. github.com/deepseek-ai...

  4. github.com/deepseek-ai...

  5. github.com/deepseek-ai...

相关推荐
云物互联1 小时前
Claude Agent SDK 架构解析
人工智能
2601_962056231 小时前
EasyMarkets:“网络安全需求持续升温”
数据库·人工智能
AI工具测评家1 小时前
降AI工具背后的技术原理:语义重构、风格迁移与轻量化打散,哪种才能稳过知网2026检测?
人工智能·ai写作·降重·ai检测·查重·降ai
用户5274675614211 小时前
别把“不让 Agent 读文件”当成权限系统
人工智能
huashengzsj1 小时前
智汇前沿 洞见未来 | 2026人工智能前沿学术会议将于10月在安徽合肥举办
人工智能·百度
xiaoduo AI1 小时前
抖音小店客服机器人哪个好?选择AI客服主要看哪些功能?
大数据·人工智能·机器人
2501_927283581 小时前
数据采集,正在让工厂从“凭经验”走向“看数据”
运维·人工智能·自动化·数据采集·agv·立体仓库
小柯南敲键盘1 小时前
跨马翻译:图片翻译软件批量处理,跨境电商视频字幕AI智能抠图
人工智能·python·音视频
EasyCVR1 小时前
空地联动!RTMP/GB28181/RTSP/ONVIF视频汇聚平台EasyCVR地质灾害视频监测+无人机巡检救援救援方案
人工智能·easycvr·国标gb28181