在过去大半年里,我自己写过 Agent 玩具,读过不少开源框架源码(比如 pi、Claude Code、LangGraph),也和做工程的朋友交流时经常聊到一个核心话题:
“大模型的输出明明每次都随机、不确定,那针对 Agent 项目,到底该怎么做自动化测试?”
最开始折腾 Agent 时,我的第一反应和大多数刚入门的同学一样:弄几十个 Prompt,写个脚本批量调接口,然后拿另一个大模型(LLM-as-a-Judge)打个分,算一下胜率(Win-rate)或者准确率,分高就觉得“这次优化成功了”。
但随着项目从一个简单的 Demo 往具有多轮工具调用、状态暂存、中断恢复的复杂系统演进,这种做法立刻就行不通了:
- 每次改两行代码想验证一下,跑一次脚本要等几分钟,一不小心几十块钱 API 额度就没了;
- 脚本跑出来这次 82 分,下次 79 分,根本不知道是因为模型天然的随机波动,还是刚写的代码里真的引入了 Bug;
- 最要命的是,哪怕平均分是 90 分,代码里一个工具调用的参数解析越界了,一运行直接整机抛异常崩溃,评分再高有什么用?
痛苦反思之后我意识到:我们不能把学术圈做科研、打榜评测的思维,硬套在软件工程的质量防线上。
如果我们想把 Agent 当成一个严肃的软件系统去构建,就必须站在“宏观系统设计者”的角度去重新拆解:什么是测试(Testing)?什么是评估(Evaluation)?我们究竟该怎么从零设计一套靠谱的测试体系?
💡 观念破局:别再把“评估”当成“测试”了
要想理清测试体系,第一步必须在认知上把这两件事彻底割裂开来。
在很多讨论里,大家会把“写 Evals”和“写 Tests”混在一起说。但从系统设计的视角来看,它们解决的完全是两类不同性质的问题:
| 维度 | 工程测试(Testing) | 体系评估(Evaluation / Evals) |
|---|---|---|
| 回答的问题 | 系统有没有按契约坏掉?(Bug / Crash) | 模型这波回答得漂不漂亮?(Quality) |
| 判定结果 | 确定性的二元结果:Pass / Fail(1 或 0) | 统计学分布:评分、胜率、区间命中率 |
| 执行代价 | 几乎为零:毫秒级本地单测,离线无外部依赖 | 昂贵:成百上千次 API 调用,耗时数十分钟 |
| 拦截意义 | 阻断门禁:测试不通过,代码严禁合并上线 | 演进罗盘:指导后续怎么微调或改 Prompt |
举个通俗的例子:
- 测试就像是汽车出厂前的安全质检:刹车踩下去有没有制动力?电门踩死会不会短路?转向机会不会卡死?有一项不过,这辆车就绝对不准出厂。
- 评估就像是在测试这辆车在不同路况下开起来舒不舒服、油耗是不是百公里 6 升。
如果连底盘螺栓有没有拧紧(代码与契约测试)都没确认,天天测百公里加速度和内饰质感(跑 Eval 打分),这就是本末倒置。
🏛️ 从零推导:Agent 系统的四层测试架构
既然模型输出是不确定的,那到底该怎么测?
经过阅读大量优秀开源实现并亲手推导后,我总结出一个心智模型:“把非确定性的模型剥离出去,把确定性的逻辑死死锁在测试网内。”
一个鲁棒的 Agent 系统,测试应该自底向上分成四层:
┌────────────────────────────────────────────────────────┐│ Layer 4: 影子运行与只读金丝雀(线上真实环境校验) │├────────────────────────────────────────────────────────┤│ Layer 3: 录制回放与离线沙盒集成测试(VCR / Snapshot) │├────────────────────────────────────────────────────────┤│ Layer 2: 运行时循环与状态机契约测试(Mock Provider) │├────────────────────────────────────────────────────────┤│ Layer 1: 工具与边界防线的纯函数单测(Schema & Utils) │└────────────────────────────────────────────────────────┘Layer 1:工具与边界防御的纯函数单测(最轻、最快、最硬)
在 Agent 架构里,工具(Tool Call)是 Agent 唯一能改变真实外部世界的“手”。
这一层完全不需要联网,更不需要大模型介入,全部是纯函数级的逻辑测试:
- Schema 容错与反序列化:当模型漏传必要参数,或者把数字当成字符串传过来时,我们的 Pydantic / Zod 校验能不能安全拦截,并返回清晰易懂的错误说明?
- 反越权与参数清洗:如果让 Agent 读文件,传入路径是
../../etc/passwd,防御层是否能在执行前把它拦下来? - 幂等键生成:相同业务上下文下多次计算出的业务唯一键是否稳定?
这一层测试跑起来只需几毫秒,是保护系统的第一道绝对防护网。
Layer 2:运行时循环与状态机契约测试(核心主战场)
在之前关于 腾讯面试状态设计复盘 的文章中,我详细拆解过 Agent 的三层状态模型。
代码最容易出 Bug 的地方,恰恰是 Agent 循环(Agent Loop)和状态流转。这一层我们要测试的是代码的骨架,做法是编写一个可编程的 Mock Model(模拟模型)。
一个极为优雅的现实工业范例,就是我在精读 pi 的源码 时发现的 fauxProvider。pi-ai 在设计最底层时,没有把测试留给真实网络,而是内置了一个高拟真的假模型供应器:
- 你可以通过
faux.setResponses([...])给它预设一套确定性的「剧本」——第 1 轮返回思考(fauxThinking)和工具调用(fauxToolCall),第 2 轮拿到工具结果后返回总结(fauxText); - 它甚至支持配置
tokensPerSecond,把 Token 拆成半个词半个词(chunk)往外吐,真实模拟流式推送; - 剧本队列掏空后,自动派发明确的
No more faux responses queued错误,而不是引发未捕获异常。
借由类似 fauxProvider 这样的剧本驱动器,我们在本地单测里就可以把运行时的各种极端边界测个底朝天:
- 截断防御:模拟模型吐出半截被截断的 JSON(
{"order_id":),校验循环层是否会崩溃,能否正确拦截并提示参数非法; - 死循环熔断:给模型配置无限次循环调用同一无效工具的剧本,测试执行预算(
max_turns/ Token 限制)能否强行熔断并把状态置为failed; - 并发中断与取消:当底层正在模拟执行耗时工具时,通过外部注入
AbortSignal,验证状态机能否平滑切入cancelled,并丢弃滞后的工具返回值; - 工具异常写回:模拟工具在特定轮次抛出 500 异常,验证循环层能否将其格式化为标准的
tool_error喂回上下文供下一步推理,而不是导致整个 Node 进程崩掉。
这一步能够把绝大多数“服务假死”、“死循环刷爆账单”、“状态机逻辑互锁”的问题在本地彻底消灭。
Layer 3:录制与回放的沙盒集成测试(Deterministic Replay)
很多时候我们确实需要测试完整的端到端链路:比如“用户提问 规划任务 调用只读工具查库 结合数据综合回答”。
如果每次跑集成测试都去打真实的 OpenAI 或 Anthropic API,会立刻面临三个工程死穴:
- Flaky Tests(测试抖动):公网网络丢包、供应商偶发 503 限流,导致原本正确的代码在 CI 上随机标红;
- 极慢与极贵:一个稍微复杂的多轮 Agent 跑完要几十秒,积累上百个用例后,跑一次 CI 要半小时、刷掉几十刀额度,开发最后只能跳过测试直接合并;
- 输出微小漂移导致断言失效:模型换个修辞、多吐两个无关标点,基于文本严格匹配的集成断言就会全线崩溃。
解决这个问题的经典工业方案,是借鉴网络测试中的 VCR 录制与回放机制(Deterministic Replay)。
1. 录制期(Record Phase):捕获 Golden Snapshot
在受控环境下,输入一个经典的端到端 Prompt(比如“查询订单 1024 并给出履约状态”),让 Agent 与真实的 LLM 完成交互。
在这个过程中,底层网络适配器把所有出入报文抓取下来,序列化存为一份快照文件(Cassette):
- 包含用户输入的原始消息与系统 Prompt;
- 包含大模型返回的全部流式事件序列(TTFT、Token 流、Tool Call 报文、Stop Reason);
- 包含工具执行的 Mock 响应与消耗的时间戳。
2. 回放期(Replay Phase):离线环境下的确定性穿越
在日常提交代码、本地运行 pnpm test 或 CI 触发时,系统彻底切断外网连接,网络请求被重定向到本地的快照文件:
- 本地运行时就像真实面对一个毫秒级响应、完全免费且 100% 确定的大模型;
- 断言重心从“文字像不像”转移到“协议与副作用对不对”:
- 断言输入消息拼装出的 Context 结构是否符合新版约定;
- 断言解析器派发出的状态事件流(
startedtool_calledcompleted)顺序是否严格单调递增; - 断言工具接收到的实际参数对象(深度比对)是否符合预期。
3. 快照更新策略(Snapshot Maintenance)
代码重构或修复了某个状态流转 Bug 时,原有的回放应当直接绿灯通过;只有当我们主动升级了基座模型(如从 Claude 3.5 升级到 3.7)、大幅重构了系统 Prompt,或者重构了工具接口协议时,才由专人运行带有 --record 标志的命令重新录制一份基线快照。
Layer 4:影子运行与只读金丝雀(生产环境的最后屏障)
大模型的概率特性决定了:就算你在本地用 fauxProvider 测满了所有状态机边、用 VCR 回放验证了协议无损,真实用户千奇百怪的提问依然可能触发意想不到的长尾幻觉。
面对这种不可避免的“非确定性”,最高级的测试手段不是在开发机上瞎猜,而是借助线上影子流量(Shadow Traffic)与金丝雀(Canary)进行受控对抗。
1. 只读沙箱(Read-Only Guardrail / Dry-run Proxy)
Agent 最大的风险在于“副作用”。查数据的只读工具哪怕查错了,危害也远小于一个错误的转账或发帖工具。
因此在影子测试中,所有注册工具必须实现双重实现或被代理包裹:
- Read-Only 工具(查询订单、读文档、搜索):完全真实执行,确保新版 Agent 能够获取最新的真实生产数据;
- Mutating / Side-Effect 工具(创建订单、扣款、调用 webhook、写库):必须被注入只读拦截屏障(Dry-run Wrap)。当 Agent 企图调用
refund(order_id, amount)时,拦截器只把入参、调用顺序、预估影响写入影子审计日志,并直接向 Agent 返回一个假定的成功返回报文(模拟环境),绝不真正向生产数据库或外部支付网关发出写请求。
2. 流量镜像与决策比对(Diff Analysis)
把线上真实进入网关的用户请求,通过异步队列(如 Kafka / Redis Stream)旁路分发出一份镜像流量,推给处于暗室(Shadow Mode)的新版 Agent:
- 执行过程 Diff:对比线上主版本与影子版本在处理同一真实请求时,调用的工具序列是否一致?影子版本有没有出现多余的、未预期的工具调用?
- 资源与延迟监控:影子版本的 Token 消耗是否暴增?推理轮次(Turns)有没有异常飙升?
- 输出语义审查:通过自动化规则或低成本打分模型,监控影子版本是否输出敏感词、越权指令或明显的格式溃缩。
通过影子运行,系统可以在数千次真实线上流量的锤炼下暴露意料之外的长尾问题,同时完全不污染生产数据、不影响用户主流程。只有在影子流量中观察了数天、各项指标收敛稳定后,新版本才能正式切换放量。
🧭 宏观设计者的自我审视:如何衡量体系是否健康?
站在全局规划的角度,设计完这套体系后,我经常会用一套标准来反推自己的项目是否健康:
- 断网运行能力:如果一个项目的核心单元测试必须挂梯子、填 API Key 才能跑,说明代码里的外部依赖耦合极其严重,架构尚未解耦。
- 看状态机边覆盖率,而不是传统代码行覆盖率:在 Agent 里,灾难往往发生在异常跳转上(比如挂起超期、并发取消)。每一个状态流转路径是否有单测用例覆盖,比代码写了多少行关键得多。
- 写副作用必须具备物理沙箱:无论单测还是集成测试,带有破坏性的工具在未显式 Mock 时,调用必须直接触发断言中断,绝不能依赖“模型应该不会乱调”的侥幸心理。
- 测试耗时与成本的解耦:当项目从 1 个工具扩充到 30 个工具时,测试套件的执行时间应该依然保持在几十秒内,账单不随代码量的膨胀而飙升。
总结:我的几点思考
在学习和构建 Agent 的过程中,我越来越深地体会到:越是底层充满随机性的系统,其外部工程就越需要极致的确定性。
大模型给了我们一种全新的“概率计算单元”,但这绝不意味着软件工程的基本法失效了。相反:
- 评估(Evals) 是我们在未知海域航行时的罗盘,告诉我们船在往哪个大致方向走、速度快不快;
- 测试(Testing) 则是船舱底部的龙骨与防水隔舱,确保无论风浪怎么颠簸,船身绝对不会散架,发动机绝对不会熄火。
跳出单纯的“调参”和“打分”视角,用严谨的工程思维去构建确定性的沙盒、契约和状态防护网——这或许正是我们这一代年轻开发者,在 AI 浪潮中最该补上、也最硬核的一门系统设计必修课。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时