我们上线 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_callspan 和kb_searchspan)
硬截断加标记是务实的折中——覆盖 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 对比
一个典型工作流:
- 打开一个历史会话的时间轴,看到某轮推理的 trace
- 点"回放",模型下拉选
gpt-4.1(原模型deepseek-v4-pro) - 确认 → 流式输出在新面板展示
- 原输出和回放输出并排对比:哪个更准确、更专业、更友好?
可以反复回放同一 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. 经验总结
快照是回放的前提。 存引用(模板版本号、KB ID)等于丢失上下文——模板和知识库是活的,只有存组装后的完整文本才能原样重建。
回放必须隔离副作用。 不带工具、新建会话、血缘标记——三条约束缺一不可。回放是"纯推理对比",不是"再跑一次生产流程"。
SSE 流式回放 + 响应头传新 sessionId 是最自然的 API 设计:用户看到回放输出和正常聊天一样的实时打字效果,流完后一键跳转,体验连贯。
getAndClear模式的坑:当一个临时状态有多个消费者时,必须显式约定读取顺序。不是并发问题,是逻辑顺序问题——但更难发现,因为代码看起来都是对的。回放是模型的"单元测试"。 输入固定(snapshot 原文),输出可比(纯文本),副作用隔离。它让 LLM 的选型和调优从主观讨论变成可度量的工程实践。