适配器的 stream() 到底往外出什么样的数据?

答案是 StreamChunk,一种有严格顺序的分片协议。

这篇讲分片序列、关键规则,以及传输出错时怎么处理。


StreamChunk 协议

StreamChunk 是 Harness 与适配器之间的流式协议。

一个内容块先用 block-start 开始,中间用 delta 增量传输,最后用 block-end 结束。

文本与工具调用是两类不同的内容块,各自走一遍 start / delta / end。

所有分片收尾时,先发 usage 报告 token 用量,再发 finish 声明结束原因。

StreamChunk 分片序列图

上图自上而下是一次完整的分片序列。

先是一个文本块:block-start → text-delta × 2 → block-end。

再是一个工具调用块:block-start → tool-call-delta → block-end。

最后是 usage 与 finish。

finish 的 reason 为 stop 表示正常结束,为 tool-calls 表示请求执行工具。


完整分片序列

官方文档给了 exampleChunks,把一次生成的全部 chunk 按顺序产出。

text-delta 可以拆成多个分片,增量拼接成完整文本。

tool-call-delta 的 argumentsDelta 则是原始 JSON 文本的增量。

实例

// 文件路径:示例代码,演示一次完整的 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.
}

CallId 是协议自带的工厂函数,用来生成工具调用 id。

argumentsDelta 可以在一个分片里完整生成,也可以分多个分片增量生成。


关键规则

协议有五条硬性规则,违反任何一条都会让消费方解析出错。

规则 说明
block-start 与 block-end 成对 每个 block-start 都必须有与之对应的 block-end
index 从 0 开始递增 用于标识内容块的顺序
argumentsDelta 是原始 JSON 增量 可以一个分片完整生成,也可以分多个分片生成
finish 必须是最后一个分片 之后不能再有任何分片
usage 必须在 finish 之前 先报告 token 用量,再声明结束

text-delta 与 tool-call-delta 都要带上所属块的 index,内容块之间不要交叉。


错误处理:用 LlmError 表达失败

适配器应通过带稳定 code 的 LlmError 抛出传输和协议故障。

agent-loop 会保留该错误及其 code,用于诊断和策略处理。

不要依赖普通 Error 被自动转换。

稳定的 code 让上层可以精确匹配错误类型,而不是解析错误字符串。

每个提供方 HTTP 请求还必须合并 attributionHeaders()。

同时要把 options.signal 传给 fetch,让取消和资源释放过程完全停稳。

实例

// 文件路径:示例代码,一个带错误处理的 HttpAdapter 骨架
import {
  attributionHeaders,
  LlmAdapter,
  LlmError,
  type GenerateOptions,
  type StreamChunk,
} from ‘@deepseek-ai/dsh-llm’

class HttpAdapter extends LlmAdapter {
  // 构造函数注入端点地址
  constructor(private readonly endpoint: string) {
    super()
  }

  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    // 发起 HTTP 请求,合并归属头,传递中止信号
    const response = await fetch(this.endpoint, {
      method: ‘POST’,
      headers: {
        ‘content-type’: ‘application/json’,
        …attributionHeaders(),
      },
      body: JSON.stringify({ model: options.model, messages: options.messages }),
      // 调用方要求取消时,fetch 会立刻中止
      …options.signal ? { signal: options.signal } : {},
    })
    if (!response.ok) {
      // 用带稳定 code 的 LlmError 表达传输失败
      throw new LlmError(`Provider API error: ${response.status}`, ‘PROVIDER_HTTP_ERROR’)
    }
    // A real adapter parses the response and emits the complete chunk sequence.
    // 真实适配器在这里解析响应体,产出完整的分片序列
    yield { type: ‘finish’, reason: { kind: ‘stop’ } }
  }
}

attributionHeaders() 把归属信息合并进请求头。

options.signal 存在时作为 fetch 的 signal 传入,取消请求时立即停止。

response.ok 为 false 时抛出 LlmError,code 是 PROVIDER_HTTP_ERROR。

骨架只 yield 一个 finish,真实适配器会在这里解析响应体。

LlmError 的第一个参数是消息,第二个参数是稳定 code。

上层按 code 做策略判断,因此 code 一旦发布就不要改动。


不能静默丢弃的字段

GenerateOptions 里若有适配器无法支持的字段,同样要抛 LlmError,不要静默丢弃。

推理元数据包含有序的不透明 ID、展示名称和可选的配置默认值。

请保留适配器给出的权威可选列表,包括其上游能力 API 返回的 off。

不要把可选推理强度提升为核心枚举,否则适配器会失去上游的灵活性。

服务会校验聚合结果,并在调用 stream() 前拒绝显式指定但不受支持的推理强度。

省略 reasoning 表示该模型没有可选的推理强度能力。


小结与自测

一句话总结:StreamChunk 协议用成对的块描述内容,用 usage 与 finish 收尾;失败时用带稳定 code 的 LlmError 表达。

自测题一:一个文本块最少需要哪几个分片?

自测题二:usage 与 finish 的先后顺序是什么?

自测题三:请求失败时,为什么用带 code 的 LlmError 而不是普通 Error?