让Agent的挣扎可见:执行轨迹面板与生成统计行

97 阅读21分钟

让Agent的挣扎可见:执行轨迹面板与生成统计行

实证案例:heart-net 个人智能体对话链路(AgentScope 2.0 + DashScope + Vue 3 SSE 流式前端)

本文所有代码片段均取自真实实现(AgentChatServiceImpl.java / ChatView.vue / api/chat.ts), 所有数字均为真实跑流实测(8093 临时实例 + 真实模型流),可复算、可回放。 与《RAG 评测体系生产实践指南》构成姊妹篇——评测管结果质量,trace 管过程质量, 两者合起来才是 Agent 工程师的完整武器。


0. 这章讲什么、给谁看

一句话定位:把对话页的 SSE 流从"只透出最终文字"升级为两个可观测消费场景——执行轨迹面板(过程时间线:模型为什么这么干)+ 生成统计行(结果账单:这次花了多少)。两者共享同一条帧协议,一次协议建设、两个消费场景先后兑现。

给谁看:

读者读法
用 Agent 框架(AgentScope/LangChain 等)想加观测的开发者通读——§2 取证方法 + §3 帧协议直接抄作业
前端做流式 UI(SSE/WebSocket)的工程师重点 §4~§5——增量帧聚合、防御性回退、错位采样计时
想知道"可观测到底观测什么"的概念型读者重点 §1——可观测 ≠ 可视化,三层拆解
排查过"Agent 越答越慢"的人直接看 §6.4——64K 上下文雪球,面板上线当晚抓到的实锤

贯穿全文的实证对象:heart-net 是一个单 agent 对话应用——AgentScope HarnessAgent 托管 ReAct 循环(思考→调工具→再思考→作答),DashScope qwen3.8-omni-flash 做模型,五个只读工具(检索/台账查询),前端 Vue 3 走 SSE 流式渲染。改造前,这条链路对用户的全部输出只有两类:打字的正文 + 一个工具名标签条;改造后,用户能看见模型每一轮在想什么、调了什么、传了什么参数、拿到什么结果、烧了多少 token。


1. 引子:模型的挣扎,你看不见

1.1 一个真实样本

先看一个改造后才"现形"的真实行为(现场截图): shot-trace-panel-7calls.png

用户问了一个知识库里其实没有对症语料的问题,模型连续发起了 7 次检索工具调用——L1 测评、Agent、skill、memory_search……每个 query 都完整可见,每次 1~2 秒,全部执行成功。最后模型基于捞到的边角料组织了一个回答。

这件事在改造前完全不可见。你只会看到:问题发出去 → 光标闪了十几秒 → 答案出来了。答案质量一般,但你说不出为什么——是模型笨?是检索烂?还是库里根本没货?

改造后,7 枪扫射摆在眼前,你一眼就能看出两件事:模型在换着关键词碰运气(说明库里大概率没有对症语料);每次调用都成功但可能没捞到货(SUCCESS 只表示工具没抛异常,不代表有可用材料)。

通俗例子:这就好比医生看诊。只有最终处方(最终回答),你永远不知道医生做了哪些检查、排除了哪些可能;有了完整病历(执行轨迹),"为什么开这个药"才有答案可查。结果没错 ≠ 过程没浪费——7 枪扫射和后面要讲的 9 次空转,答案都对,过程都在烧钱。

1.2 可观测 ≠ 可视化

做面板之前必须先立住一个概念:可观测(observability)和可视化(visualization)不是一回事。

  • 可视化是把已经存在的数据画出来;
  • 可观测是先回答"数据存在吗?够用吗?"——工程定义是:不打断系统运行,就能回答"它刚才为什么这么干"。

所以"做个面板"之前,先过三道数据关: observability-three-gates.png

层要回答的问题本案例的答案
数据层事件存在吗?带参数吗?框架事件流里到底有没有思考流、入参流、token 账单?(§2 取证)
传输层到得了前端吗?SSE 帧协议翻译:事件 → JSON 帧(§3)
呈现层渲染得出来吗?面板 + 统计行(§4~§5)

面板只是呈现层。面板做得再漂亮,也显示不出不存在的信息——玻璃窗后面得先有东西。这就是为什么本文一半篇幅在讲数据层取证和传输层协议,真正的"画界面"反而是最薄的一层。

