DSH / Atlas
2026-07-29implementedarchitecture

Projected token usage and context occupancy

token 用量投影与上下文占用率

The Web stats line derived token totals from the currently loaded conversation nodes. That window is paged, so scrolling changed the totals, and compaction replaces visible content without preserving the billing behind it. Durable provider billing needs a source that survives both. Context occupancy needs a numerator and a denominator that no existing surface carried to the browser: the prompt size of the latest requ

English

Problem

The Web stats line derived token totals from the currently loaded conversation nodes. That window is paged, so scrolling changed the totals, and compaction replaces visible content without preserving the billing behind it. Durable provider billing needs a source that survives both.

Context occupancy needs a numerator and a denominator that no existing surface carried to the browser: the prompt size of the latest request, and the capacity of the route it used.

Decision

Both values are ordinary durable session-projection state. @deepseek-ai/dsh-token-meter registers two units when ctx.sessionProjections is present.

tokenUsage folds the complete durable log into uncached input, output, cache-read, and cache-write buckets. An assistant/chunk usage sample survives a later failed request; an assistant/message usage value for the same (turn, step) replaces the earlier sample instead of double-counting it. Reasoning stays an output subdivision. Compaction and surface replacement do not erase earlier billing.

contextPressure carries optional pressureTokens — the newest provider-reported prompt size, summing uncached input plus cache reads and writes, excluding output — and optional contextWindow from the newest request/context record. Neither field is synthesized before its source exists.

request/context is a new log-only session event recording registration-bound metadata for the route a request resolved to. AgentLoop appends it inside the step beside request/header, from the context metadata prepareCall() now returns alongside the resolved config — the same registration-bound lookup that already validated reasoning, so no second resolve happens. It is skipped when provider, model, and capacity all match the previous record. A route whose adapter advertises no capacity is recorded with contextWindow absent, clearing an older route's denominator.

Capacity deliberately stays out of EpochHeader. That type is the reconstruction contract — what a request was built from — and headerEquals compares it field-wise to decide whether a snapshot is a real change. Capacity is adapter metadata describing a route, so placing it there would let a capacity change masquerade as a request-envelope change and would drag it into the loop's reconstruction invariant.

Both units ride the standard projection lifecycle: history tail baselines, session/projection live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence.

The Web StatsLine reads both through the standard useProjection seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known.

Context occupancy is approximate, and that is the decision

pressureTokens and contextWindow are independent last-wins fields, not one atomic observation. Switching models pairs a fresh capacity with the previous route's pressure until the next request reports usage, and the numerator describes the last request rather than the surface as it currently stands.

This was accepted deliberately. An occupancy percentage is a user-facing reference figure: nothing in the harness makes decisions from it, and compaction reads measure() directly instead. The TUI status line has always computed occupancy this way, dividing a measure() total by a capacity resolved separately for the selected model — so an atomic variant here would have been the outlier, not the norm.

The non-atomicity is deliberate, not a defect. A consumer that genuinely needs an exact same-boundary figure should call ctx.tokenMeter.measure() at its own request boundary, where both values are available together, rather than read this projection.

Alternatives considered

An atomic request-boundary snapshot delivered as a transient mux frame (implemented, then rejected). An earlier revision emitted session/model-request: one non-replayable frame carrying contextTokens and contextWindow measured at the same agent/model-request boundary. Being the only non-replayable class on the mux stream is what broke it. Host and mux are independent SSE streams with no cross-stream ordering, so a request emitted before a removal could arrive after host/session-removed and revive a dead session's telemetry, while a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. session/subscribed is not lifecycle proof — it says a queue began subscribing to an id, not that a new in-memory session replaced an older one — and lastSeq is a durable watermark two lifecycles can share. A correct fix required a monotonic lifecycle generation on the frame, on subscription, and on removal, plus a client watermark comparison.

That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made ApiProxy a measurement site calling the O(surface) measure() on every request, and expressed reconnect state through a synthetic cancelled open error the UI had to special-case.

Fold the loaded node window in React. Cannot survive pagination or compaction, and makes a presentation package reconstruct log semantics.

Publish usage only with final assistant messages. A request that reports a usage chunk and then fails would lose its billing.

Resolve capacity inside token-meter. The package documents itself as independent of model routing and is otherwise a pure reader that never appends to the log. AgentLoop already holds the resolved metadata where the header is written.

Extend the session.models RPC with capacity. The handler already resolves and discards it, so the field is nearly free — but StatsLine lives in ui-conversation while the model directory lives in ui-model-selection, and ui-conversation cannot depend on ui-model-selection. Delivering it would have required either a second dock entry splitting one text row across two plugins, or a cross-plugin store write.

Add a context circle beside the model selector. That placement suggests selected-model state. The stats line carries the figure without a duplicate UI or data path.

Consequences

Token totals stay stable across pagination, compaction, replay, restart, and reconnect, because they are ordinary durable projection state recovered through the generic paths. The cross-stream reordering race is gone by construction rather than fenced.

Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary.

Each session log gains one small request/context record per route or advertised-capacity change. The token-meter projection is the canonical owner of durable session-projection usage semantics; the TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute.

中文

问题

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

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

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

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

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

备选方案

以临时 mux 帧交付请求边界上的原子快照(已实现,随后否决)。 较早的一个修订版会发出 session/model-request:一个不可回放的帧,携带在同一个 agent/model-request 边界测得的 contextTokenscontextWindow。真正让它失效的,是它成了 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 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。