DeepSeek Harness 的 token 计量:文档两种 baseline,源码三元联合

2026-08-16
站内工具 token 计算器 → 粘一段文本,估算它占多少 token、按当前单价一次调用大概花多少钱。

如果你打算写一个消费 ctx.tokenMeter 的插件,多半会先翻 docs/subsystems/token-meter.md,照着那一页把 TokenMeasurement 的字段抄进自己的类型里,然后对 baseline.kind 写一个 switch。这时候会碰到一件事:文档页解释的是两种情况,而源码的类型定义是三个分支的联合。

先把限定条件说在前面。deepseek-harness 这个仓库建立于 2026-08-13,我们采集快照是 2026-08-16,前后只差三天;根 package.json 里的版本是 0.1.0-rc.5,GitHub 上一个 Release 都没有,README 自述处于开发者预览阶段并明确写了未来会出现破坏兼容性的变更。所以下面提到的所有字段名、常量值、行号,都只对我们读到的这份快照成立,随时可能变。

文档那一页说了什么

docs/subsystems/token-meter.md:29 那一段只解释了两种 kind:baseline.kind === 'usage',以及 baseline.kind === 'estimated'。整段就这两种,没有第三种。

这两种是什么意思,包 README 的已知限制小节(packages/llm/token-meter/README.md:65-68)给了同一件事的另一面说法:固定启发式只是近似值,不是任何 provider 的精确 tokenizer;而 provider 返回的 usage 只有在规范化请求信封完全一致时才可复用。也就是说,能不能用上 usage 这一支是有前置条件的,条件不成立时才落到启发式那一支。同一份限制清单里还写了另外两条:每次 measure() 都会克隆当前 surface 节点,复杂度是 O(surface)(docs/subsystems/token-meter.md:72-74 也写了同一条);以及缺少 sourceEventSeqs 的历史 assistant 消息会按保守方式处理。

源码里是三个分支

打开 packages/llm/token-meter/src/types.ts,第 15 到 18 行是这样一个联合类型:

export type TokenMeasurementBaseline =
  | { readonly kind: 'none'; readonly tokens: 0 }
  | { readonly kind: 'estimated'; readonly tokens: number }
  | { readonly kind: 'usage'; readonly tokens: number; readonly usage: Readonly<TokenUsage> }

多出来的是第一个分支。注意它的 tokens 字段类型不是 number 而是字面量 0——这个分支的 tokens 恒为 0,是写在类型里的,不是运行时才决定的。

'none' 在什么时候产生,packages/llm/token-meter/src/index.ts:128-130 给了确切条件:在没有可复用锚点、headerundefined、并且 state.surfaceTokens 为 0 的时候,measure() 返回 { kind: 'none', tokens: 0 }。三个条件是同时成立才走这一支;不满足时具体落到哪一支,请自己回这段代码往下读,我们不替它概括。

同一个文件里,index.ts:143 是总量的算法:

totalTokens: Math.max(0, baseline.tokens + surfaceDeltaTokens)

三种 kind 走的是同一个表达式,baseline.tokens'none' 那支恒为 0。也就是说,如果你的代码只是读 totalTokens,三个分支之间没有分叉;只有当你要对 baseline 本身做穷举分派、或者要访问只有 'usage' 分支才有的 usage 字段时,第三个分支才会真正找上门来。

同一个包里还有一处把 'none' 排除掉

这一处值得单独拎出来。packages/llm/token-meter/src/index.ts:31 有这样一行:

readonly baseline: Exclude<TokenMeasurementBaseline, { kind: 'none' }>

同一个包里,导出的 TokenMeasurementBaseline 是三元联合,而这一处的字段类型用 Exclude'none' 显式窄化掉,只剩 'estimated''usage' 两支。这里只陈述位置与差异:types.ts:15-18 是三个成员,index.ts:31 是被窄化后的两个成员,measure() 的返回值走的是前者(index.ts:128-130 就在 'none' 那一支上)。至于为什么这里要窄化、哪一处更该被当作准绳,我们不做推断。

对写下游代码的人来说,实际的影响很直接:你消费的如果是 measure() 的返回值,switch (baseline.kind) 就得有三个 case;如果只是照文档页那一段抄了两个 case,'none' 落进去会走到你没写的分支。

到这里,差异的两处位置就都点明了:文档在 docs/subsystems/token-meter.md:29,源码在 packages/llm/token-meter/src/types.ts:15-18index.ts:128-130。为什么两边没有对齐,我们不做推断,也不用这个差异去评价什么。我们能确定的只有:以我们实读的快照为准,TokenMeasurementBaseline 是三个成员。

顺带说说文档没写的三个常量

同一个包里还有一类东西是只在源码里的。固定密度启发式用到三个常量,docs/subsystems/token-meter.md 那一页没有出现这三个数:

常量位置
CHARS_PER_TOKEN4packages/llm/token-meter/src/estimate.ts:13
BLOCK_OVERHEAD4estimate.ts:16(每个内容块的 JSON 结构开销)
ROLE_OVERHEAD4estimate.ts:19(每条消息的角色字段开销)

