夜雨聆风学习资料网

ARTICLE · 1177169

DeepSeek-Harness 源码深读(25):写一个新后端,只要三个钩子

DeepSeek-Harness 源码深读(25):写一个新后端,只要三个钩子

摘要:settings、credentials、sandbox、llm,翻遍 packages/ 全是同一种目录形状:一个抽象包加若干实现包。以给 settings 写远端后端为主线案例,拆这个贯穿 234 个包的模式:基类攥着机制,子类只剩三个抽象钩子;declare module 把类型长进 ctx;227 个 invariant companion 守事件契约;peerDep 保证全树只有一份 seam。最后对账什么时候该拆、什么时候是目录税。

这个系列里,同一种目录形状已经撞见三次了。《不封装任何 SDK,llm 插件凭什么接住所有模型调用》里所有供应商挤进 LlmAdapter 这条窄缝,《子代理后端只要两个方法,其余全归框架》里后端只交两个方法,《工具管线只有一条,调度器却写了两个》里桥内调度器靠注释逐条锚定原生语义。它们背后是同一个模式:一个能力拆成两个包,抽象包定义契约,实现包挂接派生。这篇把这个模式正面拆开:它长什么样,覆盖多广,以及为什么非要这么拆。

先把数字摆出来。全仓库 234 个 package.json,其中导出抽象基类的 22 个;配对关系像 settings 配 settings-file、credentials 配 credentials-local、session-persistence 配 jsonl 和 sqlite 两个实现、sandbox 配 sandbox-local。同一个形状复制了二十多遍。为什么?

我们拿一个思想实验当主线:给 settings 写一个远端后端。配置不放本地 YAML,放远端配置中心,团队共享一份默认值。这个需求合理,写起来却先要弄清楚一件事:哪些代码归你写,哪些归框架。

一、拷 settings-file 为什么拷不动

直觉的路子是把 settings-file 复制一份改改。真去拷就知道拷不动,因为 settings-file 里几乎没有可拷的东西。

抽象基类在这头(settings/settings/src/index.ts:350):

