feat(web): show durable token usage and context occupancy in the stats line

The chat stats line took its token totals from the loaded conversation nodes,
so paging changed them and compaction erased the billing behind replaced
content. It also had no way to show context occupancy: the numerator and
capacity never reached the browser.

Both now come from token-meter session projections read through the standard
useProjection seat. Window nodes keep supplying turn and step counts plus LLM
and tool wall times, which are correctly window-scoped facts about what is on
screen; accounting no longer comes from there.

`tokenUsage` supplies billing and cache hit. `contextPressure` supplies
occupancy, pairing the newest provider-reported prompt size with the newest
capacity recorded by `request/context`. Deployments without token-meter drop
the token groups; a route whose adapter advertises no capacity drops the
occupancy group rather than rendering a placeholder.

Occupancy is deliberately approximate: the numerator and capacity are
independent last-wins fields, not one atomic request observation, so switching
models pairs a fresh capacity with the prior route's pressure until the next
request reports usage. It is a user-facing reference figure that nothing in the
harness makes decisions from, and it matches how the TUI status line has always
computed occupancy. The Agent Note and token-meter README state this as a
decision, including why the atomic alternative was implemented and rejected, so
it is not re-litigated as a defect.

Snapshot delta is one added `Context N% of 128K` segment across eight web
goldens; the preceding commit absorbed master's pre-existing golden drift.
This commit is contained in:
Hypatia May
2026-07-30 14:48:19 +08:00
parent 901bd575e1
commit 8a8c1965d7
43 changed files with 535 additions and 159 deletions
@@ -1,4 +1,4 @@
# Agent Note:token 用量投影与请求上下文
# Agent Note: token 用量投影与上下文占用率
Status: implemented
@@ -6,40 +6,54 @@ Status: implemented
## 问题
Web 统计行若根据当前已加载的会话节点推导,其结果会在分页时依赖当前窗口。压缩(compaction)可以替换可见内容,却不保留历史用量。另一方面,上下文占用率描述的是一个真实请求边界:所选模型只代表意图;若把某一时刻的 token 压力与另一路由解析出的容量组合,就会产生虚假百分比。
Web 统计行原先从当前已加载的会话节点推导 token 总量。该窗口是分页的,因此滚动会改变总量;压缩(compaction)又会替换可见内容,而不保留其背后的计费用量。持久的提供方计费用量需要一个能同时经受这两者的数据源。
因此,这两个值具有不同的生命周期。提供方报告的计费用量属于持久、可回放的会话状态。请求压力和与注册项绑定的容量则是恰好可得的实时观测,跨连接代次时必须消失。
上下文占用率需要一个分子和一个分母,而这两者都不曾由任何既有接口送达浏览器:最新一个请求的提示词规模,以及该请求所用路由的容量。
## 决策
当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册通用的 `tokenUsage` 会话投影。该投影将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前值,不会重复计数。推理(reasoning)token 仍是输出的细分项,不会再次累加。压缩和表层替换不会抹除先前的计费用量。
这两个值都是普通的持久会话投影状态。当 `ctx.sessionProjections` 存在时,`@deepseek-ai/dsh-token-meter` 会注册两个单元。
该投影使用标准的投影生命周期与协议路径。历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载均保持通用机制。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端 seq 防护机制。
`tokenUsage` 将完整持久日志归并为未缓存输入、输出、缓存读取和缓存写入四类计数项。即使后续请求失败,`assistant/chunk` 用量样本仍会保留;同一 `(turn, step)` 的 `assistant/message` 用量值会替换先前样本,不会重复计数。推理(reasoning)仍是输出的细分项。压缩和表层替换不会抹除先前的计费用量。
`LlmService.prepareCall()` 会保留精确查询得到的上下文元数据;同一次查询还会校验推理强度并捕获适配器注册项。外层流调用返回句柄后、开始迭代前,AgentLoop 会发出一条失败受收容的 `agent/model-request` 通知。准备阶段或外层 waterfall(瀑布式事件)的同步失败不会发出通知;短路句柄以及之后的迭代器构造失败、迭代失败或中止仍算作一次已观测的请求尝试。
`contextPressure` 携带 `pressureTokens`(提供方报告的最新提示词规模,为未缓存输入加缓存读取与写入之和,不含输出),以及来自最新一条 `request/context` 记录的可选 `contextWindow`。
ApiProxy 会同步处理该通知。当可选服务存在时,它会读取一次 `tokenMeter.measure(agent.session).totalTokens`,并将结果与同一准备完成调用中绑定注册项的 `contextWindow` 合并。它会广播一个原子 `session/model-request` 帧,其中包含路由、轮次、步骤,以及可用的 `contextTokens`/`contextWindow` 字段。测量失败时只省略分子。该帧只发送给当时已经打开的 mux 连接;历史记录、订阅基线、重连和恢复都绝不回放该帧。
`request/context` 是新增的仅入日志会话事件,记录请求所解析到的路由的、绑定注册项的容量。AgentLoop 在步骤内紧随 `request/header` 追加它,数据取自 `prepareCall()` 现在与已解析配置一并返回的上下文元数据:正是那次已经校验过推理的、绑定注册项的查询,因此不会发生第二次解析。当提供方、模型和容量都与上一条记录相同时会跳过;适配器不公布容量的路由则完全不记录。
客户端将最新的完整请求帧存储为 `ConversationSnapshot.modelRequest`。每个后续帧都会替换整个快照,因此省略字段会清除先前值。`SessionManager` 会临时保存一个实例化前帧;新订阅代次、断开连接或移除会话时,则会同时清除常驻值和待处理值。仅选择模型不会改变该快照。
容量刻意不进入 `EpochHeader`。该类型是重建契约,即请求由什么构建而成,而 `headerEquals` 会逐字段比较它,以判定某个快照是否真的是一次 `change`。容量是描述路由的适配器元数据,把它放进去会让容量变化伪装成请求封装的变化,还会把它拖进 AgentLoop 的重建不变式。
Web `StatsLine` 通过标准 `useProjection` 钩子读取 `tokenUsage`,并通过 `useSession` 读取请求观测数据与可见节点。它分别显示未缓存输入、输出与缓存读取,通过 `cacheRead / (uncachedInput + cacheRead)` 计算缓存命中率,并且只有同一份请求快照同时包含分子与容量时才显示上下文占用率。可见节点仍只提供轮次和步骤计数。系统保留现有的行内文本 UI;模型选择器不增加圆环或其他附属控件。
两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。未部署 token-meter 时会去掉 token 分组;容量未知的路由会去掉占用率分组,而不是渲染占位符。
## 上下文占用率是近似值,而这正是决策本身
`pressureTokens` 与 `contextWindow` 是两个各自后者胜的独立字段,不是一次原子观测。切换模型时,新容量会与上一路由的压力配对,直到下一个请求报告用量为止;分子描述的是最后一个请求,而不是此刻的表层。
这是刻意接受的结果。占用率百分比是面向用户的参考数字:harness 中没有任何环节依据它做决策,压缩改为直接读取 `measure()`。TUI 状态行一直以这种方式计算占用率,即用 `measure()` 总量除以为所选模型单独解析出的容量;因此在这里做成原子版本才是异类,而不是常态。
评审人不应把这种非原子性当作待修的缺陷。确实需要同一边界精确数字的消费方,应在自己的请求边界调用 `ctx.tokenMeter.measure()`,那里两个值同时可得,而不是读取该投影。
## 备选方案
**自定义会话指标历史字段和 mux 帧。** 这会重复实现通用投影协议、缓存、恢复和 seq 防护机制,并将持久计费用量与临时请求压力耦合。
**以临时 mux 帧交付请求边界上的原子快照(已实现,随后否决)。** 本分支较早的一个修订版会发出 `session/model-request`:一个不可回放的帧,携带在同一个 `agent/model-request` 边界测得的 `contextTokens` 与 `contextWindow`。真正让它失效的,是它成了 mux 流上唯一的不可回放类别。Host 流与 mux 流是两条独立的 SSE(Server-Sent Events)流,彼此之间没有顺序保证:在移除之前发出的请求可能在 `host/session-removed` 之后才到达,让一个已死会话的遥测数据复活;而复用同一 id 的新生命周期的合法请求,又可能被一条迟到的移除拦下。`session/subscribed` 不能证明生命周期:它只说明某个队列开始订阅某个 id,而不说明新的内存会话替换了较早的会话;`lastSeq` 则是两个生命周期可以共用的持久水位线。正确的修法需要在帧上、订阅上和移除上都带一个单调递增的生命周期代次,再加上一次客户端水位线比较。
**在 React 中归并已加载的节点窗口。** 此方案无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。
这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把 ApiProxy 变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。
**仅随最终 assistant 消息发布用量。** 如果请求报告一个用量分片后失败,就会丢失提供方计费用量。
**在 React 中归并已加载的节点窗口。** 无法跨分页或压缩保留数据,还会迫使展示包(package)重建日志语义。
**从所选模型查询容量。** 选择操作可能永远不会产生请求;第二次元数据查询还可能与实际调用所用的、绑定注册项的查询不一致。
**仅随最终 assistant 消息发布用量。** 如果请求报告一个用量分片后失败,就会丢失自己的计费用量。
**持久化或回放最新请求快照。** 即使没有观察到任何新请求,先前连接的请求也会在恢复后显得仍是当前请求。
**在 token-meter 内部解析容量。** 该包自述与模型路由无关,且在其他方面是一个从不向日志追加内容的纯读取方。AgentLoop 在写入请求头的位置已经持有已解析的元数据。
**在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。现有统计行可以表达按请求作用域的语义,无需引入重复的 UI 或数据路径。
**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但 `StatsLine` 位于 `ui-conversation`,模型目录位于 `ui-model`,而 `ui-conversation` 不能依赖 `ui-model`。要送达它,就得增加第二个 dock 条目、把一行文本拆到两个插件里,或者做一次跨插件的 store 写入。
**在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。统计行可以承载该数字,无需引入重复的 UI 或数据路径。
## 后果
token 总量在分页、压缩、回放和重连期间保持稳定,因为它们属于普通的持久投影状态。重连后,上下文占用率会刻意保持未知,直到系统观察到新的真实请求。未部署 token-meter 或模型不提供容量时,系统仍会发布请求路由,并清除陈旧的可选字段,而不会虚构百分比。
token 总量在分页、压缩、回放、重启和重连期间保持稳定,因为它们是通过通用路径恢复的普通持久投影状态。跨流重排序竞态从构造上就不存在,而不是被栅栏挡住。
ApiProxy 会为每次已观测请求执行一次可选的同步测量和一次帧转换。它不拥有任何逐会话指标缓存或刷新队列。浏览器只保留一个通用投影值和一个小型连接本地请求快照;流式文本增量不会迫使统计行重新计算。
占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。
每个会话日志会为每次路由变化增加一条小型 `request/context` 记录。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。