AI Agent 越改越崩?这个开源框架用确定性思维杀死了测试的随机性
你改了一行 prompt,跑了 50 条测试用例——48 条过,2 条挂。你心想应该是 prompt 改出问题了,于是把那行改回去,再跑一次。结果变成 45 条过,5 条挂。而且挂的那 5 条跟上一次还不完全一样。
这不是段子,这是几乎所有做 AI Agent 测试的人每天都在经历的噩梦。
问题出在哪?不是你的 Agent 不稳定,是你的测试框架本身就不稳定。那些用 LLM 打分的测试工具(所谓的 LLM-as-Judge),用 0 到 1 的浮点数来评估回复质量——同一个输入跑三次,可能给你 0.8、0.6、0.9。你连基线都建立不了,谈什么回归测试?
最近我发现了一个思路完全不同的开源项目:TrainForge Tester。它不做模糊打分,不搞语义相似度,而是用最朴素但最可靠的方式——确定性断言——来测试你的 AI Agent。
本文提纲
- 核心问题:为什么 LLM-as-Judge 不靠谱
- TrainForge 的解法:确定性优先
- Golden Injection:防止错误雪崩的关键设计
- 20 个标准检查项:把模糊变成二选一
- 回归对比:从「感觉变差了」到「精确知道哪里变了」
- 实战体验
为什么 LLM-as-Judge 不靠谱
先说清楚问题。主流的 AI Agent 测试方案长这样:
用户输入 → Agent 回复 → 另一个 LLM 打分(0~1)→ 得分低于阈值就报错看起来合理,但有一个致命缺陷:评委本身是随机的。
同样的 Agent 回复,GPT-4o 今天给你打 0.85,明天可能打 0.72。temperature 设成 0?没有完全消除随机性,只是降低了方差。更何况不同模型(Claude、Gemini、国产大模型)打出来的分数根本不可比。
这就导致了一个荒谬的局面:你分不清 Agent 是真的退步了,还是评委今天状态不好。
更糟糕的是,Agent 的结构化输出(比如工具调用)其实完全不需要 LLM 来评判。你的 Agent 应该调用 book_restaurant,结果调了 cancel_booking,这还需要 LLM 来判断对不对吗?直接字符串比较不就行了。
TrainForge 的核心洞察就在这里:能用确定性检查的地方,绝不碰 LLM。
确定性优先的设计哲学
TrainForge 把测试拆成了两条完全不同的路径:
路径一:结构化内容 → 纯 Python 断言,零 LLM 调用
工具名用字符串相等(==)比较。参数用类型检查 + 精确匹配。匹配顺序可配置(有序/无序)。不需要任何 AI 参与,一个 if 语句就能判断。
路径二:自然语言文本 → 默认也是精确匹配,只有你主动声明「可以发散」才走 LLM
这个设计很聪明。默认情况下(may_diverge: false),回复文本做精确匹配——如果你的 Agent 应该回复「好的,已为您预订」,那就必须一字不差。只有在场景文件里显式标记 may_diverge: true 的地方,才会调用 LLM 做语义检查。
而且即便是 LLM 检查,也不是打分——是 20 个固定的二元判断题。
// 场景定义示例
{
"turns": [
{
"user": "帮我订一个明天晚上7点的川菜馆",
"expected_response": "好的,已为您搜索附近的川菜馆...",
"may_diverge": true,
"expected_tool_calls": [
{
"name": "search_restaurant",
"arguments": {
"cuisine": "川菜",
"date": "明天",
"time": "19:00"
}
}
]
}
]
}上面的例子中,工具调用 search_restaurant 及其参数会被精确匹配。而回复文本允许发散(may_diverge: true),所以会走 LLM 做语义层面的 20 项检查。
Golden Injection:一个被严重低估的设计
这是 TrainForge 最让我眼前一亮的设计,也是我觉得最值得聊的点。
测试多轮对话时,你面临一个根本性的问题:Agent 在第 2 轮说错了,第 4、6、8 轮自然也会跟着错。这不是 Agent 在后续轮次退步了,而是前面的错误上下文污染了后续对话。传统测试框架会把这记录为一堆连锁失败,让你完全看不出真正的问题在哪。
TrainForge 的解法叫 Golden Injection(黄金注入):
第1轮:用户提问 → Agent 回复 A
↓ 测试 A 是否正确
↓ 不管 A 对不对,把「期望的正确回复」注入对话历史
第2轮:用户提问 → Agent 回复 B(基于正确的历史上下文)
↓ 测试 B 是否正确
↓ 同样注入正确回复
...以此类推每一轮测试都基于干净、正确的上下文。第 2 轮的失败不会传染给第 4 轮。你可以精确定位到「Agent 在第 3 轮处理价格查询时出了问题」,而不是面对一堆连锁错误无从下手。
这个设计我之前在别的地方没见过,但仔细一想就觉得——这不就是单元测试里 mock 依赖的思想吗?你在测函数 A 的时候不会真的去调函数 B,而是 mock B 的返回值。Golden Injection 做的就是这件事:把 Agent 前几轮的表现 mock 成理想状态,只测当前轮。
20 个标准检查:二元判断,拒绝打分
当文本确实需要语义检查时(may_diverge: true),TrainForge 不会让 LLM 打一个模糊的分数。它定义了 20 个固定的二元检查项,每个都有稳定的 ID:
| 检查 ID | 含义 |
|---|---|
| same_language | 语言是否一致 |
| same_intent | 意图是否一致 |
| no_added_facts | 有没有编造新事实 |
| no_omitted_facts | 有没有遗漏原有事实 |
| same_numerics | 数值是否一致 |
| same_persona | 人设是否一致 |
| comparable_register | 语体是否匹配 |
| comparable_tone | 语气是否匹配 |
| ... | (共 20 项) |
每个检查只有「通过」或「不通过」两个结果。LLM 返回的是一个极其精简的格式:
{"r": [1,0,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1], "f": {"3": "遗漏了价格信息", "7": "语气从正式变成了非正式"}}r 是 20 个二元结果的数组(1=通过,0=不通过),f 是失败项的解释。一个全部通过的回复只需要约 30 个 token 的 LLM 输出。相比传统方案动辄几百 token 的评分理由,成本和速度都优化了一个量级。
而且因为这些检查 ID 是稳定的,你可以跨版本对比:「上次检查 #3(no_added_facts)没挂,这次挂了」。这种精确的回归追踪,打分制做不到。
回归对比:diff 两个版本
TrainForge 提供了 trainforge diff 命令,可以对比两次测试运行的结果,把场景分成 7 个桶:
newly_passing— 上次挂,这次过newly_failing— 上次过,这次挂still_passing— 两次都过still_failing— 两次都挂consistency_changed— 一致性变化(多次运行稳定性)only_in_before/only_in_after— 新增或删除的场景
你可以跑 --runs 5 让每个场景执行 5 次,低于 80% 通过率的场景标记为 inconsistent——这就能区分「总是挂」(逻辑错误)和「时好时坏」(不稳定)。
# 跑两次,对比差异
trainforge run --scenarios tests.json --agent-url http://localhost:8080/chat --output before.json --runs 5
# ...修改 Agent...
trainforge run --scenarios tests.json --agent-url http://localhost:8080/chat --output after.json --runs 5
trainforge diff --before before.json --after after.json --output regression-report.html生成的 HTML 报告会详细展示每个场景的逐轮对比,哪轮挂了、哪个检查项失败、具体原因是什么。
几个我觉得特别靠谱的细节
Pydantic strict mode:所有 schema 用 extra="forforbid",场景文件里写错字段名直接报错,不会被默默忽略。这避免了「你以为测试在检查 A,其实因为拼写错误它什么都没检查」的尴尬。
MAX_TOOL_ROUNDS_PER_LOOP = 8:如果 Agent 进入无限工具调用循环,8 轮后强制停止。防御性编程的教科书。
Strict-retry:LLM 返回的 JSON 解析失败时,不是直接报错,而是用更严格的 prompt 重试一次。实测中大部分格式问题(比如 LLM 把 JSON 包在 markdown code fence 里)都能在重试时解决。
BYO-key:框架本身不碰你的 API key,你指向任何 OpenAI 兼容的 API 就行——OpenAI、Anthropic、vLLM、本地模型都可以。
它不适合什么
公平起见,说一下局限性。
它目前要求 Agent 暴露一个无状态的 HTTP POST 接口(接收消息历史,返回回复 + 工具调用)。如果你的 Agent 是有状态的(WebSocket、session-based),需要自己做一层适配。
它也没有可视化编辑器来编写测试场景——你得手写 JSON。对于非技术团队成员来说门槛不低。
最后,对于需要评估「回复质量好不好」(而不是「跟之前一不一致」)的场景,TrainForge 基本帮不上忙。它的设计目标是回归测试,不是质量评估。
如果你想试试
pip install -e ".[dev]"
# 用内置的 mock agent 先跑起来看看
trainforge mock-agent --scenarios scenarios/example_restaurant_booking.json --port 8080
# 另一个终端跑测试
trainforge run \
--scenarios scenarios/example_restaurant_booking.json \
--agent-url http://localhost:8080/chat \
--output results.json
# 生成 HTML 报告
trainforge report --results results.json --output report.html项目地址:github.com/TrainForge/TrainForgeTester
Apache 2.0 协议,Python ≥ 3.10,代码量不算大但设计得很扎实。如果你在做 AI Agent 开发并且被测试稳定性折磨过,值得花一个小时看看。
——来自公众号:人生几十年噢耶