time-travel 调试 — 「重放」不等于「重现」
time-travel 调试 — 「重放」不等于「重现」
日期: 2026-07-22 阶段: Phase 2 - AI-native 参考架构 标签: #time-travel #record-replay #non-determinism
核心问题
P2 的 orchestrator 接上真实 LLM 后,会出现一类传统程序里几乎不存在的 bug:同样的输入,agent 这次答对、下次答错,而你无法稳定复现。生产 trace 显示「第 4 步 subagent 给了个幻觉证据导致 SAR 误判」,你想回到第 4 步看看到底哪儿出的问题——但你重跑一遍,它不出错了。
这就是 agent 调试的核心难点,今天要把它讲透并给出工具:
- checkpoint 历史回放 / fork 重放机制——怎么回到任意一步、改一个参数、从那步分叉重跑(time-travel)?
- 怎么接入 agent-lab(复用
useTraceStore),让这套能力在产品里可点、可看? - 最关键的认知——为什么 agent 调试比传统程序难? 答案是「不可重现性」,而今天会揭穿一个广泛的误解:LangGraph 式的「time-travel replay」并不能重现一次故障,因为它会重新执行非确定性的 LLM 调用。真正的重现需要另一套机制(record/replay)。
关键内容
A. checkpoint 历史、replay 与 fork:time-travel 的三件套
time-travel 建立在 Day 36/37 的 checkpoint 持久化之上。LangGraph 把它做成三个操作(LangChain docs, 2026):
① get_state_history — 拿到检查点历史。返回逆序的 StateSnapshot 列表,每个含 state.next(下一步要执行的节点)和 checkpoint_id:
history = list(graph.get_state_history(config))
for s in history:
print(f"next={s.next}, checkpoint_id={s.config['configurable']['checkpoint_id']}")
② replay — 从某个旧检查点重跑。用旧 checkpoint 的 config 调 invoke:
before_step4 = next(s for s in history if s.next == ("subagent_research",))
replay_result = graph.invoke(None, before_step4.config)
语义关键点(也是今天的炸弹):
"Nodes before the checkpoint are not re-executed (results are already saved). Nodes after the checkpoint re-execute, including any LLM calls, API requests... Replay re-executes nodes—it doesn't just read from cache. LLM calls, API requests, and interrupts fire again and may return different results."(LangChain docs, 2026)
③ fork — 改状态后分叉。update_state 在某检查点注入改过的 state,创建一条新分支:
fork_config = graph.update_state(before_step4.config, values={"topic": "structuring"})
fork_result = graph.invoke(None, fork_config)
# update_state 不回滚线程,而是从指定点创建一个分叉的新检查点
fork 不破坏原线程,于是检查点历史长成一棵分支树——这正好对接 Day 37 审批的「approve/edit/resume」(edit 本质就是 fork)。fork 重放状态树:
cp0 ── cp1 ── cp2 ── cp3 ── cp4(故障) ── cp5
│
└─ cp2' ── cp3' ← fork: 在 cp2 改 state 分叉
│
└─ cp2'' ── ... ← 另一条 what-if 分支
回放算法:
replay(cp_k): load state@cp_k; re-run nodes where order > k
fork(cp_k, Δ): new_cp = update_state(cp_k, Δ); replay(new_cp)
B. 「重放」≠「重现」:揭穿 time-travel replay 的误解
这是今天最重要的反直觉点。直觉上「time-travel replay」听起来像「把那次故障原样再放一遍」,像录像回放。但 A 节的 replay 是「重新执行」而非「重新播放」——它会把 cp4 之后的 LLM 调用、API 请求、interrupt 全部重新触发,而 LLM 是非确定性的:
Anthropic 官方:即使
temperature=0.0,结果也不会完全确定(Anthropic docs, 2026)。OpenAI 的seed参数改善可复现性但「明确不保证」。
反直觉洞察①(temperature=0 不能让多步 agent 变确定,replay 因此无法重现故障):很多人以为「把 temperature 设 0、用 LangGraph replay 就能复现 bug」。双重错误:(1) 即便单次调用 temp=0,供应商也不保证逐 token 确定(浮点累加顺序、批处理、MoE 路由、静默换权重都会引入抖动);(2) 多步 agent 的非确定性是复合的——一步的微小措辞差异会改变下一步的工具选择,误差沿推理链放大。所以 LangGraph replay「重跑 cp4 之后的节点」时,第 4 步那个幻觉证据大概率不再出现,你想 debug 的故障当场消失。replay 能重建「状态」,但重建不了「那一次非确定性输出」。
那怎么才能真正重现?答案是 record/replay 架构(与 A 节的 state-replay 是两件事):在第一次执行时录下每一次 LLM 调用、工具响应、时间戳;调试时用录制的响应替换真实调用,让控制流沿着原路精确复现(tianpan.co, 2026-04;arXiv 2505.17716):
"Recording every LLM call, tool response, and timestamp during agent execution allows replaying the exact sequence to reproduce failures — because setting temperature to zero won't make your multi-step agent deterministic."(2026-04)
一个常被漏掉的细节:时钟也要拦。「若 agent 在 prompt/日志/决策逻辑里用了时间戳,replay 引擎必须拦截系统时钟调用、替换成录制的时间戳」(tianpan.co, 2026-04)——否则 now() 每次不同,又引入非确定性。
两类 time-travel 的本质区别(今天的核心决策表):
| 维度 | state-replay(LangGraph 式) | record/replay(确定性重现) |
|---|---|---|
| 录什么 | 仅 state checkpoint | 每次 LLM/工具/时钟的输入+输出 |
| 重放 cp 后节点 | 重新执行(真调 LLM) | 回放录制响应(不调 LLM) |
| LLM 调用 | 重新触发,结果可能不同 | 用录制输出替换,结果一致 |
| 能重现故障吗 | ❌ 非确定性故障消失 | ✅ 逐步精确重现 |
| 适合 | what-if 探索、改 prompt 看分支 | debug 已发生的故障 |
| 成本 | 重跑烧 token | 几乎零(不调模型) |
结论:LangGraph 的 replay/fork 是优秀的 what-if 探索工具(改个参数看会走哪条分支),但它不是故障重现工具。要 debug 「那一次为什么错」,必须 record/replay。 这是本笔记最值钱的认知,也纠正了把两者混为一谈的普遍误解。
C. 为什么 agent 调试比传统程序难 + 接入 agent-lab
传统程序调试的隐含前提是可重现:同输入同输出,下断点、单步、看变量,bug 稳定在那里等你抓。agent 打破了这个前提——LLM 非确定性 + 多步复合放大,故障是「概率性闪现」的。难点对照:
| 调试维度 | 传统程序 | LLM agent |
|---|---|---|
| 可重现性 | 同输入→同输出 | 同输入→不同输出(核心难点) |
| 状态可见性 | 变量/栈一目了然 | 推理在 prompt/隐空间,靠 trace 还原 |
| 断点 | 行级断点暂停 | 只能在节点/工具边界(checkpoint) |
| 单步 | 确定性下一行 | 「下一步」本身由模型概率决定 |
| 根因 | 逻辑错误,确定 | 可能是数据/检索/模型抖动,需归因 |
反直觉洞察②(agent 的「单步调试」语义被模型偷换了):传统单步调试里「下一步」是程序计数器决定的、确定的。agent 里「下一步执行哪个 subagent / 调哪个工具」本身就是模型的非确定性输出——你 step over 一次走 research,再 step over 一次可能走 portfolio。所以 agent 调试不能照搬「单步执行」心智模型,要换成「在检查点边界做状态快照 + 分支对照」:与其追问「这一步之后会怎样」(不确定),不如固定多条录制分支做 A/B 对照,看哪个状态改动改变了结局。调试范式从「跟踪单一确定执行」变成「比较多条非确定分支」。
接入 agent-lab(复用 useTraceStore):现有 RunTrace.steps[] 已是检查点历史的雏形——每个 StepTrace 含 agent、toolCalls[](带 args/result)、inputTokens/outputTokens、startedAt/endedAt。这天然支持:
- 回放视图:把
steps[]渲染成可点击时间轴,点任一步看当时的 state(呼应 Day 8/24 的 trace 视图)。 - fork 入口:在某步「改 args 重跑」即 fork——但要诚实标注这是 state-replay(会重调 LLM、结果可能变),不是故障重现。
- record/replay 切口:要做真正的故障重现,需在
ToolCallTrace上加录制的result回放——把 trace 从「事后只读记录」升级为「可回灌的录制」。
设计要点/决策表
| 要点 | 决策 | 理由 |
|---|---|---|
| 探索 what-if | 用 state-replay(fork + update_state) | 改参数看分支,无需精确重现 |
| 重现故障 | 用 record/replay(回灌录制的 LLM/工具/时钟响应) | state-replay 会重调 LLM,非确定性故障消失 |
| 时钟处理 | record/replay 必须拦系统时钟、回放录制时间戳 | now() 每次不同会引入非确定性 |
| 调试心智 | 检查点边界快照 + 多分支对照,弃「单步执行」 | 「下一步」由模型概率决定,非确定 |
| trace 升级 | ToolCallTrace.result 既是记录也是回放源 | 同一份 trace 支撑只读查看 + 确定性回灌 |
| 诚实标注 | fork 重跑标「结果可能变」,不冒充重现 | 防把 what-if 工具误当 debug 工具 |
对本项目的落地
- 新增
src/agent/trace/timeTravel.ts:导出getStateHistory(runId) → StepSnapshot[](从useTraceStore.runs派生)、forkFrom(runId, stepId, stateDelta) → newRunId(state-replay:复制到该步、改 state、标记replayKind:'state-replay'重跑——会真调 LLM)、recordReplay(runId, stepId) → DeterministicRun(record/replay:用ToolCallTrace.result回灌、不调 LLM,标记replayKind:'record-replay')。两个入口在类型上区分,强制调用方明确意图。 - 扩展
src/agent/trace/types.ts:ToolCallTrace加可选replayedFrom?: string(标记本次 result 是录制回放而非真实调用);RunTrace加forkedFrom?: { runId: string; stepId: string }形成分支树,对接 B 节 fork 树结构与 Day 37 的 edit-resume。 - agent-lab 时间轴面板:
/agent-lab把useTraceStore的steps[]渲染成可点击 time-travel 时间轴,每步右键「fork 探索(会重跑)」或「精确重现(回灌录制)」二选一,UI 文案明确区分——直接把反直觉洞察①教给用户,避免把 fork 当 debug。 - 与 Day 36/37 共栈:time-travel 的 checkpoint 历史、fork(=Day 37 的 edit-resume)、状态加载,与 Day 36 的崩溃恢复重放、Day 37 的 HITL interrupt 是同一套 checkpoint 基础设施的三种读路径(恢复=被动重放、审批=主动暂停重放、调试=任意点重放/重现),落地时统一在
useTraceStore+src/agent/durable/之上,不另起炉灶。 - 诚实标注:
timeTravel.ts头注明确 v1 在浏览器内基于useTraceStore,record/replay 的「拦截系统时钟」在 P2 仅对工具调用的 result 回放生效,LLM 逐 token 级确定性重放为 P3 接入真实录制存储后的能力;不谎称已实现完整确定性重现。
参考资料
- LangChain — Use time-travel(docs.langchain.com/oss/python/langgraph/use-time-travel, 2026):
get_state_history返回逆序快照含 checkpoint_id;replay = 用旧 config invoke、cp 前节点不重跑 cp 后重新执行;fork =update_state创分叉新检查点不回滚;replay 重新执行非缓存、LLM/API/interrupt 重新触发结果可能不同 - TianPan.co — Deterministic Replay: How to Debug AI Agents That Never Run the Same Way Twice(2026-04-12):record/replay 录每次 LLM/工具/时间戳、回放精确重现;temp=0 不能让多步 agent 确定;replay 引擎须拦系统时钟替换录制时间戳
- arXiv 2505.17716 — Get Experience from Practice: LLM Agents with Record & Replay(2025-05):agent record/replay 架构与经验复用
- Anthropic docs / OpenAI docs(2026):即便 temperature=0 结果不完全确定;OpenAI seed 改善但不保证可复现
- DEV — Debugging Non-Deterministic LLM Agents: Checkpoint-Based State Replay with LangGraph Time Travel(2026):把瞬态 agent 执行转成可复现可检查的状态机
- 本项目 Day 36(崩溃恢复重放)、Day 37(interrupt/fork=edit-resume)、Day 8/24(trace 视图);
src/agent/trace/useTraceStore.ts、src/agent/trace/types.ts(StepTrace/ToolCallTrace 作检查点历史雏形)(2026-06)
SOTA 检查 (2026-06-11)
- time-travel(state-replay)已是 2026 agent 框架标配:LangGraph 1.0(2025-10)get_state_history/update_state/replay 成熟;但「replay=重新执行而非重现」是其当前语义、且是 live 误解点(社区常把它当故障重现工具),反直觉洞察①未过时。
- record/replay 作为确定性重现的正解在 2026 升温:tianpan.co(2026-04)、arXiv 2505.17716(2025-05)、debugg.ai 等多方主张「录 LLM/工具/时钟 → 回灌重现」;这是与 state-replay 正交的另一套机制,本笔记的两者区分是当前正确认知,未见被推翻。
- 非确定性根因共识稳固:Anthropic「temp=0 仍不完全确定」、OpenAI「seed 不保证」是 2026 官方口径,未变;多步复合放大使「设 temp=0 即可调试」的朴素方案在 agent 上失效。
- 过时认知警示:(1) 不要把 LangGraph replay/fork 当故障重现工具——它会重调 LLM;(2) 不要以为 temperature=0 能让 agent 确定可复现;(3) 不要照搬「单步执行」心智——agent 的下一步由模型概率决定。
- 待跟踪:是否出现 agent 专用的成熟 record/replay 开源框架(统一拦截 LLM/工具/时钟);OpenTelemetry GenAI semconv(Day 22)是否新增「录制回放」相关属性,使 trace 标准化为可回灌格式;LangGraph 后续是否提供「缓存式 replay」(回放时复用 cp 后节点的录制输出)以原生支持确定性重现。