测不住的 Agent 上不了线:像写单测一样给 Agent 写 Evals

37 阅读10分钟

在这里插入图片描述

本文基于 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 老哥都似曾相识:

  1. 输出非确定性——同一个输入两次运行的回答不一样,assertEquals 对不了字符串;
  2. 行为是多步链路——正确性 = 轨迹正确(调了该调的工具、参数对、顺序合理)+ 结果正确,只看最终文本会漏掉一半错误;
  3. 依赖本身不稳定——模型 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。

四个坑,每个都有人替你踩过:

  1. 裁判要和生产模型分家。 模型给自己当裁判,分数系统性虚高;且裁判要用你负担得起的最强模型——批评一个输出需要的推理能力,往往高于生成它。
  2. 成本失控。 全量 case 上 judge 等于每次回归付双倍 token 账单。纪律:确定性断言先行,任何 case 先走 L2 判定,judge 只兜语义层;Spring AI 官方还建议事实核查类裁判挂小型专用模型(如 Bespoke-MinicCheck)降成本。
  3. 分数漂移。 裁判提示词改一版,历史分数就全不可比了——我们内部的做法是把裁判提示词当代码管:进 git、有版本号、每次改版重跑基线。裁判本身也需要一个微型评估系统来追踪。
  4. 全自动神话。 "正确性"常常是主观的,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(它是确定性单测,没有借口)
L2PR + 模型配置变更通过率低于基线阈值(如 -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 上不了线;测得住了,模型月抛反而是你的红利期。

参考资料