当你要把自研模型、第三方厂商端点或者本地推理服务接进 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 一无所知。

要把这个类写出来,导入路径必须先理清楚。抽象类本身、以及它在方法签名里用到的两个类型 GenerateOptionsStreamChunk,全部来自同一个包:@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 它),而 GenerateOptionsStreamChunk 是类型导入,用 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。

示意图
LLM 适配器的 seam 结构:顶层 agent-loop 消费提供方无关的流式生成服务,中层 ctx.llm 注册表维护 LlmAdapter 抽象契约,底层各适配器分别对接不同 API 格式。

把层级关系画出来看会更清楚:顶层是 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() 内部。官方骨架里留了三行注释,恰好勾勒出适配器的完整工作流,也划定了它唯一的职责边界:

  1. Convert options.messages to the provider format.——把 Harness 统一格式的消息,转换成提供方要求的请求体。
  2. Call the streaming API.——带着你的 apiKey 去调用提供方的流式接口。
  3. 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 属于哪个块」。

一条完整序列的收尾还有两个特殊分片:usagefinish。所有内容块都结束后,先发 usage 报告 token 用量,再发 finish 声明结束原因。finish 是最后一个分片,它的 reason 字段里 kind 为 stop 表示正常结束,为 tool-calls 表示模型请求执行工具。这两片的顺序不能颠倒,理由下一节和最后一节都会再强化。

示意图
一次完整生成的 StreamChunk 分片序列:文本块走 block-start → text-delta × 2 → block-end,工具调用块走 block-start → tool-call-delta → block-end,最后以 usage 与 finish 收尾。

