为什么需要检查点协议
LLM Agent 跑长任务(多文件重构、端到端迁移、持续监控)有三个致命问题:
- 上下文撑爆:每步工具调用的输入输出都塞进 context,跑 20 步就逼近 token 上限,模型开始"遗忘"早期决策。
- 中断即丢失:进程被杀、OOM、网络断开,整个 session 的中间状态全丢,只能从头重跑。
- 跑偏无法回退:Agent 走错方向后,没有"撤回到第 N 步"的机制,只能眼睁睁看它继续错下去。
Muse Code 的解法是 append-only 事件日志(写入不可变,重启可重放),学术界 Ledger 论文提出"执行状态运行时层"。本技巧把这两套思路浓缩成一个 Prompt 协议,让任何 LLM(不依赖特定 Agent 框架)都能输出结构化可恢复轨迹。
检查点协议三件套
1. CHECKPOINT 块:每个原子步骤的状态快照
每完成一个工具调用或原子步骤,Agent 输出一个 CHECKPOINT 块,记录:已完成什么、下一步做什么、关键状态引用、回退提示。这是"append-only 日志"的 Prompt 等价物。
===CHECKPOINT 7===
phase: 实施
done: [已读取 package.json 发现 moment 依赖, 已扫描出 12 个引用 moment 的文件]
next: [逐个替换 moment 为 dayjs,先从 src/utils/date.ts 开始,工具:edit_file]
state_ref: [affected_files=[src/utils/date.ts, src/api/time.ts, ...], current_file=src/utils/date.ts]
rollback_hint: [若 edit_file 失败,回退到 CHECKPOINT 6(替换前),用 git checkout src/utils/date.ts 撤销]
===END===
2. SUMMARY 块:上下文压缩
每 5 个 checkpoint 输出一个 SUMMARY,把前 5 步压缩成 3 句话。之后模型只引用 SUMMARY id,不再展开细节。这是解决"上下文撑爆"的关键——用语义边界(论文里的"自声明轨迹分段")做有损压缩。
===SUMMARY S2 (covers C6-C10)===
已替换 5 个文件的 moment 引用为 dayjs,新增 dayjs 配置。当前进度 5/12,无报错。
===END===
3. RETRY 块:失败恢复
遇到错误时,Agent 不慌乱重试,而是先输出 RETRY 块:从哪个 checkpoint 恢复、改用什么策略。这是"重启安全"的 Prompt 等价物。
===RETRY===
from: CHECKPOINT 7
reason: [edit_file 报错:src/utils/date.ts 第 42 行有 moment.duration,dayjs 无等价 API]
new_strategy: [用 dayjs-duration 插件替代,先 npm install dayjs-duration,再改 import]
===END===
完整 Prompt 模板
上面的 frontmatter prompt 字段即为可一键复制的完整模板。使用时填入 {{task}} 和可选的 {{prior_context}}(恢复时传入之前的 SUMMARY 块)。
实战示例:把 moment.js 迁移到 dayjs
下面展示 Agent 按检查点协议跑一个 12 文件迁移任务的真实输出片段(精简版):
===CHECKPOINT 1===
phase: 探索
done: [扫描项目,发现 12 个文件引用 moment,总 47 处调用]
next: [按文件依赖顺序替换,先改 utils 层再改 api 层,工具:read_file]
state_ref: [affected=12 files, calls=47]
rollback_hint: [本步只读不写,无需回退]
===END===
===CHECKPOINT 2===
phase: 实施
done: [读取 src/utils/date.ts,识别 8 处 moment 调用]
next: [逐个替换,工具:edit_file]
state_ref: [current=src/utils/date.ts, remaining_in_file=8]
rollback_hint: [若替换失败,git checkout src/utils/date.ts 回到 C1 后状态]
===END===
... (C3-C5 省略)
===SUMMARY S1 (covers C1-C5)===
完成探索 + 替换 src/utils/date.ts 全部 8 处 moment 调用为 dayjs,新增 dayjs-duration 插件。进度 1/12 文件,无报错。
===END===
===CHECKPOINT 6===
phase: 实施
done: [开始替换 src/api/time.ts]
next: [读取并替换,工具:read_file → edit_file]
state_ref: [current=src/api/time.ts, progress=1/12]
rollback_hint: [回退到 C5(date.ts 已完成),git checkout src/api/time.ts]
===END===
===RETRY===
from: CHECKPOINT 7
reason: [time.ts 第 42 行 moment.duration 不兼容]
new_strategy: [改用 dayjs-duration 插件,先安装再改 import]
===END===
===CHECKPOINT 8===
phase: 实施
done: [安装 dayjs-duration,替换 time.ts 全部调用]
next: [替换下一个文件 src/components/Timer.tsx]
state_ref: [current=src/components/Timer.tsx, progress=2/12]
rollback_hint: [回退到 C7,git checkout src/api/time.ts]
===END===
... (C9-C12 省略)
===FINAL===
checkpoints: [C1, C2, C3, C4, C5, C6, RETRY→C7, C8, C9, C10, C11, C12]
summaries: [S1(C1-C5), S2(C6-C10), S3(C11-C12)]
result: 12 个文件全部迁移完成,47 处调用替换,新增 1 个插件依赖
===END===
轨迹回放器(Python)
把 Agent 输出的 checkpoint 序列喂给下面的脚本,可以回放任意步骤的状态,或从指定 checkpoint 恢复:
import re
from dataclasses import dataclass
from typing import Optional
@dataclass
class Checkpoint:
step_id: str
phase: str
done: list
next: str
state_ref: str
rollback_hint: str
def parse_trajectory(raw: str) -> list:
"""从 Agent 原始输出解析 checkpoint 序列"""
pattern = r"===CHECKPOINT (\S+)===(.*?)===END==="
checkpoints = []
for m in re.finditer(pattern, raw, re.DOTALL):
body = m.group(2)
cp = Checkpoint(
step_id=m.group(1),
phase=_field(body, "phase"),
done=_field(body, "done"),
next=_field(body, "next"),
state_ref=_field(body, "state_ref"),
rollback_hint=_field(body, "rollback_hint"),
)
checkpoints.append(cp)
return checkpoints
def _field(body: str, name: str) -> str:
m = re.search(rf"{name}:\s*(.*)", body)
return m.group(1).strip() if m else ""
def replay_to(checkpoints: list, target_id: str) -> Optional[Checkpoint]:
"""回放到指定 checkpoint,返回该步状态"""
for cp in checkpoints:
if cp.step_id == target_id:
return cp
return None
def find_rollback_target(checkpoints: list, failed_id: str) -> Optional[Checkpoint]:
"""失败时,根据 rollback_hint 找到回退目标"""
cp = replay_to(checkpoints, failed_id)
if not cp:
return None
m = re.search(r"C\w+", cp.rollback_hint)
return replay_to(checkpoints, m.group(0)) if m else None
if __name__ == "__main__":
raw = open("agent_session.log").read()
traj = parse_trajectory(raw)
cp7 = replay_to(traj, "7")
print(f"恢复到 C7: 下一步 {cp7.next}")
target = find_rollback_target(traj, "7")
print(f"回退到: {target.step_id if target else '无'}")
常见误区
- 把每个 token 都记 checkpoint:checkpoint 粒度是"原子步骤"(一个工具调用、一次决策),不是每个 token。粒度太细日志爆炸,太粗无法精确恢复。经验值:一个长任务 10-30 个 checkpoint。
- SUMMARY 压缩太狠:SUMMARY 压成 3 句话是平衡点。压成 1 句会丢关键状态引用,压成 10 句等于没压缩。如果某段步骤状态引用多,单独留一个
state_ref清单不压缩。 - rollback_hint 写成"回滚"两字:rollback_hint 必须可执行——指明回退到哪个 checkpoint、用什么命令(git checkout / 还原变量 / 删临时文件)。写成"回滚"等于没写。
- 不区分 phase:phase(探索/实施/验证/收尾)让你回放时能快速定位"跑到哪个阶段了",比纯 step_id 更易读。
何时用这个协议
- ✅ 多步骤工具调用任务(文件重构、批量迁移、CI 修复)
- ✅ 长跑监控任务(持续 lint 修复、日志告警处理)
- ✅ 需要审计回放的场景(合规要求每步可追溯)
- ❌ 单轮问答(一步就答完,记 checkpoint 是过度工程)
- ❌ 纯创作任务(写文章、写文案,状态难以结构化)