1.3 为什么这是编排阶梯的第 1 级

heart-net 的编排学习路线里有一个六级实战阶梯(基线 → 轨迹可观测 → 撞墙取证 → 显式 workflow → HITL 断点 → 子 agent 扇出),观测排在所有编排改造之前——理由只有一句话:

改编排之前必须能观测编排,否则改了不知道好在哪。

没有轨迹面板,"把检索从一锤子改成多跳""把串行改并行"这类改造的收益只能靠体感;有了面板,每一步决策可见,A/B 对比才有据可依。观测是所有后续编排实验的地基。


2. 数据层:一个错误结论的诞生与推翻

2.1 错误结论怎么诞生的

时间回到几天前。当时在做多跳检索改造,想知道一个问题:ReAct 循环里每一跳工具调用的入参,能不能从 SSE 事件流透出到前端?(如果能,前端就能显示每跳的 query——观测多跳的关键。)

取证姿势:javap 反编译 ToolCallStartEvent,看它有哪些 getter。结果:

ToolCallStartEvent 的全部 getter:replyId / toolCallId / toolCallName

没有 arguments。于是当时的结论是:"事件不带工具入参,每跳 query 只能走服务端日志,前端帧透不出。"这个结论写进了素材卡和记忆库,还成了当时观测层设计的一个边界约束。

这个结论是错的。但错不在证据,在取证方法。

2.2 翻案:全量枚举

这次做面板,动工侦察时换了一个取证姿势——不查想到的那个类,先枚举全部类:

# 1. 解压 jar,枚举 event 包内全部类文件:35 个
unzip agentscope-core-2.0.3.jar -d target/asj
ls target/asj/io/agentscope/core/event/    # 35 个类文件

# 2. 反编译枚举类,拿全部事件类型:31 个枚举值
javap io.agentscope.core.event.AgentEventType

# 3. 逐类 javap,看每个事件带什么字段
javap io.agentscope.core.event.ToolCallDeltaEvent
javap io.agentscope.core.event.ThinkingBlockDeltaEvent
...

全量枚举后发现一个全家桶——之前只盯着 Start 类看,漏掉了它周围的兄弟:

事件类型携带字段含义
TOOL_CALL_DELTAgetDelta()工具入参增量流——模型流式吐 JSON 参数片段
TOOL_RESULT_TEXT_DELTAgetDelta()工具结果增量流——结果文本一片片到达
THINKING_BLOCK_DELTAgetBlockId() + getDelta()思考流——模型思考过程逐字输出
MODEL_CALL_ENDgetUsage()(ChatUsage:input/output/cached/total 四字段)单轮 token 账单

也就是说:入参、结果、思考、账单,观测需要的四类数据在事件 API 里全都有,之前那个"事件不带参数"的结论错得离谱——错的根源是只查了一个类就下了包级结论。

2.3 教训:取证方法决定结论质量

这个翻案比面板本身更有博客价值,因为它暴露的是一类系统性风险:

查 API 存在性,先枚举包内全部类,再逐类看 getter。只查你想到的那个类,结论天然带着盲区——你不知道自己不知道什么。

技术结论是有保质期的:结论依附于取证方法,方法有盲区,结论就有盲区。

有个细节让这个教训更真实:错误结论当时被写进了两张素材卡和一条记忆库,翻案时三处都要追加修正(保留原文 + 附修正段)——错误结论的传播成本远高于产生成本,这也是"下结论前多花十分钟取证"的性价比所在。


3. 传输层:帧协议设计

数据层确认"全家桶都在"之后,下一步是把它送到前端。总体管道:

trace-frame-pipeline.png

3.1 从三类帧到八类帧

改造前,SSE 通道里只有 4 类帧:token(正文增量)、tool(工具开始/结束标签)、done、error。改造后扩到 8 类:

帧内容设计要点
thinking思考流增量(blockId + text)blockId 区分多个思考块;模型不支持思考流时自然缺省,前端不渲染该行
token正文增量不变
tool工具 start/end(新增 id)原来只按工具名匹配,同名并发会错配;改 toolCallId 精确聚合
tool_args工具入参增量流(id + delta)模型流式吐 JSON 参数片段,前端按 id 拼出完整入参
tool_result工具结果增量流(id + delta)结果文本片段,前端聚合为结果摘要
usage单轮 token 账单ReAct 多轮会发多帧,前端累加——观测成本的第一现场
done / error结束 / 错误不变

