Skip to content

feat: 会话标签页显示实时 Token 输出速度 - #432

Merged
xintaofei merged 2 commits into
xintaofei:mainfrom
pia:feat/token-output-speed
Aug 12, 2026
Merged

feat: 会话标签页显示实时 Token 输出速度#432
xintaofei merged 2 commits into
xintaofei:mainfrom
pia:feat/token-output-speed

Conversation

@pia

@pia pia commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

功能

每个会话标签页在生成过程中,于 composer 下方状态栏、上下文窗口百分比指示器之前,显示实时 Token 输出速度(如 12.4 tok/s);生成结束自动隐藏。

Token 输出速度

实现方式

  • 纯前端估算:读取每个标签页连接状态中流式到达的 text + thinking 内容(只统计根 agent,排除子代理输出)
  • 本地换算:中文约 1.8 字符/token,其他可见字符约 4 字符/token
  • 时间常数指数平滑(τ ≈ 1.5s),突发与停顿读数自然;显示节流 500ms
  • 复用现有 per-tab 连接 store(与 ComposerContextUsage 相同模式);无后端改动,桌面 / Web / 服务器 / 移动端共用同一套代码

改动文件

  • src/lib/token-speed.ts — 字符换算 + 平滑 tracker(纯函数,已单测)
  • src/components/chat/token-output-speed.tsx — 徽标组件
  • src/components/chat/message-input.tsx — 在 ComposerContextUsage 前渲染
  • 10 个语言文件各新增 2 个 i18n key
  • 测试:src/lib/token-speed.test.tssrc/components/chat/token-output-speed.test.tsx

验证

  • tsc --noEmit 通过
  • vitest run 全量通过:281 个文件 / 3632 个测试
  • ESLint(改动文件)通过

@xintaofei

Copy link
Copy Markdown
Owner

感谢这个 PR,功能很实用,实现也比我预期的扎实 👏

我在本地把 feat/token-output-speed 完整跑了一遍,结论是可以合并,稍后我会进行合并。下面几条都不阻塞,当作后续 polish 记一下就好。

本地验证(全绿)

  • tsc --noEmit
  • eslint(全部改动文件)✅
  • vitest run ✅ 281 files / 3632 tests
  • next build(静态导出)✅

读代码时确认没问题的几处

这几个判断都很准,特意说一下,看得出是真的读过 connection store 的 reducer 才动手的:

  • parentToolUseId == null 排除子代理内容是对的 —— 本地 LiveContentBlock 里这个字段是 parentToolUseId?: stringundefined == null 成立。
  • 回合边界的注释和代码完全对得上:STATUS_CHANGED → "prompting" 会用 content: [] 重建 liveMessage,所以组件的空内容分支确实会 reset tracker。
  • 中途接入的 seeding 避免了刷新/重连后把整段已累积文本算成一次 delta,这个坑埋得很深,处理得好。
  • 把 2Hz 的 state 收在叶子组件里,composer 不会跟着重渲染;subscribeKey 的清理、[store, tabId] 依赖都稳;面板按 key={tab.id} 常驻所以 tabId 不会变,用 useState 而不是 useSyncExternalStore 也不会有跨 tab 读数残留。
  • 徽标放在右对齐 shrink-0 组的第一个位置是对的 —— 宽度变化只移动它自己的左边缘,不会顶动右边的上下文圆环和连接状态图标。

1. 冷启动会从 0 慢慢爬上来,前 2–3 秒明显偏低

这条是实际能看到的。applyMappedEnvelope 里每条 envelope 在 handleMappedEvent 之后都会 dispatch({ type: "EVENT_APPLIED" })acp-connections-context.tsx:3703),它改了 lastAppliedSeq 就会产生新的 connection 对象并同步 notifyKeyListeners:1422 / :2736)。而 content_delta 走的是 16ms 批次的 enqueueStreamingAction:3054)。两者一叠加,每条原始 envelope 都会触发一次订阅回调,但那一刻 liveMessage.content 还没变,tracker 拿到的就是一个 delta = 0 的样本。

于是 seeding 之后紧接着那次 observe 通常是 0,而 token-speed.ts:56this.ewma == null ? instant : ... 会把 EWMA 冷启动到 0;再加上 lastRenderRef.current = 0token-output-speed.tsx:29)保证第一次必定渲染,徽标是以 0.0 tok/s 出现的,然后按 τ=1.5s 往上爬。按真实顺序模拟(真值 60 tok/s):

