2026年7月30日 1 分钟阅读

AI Agent 的测试为什么总在 CI 里失灵:用 AgentSnap 把回放、实时漂移和工具调用分开验证

tinyash 0 条评论

传统单元测试擅长断言“函数输入 A,得到输出 B”。但把大模型、工具调用和多步循环放进应用后,测试目标随之改变:同一问题的自然语言输出不一定逐字相同;模型可能在不改业务代码的情况下更新;工具名称、调用次序或参数却可能悄悄变化。只用最终文本做精确比较,测试会脆弱;完全不测,又会把提示词退化、错误的工具接线和调用次数膨胀带到生产环境。

AgentSnap 是一个 MIT 许可证的 Python 工具,定位是为 AI Agent 做确定性的快照测试。它在一次“黄金运行”中记录 LLM 与工具调用轨迹,随后把新运行与提交到仓库的快照进行比对。它尤其适合补在已有 pytest 套件中:不要求把原来的 Anthropic 或 OpenAI 客户端改写成某个特定框架包装器。

本文把它放在一个更实际的工程问题里讨论:怎样把“代码回归”和“模型漂移”拆开,让 CI 给出有行动价值的失败信息。

先分清三类会坏掉的东西

Agent 的失败通常至少来自三个层面。

第一层是应用代码回归。比如有人调整了 system prompt、把工具参数字段接错,或一次重构让循环多跑了两轮。这类问题应该在每个 PR 中稳定复现,不能依赖外部 API,也不应消耗 token。

第二层是模型行为漂移。供应商更新模型、改动安全策略或改变工具选择偏好后,代码完全没变,真实调用的轨迹仍可能不同。它不适合成为每次提交的硬阻塞条件,却值得以夜间任务或发布前检查的方式尽早发现。

第三层是副作用。即便 LLM 响应能回放,Agent 的工具仍可能写文件、发请求或修改测试数据库。测试设计必须明确:这次要验证的是“LLM 决策是否被固定”,还是“整条工作流也不允许产生真实副作用”。

把三者混在一个 assert answer == expected 里,失败后很难判断究竟该修 prompt、修业务代码,还是接受模型的合理变化。

AgentSnap 记录什么,而不只记录最终答案

AgentSnap 将一次运行保存在 __agent_snapshots__ 下的快照中,并从多个维度比较新旧轨迹。结构维度关注工具调用名称与顺序;参数维度比较工具调用参数;模型工具维度关注模型实际请求的工具及其参数;语义维度则比较 LLM 回复与最终输出。

这里“模型请求的工具”和“你的代码实际执行的工具”要分开看。一个调度器可能对模型请求做过滤、重试或补偿。如果模型从 search 改为请求高风险的 delete_file,即使业务层尚未执行它,也应当在测试报告中成为独立信号。AgentSnap 的 README 说明,这项模型工具比对目前覆盖非流式的 Anthropic 与 OpenAI 调用;Groq 和 OpenRouter 通过其 OpenAI 适配路径获得同类支持。不要把它泛化为所有 provider、所有流式接口都已覆盖。

最小接入:先把现有 Agent 围起来

安装后先运行初始化向导。它会把比较配置写入 pyproject.toml,并将密钥写到 .env,而不是写入项目配置文件。离线语义比较可选用嵌入模型;需要更高语义判断精度时再配置裁判模型。

pip install agentsnap
agentsnap init
agentsnap check

对于已有的 Python Agent,关键是用 PatchSet 打开 SDK 级别的拦截,再用 AgentRecorder 包裹黄金运行。下面的调用结构来自项目文档;my_agent 可以保持自己的内部实现:

from agentsnap import PatchSet, AgentRecorder

with PatchSet():
    with AgentRecorder("support_agent") as rec:
        result = my_agent("查一下订单 #1234 的状态")
        rec.output = result

首次运行后,检查生成的快照内容是否确实对应一个可复现的业务场景,再把应提交的快照文件加入版本控制。黄金样本不是“任何一次碰巧成功的线上响应”:输入应去除时间、随机 ID、真实用户数据等不稳定因素;涉及写操作的工具最好指向测试替身或沙箱。

