Back to Blog

AI Agent 越改越崩?这个开源框架用确定性思维杀死了测试的随机性

2026-05-06
AI Agent 测试框架 开源项目 TrainForge

你改了一行 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。

本文提纲

  1. 核心问题:为什么 LLM-as-Judge 不靠谱
  2. TrainForge 的解法:确定性优先
  3. Golden Injection:防止错误雪崩的关键设计
  4. 20 个标准检查项:把模糊变成二选一
  5. 回归对比:从「感觉变差了」到「精确知道哪里变了」
  6. 实战体验

为什么 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 开发并且被测试稳定性折磨过,值得花一个小时看看。


——来自公众号:人生几十年噢耶

Enjoyed this article? Share it with others!