读完这篇你能:分清「花了多少」其实是三个问题,知道用量为什么是可重放的而不是计数器、它的精度边界在哪;知道想外发数据时脱敏该由谁做;以及内置缺的那块(失败次数)怎么自己折出来。
前置:23-会话上下文的压缩、裁剪与排水。约 30 分钟。
示例代码:dsh-turn-cost
DSH 版本:0.2.0-rc.2
「这一趟花了多少」听起来是一个问题,拆开是三个,而且落在三个互不依赖的机制上:
| 问题 | 机制 |
|---|---|
| 这个会话压着多少 token? | ctx.tokenMeter:重放日志折出「压力」与「表面价格」 |
| 花了多少时间、多少步? | sessionStats 投影单元:纯函数折出墙钟时长 |
| 这些数据会不会被发出去? | ctx.sessionTelemetry:默认连发都不发 |
再外加一件小事:time-context 给模型塞一个时钟,让「三天前」这种说法有个锚。它跟前三者无关,放在这篇只因为它也在回答「这一趟到底过了多久」。
目标
- 理解「可重放计量」是什么意思,以及为什么它不能用计数器实现。
- 知道用量的精度边界:它服务于压缩策略,不是账单。
- 弄清外发数据的责任划分:seam 脱不脱敏、出厂发不发、怎么彻底关掉。
- 给会话折出一条内置没有的账:失败次数。
DSH 设计
要回答「这一趟花了多少」,先问数据从哪来。DSH 的会话日志(每个会话一份只追加的事件流水,写下不改)把每次模型调用的完整来往都记了下来:成功的消息内嵌产生它的精确流式记录,失败、重试、被取消的尝试也各有留存。也就是说,「每一次调用花了多少」在日志里已经是完整事实,计量不需要一个跟着进程跑的旁路计数器,只需要把日志重新折一遍。
为什么不信任计数器?打个比方:在柜台上放一个计数器,来一笔按一下。进程重启,计数器清零,账就没了;一段旧内容被压缩换下,它不该再算进下一次请求,多按的也不会自己退回去。而把日志重新折一遍是可重放的:任何时候从头算,得到的都是同一个数。计数器会丢、会错,折叠不会。
一份折叠要同时喂好几个消费者,所以取舍是确定性优先于精确性。计量器是 DSH 自带的用量估算器(token-meter),它的 README 写明:保持单一、确定的启发式,大约四个字符算一个 token。token 是模型计量文本量的单位。这种固定的启发式正是让每个消费者读到同一个数、回放稳定的原因。压缩策略、占用显示、遥测,读的都是同一份折叠。README 同时写明「占用是参考数字,不是计费记录」:消费者要的是一致、确定、便宜,不是精确到分,要对账的需求被明确指路到模型提供方自己的分词器。
计量器自己的定位也收得很窄:它是被调用的工具,不是循环里的角色。同一份 README 写明,它不添加模型可见内容,也不在主循环里做任何决定,估算器干脆一个配置项都不留。量多少、拿这个数做什么决定,全是消费方的事;想换计量行为,只能整个换掉实现。
这样一个小工具,走的却是公开扩展点。它通过会话投影(projection:把日志逐条折成一份状态的纯函数扩展点,插件可以注册自己的单元)注册三个状态单元,插件一卸载,这三个键就消失。也就是说,「内置没算的账,自己折一个」是设计好的用法,不是绕路:能折出什么账,不由框架限制,只由注册了哪些单元决定。
另一半问题是:这些数据会不会被发出去?遥测(telemetry:把会话记录发往系统之外)文档写明,外发前的变换通道「自己一条规则都不带」。没挂监听器时,记录原样离开进程;导出数据能干净到什么程度,恰恰取决于部署方挂了什么。规则是部署方的财产,框架提供的是挂点和保险丝:监听器抛错就扣下那一条,宁扣不放。文档还给了默认方向:出厂后端按需捕获,只在用户显式反馈之后才释放截至该反馈的日志前缀,普通活动从不捕获。默认收紧,放开要显式(依据 local/deepseek-harness-dsh-v0.2.0-rc.2/docs/subsystems/session-telemetry.zh.md)。
外发管道还有一条硬约束:从设计上不能反过来影响会话。后端的接收口被要求必须是非阻塞入队,因为它是在会话事件的热路径上被同步调用的,任何慢于一次入队的动作都会拖累主循环。投递是尽力而为的:记录可以丢、可以重,但绝不能反过来影响会话。「上报数据不会进入模型请求」写在文档开头。
被排除的消费者在图上有自己的位置:右下角。一份要同时喂给策略与显示的数,一致性比精确性值钱——这就是「别拿它当账单」在设计层的来源。
可重放计量
先破一个直觉:它不是计数器累加。
total += usage 这种写法在三种情况下都会算错:
- 会话被 resume 到新进程——内存里的累加值没了;
- 发生压缩,表面被替换——被遮蔽的内容不该再算进「下一次请求有多大」;
- 同一步里发生过失败重试,权威的那次调用被换掉了。
DSH 的答案是:把计量做成会话日志上的一个可续跑折叠。日志是唯一真相,任何时刻都能从零重算出同一个数。
每个会话只存一份极小的游标状态,五个字段:consumedEvents(已消费到第几个持久事件)、header(最近的规范请求信封)、surface(已计价的表面节点)、stepStart(当前开着的 step)、anchor(最近一次成功调用的锚)。每次测量先把游标推到日志尾:
while (state.consumedEvents < session.seq) {
const event = session.eventAt(SessionSeq(state.consumedEvents))!
this._foldEvent(state, event)
state.consumedEvents = SessionLogOffset(state.consumedEvents + 1)
}
「可重放」在这里有三个具体含义:
一、游标式。 consumedEvents 是持久序号,进程重来就从日志重新折一遍,不依赖任何内存累加值。返回的 logRevision 就是”这次测量消费了多少个持久事件”。
二、锚加有符号增量。 不是”把所有 usage 加起来”,而是”拿最近一次成功调用的 provider 用量当锚,再按当前表面相对锚的重定价算一个有符号的增量”。所以模型换了、上下文缩了,这个数字会减。最后还要夹到 0:
totalTokens = Math.max(0, baseline.tokens + surfaceDeltaTokens)
三、锚只在信封完全一致时才用。 只有 provider/model/参数都一样、而且 provider 报的总量不低于同一次调用的完整启发式价格时,锚才成立;否则退回纯启发式。这个守卫是为了防止拿”另一次调用的用量”当基准。
顺带一个设计上的对照:计量的追平是事件驱动的——服务在 session/event 上挂了一个监听器,只为已经在跟踪的会话推游标。这正是 09-订阅 DSH 事件与五种分发模式教的那件事,内置实现也在这么做。
整条折叠链是这样的:
没有精确 tokenizer
这是本篇最该带走的限制:
token-meter 里没有精确 tokenizer。 它用固定启发式——4 个字符算一个 token,每块结构加 4,每条消息的角色开销加 4。README 自己写着:需要精确到能对账的计数时,请改用 provider 的 tokenizer;而”不引入每家 provider 的精确 tokenizer”是一条明确的已知取舍。
原因在消费者身上:这份数字是给压缩策略和占用率显示用的——它们要的是”离阈值还有多远”,不是”这次调用多少钱”。所以:
- 中文(CJK)会被系统性低估,因为一个汉字算一个字符、四个字符才算一个 token;
- 大段 JSON schema 也一样,结构开销的估算比真实情况乐观。
别拿它当账单。 要账单调 provider 的 usage 字段,那是权威来源;token-meter 的产出是”够用的近似”。
外发与脱敏
正确答案是:seam 自己一条规则都不带。 这不是我的判断,是两份原文的一致说法——没有挂监听器时,记录原样离开进程,包括文件内容或命令输出里夹带的任何凭据;导出到共享收集器的部署方自己拥有规则集。
所以脱敏的做法是:在 session-telemetry/record 这个瀑布上挂自己的监听器,对每条出站记录做变换。仓库里唯一的现成范例是测试夹具里那条正则规则。
但还有一层比脱敏更强的保护:出厂后端默认不发。 它的模式是 FEEDBACK_ONLY——普通活动根本不会被捕获。只有会话日志里出现本会话自己的反馈事件时,才会把”到那条序号为止的完整前缀”交出去。base 层的注释写得很直接:
OTel releases a Session-log prefix only after explicit user feedback, regardless of model provider. Ordinary activity never triggers capture.
要彻底关掉,设环境变量 DSH_TELEMETRY_DISABLED(任意非空值都算,包括 '0' 和 'false')。这里有个反直觉的地方:它不是靠配置关的,而是启动器把那一行 patch 成 disabled——注释原话是”config cannot disable a row”。所以别指望在 cordis.yml 里把它设成 false 就能关。
记录从产生到彻底关掉外发:
给模型的时钟
time-context 做的事很小:在 agent/pre-step 上追加一条 user/message,三行文本:
Time sampled while preparing turn 3, step 1: 2026-09-12T18:04:31+08:00[Asia/Shanghai]
Browser time zone for this request: Asia/Shanghai. Interpret otherwise-unqualified dates and times in this zone.
Elapsed since the preceding model-visible message: 2h 14m 7s.
三行分别是时间戳、浏览器时区策略、距上一条的时间。第三行的基线是分段的:第 1 步从”最近一条模型可见消息”算起,第 2 步及以后从本轮上一次注入算起。没有基线时字面量就是 unavailable。
两个实现细节值得记:
- 它注册时用了
prepend: true,并且先await next()拿到下游决定,只有决定不是拒绝、也没被中止时才追加。所以它读得到本轮已进入或已提议的全部用户消息。 - 时区按”本轮所有相关消息里那个唯一的浏览器时区 → 配置的时区 → 进程时区”依次取。如果本轮出现了多个不同时区,它会渲染成
mixed [...]并让模型去问用户——这是刻意不做猜测。
还有一个现状:它默认根本不在配置里。插件是 opt-in 的,出厂组合把它留着不挂,唯一的配置点是那个定时任务的 overlay。想让模型有个时钟,得自己加这一行。
自己折的失败计数
规划里写的是”想知道花了多少 token、多久、失败几次“。前两个内置都有,第三个没有。
内置的 sessionStats 有八个字段,没有一个是失败计数。但失败信号确实存在,只是散在三处、都要自己折:
| 信号 | 位置 | 含义 |
|---|---|---|
工具结果带 isError |
tool/result 事件 |
这次工具调用失败了 |
| 轮次结束原因是错误 | turn/end 的 reason.kind |
整轮失败了 |
| provider 层重试 | llm/retry 事件 |
上游调用被重试过 |
所以”失败几次”是一个自定义投影单元的练习,而不是查一个现成字段——这正好是 demo 做的事。
这一趟的账
demo 干脆把两件事一起做:读内置的三个单元,再自己折一个失败计数。
const failureCounts = {
key: 'failureCounts' as const,
stateVersion: 1,
stateSchema: failuresSchema,
init: (): FailuresState => zero,
// 纯函数:不关心的事件必须返回同一个引用。
apply: (state: FailuresState, event: SessionEvent): FailuresState => {
if (event.type === 'tool/result' && event.data.message.content[0]?.isError === true) {
return { ...state, toolErrors: state.toolErrors + 1 }
}
if (event.type === 'turn/end' && event.data.reason.kind === 'error') {
return { ...state, turnErrors: state.turnErrors + 1 }
}
if (event.type === 'llm/retry') {
return { ...state, providerRetries: state.providerRetries + 1 }
}
return state
},
}
三个分支正好对应上表的三处信号。读取的时候把四块拼在一起:
execute: (_args, exec) => {
const session = exec.agent?.session
if (session === undefined) return 'no agent-bound caller'
const read = (key: 'tokenUsage' | 'sessionStats' | 'contextPressure' | 'failureCounts') =>
ctx.sessionProjections.stateOf(session, key)
return JSON.stringify({
usage: read('tokenUsage'),
time: read('sessionStats'),
pressure: read('contextPressure'),
failures: read('failureCounts') ?? zero,
}, null, 2)
},
这里有几行 type-only import 不是废行,它们是把对应包对 SessionProjectionStateMap 的声明合并进本程序的开关——去掉就编译不过,因为 stateOf 认不出 tokenUsage 这几个 key。内置的 token-meter 自己也是这么写的。
一个数据形状上的细节:tokenUsage 的四个桶是互斥的。推理用量已经算在输出里,不会重复累加——所以别把四个数加起来当成”总量”,它们本来就是分类不是分项。另外 sessionStats 的步数数的是 step/end,失败和被取消的步也各算一步;要区分就得像 demo 这样自己折。
验证
cd code/dsh_plugin/dsh-turn-cost && ./install.sh
三步:
# 1. 配置对不对
dsh --profile test --dump-config | grep -A3 'id: dsh-turn-cost'
# 2. 跑一轮有失败的活:让模型读一个不存在的文件,再正常跑一个工具
dsh --profile test "读一下这个仓库里根本不存在的文件,然后告诉我当前目录有几个文件"
# 3. 让模型读账本
dsh --profile test "调 turn_cost,把结果原样贴给我"
第三步会返回四块:usage(四个互斥桶)、time(轮数/步数/模型时长/工具时长/首 token/解码)、pressure(压力、投影、上下文窗口——模型没报用量时可能缺字段)、failures(至少 toolErrors ≥ 1)。
最后做一个对照实验,它是”可重放”最直接的可观测证据:
在同一个会话里连续读两次 turn_cost,中间什么都不做。
两次的数字必须完全相同——包括 usage 和 time。
如果是计数器累加,重读只会越加越多;而折叠是可重放的,没发生新事件就不会有新数字。
注意事项
- 别把这份用量当账单。 固定 4 字符/token 的启发式会系统性低估中文和大段 schema。要精确数字,读 provider 的 usage 字段。
- 脱敏是你自己的事。 telemetry seam 一条规则都不带,挂上监听器之前,记录原样出门。要导出到共享收集器,先挂规则,再决定导什么。
- 关掉外发要靠环境变量,不是配置。
DSH_TELEMETRY_DISABLED是启动器把那一行 patch 成disabled的开关;在配置里写false关不掉。 FEEDBACK_ONLY是默认,不是保证。 换了后端或者改了模式,普通活动就可能被捕获。部署到公司环境前,确认这一行到底是什么。- 计量单元没有配置项,写了键会抛。 它是固定启发式,干脆一个旋钮都不给。想换行为就换实现(08-替换内置能力的提供方与消费者的换供应商套路)。
time-context默认不挂。 觉得模型对时间没概念,先确认这一行有没有在配置里;不是模型不会看时钟,是没人给它时钟。- 失败计数要自己折,而且要接住三处信号。 只数
tool/result会漏掉整轮失败和 provider 重试——三者的含义完全不同,别混成一个数。