DeepSeek Harness 流式输出:一个 token 到屏幕上要经过几层
先说清楚这篇要回答什么问题。翻 deepseek-harness 这个仓库的时候,很容易冒出一个疑惑:模型流式吐出来的一段文本,在服务端已经被 BlockAssembler 折叠成完整的内容块了,为什么客户端还有一个几乎同构的 PartialAccumulator 又折叠一遍?重复造轮子吗?
答案藏在这条链路的分层里。要看懂它,得从最底下的字节开始,一层一层往上走一遍。下面这条路径全部按仓库快照 47f9438(版本 0.1.0-rc.5)的源码来讲。另外必须先把限定说在前面:该仓库 README 自述目前处于开发者预览阶段,并明确说明未来会出现破坏兼容性的变更,所以下面提到的文件位置、字段名和默认值随时可能变,读的时候请以仓库最新内容为准。README 里给出的启动方式是 npx @deepseek-ai/dsh web,自述会起一个默认地址为 http://127.0.0.1:3080 的 Web UI——所谓”到屏幕上”,终点就是这个 Web 端。
第 0 层:字节还不是消息
入口在 packages/llm/llm-deepseek/src/sse.ts。parseSse() 做的事情很克制:把 ReadableStream 过一遍 TextDecoderStream,再过 eventsource-parser 的 EventSourceParserStream,然后逐个吐出事件的 data 负载。分帧、CRLF、BOM、跨读取切断的 UTF-8 序列、多行 data: 拼接,这些脏活全交给了那个第三方库。
这一层有两个细节值得记住。一是 [DONE] 这个哨兵会被原样 yield 出去,由上层负责收尾;如果字节流在 [DONE] 之前结束,这里直接抛 LlmError,code 是 STREAM_CLOSED,模块注释写明理由是”截断的响应不可信”。二是 SSE 注释行不会进入负载流,只会走一个可选的 onComment 回调——这个回调后面会派上大用场。
第 1 层:负载翻译成 StreamChunk
packages/llm/llm-deepseek/src/translate.ts 的 translate()(文件第 86 行)把上一层的字符串负载翻成协议层的 StreamChunk。这里有一个我第一次读时判断错了的地方:block-end 不是在某个块结束时发出的,而是全部推迟到 [DONE] 那一刻一次性补齐。函数的 JSDoc 自己写得很直白——block-end、usage、finish 三类都 deferred 到哨兵。
也就是说,流的中途你只会看到 block-start 和三种 delta(text-delta / reasoning-delta / tool-call-delta)。reasoning 被有意排在 content 前面处理,源码注释写的是思考模式会把它交错在文本之前;并且长度为 0 的第一个空字符串不会开块。工具调用的 arguments 全程是原始 JSON 字符串片段,靠 argumentsDelta 一段段累加。
还有一条对排错很有用:如果流以 stop 结束、却一个块都没开过,这里不会给你一个”成功的空消息”,而是翻成 finish 里带 EMPTY_RESPONSE code 的 error 结束。这个 code 的常量定义在 packages/llm/llm/src/error.ts。
第 2 层:谁在盯着”多久没动静”
第 1 层和第 0 层被拼在一起的地方是 packages/llm/llm-deepseek/src/adapter.ts 第 344 行:yield* translate(parseSse(response.body, onComment))。而包在外面的是同文件第 227 行的 idleWatchdog。
这个 watchdog 的默认值在 adapter.ts 第 89 行:DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000,即五分钟。它的实现在 packages/util/timeout/src/index.ts 第 126 行,语义有一处容易读错:计时器只在 iterator 的 next() 尚未完成时启动,每次要下一个值才 arm()。所以它量的是”提供方停顿”,不是整个请求的总时长。上一层那个 onComment 回调在这里被接成 watchdog.pulse()——提供方发的 SSE 注释虽然不是内容,却算传输层还活着。
超时到期映射成 code TIMEOUT,而调用方自己发起的中止保留为 ABORTED,两者不会混。要提醒一句:streamIdleTimeoutMs 是配置里的默认值,它约束的是”多久没收到东西才判超时”,不是任何关于响应速度的承诺。
第 3 层:llm/stream 这道 waterfall
适配器之上是 LlmRuntime。ctx.llm.stream() 并不是直接调适配器,而是走 llm/stream waterfall(声明在 packages/llm/llm/src/index.ts 第 64 行),listener 可以调 next() 拿到解析后的适配器流,也可以自己 yield chunk 来短路整个调用。子系统文档还写明:适配器查找发生在这道 waterfall 的终端 continuation,所以 listener 能在查找之前就短路。
重试不在这一层。文档写明”一次适配器调用就是一次提供方尝试”,适配器要禁用库自带的重试;恢复发生在 agent 层的 agent/request-error 扩展点上。默认策略解析在 packages/llm/llm/src/retry-policy.ts,从第 14 行起是一串常量:DEFAULT_MAX_RETRIES = 2、DEFAULT_INITIAL_DELAY_MS = 500、DEFAULT_MAX_DELAY_MS = 10_000、DEFAULT_JITTER_RATIO = 0.1,默认可重试的 code 恰好五个——EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT。上一层那个”空响应不当成功”的设计到这里就闭环了:它是可重试项之一。
第 4 层:agent loop 的双写,本文的答案在这
packages/core/agent-loop/src/agent.ts 第 343 行开始,是整条链路的关键几行。循环里做的是两件事,顺序固定:
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
每一个 chunk 先作为 assistant/chunk 事件追加进会话日志、记下它的 seq,再喂给 BlockAssembler。流结束后用 assembler.blocks() 造出 assistant 消息,追加 assistant/message 事件时把刚才那串 chunkSeqs 作为 sourceEventSeqs 一并带上。
这就回答了开头的问题:BlockAssembler(packages/llm/llm/src/assembler.ts)折叠出来的结果,是给循环自己和持久历史用的——同文件第 393 行紧接着从 message.content 里 filter 出 tool-call 块去执行工具,而这条消息一旦落进会话日志,下一轮 buildRequest() 用 this.session.deriveMessages() 派生出来的历史读到的也是它。它折叠完就把结果落进日志,并不负责往前端推。该模块自己的注释把它称作 agent loop 用来从 chunk 流构建 assistant 消息的唯一权威组装算法,同时保留原始 chunk 以供回放。
BlockAssembler 本身有几条写在 JSDoc 里的容错约定值得单独记:它容忍完全没有 block-start / block-end 的纯 delta 协议;某个 index 已经被 block-end 关闭之后再来的 delta直接忽略,注释写明理由是不让行为异常的适配器撑大内存或污染已完成的块;block-end 只有第一次关闭算数。另外 blocks() 里有一条特殊处理:finish.kind === 'max-tokens' 时会把 tool-call 块过滤掉,注释说的是被截断的工具调用无法安全执行。
第 5 层:日志落盘时会被打包
会话日志不是一个 chunk 一行照写的。packages/core/session/src/chunk-rows.ts 做的是无损存储打包:把连续、同块的 delta 事件压成一行存储行——text-chunks、reasoning-chunks 或 tool-call-chunks,读的时候再展开成一模一样的原事件。该模块头部注释自述的动机是,token 级 delta 会让日志里出现大量 JSON 信封远大于负载的行。
两个约束写在源码里:第 77 行 const MIN_RUN = 3,注释说明这是格式常量而非可调项,低于三条时行信封本身就抵消了收益;编码器采用精确 key 白名单,任何它没有完全识别的形状都原样逐条存——注释写的是”未知字段或未来的 chunk 变体只会丢失压缩,绝不丢数据”。文本行还刻意保留 texts: string[] 而不是拼成一个字符串,注释里给的理由是 token 边界本身也是数据。
顺便一提,block-start、block-end、usage、finish 从不参与打包,永远一条事件一行。
第 6、7 层:过河与二次折叠
从 host 到浏览器这一段走的是推送帧。packages/host/apiproxy/src/api/events.ts 第 70 行能看到帧的形状里有 { type: 'session/event'; sessionId; event; view? } 这一支(view 是可选的 ToolEventView)——客户端收到的是原始会话事件,不是服务端折叠好的结果。同一个 MuxFrame 联合类型里还并排列着 session/subscribed、approval/requested、question/requested 这些控制帧,类型注释把这一支自述为 raw session-event passthrough,即原样透传。
于是客户端必须自己再折叠一次,这就是 packages/client/runtime/src/client/sessions/partial.ts 里的 PartialAccumulator。它和 BlockAssembler 折的是同一批 chunk,但目标不同:它产出的是 UI 用的 AssistantBlock[] 投影,按块级不可变——一个 delta 只换掉那一个块的引用。类里 blocks 那个字段上方的注释注明它故意是稀疏的,因为 block-start 可能乱序到达,中间的洞留到第 95 行的 toPartial() 里再 filter 成渲染顺序。文件头三行注释写明它折的是六种 StreamChunk 变体,按 block index 归位。
同文件第 14 行的 isVisibleAssistantChunk() 列出了会改变可见投影的五种 chunk:block-start 和三种 delta 加上 block-end。usage 和 finish 返回 false,注释解释 finish 之后紧跟着的 assistant/message 会取代这份 partial。
再往上一层是通知节流。packages/client/runtime/src/client/contract/store.ts 第 86 行的 createSnapshotStore(),flush 默认是 'sync',只有帧驱动的 store 才 opt-in 'raf',把一帧内的更新合并成一次通知。注释里把这个取舍的已知代价也写出来了:帧中途挂载的组件读到的是新状态,而已有订阅者要等下一次 flush,属于帧级的短暂偏斜。
这张分层图什么时候用得上
真正用得上的场景是定位。同一个”字没出来”的现象,落在不同层的表现是不一样的:STREAM_CLOSED 和 TIMEOUT 只可能来自第 0、2 层;EMPTY_RESPONSE 来自第 1 层的翻译规则;工具参数在流中途看起来是”一段段拼”而完整块要等 [DONE],那是第 1 层刻意的推迟,不是卡住;日志行数远少于 token 数是第 5 层的打包,不是丢事件。
还有一个常被问的口径:所谓”首 token 时间”到底从哪一刻算。packages/client/runtime/src/client/sessions/assistant-timing.ts 的答案是,step/start 事件开条目,第一个满足 isTokenDelta() 的 assistant/chunk 事件的 time 打上首 token 时间戳,且只打一次。而 isTokenDelta() 在 packages/llm/llm/src/message.ts 第 251 行:空字符串的 text-delta / reasoning-delta 不算,tool-call-delta 要么有非空 argumentsDelta、要么带 name 才算。所以它量的是会话事件的时间戳,跟提供方那边的实际时刻不是一回事。
最后重复一遍那条限定:以上都是我们逐行读仓库源码整理出来的路径与默认值,不是运行观察。这些常量是配置默认值,不构成任何关于表现的承诺;而这个项目自述还在开发者预览阶段,文件位置、字段名和默认值都可能在下一个版本变掉。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。