StreamChunk 是 Harness 与适配器之间的流式协议,一种有严格顺序的分片协议。一个内容块先用 block-start 开始,中间用 delta 增量传输,最后用 block-end 结束。文本与工具调用是两类不同的内容块,各自走一遍 start / delta / end。所有分片收尾时,先发 usage 报告 token 用量,再发 finish 声明结束原因。
完整分片序列
1// 示例代码,演示一次完整的 chunk 序列
2import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
3
4async function* exampleChunks(): AsyncIterable<StreamChunk> {
5 // 1. 开启一个文本块,index 为 0
6 yield { type: 'block-start', index: 0, blockType: 'text' }
7
8 // 2. 文本增量,可拆成多个分片
9 yield { type: 'text-delta', index: 0, text: 'runoob' }
10 yield { type: 'text-delta', index: 0, text: ' 教程' }
11
12 // 3. 用完整块结束,index 与 block-start 一致
13 yield {
14 type: 'block-end',
15 index: 0,
16 block: { type: 'text', text: 'runoob 教程' },
17 }
18
19 // 4. 工具调用块,index 为 1
20 yield { type: 'block-start', index: 1, blockType: 'tool-call' }
21 yield {
22 type: 'tool-call-delta',
23 index: 1,
24 id: CallId('call-123'),
25 name: 'bash',
26 argumentsDelta: '{"command":"echo runoob"}',
27 }
28 yield {
29 type: 'block-end',
30 index: 1,
31 block: {
32 type: 'tool-call',
33 id: CallId('call-123'),
34 name: 'bash',
35 arguments: '{"command":"echo runoob"}',
36 },
37 }
38
39 // 5. 报告 token 用量,必须在 finish 之前
40 yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
41
42 // 6. 最后一个分片,声明结束原因
43 yield { type: 'finish', reason: { kind: 'stop' } }
44 // 也可以是 { kind: 'tool-calls' } 请求执行工具
45}
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 被自动转换。
1// 一个带错误处理的 HttpAdapter 骨架
2import {
3 attributionHeaders,
4 LlmAdapter,
5 LlmError,
6 type GenerateOptions,
7 type StreamChunk,
8} from '@deepseek-ai/dsh-llm'
9
10class HttpAdapter extends LlmAdapter {
11 constructor(private readonly endpoint: string) {
12 super()
13 }
14
15 async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
16 // 发起 HTTP 请求,合并归属头,传递中止信号
17 const response = await fetch(this.endpoint, {
18 method: 'POST',
19 headers: {
20 'content-type': 'application/json',
21 ...attributionHeaders(),
22 },
23 body: JSON.stringify({ model: options.model, messages: options.messages }),
24 // 调用方要求取消时,fetch 会立刻中止
25 ...options.signal ? { signal: options.signal } : {},
26 })
27 if (!response.ok) {
28 // 用带稳定 code 的 LlmError 表达传输失败
29 throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
30 }
31 // 真实适配器在这里解析响应体,产出完整的分片序列
32 yield { type: 'finish', reason: { kind: 'stop' } }
33 }
34}
attributionHeaders()把归属信息合并进请求头。options.signal存在时作为 fetch 的 signal 传入,取消请求时立即停止。response.ok为 false 时抛出LlmError,code 是PROVIDER_HTTP_ERROR。
LlmError 的第二个参数是稳定 code。上层按 code 做策略判断,因此 code 一旦发布就不要改动。
不能静默丢弃的字段
GenerateOptions 里若有适配器无法支持的字段,同样要抛 LlmError,不要静默丢弃。保留适配器给出的权威可选列表(包括上游能力 API 返回的 off),不要把可选推理强度提升为核心枚举,否则适配器会失去上游的灵活性。
要点
- 文本块最少需要三个分片:
block-start+text-delta+block-end(text-delta可多个)。 - 顺序:
usage必须在finish之前,finish必须是最后一个分片。 - 请求失败时用带稳定 code 的
LlmError(而非普通 Error),这样上层能精确匹配错误类型做策略判断。