3.2 翻译层代码:toFrame

整个协议就是 AgentChatServiceImpl 里一个纯函数——事件进、帧出,未观测到的事件类型静默丢弃(Flux.empty())。核心分支节选:

// AgentChatServiceImpl.java —— 事件 → SSE 帧的唯一翻译层(节选)
private Flux<String> toFrame(AgentEvent event) {
    if (event.getType() == AgentEventType.THINKING_BLOCK_DELTA) {
        ThinkingBlockDeltaEvent e = (ThinkingBlockDeltaEvent) event;
        if (e.getDelta() == null || e.getDelta().isEmpty()) return Flux.empty();
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("type", "thinking");
        payload.put("blockId", Objects.toString(e.getBlockId(), "-")); // 多思考块区分键
        payload.put("text", e.getDelta());
        return Flux.just(frame(payload));
    }
    if (event.getType() == AgentEventType.TOOL_CALL_DELTA) {
        // 工具入参增量流:模型流式吐 JSON 参数片段,前端按 id 聚合出完整入参
        ToolCallDeltaEvent e = (ToolCallDeltaEvent) event;
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("type", "tool_args");
        payload.put("id", Objects.toString(e.getToolCallId(), "-"));   // 贯穿聚合键
        payload.put("name", Objects.toString(e.getToolCallName(), "-"));
        payload.put("delta", Objects.toString(e.getDelta(), ""));
        return Flux.just(frame(payload));
    }
    if (event.getType() == AgentEventType.MODEL_CALL_END) {
        // 单轮模型调用 token 账单(ReAct 多轮会发多帧,前端累加)
        ModelCallEndEvent e = (ModelCallEndEvent) event;
        if (e.getUsage() == null) return Flux.empty();
        Map<String, Object> payload = new LinkedHashMap<>();
        payload.put("type", "usage");
        payload.put("inputTokens", e.getUsage().getInputTokens());
        payload.put("outputTokens", e.getUsage().getOutputTokens());
        payload.put("totalTokens", e.getUsage().getTotalTokens());
        return Flux.just(frame(payload));
    }
    // ... TOOL_RESULT_TEXT_DELTA / TOOL_CALL_START / TOOL_RESULT_END 同构
    return Flux.empty();   // 未观测到的事件静默丢弃
}

三个实现细节值得点名:

  1. Objects.toString(x, "-") 兜 null——适配器没透出的字段降级为占位符而不是 NPE 或丢帧;
  2. 空 delta 直接 Flux.empty()——思考流经常发空片,翻译层过滤掉,不让垃圾帧进通道;
  3. MODEL_CALL_END 是"每轮一张账单"——ReAct 一轮对话可能调七八次模型,每次调用结束都有一帧 usage,所以 7 帧 usage = 7 轮模型调用,这个数字本身就是行为证据(后面测试案例会用到)。

3.3 聚合协议:toolCallId 贯穿四帧

一次工具调用在前端要拼成一条完整轨迹,靠的是 toolCallId 贯穿四类帧:

tool(phase=start, id=T1) ──┐
tool_args(id=T1, delta..) ──┼── 前端按 id=T1 聚合成一条:名称+入参+结果+状态+耗时
tool_result(id=T1, delta..)┤
tool(phase=end, id=T1) ────┘

为什么必须靠 id 而不是工具名?实测抓到一个铁证:DashScope 在参数流阶段,tool_args 帧的 name 字段是 "__fragment__"——工具名还没定,参数先流出来了。如果按 name 聚合,这些增量帧全部对不上号;按 toolCallId 聚合则稳如泰山。协议键的选型要以实测为准,不能想当然——"同名匹配"在纸面上很合理,在流式现实里一枪就碎。

3.4 前端类型层

帧协议在前端的第一落点是类型定义——联合类型把 8 类帧钉死,让后续聚合代码全程有类型护栏:

