把 Agent 线上事故变成 CI 里的回归测试:Chronicle 的 Cut-Point Replay
上周排查一个 agent 线上事故的时候,我又碰到了那个让人血压升高的场景:日志里明明白白写着 agent 把一笔约 $1k 的请求当成「卖出 1000 股」执行了,按市价折算接近 $190k。然后你想复现?对不起,重跑十遍,十遍的轨迹都不一样。模型温度已经调到 0 了,没用——GPU 上并行计算的浮点非结合性让 logits 每次都有微小差异,多跑几步轨迹就彻底分叉。
这就是 LLM agent 调试里最憋屈的地方:失败是真实的,但它不可复现。
今天聊的这篇论文 Chronicle(arXiv 2609.20625),就是冲着这个痛点来的。它做的事一句话能讲完:把 agent 运行中所有非确定性的点记录下来,回放的时候让你只重新跑你想改的那一段代码,其余部分原样从记录里喂回来——于是一次线上事故就变成了一个能进 CI、零模型调用成本的回归测试。
核心摘要
Chronicle 解决的问题非常具体:LLM agent 的失败依赖不可复现的推理、会变化的外部状态、以及重跑几乎不会重复的多步轨迹,导致事故修完没法验证「真的修好了」。它的方案是把模型调用、工具调用、路由决策这些非确定性边界记录成不可变的 envelope,然后引入 cut-point replay——回放时选定一部分边界用新代码 live 执行,其余边界从记录里服务。实测数据挺能打:记录开销中位数仅 23 µs/次(占一次 300ms 模型调用的 0.008%),full replay 零模型调用、20 次重复逐位一致,cut-point 测试在 6 个记录事故上全部正确捕获故障、通过修复。在 192 个变异体的 mutation study 里,cut-point 杀掉了 51 个危险 mutant,而传统「stub 所有边界」的 mock 方案一个都杀不掉。这篇论文不是底层算法突破,而是一次很扎实的工程范式整合——但对做 agent 的人来说,可能比很多「大新闻」论文更有用。
论文信息
- 标题:Chronicle: Cut-Point Replay for Regression Testing of LLM Agents
- 作者:Tisha Chawla、Susheem Koul(共同一作)
- 机构:Microsoft
- 发表:arXiv:2609.20625,2026 年 9 月 17 日
- 代码:https://github.com/theagentplane/chronicle (论文与 benchmark 均已开源)
为什么重跑救不了你
先把这个问题的三根钉子钉清楚,因为论文的整个设计都是冲着这三点来的。
钉子一:LLM 推理不是 bitwise reproducible 的。 哪怕 temperature=0、同样的 prompt、同样的模型版本,两次调用的输出在比特层面也不保证一致。这个坑做过 infra 的基本都踩过,论文引用的 Atıl et al. (2025) 专门研究过这个现象。
钉子二:工具读的是会变化的世界。 事故发生时查的账户余额、当时的库存、那一刻的文件系统状态——事后全变了。你重跑 agent,工具返回的已经不是当时的输入。
钉子三:多步轨迹有路径依赖。 失败往往不是一个错误输出,而是一条由模型调用、工具调用、路由决策串起来的链。重试逻辑、路由分支稍微偏一点,链条就走向完全不同的方向。Cemri et al. (2025) 的多智能体失败分类研究说的就是这个。
三根钉子合起来的结果,工程上叫 flakiness:同一份代码,时而过时而挂,你根本不知道修复有没有生效。
那现有的工具链呢?说实话这块我看到论文的批判时是有点共鸣的——LangSmith、Arize 这类 tracing 平台记录的是「发生了什么」,promptfoo 这类 eval 框架给输出打分,但没有任何一个东西允许你:改动事故轨迹里的某一个组件,其他一切保持不变,验证这个改动是否修复了失败。前者是事后观察,后者是打分排名,都不是回归测试。
这个 gap 就是 Chronicle 的落点。
Chronicle 的三个动作:Record、Replay、Test
Record:在边界处打信封
整个系统的基本单位叫 boundary(边界)——agent 调模型、调工具、做路由决策的点,也就是重跑可能发散的点。
开发者只需要一行注解:
@boundary("place_order", kind="tool")
def place_order(symbol, qty): ...
plan = (ReplayPlan()
.stub("agent", 1) # 从记录里服务
.live("place_order", 1) # cut-point:用新代码真跑
.live("agent", 2))
assert session.captured_result(
"place_order", 1)["blocked"]
边界每执行一次叫一次 crossing,存成一个不可变的 envelope:输入、输出、加上模型版本和采样参数这类防漂移的元数据。同一边界被穿越多次时按「名称 + 出现次序」寻址——循环里跑了三次就是 agent[1]、agent[2]、agent[3]。
有几个工程细节我想提一下:记录是透明的(不改返回值、不吞异常);写入前对 secrets 和易变字段做 redaction;顺手发标准 OpenTelemetry spans——可以直接挂进现有观测体系,不用另起一套。
还有一个忠实性前提要想清楚:envelope 只记录边界的输入输出,不记录内部行为。所以只要边界的输出仅依赖被记录的输入,回放就是忠实的;反过来,偷偷读时钟、读数据库隐藏状态的边界是例外,这类代码得自己管住。
Replay:full replay 与 cut-point replay 的分工
回放有两种模式,分工非常清晰:
| 模式 | 谁 live 跑 | 测的是什么 |
|---|---|---|
| Full replay | 谁都不 live,全部从记录服务 | 边界之间的确定性胶水代码(用真实记录的输入跑) |
| Cut-point replay | 选定子集用新代码 live 执行,其余从记录服务 | 你改的那个组件及其后果 |
Cut-point 这个词取得挺形象:你在轨迹上切一刀,切口之前的部分不重跑(直接用记录复现前置状态),切口处的代码用新版本真跑,看结果对不对。
安全性上有个务实的兜底:Chronicle 按名称为每个被 stub 的边界做调用计数检查——如果新代码导致某边界被穿越的次数跟记录对不上(多转了一轮循环、少了一次重试),回放直接抛错,而不是静默通过。这个设计我挺喜欢,它让提交的测试在 agent 演进过程中保持诚实:fixture 过期了会大声告诉你「该重新录了」。
当然它有已知盲区:保持每个名称计数不变的重排序检测不到。论文自己也承认了,说给 stubbed crossings 加保序摘要(order-sensitive digest)可以补上,但这次发布没做。
Test:一次事故 + 一条断言 = 零成本 CI 测试
测试层用的是结构断言:检查 agent 到底做了什么——调了哪个工具、传了什么参数、被防护的操作是否被拒绝。
关键的账在这里:full replay 上的断言是确定性的、零模型调用,所以一次记录的事故加上一条断言,就是一个可以随每次 commit 跑、provider 成本为零的回归测试。对于 faithfulness 这类非结构属性,Chronicle 附带了一个 advisory 性质的 LLM-as-judge,但论文很坦诚地说没评估它的可靠性。
实验:6 个事故,192 个变异体
Benchmark 设计
作者发布了 6 个记录事故构成的 benchmark,每个都是一个小 agent(model → tool → model 的三步结构),其中无防护的工具会产生不安全结果,受防护版本纠正它:
| 事故 | 不安全结果 | 防护手段 |
|---|---|---|
| Refund | 退款金额按订单号定大小 | 固定上限 |
| Invoice | 发错货币 | 货币校验 |
| Trade | 把名义金额当成股数 | 名义金额上限 |
| 收件人范围过宽 | 收件人白名单 | |
| Payout | 付款账户被替换 | 账户校验 |
| Deletion | 删掉生产文件 | 删除闸门 |
坦白讲,这个 benchmark 规模很小,而且论文自己也承认:事故、防护、断言是一起写的,所以这更像机制验证,而不是一般意义上的故障检测率度量。但 6 个场景都对应 Cemri et al. (2025) 失败分类法里有据可查的模式(主要是「行动前缺失检查」这一类),不是拍脑袋编的。
记录开销:便宜到可以忽略
| 指标 | 数值 |
|---|---|
| 每次 crossing 记录开销(中位) | 23 µs |
| 占一次 300ms 模型调用的比例 | 0.008 个百分点 |
| 每次 crossing 存储增长 | ≤ 1.44 KB |
| 真实模型调用延迟(recording on / off) | 3,136 ms / 3,045 ms |
最后一行值得展开说。作者用 Qwen3.5 4B(4-bit、Ollama、笔记本 CPU 本地跑)做了每臂 50 次的交错对比,开关记录的均值差只有 91 ms(95% 置信区间 -59 到 +241 ms),统计上跟零不可区分。考虑到推理本身逐次波动就有近 400ms 的标准差,23 µs 的插桩成本比运行间自然波动低四个数量级。也就是说,「开记录会拖慢 agent」这个顾虑可以放下了。
确定性:20 次重复,逐位一致
Full replay 在 20 次重复中逐位一致地复现每次运行,零分歧、零 live crossing。套件里 12 个模型 crossing 发起 0 次真实调用,1000 次全套件回放的 provider 成本是 $0.00。一次 full-stub 套件跑完 3.5ms,cut-point 3.6ms。
这里要泼一点冷水:benchmark 的模型边界是模拟的确定性 stub,所以这个「bit-stable」验证的是回放机制本身,不是「在真实非确定性 provider 下能复现」。论文在 Limitations 里明说没测后者。诚实,但读的时候要记住这个边界。
真正的重头戏:mutation study
故障检测部分(cut-point 在 6/6 事故上正确 fail 无防护代码、pass 修复和良性改写,容忍 30 个无关改写)说实话说服力一般——事故和断言本来就是配套写的。
真正有信息量的是 mutation study。作者用标准的关系、逻辑、常量变异算子给每个受防护工具生成所有一阶变异体,共 192 个。规则:测试对变异体的判定与对未变异修复不同,就算「杀死」。
结果:
| 方案 | 杀死的 mutant |
|---|---|
| Cut-point replay | 51 / 192 |
| Full-stub 基线(stub 所有边界,同样的断言) | 0 / 192 |
基线挂零的原因很本质:stub 掉所有边界之后工具代码根本没跑,mock 永远返回记录里那个不安全结果,有没有防护门都一样判 fail。这就是为什么「重 mock」的 agent 测试验证不了真实交互——mock 得越狠,测试离真实代码越远。
再看 141 个幸存者的构成,更能看出作者的严谨:110 个是任何基于这些记录的测试都杀不死的(改动的是记录输入从未到达的代码路径,或者改了防护但不改变对该输入的判定,比如在输入早已超过的阈值上把 > 改成 ≥);剩下 31 个改的是断言故意忽略的输出字段(如 status label),操作照样被拦住了。没有任何一个幸存 mutant 能让记录的不安全行为漏过去。 如果只算「会改变记录输入上行为」的 mutant,杀死率是 51/82,即 62%。
这个数字该怎么看?62% 不算惊艳,但考虑到每条记录只演练一个输入,这个覆盖上限是 fixture 的固有局限,不是方法的锅。想提高,就录更多事故。
我的判断
这篇论文最值钱的地方不是技术新颖性,而是把一件大家隐约知道该做、但没人系统做的事做成了可用的范式。
Record-and-replay 本身不新鲜,Temporal 的 replay tests 早就会拿记录的事件历史重跑变更后的工作流代码,LangGraph 的 checkpoint 也支持重新进入已执行节点。Chronicle 的贡献在于把这个思想精确地适配到 agent 的非确定性边界上:按名称索引的 crossings、任意选择 live 子集、用真实记录替代手写 mock。对比之下,LangGraph 那类系统作用在运行期间(恢复进行中的 run),Chronicle 作用在运行结束之后(拿过去的事故评估候选修复)——前者是运维工具,后者是测试工具,这个定位区分是清楚的。
问题也说几个。Benchmark 太小,6 个三步事故,没有循环、没有重试、没有多智能体路由——而这些恰恰是最能体现 cut-point 价值的复杂场景,论文等于在自己激发动机的地方没有给出评估。另外不支持流式响应和并发工具调用,这在 2026 年的 agent 框架里是挺常见的形态。对破坏性工具做 live cut-point 时论文建议指向 sandbox,这个提醒很必要——你可不想在 CI 里真删一次生产文件。
还有一层值得想想:这套方案的哲学是「用真实记录替代手写 mock」。顺着这个思路再推一步,它其实暗示了 agent 工程的一种工作流闭环——线上事故不再是「修完祈祷」,而是变成不断累积的回归资产。每次事故录一条 trace,提交进仓库,测试套件随时间自然变厚。这比手工编 benchmark 的覆盖面真实得多,因为场景全部来自真实炸过的坑。
如果你在做 agent 工程,我的建议很直接:去看看它的 GitHub 仓库,那个 @boundary 注解 + ReplayPlan 的 API 设计很轻,接入成本看起来不高。哪怕不用它的实现,「事故即 fixture」这个思路也值得抄进自己的基建里。
觉得有启发的话,欢迎点赞、在看、转发。跟进最新AI前沿,关注我