L9 · 能力缝隙
Lesson 09 · 模块 C · 会改

能力缝隙:
写一个模型适配器。

这套 harness 里,模型、shell、文件系统、subagent 都不是写死的部件,而是同一种东西: capability seam(能力缝隙)。这一课讲透缝隙的三角色设计法,解剖 llm 缝隙, 并带你亲手写一个最小模型适配器——接进任何一个 OpenAI 兼容服务。

精读 20 分钟 练习 10 分钟 · 6 题 前置 见课程首页
学完你将能够
  • 说出 capability seam 的三角色分工,判断一条能力缝隙是否完整
  • 读懂缝隙地图,解释换掉 fs / subprocess 的 provider 为何能让 Bash、PTY、LSP 整体搬进远程沙箱
  • 复述 StreamChunk 词汇与 adapter 约定,说清 chunk 如何变成会话日志里的 assistant/chunk 事件
  • 照 cookbook 写出最小 LlmAdapter,并在 cordis.yml 里把它接到一个 OpenAI 兼容端点
01 · Three Roles

seam 三角色:Definition、Provider、Consumer

术语表给 capability seam 的定义很短:一种包含三种角色可替换能力。 三种角色缺任何一个,都不是 seam——这个词只保留给完整能力。

  • Service Definition:一个 Cordis Service,拥有自己的 ctx.<key> 和词汇类型。它可以是抽象类(如 ShellExecutor),也可以是具体注册表(如 WebRuntime)——但绝不是 TypeScript interface,因为它要有运行时的注册表与生命周期。
  • Service Provider:一个或多个实现,把能力真正做出来,注册到 Definition 的注册表。
  • Consumer:inject 这个服务并消费它的插件——工具、loop、hooks 桥。Consumer 只依赖 Definition 的词汇与 ctx 键,从不 import Provider 的实现类。

规范范例是 packages/shelldsh-shell 是 Service Definition(ShellExecutor + ctx.shell), dsh-bash-localdsh-bash-sandbox 是 Service Provider,dsh-tool-bash 是 Consumer。

Service Definition · dsh-llm 拥有 ctx.llm 与 StreamChunk 词汇 Service Provider llm-deepseek / llm-pi-ai 实现 stream() · 注册提供方路由 Consumer agent-loop / compaction-basic 注入服务 · 调用 ctx.llm.stream() registerAdapter() inject: ['llm'] Consumer 只依赖 Definition 的词汇与 ctx 键,从不 import Provider 的实现类——可替换性由此而来。
图 9-1 · 三角色关系(以 llm 缝隙为例):Definition 拥有词汇与 ctx 键;Provider 实现并注册;Consumer 注入消费。换成 GPT / Kimi / Claude,只是再多一个 Provider。

由此得到新增能力的设计纪律:一次设计三个角色。先定词汇与 ctx 键(Definition), 再写至少一个实现(Provider),最后决定谁把能力暴露给模型或用户(Consumer)。 只写 Definition + Provider 就宣布完工,能力永远到不了模型——这不是 seam,是半成品。

一个包可以承担多个角色

三角色通常分包,但同属一个关注点时不必硬拆:dsh-llm 就同时承担 llm 缝隙的 Service Definition 和 Consumer(词汇 + 注册表 + 提供方无关的 stream 服务)。是否拆分的唯一标准是角色是否需要独立演进,详见第 05 节。

02 · Seam Map

缝隙地图与可替换性的威力

docs/capability-seams.zh.md 维护着一张自动生成的全量缝隙地图(带完整性守卫)。 按本课视角,最值得记住的是这几条:

  • ctx.llmllm-deepseek / llm-pi-ai / llm-replay;消费方 agent-loopcompaction-basic
  • ctx.shellbash-local / bash-sandbox / pwsh-local;消费方 tool-bashtool-pwsh、两个 hooks 桥
  • ctx.fsfs-local / fs-sandbox / fs-e2b;消费方 tool-fs
  • ctx.subprocesssubprocess-local / subprocess-e2b;消费方 bash-localbash-sandboxterminal-bashlsp-stdio 和三个进程外 subagent 后端
  • ctx.terminals ← 持久终端(terminal-bash 等 PTY 后端);消费方 tool-terminaltool-bash-persistent / tool-pwsh-persistent——让 cwd、环境变量与后台任务跨调用保留
  • ctx.subagents ← spawn/fork-in-process、acp、codex、claude-code、dsh-sdk 六个提供方;消费方 tool-subagent
  • ctx.web ← web-search-deepseek / exa / perplexity + web-fetch-http;消费方 tool-web