0.5s=17.0  1.0s=29.1  1.5s=37.7  2.0s=44.4  2.5s=48.7  3.0s=51.8  4.0s=56.1  …→ 60.0

稳态是准的,说明时间常数式 EWMA 的选型没问题 👍,就是短回合可能全程都在爬坡。加个 bias correction(ewma / (1 - exp(-elapsed/τ))),或者跳过 delta = 0 的样本再初始化,就能收敛得快很多。

2. 日文假名和韩文谚文没算进 CJK

token-speed.ts:14CJK_RE 覆盖了汉字和半/全角形式,但不含平假名 U+3040–309F、片假名 U+30A0–30FF、谚文音节 U+AC00–D7A3,这些字符落进了 other / 4 那一档。按这份代码自己的比例算,漏掉的字符是 0.25 token/字符 而不是 0.556,正好差 2.22×(真实分词器的差距视模型而定,混排日文里汉字部分算对了会拉回来一些,纯假名/谚文则可能更大)。项目带 ja / ko 语言,补两个字符范围就能修。另外 CJK 扩展 B(U+20000+)和 emoji 是代理对,会按 2 个 "other" 字符计。

3. 每次通知都会全量重扫已累积文本

token-output-speed.tsx:67 每次回调都遍历全部 root text/thinking 块,estimateTokensmatch + replace(/\s/g,"") 又各扫一遍全文并分配。配合上面那个 per-envelope 的通知频率,这是 O(n²)/回合。实测(Apple Silicon,按 40 次通知/秒估算):

回合规模 单次扫描 每秒流式的 CPU
2k tok 0.04 ms ~1.8 ms
8k tok 0.18 ms ~7 ms
20k tok 0.48 ms ~19 ms
50k tok(中文) 1.31 ms ~52 ms

日常回合完全无感,只有很长的回合、尤其分屏多个 tab 同时在流的时候才会显出来,所以不当阻塞项。好在修起来很干净:estimateTokens 对字符串拼接是严格可加的(两项都是逐字符计数,字符类也不跨字符),所以只统计每个 block 新追加的后缀即可,结果逐位一致,复杂度降到 O(delta)。

4. 静默间隙读数会冻住

observe 只在订阅回调里被调用,没有心跳。TokenSpeedTracker 的注释说「工具停顿时会衰减到 0 而不是钉住旧读数」—— 这只在期间仍有事件到达时成立。工具有进度输出时确实会顺带衰减(:3097),但静默的工具、重试等待、尤其是权限 / 提问弹窗阻塞期间是完全没有事件的(keepalive 定时器明确不发事件,:3987),这时徽标会停在最后一个值不动。加个 500ms 的 setIntervalobserve(lastTotal, now) 就能让注释描述的衰减真正成立。

5. 小 nit

  • 读数没有 tabular-numstoken-output-speed.tsx:88),2Hz 刷新时数字会轻微抖动。
  • aria-label 挂在没有 role 的裸 <span> 上不一定会被朗读(composer-context-usage.tsx 是挂在 <svg> 上的),而且这个 label 是静态的、不含变化的数值。
  • 注释里的 ponytail: 标记全仓库只有这两处,不是现有约定,建议换成 TODO:
  • docs/images/token-output-speed.png(24KB)提交进来了,但仓库里没有任何地方引用它(PR 描述用的是 fork 的 raw 链接,fork 分支删掉后会挂)。要么在 README/docs 里接上,要么图片直接传到 PR 描述里、不进仓库。
  • 组件测试把整个 store mock 掉了,覆盖的是 2 个正向 + 2 个初始隐藏用例;回合结束 reset、500ms 节流、以及第 1 条那个真实的 EVENT_APPLIED / 批次交错顺序都还没覆盖(reset()token-speed.test.ts:66 有单独测到)。

再次感谢 🙏 上面这些都不影响合并,我先合了,后续再跟进。

Follow-up to the token-output-speed badge, closing the review notes on it.

