能力缝隙:
写一个模型适配器。
这套 harness 里,模型、shell、文件系统、subagent 都不是写死的部件,而是同一种东西: capability seam(能力缝隙)。这一课讲透缝隙的三角色设计法,解剖 llm 缝隙, 并带你亲手写一个最小模型适配器——接进任何一个 OpenAI 兼容服务。
- 说出 capability seam 的三角色分工,判断一条能力缝隙是否完整
- 读懂缝隙地图,解释换掉 fs / subprocess 的 provider 为何能让 Bash、PTY、LSP 整体搬进远程沙箱
- 复述 StreamChunk 词汇与 adapter 约定,说清 chunk 如何变成会话日志里的
assistant/chunk事件 - 照 cookbook 写出最小 LlmAdapter,并在 cordis.yml 里把它接到一个 OpenAI 兼容端点
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/shell:dsh-shell 是 Service Definition(ShellExecutor + ctx.shell),
dsh-bash-local 与 dsh-bash-sandbox 是 Service Provider,dsh-tool-bash 是 Consumer。
由此得到新增能力的设计纪律:一次设计三个角色。先定词汇与 ctx 键(Definition), 再写至少一个实现(Provider),最后决定谁把能力暴露给模型或用户(Consumer)。 只写 Definition + Provider 就宣布完工,能力永远到不了模型——这不是 seam,是半成品。
三角色通常分包,但同属一个关注点时不必硬拆:dsh-llm 就同时承担 llm 缝隙的 Service Definition 和 Consumer(词汇 + 注册表 + 提供方无关的 stream 服务)。是否拆分的唯一标准是角色是否需要独立演进,详见第 05 节。
缝隙地图与可替换性的威力
docs/capability-seams.zh.md 维护着一张自动生成的全量缝隙地图(带完整性守卫)。
按本课视角,最值得记住的是这几条:
ctx.llm←llm-deepseek/llm-pi-ai/llm-replay;消费方agent-loop、compaction-basicctx.shell←bash-local/bash-sandbox/pwsh-local;消费方tool-bash、tool-pwsh、两个 hooks 桥ctx.fs←fs-local/fs-sandbox/fs-e2b;消费方tool-fsctx.subprocess←subprocess-local/subprocess-e2b;消费方bash-local、bash-sandbox、terminal-bash、lsp-stdio和三个进程外 subagent 后端ctx.terminals← 持久终端(terminal-bash等 PTY 后端);消费方tool-terminal与tool-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-e2b 与 subprocess-e2b 两个 provider(共享 ctx.e2b 持有的沙箱句柄)。
于是只要在组合层把这两条 seam 的 provider 换成 E2B 实现,
Bash 工具、持久 PTY、LSP 导航就一起搬进了远程 Linux 沙箱——而 bash-local、terminal-bash、
lsp-stdio 这些 provider 一行代码都不用改,更不需要 fork。
用 L1 的 --dump-config 看当前组合里每条 seam 挂的是哪个 provider——缝隙不是抽象概念,是插件树上真实的行。
dsh --profile web --dump-configllm 缝隙解剖:词汇、注册与会话日志
Definition 侧:词汇与注册表。dsh-llm 定义了全仓库的模型词汇:
Message 由 ContentBlock 组成(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 因此都是一等会话事件。
动手试:最小 adapter 接入 OpenAI 兼容服务
照 docs/cookbook/adding-an-llm-adapter.zh.md 与 docs/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完成上面两个文件后跑一次无头任务,观察输出事件流——你的 adapter 吐出的分片正以 assistant/chunk 落进会话日志:
pnpm dsh --profile headless "用一句话介绍你自己"对照参考实现:packages/llm/llm-deepseek(OpenAI 兼容格式,直接 HTTP)与 packages/llm/llm-pi-ai(封装 LLM 库,不同的 API 格式)。对比二者,就能看出同一套 harness 契约如何在不同提供方之上实现。
① usage 在 finish 之前发出,finish 之后零分片;② 错误只有两条合法路径——从 stream() 抛出带稳定 code 的 LlmError(传输/协议故障),或以 finish {kind: 'error' | 'aborted'} 结束流(提供方带内故障);③ 提供方不支持的 GenerateOptions 字段抛 LlmError(..., 'UNSUPPORTED'),绝不静默丢弃。
常见坑
坑 1:为「干净」而过度拆分。三角色分包的前提是角色需要独立演进。
同属一个关注点时硬拆三个包,只会多出两份 manifest 和版本协调成本——dsh-llm
同包承担 Definition 与 Consumer 正是正面反例。判断标准只有一个:这几个角色会不会由不同人、按不同节奏演进?
坑 2:Provider 跑通就宣布完工。seam 的验收标准是能力到达消费方。忘了 Consumer 侧,
模型根本看不见这条能力:tool-fs 之于 ctx.fs、tool-web 之于
ctx.web,都是 Consumer 把能力以工具/schema 的形式暴露给模型;llm 缝隙里则由
agent-loop 把 ToolSchema 组装进 GenerateOptions.tools。
交付清单永远包含三件:词汇、实现、面向模型的暴露。
提供方不支持某个 GenerateOptions 字段(例如 stop sequences)时,正确做法是抛
LlmError('...', 'UNSUPPORTED')。静默丢弃会让模型行为与请求不符且无从排查——
harness 把「misconfiguration fails loud」一直贯彻到了 adapter 约定里。
课堂练习
术语表原文:seam 是包含 Service Definition(拥有自身 ctx.<key> 与词汇类型的 Cordis Service)、一个或多个 Service Provider、一个或多个 inject 该服务的 Consumer 的可替换能力。A 是近义改写但不是规范术语;C、D 拼凑了仓库里真实存在的概念,但都不是三角色。
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-thread;code-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,以及把你的插件发布出去。