可替换性的威力用一个例子说透:把 fs 和 subprocess 指向远程沙箱。 注意 ctx.subprocess 的消费方名单——bash 执行器、terminal-bash(PTY)、lsp-stdio 都只认这条 seam。E2B 演示交付了 fs-e2bsubprocess-e2b 两个 provider(共享 ctx.e2b 持有的沙箱句柄)。 于是只要在组合层把这两条 seam 的 provider 换成 E2B 实现, Bash 工具、持久 PTY、LSP 导航就一起搬进了远程 Linux 沙箱——而 bash-localterminal-bashlsp-stdio 这些 provider 一行代码都不用改,更不需要 fork。

观察方法

用 L1 的 --dump-config 看当前组合里每条 seam 挂的是哪个 provider——缝隙不是抽象概念,是插件树上真实的行。

dsh --profile web --dump-config
03 · Anatomy of ctx.llm

llm 缝隙解剖:词汇、注册与会话日志

Definition 侧:词汇与注册表。dsh-llm 定义了全仓库的模型词汇: MessageContentBlock 组成(text / reasoning / image / tool-call / tool-result); 一次请求是完全组装好的 GenerateOptions(provider、model、messages、system、tools、采样参数、signal……); 响应是 StreamChunk 的封闭联合。一次流式响应的典型分片序列:

// StreamChunk:block-start → delta* → block-end → usage → finish { type: 'block-start', index: 0, blockType: 'text' } { type: 'text-delta', index: 0, text: 'Hello' } { type: 'text-delta', index: 0, text: ' world' } { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello world' } } { type: 'block-start', index: 1, blockType: 'tool-call' } { type: 'tool-call-delta', index: 1, id: CallId('call-123'), name: 'bash', argumentsDelta: '{"command":"ls"}' } { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{"command":"ls"}' } } { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } } { type: 'finish', reason: { kind: 'tool-calls' } }

关键规则:index 按首次出现顺序分配,把每个 delta 关联到所属块;block-end 携带完整组装好的块; usage 必须在 finish 之前,finish 之后不再有任何分片; 工具调用的 arguments 全程是原始 JSON 字符串,流式片段走 argumentsDelta

Provider 侧:注册,而不是被 import。adapter 是 LlmAdapter 的子类,实现唯一必需的方法 stream(),然后在插件的 apply() 里注册提供方路由: ctx.llm.registerAdapter(['my-provider'], adapter)。请求的 GenerateOptions.provider 选择路由,options.model 是提供方自己的模型 id(无需提前注册)。每个路由只能有一个 adapter: 重复注册会以 DUPLICATE_ADAPTER 原子失败——多路由注册要么全部成功,要么全部失败。 已交付的三个 provider:llm-deepseek(直连 HTTP,SSE 由 eventsource-parser 分帧)、 llm-pi-ai(封装 LLM 库)、llm-replay(测试回放)。

Consumer 侧:agent-loop 与 compaction-basic。loop 通过 ctx.llm 发起每次模型调用, 所有流式调用都经过 llm/stream waterfall(L5 的四种派发之一,listener 必须调 next())。 消费方只认词汇、不认提供方——这就是为什么换模型不需要动 loop。

chunk 如何进入会话日志(呼应 L6)。loop 一边把每个原始分片以 assistant/chunk { turn, step, chunk } 事件追加进会话日志(token 级回放保真), 一边把同一批分片喂给共享的 BlockAssembler;流结束后,组装出的 assistant/message 连同 provider/model 来源与可选 replayState 一起落日志。这就是 L6 的铁律 model-visible ⟺ logged 在 llm 缝隙上的体现:凡进入模型请求与响应的内容,都必须能从日志重建—— adapter 吐出的每个 chunk 因此都是一等会话事件。

