
字节笔记本
2026年10月6日 · 约 10 分钟读完
DeepSeek Harness LLM 流式子系统解析
DeepSeek Harness(dsh)是 DeepSeek 开源的智能体框架,MIT 协议,架构口号是把一切做成插件,底层由 Cordis 驱动。本站此前已经写过它的整体架构、事件矩阵和接入新模型的适配器实战,这篇换一个视角,读仓库 docs/subsystems 目录下的 llm-streaming 子系统参考文档,看框架如何用一套类型词汇接住模型的流式响应。
这份文档对应 packages/llm 包。它定义的不是某个具体功能,而是整个框架里「模型说话」这件事的公共语言:消息与内容块、原始流式分片、统一的失败类型、token 记账,以及把分片折叠回消息的共享组装器。同一套词汇同时服务三个场景:界面实时呈现、持久会话历史、发给模型的请求,三方看到的都是同一种消息表示。

一个所有人共用的消息词汇
一切从两个类型开始。ContentBlock 是内容块,核心五种:text 是可见文本;reasoning 是思维过程,与可见文本严格区分;image 是持久的图片附件;tool-call 记一次工具调用,携带调用 ID、工具名和原始 JSON 参数;tool-result 是工具结果,内部可以嵌套内容块数组,并用可选标记声明失败。Message 则是一条不可变消息:稳定 ID、三种角色之一(system、user、assistant)、内容块数组,外加一个来源字段。
两个设计细节值得展开。第一,assistant 消息的来源里会记录生成它的提供方和模型,还有一个可选的回放状态:这是适配器私有的无损 JSON 数据,用于原样重放提供方响应。框架只在同一个适配器实例同时拥有历史提供方和目标提供方时才把它交出去;换成别的适配器,历史消息就只剩提供方无关的公共内容。第二,来源是可合并扩展的和类型:用户、插件、模型、工具四类生产方各自声明 kind 回答「由谁产生」,可选的 form 回答「这是什么类型的信息」,两条轴刻意独立。form 词表目前有六个值:工作区文件里的指令、会话内可用的项目录、会被后续快照取代的状态、一次性事件通报、别的智能体捎来的话、从其他会话日志捞回的材料。这个词表只描述语义,不规定颜色、图标或折叠方式,怎么呈现是消费方自己的事;未声明的值按不透明内容处理,插件可以逐个增加新值。
StreamChunk:封闭的七种分片
模型的流式响应是多种片段交错的:正文写着写着插进推理,再插进一两个工具调用。子系统的答案是一个封闭的可辨识联合:
type StreamChunk =
| { type: 'block-start'; index: number; blockType: ContentBlockType }
| { type: 'text-delta'; index: number; text: string }
| { type: 'reasoning-delta'; index: number; text: string }
| { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
| { type: 'block-end'; index: number; block: ContentBlock }
| { type: 'usage'; usage: TokenUsage }
| { type: 'finish'; reason: FinishReason; replayState?: unknown }index 把每个增量关联回所属的内容块,消费方不需要自己拼接碎片,block-end 直接携带组装完成的块。封闭意味着对 type 的 switch 以 assertNever 收尾:将来新增一种分片,所有必须处理它的消费方都会在编译期报错,协议演进不靠人肉通知。
顺序契约只有一句话:usage 出现在 finish 之前,finish 之后不再有任何分片,适配器把这两者都推迟到提供方的流结束标记之后发出。工具调用参数全程保持原始 JSON 字符串:片段经 argumentsDelta 流式传输,提供方若直接吐出解析好的对象,适配器在 block-end 时负责重新序列化,保证下游看到的世界始终一致。
失败只有一种形状
流式链路上失败随处可见:传输断了、提供方限流、上下文超限、模型返回空。子系统的做法是把它们全部归一成一个可序列化、提供方无关的 LlmFailure:人类可读的 message、稳定的机器路由 code、可选的 HTTP 状态码、经校验的 providerRetryAfterMs(注意它只是提供方请求的延迟,本身不是重试决策),以及用于排查的不透明请求 ID。

失败有两条受支持的路径,共用这一个类型:传输或协议错误直接从 stream() 抛出,运行时保留被抛出的确切错误对象;无法在流中途抛异常的适配器,则用带 failure 载荷的 error 或 aborted 终止分片带内收尾。两条路汇合后,失败事实连同当次调用捕获的重试策略一起交给请求错误事件,监听方完成修复返回 retry 即可重来;若无人恢复,结构化失败成为轮次错误,这一次尝试不会提交 assistant 消息,也不会留下工具副作用。
契约里还有几条容易被忽略的硬规则。一次适配器调用就是一次提供方尝试:适配器禁用库级重试,重试是 agent 层打开的另一个带编号的持久轮次,直接调用流式接口的调用方则只有一次机会。提供方停顿受传输层看门狗约束:闲置超时默认五分钟,到期映射为 TIMEOUT,调用方先主动中止则保留 ABORTED。上下文溢出只有一个规范 code,消费方按 code 路由,绝不解析提供方的报错文本。空回复不是静默成功:不带任何内容块的正常结束会映射为 EMPTY_RESPONSE 错误,官方重试插件默认会重试它。最后,每个提供方 HTTP 请求都携带应用归属头:归属信息只含产品名、版本号和仓库地址这类公开产品事实,不含密钥、路径、会话 ID 或任何逐用户数据,逐请求信息也不得影响这些值。
互不重叠的 Token 记账
TokenUsage 的字段语义经过刻意切分:inputTokens 只算未缓存输入,缓存读和缓存写单独上报,计费输入是三者之和。有的提供方把缓存命中折进单一的提示词总数(DeepSeek 的 prompt_tokens 就是这样),适配器负责再把它扣除。reasoningTokens 如果存在,只是信息性细节,已经包含在 outputTokens 里,汇总时不得重复相加。这些看似琐碎的规则,恰恰是做多服务商成本面板时最容易踩坑的地方:同一个「输入 token」,在不同提供方的账本里含义并不相同。
BlockAssembler:唯一的折叠器
有了协议还要有人干活。BlockAssembler 是全框架唯一的共享组装器:push() 逐片喂入,流结束后读取组装好的块、用量、结束原因和回放状态,message() 一步给出冻结的 assistant 消息。agent 循环在记录原始分片留作回放的同时,把同一批分片送进组装器,两边互不耽误;需要组装结果又不想重写折叠逻辑的消费方也用它。实现上有几处防御很值得学:只发增量、没有块边界的协议也能宽容处理;对已经关闭的索引再来的增量,按畸形流直接忽略,行为不端的适配器既撑不大内存,也污染不了已完成的块;max-tokens 截断时,无法安全执行的工具调用会被丢弃;从未被 block-end 关闭的未知块类型则直接抛错。
请求必须可重建
这个子系统最硬的主线是可重建性。循环从已记录状态构建每个请求:调用配置头记录提供方、模型、推理强度和采样参数;完整的请求头快照记录渲染后的系统提示词、按序的消息历史和权威的工具顺序。结合派生历史,任何一个请求都能从会话日志原样重建,这是回放和调试的地基。到达流式水瀑布的循环请求会被深度冻结,任何改动直接抛异常;请求上还带进程本地的循环标识,观察者不会把框架自己发起的冻结辅助调用(压缩、起标题这类)误认成对话请求。
适配层还留了一条名为 llm/stream 的瀑布事件,包裹每一次流式模型调用:监听方调用 next() 放行到解析出的适配器,也可以产出自己的分片短路整个请求,重试、回放、路由这类横切能力都挂在这条缝上。运行时表面还包括适配器注册与原子换路由、休眠提供方目录、端点探测(凭据一次性使用,框架不保存)、按确切模型解析上下文容量与推理档位等接口,目录数据只做参考,从不充当请求白名单。
小结
把这些拼起来,是一份相当完整的流式层设计样本:用封闭联合类型锁死协议形状,用统一失败类型打通错误恢复,用互不重叠的计量支撑成本核算,用共享组装器免除每家适配器的重复劳动,再用请求头快照保证一切可重建。想动手给 DeepSeek Harness 接一个自己的模型服务商,官方 cookbook 的适配器实操指南讲怎么写;想理解契约为什么长成这样,这份子系统参考就是原始出处。项目在 GitHub 上以 MIT 协议开源,装好 Node.js 后一条 npx 命令即可在本地跑起 Web 界面。



