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

Status: implemented

[English](2026-07-29-projected-token-usage-and-request-context.md) | 中文

## 问题

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

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

## 决策

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

`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败，`assistant/chunk` 用量样本仍会保留；同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前样本，不会重复计数。推理（reasoning）仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。

`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 分组；只有压力与容量都已知时才显示占用率。

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

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

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

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

## 备选方案

**以临时 mux 帧交付请求边界上的原子快照（已实现，随后否决）。** 较早的一个修订版会发出 `session/model-request`：一个不可回放的帧，携带在同一个 `agent/model-request` 边界测得的 `contextTokens` 与 `contextWindow`。真正让它失效的，是它成了 mux 流上唯一的不可回放类别。Host 流与 mux 流是两条独立的 SSE（Server-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 投影是持久会话投影用量语义的正典所有方；TUI 未挂载通用投影 seam，因此保留自己的实时逐步骤 map，而独立浏览器 fixture（测试前置数据）会镜像该单元。ApiProxy 不携带任何 token 专用代码，不拥有逐会话指标缓存，也不执行测量。浏览器只保留两个通用投影值，不保留连接本地的遥测数据；流式文本增量仍不会迫使统计行重新计算。