04 · Hands On

动手试:最小 adapter 接入 OpenAI 兼容服务

docs/cookbook/adding-an-llm-adapter.zh.mddocs/user/develop/practice/llm-adapter.zh.md 的配方,最小 adapter 只有三块:一个继承 LlmAdapter 的类、一份 schemastery Config、一个在 apply() 里调用 registerAdapter() 的插件入口。

// src/my-llm-adapter.ts —— 最小骨架;分片转换的完整义务见 cookbook import type { Context } from '@deepseek-ai/cordis' import Schema from '@deepseek-ai/schemastery' import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' class MyAdapter extends LlmAdapter { constructor(private apiKey: string, private baseURL: string) { super() } async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> { // 1. 把 options.messages / tools 翻译成提供方格式(这里是 OpenAI 兼容) const res = await fetch(`${this.baseURL}/chat/completions`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}`, ...attributionHeaders(), // 每个提供方 HTTP 请求都必须携带 }, body: JSON.stringify({ model: options.model, messages: options.messages, stream: true }), signal: options.signal, // 遵守中止信号 }) if (!res.ok) throw new LlmError(`Provider API error: ${res.status}`, 'PROVIDER_HTTP_ERROR') // 2. 解析 SSE 流(llm-deepseek 用 eventsource-parser 分帧,直接抄它的布局) // 3. 逐分片 yield:block-start → text-delta* → block-end → usage → finish } } export const name = 'my-llm-adapter' export const inject = ['llm'] export interface Config { apiKey: string; baseURL: string; providers: string[] } export const Config: Schema<Config> = Schema.object({ apiKey: Schema.string().required(), baseURL: Schema.string().required(), providers: Schema.array(Schema.string()).required(), }) export function apply(ctx: Context, config: Config) { ctx.llm.registerAdapter(config.providers, new MyAdapter(config.apiKey, config.baseURL)) }

然后把它写进你正在使用的 profile 的 cordis.yml(组合与 patch 见 L2), 密钥用 !!js 从环境变量注入,并把 main agent 指到这条新路由:

# cordis.yml - id: my-llm name: './src/my-llm-adapter.ts' config: apiKey: !!js process.env.MY_API_KEY baseURL: https://your-openai-compatible.endpoint/v1 providers: - my-provider - id: agent-loop name: '@deepseek-ai/dsh-agent-loop' config: agents: - id: main provider: my-provider model: my-model-v1
动手试 · 10 分钟

完成上面两个文件后跑一次无头任务,观察输出事件流——你的 adapter 吐出的分片正以 assistant/chunk 落进会话日志:

pnpm dsh --profile headless "用一句话介绍你自己"

对照参考实现:packages/llm/llm-deepseek(OpenAI 兼容格式,直接 HTTP)与 packages/llm/llm-pi-ai(封装 LLM 库,不同的 API 格式)。对比二者,就能看出同一套 harness 契约如何在不同提供方之上实现。

协议义务(两个已交付实现共同验证的约定)

usagefinish 之前发出,finish 之后零分片;② 错误只有两条合法路径——从 stream() 抛出带稳定 code 的 LlmError(传输/协议故障),或以 finish {kind: 'error' | 'aborted'} 结束流(提供方带内故障);③ 提供方不支持的 GenerateOptions 字段抛 LlmError(..., 'UNSUPPORTED'),绝不静默丢弃。

05 · Pitfalls

常见坑

坑 1:为「干净」而过度拆分。三角色分包的前提是角色需要独立演进。 同属一个关注点时硬拆三个包,只会多出两份 manifest 和版本协调成本——dsh-llm 同包承担 Definition 与 Consumer 正是正面反例。判断标准只有一个:这几个角色会不会由不同人、按不同节奏演进?

坑 2:Provider 跑通就宣布完工。seam 的验收标准是能力到达消费方。忘了 Consumer 侧, 模型根本看不见这条能力:tool-fs 之于 ctx.fstool-web 之于 ctx.web,都是 Consumer 把能力以工具/schema 的形式暴露给模型;llm 缝隙里则由 agent-loopToolSchema 组装进 GenerateOptions.tools。 交付清单永远包含三件:词汇、实现、面向模型的暴露。