这张表怎么被用起来,estimate.ts 里写得很直白:textreasoning 块记 ceil(text.length / 4) + 4estimate.ts:32);tool-call 块记 ceil(name.length/4) + ceil(arguments.length/4) + 4estimate.ts:35-37);tool-result 递归计价之后再加 4(estimate.ts:40);碰到不认识的块类型则按 JSON.stringify(block).length/4 + 4 保守计价(estimate.ts:45)。请求信封那一侧,system 部分记 ceil(system.length/4) + ROLE_OVERHEADestimate.ts:65-68),tools 部分记 ceil(JSON.stringify(tools).length/4) + BLOCK_OVERHEADestimate.ts:75-78)。

这些是仓库里写死的常量,是这个估算器的计价规则,不是任何 provider 的真实分词结果——前面引的那条已知限制说的就是这件事。注意它们三个的值恰好都是 4,但含义完全不同:一个是「几个字符折一个 token」的密度系数,另外两个是结构开销的加数,分别加在内容块与消息角色上。读代码时把它们当成同一个 4 去合并化简,改起来就会错位。

这个字段被谁读走

TokenMeasurement 不是只拿来做统计的。packages/compaction/compaction-basic/src/index.ts:304 的触发判断写的是 if (measurement.totalTokens < spec.thresholdTokens) return null——小于阈值就直接返回 null 不压缩;剪枝之后在 :312 再判一次,每轮压缩之后在 :325 又判一次。这里读的是 totalTokens 这个数值字段,三种 baseline 下它都存在。重试耗尽时抛出的错误文案里也会把这个数字原样带出来:

compaction still above threshold after ${spec.compactionRetries + 1} compaction attempts (${measurement.totalTokens} estimated tokens >= threshold ${spec.thresholdTokens})

这段文案在 index.ts:329-331。被拿来比的那个 thresholdTokens 也不是凭空来的:packages/compaction/compaction-basic/src/config.ts:144-147 写的换算是 thresholdTokens = Math.floor(contextWindow * thresholdRatio),而 thresholdRatio 的默认值是 0.8config.ts:20DEFAULT_THRESHOLD_RATIO),retainRatio 的默认值是 0.16config.ts:23)。这两个是配置里的默认值,不是对运行表现的承诺——具体该配多少取决于你的用法,仓库没有给通用建议,我们也不替它推算。

另外有一条路径不看这个阈值:触发原因为 context-overflow 时,index.ts:288 直接以 selectCompactableRange(agent.session, measurement, 0) 选范围,也就是 retainTokens 传 0。换句话说,measurement 这个对象在两条触发路径上都被传了下去,只是压力路径要跟 thresholdTokens 比大小,溢出路径不比。

顺带一提,TokenMeasurement.logRevision 的语义在 packages/llm/token-meter/src/types.ts:22-23 写得很清楚:已消费的持久事件数,等于下一个未读事件的 seq。以及 TokenUsageProjection 的四个桶互不重叠,reasoning tokens 已经包含在 outputTokens 里,不再二次累加(packages/llm/token-meter/src/projection.ts:8-18)——这一条如果读漏了,自己做统计时很容易把同一批 token 数两遍。

你自己怎么核这一处

如果想验证上面说的,动作是固定的三步,不需要安装任何东西:

  1. 把仓库拉到本地(浅克隆即可),打开 packages/llm/token-meter/src/types.ts,直接看 TokenMeasurementBaseline 这个 export type,数它有几个 | { readonly kind: ... } 分支。
  2. 打开 packages/llm/token-meter/src/index.ts,在文件里搜 kind: 'none',看它出现在哪个条件分支下,把这个分支的三个判断条件逐个对回上面那段话。
  3. 打开 docs/subsystems/token-meter.md,在页面里搜 'none',看是否命中。

三步都做完,才算把「文档与源码不一致」这句话落到具体位置上。如果第 3 步在你拉到的版本里命中了,那说明这一处在你那个版本已经不是我们看到的样子了——这正是开头那句限定的意义:这是一个建仓三天、版本 0.1.0-rc.5、README 自述会有破坏性变更的仓库,任何字段和行号都以你拉到的那份为准。

写代码时的一点提醒

仓库里还有一条相关的规矩:SessionEventMap 是 merge-extensible 的,插件可以用 TypeScript 声明合并往里加事件类型(docs/subsystems/session.md:11),因此 switch (event.type) 不允许assertNeverdocs/subsystems/session.md:247)。TokenMeasurementBaseline 不是同一件事——它是一个封闭的联合,不走声明合并——但对写下游代码的人来说,教训的形状是相似的:类型的成员数以源码里的 export type 为准,别拿文档页的散文段落当穷举清单。至于该怎么处理 'none' 这一支,仓库没有给出面向使用者的通用建议,怎么处理取决于你的消费方式,我们也不替它编一个。

延伸阅读


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-16,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库建立于 2026-08-13,README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。