Repository navigation
Replies: 1 comment
Verified first: your quotes are exact, and the rule you object to is a written ruleI checked every excerpt in your report against
And the rule is documented as a rule, not as an accident: Your numerator premise also holds structurally, not just in your logs: 1. The anchor has a second setter — your patch location is still right, though not for the reason the snippet suggests
( When does it fire? Only after a failed or aborted attempt. The loop appends So on the normal path The "first wins" rule at
const tokenAnchor = open.firstTokenTime ?? assistantStreamFirstTokenTime(event.data.stream) ?? null
const blockStart = assistantStreamFirstBlockStartTime(event.data.stream)
const decodeStart = tokenAnchor !== null && blockStart !== undefined
? Math.min(blockStart, tokenAnchor)
: tokenAnchorthen 2. The same gap also drops steps — your two errors sit in one total, pointing opposite waysEverything is gated on the anchor ( A step whose stream carries no Two consequences for your tables:
A cheap check on your own 47 sessions: count steps where the message carries a usage report while 3. Option (3) is smaller than you think, and it has a trapAn end-to-end model time already ships. The trap: the two totals do not cover the same steps. 4. Option (4): your conclusion is right, your stated reason is not
So "cannot be implemented universally" reads better as "cannot be implemented uniformly" — subtracting would work where reported and be undefined elsewhere, which is precisely the cross-provider comparability you are trying to restore. That is a stronger reason to refuse the change than unavailability, and it is the same reason your first fix is the right one. 5. The fix has three sites, not two — and the third is a second
|
| layer | token rule | first-token capture | consumer |
|---|---|---|---|
| host | llm/src/assistant-stream.ts:250 |
:333 |
session-stats/src/projection.ts:148 → pill and dialog |
| ui-chat | conversation-nodes/event-projection.ts:167 |
conversation-nodes/assistant.ts:150, :158-159 |
turn-metrics.ts:39-40 → window-fold fallback (StatsPills.tsx:53) |
| ui-trajectory | trajectory-event-projection.ts:145 |
trajectory-assistant-definition.ts:187-188 |
TrajectoryTable.tsx:365-370 throughput() |
The third is not a mirror of the second: TrajectoryTable.tsx:365-370 computes outputTokens / ((completedTime − firstTokenTime) / 1000) from its own fold and renders it as {value} tok/s (locales.ts:67) in the step timing panel. Your item (2) covers the ui-chat side; the trajectory panel keeps the old anchor unless it is changed too. The clients do not import dsh-llm for this rule — each carries its own isTokenDelta — so editing the host reader fixes the host and nothing else.
The correction belongs in those three folds. The display is a different matter, and better than I first assumed when writing this section: the activity pill is one dock entry registered by id — packages/client/ui-chat/src/client/apply.ts:322-327 registers the conversation.composer.dock cells activity and usage, with the source comment "One dock entry per pill, so a plugin replaces or adds a single pill by id". So a client plugin can ship its own activity cell and recompute the anchor itself from the durable assistant streams, which still carry the block-start records: option (3) is shippable without waiting for upstream. What such a plugin cannot do is change the values the other surfaces read — the window-fold fallback and the trajectory timing panel.
6. One thing I cannot verify from this tree, and the measurement that would settle it
In llm-pi-ai the block-start you want to anchor on is emitted from pi-ai's own event: case 'thinking_start': yield { type: 'block-start', index: event.contentIndex, blockType: 'reasoning' } (packages/llm/llm-pi-ai/src/stream.ts:166). Whether that instant is when reasoning began or when the adapter learned the reasoning item exists is decided inside pi-ai, not in this repository — so I cannot confirm from the harness that block-start is the right anchor rather than merely a better one. Your data shows better (4.71× → 1.49×); "right" hinges on one measurement you can take on the affected channel without reading pi-ai:
Compare the
block-start { blockType: 'reasoning' }record's timestamp with its matchingblock-end { block: reasoning }. If the gap is roughly the thinking duration, the anchor is a true start; if the gap is ≈ 0, the block-start was synthesised when the completed item arrived, and the improvement comes from a different effect than the one the report describes.
Worth stating in the fix's own terms too: block-start is not guaranteed to exist. packages/llm/llm/src/assembler.ts:34 documents tolerance of "delta-only protocols (no block-start/end)", so on such a protocol the new reader returns undefined and the number stays exactly as today's. Your ?? firstToken fallback handles that correctly — it just means the change is "anchor at the earliest signal the stream can prove", not "always correct", and the corrected figure remains arrival-window based, i.e. still dependent on what the provider chooses to stream.
Uh oh!
There was an error while loading. Please reload this page.
[Bug] sessionStats 的 TPS 口径缺陷:思考不以增量下发时,decodeMs 起点滑到可见文本/工具调用上,显示值虚高 3~5 倍
摘要
底部统计条的 tok/s 取自
sessionStats的decodeTokens / decodeMs(decodeMs= 组装完成时间 − 首 token 时间,decodeTokens= 提供方上报的outputTokens,含思考 token)。当某个提供商的流不把思考内容作为增量(delta)下发,只在结束时整块给出时,
decodeMs的起点会退到第一段可见文本或第一个工具调用参数——而分子仍然包含全部思考 token。结果是同一份数据被放大 3~5 倍(以工具调用为主的步可达 30 倍)。根因不是提供商的实现有问题,而是decodeMs依赖了一个并非所有提供商都满足的可观测性前提。环境
llm-pi-ai挂载,api: openai-responses%USERPROFILE%\.dsh\sessions\<workspace>\session-<id>\session.v4.jsonl.zstd@deepseek-ai/dsh-session-stats、@deepseek-ai/dsh-llm、@deepseek-ai/dsh-client-ui-chat复现
api: openai-responses的 OpenAI 兼容第三方网关,模型为推理模型(思考等级非 off)。期望:显示值约为端到端吞吐的 1.2
1.5 倍(与思考逐字下发的提供商同量级)。5 倍**。实际:显示值为端到端的 **3
该提供商的判定特征(无需看提供商实现即可确认):
assistant/message.stream中reasoning-chunks记录数为 0,只有chunk.block-start(blockType: "reasoning")与结束时整块的chunk.block-end。根因(源码定位)
① 主机侧的折叠 ——
@deepseek-ai/dsh-session-stats的sessionStatsProjectionDefinition.apply(),assistant/message分支:② "首 token"的判定 ——
@deepseek-ai/dsh-llm的assistant-stream.ts:即:"首 token"只认承载 token 的增量片段。当思考整块下发时,流里不存在
reasoning-delta,block-start(reasoning)又不被isTokenDelta接受,锚点于是滑到第一段text-delta或第一个tool-call-delta——而后者往往出现在思考结束之后。③ 客户端同口径镜像 ——
@deepseek-ai/dsh-client-ui-chat的turn-metrics.ts→assistantStepReading():(该值在投影缺失时作为窗口折叠回退使用,与主机侧同一个锚点假设。)
数据
本机 47 个会话(46 个可用,21.6 MB 压缩日志)离线复算:按
finish.replayState.response.provider/api分组,用与源码逐字等价的公式重放每个 step。两个关键读法:
block-start不会早于首个思考 delta,Math.min取不到它。也就是说下面建议的改动对已经正常的提供商是零影响。步级分布(单个受影响会话,173 步):锚点落在可见文本的 103 步虚高 2.7×;落在工具调用的 70 步虚高 6.5×;最坏一步 30×(该步 1,705 输出 token,可见文本仅 57 字符,思考文本 6,419 字符)。
字符量对照(同一会话):思考 588K 字符 vs 可见文本 76K vs 工具参数 190K——分子的大头是思考,而它恰恰不在分母覆盖的区间里。
建议的修法
(1)把
decodeMs的起点改为"生成开始",即首个block-start与首个 token delta 二者较早者理由:分子是"全部输出 token"(思考+可见文本+工具参数),分母就应当覆盖这批 token 被产出的整个区间。
block-start是流中"模型开始产出"的最早可观测信号(实测出现在 step 起点后 2.1~3.6 s,而非 0 s,说明它标记的是开始产出而不是"流刚建立")。在逐字下发思考的提供商上它不早于首个思考 delta,故改动零影响(见上表)。(2)可选:客户端镜像同步
assistantStepReading()使用同一个锚点(需要把blockStartTime一并写入 assistant 节点的timing),使"无投影时"的窗口折叠回退与主机口径一致。(3)可选:把两个含义拆成两个数字
统计弹窗目前只有一个速度值,它同时承担"解码速度"和"这一轮花了多久"两种含义。并列一个"端到端"速度(分母用
completedTime − stepStartTime)后,两个问题各有一个诚实的答案,不需要任何一方妥协。(4)不建议:从分子里扣掉思考 token
那会让指标退化为"可见文本速度";而且多数网关的 usage 并不上报可用的
reasoningTokens(本机 1,431 个 step 的用量记录里该字段一次都没出现过),无法普遍实现。影响面
sessionStats(底部统计条与统计弹窗)。不影响真实速度、计费、工具调用或缓存命中。output_item.done/ 整块给出"的通道都会命中,属于面向未来提供商的鲁棒性问题。附:复现脚本
按 zstd 多帧逐帧解码
session.v4.jsonl.zstd,再按上述公式重放每个 step 即可复现(约 120 行)。如果需要,我可以在本讨论下贴出全文,或按仓库的测试框架把"思考整块下发的 step"改写成一个sessionStats的单测用例。All reactions