当你要把自研模型、第三方厂商端点或者本地推理服务接进 DeepSeek Harness(下文简称 dsh)时,真正要写的东西其实只有一样:一个继承 LlmAdapter 抽象类、实现 stream() 方法的适配器类。它负责把你这家提供方的 API 调用翻译成 Harness 能消费的统一流式协议 StreamChunk,再由 ctx.llm.registerAdapter() 把提供方路由绑定到适配器实例上。这样一来,agent-loop 面对的是提供方无关的接口,不管背后是 DeepSeek、是别的云厂商,还是你机房里那台跑着量化权重的机器,它都只看到一条规范的异步分片序列。本文围绕两个关键词展开:LLM 适配器与 StreamChunk,前者解决「怎么接进来」,后者解决「接进来之后吐什么」。读完你将能独立写完一个可注册、可流式、可收尾的完整适配器。
LlmAdapter 抽象类:继承关系、stream() 签名与 AsyncIterable 契约
先把最核心的一句话摆在前面:LLM 适配器就是一个继承 LlmAdapter 并实现 stream() 方法的类。它承担两段翻译工作——把 Harness 发出的、与提供方无关的请求,转换成具体提供方格式的 API 调用;再把提供方返回的响应,转换回 Harness 自己的分片结构 StreamChunk。正因为它把两头都吃掉,agent-loop 才能安心地消费统一接口,对背后到底是哪一家 API 一无所知。
要把这个类写出来,导入路径必须先理清楚。抽象类本身、以及它在方法签名里用到的两个类型 GenerateOptions 与 StreamChunk,全部来自同一个包:@deepseek-ai/dsh-llm。也就是说,你的适配器文件开头基本会长成这样:
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'其中 LlmAdapter 是一个值导入(你要 extends 它),而 GenerateOptions 和 StreamChunk 是类型导入,用 type 修饰符标注可以避免运行时残留无意义引用。Context 来自 @deepseek-ai/cordis,是插件体系里的上下文对象,后面 ctx.llm 就是从它身上取的。Schema 来自 @deepseek-ai/schemastery,负责在插件加载时校验配置。三个包的职责边界很清晰,不要互相串。
接下来说 stream() 的签名,这是整个抽象类里唯一强制你实现的方法:
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk>拆开看有三个要点。第一,参数是 GenerateOptions,而不是各家 SDK 自己的请求对象。GenerateOptions 里装着 Harness 视角下的一次生成请求,最典型的就是 options.messages——那是一份按统一角色(system / user / assistant / tool 之类)组织的对话消息数组。适配器的第一件事,就是把它翻译成你这家提供方要的格式。第二,返回值是 AsyncIterable<StreamChunk>,注意这不是 Promise,也不是数组,而是一个可异步迭代的对象。这意味着消费方(agent-loop)用 for await (const chunk of adapter.stream(options)) 的方式逐片拉取,边到边处理,天然支持流式。第三,方法上带 * 号,说明它用 async * 声明为异步生成器,内部用 yield 一片一片把 StreamChunk 往外送。这三件事共同构成了一个契约:调用者不关心你内部是几次网络往返、怎么拼包,它只会按顺序拿到一串分片。
这里有个非常容易踩的坑:不要为了图省事把整个响应攒完再一次性 yield。技术上你当然可以等完整响应到手后,用 yield 把 block-start、text-delta、block-end 一口气推完,流式协议在结构上依然成立,但你就彻底失去了流式带来的首字延迟优势,用户体验会退化成普通的同步请求。适配器的价值恰恰在于把网络层逐个到达的增量,实时地 yield 出去。建议在实现里对着提供方的 SSE(Server-Sent Events)或分块响应逐块读取,每读到一个增量就立即 yield 一个 delta 分片,不要做无谓的缓冲。
还有一个签名层面的细节值得强调:AsyncIterable<StreamChunk> 意味着「可被 for await 消费」,而不是「返回一个 StreamChunk 数组」。如果你写成 async stream(...): Promise<StreamChunk[]>,类型上就与抽象类契约不符,消费方的 for await 也会失效。TS 编译器的类型检查会在这里拦住你,所以不要尝试绕过。另外,生成器函数一旦抛出异常,异常会沿着 for await 的消费点向上冒泡,这正是后面做传输错误处理的天然切入点——你可以在 yield 之间捕获提供方 SDK 的报错,转换成合适的错误语义再抛出,而不是让原始的 HTTP 错误码直接泄漏给 agent-loop。
把层级关系画出来看会更清楚:顶层是 agent-loop,它只认「提供方无关的流式生成服务」这个能力;中间是 ctx.llm 注册表,它维护着 LlmAdapter 这个抽象契约,相当于一条接缝(seam);底层则是各家适配器,分别对接不同格式的 API。你的工作全部发生在底层这一格,只要守住接口,上面的两层完全不必改动。这种分层的好处是:新增一家提供方,改动范围被限制在一个文件、一次注册调用之内,对 agent-loop 零侵入。
ctx.llm.registerAdapter(['my-provider'], adapter):路由注册与提供方绑定
类写完了,还得让 Harness 知道它的存在。注册动作由一行调用完成:
ctx.llm.registerAdapter(['my-provider'], adapter)这个方法的两个参数各司其职。第一个参数是提供方路由列表,注意它是数组,示例里是 ['my-provider'],但这个数组里可以放多个字符串,表示同一个适配器实例同时接管多个提供方标识。这在做别名或者多租户场景时很有用,比如你想让 my-provider 和 my-provider-eu 两个名字都路由到同一个适配器实现,直接写 ['my-provider', 'my-provider-eu'] 即可,不必实例化两次。第二个参数是适配器实例,注意是实例而不是类,也就是说你必须先 new 出来一个,把 apiKey 之类构造参数传进去,再把实例交给注册表。
为什么注册的是「路由列表 + 实例」这样的组合?因为 ctx.llm 注册表的本质是一张提供方标识到适配器实例的映射表。当 agent-loop 需要发起一次生成、而请求里指定了某个提供方时,注册表会按名字查表,命中哪个适配器就用哪个的 stream()。这样一来,agent-loop 与具体 API 之间的耦合被彻底切断:它不需要知道 DeepSeek 用的是什么协议,也不需要知道你的自研模型用的是什么协议,它只需要一个字符串标识。
注册动作通常放在插件的 apply 函数里,配合 inject 声明依赖,保证 ctx.llm 已经就绪再动手注册。整段插件出口长这样:
export const name = 'my-llm-adapter'
// 声明依赖 llm 服务,保证 ctx.llm 已就绪
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
// 把提供方路由列表绑定到这个适配器
ctx.llm.registerAdapter(config.providers, adapter)
}这里有几处工程细节值得展开。inject = ['llm'] 不能省。它告诉插件系统:本插件依赖名为 llm 的服务,请在 ctx.llm 可用之后再调用 apply。如果你漏掉这一行,而加载顺序又不巧,apply 执行时 ctx.llm 可能是 undefined,注册调用会在启动阶段直接抛错,排查起来还很绕。providers 数组来自配置而非硬编码。示例把 config.providers 直接喂给 registerAdapter 的第一个参数,这样使用者可以在 cordis.yml 里自行决定这台适配器接管哪些提供方名,同一个插件按不同配置加载多次就能挂多组路由。apiKey 走构造函数注入。适配器实例把密钥存为私有字段,后续在 stream() 里调用提供方 API 时取用,避免把敏感信息散落在各处。
配置的校验交给 Schemastery。官方骨架里同名导出一个 interface Config 和一个 const Config schema,写法是:
export interface Config {
apiKey: string
providers: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})interface 负责编译期类型,Schema 负责运行期校验,同名共存是这套体系的标准做法。注意两个字段都标了 required():apiKey 缺了适配器没法调 API,providers 是空数组则等于注册了一条谁也用不上的路由,两者都属于配置错误,应该在加载阶段就拦下来,而不是等到第一次真实请求才暴露。这种「早失败」的设计能省掉大量线上排障时间。
下面用一张表把注册相关元素的职责对齐一下:
| 元素 | 来源 | 作用 | 典型值 / 形式 |
|---|---|---|---|
| LlmAdapter | @deepseek-ai/dsh-llm | 抽象基类,规定必须实现 stream() | class MyAdapter extends LlmAdapter |
| stream() | 你的适配器类 | 把请求翻译成提供方调用,并把响应翻译成分片 | async *stream(options): AsyncIterable<StreamChunk> |
| registerAdapter 第一参数 | config.providers | 提供方路由列表,决定哪些名字命中本适配器 | ['my-provider'] |
| registerAdapter 第二参数 | new MyAdapter(apiKey) | 实际处理请求的适配器实例 | adapter 实例对象 |
| inject | 插件导出 | 声明依赖 llm 服务,保证 ctx.llm 就绪 | ['llm'] |
| Config + Schema | @deepseek-ai/schemastery | 加载时校验 apiKey 与 providers | 两个字段均 required() |
再给一个可以直接粘贴运行的完整适配器骨架,把类、配置、注册三部分串起来(stream() 内部先用三步注释占位,下一节展开):
// 文件路径:src/my-llm-adapter.ts
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
// 适配器:继承抽象类,实现 stream()
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
// stream() 返回异步生成器,逐片产出 StreamChunk
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// 1. Convert options.messages to the provider format.
// 2. Call the streaming API.
// 3. Convert the response into StreamChunk values.
}
}
// 插件配置:apiKey 与 providers 都必填
export interface Config {
apiKey: string
providers: string[]
}
// 同名的 Schemastery schema,加载时校验配置
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
// 声明依赖 llm 服务,保证 ctx.llm 已就绪
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
// 把提供方路由列表绑定到这个适配器
ctx.llm.registerAdapter(config.providers, adapter)
}还有一个容易忽略的点:注册的时机只发生一次。apply 在插件加载时调用一遍,适配器实例也就被注册一遍。不要以为每来一次请求都会重新注册,也不要在 stream() 里反过来去改注册表——适配器只管翻译与产出,路由这件事交给注册表在启动阶段一次性搞定。职责分离清楚,很多事情就不会乱。
从 GenerateOptions 到提供方请求:消息格式转换的三步注释
回到 stream() 内部。官方骨架里留了三行注释,恰好勾勒出适配器的完整工作流,也划定了它唯一的职责边界:
- Convert options.messages to the provider format.——把 Harness 统一格式的消息,转换成提供方要求的请求体。
- Call the streaming API.——带着你的 apiKey 去调用提供方的流式接口。
- Convert the response into StreamChunk values.——把提供方吐回来的响应,逐片翻译成 StreamChunk。
这三步就是适配器的全部工作,一步都不能少,也一步都不该多。为什么说它是「唯一职责边界」?因为在这三步之外的一切——会话状态管理、工具编排、重试策略的上层决策、上下文压缩——都属于 Harness 的活,不该由适配器操心。适配器越薄,越容易维护、越容易复用。反过来,一旦你在适配器里塞进业务逻辑,你就失去了「换一家提供方只改一个文件」的清爽,适配器会逐渐变成一个谁都不敢碰的巨类。
第一步的转换往往比想象中琐碎。options.messages 里的角色命名、内容结构是按 Harness 统一约定来的,而不同提供方对这个约定的表达各不相同:有的把 system 提示单独抽成一个字段,有的允许它作为 messages 里的第一条;有的要求多模态内容包成带 type 标记的数组,有的只接受纯字符串;有的对工具调用历史有额外的 schema 要求。你的转换函数需要把这些差异逐个抹平——注意是抹平,不是丢弃。信息能在提供方格式里表达的,就如实映射过去;实在无法表达的,至少要保证转换后的请求语义上依然自洽。
第二步调用流式 API 时,有几个工程要点值得提醒。首先,务必使用提供方的流式模式,而不是等着一次性拿完整响应。多数提供方的 SDK 会提供 stream 开关或者返回一个可异步迭代的流对象,用起来和我们的 AsyncIterable 是天然契合的。其次,鉴权要放在请求头而非 URL,避免密钥出现在日志或代理记录里。再次,要尊重提供方的超时与重试语义,但不要在适配器里做无限重试——无限重试会把一次失败变成一个永远挂着的请求,把 agent-loop 也一起拖住。如果要重试,建议限定次数并只在连接建立阶段重试,不要在流已经开始吐出有效增量之后再重试,否则你会产出重复的分片。
第三步是翻译回 StreamChunk,也是下一篇讲得最细的部分,这里先给一个不变量:无论提供方的流式格式有多少花样,你最终输出的都必须是一条严格有序的 StreamChunk 序列。你可以在内部用任何临时结构去缓冲、拼装,但只要 yield 出去,顺序与结构就得符合协议。这种「内部随意、出口严格」的约束把复杂性锁在了适配器里。
把三步和典型故障对照着看,更容易建立直觉:
| 步骤 | 核心动作 | 输入 | 输出 | 常见故障与对策 |
|---|---|---|---|---|
| 1. 消息格式转换 | 把统一消息映射成提供方请求体 | options.messages | 提供方请求对象 | 角色/多模态结构不匹配;逐字段显式映射,避免整体透传 |
| 2. 调用流式 API | 带鉴权发起流式请求并读取响应流 | 请求对象 + apiKey | 提供方的响应流 / 事件流 | 误用非流式接口、无限重试、大缓冲;开启流式、限定重试、边读边推 |
| 3. 转成 StreamChunk | 把响应事件翻译成分片并 yield | 提供方响应流 | AsyncIterable<StreamChunk> | 分片顺序错乱、缓冲过久;严格按 block-start/delta/block-end 产出 |
一句话收束这一节:适配器只做翻译,不做决策。把这三步做好,它就合格了;把这三步做得只剩三步,它就优秀了。接下来的三节,我们集中攻第三步——StreamChunk 到底长什么样。
StreamChunk 协议入门:block-start / delta / block-end 三态包裹
适配器的 stream() 到底往外出什么样的数据?答案就是 StreamChunk,一种有严格顺序的分片协议。它是 Harness 与适配器之间的流式约定,每一个分片都是一个小对象,带着 type 字段标明自己是什么。理解这套协议,最基本的一条规则是:一个内容块先用 block-start 开始,中间用 delta 增量传输,最后用 block-end 结束。
这三态包裹是 StreamChunk 的骨架。block-start 宣告「我要开一个块了」,它携带两个关键信息:index(这个块的编号)和 blockType(这块是什么类型)。delta 是增量填充,一次只带一小段新内容,可以出现很多次。block-end 收尾,它除了 index 之外还会带上完整的块内容,也就是把之前所有增量拼好之后的最终形态。为什么收尾还要再给一份完整的块?因为消费方可能出于性能考虑不自己拼接,直接拿 block-end 里的完整对象使用;协议同时支持增量路径和完整路径,两条路都走得通,这是很贴心的设计。
贯穿这三态的是 index。它在整条序列中保持一致:某个块的 block-start 用了 index 0,那么它后续所有 delta 的 index 都得是 0,最后 block-end 的 index 也必须是 0。index 的作用是把不同块的增量区分开——真实场景里块之间可能交错出现,靠 index 才能让消费方知道「这个 delta 属于哪个块」。
一条完整序列的收尾还有两个特殊分片:usage 与 finish。所有内容块都结束后,先发 usage 报告 token 用量,再发 finish 声明结束原因。finish 是最后一个分片,它的 reason 字段里 kind 为 stop 表示正常结束,为 tool-calls 表示模型请求执行工具。这两片的顺序不能颠倒,理由下一节和最后一节都会再强化。
对照配图自上而下看一遍,序列的节奏就一目了然:先是一个文本块(block-start → text-delta × 2 → block-end),再是一个工具调用块(block-start → tool-call-delta → block-end),最后是 usage 与 finish。任何一次生成,不管内容多少,都可以用这个模板去套。下面给出一个最小但完整可运行的分片序列示例,直接 yield 出上述结构,可以拿它当作对照实现的基准:
// 文件路径:示例代码,演示一次完整的 chunk 序列
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 1. Start each content block with block-start.
// 开启一个文本块,index 为 0
yield { type: 'block-start', index: 0, blockType: 'text' }
// 2. Stream text through text-delta.
// 文本增量,可拆成多个分片
yield { type: 'text-delta', index: 0, text: 'runoob' }
yield { type: 'text-delta', index: 0, text: ' 教程' }
// 3. End each content block with block-end and the complete block.
// 用完整块结束,index 与 block-start 一致
yield {
type: 'block-end',
index: 0,
block: { type: 'text', text: 'runoob 教程' },
}
// 4. Tool-call block.
// 开启一个工具调用块,index 为 1
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
// 工具名与参数增量,id 用 CallId 工厂生成
yield {
type: 'tool-call-delta',
index: 1,
id: CallId('call-123'),
name: 'bash',
argumentsDelta: '{"command":"echo runoob"}',
}
// 用完整块结束,arguments 是拼好的 JSON 文本
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: CallId('call-123'),
name: 'bash',
arguments: '{"command":"echo runoob"}',
},
}
// 5. Token usage.
// 报告 token 用量,必须在 finish 之前
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
// 6. Finish reason.
// 最后一个分片,声明结束原因
yield { type: 'finish', reason: { kind: 'stop' } }
// Alternatively, { kind: 'tool-calls' } requests tool execution.
}把这段序列按分片类型归纳成一张对照表,写适配器时可以直接当 checklist 用:
| 分片类型 | 必填字段 | 作用 | 出现次数 |
|---|---|---|---|
| block-start | type, index, blockType | 开启一个内容块 | 每个块恰好一次 |
| text-delta | type, index, text | 文本块的增量内容 | 每个文本块 1 次或多次 |
| tool-call-delta | type, index, id, name, argumentsDelta | 工具调用块的增量参数(原始 JSON 文本) | 每个工具调用块 1 次或多次 |
| block-end | type, index, block | 结束一个内容块,并给出完整块 | 每个块恰好一次 |
| usage | type, usage(inputTokens / outputTokens) | 汇报 token 用量 | 收尾时一次 |
| finish | type, reason(kind) | 声明结束原因,序列终结 | 最后一次,且仅一次 |
三条铁律值得反复默念:其一,每个块必须以 block-start 起、以 block-end 止,中间至少有一个 delta。永远不要只发 start 不发 end,那会让消费方一直等一个不会到来的收尾,在很多实现里直接表现为请求悬挂。其二,index 在同一块内严格一致,不同块之间不重复。实践中简单递增即可,0、1、2……其三,usage 和 finish 必须出现在所有块的 block-end 之后。它们是全局收尾,不是某个块的收尾,混在块中间会让语义彻底混乱。
文本块与工具调用块:两类 blockType 各自的 start / delta / end 走法
StreamChunk 里有两类内容块,由 cubeType(也就是 blockType 字段)区分:text 与 tool-call。它们在结构上共享同一套三态包裹,各自独立跑一遍完整的生命周期,互不交错。这一点极其重要——不是说文本块跑到一半可以插一句工具调用再回来接着跑文本,而是「你跑完你的 start / delta / end,我再跑我的 start / delta / end」。配图里那两段就是标准的先后关系。
先看文本块。它的生命周期是:block-start(blockType 为 text,index 记下来)→ 若干条 text-delta(每条带一小段 text)→ block-end(带完整的文本块)。文本块的完整块形如 { type: 'text', text: 'runoob 教程' },text 字段就是拼接后的最终文本。示例里把它拆成两片:先是 'runoob',再是 ' 教程',拼起来正好是 'runoob 教程'。
再看工具调用块。它的生命周期同构,但内容字段不同:block-start(blockType 为 tool-call,index 记下来)→ 若干条 tool-call-delta(每条带 id、name 与 argumentsDelta)→ block-end(带完整的工具调用块)。完整块形如 { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{...}' }。注意这里 arguments 是拼好的完整 JSON 文本,而 delta 阶段的 argumentsDelta 只是它的一段增量。
把两类块摊开对照,差异与共性都清楚了:
| 维度 | 文本块(blockType: text) | 工具调用块(blockType: tool-call) |
|---|---|---|
| 开启分片 | block-start, blockType: 'text' | block-start, blockType: 'tool-call' |
| 增量分片类型 | text-delta | tool-call-delta |
| 增量携带字段 | text(文本片段) | id、name、argumentsDelta(原始 JSON 文本增量) |
| 完整块字段 | type、text | type、id、name、arguments |
| 身份标识 | 无独立 id,靠 index 区分 | 有 id,由 CallId 工厂生成 |
| 是否触发宿主行为 | 否,只是内容 | 是,配合 finish reason 为 tool-calls 请求执行 |
为什么文本块和工具调用块要各自完整跑一遍生命周期,而不是混在一起?因为它们的消费语义完全不同。文本块产出的是给人看的内容,消费方可以边收边渲染;工具调用块产出的是给系统执行的结构化指令,消费方通常要等到 block-end、拿到完整且合法的 arguments 之后才敢解析并执行。把两者分成独立块,消费方就能用最简单的规则决定「什么时候可以执行工具」——看到工具调用块的 block-end 即可,不必担心文本还在后面继续流。
工程上最容易犯的错误是交错产出:模型可能在一次生成里先说了两句解释,再发起一个工具调用,有的提供方返回的事件流也会把文本与工具参数交替推过来。你的适配器要做的是在内部把它们归位,确保 yield 出去时是「文本块完整结束 → 工具调用块完整开始」这样干净的顺序。如果你图省事,把交错的增量直接按到达顺序 yield 出去,消费方会看到文本块的 delta 和工具调用块的 delta 互相穿插,轻则渲染错乱,重则工具参数拼不完整。
那怎么在适配器里做归位?一个稳妥的做法是在 stream() 内部维护两个缓冲区:文本缓冲和工具调用缓冲。读到文本事件就追加到文本缓冲并即时 yield 一条 text-delta;读到工具参数事件就追加到工具缓 冲并即时 yield 一条 tool-call-delta。但要注意:如果两类事件真的会交错到达,你就必须在 yield 之前先决定块的边界。更稳的策略是延迟开启新块:只有当某个块确定已经开始且前一个块已确定结束时,才允许 yield 下一个 block-start。这需要你稍微多缓冲一点状态,换来的是出口序列的严格有序。对于大多数以「文本在前、工具调用在后」顺序返回的提供方,直接顺序处理即可,只有在实测发现交错时才启用更细的缓冲策略。
另一个细节是 index 的分配。示例里文本块用 0、工具调用块用 1,这是最直观的递增策略。它不要求从 0 开始,只要求同一序列内唯一且与块的 start / delta / end 保持一致。建议在 stream() 开头维护一个计数器 let nextIndex = 0,每开启一个新块就取当前值再自增,省得手工管理出错。
text-delta 增量拼接与 tool-call-delta 的 argumentsDelta 语义
delta 分片的核心思想是「化整为零」。文本方面,text-delta 可以拆成多个分片,由消费方拼接成完整文本。示例里把 'runoob 教程' 拆成了 'runoob' 和 ' 教程' 两片,这只是为了演示;真实场景里拆多少片取决于提供方怎么推、以及你怎么读。一个中文回复可能在提供方的流里被切成几十甚至上百个增量,你读到一个就 yield 一个,消费方按 index 顺序收拢,用简单的字符串相加就能还原全文。这种「生产者负责拆、消费者负责拼」的分工,是流式体验的基础:首字尽早出现,后续内容陆续到达。
这里要澄清一个常见误解:text-delta 的 text 不是「整段文本的第 N 个字」,而是「新增的那一段」。消费方要做的是累加(append),不是替换。如果你在适配器里不小心把累计后的完整文本塞进每个 text-delta,消费方再累加一遍,结果就会变成 'runoobrunoob 教程' 这种重复拼接。所以适配器这边每次 yield 的必须是纯增量。判断方法很简单:把所有 text-delta 的 text 依次相加,结果应当恰好等于 block-end 里完整块的 text,一个字符不多、一个字符不少。
工具调用这边,语义要再拧紧一点。tool-call-delta 的 argumentsDelta 是原始 JSON 文本的增量。注意三个关键词:「原始」「JSON 文本」「增量」。它不是解析后的对象,不是某个字段的局部值,而是最终 arguments 这个 JSON 字符串的一段连续切片。示例里 argumentsDelta 是 '{"command":"echo runoob"}',恰好一段就给全了;但真实场景里它极可能被切成 '{"comm'、'and":"echo'、' runoob"}' 这样几段,甚至切在一个转义序列的中间。适配器不需要、也不应该去解析它,只需要原样 yield;真正需要解析的时刻是 block-end 之后,那时 arguments 字段里已经是拼好的完整 JSON 文本。
为什么工具参数要设计成「流式 JSON 文本增量」而不是「直接给解析好的对象」?因为流式场景下,提供方本身就是按 token 往外吐 JSON 字符的,你在它吐完之前根本没法解析出合法对象。协议顺应这个物理事实,让增量阶段只负责搬运字符串,拼接与解析都推迟到收尾。这样适配器可以做到几乎零解析逻辑,只做搬运。
把两种 delta 的语义并排比较:
| 对比项 | text-delta.text | tool-call-delta.argumentsDelta |
|---|---|---|
| 内容本质 | 纯文本片段 | 原始 JSON 文本片段 |
| 是否可直接解析 | 不需要解析,直接展示或拼接 | 不可单独解析,须等完整拼接后再解析 |
| 消费方动作 | 字符串累加 | 字符串累加,得到完整 JSON 文本后再 JSON.parse |
| 与 block-end 的关系 | 累加结果等于 block.text | 累加结果等于 block.arguments |
| 适配器职责 | 搬运增量,不重复发送累计值 | 搬运增量,绝不做局部 JSON 解析 |
拼接的一致性怎么保证?给两条实践建议。第一,delta 阶段只 yield 新增内容,块级完整性由 block-end 兜底。即便你因为某种原因在某个 delta 上多切或少切了,只要 block-end 里的完整块是对的,消费方以完整块为准就能纠正过来。这也是协议同时给 delta 和完整块的价值之一。第二,在开发期加一个断言:把同一 index 下所有 delta 的文本拼起来,和 block-end 的 block.text(或 block.arguments)比较,必须相等。把这个断言放进单元测试,能挡住绝大多数拼接类 bug。
另外提醒一个边界情况:argumentsDelta 为空字符串、或者根本没有 tool-call-delta 怎么办?如果一个工具调用没有任何参数,你仍然应该发 block-start,然后(可以省略 delta,也可以发一个 argumentsDelta 为空的 delta,视提供方与消费方约定而定),最后用 block-end 给出 arguments 为空 JSON 对象文本(比如 '{}')的完整块。关键是 block-start 与 block-end 必须成对出现,中间有没有 delta 反而是次要的。工具调用的完整性由这对 start / end 保证,不是由 delta 数量保证。
CallId('call-123') 工厂与工具调用块的 id / name / arguments 字段
工具调用块有三个关键字段,值得单独拎出来讲清楚:id、name、arguments。它们分别回答三个问题——这次调用是谁、要调用什么工具、参数是什么。
先说 id。示例里用的是 CallId('call-123'),注意这不是普通字符串,而是通过 CallId 工厂生成的标识。id 必须用 CallId 工厂生成,不能随意塞一个裸字符串。工厂的作用是把提供方返回的原始调用标识包装成协议认可的 CallId 类型,这样后续在对话历史里引用这个工具调用、以及把工具执行结果回填给模型时,标识的类型是一致的,不会在类型层面出现「字符串 vs CallId」的错配。在 stream() 里,你应当把提供方给出的调用 id 原样喂给 CallId(示例里是 'call-123'),而不要自己另造一个与提供方无关的随机值——否则回填工具结果时可能对不上号。
再看 name。它是工具名,示例里是 'bash'。这个字段在 delta 阶段和 block-end 阶段都会出现,适配器要保证两处一致。工具名通常来自模型输出,你必须原样透传,不要在适配器里做重命名或映射——工具名与宿主侧注册的工具表要对得上,改名会让工具解析不到。name 有时也可能分片到达(比如模型先吐了前几个字符),这时以 block-end 完整块里的 name 为准,delta 阶段能带上就带上,带不全也没关系,重要的是完整块准确。
最后是 arguments。它只在 block-end 的完整块里以「拼好的 JSON 文本」形式出现,示例里是 '{"command":"echo runoob"}'。注意它是字符串,不是对象——协议保留原始 JSON 文本,把解析权交给消费方。这样做的好处是适配器不参与 JSON 解析,也不必为各家提供方在参数上的细微格式差异(比如是否补全引号、是否允许尾随逗号)做归一化。消费方拿到完整文本后自行解析,解析失败也能清晰地归因到模型输出而不是适配器。
把三个字段的约束整理成清单:
- id:必须用
CallId(...)工厂生成;在 delta 与 block-end 中保持一致;内容应来自提供方的原始调用标识,不要自造。 - name:工具名字符串;delta 与 block-end 中保持一致;原样透传,不做重命名。
- arguments:完整 JSON 文本字符串,只在 block-end 的完整块里出现;与所有 argumentsDelta 累加结果一致。
下面给出工具调用块这三处字段的写法对照,直接可抄:
// delta 阶段:携带 id、name 与参数增量
const callId = CallId('call-123')
yield {
type: 'tool-call-delta',
index: 1,
id: callId,
name: 'bash',
argumentsDelta: '{"command":"echo runoob"}',
}
// block-end 阶段:给出完整块,arguments 是拼好的 JSON 文本
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: callId,
name: 'bash',
arguments: '{"command":"echo runoob"}',
},
}两个细节顺手强调。其一,同一个调用在 delta 与 block-end 里应当使用同一个 CallId 值。示例里两次都写 CallId('call-123'),语义上它们代表同一次调用。如果你在实现里从提供方拿到了 id,就把它存进一个局部变量(如上例的 callId),两处复用,避免手抖写出两个不同值。其二,一个生成里可能出现多个工具调用,此时每个调用应当拥有自己的 index 与自己的 id,各自跑一遍 start / delta / end。index 用来区分块,id 用来区分调用,两者不要混用:index 是流内的位置标识,id 是业务上的调用标识。
如果模型在同一次生成里发起了多个工具调用(常见于并行工具调用场景),你的适配器就按顺序为每个调用产出一组三态分片:第一个调用 block-start → tool-call-delta(可能多条)→ block-end,然后第二个调用再来一遍。注意它们各自有不同的 index,`finish` 的 reason 在多个调用场景下依然是 { kind: 'tool-calls' },因为结束原因是「要求执行工具」,与调用数量无关。
usage 与 finish 的收尾顺序:先报 token 用量,再声明结束原因
一条完整的序列,最后一定有两片:usage 和 finish。顺序是硬性的——先 usage 报告 token 用量,再 finish 声明结束原因,顺序不可颠倒。示例里 usage 是这样写的:
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
yield { type: 'finish', reason: { kind: 'stop' } }usage 里带两个数值字段:inputTokens 与 outputTokens,分别表示这次生成消耗的输入与输出 token 数。示例给的是 100 和 50。这两个数应当来自提供方返回的用量信息,能拿到就如实填,拿不到则按约定处理(不要凭空编造)。为什么要在 finish 之前发?因为消费方通常会在看到 finish 时做收尾动作——结算用量、更新账单、写日志、关闭流——它需要一个「所有数据都已到齐」的时机。把 usage 排在 finish 之前,消费方处理 finish 时已经握有完整的用量信息,一次就能把收尾做完。如果反过来先 finish 再 usage,消费方很可能在 finish 那一刻就结束了迭代,usage 永远读不到,统计就丢了。
finish 是最后一个分片,它的 reason.kind 声明这次生成为什么结束。示例里是 { kind: 'stop' },表示正常结束,模型自己把话说完了。另一种是 { kind: 'tool-calls' },表示模型请求执行工具——注意这通常意味着前面已经产出过至少一个工具调用块,消费方据此进入工具执行流程,把执行结果回填后再发起下一轮生成。可以这样理解:finish 的 reason 决定了 agent-loop 的下一步动作,stop 就结束本轮,tool-calls 就继续跑工具。适配器要做的,就是把提供方给出的结束原因正确映射到这两种 kind 上。
把收尾两片的字段与语义整理如下:
| 分片 | 字段 | 语义 | 位置约束 |
|---|---|---|---|
| usage | usage.inputTokens、usage.outputTokens | 本次生成的 token 用量 | 所有 block-end 之后、finish 之前 |
| finish | reason.kind | stop 为正常结束;tool-calls 为请求执行工具 | 整条序列的最后一片,仅此一次 |
还有几条收尾纪律要守住。第一,finish 必须有且只有一个。正常路径结束时 yield 一次,异常路径不要额外再 yield 一个 finish 试图「补救」,那会让消费方看到两个结束信号。异常应当以抛出异常的方式传递,而不是伪造一个 finish。第二,usage 也是收尾字段,不要放在块中间。有的实现为了「顺手」在第一个块结束后就把 usage 发了,这是错的,usage 描述的是整次生成的总用量,必须在所有块都结束之后才有意义。第三,即使这次生成没有任何内容块(比如模型直接结束、或只要求执行工具),usage 与 finish 依然要发。空响应也是一次合法生成,收尾两片不能省。
把整条序列的顺序用一句口诀记住:块先开、增量填、块后合;全收尾、报用量、再声明。对应的顺序就是 block-start → delta → block-end(可重复多块)→ usage → finish。适配器只要保证 yield 出去的对象严格符合这个节奏,agent-loop 就能稳定消费,无论背后接的是谁家的模型。到这里,接口怎么写(LlmAdapter 与 registerAdapter)和分片怎么产(StreamChunk 协议)两条主线都铺开了。下一部分我们把这套协议落到真实适配器的实现细节上,讨论 In this 传输错误该在哪一层捕获与转换、交错事件如何归位、以及把上面这套分片序列跑通之后,怎样用最小的测试用例验证你的适配器确实合规。
在上一段里,我们已经理清了 LlmAdapter 抽象类与 stream() 方法的职责边界:适配器负责把 Harness 的提供方无关请求翻译成具体厂商 API 调用,再把厂商响应翻译回 Harness 的分片。这一段落我们把镜头推进到协议细节与落地工程:StreamChunk 到底长什么样、分片顺序为什么不能乱、finish.reason 如何在「自然结束」与「请求执行工具」之间分叉,以及一个真实插件从 cordis.yml 到 registerAdapter 的完整生命周期。
finish.reason:stop 正常结束与 tool-calls 请求执行工具的分叉
finish 是一次流式生成的最后一个分片,它携带的 reason 字段决定了 agent-loop 接下来做什么。素材给出的取值语义非常明确:reason.kind 为 stop 时表示模型生成自然收尾,本轮对话可以进入下一轮用户输入;reason.kind 为 tool-calls 时表示模型要求 Harness 去执行工具,工具执行结果需要回流进上下文,再发起新一轮生成。这两条路径的差异不是「格式差异」,而是「控制流差异」。
把这条分叉讲得更工程一点:在 agent-loop 的视角里,它消费的是一个统一的异步流。它一边按 index 聚合内容块,一边盯着最后一个 finish 分片。如果 reason.kind === 'tool-calls',循环不能就此退出,而是要把已经收拢完毕的 tool-call 块取出来,交给工具执行层,拿到结果后再构造新的 options.messages,重新调用适配器的 stream()。所以适配器作者必须保证一件事:只要产生了工具调用块,finish 就必须报 tool-calls;没有工具调用块,才允许报 stop。这个一致性如果破坏,Harness 要么拿着空工具列表去执行,要么丢掉模型明确提出的工具请求——两种都是难以定位的运行时故障。
| finish.reason.kind | 语义 | Harness 的下一步动作 | 典型触发场景 |
|---|---|---|---|
| stop | 生成自然结束,对话轮次完成 | 把聚合后的文本块返回给上层,等待下一轮输入 | 普通问答、纯文本总结、最终答复已给出 |
| tool-calls | 请求 Harness 执行工具后再回流 | 取出 tool-call 块,执行工具,结果写回 messages 后重新生成 | 模型决定调用 bash、检索、写文件等外部能力 |
注意一个容易被忽略的细节:finish 必须是整个流的最后一个分片,在它之前必须先把 usage 发出去。素材在示例里明确标注了「报告 token 用量,必须在 finish 之前」。这条顺序约束的意义在于:agent-loop 只有先拿到 usage,才能在本轮生成结束时完成成本与用量记账;如果适配器把 finish 先发了、usage 后发,消费者可能在收到 finish 的瞬间就关闭聚合器并向上返回,usage 分片就会被丢弃。所以顺序不是风格问题,是协议契约。
再补一层判断经验:当模型在生成一段文本之后又发起了工具调用,正确做法是先正常关闭文本块(block-end 携带完整文本),再开启工具调用块。不要试图把文本 delta 和工具调用 delta 混在同一个 index 下,因为 blockType 在 block-start 时就已经声明,后续 delta 无法改变块的类型。素材的示例序列也印证了这一点:index 0 是 text,index 1 是 tool-call,两块各自走完 start / delta / end,最后才收尾。这个「块内自洽、块间有序」的模型,是整个 StreamChunk 协议的核心。
exampleChunks 完整分片序列逐片拆解:0 号文本块 + 1 号工具调用块
素材提供了一个 exampleChunks 官方示例,把一次生成的全部 chunk 按顺序产出。我们按 index 逐一走查,看清两块如何首尾衔接,以及最后 usage 与 finish 的落点。
第一片:开启文本块。 { type: 'block-start', index: 0, blockType: 'text' }。这里 index 是块编号,从 0 开始;blockType 声明这个块的类型是文本。消费者收到这一片,就知道接下来要初始化一个文本聚合器,并把它登记在 index 0 上。注意此时还没有任何文本内容,block-start 只负责「占位 + 声明类型」。
第二、三片:文本增量。 先是 { type: 'text-delta', index: 0, text: 'runoob' },再是 { type: 'text-delta', index: 0, text: ' 教程' }。素材特意说明「text-delta 可以拆成多个分片,增量拼接成完整文本」。这意味着适配器完全可以把一段回复切成任意多个 delta 往外吐,只要它们都指向同一个 index,消费者按顺序拼接就能还原。工程上的权衡是:分片太粗,流式体感变差;分片太细,分片数量和调度开销上升。常见做法是跟随上游 SSE 事件的粒度,上游给一段就转一段,不要自作主张缓冲成大块。
第四片:关闭文本块。 { type: 'block-end', index: 0, block: { type: 'text', text: 'runoob 教程' } }。关键点有两处:一是 index 必须与 block-start 一致,都是 0;二是 block 里携带的是完整块,也就是两个 delta 拼接后的最终文本。这就给消费者一个校验锚点——如果消费者自己拼接的结果与 block-end 携带的完整块不一致,说明分片在传输或转换环节出了问题。素材注释写得直接:「用完整块结束,index 与 block-start 一致」。
第五片:开启工具调用块。 { type: 'block-start', index: 1, blockType: 'tool-call' }。index 递增到 1,blockType 变成 tool-call。文本块与工具调用块是两类不同的内容块,各自走一遍 start / delta / end,所以这里是新的起点,而不是在 index 0 上叠加。
第六片:工具调用增量。 { type: 'tool-call-delta', index: 1, id: CallId('call-123'), name: 'bash', argumentsDelta: '{\"command\":\"echo runoob\"}' }。这里字段密度最高,逐个说:id 用 CallId 工厂生成,保证类型安全与统一标识;name 是工具名;argumentsDelta 是原始 JSON 文本的增量——注意素材的原话,它不是解析后的对象,而是字符串片段。上游很多厂商的流式工具调用就是把 JSON 参数按字符切片推过来的,适配器的职责是原样搬运这些字符片段,不要在这里做 JSON 解析或补全,否则极易在半截 JSON 上抛错。
第七片:关闭工具调用块。 { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{\"command\":\"echo runoob\"}' } }。index 依然是 1,block 里的 arguments 是「拼好的 JSON 文本」。到此为止,两个内容块全部闭合。
第八片:用量报告。 { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }。素材明确「必须在 finish 之前」。字段是 inputTokens 与 outputTokens,示例值分别是 100 和 50。
第九片:结束声明。 { type: 'finish', reason: { kind: 'stop' } }。素材注明这是「最后一个分片,声明结束原因」,并提示可替换为 { kind: 'tool-calls' } 来请求工具执行。因为本例里确实产生了 index 1 的工具调用块,所以一个更自洽的变体是把这一片写成 { kind: 'tool-calls' };示例保留 stop 只是为了演示字段位置。
下面是一段可直接对照运行的最小生成器,把上述九个分片原样产出,方便你在本地先跑通协议再对接真实厂商:
// 文件路径:examples/example-chunks.ts
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
export async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 1. 开启文本块,index 0
yield { type: 'block-start', index: 0, blockType: 'text' }
// 2. 文本增量,可拆成多个分片
yield { type: 'text-delta', index: 0, text: 'runoob' }
yield { type: 'text-delta', index: 0, text: ' 教程' }
// 3. 用完整块结束,index 与 block-start 一致
yield {
type: 'block-end',
index: 0,
block: { type: 'text', text: 'runoob 教程' },
}
// 4. 工具调用块,index 1
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield {
type: 'tool-call-delta',
index: 1,
id: CallId('call-123'),
name: 'bash',
argumentsDelta: '{\"command\":\"echo runoob\"}',
}
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: CallId('call-123'),
name: 'bash',
arguments: '{\"command\":\"echo runoob\"}',
},
}
// 5. token 用量,必须在 finish 之前
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
// 6. 结束原因;替换为 { kind: 'tool-calls' } 可请求执行工具
yield { type: 'finish', reason: { kind: 'stop' } }
}
跑通这段生成器后,你会对「块」的概念形成肌肉记忆:每个块是 start → delta* → end 的闭合单元,块与块之间靠 index 区分,整条流靠 usage + finish 收尾。后面所有厂商适配的难点,本质上都是把厂商的私有事件流映射成这几个闭合单元。
cordis.yml 配置与 Schemastery 校验:apiKey、providers 两个必填项
协议讲清楚之后,回到插件侧。一个适配器插件要被 Harness 加载,需要声明配置结构,并在加载时校验。cordis.yml 是配置载体,Schemastery 是校验器。素材给出的配置接口非常克制,只有两个字段,但两个都是必填。
// 文件路径:src/my-llm-adapter.ts(配置部分)
import Schema from '@deepseek-ai/schemastery'
export interface Config {
apiKey: string
providers: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
这里有一条硬性约定,素材原文表述为「同名的 Schemastery schema,加载时校验配置」:导出的 Config 接口与导出的 Config schema 必须同名、同结构。TypeScript 里接口和常量可以同名共存,前者提供编译期类型,后者提供运行期校验,二者缺一不可。接口负责让你在 apply(ctx, config) 里写 config.apiKey 时有类型提示;schema 负责在用户漏填或填错类型时,在插件加载阶段就报错,而不是等到第一次请求时抛一个晦涩的运行时异常。
两个字段的分工也值得说清。apiKey 是 Schema.string().required(),属于凭证,通常每个提供方一份;providers 是 Schema.array(Schema.string()).required(),是提供方路由名列表,决定这个适配器响应哪些路由。素材在示例里传入的是 ['my-provider'] 这样的数组——注意是数组,不是单个字符串,这为一次绑定多条路由留出了接口。
| 配置项 | Schemastery 写法 | 是否必填 | 类型 | 作用 |
|---|---|---|---|---|
| apiKey | Schema.string().required() | 是 | string | 访问具体提供方所需的凭证 |
| providers | Schema.array(Schema.string()).required() | 是 | string[] | 该适配器绑定的路由名列表 |
对应的 cordis.yml 片段形如:
# 文件路径:cordis.yml
plugins:
my-llm-adapter:
apiKey: sk-your-provider-key
providers:
- my-provider
- my-provider-backup
常见坑有三类。第一类:字段名拼写不一致。 接口里写 apiKey,schema 里写 api_key,TypeScript 不会报错(因为它是两个独立声明),但用户按 schema 填了 api_key,代码里读 apiKey 就是 undefined。规避办法是让接口与 schema 紧邻书写,并用一个显式赋值把二者绑定,例如 export const Config: Schema<Config> = ...,让泛型参数替你检查结构差异。
第二类:忘记 required。 少写 .required() 的字段在缺失时不会报错,而是静默变成 undefined。apiKey 缺失会让你在第一次请求时得到 401;providers 缺失则更隐蔽——registerAdapter(undefined, adapter) 可能不报错,但路由永远不会命中,表现为「插件加载成功但模型调不通」。所以素材强调两个字段都标记 required,是必要的防御。
第三类:providers 填了单字符串。 用户在 YAML 里写成 providers: my-provider 而不是列表,Schemastery 的 Schema.array() 会在加载阶段直接拒绝,这反而是好事——错误前移到了配置阶段。发布插件时在 README 里附一份最小 cordis.yml 示例,能省掉大量这类问题。
inject = ['llm'] 与 apply(ctx, config):依赖注入保证 ctx.llm 就绪
配置校验通过后,插件进入生命周期。素材给出的两个导出——inject 与 apply——构成了插件的执行入口。它们的配合关系是:inject 声明依赖,apply 在依赖就绪后执行。
export const inject = ['llm'] 的含义是:本插件依赖名为 llm 的服务。Harness(基于 Cordis 的依赖注入)在加载插件时,会先确认 ctx.llm 已经可用;未就绪就不会调用 apply。素材的原话是「声明依赖 llm 服务,保证 ctx.llm 已就绪」。这解决的是一个非常现实的时序问题:如果插件在 ctx.llm 尚未注册完成时就调用 ctx.llm.registerAdapter(...),会直接抛异常或静默失败——而这类失败往往在启动日志里只是一行不起眼的错误,排查成本很高。
进入 apply(ctx, config) 之后,逻辑只有两步:构造适配器实例,绑定路由。素材代码是:
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
// 把提供方路由列表绑定到这个适配器
ctx.llm.registerAdapter(config.providers, adapter)
}
逐点拆解。第一,new MyAdapter(config.apiKey) 把校验过的 apiKey 注入适配器实例,适配器内部持有它并在 stream() 中用于鉴权。第二,ctx.llm.registerAdapter(config.providers, adapter) 把「路由名数组 → 适配器实例」的映射写入 ctx.llm 注册表。素材在别处也说明了注册表的定位:它是中间层,维护 LlmAdapter 的抽象契约;顶层是 agent-loop,消费提供方无关的流式生成服务;底层是各个适配器,分别对接不同 API 格式。这一层三明治结构,正是 seam 图所描绘的接缝。
几个工程坑在这里集中出现。坑一:在 apply 之外注册。 有人把 registerAdapter 写到模块顶层作用域里,模块一被 import 就执行。此时 ctx 可能还不存在,或者 ctx.llm 尚未就绪。正确做法是只在 apply 里注册。
坑二:把 inject 写成依赖字符串之外的东西。 inject 必须是服务名数组,写错名字等于没声明依赖,时序问题照旧。
坑三:apply 里做重活。 apply 应该是轻量的装配逻辑——构造实例、注册路由。不要在 apply 里发起网络请求探测端点可用性、不要做大批量初始化,否则会拖慢启动,还可能因为一次探测失败导致整个插件加载失败。
坑四:多实例冲突。 如果同一个 providers 名被两个插件同时注册,后注册的行为取决于注册表实现。规模化部署时建议给每条路由一个唯一名字,在配置评审阶段就用脚本查重。
适配器骨架落地:src/my-llm-adapter.ts 的文件结构与导出约定
把前面所有片段拼起来,就是一个最小可运行骨架。素材给的文件路径是 src/my-llm-adapter.ts,导出约定包含 name、Config、inject、apply 四件套。完整结构如下:
// 文件路径:src/my-llm-adapter.ts
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
// 适配器:继承抽象类,实现 stream()
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
// stream() 返回异步生成器,逐片产出 StreamChunk
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
// 1. 把 options.messages 转换成提供方格式
// 2. 调用流式 API
// 3. 把响应转换成 StreamChunk
}
}
// 插件配置:apiKey 与 providers 都必填
export interface Config {
apiKey: string
providers: string[]
}
// 同名的 Schemastery schema,加载时校验配置
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
// 声明依赖 llm 服务,保证 ctx.llm 已就绪
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
// 把提供方路由列表绑定到这个适配器
ctx.llm.registerAdapter(config.providers, adapter)
}
四个导出的分工可以这样记忆。name 是插件标识,用于日志、配置段键名与错误归属,必须与 cordis.yml 里的键一致。Config 是双形态导出,接口给类型、schema 给校验,二者同名同构。inject 是依赖声明,当前只需要 ['llm']。apply 是装配函数,接收已就绪的 ctx 与校验过的 config。
stream() 的签名是 async *stream(options: GenerateOptions): AsyncIterable<StreamChunk>。这里有三层信息:async generator 说明它是异步生成器,天然支持 yield 逐片产出、for await...of 逐片消费;参数 GenerateOptions 承载 Harness 的提供方无关请求,其中最重要的就是 options.messages;返回类型 AsyncIterable<StreamChunk> 锁定输出必须是 StreamChunk 序列。素材的三步注释——转换消息格式、调用流式 API、把响应转成 StreamChunk——正是所有适配器的通用三段式。
| 导出名 | 形态 | 必填 | 职责 | 写错的后果 |
|---|---|---|---|---|
| name | 字符串常量 | 是 | 插件标识,与 cordis.yml 段键对应 | 插件无法被正确加载或日志归属混乱 |
| Config | interface + Schema 同名导出 | 是 | 编译期类型 + 运行期校验 | 配置错误无法在加载阶段暴露 |
| inject | 字符串数组 | 是(依赖 llm 时) | 声明依赖,保证 ctx.llm 就绪 | 注册时机过早,registerAdapter 失败 |
| apply | 函数 | 是 | 构造适配器并绑定路由 | 适配器不生效,路由永远 404 |
还有一个实践建议:把适配器类 MyAdapter 保持模块私有(不导出),只导出四件套。这样外部只能通过路由名调用能力,无法直接 new MyAdapter() 绕过配置校验。同理,构造函数的 apiKey 参数不要有默认值,强制调用方显式传入,避免出现「配置缺失但用默认空串跑起来」的情况。
2026 年 9 月实践:多提供方路由与 StreamChunk 顺序校验的工程化落地
当适配器从「一个」变成「一批」,工程重心就从「能跑通」转向「可回归、可观测、可扩展」。以下是 2026 年 9 月前后多提供方场景下的主流落地做法,全部围绕素材给出的机制展开。
第一,用 providers 数组一次绑定多条路由。 素材的注册签名 ctx.llm.registerAdapter(config.providers, adapter) 本身就接受数组,所以「同一 API 格式、多个路由名」的场景不需要注册多次。典型用法是主备两个路由名指向同一个适配器实例,配合上层路由策略做切换;或者把某个厂商的多个模型族群拆成多个路由名,但在适配器内部按路由名选择不同的请求参数。这样做的收益是明显的:适配器实例只有一份,连接池、凭证、重试策略都复用。
第二,对分片序列做顺序断言。 素材用 exampleChunks 展示了严格顺序,规模化后就应该把这种顺序变成可执行的断言,而不是靠人肉 review。推荐两类断言:结构断言——每个 block-start 必须有配对的 block-end 且 index 相同;状态机断言——delta 只能出现在已开启且未关闭的块内,不允许出现「没有 block-start 的 delta」或「block-end 之后还有 delta」。第三类是收尾断言——流的最后两片必须是 usage 和 finish,且 usage 在 finish 之前。
下面这段校验器可以直接接进 CI,把适配器产出的分片流丢进去检查:
// 文件路径:test/assert-chunk-sequence.ts
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
export function assertChunkSequence(chunks: StreamChunk[]): void {
const open = new Map<number, string>()
chunks.forEach((c, i) => {
if (c.type === 'block-start') {
if (open.has(c.index)) {
throw new Error(`第 ${i} 片:index ${c.index} 重复 block-start`)
}
open.set(c.index, c.blockType)
return
}
if (c.type === 'text-delta' || c.type === 'tool-call-delta') {
if (!open.has(c.index)) {
throw new Error(`第 ${i} 片:index ${c.index} 的 delta 落在未开启的块内`)
}
return
}
if (c.type === 'block-end') {
if (!open.has(c.index)) {
throw new Error(`第 ${i} 片:index ${c.index} 缺少配对的 block-start`)
}
open.delete(c.index)
return
}
if (c.type === 'usage') {
if (open.size > 0) {
throw new Error(`第 ${i} 片:usage 之前仍有未闭合的块`)
}
return
}
if (c.type === 'finish') {
const last = chunks[i - 1]
if (!last || last.type !== 'usage') {
throw new Error('finish 之前必须是 usage')
}
if (i !== chunks.length - 1) {
throw new Error('finish 必须是最后一个分片')
}
}
})
if (open.size > 0) {
throw new Error(`仍有未闭合的块:${[...open.keys()].join(', ')}`)
}
}
第三,把 usage 与 finish 纳入回归用例。 很多团队的回归用例只断言「有文本产出」,漏掉了 usage 与 finish。一旦某次改动让适配器在异常路径上提前 return,文本照样出现,但 usage 和 finish 缺失,工具调用场景就会静默失效。把「最后一个分片是 finish 且前一个是 usage」写成断言,这类问题就能在 CI 阶段拦下。
第四,工具调用与 finish 语义的联动校验。 回归用例里应加一条:如果分片序列中出现过 tool-call 块,那么 finish 的 reason.kind 必须是 tool-calls;如果没有任何 tool-call 块,则应为 stop。这条规则把「模型意图」与「协议声明」绑定起来,避免 agent-loop 拿到自相矛盾的流。
第五,为多提供方建立统一的录制回放机制。 每个适配器至少录一条真实分片序列作为 fixture,回放时跑同一套顺序断言。这样新增提供方时,只要 fixture 能通过断言,协议兼容性就有了第一层保障;线上出问题时,也能用同样断言快速判定是适配器转换错了,还是上游本身返回了异常序列。
流式传输出错时的排查清单:分片顺序、index 对齐与收尾缺失
线上出现「模型没响应」「工具不执行」「回复被截断」时,绝大多数根因都落在分片协议上。下面这份排查清单按「由外向内、由粗到细」排列,照着走能快速收敛。
- 先确认流有没有结束。 看是否收到了 finish 分片。没有 finish,说明适配器可能在上游连接中断时直接抛异常或提前 return,而消费者还在等。此时应该检查异常路径是否统一补发「usage + finish」或至少发出一个可识别的结束信号。
- 检查 usage 是否在 finish 之前。 这是最常见的顺序错误。如果 usage 缺失,成本记账会丢;如果 usage 在 finish 之后,消费者可能已经关闭聚合器而丢弃它。修复方式是在适配器的收尾逻辑里固定顺序:先 yield usage,再 yield finish。
- 核对 block-start 与 block-end 的 index 是否配对。 每个开启的块都必须有同 index 的闭合。常见 bug 是复制粘贴时 index 写死成 0,导致多块场景下 index 1 的块永远关不掉,或者两个块互相串扰。
- 检查 delta 是否落在正确块内。 text-delta 的 index 必须指向一个已开启的 text 块,tool-call-delta 的 index 必须指向一个已开启的 tool-call 块。类型不匹配(往 text 块里发 tool-call-delta)在宽松实现里可能不报错,但消费端聚合结果必然是错的。
- 核对 blockType 与后续 delta 类型的一致性。 block-start 声明了
blockType,后续 delta 就必须是同类型。想同时产出文本和工具调用,正确做法是开两个块,而不是在一个块里混发。 - 检查 block-end 携带的完整块是否与 delta 拼接结果一致。 这是最后一道自检。如果消费者按 delta 拼出的文本与 block-end 的
block不一致,说明适配器在某一侧做了额外加工(比如对 delta 做了 trim,或对完整块做了补全)。 - 检查工具调用的 argumentsDelta 是否被二次解析。 很多适配器作者会在接收到第一个 argumentsDelta 时就尝试
JSON.parse,结果在半截 JSON 上抛错并中断流。正确做法是原样透传字符片段,只在 block-end 时给出拼好的完整 JSON 文本。 - 检查 finish.reason 与工具调用块是否自洽。 有 tool-call 块却是
stop,Harness 就不会去执行工具,表现为「模型说要调工具但没动作」;没有 tool-call 块却是tool-calls,Harness 会拿到空工具列表,可能报错或空转。 - 检查 inject 与注册时机。 如果日志里出现「适配器未注册」「路由未命中」,回头确认
inject = ['llm']是否声明,registerAdapter是否在apply内调用。这类问题的表现是「整个流根本没开始」,与分片顺序问题容易混淆。 - 检查 providers 路由名是否与调用侧一致。 配置里绑定了
my-provider,调用侧写的是my-provider-2,就会得到路由找不到的错误。规模化部署时,建议在启动日志里打印已注册的路由清单。
为了把这份清单变成可复用的诊断工具,可以在适配器外面包一层调试代理,把每次产出的分片序列打印出来并跑一遍断言:
// 文件路径:scripts/trace-adapter.ts
import { assertChunkSequence } from '../test/assert-chunk-sequence'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
export async function traceStream(
label: string,
stream: AsyncIterable<StreamChunk>,
): Promise<StreamChunk[]> {
const seen: StreamChunk[] = []
for await (const chunk of stream) {
seen.push(chunk)
console.log(`[${label}] #${seen.length - 1}`, JSON.stringify(chunk))
}
assertChunkSequence(seen)
console.log(`[${label}] 分片总数 ${seen.length},顺序校验通过`)
return seen
}
// 用法:把适配器产出的流包进来即可
// const chunks = await traceStream('my-provider', adapter.stream(options))
运行方式很简单,在你的集成测试或本地调试脚本里调用即可:
# 运行分片顺序回归
npx tsx scripts/trace-adapter.ts
这份清单的价值在于顺序不可跳过:先看收尾,再看配对,再看块内,最后看注册。很多人一上来就 diff 具体文本内容,反而绕了远路——协议层的问题,永远先用协议层的断言定位。
总结与最佳实践
把两段内容压缩成一份可执行清单,按「写代码前、写代码时、上线后」三段组织:
- 适配器职责单一化:
LlmAdapter只做双向翻译——把options.messages转成提供方格式,把提供方响应转成StreamChunk。不要在适配器里掺业务逻辑、重试编排或上下文裁剪,这些属于上层。 - 块模型牢记于心:每个内容块是
block-start → delta* → block-end的闭合单元;文本块与工具调用块各自走一遍,靠 index 区分;block-end 必须携带与 delta 拼接结果一致的完整块。 - 收尾顺序不可颠倒:先
usage(含 inputTokens / outputTokens),后finish;finish 必须是最后一个分片。 - finish.reason 与工具调用严格自洽:产生过 tool-call 块就用
{ kind: 'tool-calls' }请求 Harness 执行工具并回流;否则用{ kind: 'stop' }。 - argumentsDelta 原样透传:它是原始 JSON 文本的增量,禁止在分片阶段做 JSON 解析或补全,拼接与解析交给消费端在 block-end 之后完成。
- 配置双导出同名同构:
Config接口给类型,Configschema 给校验,apiKey与providers都用.required()标记,让配置错误在加载阶段暴露。 - 依赖声明不能省:
export const inject = ['llm']保证ctx.llm就绪,registerAdapter只在apply(ctx, config)内调用,杜绝注册时机过早。 - 四件套导出约定:
name、Config、inject、apply齐备;适配器类保持模块私有,构造函数强制显式传入 apiKey。 - 多路由一次绑定:直接利用
registerAdapter(config.providers, adapter)的数组能力,让主备路由或同格式多路由共享一个适配器实例。 - 把协议顺序写成断言:在 CI 里校验 block-start / block-end 的 index 配对、delta 落在正确块内、usage 与 finish 的落点,并额外断言 tool-call 块与 finish.reason 的联动关系。
- 用 fixture 做多提供方回归:每个适配器录一条真实分片序列作为回放样本,新增提供方时先过断言再上线。
- 排查按顺序走:先看 finish 与 usage 是否发出、顺序是否正确,再查 index 配对,再查 delta 归属,最后查 inject 与 providers 路由注册。
做到以上十二条,你就拥有了一个既能对接任意厂商、又能被 Harness 稳定消费的 LLM 适配器:agent-loop 只面对统一的 StreamChunk 协议,ctx.llm 注册表维护抽象契约,底层适配器各管各的 API 格式——这正是 seam 图所描绘的分层价值。