我们上线 Trace 系统后,团队某天讨论了一个问题:"如果把 Lily 的模型从 deepseek-v4-pro 换成 gpt-4.1,同一个用户输入质量会变好还是变差?"

讨论很快陷入主观——有人觉得 deepseek 逻辑更强、有人说 gpt-4.1 的输出更友好,谁也说服不了谁。因为缺少一个关键能力:拿历史对话一模一样地扔给不同模型,对比输出。

这不是一个"加个按钮"的需求。它需要:你能拿到当时给模型的完整 prompt(不是模板,是拼好的原文),你需要把副作用隔离(不真的发消息、调业务 MCP),你还需要对比结果有地方看。

这就是 Trace Replay——Agent 决策血缘系统的"杀手特性"。本文记录从快照采集到回放闭环的全过程。


1. 回放 ≠ 重跑

先说清楚为什么不能"重跑"——不能重新调 PromptBuilder 再拼一遍 prompt 然后扔给新模型。

1.1 引用方案失败

一个看起来很自然的方案:Trace 里不存完整 prompt 文本,只存引用——prompt_template_version=v2 + kb_ids=[...] + skill_ids=[...]。回放时根据引用重新拼装。

这个方案有一个隐含假设:被引用的内容是幂等的。但在 Agent 平台里:

  • 技能(Skill)定义会改:两周前 sk_marketing_strategy 的内容可能完全换了一套行业分析框架
  • 知识库文档会更新:产品文档、FAQ、行业报告都在持续迭代
  • Agent 配置会变:system prompt 模板、绑定的工具清单、模型参数都会调整

你拿到一个 trace,看到 skill_ids=[sk_marketing_strategy],去查这个 skill 的当前内容——但它在过去两周已经被编辑过三次。你不知道模型当时看到的是什么。

引用 = 丢失了"当时"的上下文。只有全量快照才能支持真正的回放。

1.2 快照方案

我们的方案:在每次 LLM 调用时,把组装好的完整上下文存为 JSON 快照:

{
  "systemPrompt": "你是 Lily,一位外贸客户经营专家...(完整 prompt 文本)",
  "messages": [
    {"role": "user", "content": "帮我分析这个客户的流失风险"},
    {"role": "assistant", "content": "我需要先了解一下..."},
    {"role": "user", "content": "客户 ID 是 12345"}
  ],
  "model": "deepseek-v4-pro",
  "tools": ["bt_search_knowledge", "bt_calculator", "bt_send_message"]
}

回放时,直接取这段 JSON,原样重建 messages 列表扔给 LLM——不经过 PromptBuilder、不挂载知识库、不激活技能。模型看到的内容和当时完全一致。

1.3 为什么 64KB 截断就够了

完整 prompt 可能很大——system prompt 8KB、几十条历史消息加起来几十 KB。我们硬截断在 64KB,超出在 JSON 末尾追加 ...[truncated at 65536 bytes] 标记。

截断是不是丢了关键信息?实际观察:

  • 绝大多数 Agent 的 system prompt + 历史消息在 15~30KB——距离截断线还很远
  • 超过 64KB 的是异常大请求,本身就值得关注(可能是消息历史失控)
  • 如果要追求完整性,工具结果、知识库 chunk 内容才是大头——但这些不在 request_snapshot 里(它们属于 tool_call span 和 kb_search span)

硬截断加标记是务实的折中——覆盖 99.9% 的 case,剩余 0.1% 丢掉的也不是关键推理上下文。


2. 回放的三条安全约束

回放不是"再跑一次推理"——它有三条明确的约束,区分于正常对话:

2.1 不带工具

回放调用的 ChatClient.prompt() 不传任何 tool callback。这意味着:

  • 不会真的调 MCP Server(可能产生计费、修改业务数据)
  • 不会发消息给子 Agent(bt_send_message 不在工具列表里)
  • 不会调搜索工具(不会消耗 Tavily API 额度)
  • 不会生成媒体文件(不浪费图片/视频生成配额)

模型会尝试调工具?不会——因为没有工具可用,它只能基于历史上下文直接生成文本。回放的目的就是对比模型在纯推理层面的输出质量,工具执行结果不在对比范围内。

2.2 新会话隔离

回放不修改原始会话——新建一个 PRIVATE 会话,标题格式 回放:{原标题},归属执行回放操作的用户(不是原会话用户)。

这样:

  • 回放结果有独立的 sessionId,可以单独查看、对比
  • 原始会话完全不受影响
  • PRIVATE 可见性避免干扰其他成员

2.3 血缘标记

回放产生的新 trace span 在 metadata 里标 replay_of: {原traceId}

{
  "replay_of": "trc_a1b2c3d4e5f6",
  "model_override": "gpt-4.1"
}

后续统计(Dashboard 的 token 用量、工具成功率等)可以按此标记排除回放会话,避免污染生产数据。


3. 实现要点

3.1 后端:从快照重建对话