Bias-correct the smoother. Every wire envelope dispatches EVENT_APPLIED and
notifies subscribers before the 16ms-batched content delta lands, so the first
sample after a turn begins routinely carries no new tokens. Cold-starting the
EWMA from that sample opened the badge at 0.0 and crawled toward the true rate
over several seconds; tracking the accumulated weight and dividing it back out
makes the reading the turn's cumulative average until TAU has elapsed, and an
exponential window after. A 300ms warmup gate keeps the first reading off the
screen until it covers a meaningful slice of wall clock. On a 60 tok/s stream
the opening reading goes from 0.0 to 57.6.

Measure incrementally. Re-scanning the whole accumulated turn on every
notification was O(turn) per event on the synchronous dispatch path, and both
the CJK match and the whitespace strip allocated. TokenCountAccumulator keeps
each block's consumed length and measures only what was appended, and
countChars is an allocation-free char-code scan. 73-315x less work over a turn,
with results identical to the whole-string measure. Re-seat on a live message
id change, since a snapshot hydration replaces the message rather than
appending to it.

Widen the dense-script class. Hiragana, katakana, Hangul and CJK punctuation
(。、) all fell into the latin bucket, under-reporting Japanese and Korean
output by more than 2x. Astral characters now score once via their high
surrogate, which keeps the count exactly additive across a suffix split.

Add a heartbeat so the rate decays through a silent gap instead of freezing:
the store only notifies on wire activity, and a quiet tool, a retry backoff or
a blocking permission prompt would otherwise pin the last reading.

Also: tabular-nums so the digits stop shimmering, the accessible name moved off
the bare wrapper span onto the icon to match ComposerContextUsage, and drop the
screenshot that nothing in the repo referenced.
@xintaofei

Copy link
Copy Markdown
Owner

上面那些后续项我直接在这个分支上补了一个 commit(695d53c3),你原来的提交没动 🙂 如果哪里改得不合你意,尽管推翻或者让我调整。

对应关系:

1️⃣ 冷启动爬坡 — 给 EWMA 加了偏差校正(跟踪累计权重再除回去,就是 Adam 那套),并加了 300ms 的预热闸门。这样在 τ 之前读数是这一回合的累计均值,之后才退化成指数窗口。按真实的 EVENT_APPLIED / 批次交错顺序模拟,真值 60 tok/s 时首个读数从 0.0(爬 5 秒)变成 57.6

2️⃣ 假名 / 谚文 — 字符类改写成 charCode 区间判断,补上平假名、片假名、谚文,以及之前也漏掉的 。、(U+3000–303F 那一段)。代理对现在按高位算一次,这样即使增量测量正好从代理对中间切开,计数依然可加。

3️⃣ 全量重扫 — 新增 TokenCountAccumulator,缓存每个 block 已消费的长度,只测新追加的后缀;countChars 也换成了零分配的 charCode 扫描(不再有 match 的匹配数组和 replace 的整串拷贝)。实测:

回合 改前 改后
8k tok 英文 96 ms 1.3 ms
20k tok 中文 862 ms 2.7 ms
50k tok 中文 5618 ms 18.7 ms

(整个回合累计的 CPU;和整串测量的数值误差是 0.0,不是近似。)

4️⃣ 静默间隙冻住 — 加了 500ms 心跳,回合开始时启动、回合结束/卸载时停掉,让注释里写的那个衰减真正成立。

5️⃣ nittabular-numsaria-label 从裸 <span> 挪到图标上(对齐 ComposerContextUsage);ponytail:TODO:;删掉了仓库里没被引用的那张截图(PR 描述里的图还在,不影响看)。

额外补了一个:TokenCountAccumulator 缓存前缀的前提是「回合内只追加」,但 snapshot hydration 是整条 liveMessage 换掉的(acp-connections-context.tsx:1399),换进来的文本如果不短于缓存长度但前缀不同,缓存就会失效。所以按 liveMessage.id 变化重新播种了。

测试从 16 个加到 36 个,每个新增用例都做了变异验证(去掉心跳只挂衰减那条、去掉偏差校正只挂冷启动那几条、去掉重新播种只挂 hydration 那条)。这个分支上 tsc --noEmiteslintvitest(281 files / 3652 tests)、next build 都是绿的。

@pia

pia commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

赞,大佬辛苦了

@xintaofei
xintaofei merged commit d29e0fc into xintaofei:main Aug 12, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants