Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md
T
ZiyaandZiyaZhang b565df3442 feat(web): show exact per-turn token usage (#3005)
* feat(web): show exact per-turn token usage

* test(runtime): refresh exact token usage snapshots

* refactor(token-meter): own per-turn usage folding

* perf(ui-chat): bound paging anchor layout reads

* test(web): align usage golden with system prompt row

* fix(test): resolve token-meter client from source

* test(token-meter): cover retry without usage

---------

Co-authored-by: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com>
2026-08-25 19:05:52 +08:00

8.1 KiB
Raw Blame History

Agent Note: token 用量投影与上下文占用率

Status: implemented

English | 中文

问题

Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗口是分页的,因此滚动会改变总量;压缩(compaction)又会替换可见内容,而不保留其背后的计费用量。持久的提供方计费用量需要一个能同时经受这两者的数据源。

上下文占用率需要一个分子和一个分母,而这两者都不曾由任何既有接口送达浏览器:最新一个请求的提示词规模,以及该请求所用路由的容量。

决策

这两个值都是普通的持久会话投影状态。当 ctx.sessionProjections 存在时,@deepseek-ai/dsh-token-meter 会注册两个单元。

tokenUsage 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,assistant/chunk 用量样本仍会保留;assistant/message 用量值会替换同一次模型 attempt 的先前样本,不会重复计数。匹配的 llm/retry-started 边界会结束该替换作用域,因此复用同一 (turn, step) 的重试会贡献一次新的 attempt。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。

token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。它采用相同的重试边界,并增加精确单轮次 disclosure 所需的更严格完整性与精确总量检查。展示消费方可以选择完整 Turn 窗口并调用该 fold,但不拥有或复制记账语义。

contextPressure 携带可选的 pressureTokens(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和,不含输出),以及来自最新一条 request/context 记录的可选 contextWindow。在各自来源出现前,两个字段都不会被合成。

request/context 是新增的仅入日志会话事件,记录请求所解析到的路由的、绑定注册项的元数据。AgentLoop 在步骤内紧随 request/header 追加它,数据取自 prepareCall() 现在与已解析配置一并返回的上下文元数据:正是那次已经校验过推理的、绑定注册项的查询,因此不会发生第二次解析。当提供方、模型和容量都与上一条记录相同时会跳过。适配器不公布容量的路由会以缺失 contextWindow 的形式记录,从而清除较早路由的分母。

容量刻意不进入 EpochHeader。该类型是重建约定,即请求由什么构建而成,而 headerEquals 会逐字段比较它,以判定某个快照是否真的是一次 change。容量是描述路由的适配器元数据,把它放进去会让容量变化伪装成请求封装的变化,还会把它拖进 AgentLoop 的重建不变式。

两个单元都沿用标准投影生命周期:历史尾页基线、session/projection 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。

Web StatsLine 通过标准 useProjection 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。

上下文占用率是近似值,而这正是决策本身

pressureTokenscontextWindow 是两个各自后者胜的独立字段,不是一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;分子描述的是最后一个请求,而不是此刻的表层。

这是刻意接受的结果。占用率百分比是面向用户的参考数字:harness 中没有任何环节依据它做决策,压缩改为直接读取 measure()。TUI 状态行一直以这种方式计算占用率,即用 measure() 总量除以为所选模型单独解析出的容量;因此在这里做成原子版本才是异类,而不是常态。

这种非原子性是有意为之,不是缺陷。确实需要同一边界精确数字的消费方,应在自己的请求边界调用 ctx.tokenMeter.measure(),那里两个值同时可得,而不是读取该投影。

备选方案

以临时 mux 帧交付请求边界上的原子快照(已实现,随后否决)。 较早的一个修订版会发出 session/model-request:一个不可回放的帧,携带在同一个 agent/model-request 边界测得的 contextTokenscontextWindow。真正让它失效的,是它成了 mux 流上唯一的不可回放类别。Host 流与 mux 流是两条独立的 SSEServer-Sent Events)流,彼此之间没有顺序保证:在移除之前发出的请求可能在 host/session-removed 之后才到达,让一个已死会话的遥测数据复活;而复用同一 id 的新生命周期的合法请求,又可能被一条迟到的移除拦下。session/subscribed 不能证明生命周期:它只说明某个队列开始订阅某个 id,而不说明新的内存会话替换了较早的会话;lastSeq 则是两个生命周期可以共用的持久水位线。正确的修法需要在帧上、订阅上和移除上都带一个单调递增的生命周期代次,再加上一次客户端水位线比较。

这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把 ApiProxy 变成一个测量点,每个请求都要调用 O(surface) 的 measure(),并通过一个 UI 必须特殊处理的、连接打开时的合成 cancelled 错误来表达重连状态。

在 React 中归并已加载的节点窗口。 无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。

仅随最终 assistant 消息发布用量。 如果请求报告一个用量分片后失败,就会丢失自己的计费用量。

在 token-meter 内部解析容量。 该包自述与模型路由无关,且在其他方面是一个从不向日志追加内容的纯读取方。AgentLoop 在写入请求头的位置已经持有已解析的元数据。

session.models RPC 增加容量字段。 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但 StatsLine 位于 ui-conversation,模型目录位于 ui-model-selection,而 ui-conversation 不能依赖 ui-model-selection。要送达它,就得增加第二个 dock 条目、把一行文本拆到两个插件里,或者做一次跨插件的 store 写入。

在模型选择器旁增加上下文圆环。 该位置会让人以为这是所选模型的状态。统计行可以承载该数字,无需引入重复的 UI 或数据路径。

后果

token 总量在分页、压缩、回放、重启和重连期间保持稳定,因为它们是通过通用路径恢复的普通持久投影状态。跨流重排序竞态从构造上就不存在,而不是被栅栏挡住。

占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。

每个会话日志会为每次路由或已公布容量变化增加一条小型 request/context 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attemptTurn foldWeb Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。