本文基于 Spring AI 2.0.1 / LangChain4j 1.19.0(截至 2026-09-02)编写。Spring AI 评估 API 核对自官方文档,LangChain4j 测试文档现状核对自官方教程页;OpenAI Evals 平台关停时间线为多方公开报道交叉一致的口径(官方页面需登录访问,请以 OpenAI 平台公告为准)。该领域迭代极快,请以官方文档为准。
开篇:一个测试平台的倒闭事件
2026 年 6 月 3 日,OpenAI 一次性宣布弃用三项开发者平台服务:Evals 评测平台、Agent Builder、Reusable Prompts。时间线干脆利落——存量评测 10 月 31 日转只读,11 月 30 日平台整体关停。
对依赖它跑回归的团队,这不是产品调整,是搬迁通知。但对更多还在犹豫"评测要不要建"的团队,这个事件反而回答了一个悬而未决的争论:评测到底该托付给平台,还是长在自己的工程里? OpenAI 用行动投了票——托管评测没有成为一门持续的生意。
Agent 上不了线的团队,缺的从来不是模型能力,是一套测得出好坏的 Evals。评测不是平台的附赠品,是你自己的工程资产——连 OpenAI 都示范了平台靠不住。
上一篇《模型月抛时代》立了纪律:评测跑在 CI 里,分数低于基线,合并就被拦下。这篇是完整图纸——用你写单测的同一套肌肉(JUnit、Mockito、Maven、CI),给 Agent 搭一套三层评测体系。Java 团队不需要学新框架,需要的是把"测试对象"从方法换成 Agent。
先看全图。
一、Agent 为什么"测不住"
三个难点,每个 Java 老哥都似曾相识:
- 输出非确定性——同一个输入两次运行的回答不一样,
assertEquals对不了字符串; - 行为是多步链路——正确性 = 轨迹正确(调了该调的工具、参数对、顺序合理)+ 结果正确,只看最终文本会漏掉一半错误;
- 依赖本身不稳定——模型 API 就是那个 flaky 的外部服务,今天过明天挂。
但请注意:这三条没有一条是测试文化二十年的 Java 社区没见过的。非确定性输出,见过(时间、随机数、IP 地址);多步链路,见过(跨服务调用的契约测试);不稳定依赖,见过(Testcontainers 出现之前的数据库测试)。
测试金字塔的形状没变,变的是断言对象。 这就是本文标题的后半句——像写单测一样写 Evals,不是修辞,是工程事实。
二、评测金字塔:三层,各跑各的节奏
分层原则一句话:成本决定节奏。这个分级思想来自 Hamel Husain 的《Your AI Product Needs Evals》——有趣的是,LangChain4j 官方测试文档至今还是一页"即将推出"的占位页,全文唯一推荐的方法论文章就是它(生态现状后面细说)。越往下越便宜越快、每次变更都跑;越往上越贵越慢、定期跑。
| 层 | 测什么 | 依赖 | 量级 | 时机 |
|---|---|---|---|---|
| L1 工具与编排单测 | 编排逻辑、工具注册、调用轨迹 | Mockito(不碰真模型) | 毫秒 | 每次提交 |
| L2 链路级回归 | golden dataset 上的确定性断言 | 真模型 + 固定 case | 分钟 | PR + 模型变更 |
| L3 任务级评测 | 语义质量评分 | LLM-as-judge + 人工抽检 | 小时 | 每日 / 发布前 |
关键认知:三层不是三个阶段,是三道同时存在的闸门。只做 L3 的团队会烧钱烧到不敢跑;只做 L1 的团队测了个寂寞——模型一换就现形。
三、L1 与 L2:你的 JUnit 肌肉直接复用
L1:编排逻辑单测,Mockito 照 mock 不误
Agent 的骨架是你自己的 Java 代码:工具就是加了 @Tool 注解的普通 Spring Bean 方法(上一篇验证过的口径),编排是你拼的 ChatClient 调用链。既然是普通 Java 代码,就按普通 Java 代码测:
- mock
ChatModel,让它返回"模型决定调用工具"的预设响应,断言你的编排把工具结果正确地喂回了下一轮; - spy 工具 Bean,断言轨迹——调了哪个工具、参数对不对、顺序合不合理。
@ExtendWith(MockitoExtension.class)
class OrderAgentTrajectoryTest {
@Spy
OrderTools orderTools = new OrderTools(stubOrderService); // @Tool 方法 = 普通方法
@Test
void 查订单必须先查状态再给结论() {
// given:编排循环由 StubChatModel 驱动(首轮回"调用工具",次轮回"总结")
OrderAgent agent = agentWith(new StubChatModel(
toolCall("getOrderStatus", "SO-1024"),
finalAnswer("订单已发货")));
// when
agent.chat("帮我查下 SO-1024 发没发货");
// then:断言轨迹,而不是断言最终文本
verify(orderTools).getOrderStatus("SO-1024");
verifyNoMoreInteractions(orderTools); // 多调一次工具 = 轨迹退化
}
}
一句话结论:L1 把"Agent 测试"还原成"方法调用序列断言",这是 Java 团队闭着眼睛都会写的东西——StubChatModel 是你手写的测试夹具,返回预设决策即可(ChatModel 是接口,怎么 stub 取决于框架版本,以官方文档为准)。
L2:链路级回归,golden dataset 上真模型
L1 不碰真模型,L2 必须碰——这层回答的问题是"真模型 + 你的编排,在固定 case 上的行为是否稳定"。回归集配方上一篇给过(20~50 个 P0 场景 + 历史 badcase,脱敏后从线上 trace 抽取),这里补两件事。
冷启动:没有线上流量时,用 LLM 合成 case。Hamel 的判据很有味道——测试写得好的信号,是模型难以通过它们:合成出来的 case 如果模型全通过,说明没写到痛处;通不过的地方,就是你要修的地方(改提示词、补工具、或者把它标记为已知失败)。
进 CI 的组织方式:用 JUnit 5 的 @Tag 把三层分开,Maven 侧 surefire 只跑 unit,failsafe 跑 llm-eval:
@TestFactory
@Tag("llm-eval") // CI 里与 L1 分开调度
Collection<DynamicTest> orderAgentRegression() {
return EvalCases.load("evals/order-agent.yaml").stream()
.map(c -> DynamicTest.dynamicTest(c.id(), () -> {
String output = agent.chat(c.input());
assertTrue(c.mustContain().stream().allMatch(output::contains),
"场景[%s] 关键词缺失".formatted(c.scenario()));
})).toList();
}
// 跑完输出通过率:82.4%(基线 84.0%,-1.6pp,未越 -2pp 阈值,门禁放行)
一句话结论:L2 的本质是"确定性断言 + 相对基线"——断言只写客观可判定的东西(关键词、格式、工具调用序列、JSON 可解析),语义质量留给 L3。
四、L3:LLM-as-judge,Spring AI 内置了两个裁判
确定性断言覆盖不了的语义质量("这个回答好不好"),交给 LLM-as-judge:用一个模型给另一个模型的输出打分。Spring AI 2.0.1 的 evaluation 包里有现成的:
@Test
@Tag("llm-judge")
void 订单回答必须与检索上下文相关() {
// 生产链路:真模型 + RAG
ChatResponse response = chatClient.prompt(question)
.advisors(ragAdvisor).call().chatResponse();
EvaluationRequest request = new EvaluationRequest(
question, // 用户问题
response.getMetadata().get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT), // 检索上下文
response.getResult().getOutput().getText()); // 待评回答
// 裁判模型独立配置——可以(也应该)与生产模型不同
RelevancyEvaluator judge = new RelevancyEvaluator(ChatClient.builder(judgeModel));
assertThat(judge.evaluate(request).isPass()).isTrue();
}
一句话结论:Spring AI 把 LLM-as-judge 做成了一个 Evaluator 函数式接口 + 两个开箱裁判——RelevancyEvaluator 判"回答与上下文相关性",FactCheckingEvaluator 判"回答是否被上下文事实支持"(查幻觉),官方自己用它测 QuestionAnswerAdvisor。
四个坑,每个都有人替你踩过:
- 裁判要和生产模型分家。 模型给自己当裁判,分数系统性虚高;且裁判要用你负担得起的最强模型——批评一个输出需要的推理能力,往往高于生成它。
- 成本失控。 全量 case 上 judge 等于每次回归付双倍 token 账单。纪律:确定性断言先行,任何 case 先走 L2 判定,judge 只兜语义层;Spring AI 官方还建议事实核查类裁判挂小型专用模型(如 Bespoke-MinicCheck)降成本。
- 分数漂移。 裁判提示词改一版,历史分数就全不可比了——我们内部的做法是把裁判提示词当代码管:进 git、有版本号、每次改版重跑基线。裁判本身也需要一个微型评估系统来追踪。
- 全自动神话。 "正确性"常常是主观的,judge 分数必须定期与人工对齐:每批抽 25~50 条,人给自己的版本,追相关性。特别注意:只有失败案例约占一半时"原始一致率"才有意义,类别不平衡时要分开测精确率和召回率。
五、生态现状:别等框架,Java 的答案是自建
盘一下两家的现状(截至 2026-09-02,均已核对官方文档):
- Spring AI 2.0.1:有
Evaluator接口和两个内置裁判,但主打 RAG 场景的相关性/事实性——姿势示范到位,裁判只给了两个; - LangChain4j 1.19.0:官方 Testing and Evaluation 文档至今是"More information Coming Soon"占位页,全文只有一个 CustomerSupportAgentIT 集成测试示例加一串推荐阅读。
Python 生态里 promptfoo、DeepEval 那种开箱即用的评测框架,Java 目前没有对等物。但这恰恰不是坏消息:评测的核心资产从来不是框架,是 case 集和裁判提示词。一个 JUnit 5 扩展 + 一个 YAML case 文件 + 两个 Spring AI 裁判,两个下午的自建量——而"自建测试基建"这件事,是 Java 团队二十年的主场。
六、CI 门禁:拦"相对退化",不拦"绝对分数"
三层各挂各的钩子:
| 层 | 触发 | 失败动作 |
|---|---|---|
| L1 | 每次提交 | 直接 block(它是确定性单测,没有借口) |
| L2 | PR + 模型配置变更 | 通过率低于基线阈值(如 -2pp)则 block |
| L3 | 每日定时 + 发布前 | 评分报告贴进 PR,低于基线则 block |
阈值哲学是本节的干货:门禁不该拦"分数不好",该拦"比基线差了"。理由很实际:L2/L3 天生有噪声(模型偶尔不听话是常态),拿绝对分数做门禁,团队会被逼成"为过门禁改 case";拿相对退化做门禁,case 集只会越长越像你的真实业务。Hamel 的实践佐证了这一点:传统测试要 100% 通过率,AI 测试的通过率是产品决策——你决定容忍哪些失败,而不是消灭它们。
最后一个自举场景:用 [spring-ai-agent-utils 搭一个编码 Agent,](juejin.cn/post/767534… Agent 时代才有的测试形态。
判断:谁现在就该建
Agent 要上生产的团队,现在就建,两周足够:第一周 L1 + L2 骨架进 CI(先 10 个 case,跑起来再长);第二周 L3 裁判上线、锁定基线。还在 demo 阶段的团队,先把 L1 的轨迹断言写了——它不花钱,且 demo 转 生产时直接复用。已经在用平台托管评测的团队,OpenAI 的搬迁通知就是你的 deadline。
一个收尾判断:评测集是随 badcase 复利的数据资产——提示词会被下一版模型淘汰,回归集不会。平台会关停(OpenAI 刚示范过),模型会月抛(8 月刚示范过),你的 case 集谁也拿不走。
测不住的 Agent 上不了线;测得住了,模型月抛反而是你的红利期。
参考资料
- Spring AI 官方文档:Evaluation Testing(2.0.1,Evaluator / RelevancyEvaluator / FactCheckingEvaluator)
- LangChain4j 官方文档:Testing and Evaluation(占位页现状与 CustomerSupportAgentIT 示例)
- Hamel Husain,《Your AI Product Needs Evals》——评测分级体系与 LLM-as-judge 对齐方法
- OpenAI 平台服务弃用公告(2026-06-03;关停时间线为多方公开报道交叉口径)