上周帮一个团队看 Agent 线上问题时,最典型的一句话又出现了:
"日志里能看到它调用了工具,但不知道为什么当时会选这个工具,也没法复现。"
这句话基本说明 Agent 已经从 demo 阶段进入了生产阶段。demo 阶段,日志能让你知道"程序有没有跑";生产阶段,日志只能回答"它大概发生过什么"。真正棘手的问题是:
- 用户明明问 A,Agent 为什么检索了 B?
- 工具返回里有一个边界值,为什么模型没有继续追问?
- 第三步失败后,重试为什么把第一步的外部动作又执行了一遍?
- 人工确认前后的状态是否一致?
- 修完 prompt 或工具 schema 后,旧 badcase 能不能稳定复现?
如果这些问题都只能靠翻日志、猜上下文、手动拼请求,那系统迟早会变成"玄学服务"。我的判断很明确:Agent 生产化的分水岭,不是有没有 tracing,而是有没有 checkpoint replay。
这里的 checkpoint replay 不是一个很重的平台概念。你可以先把它理解成:在 Agent 执行的关键节点,记录足够完整、结构化、可校验的状态快照;当线上出现失败时,可以把某个 run 恢复到指定步骤,以"只读 / mock / 幂等保护"的方式重新执行,复现当时的决策链。
这篇文章不讲空泛理念,直接拆工程落地:哪些状态必须存,哪些东西不能重放,怎么用一个小型 Node.js 实现把日志升级成 checkpoint replay,以及团队如何按一周、一个月、一个季度逐步落地。
先把四个概念分清:trace、checkpoint、replay、store
很多团队说自己"有观测",实际只是把几段日志打到了平台里。Agent 场景里至少要区分四层东西:
| 概念 | 解决的问题 | 典型内容 | 常见误区 |
|---|---|---|---|
| Trace | 发生了什么 | span、耗时、输入输出摘要、错误栈 | 以为 trace 等于可复现 |
| Checkpoint | 当时状态是什么 | message、plan、tool result、变量、版本、游标 | 只存最终结果,不存中间状态 |
| Replay | 能否回到当时再走一次 | 从 checkpoint 恢复、mock 工具、对比输出 | 直接重放外部写操作 |
| Store | 跨会话长期记忆 | 用户偏好、知识、历史事实 | 把短期执行状态塞进长期记忆 |
LangGraph 的文档把 checkpointer 和 store 分得很清楚:checkpointer 持久化单个 thread 的图状态,适合会话连续性、人类介入、time travel、fault tolerance;store 则用于跨 thread 的长期数据。这个区分很重要,因为生产事故大多发生在"一个 run 内部的状态演化",不是长期记忆本身。
Trace 工具也很重要。LangSmith、Langfuse 这类平台能帮你看到调用链、模型输入输出、评估指标、prompt 版本等信息。问题是:**trace 更像录像,checkpoint replay 更像存档点。**录像能看,存档点能回去试。
Agent 的复杂性就在这里:一次执行里既有概率性模型调用,也有确定性工具调用,还有外部系统副作用。只看录像,你很难验证"如果我改了工具 schema,这个 badcase 会不会修好";有 checkpoint,你才能把修复变成回归测试。
为什么"只存日志"在 Agent 场景会失效
传统后端服务排障时,日志、指标、trace 已经覆盖了大多数问题。请求来了,代码路径相对确定,数据库状态可查,重试语义可控。但 Agent 服务多了三类不确定性。
第一类是上下文不确定性。模型看到的不是用户原始输入,而是一大坨动态拼装后的上下文:系统规则、历史消息、检索片段、工具列表、权限、预算、临时策略。日志里如果只写"用户问了什么"和"模型答了什么",中间最关键的 prompt assembly 过程就丢了。
第二类是决策不确定性。Agent 并不是每一步都由业务代码决定。它可能先规划、再调用工具、再根据结果修正计划。一次错误工具调用的原因可能不是工具本身,而是上一轮模型输出里隐藏的错误假设。
第三类是副作用不确定性。Agent 调用的工具越来越接近真实业务:发消息、建工单、改配置、查账单、触发工作流。失败后直接"再跑一遍"可能造成重复操作;不跑又无法复现。
所以只存日志会出现三个断点:
- 上下文断点:不知道模型当时完整看到了什么。
- 状态断点:不知道每一步执行后 Agent 内部变量如何变化。
- 副作用断点:不知道哪些外部动作已经真实发生,哪些只是准备发生。
Checkpoint replay 要补的就是这三个断点。
一个最小可用的 checkpoint 长什么样
我建议不要一上来追求"全量录制一切"。最小可用 checkpoint 只需要覆盖 7 类字段:
ts
type AgentCheckpoint = {
runId: string;
stepIndex: number;
threadId: string;
nodeName: string;
createdAt: string;
versions: {
agentVersion: string;
promptVersion: string;
toolSchemaVersion: string;
modelRoute: string;
policyVersion: string;
};
input: {
userMessageHash: string;
messages: Array<{ role: string; content: string }>;
retrievedContext?: Array<{ id: string; source: string; textHash: string; text?: string }>;
};
state: {
plan?: unknown;
scratchpad?: unknown;
variables: Record<string, unknown>;
budget: { maxSteps: number; usedSteps: number; maxTokens?: number; usedTokens?: number };
};
toolCall?: {
name: string;
arguments: unknown;
argumentsHash: string;
idempotencyKey?: string;
sideEffectLevel: 'none' | 'read' | 'write' | 'external';
};
toolResult?: {
ok: boolean;
resultHash: string;
resultPreview?: unknown;
errorType?: string;
latencyMs?: number;
};
replay: {
mode: 'live' | 'mock' | 'dry-run';
canResumeFromHere: boolean;
unsafeReasons?: string[];
};
};
这里有几个字段容易被低估。
versions 必须存。Agent badcase 很多不是"模型突然变笨",而是 prompt、工具 schema、路由策略、权限策略有一个变了。没有版本,你无法判断一次 replay 的差异来自修复,还是来自环境漂移。
retrievedContext 最好既存 hash,也在合规允许时存文本快照。只存文档 ID 不够,因为知识库内容可能已经更新。你排查的是"当时为什么错",不是"现在看起来应该怎样"。
sideEffectLevel 是 replay 安全的核心。读操作可以 live replay;写操作默认只能 mock 或 dry-run;外部不可逆动作必须要求人工确认或幂等键保护。
canResumeFromHere 不要自动推断,应该由节点显式声明。有些节点天然可恢复,比如"根据已保存的工具结果继续总结";有些节点不应恢复,比如"已经发出外部付款请求但确认状态不明"。
可重放边界:别把 replay 做成事故放大器
Checkpoint replay 最大的坑,是把"复现"误做成"重新执行一遍"。Agent 系统里,重放必须按边界分层。
1. 模型调用:记录输入,允许替换输出
模型调用本身可以 replay,但要区分两种模式:
- exact replay:使用当时保存的模型输出,不再调用模型,用于复盘状态机和工具链。
- regression replay:用新的 prompt / schema / model route 再调用一次,用于验证修复效果。
两者都需要同一个 checkpoint,但目的不同。exact replay 问"当时系统为什么走到这里";regression replay 问"现在是否还会犯同样错误"。不要混在一起。
2. 工具读操作:可以 live,但要记录环境
例如搜索、数据库查询、读取工单详情。这类操作理论上可重放,但数据源会变化。因此 checkpoint 至少要保存 result hash 和必要 preview。排障时如果 live replay 的结果 hash 和当时不同,系统应提示"外部数据已漂移",而不是把差异算成模型修复。
3. 工具写操作:默认 mock,除非有幂等键
例如创建工单、发送通知、更新配置。这类操作不能直接重放。正确做法是:
- 记录 idempotencyKey;
- 记录请求 hash;
- 记录外部系统返回的 envelope;
- replay 时默认返回已保存的 toolResult;
- 只有在沙箱环境或显式 dry-run endpoint 下才允许重新调用。
这和支付系统、消息系统的思路很像:外部副作用必须有幂等和状态查询能力,否则重放只会制造新事故。
4. 人工确认:确认本身也是状态
很多 Agent 系统有 human-in-the-loop,但只把它当 UI 弹窗。生产里,人工确认点应该进入 checkpoint:确认人、确认时间、确认前状态 hash、确认后的策略、允许执行的动作范围。
否则你会遇到一个很麻烦的问题:事故发生后,没人说得清"人当时到底确认了什么"。
参考实现:用 SQLite 做一个轻量 checkpoint recorder
下面给一个最小 Node.js 版本。它不依赖某个 Agent 框架,适合塞进现有服务里先跑起来。生产环境可以把 SQLite 换成 Postgres,结构不变。
ts
import crypto from 'node:crypto';
import Database from 'better-sqlite3';
export type SideEffectLevel = 'none' | 'read' | 'write' | 'external';
export type CheckpointInput = {
runId: string;
threadId: string;
stepIndex: number;
nodeName: string;
versions: Record<string, string>;
input: unknown;
state: unknown;
toolCall?: {
name: string;
arguments: unknown;
idempotencyKey?: string;
sideEffectLevel: SideEffectLevel;
};
toolResult?: unknown;
replay?: {
canResumeFromHere: boolean;
unsafeReasons?: string[];
};
};
function sha256(value: unknown) {
return crypto
.createHash('sha256')
.update(JSON.stringify(value))
.digest('hex');
}
export class CheckpointStore {
private db: Database.Database;
constructor(file: string) {
this.db = new Database(file);
this.db.exec(`
create table if not exists agent_checkpoints (
run_id text not null,
thread_id text not null,
step_index integer not null,
node_name text not null,
created_at text not null,
versions_json text not null,
input_json text not null,
state_json text not null,
tool_call_json text,
tool_result_json text,
replay_json text not null,
state_hash text not null,
primary key (run_id, step_index)
);
create index if not exists idx_agent_checkpoints_thread
on agent_checkpoints(thread_id, created_at);
`);
}
save(cp: CheckpointInput) {
const replay = cp.replay ?? { canResumeFromHere: true, unsafeReasons: [] };
const stateHash = sha256({
versions: cp.versions,
input: cp.input,
state: cp.state,
toolCall: cp.toolCall,
toolResult: cp.toolResult,
});
this.db.prepare(`
insert into agent_checkpoints (
run_id, thread_id, step_index, node_name, created_at,
versions_json, input_json, state_json, tool_call_json,
tool_result_json, replay_json, state_hash
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
cp.runId,
cp.threadId,
cp.stepIndex,
cp.nodeName,
new Date().toISOString(),
JSON.stringify(cp.versions),
JSON.stringify(cp.input),
JSON.stringify(cp.state),
cp.toolCall ? JSON.stringify(cp.toolCall) : null,
cp.toolResult ? JSON.stringify(cp.toolResult) : null,
JSON.stringify(replay),
stateHash,
);
return { stateHash };
}
get(runId: string, stepIndex: number) {
const row = this.db.prepare(`
select * from agent_checkpoints
where run_id = ? and step_index = ?
`).get(runId, stepIndex) as any;
if (!row) return null;
return {
runId: row.run_id,
threadId: row.thread_id,
stepIndex: row.step_index,
nodeName: row.node_name,
createdAt: row.created_at,
versions: JSON.parse(row.versions_json),
input: JSON.parse(row.input_json),
state: JSON.parse(row.state_json),
toolCall: row.tool_call_json ? JSON.parse(row.tool_call_json) : null,
toolResult: row.tool_result_json ? JSON.parse(row.tool_result_json) : null,
replay: JSON.parse(row.replay_json),
stateHash: row.state_hash,
};
}
}
保存 checkpoint 的位置建议放在每个节点完成后,而不是整个 run 结束后。否则 Agent 中途崩溃时,你正好丢掉最需要的信息。
一个包装工具调用的例子:
ts
async function runToolWithCheckpoint(params: {
store: CheckpointStore;
runId: string;
threadId: string;
stepIndex: number;
toolName: string;
args: unknown;
sideEffectLevel: SideEffectLevel;
execute: () => Promise<unknown>;
}) {
const idempotencyKey = `${params.runId}:${params.stepIndex}:${params.toolName}`;
params.store.save({
runId: params.runId,
threadId: params.threadId,
stepIndex: params.stepIndex,
nodeName: `before_tool:${params.toolName}`,
versions: currentVersions(),
input: { toolArgsHash: sha256(params.args) },
state: currentAgentState(),
toolCall: {
name: params.toolName,
arguments: params.args,
idempotencyKey,
sideEffectLevel: params.sideEffectLevel,
},
replay: {
canResumeFromHere: params.sideEffectLevel === 'read',
unsafeReasons: params.sideEffectLevel === 'read' ? [] : ['tool_has_side_effect'],
},
});
const started = Date.now();
try {
const result = await params.execute();
params.store.save({
runId: params.runId,
threadId: params.threadId,
stepIndex: params.stepIndex + 0.1,
nodeName: `after_tool:${params.toolName}`,
versions: currentVersions(),
input: { toolArgsHash: sha256(params.args) },
state: currentAgentState(),
toolCall: {
name: params.toolName,
arguments: params.args,
idempotencyKey,
sideEffectLevel: params.sideEffectLevel,
},
toolResult: {
ok: true,
resultHash: sha256(result),
resultPreview: preview(result),
latencyMs: Date.now() - started,
},
replay: { canResumeFromHere: true, unsafeReasons: [] },
});
return result;
} catch (err: any) {
params.store.save({
runId: params.runId,
threadId: params.threadId,
stepIndex: params.stepIndex + 0.1,
nodeName: `after_tool:${params.toolName}`,
versions: currentVersions(),
input: { toolArgsHash: sha256(params.args) },
state: currentAgentState(),
toolCall: {
name: params.toolName,
arguments: params.args,
idempotencyKey,
sideEffectLevel: params.sideEffectLevel,
},
toolResult: {
ok: false,
resultHash: sha256({ name: err.name, message: err.message }),
errorType: err.name,
latencyMs: Date.now() - started,
},
replay: { canResumeFromHere: true, unsafeReasons: [] },
});
throw err;
}
}
上面代码里 currentVersions()、currentAgentState()、preview() 是你自己系统里的函数。重点不是这几个函数怎么写,而是记录的位置:工具调用前存意图,工具调用后存结果。
只存结果会丢掉"当时为什么要调用";只存意图会丢掉"外部世界到底返回了什么"。两边都存,排障才闭环。
Replay Runner:重放时默认不碰外部世界
有了 checkpoint,还需要一个 replay runner。最小规则是:
- read 工具可以 live replay,但要标记数据漂移;
- write / external 工具默认使用保存的 toolResult;
- 模型调用默认 mock 成旧输出,除非显式进入 regression 模式;
- replay 输出必须生成 diff,而不是只给"成功 / 失败"。
伪代码如下:
ts
type ReplayMode = 'exact' | 'regression';
async function replayFrom(params: {
store: CheckpointStore;
runId: string;
stepIndex: number;
mode: ReplayMode;
}) {
const cp = params.store.get(params.runId, params.stepIndex);
if (!cp) throw new Error('checkpoint_not_found');
if (!cp.replay.canResumeFromHere) {
return {
ok: false,
reason: 'unsafe_checkpoint',
unsafeReasons: cp.replay.unsafeReasons,
};
}
const sandbox = createReplaySandbox({
mode: params.mode,
toolPolicy: (toolCall) => {
if (toolCall.sideEffectLevel === 'read') return 'live-with-drift-check';
return 'mock-saved-result';
},
modelPolicy: params.mode === 'exact' ? 'mock-saved-output' : 'call-current-route',
});
const replayResult = await sandbox.run({
versions: cp.versions,
input: cp.input,
state: cp.state,
});
return {
ok: true,
checkpointHash: cp.stateHash,
outputHash: sha256(replayResult.output),
diff: compareReplay(cp, replayResult),
};
}
工程上最有价值的是 diff。它至少应该回答:
- prompt / policy / tool schema 版本是否变化;
- 检索上下文 hash 是否变化;
- 工具参数是否变化;
- 工具结果是否变化;
- 最终答案是否变化;
- 变化是否命中预期修复点。
没有 diff 的 replay 只是在"重新跑一次"。有 diff,replay 才会变成回归测试。
一条真实排障链路应该怎么走
假设线上报警:Agent 自动处理工单时,把"需要人工确认"的退款请求归类成"可自动处理"。传统日志排障大概是:搜 runId、翻 prompt、翻工具结果、问业务同学、手动构造一个相似请求再跑一次。
Checkpoint replay 的流程应该是:
- 从报警拿到
runId和失败 step。 - 打开 trace,看执行链路和耗时,确认异常节点。
- 定位该节点前后的 checkpoint。
- exact replay:用旧模型输出和旧工具结果恢复执行,验证状态机是否能复现错误路径。
- 查看 checkpoint diff:发现
policyVersion=refund-policy-2026-07-01,而工具 schema 里requires_manual_approval字段在该版本没有进入模型上下文。 - 修复 schema 映射或 prompt assembly。
- regression replay:使用新版本重新跑同一 checkpoint,确认 Agent 会进入人工确认分支。
- 把该 checkpoint 固化为 eval case,进入发布前回归集。
这个流程最大的收益不是"快一点",而是把事故从口头复盘变成结构化资产。每个线上坏例子都能沉淀成可重放样本,后面改 prompt、改工具、换模型路由时都能跑一遍。
Checkpoint Replay 和 Eval Harness 的关系
很多团队已经在做 eval harness:准备一批输入,看模型输出是否符合预期。这当然有用,但 Agent 的 badcase 往往不是单轮输入输出,而是中间状态出错。
离线 eval 更像"题库";checkpoint replay 更像"事故切片"。两者应该合并:
- 从线上 checkpoint 中筛选高价值 badcase;
- 清理敏感字段,生成可共享的 replay fixture;
- 给 fixture 标注期望路径,而不只是期望最终答案;
- 每次发布 prompt / schema / route 前跑 replay regression;
- 对差异生成报告,要求 reviewer 看过再上线。
一个 fixture 可以长这样:
json
{
"case_id": "refund_manual_approval_001",
"source_run_id": "run_20260713_0830_9fc2",
"start_checkpoint": 4,
"expected": {
"must_call_tools": ["get_refund_policy"],
"must_not_call_tools": ["execute_refund"],
"final_state": "WAITING_HUMAN_APPROVAL"
},
"redactions": {
"user_id": "hash",
"order_id": "synthetic"
}
}
注意这里的 expected 不是"回答里必须包含某句话",而是路径约束。Agent 工程里,路径经常比文案更重要。一个答案看起来没问题,但如果它绕过了审批工具,那就是事故。
什么时候只做 trace 就够,什么时候必须做 replay
不是所有 Agent 都需要一开始就上 replay。下面这张表可以作为判断:
| 场景 | Trace 是否足够 | 是否需要 checkpoint replay | 原因 |
|---|---|---|---|
| 纯问答助手,无外部工具 | 多数情况下够 | 可选 | 主要问题是回答质量 |
| RAG 问答,有检索 | 不够 | 建议做 | 知识库会漂移,需要保存检索快照 |
| 工单分类 / 客服分流 | 不够 | 建议做 | 需要复现分类路径和策略版本 |
| 自动代码修改 | 不够 | 必须做 | 文件状态、工具调用、副作用复杂 |
| 自动运维 / 配置变更 | 远远不够 | 必须做 | 外部副作用高风险,必须可恢复 |
| 支付 / 退款 / 权限变更 | 远远不够 | 必须做且要人工 gate | 不可逆动作,重放必须受控 |
我的建议是:只要 Agent 会调用业务工具,尤其是会产生写操作,就不要只停留在 trace。至少把 checkpoint 的数据结构先定下来,哪怕 replay runner 晚一点做。
最容易踩的 6 个坑
坑一:只存自然语言摘要。 摘要适合人看,不适合机器重放。checkpoint 必须存结构化字段,摘要只能作为附属信息。
坑二:不存版本。 没有 promptVersion、toolSchemaVersion、policyVersion,replay 差异基本不可解释。
坑三:把长期记忆当 checkpoint。 长期记忆会被更新、压缩、合并,不适合作为事故现场。checkpoint 要保留当时现场。
坑四:重放时真的执行写工具。 这是最危险的。写工具必须 mock、dry-run 或通过幂等键确认状态。
坑五:没有脱敏策略。 checkpoint 会保存上下文和工具结果,必须在采集层做字段级脱敏、加密和访问控制。否则观测系统会变成新的数据风险点。
坑六:只给平台团队用。 replay 的价值不只在基础设施团队。业务负责人、客服、测试、算法同学都应该能看到"这次错误路径是什么",只是权限视图不同。
团队落地路线:别一口吃成平台
如果你现在只有日志,不建议立刻做一个大平台。可以按三个阶段来。
第一周:把关键节点状态存下来
目标很简单:每个 run 有 runId,每个 step 有 checkpoint。先覆盖:
- 用户输入 hash;
- prompt / tool schema / policy 版本;
- 工具调用参数和结果 hash;
- 外部副作用级别;
- 错误类型和耗时。
这一阶段不要求完整 replay,只要求事故发生后能看到结构化现场。
第一个月:做 exact replay 和 diff
第二阶段做一个内部 CLI 或页面:输入 runId 和 stepIndex,可以从 checkpoint 恢复,使用保存的模型输出和工具结果跑状态机,并生成 diff。
这个阶段重点是验证:你的状态机是否真的可恢复;哪些字段缺失会导致 replay 失败;哪些工具没有明确副作用级别。
第一个季度:接入回归与发布门禁
第三阶段把线上 badcase 变成 fixture,接入发布流程。每次改 prompt、工具 schema、模型路由、权限策略,都跑一遍 replay regression。
如果某个高风险 case 从 WAITING_HUMAN_APPROVAL 变成 AUTO_EXECUTE,发布直接阻断。这个门禁比"平均分提升 2%"更有生产意义。
一个实用的 checkpoint 分级标准
为了避免过度采集,可以把 checkpoint 分成三级:
| 等级 | 记录内容 | 适用场景 | 成本 |
|---|---|---|---|
| L1 现场摘要 | 版本、输入输出 hash、错误、耗时 | 低风险问答、早期试点 | 低 |
| L2 状态快照 | messages、plan、变量、工具参数、结果 preview | 大多数业务 Agent | 中 |
| L3 可重放现场 | 完整上下文、mock 结果、diff、fixture、脱敏副本 | 高风险写操作、核心业务 | 高 |
不要所有场景都上 L3。真正要做的是:按风险决定采集深度。一个内部知识库问答助手,L1/L2 可能够用;一个能改配置的运维 Agent,必须 L3。
对工程团队的真正价值
Checkpoint replay 听起来像"排障工具",但它最后会改变团队协作方式。
对开发来说,它把"我本地复现不了"变成"从第 5 步 checkpoint 开始 replay"。
对测试来说,它把线上事故变成可回归样本,而不是写在复盘文档里的故事。
对产品和业务来说,它能解释 Agent 为什么做出某个决策,而不是只给一个最终答案。
对管理者来说,它让 Agent 质量从"感觉还行"变成"关键路径 badcase 是否回归通过"。
这也是为什么我认为 checkpoint replay 会成为 Agent 工程的基础设施,而不是某个框架的高级功能。框架可以换,模型可以换,但"线上现场可恢复"这件事不会过时。
结论:Agent 生产化,要从"看见"走向"回到现场"
过去一年,很多团队补上了 tracing、prompt 管理、评估集,这些都很必要。但 Agent 真正进入生产后,最值钱的问题会变成:
事故发生时,我们能不能回到那个现场?
如果不能,你只能靠日志猜。能回到现场,你就可以复盘、修复、回归、阻断发布。
所以我的建议很直接:只要你的 Agent 已经接入业务工具,从今天开始给它加 checkpoint。先不用复杂,先把版本、状态、工具意图、工具结果、副作用级别存下来。等下次线上 badcase 出现时,你会庆幸自己不是只留了一堆"模型调用成功"的日志。
日志告诉你它曾经来过。Checkpoint replay 让你回到它出错的那一刻。