// frontend/src/api/chat.ts —— SSE 流事件帧(与后端 toFrame 一一对应)
export type StreamEvent =
  | { type: 'token'; text: string }
  | { type: 'thinking'; blockId?: string; text: string }
  | { type: 'tool'; phase: 'start' | 'end'; id?: string; name: string; state?: string }
  | { type: 'tool_args'; id?: string; name: string; delta: string }
  | { type: 'tool_result'; id?: string; name: string; delta: string }
  | { type: 'usage'; inputTokens: number; outputTokens: number; totalTokens: number }
  | { type: 'done' }
  | { type: 'error'; message: string }

// 面板条目:thinking 按 blockId 聚合;tool 按 toolCallId 聚合成一条完整轨迹
export type TraceEntry =
  | { kind: 'thinking'; blockId: string; text: string; startTs?: number; endTs?: number }
  | { kind: 'tool'; id: string; name: string; phase: 'start' | 'end'; state?: string;
      args?: string; result?: string; startTs?: number; endTs?: number }

注意 TraceEntry 上的 startTs/endTs——这是前端本地采样的时间戳(§5.2 会讲为什么不用后端时间),是统计行"思考时长/工具耗时"的数据来源。


4. 呈现层 A:执行轨迹面板

4.1 聚合逻辑:帧 → 条目

面板的主体是 onStreamEvent——每帧到达时判断类型、找聚合目标、增量追加。核心分支节选:

// ChatView.vue —— SSE 帧分发与聚合(节选)
function onStreamEvent(event: StreamEvent, reply: ChatMessage) {
  if (event.type !== 'thinking') {
    closeActiveThinking(reply)        // 非思考帧到达 = 思考结束(错位采样,见 §5.2)
  }
  if (event.type === 'token') {
    reply.content += event.text       // 正文:直接拼接
  } else if (event.type === 'thinking') {
    if (!reply.trace) reply.trace = []
    const key = event.blockId || '-'
    // 按 blockId 找已有思考块;没有就开新块(startTs = 计时起点)
    let entry = reply.trace.find(
      (t): t is ThinkingTraceEntry => t.kind === 'thinking' && t.blockId === key)
    if (!entry) {
      entry = { kind: 'thinking', blockId: key, text: '', startTs: Date.now() }
      reply.trace.push(entry)
    }
    entry.text = (entry.text + event.text).slice(0, TRACE_TEXT_CAP)  // 2000 字上限
  } else if (event.type === 'tool_args' || event.type === 'tool_result') {
    const target = findToolEntry(reply.trace, event.id, event.name)  // id 优先
    if (target) {
      const field = event.type === 'tool_args' ? 'args' : 'result'
      target[field] = ((target[field] || '') + event.delta).slice(0, TRACE_TEXT_CAP)
    }
  } else if (event.type === 'usage') {
    const u = reply.usage ?? { inputTokens: 0, outputTokens: 0, totalTokens: 0 }
    u.inputTokens += event.inputTokens    // ReAct 多轮:账单逐帧累加
    u.outputTokens += event.outputTokens
    u.totalTokens += event.totalTokens
    reply.usage = u
  }
  // ... tool start/end 分支:start 建条目,end 定位改状态(见下文防御)
}

聚合目标定位带防御性回退——id 精确匹配优先,缺 id 时退回"同名且运行中"最后一条;end 帧找不到对应 start 时直接补一条终态条目,宁可多一条也不丢数据:

// 优先 toolCallId 精确匹配;缺 id(适配器未透出)时退回「同名且运行中」最后一条
function findToolEntry(trace: TraceEntry[], id?: string, name?: string) {
  if (id && id !== '-') {
    const byId = trace.find((t): t is ToolTraceEntry => t.kind === 'tool' && t.id === id)
    if (byId) return byId
  }
  for (let i = trace.length - 1; i >= 0; i--) {
    const t = trace[i]
    if (t.kind === 'tool' && t.phase === 'start' && (!name || t.name === name)) return t
  }
  return undefined
}

4.2 面板模板:折叠条 + 两种行

面板 UI 很克制——一个折叠头 + 两类行(思考行 / 工具行),模板节选:

