DSH 为什么敢「万物皆插件」:会话日志是唯一真相

文章来源声明: 原文作者:Regulus手记; 来源站点:掘金; 原文链接:https://juejin.cn/post/7685304273191354406; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

日志即真相源的架构里,能力散落在各个消费者手中,纪律却无人强制。本文对排查插件体系的静默失效、以及设计日志驱动状态的项目都很有参考价值。

> 一套能稳定运行的插件化体系,不仅仅靠一套能替换的插件系统,更重要的是**一个不动的真相源**。DeepSeek Harness 用仅追加的 Session 事件日志,把可插拔建立在可重建之上。

最近在 DSH 上写一个工具,我要算 TTFT 和吞吐量。但是查看轨迹页面时发现,界面上首token时间不可用。

image.png

拉日志

第一反应是:这个数据是不是压根没记?那就把日志拉下来看。

screenshot-20260915-082524.png

日志是 JSONL,一行一个事件,带 seq / time / type / data。我要的东西在 assistant/message 事件的 data.stream 里。

但它不是我预期的那种「一行一个 token」的裸增量,而是压缩批

<span>{</span><span>"type"</span><span>:</span> <span>"reasoning-chunks"</span><span>,</span> <span>"time0"</span><span>:</span> <span>1787797260198</span><span>,</span> <span>"index"</span><span>:</span> <span>0</span><span>,</span>
 <span>"dt"</span><span>:</span> <span>[</span><span>147</span><span>,</span> <span>49</span><span>,</span> <span>1</span><span>,</span> <span>0</span><span>,</span> <span>0</span><span>,</span> ...<span>]</span><span>,</span>
 <span>"texts"</span><span>:</span> <span>[</span><span>"The"</span><span>,</span> <span>" user"</span><span>,</span> <span>" wants"</span><span>,</span> <span>" me"</span><span>,</span> ...<span>]</span><span>}</span>

  • texts 是逐 token 的文本
  • dt 是每个 token 相对上一个的时间增量
  • time0 是这一批的基准时间

写了个脚本,发现数据都在

time0 逐项累加 dt[i-1],就能还原出每个 token 的绝对时间;首个非空文本或推理成员的时间,就是首 token 时间。

image.png

跑完之后,得到一份真实会话的账单:

  • 175 个请求,TTFT 平均 3.47s(最小 0.69s,最大 17.86s)
  • 总生成 2795.2s,输出 307,128 token,平均吞吐 109.9 tok/s
  • 其中 207,949 token 是推理,占 68%

信息全都在日志里。 并不需要额外采集。

而且我注意到,推理占了输出的三分之二,这和我当前会话的思考深度有关。

但同一个界面里,有的能算,有的不能

数据既然在,那问题就变成了:谁没用上它。

继续往下看,轨迹面板的首 token 时间来自一个状态字段 firstTokenTime,而它唯一的赋值点是处理实时增量块时的判断——只有块类型是 text-delta / reasoning-delta / tool-call-delta 时才会被设置:

<span>// /packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts</span>
...<span>isTokenDelta</span>(chunk) && state.<span>firstTokenTime</span> === <span>undefined</span>
  ? { <span>firstTokenTime</span>: time }
  : {}

而它消费的 chunk 来自客户端合成的 assistant/live-chunk 事件——这个事件只在流式进行中存在。会话结算之后,往 firstTokenTime 赋值的那条实时路径断了。

这会导致现象:直播的时候正常,历史会看就是undefind

我找了个对照组:

消费者是否解码压缩批TTFT
`dsh-session-stats`✅ 调 `assistantStreamFirstTokenTime`正常
`dsh-client-connection`✅ 本地 `expandAssistantStream`正常
`dsh-client-ui-trajectory`❌ 只认裸 delta不可用

同一份日志,有的reader解码了,有的没解码。

我在Discussions看了下,之前的问题已经有人在讨论,分析也透彻,感兴趣的可以看下#6129,这篇不重复那些答案。

而且我翻阅了源码note,发现这是一次处于性能优化导致的缺口,那么为什么一次为性能做的优化,能让一个能力静默消失?

仓库里有一份 2026-09-06 的 Agent Note:Embedded Assistant stream consumers read compact records。它解释了这次改动的动机。

