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

让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 一个真实样本

先看一个改造后才"现形"的真实行为(现场截图):

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

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

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

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

1.2 可观测 ≠ 可视化

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

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

所以"做个面板"之前,先过三道数据关:

层 要回答的问题 本案例的答案
数据层 事件存在吗?带参数吗? 框架事件流里到底有没有思考流、入参流、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。结果:

objectivec 复制代码
ToolCallStartEvent 的全部 getter:replyId / toolCallId / toolCallName

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

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

2.2 翻案:全量枚举

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

bash 复制代码
# 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_DELTA getDelta() 工具入参增量流------模型流式吐 JSON 参数片段
TOOL_RESULT_TEXT_DELTA getDelta() 工具结果增量流------结果文本一片片到达
THINKING_BLOCK_DELTA getBlockId() + getDelta() 思考流------模型思考过程逐字输出
MODEL_CALL_END getUsage()(ChatUsage:input/output/cached/total 四字段) 单轮 token 账单

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

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

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

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

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

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


3. 传输层:帧协议设计

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

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())。核心分支节选:

java 复制代码
// 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 贯穿四类帧:

ini 复制代码
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 类帧钉死,让后续聚合代码全程有类型护栏:

typescript 复制代码
// 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------每帧到达时判断类型、找聚合目标、增量追加。核心分支节选:

typescript 复制代码
// 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 时直接补一条终态条目,宁可多一条也不丢数据:

typescript 复制代码
// 优先 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 很克制------一个折叠头 + 两类行(思考行 / 工具行),模板节选:

html 复制代码
<!-- 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 现算 一行
token usage 帧累加(§3 协议原样复用) 一行
思考时长 thinking 块 startTs/endTs 采样 见 5.2,唯一有讲究的

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

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

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

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

typescript 复制代码
// 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 行):

帧类型 实测帧数 读数含义
thinking 945 思考流密度远超预期------qwen3.8-omni-flash 思考块逐字流式
token 599 正文
tool_args 79 入参增量片段(9 次调用的参数流总和)
tool_result 9 每次工具一条结果流
tool 18 9 次 start + 9 次 end,精确配对
usage 7 7 轮模型调用 = 7 张账单
done / error 1 / 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 上下文雪球

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

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

观测项 数值 读法
tokens 64413 + 218 input 累计 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 逐轮数据、轨迹的步骤数据,协议里早就有了。

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

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


相关推荐
亚林瓜子1 小时前
Spring Boot 集成 ip2region 离线 IP 库实战指南
java·spring boot·后端·ip·ip2region
盛世宏博智慧档案2 小时前
恒湿净化一体机联动开发:档案馆温湿度阈值判断与启停控制实践
java·后端·struts·恒湿机·加湿除湿消毒净化一体机
兔子零10242 小时前
FastAPI 零基础入门教程:写过 Node.js,就能轻松学会 Python 后端开发(一)
前端·后端·python
Thneonl2 小时前
ZK、etcd、Consul 怎么选:差别不在共识,在壳
后端·架构
vipxieliang2 小时前
ValidX 应用于 HR系统员工信息验证:入职、离职、社保数据
java·spring boot·后端
花间相见2 小时前
【计算基础|网络15】【上手】Socket 网络编程:亲手写 TCP/UDP 程序
后端
huaweichenai2 小时前
spring boot实现base64上传
java·spring boot·后端
ThinkerQAQ_2 小时前
并发编程(九):BlockingQueue 与 Channel——语言层的原子性、可见性与有序性
后端
花间相见2 小时前
【计算基础|网络16】【工程实战】Nginx 部署与反向代理
后端