<!-- ChatView.vue —— 轨迹面板模板(节选) -->
<div v-if="msg.trace?.length" class="trace-panel" :class="{ open: traceOpen(idx, msg) }">
  <button class="trace-head" @click="toggleTrace(idx, msg)">
    <span>执行轨迹 · {{ msg.trace.length }} 步</span>
    <span v-if="msg.usage" class="trace-usage">
      tokens {{ msg.usage.inputTokens }} + {{ msg.usage.outputTokens }}</span>
  </button>
  <div v-show="traceOpen(idx, msg)" class="trace-body">
    <!-- 思考行:紫色标签 + 块级耗时 + 暗色滚动区 -->
    <div v-if="t.kind === 'thinking'" class="trace-thinking">
      <span class="trace-tag">思考</span>
      <span class="think-elapsed">{{ thinkElapsed(t) }}</span>
      <div class="thinking-text">{{ t.text }}<span class="cursor">▌</span></div>
    </div>
    <!-- 工具行:名称 + 入参 k=v + 状态/耗时 + 结果区 -->
    <div v-else class="trace-tool"
         :class="{ running: t.phase === 'start', failed: t.state === 'ERROR' }">
      <span class="trace-tag tool">工具</span>
      <span class="trace-tool-name">{{ toolName(t.name) }}</span>
      <span class="trace-args">{{ argsPreview(t.args) }}</span>
      <span class="meta-state">{{ t.state }}</span>
      <span class="meta-elapsed">{{ toolElapsed(t) }}</span>
      <div v-if="t.result" class="trace-result">{{ t.result }}</div>
    </div>
  </div>
</div>

4.3 交互与工程细节

几个不显眼但都在防真实事故的细节:

  • 展开策略:流式期间默认展开(实时看着轨迹长出来),答完自动收起,用户手动点过之后手动优先——traceExpanded[idx] ?? !!msg.streaming,一行实现三种态;
  • 入参预览(argsPreview):完整 JSON 解析成 k=v 串、长值截 48 字;流式中半截 JSON 解析失败则原样截断 120 字——流式与终态共用一个函数,两种形态都好看;
  • TRACE_TEXT_CAP = 2000:思考流/入参/结果的聚合文本一律截 2000 字符。这不是为了好看——localStorage 持久化是面板的硬需求(刷新页面轨迹不丢),而 945 帧思考流不截断能把配额撑爆。展示上 2000 字也够了,长思考本来就是滚动区;
  • 旧消息兼容:localStorage 里改造前的历史消息没有 trace 字段,模板用 v-else-if 走旧标签条渲染——升级面板不破相历史消息,这是"渲染模型演进"最容易被忘的一步。

5. 呈现层 B:生成统计行——第二个消费场景

面板上线几天后,来了一个新需求(用户原话):"我想知道每次对话生成多少字,烧了多少 token……模型思考也需要体现出来"——四个要素:思考时长、总耗时、字数、token,一行小灰字挂在回复下方(Qoder 风格):思考 0.3s · 8.3s · 63 字 · 15693 tokens。

5.1 零后端改动:协议先行铺好,UI 后到

这次需求的实现成本是一次纯粹的复利兑现:四要素的数据,thinking 帧、usage 帧在做面板时就已经在通道里流了——统计行只是把已到达前端却被丢弃的数据接上展示,后端一行未动:

要素数据来源实现动作
总耗时send() 起点到 finally 定格前端记时
字数content.length 现算一行
tokenusage 帧累加(§3 协议原样复用)一行
思考时长thinking 块 startTs/endTs 采样见 5.2,唯一有讲究的

协议建设的复利在第二个消费者身上兑现。 面板落地时把八类帧一次铺完,看似"超前建设"(当时 thinking/usage 帧没有消费者),统计行需求到来时边际成本只剩排版。反过来,如果当时只透出最小集,这个需求就得重新动后端、重启、重新联调——通道建设的正确姿势是按数据全貌建,不是按当下需求建。

5.2 思考时长的「错位采样」

四要素里唯一有讲究的是思考时长。难题:ThinkingBlockDeltaEvent 只有增量文本,没有"思考结束"信号——帧协议里不存在 thinking_end。

解法是错位采样:用"下一个非 thinking 帧到达"作为思考结束的证据——模型开始说话或调工具了,思考必然已结束:

// ChatView.vue —— 「错位采样」:思考没有结束帧,用下一个非 thinking 帧兜
function closeActiveThinking(reply: ChatMessage) {
  for (const t of reply.trace ?? []) {
    if (t.kind === 'thinking' && t.startTs && !t.endTs) {
      t.endTs = Date.now()   // 所有未闭合思考块,此刻定格
    }
  }
}