陷阱 · 静默丢弃

提供方不支持某个 GenerateOptions 字段(例如 stop sequences)时,正确做法是抛 LlmError('...', 'UNSUPPORTED')。静默丢弃会让模型行为与请求不符且无从排查—— harness 把「misconfiguration fails loud」一直贯彻到了 adapter 约定里。

06 · Quiz · 10 分钟

课堂练习

2 单选 + 2 多选 + 2 问答。选择即时批改,问答先写下你的答案再对照。成绩保存在本地浏览器。
单选
一个 capability seam 由哪三个角色构成?

术语表原文:seam 是包含 Service Definition(拥有自身 ctx.<key> 与词汇类型的 Cordis Service)、一个或多个 Service Provider、一个或多个 inject 该服务的 Consumer 的可替换能力。A 是近义改写但不是规范术语;C、D 拼凑了仓库里真实存在的概念,但都不是三角色。

单选
写好一个模型 adapter 后,应该把它注册到哪里?

Service Definition dsh-llm 拥有 ctx.llm 注册表;adapter 在插件的 apply() 里调用 ctx.llm.registerAdapter(providers, adapter) 注册提供方路由,GenerateOptions.provider 据此选路。cordis.yml 只做组合与配置注入,注册动作仍是代码里的副作用;adapter 既不是工具也不是 agent。

多选
下列「角色 → 职责」的配对,哪些正确?(选出所有正确项)

C 错:Consumer 的职责是消费——注入服务,把能力接到模型或用户面前(工具、loop、hooks 桥);发布工程不属于任何角色的定义。A、B、D 正是 packages/shell 规范范例里的三角色分工。

多选
关于能力缝隙,哪些说法正确?

B 错:缺了 Consumer,能力永远到不了模型——术语表明确 seam 是完整能力,绝不只是其中一个角色。D 错:Consumer 只 inject Definition 的服务、只依赖词汇与 ctx 键,从不 import Provider 实现,这正是可替换性的来源。A 是 E2B 演示的实证,C 是三角色的定义本身。

问答用三角色解释:为什么 GPT、Kimi、Claude 都能接进这套 harness?写下答案后点击对照

参考要点:① dsh-llm 作为 Service Definition 规定了提供方无关的词汇(Message / ContentBlock / StreamChunk)与 LlmAdapter 约定,并持有 ctx.llm 注册表——接入方要满足的契约与具体厂商无关;② 这些服务多为 OpenAI 兼容端点,写一个 Provider(继承 LlmAdapter、实现 stream(),把自家协议翻译成 StreamChunk)再 registerAdapter 注册路由即可,llm-deepseek 就是参照实现;③ Consumer(agent-loop、compaction-basic)只 inject ctx.llm、只认词汇,新 provider 接入它们零改动。L3 的「接入任意模型」正是这条缝隙在组合层的用法。

问答如果你要设计一条新能力缝隙(以仓库里的 code-runtime 为例),三角色各自要交付什么?写下答案后点击对照

参考要点:① Service Definition(dsh-code-runtime):定义 Cordis Service ctx.codeRuntime、请求与结果词汇及约定(用 Host 提供的异步绑定运行模型编写的程序);② Service Provider(code-runtime 组的 code-runtime-worker-threadcode-runtime-python 已移入 packages/experimental,不再是正式组合的一部分):选定基础环境与语言,实现执行语义并注册;③ Consumer(dsh-tools 注册表):在 PTC mode(原 Code Mode)下把能力包装成模型可见的工具与 schema。三者缺一不可:没有 Definition 就没有稳定词汇,没有 Provider 就没有可运行实现,没有 Consumer 能力永远到不了模型。是否分包,取决于角色是否需要独立演进。

本课小结

三角色设计法是这套 harness 的扩展母语:Definition 定词汇,Provider 做实现,Consumer 做暴露。 llm 缝隙是它的最佳样本——而你已经照 cookbook 把一个新的 provider 接了进去, 还顺手复习了 chunk 如何经 assistant/chunk 落进会话日志。 下一课收尾模块 C:工程规范与毕业实战——测试政策、Agent Notes,以及把你的插件发布出去。