返回 AIPA 笔记
AIPA Day 38

time-travel 调试 — 「重放」不等于「重现」

time-travel 调试 — 「重放」不等于「重现」

2026-07-22
time-travelrecord-replaynon-determinism

日期: 2026-07-22 阶段: Phase 2 - AI-native 参考架构 标签: #time-travel #record-replay #non-determinism

核心问题

P2 的 orchestrator 接上真实 LLM 后,会出现一类传统程序里几乎不存在的 bug:同样的输入,agent 这次答对、下次答错,而你无法稳定复现。生产 trace 显示「第 4 步 subagent 给了个幻觉证据导致 SAR 误判」,你想回到第 4 步看看到底哪儿出的问题——但你重跑一遍,它不出错了

这就是 agent 调试的核心难点,今天要把它讲透并给出工具:

  1. checkpoint 历史回放 / fork 重放机制——怎么回到任意一步、改一个参数、从那步分叉重跑(time-travel)?
  2. 怎么接入 agent-lab(复用 useTraceStore),让这套能力在产品里可点、可看?
  3. 最关键的认知——为什么 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[] 已是检查点历史的雏形——每个 StepTraceagenttoolCalls[](带 args/result)、inputTokens/outputTokensstartedAt/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.tsToolCallTrace 加可选 replayedFrom?: string(标记本次 result 是录制回放而非真实调用);RunTraceforkedFrom?: { runId: string; stepId: string } 形成分支树,对接 B 节 fork 树结构与 Day 37 的 edit-resume。
  • agent-lab 时间轴面板/agent-labuseTraceStoresteps[] 渲染成可点击 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 接入真实录制存储后的能力;不谎称已实现完整确定性重现。

参考资料

  1. 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 重新触发结果可能不同
  2. 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 引擎须拦系统时钟替换录制时间戳
  3. arXiv 2505.17716 — Get Experience from Practice: LLM Agents with Record & Replay(2025-05):agent record/replay 架构与经验复用
  4. Anthropic docs / OpenAI docs(2026):即便 temperature=0 结果不完全确定;OpenAI seed 改善但不保证可复现
  5. DEV — Debugging Non-Deterministic LLM Agents: Checkpoint-Based State Replay with LangGraph Time Travel(2026):把瞬态 agent 执行转成可复现可检查的状态机
  6. 本项目 Day 36(崩溃恢复重放)、Day 37(interrupt/fork=edit-resume)、Day 8/24(trace 视图);src/agent/trace/useTraceStore.tssrc/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 后节点的录制输出)以原生支持确定性重现。