做 AI Agent 应用的人恐怕都有过这种经历生产环境突然报了一个事故你打开控制台去翻日志看到一串“agent execution terminated due to error”然后整个会话上下文被新日志冲得七零八落想复现又复现不出来只能靠猜或者靠“重启大法”。等你好不容易定位到问题修了一版又不敢确认它就真的修好了因为整条链路上涉及模型生成、工具调用、外部 API、多轮记忆任何一个环节微小的扰动都会导致结果完全不同。我在这类问题上吃过大亏所以后来痛定思痛做了一套把生产 Agent 事故 Trace 转换成可重放回归测试集的机制。核心思路其实很简单把事故现场完整“录”下来等代码改动后随时回放同一段执行过程让每一次变更都重新面对那次事故直到它确定性通过。这套东西上线后很多偶发事故终于不再只是“改了试试”而是变成了真正可回归、可验证的测试资产。今天把整体设计、实现细节和踩过的坑盘一遍适合正被 Agent 可靠性和回归测试折磨的工程师参考。1. 整体思路与设计拆解1.1 为什么生产事故会成为测试进不去的死角传统 Web 服务的回归测试基本上就是构造请求、断言响应外部依赖可以 mock 掉DB 可以准备 fixture。但 Agent 应用完全不是这套玩法。一次用户请求可能触发多轮 LLM 调用每次调用之间还有工具调用、状态更新、记忆写入任何一环的外部返回不一致后续规划路径就会跟着变。更麻烦的是生产环境的输入和中间状态高度耦合比如用户之前说过什么、当前库存数据是多少、某个上游服务是否超时这些要素不完整事故就不可能准确复现。我见过很多团队在 Agent 测试上只做两件事一是把“最终回复是否包含某个关键词”当成测试断言二是靠手动点一遍 UI 做冒烟。这两种方式对普通功能有点用但遇到生产事故基本捉襟见肘。因为事故往往发生在一个很长的上下文里触发条件可能是第三轮追问后模型意外调用了错误的工具也可能是工具返回了一个异常结构导致下游崩溃。没有对完整执行路径的录制这些事故就像在测试环境里凭空消失了一样。所以需要换一个思路不是“照着需求文档写测试”而是“把事故现场原样重放一遍”。生产环境里每一次 Agent 执行都会产生一条 Trace它记录了从用户输入到最终输出之间的所有关键事件。事故 Trace 就是最真实的测试用例因为它包含了当时环境下的完整上下文、工具返回、模型行为。只要我们能把 Trace 整理成可重放的形式就能让每个事故变成一个防回归的护栏。1.2 核心思路录制、转换、回放、断言这套方案可以拆成四个环节我习惯称作“四步流水线”录制在生产环境给 Agent 执行过程打点输出结构化 Trace。转换把事故 Trace 从生产日志里捞出来清洗、脱敏、裁剪变成测试用例。回放用测试框架重新执行这段 Agent 逻辑过程中通过 mock 还原当时的工具结果和模型响应。断言判断回放结果是否符合预期比如不再触发某个异常、关键节点被正确调用、最终回复达到语义相似度。这个过程有点像飞行员用的飞行数据记录仪。飞机出事之后黑匣子记录的仪表数据和操作记录能够完整还原事发过程工程师拿这些数据去找原因、做模拟器验证。Agent 的事故 Trace 就扮演黑匣子的角色回归测试集就是模拟器里的复现脚本。实现时要注意回放并不是“让同一段代码重新跑一遍”那么无脑关键是把外部不确定性隔离掉。现实生活中你不可能让天气、风速、其他飞机的航线都变回事故发生那一刻模拟器能做的就是把关键变量固定下来。对应到 Agent 上要固定的是 LLM 的输入输出、工具的返回值、状态缓存等外部依赖让被测逻辑在可控变量下执行。1.3 方案边界哪些场景适合重放哪些不适合这套方法不是银弹我自己在落地过程中也踩过“什么都想重放”的坑。先明确边界能省很多时间。适合纳入重放回归的工具调用异常导致崩溃例如某个外部 API 返回了非预期结构Agent 在处理返回值时抛异常。多轮对话中某些上下文导致模型规划路径偏移比如把查天气的工具误用于查库存。Prompt 或模型版本变更后的回归确认新版本不会重新触发旧事故。重试、超时、并发等时序型问题通过录制锁定的返回顺序来重放。不适合强行重放的需要实时评估模型开放生成能力的场景例如对全新用户请求的响应质量评测。依赖真实红包、真实支付、真实计费的端到端验证这类测试应该留在沙箱环境。模型输出必须逐字一致才能通过的用例。重放的价值在于“过程可回归”不是“输出可克隆”把断言设计成逐字比较只会让你陷入无休止的假失败。一句话总结重放回归集的目标是防止“已知问题再次出现”而不是替代全部测试。它应该和单元测试、集成测试、线上巡检并存各管一段。2. 数据采集把事故现场完整保存下来2.1 Agent 执行过程里到底要记录什么这部分是整个方案的基石。Trace 录得不完整后面一切都是空中楼阁。我总结了一个最小记录清单会话与执行标识session_id、trace_id、时间戳、Agent 版本、代码版本、模型版本。用户输入本轮输入以及必要的上下文摘要或原文。LLM 请求调用的模型名、messages 内容、temperature 等采样参数。LLM 响应完整输出内容、finish_reason、token 使用情况可选。工具调用工具名、入参、返回值、异常信息、耗时。状态变更Agent 在节点间传递的关键状态比如意图、候选结果、记忆片段。事件顺序每个事件的全局递增序号以及父子关系哪个节点调起了哪个子步骤。这个清单看起来琐碎但事故往往就藏在某个看似不起眼的字段里。比如有一次我们定位了三个小时最后发现是工具返回的 stock 字段为 0 时Agent 代码里用了 stock / 2 做换算直接抛了除零异常。如果没有记录工具返回值这类问题根本无从查起。记录时有一条原则宁可多记不可少记但要设上限。不可能把整段对话无限量堆下去所以对单条 Trace 的事件数、单条消息大小要设置上限超出部分截断并标记防止生产链路被追踪逻辑拖垮。2.2 Trace 格式设计我推荐使用扁平事件列表加统一 schema 的方式每个事件都带 type 字段。这样对后续解析和回放都友好也方便在排查时用 jq、Python 等工具快速过滤。下面是我项目里使用的示例格式{ schemaVersion: 1.0, traceId: t-1727687012345-9f2a, sessionId: s-20250601-88a2, startedAt: 2025-06-01T10:23:45.120Z, appVersion: agent-service-2.4.0, events: [ { seq: 1, type: llm_request, node: planner, model: deepseek-chat, messages: [ {role: system, content: 你是库存助手。}, {role: user, content: 查一下 A100 库存} ], temperature: 0.0, timestamp: 2025-06-01T10:23:45.124Z }, { seq: 2, type: tool_call, node: planner, tool: search_inventory, input: {sku: A100}, result: {stock: 0, warehouse: east}, timestamp: 2025-06-01T10:23:46.200Z }, { seq: 3, type: error, node: calculator, tool: calc_turnover, error: ZeroDivisionError: division by zero, stack: ... timestamp: 2025-06-01T10:23:46.210Z } ] }设计上有几个重点。第一schemaVersion 一定要有后面格式演进时可以写兼容迁移脚本。第二seq 是全局递增的回放时依赖这个序号恢复原始顺序。第三工具调用的 input 和 result 必须是完整 JSON 序列化后的快照而不是引用一个外部对象否则回放时数据就找不到了。如果你用的是成熟框架例如 LangChain 或自研框架可以在框架的 callback 机制里埋点统一输出上述 event。我们早期试过在每个 Agent 工具函数里手动加 print 日志结果漏了一堆关键节点后来改成在框架层做统一拦截才稳定下来。2.3 埋点与采样的工程取舍生产环境全量打 Trace 的成本不小尤其是 LLM 请求的 messages 可能很大日志系统扛不住。我的做法是分层采样所有正常请求默认按 1%~5% 比例采样。所有出现 error、exception、重试、超时的请求 100% 记录。所有携带高风险操作支付、删除、迁移的请求 100% 记录。采样不是目的目的是保证“出事时有现场”。所以我做了一层兜底逻辑如果试运行过程中检测到工具调用异常或模型返回 format error立刻把该条的采样率提升到 100%并打上“urgent”标记方便后续捞取。埋点阶段还要考虑数据安全和隐私。用户的输入、模型输出里可能包含手机号、邮箱、证件号甚至内部业务数据和商业机密。存储前要按字段做脱敏比如把电话中间四位、邮箱前半部分打码或者整体替换成假数据。脱敏操作要记录到 Trace 元信息里比如“maskApplied”: true避免回放时把脱敏结果当成原始数据。关于 Trace 的存储我们一开始直接写日志文件后来发现搜索太费劲改成了先写本地队列再由采集服务批量写入对象存储并且按 traceId 做了索引。这样事故发生后只要拿着 traceId 就能快速拉出完整事件链不需要在一大堆无结构日志里 grep。3. 从 Trace 到测试用例转换实现3.1 清洗与裁剪生成最小复现集原始生产 Trace 往往包含大量与事故无关的噪音比如前面几轮正常对话、冗余的系统提示、重复的模型请求。如果原封不动扔进回归测试集不仅执行慢而且定位问题时还得在一堆无关事件里找线索。我采用了一个“最小复现集裁剪”的思路。第一步根据事故类型确定保留范围。如果是工具调用异常就保留从用户输入到触发异常节点的路径裁剪掉后续未执行的节点。如果是模型规划错误就保留导致错误决策的上下文片段可能需要回退几轮。第二步做二分式删除验证。先把事件列表从中间砍掉一半回放一次如果事故还能复现说明后半段存在关键事件继续砍后半段如果事故消失说明被砍掉的某段参与了触发把这段加回来继续二分。重复几次就能得到一个比较小但仍然能复现问题的用例。这个操作我以前靠人肉做后来写成了脚本配合回放执行器自动迭代。清洗时还要处理一个问题Trace 事件里可能带有时间戳和耗时数据这些值会影响回放吗在大多数情况下不会但是有些 Agent 逻辑会依赖“当前时间”去判断早晚比如“早上问候”“工作日排班”。这种情况下需要把事件里的原始终端时间记录下来回放时用时间冻结机制还原成那个时刻否则逻辑就会走偏。脱敏也是清洗的重要环节。在转换脚本里我会对每一类敏感字段配置正则或关键词规则统一替换成确定性假数据。注意一定要确定性否则同一个字段在两次转换中生成不同假数据后续断言会不稳定。最简单的做法是用原始值的 hash 推导出一个固定替代值。3.2 测试用例结构设计每个事故最终会变成一个测试用例目录里面至少包含两个文件trace.json 和 expected.yaml。trace.json 是经过清洗脱敏后的事件列表回放执行器的输入。expected.yaml 是断言配置描述“通过”和“失败”的判定标准。我习惯把 expected.yaml 设计得很明确例如name: inventory_zero_should_not_crash description: 库存为 0 时Agent 不应抛除零异常 trigger: type: error node: calculator error_type: ZeroDivisionError expect: no_error_contains: ZeroDivisionError tool_calls: - name: notify_operator required: true response_similarity: minimum_score: 0.8 reference: 库存暂时不足已通知补货人员。当回放执行完判定模块会依次检查执行过程中是否出现了指定错误类型如果仍出现判定为“事故复现”测试失败。是否在关键路径上调用了某个预期工具比如发生库存异常时通知运营人员。最终回复与参考文本的语义相似度是否达标。这套结构与普通单元测试最大的区别是它把“事故当时的上下文”和“修复后应该有的行为”绑在一起天然比手写的 fixture 更贴近真实环境。测试集里的每个用例都有一段来自生产的真实故事这让测试集本身具备了很高的说服力。3.3 断言策略不要只盯着最终答案最初我以为断言很简单只要判断“回放后 Agent 没有报错”就算通过。后来发现这种断言太脆弱。事故往往表现为“没报错但行为已经错了”比如模型开始在错误场景调用收费工具或者用户在第三轮被强行终结。所以我把断言分成三层。结果层断言检查最终回复或最终工具结果是否符合期望。由于模型输出天然有随机性不要做字符串相等判断而是用嵌入向量算余弦相似度。选一个阈值比如 0.8 以上视为语义等价。阈值要定期根据失败用例回调我见过太低的阈值把明显错误放过太高的阈值又让测试天天红。过程层断言这是重放测试独特的优势。因为回放保留了事件顺序断言可以指定“必须经过某个节点”“必须按某个顺序调用工具”“禁止调用某个工具”。比如有一次事故是模型在用户仅询问价格时调用了“下订单”工具我把这个行为固化成断言process.forbidden_tool_calls: [place_order]防止回归。副作用层断言Agent 不仅产生文本回复还可能修改数据库、发送消息、触发外部流程。回放时我会把副作用调用记录成事件断言可以检查副作用事件的数量和类型是否符合预期。比如“通知用户”事件应该出现一次而不是三次。这样能拦截不少“重复执行同一副作用”的问题。三层断言并不要求每条用例都全配齐而是按事故类型选择。对于纯规划错误类事故过程断言是主力对于工具返回异常类事故结果断言和错误类型断言更有效。关键是想清楚“这个事故当时被破坏的到底是哪一层”对应的断言才会有的放矢。4. 可重放测试框架的实现4.1 mock 外部依赖让回放跑在可控环境里回放的核心难点是让 Agent 在测试环境里“重温”当初的路径而不是真的再去调用一次生产数据库和外部 API。外部依赖必须 mock 掉否则环境一变结果就变测试也就失去意义了。我在实现时做了一个 ReplayHarness它从 trace.json 读取所有事件为 Agent 框架注册了可替换的 LLM 客户端和工具执行器。当 Agent 发起一次工具调用时执行器会去 Trace 里查找相同 seq 的事件直接返回当时记录的结果。如果找不到对应事件就抛出一个显眼的“ReplayMiss”异常提示测试数据不完整。核心逻辑示意如下实际项目里可以根据自己框架的接口做适配class ReplayHarness: def __init__(self, trace_events): self.events trace_events self.index 0 def llm_call(self, messages, **kwargs): 模型调用直接回放历史响应 while self.index len(self.events): event self.events[self.index] self.index 1 if event[type] llm_request: # 可选校验当前请求应该和记录中的什么阶段对应 return event[response] raise ReplayMiss(no llm event left) def tool_call(self, name, tool_input): while self.index len(self.events): event self.events[self.index] self.index 1 if event[type] tool_call and event[tool] name: return event[result], event.get(error) raise ReplayMiss(fno matching tool event for {name})这里有个值得注意的点顺序匹配用“按当前序号推进”的方式虽然简单但实际执行中 Agent 的编排可能因为分支而产生与原始 Trace 不同的调用顺序。为了兼顾灵活性和确定性我增加了一个选项基础模式是严格按 seq 一次取一个事件如果本地复现确认路径已修复与原始 Trace 有出入可以切换到“按工具名查找下一个匹配事件”的宽容模式。测试默认用严格模式让制造代码改动时第一时间看到路径漂移非常直观。工具函数本身要注册进 harness 的注册表但注册进来的函数体不需要真的实现业务逻辑它的职责是接收参数、返回 Trace 中锁定的结果或者抛出 Trace 中锁定的异常。这样即使工具背后依赖的第三方 SDK 升级了、数据库连接挂了回放测试都不会受影响。数据库和缓存也必须隔离。我在回放环境里使用独立的 SQLite 或内存库初始数据通过 fixture 准备或者干脆不准备因为工具调用结果已经被 mock 掉了Agent 内部如果还有直接查询 DB 的节点则需要单独把这些查询结果也录制进 Trace。一个经验凡是 Agent 会直接访问的 I/O 都在埋点阶段记录返回值否则回放时会漏。4.2 确定性控制与时间管理重放最忌讳“这回放结果不稳定”否则没人敢信这套测试集。要做到稳定需要从几个源头消除随机性。第一个源头是 LLM。测试时如果还走真实模型调用哪怕同一个 Prompt输出也可能不同。我会在回放时将 LLM 调用切换到 harness 提供的“历史响应”模式本质上是不调模型只按 Trace 返回当时的响应。这样既快又稳还不会产生额外 token 费用。有些读者可能会问如果用历史响应岂不是测不出模型升级带来的影响这个问题很好。我会单独维护一组“模型真实验证”用例专门跑模型变更评估区分开“框架回归”与“模型回归”。第二个源头是随机种子。Agent 代码里如果用到随机数、numpy 随机、随机 sample、蒙特卡洛方法编译探索路径时就会出现差异。回放前设置固定的 random.seed(42)并且在代码库中禁止生产逻辑使用未受控的全局随机源。第三个源头是系统时间和并发时序。时间依赖用时间冻结库比如 Python 的 freezegun 或者 JavaScript 的 timekeeper把 datetime.now() 固定为 Trace 中的 startedAt。并发和异步回放时最容易乱序所以我默认将回放模式设置为串行执行。每个事件执行完并校验顺序后再进入下一个事件。如果需要复现并发问题我会单独做并发测试集而不是复用这套回归集因为两者目标不同混在一起只会互相干扰。为了进一步校验回放路径是否漂移我还在 harness 里记录了“本次回放实际触发的事件 seq 列表”并和原始 Trace 做 diff。diff 结果会附加到测试报告里。这个设计在排查问题时尤其有用有一次我们改了个 Prompt结果发现所有测试用例的事件 seq 整体顺延了一位说明某个中间节点被新版本悄悄插入这种变化如果不看 diff 根本发现不了。4.3 与 CI 编排让每个事故用例回归到每一次变更测试集建完之后最后一步是接入 CI让它在每次代码变更时自动运行。我采用的做法是建一个独立仓库或独立目录保存所有事故用例流水线里增加一个 job 专门跑“Agent 事故回归集”。流水线配置不需要很复杂关键点有四个预构建测试数据启动时把回放环境所需依赖装好并拉取 trace 测试集到固定目录。并行与串行两种模式都要支持。默认并行跑不同用例但同一用例内部必须串行防止共享状态污染。结果上报支持 JUnit 或 JSON 报告格式确保能接入现有质量看板。失败自动归档如果某个用例在 CI 上挂了自动把当次的完整日志和执行环境信息保存成新的排查材料。最小的 GitHub Actions 示意大致如下name: agent-regression on: pull_request: paths: - src/** - tests/regression/** jobs: replay: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: actions/setup-pythonv5 with: python-version: 3.11 - run: pip install -r requirements.txt - run: python -m pytest tests/regression -m replay --htmlreport.html - if: failure() uses: actions/upload-artifactv4 with: name: replay-logs path: | report.html logs/**回归测试集的数量会随着事故增多而增多我建议对用例按模块或事件类型打 tag定期跑全量日常可以只跑新增用例和受影响模块。全部用例跑一遍通常几分钟到十几分钟完全在可接受范围内。5. 常见问题与排查技巧实录5.1 事故 Trace 经常缺关键信息回放直接卡死这是刚落地时最频繁的问题拿着一条生产 Trace 去转换回放时报 ReplayMiss说找不到某个工具调用。原因通常是埋点阶段没有覆盖全有些工具函数是绕过统一调度器直接暴露给外部模块调用的或者异步回调里的工具调用没有打进事件流。解决办法分两步走。第一步在埋点阶段做一个“事件完整性校验”每条 Trace 结束时检查一下 LLM 调用次数、工具调用次数是否有明显缺口比如规划器说调用了 search_inventory但事件列表里没有对应记录就主动标记为 inComplete。第二把这种事故 Trace 在测试集构建阶段就打回“不可用”名单宁可不回归也不能让测试带着残缺数据运行。另一种情况是缺的不是事件而是事件里的字段。比如 context 只记录了摘要没有记录原始 messages回放时 Agent 在节点上想要读取某段对话内容却取不到。处理方法是把字段缺失校验也加进转换脚本发现缺必填字段就报错绝不会悄悄给个空值。5.2 回放结果和事故现场对不上路径漂移严重我遇到过一种诡异情况事故 Trace 是从生产环境捞出来的代码也没改但一跑回放就走不到原来的故障点。后来查下来发现生产代码里有一个全局配置开关在测试环境的默认值不同导致 Agent 在选择模型策略时走了另一个分支。这类“隐藏环境依赖”最难排查。对策是回放环境的默认配置必须严格镜像生产环境尤其是模型名、temperature、超时阈值、工具开关、最大迭代轮数。如果这些配置有变化要在测试报告里明确提示“环境配置不一致”。其次用路径 diff 定位漂移点对比原始事件 seq 和本次实际事件 seq。一旦发现从第 N 个事件开始不匹配就去检查代码变更或者环境配置基本上每次都能快速定位。漂移本身不是坏事有时它说明代码行为发生了预期变化。关键在于“漂移是否被明确评估过”。我在项目里设了一个机制当新版本回放时发生漂移测试不会直接判定失败而是进入人工审核模式维护者需要确认这次漂移是否合理。合理则更新 expected.yaml 和原始 Trace 快照不合理则记为真回归阻断合并。这个机制极大减少了“测试红了但不知道是测试数据老还是 bug 新”的迷茫。5.3 回放结果波动同一份 Trace 今天过明天挂这种不稳定通常来自三类原因。第一类是 Agent 代码在回放时仍然依赖了真实模型哪怕只有一小段比如“用于接口解析的 LLM 调用”没有走 harness 的 mock 逻辑。这个问题的排查方式是看测试日志里的模型调用事件一旦发现没有 ReplayHarness 标记就说明有漏网的真实调用。第二类是断言本身太脆弱用了精确字符串匹配。比如断言“同意退款 123.45 元”模型输出可能是“123.45 元退款已同意”字符串对不上但语义没问题。换成语义相似度断言后这类问题基本消失。要注意的是语义断言不要对所有用例一刀切关键词类场景、固定 JSON 输出场景还有各自更合适的断言方式。第三类是测试集共享状态被污染。比如多个用例跑在同一进程里某个用例改了一个全局变量影响后续用例。治理方案是每个用例用独立进程或独立临时目录环境变量和全局配置在 setUp 和 tearDown 里强制重置。虽然会牺牲一点执行速度但换来的稳定性非常值得。我在实际项目中维护过一个简单的“事故复现成功率”指标用来衡量测试集的质量成功率低于 100% 时优先排查测试框架本身而不是往业务代码上找问题。经过持续治理这套回归集现在已经能够稳定运行确实帮我们堵住了好几个差点漏掉线上的回归事故价值非常直接。最后再分享一个小技巧转换脚本一定要支持“只抽出某一段连续事件”的命令行参数。很多事故场景只需要 Trace 中第 8 到第 15 个事件就能复现没必要把整个会话拉进来。我习惯用类似 python tools/trace_to_case.py --trace t-xxx.json --start 8 --end 15 的方式快速生成最小用例。配合自动化裁剪整个流程能用分钟级完成。经过几次事故洗礼后我觉得每个 Agent 团队都应该给自己的生产事故配上这样一套“黑匣子”不然每次线上出问题都像是在没光的房间里摸电路成本太高了。