后续断言使用 AgentAsserter。如果轨迹超过阈值,工具会抛出 AgentRegressionError 并给出结构化 diff,而不是只告诉你“输出不相等”。

from agentsnap import PatchSet, AgentAsserter

with PatchSet():
    with AgentAsserter("support_agent", mode="replay") as check:
        result = my_agent("查一下订单 #1234 的状态")
        check.output = result

PR 用 replay,夜间再用 live

最值得采用的切分是双通道,而不是二选一。

replay 模式会把已记录的模型响应送回 Agent。这样每个 PR 可以检查提示词、调用顺序、工具接线和循环逻辑,却不需要 API key、不会产生模型调用费用,也减少了因模型随机性导致的 CI 抖动。它非常适合设为默认测试模式:

[tool.agentsnap]
mode = "replay"

live 模式则调用真实 API,用来捕捉模型或 provider 发生的行为变化。更合理的落点是每日定时任务、候选版本验证或人工复核队列。live 失败不应自动等价于“代码有 bug”:先查看是工具选择、工具参数还是语义输出的变化;再判断它是否违反产品约束,以及是否需要更新 prompt、策略或黄金快照。

注意一个容易遗漏的边界:replay 只替换 LLM 响应,工具调用默认仍会真实执行。若测试目标是不产生任何副作用,需要显式启用 replay_tools=True,或在应用层把邮件、支付、文件写入等工具替换为可观察的 fake。把“没有 token 消耗”误解为“没有外部影响”,是 Agent 测试中很危险的假设。

pytest 中的推荐形态

项目还提供 pytest fixture。它的好处是第一次调用可录制快照,之后自动转为断言;agentsnap_instrument 负责启用拦截。团队可以把场景名称视为测试契约,按业务能力拆分,而不是用一个巨大端到端用例覆盖所有路径:

def test_support_agent_reads_order(snapshot, agentsnap_instrument):
    with snapshot.run("support_agent_reads_order") as run:
        result = my_agent("查一下订单 #1234 的状态")
        run.output = result

场景应覆盖风险最高的分支:需要读取敏感数据前的确认、工具参数校验失败后的恢复、模型拒绝时的降级路径,以及多工具串联时的调用顺序。对于每个场景,优先断言可审计的行为边界,而不是要求自然语言修辞一字不差。

流式、旧快照与 provider 差异

落地前还要把能力边界写进测试说明。项目文档指出,replay 需要由 AgentSnap 0.2.0 或更高版本录制的快照;旧格式缺少 raw_response,需要重新录制。流式调用可被记录和重建,但录制时的流式形态不能直接当作非流式调用来回放。client.messages.stream() 辅助接口与流式 OpenAI Responses API 仍属于未覆盖或需要谨慎验证的范围。

因此,升级 AgentSnap、切换 SDK 或调整 provider 时,先在一个隔离分支重新录制少量代表性场景,比较 diff 是否符合预期,再批量更新快照。不要把快照更新命令直接绑定到所有 CI 失败后的自动修复流程;否则真正的回归会被新的基线掩盖。

适合从哪里开始

如果团队还没有 Agent 测试体系,不必一开始就追求全量覆盖。选择一个工具调用明确、输入稳定、业务价值高的 Agent 路径,先建立一条 replay PR 检查和一条 live 夜间检查。前者保护代码与提示词改动,后者观察外部模型变化;两者共享场景名称,但失败处置流程不同。

AgentSnap 不能替代权限控制、工具 allowlist、集成测试或人工验收。不过它把原本难以复现的 Agent 行为压缩成可提交、可比较的轨迹,使“这次改动究竟改变了什么”成为能在 CI 中回答的问题。这正是从演示型 Agent 走向可维护系统的一块基础测试拼图。

更进一步,快照应被当作需要审查的测试资产,而不是自动生成的缓存。每次更新都要说明:改的是产品预期、提示词策略、工具契约,还是为了适配已确认的 provider 变化;同时保留旧轨迹的差异报告。对高风险工具,可把允许的调用集合、关键参数和确认步骤单独写成断言,让语义相近的回答也无法越过执行边界。这样,快照测试既能降低随机性,也能留下可追溯的变更理由。

相关链接

发表评论

你的邮箱地址不会被公开,带 * 的为必填项。