// 思考总时长 = 全部思考块计时之和(ReAct 每轮一个块,blockId 区分)
function thinkMsOf(msg: ChatMessage): number {
  let sum = 0
  for (const t of msg.trace ?? []) {
    if (t.kind === 'thinking' && t.startTs) {
      sum += (t.endTs ?? Date.now()) - t.startTs
    }
  }
  return sum
}

「错位」的含义要说透:endTs 记录的不是模型内部思考结束的精确时刻,而是"前端收到思考结束证据的时刻"——两者之间隔着网络抖动和帧调度,毫秒级误差。对展示用途完全够(人对 0.3s 和 0.31s 无感),对计费用途不够(要跟模型方对账就得后端权威时间)。所以这里有个明确的取舍记录:

计时精度要与用途对齐,不为用不到的精度付协议改造成本。

顺带一个白送的效果:流式中耗时随帧到达实时跳动(每帧触发 Vue 重渲染,thinkElapsed 里 endTs ?? Date.now() 让未闭合块一直"活"着)——统计行在生成过程中是个活的秒表,答完在 finally 定格。这个动效一行代码没写,是响应式渲染的自然结果。

5.3 顺带修掉一个盲区

统计行上线时顺带修复了一个真实盲区:纯对话场景(零工具调用)token 完全不可见。

原设计里 token 账单只挂在轨迹面板折叠条头部——但纯对话没有轨迹,面板整个不渲染,token 就消失了。问题出在:面板的出现条件是"有 trace",而 usage 数据不依赖 trace 存在——同一批帧的两个消费者,不该共享同一个出现条件。统计行独立渲染(v-if="msg.stats || msg.usage"),盲区顺手修掉。


6. 测试案例

6.1 案例一:帧协议全命中实测

方法:8093 起临时实例(--server.port=8093,连同一 PG,对 8092 零打扰),发一条真实对话:"我的技术栈里 Java 掌握到什么程度",curl 收完整 SSE 流逐帧统计(共 3316 行):

帧类型实测帧数读数含义
thinking945思考流密度远超预期——qwen3.8-omni-flash 思考块逐字流式
token599正文
tool_args79入参增量片段(9 次调用的参数流总和)
tool_result9每次工具一条结果流
tool189 次 start + 9 次 end,精确配对
usage77 轮模型调用 = 7 张账单
done / error1 / 0正常收尾,全程无 error 帧

结论:四类新帧全部真实命中(DashScope 链路确实发射全家桶,§2 的翻案不是纸面推断);旧协议消费者不受新帧干扰;tool 18 = 9×2 精确配对说明 toolCallId 聚合协议成立。

6.2 案例二:9 次空转——首个可观测发现

这条实测顺带抓到了面板上线的第一笔可观测红利。

翻看轨迹:模型连调 9 次 queryTechStack,全部落空(技术栈台账还没有数据);7 帧 usage 显示每轮 input tokens 从 15.6K 一路涨到 15.9K——每轮模型调用都背着之前所有工具结果;最后模型诚实告知台账为空,并提议走管理端点补录。

三层数字互相印证出一个完整故事:结果正确(诚实告知)+ 过程低效(9 次空转、7 轮模型调用)。这件事以前埋在服务端日志里,现在面板上一眼可见——正是 §1 说的:结果没错 ≠ 过程没浪费。9 次空转后来成了"该不该在工具 description 里写明'台账可能为空'"的讨论起点,也是编排阶梯 2(撞墙取证)的第一份候选素材。

6.3 案例三:两个消费者互证 + localStorage 注入测试法

统计行上线后的验证分两层:

第一层,口径互证:面板折叠条显示 tokens 15630 + 63,统计行显示 15693 tokens——同一批 usage 帧喂出的两个数字,加法吻合(15630 + 63 = 15693)。两个消费者互为对方的测试用例,这是"一套协议多个消费者"架构送的免费测试。

第二层,离线 UI 验证:后端不可起时用 localStorage 注入 + 真实渲染代码路径——把带 trace/stats/usage 的消息 JSON 直接写进 heartnet.chatMessages 键再刷新页面,统计行和面板照常渲染。注入的是数据不是伪造图像,走的是完全真实的渲染代码路径——比截图靠谱,比等后端快。