所有折叠嵌入流的消费者都先调 expandAssistantStream()(这是个会把整个压缩批完整物化成成员数组)。可消费者常常只需要小量数据(找首 token、找最后一个 usage、拼一段文本),却付出了 O(members) 的分配和时间:每个成员大约多两个对象。

改后的收益很可观:

阶段改前改后
首次打开 · 投影28.0 ms**5.9 ms**
首次打开 · 总计76.9 ms**53.8 ms**
首次打开 · 峰值内存137.2 MB**94.6 MB**

dsh-llm 直接支持从压缩记录回答问题,并造了一批提前退出的记录级读取器——runFirstTokenTimeassistantStreamFirstTokenTimelastAssistantStreamChunk 等等。只取一个事实,不展开整个数组。

同一份 note 里还有一句记录:

Chat and Trajectory sections settled directly from it.

也就是说,Chat 和 Trajectory 改成直接从嵌入流结算,不再先物化后折叠。

所以首token时间的数据源没了,这也是为什么新版本出现了这个问题。

我翻了提交记录,commit 84c11c7243 有对测试的改动。

它把轨迹面板的一个断言,从「期望有值」改成了「期望为空」:

diff

- <span>expect</span>(finalNode?.timing?.firstTokenTime)<span>.toBe</span>(<span>1</span>_999)
+   timing: { firstTokenTime: null },

同时新增了一个用例,名字里写了:

uses live Assistant deltas <span>without</span> replaying settled embedded streams

这意味着:

  1. 行为变化是有意的,「不重放已结算的流」是这次提交的目标,这是有实质收益。
  2. 空值被编码成了期望,测试不再报警。至于产品上显示「不可用」是未被识别的副作用,还是被接受的取舍,单看 diff 推不出来。

这才是这类问题最难被发现的地方:当测试跟着实现一起改,测试用例不能保证用户可见的结果。

最后,落到 DSH 的设计思路

这不是一次孤立的疏忽,而是一套设计取向的必然副产品。

第一,解码被定义成「读者的责任」。 Agent Note Session format v2 里写着:

Ordinary Session restoration validates the settlement fields needed by the runtime without expanding every historical stream; consumers that expand a compact stream validate its records when they read it.

这条设计的合理性很明显:如果恢复会话时展开所有历史流,成本会随会话长度线性增长;而绝大多数时候,打开一个会话根本用不到每一段流。所以「谁要读,谁自己解码」是省资源的选择。

第二,时间信息原本有一个更持久的归属,但被推迟了。 2026-08-04 那份(现已归档的)Note Web turn and window latency/throughput metrics 里,有一个被推迟的方案:

A durable session projection (token-meter shape) … would survive compaction and window paging and cover the whole log. Deferred, not rejected.

一个持久化投影,能让时序活得比加载窗口久、覆盖整条日志。它被推迟了,理由是投影状态必须保持 O(1)——存均值,而不是分位数。

于是现在的时序,完全挂在节点上的 firstTokenTime 上。而节点,是实时路径的产物。

第三,规则被集中了,谁必须调用这些规则,仍然没人强制。之前依赖的部分没有被完全收束。 同一份 09-06 的 note 里还有这句:

The token, visibility, and visible-text rules have one home in dsh-llm, so a record reader and the accumulator's packing rules cannot drift apart.

「打包规则」和「读取规则」被放进了同一个归属,这防止了它们互相漂移。

DSH 的设计

我能说的是这个架构层面的观察:

可插拔建立在可重建之上。但「可重建」不是一个保证,是一条纪律。

它由每一个消费者各自履行,而没有任何机制强制。当一次优化让某个消费者不再履行,必然会出现副作用,但这套纪律正是保证这套插件系统稳定运行的基础。

这不是 DSH 独有的问题。任何把「状态从日志推出来」当基础的体系,都会长成这个样子:能力分散在每个消费者手里,而它们的义务已经写在文档里,需要自己遵守。

就这一次缺口而言,结算时补一次回退解码就够——#6129 已经给出了修法。但它修的是「这个读者忘了履行纪律」。若要让时序指标活得比加载窗口久、不跟着某个 UI 折叠一起漂移,归属还得回到那份被推迟的持久化投影。

这次的遇到的bug,本身也是这个理念一次侧面证明。

排查时顺着 Agent Note 往下读,很像在和开发团队隔空对话:动机、取舍、推迟项都写在仓库里,谁都能看见。这种「取舍可被后来者读懂」的体验,大概就是我喜欢 DSH 的原因。