对照配图自上而下看一遍,序列的节奏就一目了然:先是一个文本块(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-starttype, index, blockType开启一个内容块每个块恰好一次
text-deltatype, index, text文本块的增量内容每个文本块 1 次或多次
tool-call-deltatype, index, id, name, argumentsDelta工具调用块的增量参数(原始 JSON 文本)每个工具调用块 1 次或多次
block-endtype, index, block结束一个内容块,并给出完整块每个块恰好一次
usagetype, usage(inputTokens / outputTokens)汇报 token 用量收尾时一次
finishtype, reason(kind)声明结束原因,序列终结最后一次,且仅一次

三条铁律值得反复默念:其一,每个块必须以 block-start 起、以 block-end 止,中间至少有一个 delta。永远不要只发 start 不发 end,那会让消费方一直等一个不会到来的收尾,在很多实现里直接表现为请求悬挂。其二,index 在同一块内严格一致,不同块之间不重复。实践中简单递增即可,0、1、2……其三,usage 和 finish 必须出现在所有块的 block-end 之后。它们是全局收尾,不是某个块的收尾,混在块中间会让语义彻底混乱。

文本块与工具调用块:两类 blockType 各自的 start / delta / end 走法

StreamChunk 里有两类内容块,由 cubeType(也就是 blockType 字段)区分:texttool-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-deltatool-call-delta
增量携带字段text(文本片段)id、name、argumentsDelta(原始 JSON 文本增量)
完整块字段type、texttype、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.texttool-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 用量,再声明结束原因

一条完整的序列,最后一定有两片:usagefinish。顺序是硬性的——先 usage 报告 token 用量,再 finish 声明结束原因,顺序不可颠倒。示例里 usage 是这样写的:

yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
yield { type: 'finish', reason: { kind: 'stop' } }

usage 里带两个数值字段:inputTokensoutputTokens,分别表示这次生成消耗的输入与输出 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 上。

把收尾两片的字段与语义整理如下:

分片字段语义位置约束
usageusage.inputTokens、usage.outputTokens本次生成的 token 用量所有 block-end 之后、finish 之前
finishreason.kindstop 为正常结束;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.ymlregisterAdapter 的完整生命周期。

finish.reason:stop 正常结束与 tool-calls 请求执行工具的分叉

finish 是一次流式生成的最后一个分片,它携带的 reason 字段决定了 agent-loop 接下来做什么。素材给出的取值语义非常明确:reason.kindstop 时表示模型生成自然收尾,本轮对话可以进入下一轮用户输入;reason.kindtool-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\"}' }。这里字段密度最高,逐个说:idCallId 工厂生成,保证类型安全与统一标识;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 之前」。字段是 inputTokensoutputTokens,示例值分别是 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 负责在用户漏填或填错类型时,在插件加载阶段就报错,而不是等到第一次请求时抛一个晦涩的运行时异常。

两个字段的分工也值得说清。apiKeySchema.string().required(),属于凭证,通常每个提供方一份;providersSchema.array(Schema.string()).required(),是提供方路由名列表,决定这个适配器响应哪些路由。素材在示例里传入的是 ['my-provider'] 这样的数组——注意是数组,不是单个字符串,这为一次绑定多条路由留出了接口。

配置项Schemastery 写法是否必填类型作用
apiKeySchema.string().required()string访问具体提供方所需的凭证
providersSchema.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 就绪

配置校验通过后,插件进入生命周期。素材给出的两个导出——injectapply——构成了插件的执行入口。它们的配合关系是: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,导出约定包含 nameConfiginjectapply 四件套。完整结构如下:

// 文件路径: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 段键对应插件无法被正确加载或日志归属混乱
Configinterface + 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 对齐与收尾缺失

线上出现「模型没响应」「工具不执行」「回复被截断」时,绝大多数根因都落在分片协议上。下面这份排查清单按「由外向内、由粗到细」排列,照着走能快速收敛。

  1. 先确认流有没有结束。 看是否收到了 finish 分片。没有 finish,说明适配器可能在上游连接中断时直接抛异常或提前 return,而消费者还在等。此时应该检查异常路径是否统一补发「usage + finish」或至少发出一个可识别的结束信号。
  2. 检查 usage 是否在 finish 之前。 这是最常见的顺序错误。如果 usage 缺失,成本记账会丢;如果 usage 在 finish 之后,消费者可能已经关闭聚合器而丢弃它。修复方式是在适配器的收尾逻辑里固定顺序:先 yield usage,再 yield finish。
  3. 核对 block-start 与 block-end 的 index 是否配对。 每个开启的块都必须有同 index 的闭合。常见 bug 是复制粘贴时 index 写死成 0,导致多块场景下 index 1 的块永远关不掉,或者两个块互相串扰。
  4. 检查 delta 是否落在正确块内。 text-delta 的 index 必须指向一个已开启的 text 块,tool-call-delta 的 index 必须指向一个已开启的 tool-call 块。类型不匹配(往 text 块里发 tool-call-delta)在宽松实现里可能不报错,但消费端聚合结果必然是错的。
  5. 核对 blockType 与后续 delta 类型的一致性。 block-start 声明了 blockType,后续 delta 就必须是同类型。想同时产出文本和工具调用,正确做法是开两个块,而不是在一个块里混发。
  6. 检查 block-end 携带的完整块是否与 delta 拼接结果一致。 这是最后一道自检。如果消费者按 delta 拼出的文本与 block-end 的 block 不一致,说明适配器在某一侧做了额外加工(比如对 delta 做了 trim,或对完整块做了补全)。
  7. 检查工具调用的 argumentsDelta 是否被二次解析。 很多适配器作者会在接收到第一个 argumentsDelta 时就尝试 JSON.parse,结果在半截 JSON 上抛错并中断流。正确做法是原样透传字符片段,只在 block-end 时给出拼好的完整 JSON 文本。
  8. 检查 finish.reason 与工具调用块是否自洽。 有 tool-call 块却是 stop,Harness 就不会去执行工具,表现为「模型说要调工具但没动作」;没有 tool-call 块却是 tool-calls,Harness 会拿到空工具列表,可能报错或空转。
  9. 检查 inject 与注册时机。 如果日志里出现「适配器未注册」「路由未命中」,回头确认 inject = ['llm'] 是否声明,registerAdapter 是否在 apply 内调用。这类问题的表现是「整个流根本没开始」,与分片顺序问题容易混淆。
  10. 检查 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 接口给类型,Config schema 给校验,apiKeyproviders 都用 .required() 标记,让配置错误在加载阶段暴露。
  • 依赖声明不能省export const inject = ['llm'] 保证 ctx.llm 就绪,registerAdapter 只在 apply(ctx, config) 内调用,杜绝注册时机过早。
  • 四件套导出约定nameConfiginjectapply 齐备;适配器类保持模块私有,构造函数强制显式传入 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 图所描绘的分层价值。