
字节笔记本
2026年10月6日 · 约 9 分钟读完
DeepSeek Harness 的 LLM 流式类型设计
DeepSeek 开源的 agent 框架 DeepSeek Harness(MIT 协议)把与模型打交道的整条链路收敛进一个独立的 llm 包:消息怎么表示、流式分片长什么样、失败如何归一、重试策略何时冻结,全部有成文的类型契约,核心定义集中在 packages/llm/llm/src/types.ts。官方文档为这套契约单独维护了一页流式参考,官方 Cookbook 里另有一篇动手向的适配器指南,而这一页参考回答的是更根本的问题:一个 LLM 流式层在类型层面应该长什么样。本文把它逐节拆开。

消息与内容块:五种块的封闭清单
会话由 Message 组成,一条消息的核心是一组带类型的内容块。块的种类来自一张可合并扩展的映射表,核心清单五种:text 是可见文本;reasoning 是思考内容,与可见文本明确区分;image 是持久图片附件;tool-call 带调用 ID、工具名和原始 JSON 参数;tool-result 是工具结果,可嵌套内容块并带错误标记。新模态想进这张表,前提是适配器、界面、上下文压缩与持久回放四条路径都真正认它,而不是先加个类型再说。
Message 本身是不可变值:稳定 id、提供方中立的角色(system、user、assistant 之一)、与模型视角完全一致的内容块数组,外加必填的来源信息。模型产出的 assistant 消息还记录生成它的提供方与模型,并可选携带一段适配器私有的重放状态。
比较有意思的是来源的双轴设计。消息从哪来(kind:user、plugin、model、tool)与它是什么信息(form:instructions、catalog、snapshot、notice、relay、recall)是两个相互独立的轴:几个生产者可以共享同一种形式,一个生产者在一次会话里也能先后给出多种形式。这套词表是语义性的,刻意不含任何视觉约定,颜色、图标、折叠策略都归消费方管;遇到缺失或没见过的取值,按文档默认值当作不透明内容呈现,协议因此可以一值一值地向前生长而不会破坏老消费方。
StreamChunk:用编译器锁死的流式协议
适配器产出的原始流是一串带类型的分片,节选如下:
type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType }
| { type: 'text-delta'; index: number; text: string }
| { type: 'tool-call-delta'; index: number; argumentsDelta: string }
| { type: 'block-end'; index: number; block: ContentBlock }
| { type: 'usage'; usage: TokenUsage }
| { type: 'finish'; reason: FinishReason; replayState?: unknown }完整联合还包含 reasoning-delta 等变体。要点有二。其一,这是个封闭的可辨识联合,文档要求消费方的 switch 以 assertNever 收尾,于是新增一种分片,所有必须处理它的消费方都会在编译期报错:协议演进不靠注释靠编译器。其二,一条流里文本、思考与多个工具调用是交错的,每个分片靠 index 挂回所属块;block-end 还直接携带装配完成的整块,消费方不必自己把 delta 重拼一遍。
BlockAssembler:折叠逻辑收成一个共享实现
把分片流折叠回内容块、用量与结束原因,由共享的 BlockAssembler 完成:push 进分片,流结束后读取 blocks()、usage、finish 与重放状态,也能直接产出冻结的 assistant 消息。它对畸形流是防御性的:不含 block-start 与 block-end 的纯 delta 协议同样能工作;某个 index 已被 block-end 关闭后又来 delta,会按畸形流忽略,行为不端的适配器既撑不大内存,也污染不了已完成的块。max-token 截断时会丢弃无法安全执行的工具调用;未知块类型若始终没有 block-end 收口,则直接抛错而不是带病输出。
适配器契约:参考页上的九条硬义务
适配器指南列过七条动手规矩,这页参考给的是完整的九条契约,多出的几条最见功力:
- usage 必须在 finish 之前发出,finish 之后不发任何内容;
- 工具调用参数全程保持原始 JSON 字符串,流式片段走 argumentsDelta;
- 失败只有两条合法路径:从 stream() 抛出(传输与协议故障),或以 error、aborted 的 finish 带内收尾(提供方带内故障),二者共用同一个可序列化的 LlmFailure;
- 一次适配器调用就是一次提供方尝试,适配器必须关掉依赖库自带的重试,恢复动作由 agent 层开启新的编号轮次;
- 提供方停滞在传输层设界:在营的两个远程适配器都暴露有限正值的 streamIdleTimeoutMs,默认五分钟,看门狗只在迭代器 next() 悬空期间武装,更早发生的调用方取消仍保持 ABORTED;
- 上下文溢出只有规范码 CONTEXT_WINDOW_EXCEEDED,两个 DeepSeek 适配器统一归类,消费方按码路由,绝不解析提供方文本;
- 空补全是可重试错误而非静默成功,规范码 EMPTY_RESPONSE,重试组件默认重试它;
- 每个提供方 HTTP 请求都带应用归因头,attributionHeaders 映射成标准 User-Agent,字段只有产品名、版本与仓库主页,不含密钥、本地路径、会话 ID 或任何按请求变化的值;
- 重放状态归适配器私有,只有当历史提供方与目标提供方当前都注册在同一个适配器实例上时,运行时才把状态交回去。

失败、重试与计费:三件小事的较真
LlmFailure 是可序列化的提供方中立失败事实:人类可读消息、稳定机器码、可选 HTTP 状态、可选的 providerRetryAfterMs 与可选的请求 ID。注意 providerRetryAfterMs 是「提供方要求等多久」这个经过校验的事实,不是重试决策本身,决策权在策略层。
重试策略在路由注册前就解析成不可变联合:normal 模式带有限 maxRetries、可重试码表与初始延时、上限延时、抖动比;always 模式只有退避字段而没有有限上限。策略随注册一起被捕获,之后路由被销毁或替换,都改不了一笔在途失败的恢复策略。
TokenUsage 的账目是互斥的:inputTokens 只计未缓存输入,缓存读、缓存写各自单列,计费输入是三者之和;有的提供方(比如 DeepSeek 的 prompt_tokens)把缓存命中折进提示总数,适配器要负责减回去。reasoningTokens 若给出,只是输出 token 中思考部分的明细,已包含在 outputTokens 里,汇总时绝不能再加一遍。这三处不较真,账单与监控就是错的。
请求信封:从日志重建每一次调用
一次模型调用是组装完毕的 GenerateOptions:路由与模型、可选推理强度、消息数组、系统提示、工具 Schema、温度、输出上限、停止序列、取消信号,外加循环盖章的会话标识与辅助调用用途标记(压缩、会话起标题)。结束原因同样是可扩展联合:stop、tool-calls、max-tokens、aborted、error。
较真之处在于可重建性:循环构建的每个请求都从会话日志推导,日志还保存完整的请求头快照,记录调用配置与哪些字段来自适配器默认值;请求抵达流式入口时已被深冻结,改一个字段直接抛错。llm/stream 瀑布允许插件拦截甚至接管调用,但适配器选择、分发与迭代中的失败会被归一成终态的 error 或 aborted 分片,中间件自身的失败则照常抛出,两类故障不会混在一起。
结语
这页参考没有一句产品文案,通篇是类型与义务,却把流式层最容易烂掉的地方逐个钉死:协议演进靠编译器把关、令牌账目互斥、失败归一成一种载荷、重试策略随路由冻结、归因信息保持公开。自己设计 LLM 网关或多模型抽象层时,这份 MIT 协议下的公开样本值得整页读一遍,项目地址是 github.com/deepseek-ai/deepseek-harness。