export abstract class SettingsProvider extends Service {

实现类在另一头(settings-file/src/index.ts:105),我们先看它往类里塞了什么:

export class FileSettingsProvider extends SettingsProvider {  static Config: z<Config> = z.object({    path: z.string(),    dshHome: z.string(),    watch: z.boolean().default(true),    debounceMs: z.number().min(0).default(100),  })  // …  /** The local document is always writable through {@link SettingsProvider.update}. */  get writable(): boolean {    return true  }

这段是 settings-file 的开类。注意看 Config:文件路径、要不要 watch、防抖毫秒数,全是文件形态才有的参数。写队列、命名空间注册、schema 校验、变更检测,一样都不在这里,它们长在基类身上:写串行化是基类的 writeQueues(settings/src/index.ts:355),服务启动时先加载后发布是基类的 Service.init(:376)。settings-file 只回答「文档在磁盘哪里、怎么读、怎么写」。

基类留给子类的全部欠条,就三个抽象成员(settings/src/index.ts:389、416、423):

abstract readonly writable: boolean// …protected abstract load(): Promise<Record<string, unknown>>// …protected abstract persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void>

这段是抽象钩子的全部。注意看 persist 的签名:它收到的是已经解析好的 section,不是原始文本。序列化、原子写、合并、防抖,全在进这个函数之前就被基类处理完了。所以写 settings-remote,你的全部工作量是三个钩子加一份 Config,远端协议的拉取和推送写在 load 和 persist 里,其余白拿。

抽象方法更多的版本也有。credentials 的 CredentialProvider(credentials/src/index.ts:177)留给子类九个抽象方法(:190-263):

abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>// …abstract describe(ref: CredentialRef): Promise<CredentialInfo>// …abstract set(ref: CredentialRef, value: string): Promise<void>

这段是九个方法里的三个。注意看 resolve 的返回:找到给值,找不到给 undefined,缓存策略、来源优先级这些机制全在基类。《页面上存的 key,永远压不过 shell 里的那个》讲过的优先级裁决就长在 seam 里,所以换一个凭据后端不会改变谁赢谁输。

好处现在能说清了:换后端不换语义。可新问题跟着来,三个钩子写完,消费者代码里的 ctx.settings 类型是从哪来的?settings-remote 都还没装上,类型怎么就先在了?

二、类型是长进 ctx 的,不是 import 进来的

答案在 seam 包的一段模块合并里(settings/src/index.ts:131-135):

declare module '@deepseek-ai/cordis' {  interface Context {    settings: SettingsProvider  }}

这段是服务声明。注意看它没有运行时代码,纯粹是 TypeScript 的 declaration merging:往 cordis 的 Context 接口上合并一个 settings 属性。seam 包一被加载,全仓库写 ctx.settings 都有类型。写错名字?直接红线。

这个声明带来一个装配关系:provider 与消费者零类型依赖。tool-fs 面向 ctx.fs 编程,不知道 fs-local 存在;agent-loop inject llm,不知道 llm-deepseek 存在。类型词汇表只有一份,握在 seam 手里。llm 那层还有个双层细节值得看一眼:LlmAdapter(llm/src/index.ts:180)是个纯抽象类,连 Service 都不继承,适配器只是接口;真正注册成服务的是 LlmRuntime(:284)。s04 讲过的「所有供应商挤进窄缝」,缝就是这条纯抽象类。

类型有了。可基类还对外发事件,settings/updated 只在解析值真正变化时才发出,这条契约写在哪、谁在守?

三、227 个 invariant.ts:事件契约的守夜人

守夜的是一组伴生文件。全仓库有 227 个 src/invariant.ts(排除 tests),settings 自己那份的核心是四条 fail(settings/settings/src/invariant.ts:28-36):

const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {  ctx.on('settings/updated', (ns, next, prev) => {    const settings = ctx.get('settings')    if (settings === undefined) {      fail(`settings/updated for 「${ns}」 emitted without a live settings service`)    }    const current = settings.get(ns)    if (current === undefined) {      fail(`settings/updated for 「${ns}」 emitted while the namespace is unregistered`)    }

这段是不变式安装器的前两条。注意看判定用的相等谓词是 seam 自己的 deepEqualJson:事件只为已注册的 namespace 发、载荷等于服务的权威解析值、值确实变了,三条全对才放行,违一条就 fail。你的 settings-remote 继承了基类的发射逻辑,这份契约检查同时罩住你。

伴生文件的标准三件套:插件名、inject 到 invariants 注册表、apply 注册安装器。加载权不在包自己手里:注册表(runtime-diagnostics/invariants/src/index.ts:94)带 enabled、package_allowlist、package_blocklist 三个配置(:17-21),部署方装了 dsh-invariants 服务、逐包加载子导出,检查才生效。模块头注释把意图写死了:普通包入口与诊断解耦。

227 个里大多数是空占位。compaction-basic 的 invariant.ts 全文 30 行,只注册不检查。但 agent-team 那份是实打实的(experimental/agent-team/src/invariant.ts:17-31):拦截 internal/dispatch 上的 session/event,对每个 team 事件先 foldTeam 再 applyTeamEvent,违规在 append 前拒绝。空占位的价值在于挂点统一:哪天 compaction 要加检查,改它自己的 invariant.ts 就够,入口文件一行不动。

四、web 的变体:注册表式

到这为止的模式有个隐含前提:一个进程挂一个实现。可 web 偏要搜索和抓取两个维度、各自多个供应商并存,还能按配置现场挑。继承式装不下,seam 换了个长法(web/web/src/index.ts:74):

export class WebRuntime extends Service {

具体类,没有 abstract。实现方不继承,注册(web/src/index.ts:114):

  registerFetchProvider(provider: WebFetchProvider): () => void {    return this.registerProvider(this.fetchProviders, provider)  }  private registerProvider<P extends { readonly id: string }>(store: Map<string, P>, provider: P): () => void {    if (store.has(provider.id)) {      throw new WebError(`a web provider with id 「${provider.id}」 is already registered`, 'WEB_DUPLICATE_PROVIDER')    }

这段是注册入口。注意看重复注册的结局:抛 WEB_DUPLICATE_PROVIDER,不静默覆盖。挑选规则写在类头注释里共六条(:61-72),全在执行期判定:配了 id 且可用就用它;配了 id 没注册报 CONFIGURED_MISSING;没配 id、恰一个可用就用它;多个可用报 AMBIGUOUS。web-fetch-http 与 web-search-deepseek 各自注册进来,谁也不挤占谁的位置。

什么时候用哪种?判据是共享机制的重量。settings 的写队列、watcher 生命周期值得基类托管,用继承式;web 的实现之间没有共享的重量级机制,多实现并存倒是硬需求,seam 就退化成注册中心。subagent 的 ctx.subagents 注册表同款。

五、peerDep:装配关系写在 package.json 里

我们还剩最后一个问题:settings-remote 的 package.json 该怎么声明对 seam 的依赖。仓库里所有 provider 的写法一致(settings-file/package.json:34-40):

「peerDependencies」: {  「@deepseek-ai/dsh-atomic-write」: 「workspace:^」,  「@deepseek-ai/dsh-invariants」: 「workspace:^」,  「@deepseek-ai/dsh-home-paths」: 「workspace:^」,  「@deepseek-ai/dsh-settings」: 「workspace:^」,  「@deepseek-ai/cordis」: 「workspace:^」},

这段是依赖声明的位置。注意看 seam(dsh-settings)和 cordis 都在 peer 里。workspace:^,锁死工作区版本。session-persistence-jsonl 更进一步,把同一张表抄了两遍:

「peerDependencies」: {  「@deepseek-ai/dsh-invariants」: 「workspace:^」,  「@deepseek-ai/dsh-session」: 「workspace:^」,  「@deepseek-ai/dsh-session-persistence」: 「workspace:^」,  「@deepseek-ai/cordis」: 「workspace:^」},// …「devDependencies」: {  「@deepseek-ai/dsh-invariants」: 「workspace:^」,  「@deepseek-ai/dsh-session」: 「workspace:^」,  「@deepseek-ai/dsh-session-persistence」: 「workspace:^」,  「@deepseek-ai/cordis」: 「workspace:^」}

这段是同表两抄(package.json:34-49 节选)。注意看两块内容一字不差:peer 给宿主读,devDependencies 给自己跑单包测试用。

为什么 peer 而非普通 dep?普通 dep 允许 npm 在嵌套 node_modules 里落下第二份 seam。两份模块各自做一遍 declare module 合并、各自持有静态状态,ctx.settings 的类型与运行时实例就会对不上号。peer 把解析权提到树顶,配 workspace:^ 保证全树只有一份。第二层理由是组合权:加载哪个 settings 后端、哪个 sandbox、哪个 llm 适配器,由 bundle profile 和 preset 决定,provider 从不自我组装,peer 在类型上如实表达了「我是零件,我不拥有我的接口」。

六、对账:什么时候拆,什么时候是目录税

模式拆完了,收官对三笔账。

什么时候该拆?看两件事。一看有没有第二个合理实现:session-persistence 已经站着 jsonl 和 sqlite 两个;llm 站着 deepseek 和 pi-ai;shell 是 bash 配 pwsh、local 配 sandbox,四个格子全满;credentials 和 sandbox 还各自背着一类本地形态差异,OS 凭据库与平台 ACL。二看契约稳不稳:llm 的 seam 被 106 个包引用(peer 与 dev 合计口径),是全仓库的依赖汇点,基类语义冻结在 seam 里,适配器迭代不惊动消费者。

什么时候不该拆?反例就在仓库里站着。plan-mode 的 PlanModeController(plan/plan-mode/src/index.ts:202)、goal 的 GoalService(goal/goal/src/index.ts:183)、agent-team 的 TeamService(experimental/agent-team/src/index.ts:56)都是具体类直接 extends Service,它们没有第二实现,拆了只会得到一个永远单子类的抽象包。tool-todo 连服务都不建(tool-todo/src/index.ts:23):

export const inject = ['tools']

这行是 tool-todo 的全部装配声明。它是个纯工具插件,todo 状态记进会话日志,不需要自己的服务基座。

代价是什么?包数翻倍,234 个 package.json 里 peerDep 表全靠手工对齐;看一个能力要跳两个包;227 个 invariant.ts 里大部分是占位,新读者常把它当死代码。换来的是两条演进速度不同的工作流分开走:改 seam 全仓库感知,改 provider 局部感知。《复盘 234 个包:把纪律做成接缝,把接缝做成包》算过这笔总账,这里补的是机制侧的账目。

七、结语

回到 settings-remote 的任务清单。Config 一份,声明远端地址和鉴权;load 和 persist 各一个,写远端协议的拉取推送;writable 一个,回答远端此刻可不可写。外加 package.json 里一张 peer 表。清单到此为止,因为 writeQueues 在基类 :355 挂着,Service.init 在 :376 挂着,invariant companion 的四条 fail 在 invariant.ts:28-44 挂着。

三个钩子的原文,就钉在 settings/src/index.ts 的 :389、:416、:423 上。这个仓库 22 个 seam 包,每一个都留着同款的三五枚钉子。

装配权最后落在哪、preset 怎么把 provider 的组合下放到每个会话,下一篇拆 agent 家族,深读第 26 篇见。

你的项目里有没有「换了后端行为就飘」的经历,问题出在契约还是实现?评论区聊聊。


本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。

相关学习资料