6.4 案例四:上线当晚的第三个实锤——64K 上下文雪球

面板 + 统计行上线当晚,日常使用中就抓到了第三个实锤(现场截图):

shot-stats-line-64k.png

用户在多轮旧会话里问"介绍下类加载机制",统计行定格出这组数字:

观测项数值读法
tokens64413 + 218input 累计 64K / output 仅 218——钱花在"读"不在"写"
总耗时164s远超正常(新会话 7 轮 90s 内完成)
思考0.8s第一轮思考很快——模型没死,是 input 太重
字数41 字开场白秒出,随后长等待
执行轨迹3 步工具正常推进——不是卡在工具
终态用户点停AbortSignal 正常工作,非系统挂死

六个数字合起来完成了一次纯观测层的归因排除法:不是模型死(思考 0.8s)、不是工具慢(3 步正常推进)、不是生成慢(output 只有 218)——嫌疑高度集中在 64K input 的 prefill 耗时。对照新会话底座(系统提示词 + 工具 schema + MEMORY.md ≈ 15.6K),本会话 input 是底座的 4 倍+:会话历史 + 每轮工具结果全量驻留,input 逐轮滚动成雪球。

这个案例的完整归因(会话记忆回带机制、检索结果驻留形态)是另一篇文档的课题,这里只强调一点:没有统计行和面板,这个现象的形态只是"好像有点慢"——用户抱怨一句,开发者茫然排查;有了它们,现象直接定格成六个可复算的数字,归因还没开始,排除法已经做完了大半。这就是可观测的价值兑现方式:不是让问题消失,是让问题在第一时间以可分析的姿态出现。


7. 踩坑实录

当天落地过程中踩了三个坑,都有普适性:

坑一:mvn spring-boot:run 报 UnsupportedClassVersionException。报错说 RunMojo 类文件版本 61.0(Java 17+),而 shell 的 mvn 跑在 Java 8 上。迷惑性在于 mvn compile 一直是过的——因为 IDEA 已经编译过,compile goal 走缓存显示 "Nothing to compile";run goal 才真正加载 mojo 类。教训:compile 绿不代表 run 能起,两个 goal 的类加载路径不同。解法:命令前缀 JAVA_HOME=$(/usr/libexec/java_home -v 24)(本机无 JDK 21,24 向后兼容 21 字节码)。

坑二:幽灵构建撞车。同步静态资源到 target/classes/static 后,页面引用了一个新 hash 的入口 bundle,但配套的 ChatView chunk 没在目录里——404 主模块。取证发现:用户终端 11:30 起挂着的 npm run dev 在 16:16 触发过一次完整 build,和手动 cp -R 撞车,拷进去的 index.html 是 dev 系构建的、chunk 却是旧的。教训:同步产物前 ps 确认无并发构建;同步后做一致性校验(index.html 引用的每个 hash 文件都存在且能被服务)。

坑三:bundle 验证 grep 错地方。验证面板代码进没进 bundle 时,grep 入口 index-*.js 找不到 trace-panel——差点误判"没构建进去"。真相是 Vue Router 懒加载把视图代码拆进了 ChatView-*.js chunk,入口里根本没有。教训:验证功能进 bundle,要 grep 对应视图的 chunk 文件,不是入口文件。


8. 结语:一条协议,三个消费场景

回看这次改造的产出结构,最有价值的不是面板也不是统计行,而是中间那条帧协议:

消费场景看什么抓到的实锤
§4 执行轨迹面板过程时间线9 次空转——看得见浪费
§5 生成统计行结果账单纯对话 token 盲区——看得见成本
§6.4(下一个消费者)上下文体量64K 雪球——看得见膨胀

三个消费场景、三个实锤,全部来自同一条协议、同一批帧。而第三个消费者(会话上下文的量化归因)现在才刚开场——它需要的 usage 逐轮数据、轨迹的步骤数据,协议里早就有了。

可观测建设不是"加个监控页面",是修一条数据公路——路修通了,消费者会一个一个自己长出来。

而修路的第一块砖,永远是数据层那句朴素的问话:这个数据,存在吗?——问之前,先把包里的类全枚举一遍。