public Flux<String> replayTrace(String spaceId, String instanceId,
                                 String traceId, String modelOverride) {
    // 1. 找 root span
    TraceSpan root = traceGateway.getTrace(traceId).stream()
            .filter(s -> "llm_call".equals(s.getSpanType())
                      && (s.getParentSpanId() == null || s.getParentSpanId().isEmpty()))
            .findFirst().orElseThrow(() -> new BusinessException(NOT_FOUND));

    // 2. 解析 request_snapshot 还原 systemPrompt + messages
    Map<String, Object> req = JsonUtil.fromJson(root.getRequestSnapshot(), Map.class);
    String systemPrompt = (String) req.get("systemPrompt");
    List<Map<String, String>> msgList = (List) req.get("messages");

    // 3. 新建隔离会话
    String replaySid = createSession(spaceId, instanceId);
    sessionService.updateSessionMeta(replaySid, "回放:" + originalTitle, false);

    // 4. 流式调用(不带工具)
    return Flux.create(emitter -> {
        chatClient.prompt()
            .system(systemPrompt)
            .messages(replayMsgs)
            .options(ToolCallingChatOptions.builder().model(model))  // 没有 .tools()
            .stream().chatResponse()
            .flatMapSequential(cr -> /* 输出 t/d 事件 */)
            .subscribe(..., () -> {
                // ★ 写 replay span(metadata 标 replay_of)
                traceRecorder.offer(buildReplaySpan(replayTraceId, traceId, modelOverride));
            });
    });
}

关键决策:ToolCallingChatOptions 不设置工具——Spring AI 在没有工具时不会启动 tool-calling 循环,直接做纯文本生成。

3.2 前端:SSE 消费 + 跳转闭环

回放是 SSE 流式输出,前端需要:

1. 模型选择面板:从既有 GET /models 取可用模型列表,默认选中原 trace 使用的模型。用户可以切模型做 A/B 对比。

2. SSE 消费:用 fetch reader 消费流(和正常 chat 流复用同一套解析惯例——t 文本 / d 结束):

const res = await fetch(`/api/reasoning/traces/${traceId}/replay?model=${model}`)
const replaySessionId = res.headers.get('X-Replay-Session-Id')
const reader = res.body!.getReader()
while (true) {
  const { done, value } = await reader.read()
  if (done) break
  // 逐行解析 SSE 文本事件,追加到输出区域
}

3. 流完后跳转:从响应头 X-Replay-Session-Id 取新会话 ID,展示"查看回放会话"按钮,点击后切到新会话(setActiveSessionId + 切回消息 tab)。

3.3 token 用量的读取顺序

有一个隐蔽的坑:我们的 BxChatModel(自研 LLM 网关适配层)用 getAndClearUsage() 暴露 token 统计——读一次就清空。Trace 埋点和 CompletionHandler(消息持久化)都需要这份数据:

流结束 → trace 读 usage(pre-read)→ clear
       → CompletionHandler 用预读结果持久化 → 不重复读

如果 CompletionHandler 先读到,trace 就拿到空 map,token 列永远为 0。这不是并发问题——两者在同一个回调线里顺序执行——但调用顺序必须显式约束。我们的做法:trace 在 handleStreamComplete 开头立即预读 usage,然后把结果传给 CompletionHandler,CompletionHandler 不再自读。

这个模式可以推广:任何 getAndClear 风格的临时状态,如果多消费者,必须有一个显式的"生产者-消费者"顺序约定。


4. 回放的使用场景

4.1 模型选型 A/B 对比

一个典型工作流:

  1. 打开一个历史会话的时间轴,看到某轮推理的 trace
  2. 点"回放",模型下拉选 gpt-4.1(原模型 deepseek-v4-pro
  3. 确认 → 流式输出在新面板展示
  4. 原输出和回放输出并排对比:哪个更准确、更专业、更友好?

可以反复回放同一 trace 多次(每次一个隔离会话),系统性对比多个候选模型。

4.2 Prompt 调优回归

改了一个 Agent 的 system prompt 模板后,不能只看新对话的效果——旧版本对话里模型犯的错是否被修正了? 取旧版本的 trace,用新版 prompt 回放(手动替换 snapshot 里的 systemPrompt),检查输出质量。

目前回放用的是 snapshot 原文,如果要对比 prompt 版本,可以手动修改 snapshot JSON 再回放——后续可以考虑内置"替换 system prompt"选项。

4.3 故障复现

用户反馈"Agent 两周前那次回复不对"。打开 trace 时间轴 → 找到问题 span → 回放,确认是模型当时的问题还是后续配置变更引入的回归。


5. 后续方向

当前回放已落地 纯文本对比(不带工具,单轮回放)。接下来的自然扩展:

  • 多轮回放:不只回放 root span,回放整棵 span 树(带工具调用/子 Agent 的完整对话)
  • 并排 Diff:原输出和回放输出逐段 diff 高亮,一眼看到差异
  • 批量评测:取 50 个历史 trace,对候选模型批量回放,自动统计胜率
  • Prompt 版本对比:回放时可选替换 system prompt,对比新旧 prompt 效果

6. 经验总结

  1. 快照是回放的前提。 存引用(模板版本号、KB ID)等于丢失上下文——模板和知识库是活的,只有存组装后的完整文本才能原样重建。

  2. 回放必须隔离副作用。 不带工具、新建会话、血缘标记——三条约束缺一不可。回放是"纯推理对比",不是"再跑一次生产流程"。

  3. SSE 流式回放 + 响应头传新 sessionId 是最自然的 API 设计:用户看到回放输出和正常聊天一样的实时打字效果,流完后一键跳转,体验连贯。

  4. getAndClear 模式的坑:当一个临时状态有多个消费者时,必须显式约定读取顺序。不是并发问题,是逻辑顺序问题——但更难发现,因为代码看起来都是对的。

  5. 回放是模型的"单元测试"。 输入固定(snapshot 原文),输出可比(纯文本),副作用隔离。它让 LLM 的选型和调优从主观讨论变成可度量的工程实践。