DeepSeek Harness 的 tok/s 虚高 3 到 5 倍:sessionStats 锚点怎么修
底部统计条的 tok/s 是 decodeTokens / decodeMs:分子是提供方上报的 outputTokens,包含思考 token;分母是「组装完成时间 − 首 token 时间」。 问题就出在这个「首 token」:DeepSeek Harness(DSH)只把非空的文本、思考、工具参数增量认作 token,而把思考整块下发的提供方根本不产生 reasoning-delta。锚点于是滑到第一段可见文本或第一个工具调用,分母被砍掉一大截,分子却没变(来源)。
先用日志确认:DeepSeek Harness 的 tok/s 虚高来自锚点
判据是「日志里 reasoning-chunks 为 0、只有 reasoning 的 block-start 与 block-end、统计条仍按含思考的 outputTokens 计速」。
- 打开出问题会话的日志,找到
assistant/message.stream记录。预期:能看到内容块事件。 - 统计
reasoning-chunks的条数。预期:为 0,说明思考没有逐字下发。 - 找
chunk.block-start,确认blockType是reasoning。预期:存在,且后面紧跟整块的block-end。 - 用同一 step 的
outputTokens除以整步耗时,和显示值比较。预期:显示值是端到端的 3 到 5 倍。 - 单独看纯工具步。预期:倍率更夸张,可到 30 倍。
| 提供方形态 | reasoning-delta | 锚点位置 | tok/s |
|---|---|---|---|
| 逐字下发思考 | 有 | 思考开始 | 接近真实 |
| 整块下发思考 | 无 | 可见文本或工具调用 | 虚高 3-5 倍 |
| 纯工具步 | 无 | 工具参数 | 可到 30 倍 |
讨论中的报告者用 47 个会话离线复算,按 provider 与 api 分组后,整块下发思考的组别放大最明显(#9899)。
根因:DeepSeek Harness 的分母起点为什么会滑走
「首 token」这个锚点依赖一个没有写进契约的前提:内容必须以增量形式到达。
- 投影在
assistant/message分支取open.firstTokenTime,没有就调用assistantStreamFirstTokenTime。预期:两条路径都找增量。 - 这个函数遍历流记录,只对
isTokenDelta为真的片段返回时间。预期:其余片段被跳过。 isTokenDelta接受非空text-delta、reasoning-delta、tool-call-delta。预期:block-start永远不算。- 思考整块下发时没有
reasoning-delta,锚点后移。预期:落到第一段文本或第一个工具调用参数。 decodeMs从那里才开始累计。预期:分母明显偏小。
还有第二个方向的误差:如果某个 step 既没有文本增量也没有工具参数增量,它的 outputTokens 会因为缺少锚点而整段不计入分子。分母偏小与分子漏算同时存在,看总数无法区分,必须按 step 拆开。
路径一:用 Math.min 取更早的 DeepSeek Harness 锚点
最小改动是把「首 token」与「首个 block-start」取较早者作为解码起点,同时让 TTFT 继续用原来的 token 锚点。
- 在流读取模块里加一个只找
block-start的函数,返回首个块开始时间。预期:整块流与普通流都能返回。 - 在 message 分支同时取
tokenAnchor与blockStart,用Math.min得到decodeStart,并处理undefined。预期:正常流结果不变。 decodeMs改用decodeStart,ttftMs与ttftSteps仍用tokenAnchor。预期:TTFT 数字不变,tok/s 下降。- 三个实现点一起改:主机投影、客户端
turn-metrics镜像、统计条展示。预期:界面与日志口径一致。 - 用历史日志复算一组会话。预期:修改后接近端到端吞吐。
const tokenAnchor = open.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream) ?? null
const blockStart = assistantStreamFirstBlockStartTime(event.data.stream)
const decodeStart = tokenAnchor !== null && blockStart !== undefined
? Math.min(blockStart, tokenAnchor)
: tokenAnchor
为什么不直接把分母换成整步耗时:整步耗时混入排队、重试与工具等待,口径比解码耗时更宽,直接替换会从虚高变成偏低。真要用端到端口径,必须让分子与分母覆盖同一组 step,并在标签上写明。
路径二:在投影里按 block 类型取 DeepSeek Harness 锚点
如果不想新增全局读取器,可以在投影里按块类型决定:只有整块下发思考的 step 才把 reasoning 的 block-start 当起点。
- 在 step 打开时记录首个 reasoning block 的开始时间与是否出现过
reasoning-delta。预期:两类提供方能被区分。 - 只在该 step 没有
reasoning-delta、但存在 reasoning block 时,把 block-start 纳入候选锚点。预期:逐字下发的会话数值不变。 - 不要把这个新锚点镜像到
assistant/attempt分支。预期:重试步仍以第一次尝试的首 token 为准。 - 给重试场景补测试:第二次尝试的 block-start 晚于第一次的首 token。预期:取到的是首 token。
- 用两条不同提供方的会话做对照。预期:两条都落在合理区间。
这条路的语义更窄,回归面也小。代价是投影里多了一个条件分支,读代码的人要先理解「为什么要区分提供方形态」。
路径三:前端先不展示 DeepSeek Harness 的不可信值
如果只是使用者、改不了宿主,最诚实的做法是让界面别把不可信的数字当精确值展示。
- 在客户端判断当前 step 是否属于「有 reasoning 块但没有 reasoning 增量」。预期:能标记出可疑 step。
- 对可疑 step 改用整步耗时为分母的端到端速度,并标注口径。预期:数字与用户手算一致。
- 或者折叠为「—」,把 token 总量保留。预期:不再给出误导性的高值。
- 在 tooltip 里说明「估算」二字。预期:读者知道这不是精确解码速率。
- 用 DSH插件 做展示层替换时保留原字段。预期:不破坏其他消费方。
不要用「outputTokens 减 reasoningTokens」当通用修法:reasoningTokens 是可选字段,只有部分提供方上报,混用会让跨提供方比较失去意义。
验证与边界:DeepSeek Harness 三个实现点复查
改完后要同时看三件事:tok/s 回落到端到端量级、TTFT 不变、纯工具步不再出现极端倍率。
- 用同一批历史会话复算,不要只测一条新会话。预期:整块与逐字两组的差距消失。
- 确认分母与分子覆盖同一组 step。预期:不再出现只算一半的情况。
- 对比修改前后的 TTFT。预期:数字一致。
- 看一条纯工具步。预期:倍率从 30 倍回到个位数。
- 检查三个实现点是否都生效。预期:日志、回退路径、统计条显示同一个值。
| 层 | 作用 | 漏改后果 |
|---|---|---|
| 主机投影 | 日志与统计的主来源 | 日志里的值仍虚高 |
| 客户端 turn-metrics | 投影不可用时的回退 | 掉线或旧会话显示不一致 |
| 统计条展示 | 第二处速度入口 | 同屏出现两个 tok/s |
边界有三条:统计口径的说明文档要同步改,写清「首 token」的适用前提;不要只测一条新会话就宣布修好;讨论里没有官方修复版本。状态栏展示项的另一个问题见 状态栏只剩 tok/s。
常见问题
这是 DeepSeek Harness 的口径缺陷,不是你的算错。sessionStats 的分子含思考 token,分母 decodeMs 却从首 token 开始算;思考整块下发时锚点滑到可见文本,分母变短,速度就虚高。
DeepSeek Harness 的缺陷依赖一个未写进契约的前提:思考必须以增量下发。逐字下发会产生 reasoning-delta,锚点落在思考开始处;整块下发只有 block-start 与 block-end,锚点只能后移。
DeepSeek Harness 这条规则写在三个地方:主机投影、客户端 turn-metrics 镜像、统计条展示。只改主机侧,前端回退路径仍会显示虚高值,所以三处要一起改。
DeepSeek Harness 的 reasoningTokens 是可选字段,不能直接相减。只有部分提供方上报;在上报处相减、不上报处留空,会破坏跨提供方可比性。更稳的是修分母锚点。
相关术语
- decodeMs
- decodeMs 是 DeepSeek Harness 会话统计里的解码耗时,按「组装完成时间减去首 token 时间」累计,用作 tok/s 的分母。它的起点选择决定速度口径。— https://github.com/deepseek-ai/deepseek-harness/discussions/9899
- isTokenDelta
- isTokenDelta 是 DeepSeek Harness 判断一个流片段是否承载 token 的函数,只认非空的 text-delta、reasoning-delta 与 tool-call-delta。block-start、block-end、usage、finish 都不算。— https://github.com/deepseek-ai/deepseek-harness/discussions/9899
- block-start
- block-start 是流式响应里标记一个内容块开始的片段,带 blockType,例如 reasoning 或 text。它不承载 token,因此不会被计为首 token,但它标志着生成已经开始。— https://github.com/deepseek-ai/deepseek-harness/discussions/9899
来源
- #9899 — sessionStats 的 TPS 口径缺陷:decodeMs 起点滑到可见文本/工具调用· deepseek-ai(GitHub Discussions)
- DeepSeek Harness 官方仓库· deepseek-ai(GitHub)