一个 Agent 的 system prompt 只改了一行,CI 全绿,PR 也顺利合并。第二天,它把退款期限从 14 天答成了 30 天。

这不是段子。OpenRouter 今天发布了一份把 LLM eval 接入 CI 的教程,拿的正是客服 Agent 的例子。普通测试没有发现问题很正常,它们测的是代码有没有报错,没人在构建阶段问模型一句,你刚才是不是把业务规则讲错了。

我一直觉得,Agent 工程里最危险的错觉,是把提示词当作文案,把模型切换当成配置。它们其实都在改程序行为,只是改动没有类型检查,也没有传统意义上的确定性输出。

如果你的团队已经在 Java 或 Spring 项目里给接口、数据库迁移、权限判断设了 CI 门禁,却允许 Agent 的 prompt、工具 schema 和检索参数随手进主干,这套工程纪律中间缺了一块。

这篇不聊泛泛的「怎么做评测」。我想把今天几篇官方教程拼成一条更实用的线,为什么 Agent 需要自己的回归测试,什么该拦 PR,什么不该拦,以及一个小团队如何从 20 个案例起步,而不是先造一座没人维护的评测平台。

先承认一个事实,Agent 不像普通函数

普通函数很舒服。输入固定,输出大概率固定。你改了一个 Java 方法,单元测试可以直接断言返回值,失败了就定位 diff。

Agent 不行。

同一个问题,两句都合理的回答可能长得完全不同。你把回答和一段 golden text 做 diff,只会得到一堆误报。更麻烦的是,真正会把线上带沟里的往往不是语言风格变了,而是行为变了。

比如一个售后 Agent 该不该查订单,什么时候必须转人工,已经有足够信息时是否不该调用工具,退款金额超过上限时能不能自行执行。这些都不是「文字像不像标准答案」的问题。

OpenRouter 对这件事的拆分很清楚。Agent 的变化至少有四类。

  • system prompt 改了一句,可能改变语气,也可能改变第一个调用的工具
  • 模型换代,或者使用了会自动指向最新版本的 alias,仓库一行没动,推理者已经换了
  • tool definition 改了字段、描述或 enum,模型读到的是另一份接口契约
  • RAG 的 chunk、召回数量、历史消息长度变了,关键规则可能被挤出上下文

最后一条特别容易漏。后端同学改过检索通常会盯召回率、延迟和 token 消耗,但 Agent 看到什么,决定了它下一步做什么。一个退款政策段落因为 chunk 变化没有进上下文,模型仍然会流畅地回答,只是开始凭记忆编规则。

它不会抛异常。

所以 Agent 的回归测试不该追逐每个字。它要锁住的是行为契约。

行为契约不是一句「回答要正确」

我建议把一个评测用例拆成三层,像给 Agent 写一份能执行的业务规格。

第一层是场景输入。它来自真实任务、线上事故或高风险边界,不是为了凑数量临时编的十个问答。客服里会有普通退款、缺少订单号、金额超权限、数据丢失和法律威胁。面向内部知识库的 Agent,则可能是权限不足、资料冲突、问题本该澄清而不是猜。

第二层是结构性断言。它描述 Agent 应该调什么工具,不该调什么工具,参数里必须保留什么值。对于订单 ord_7281,调用 lookup_order 却传成 ord_7282,JSON 再漂亮也没用。

第三层是硬不变量。它只留给绝对不能发生的事,比如金额超过授权上限仍自动退款,缺少权限仍返回内部文档,遇到人工介入规则却继续执行写操作。硬不变量不需要「平均通过率还不错」来安慰自己,它一旦被新版本单独打破,就应该挡住发布。

这里有个挺重要的取舍。不是每个用例都要上 LLM-as-a-judge。

能用代码断言的,就别让另一个模型来裁判。工具名、JSON Schema、必填字段、参数值、禁止调用、最终数据库状态,这些都该走 deterministic check。它便宜、稳定,还能直接说明错在哪里。

只有一个任务本来就允许多条合理路径时,才考虑 Judge。比如研究 Agent 在 web search 和内部资料库之间的选择取决于上下文,你很难写死唯一工具。这时 Judge 需要固定住 rubric 和裁判模型,并先拿人工复核的小样本验证。否则你会得到一个很会写评语、但标准天天变的裁判。

很多团队一上来就做「全量 LLM Judge」,我不太赞成。不是它没价值,而是把可验证的业务约束外包给概率系统,既贵又很难排错。

Tool 调对了,参数错了,仍然是事故

今天另一篇工具调用评测教程提醒了一个常被合并统计掩盖的事实,工具选择错和参数错不是同一种失败。

Agent 可能把「查退款规则」交给了 issue_refund,这是选择错误。它也可能选对了 lookup_order,却把订单号拼错了,这是参数错误。前者多半和路由、工具描述、上下文有关,后者可能来自抽取、格式、跨轮信息保持。

如果两个错误只报成一条「tool accuracy 下降」,修的人不知道该改 prompt、改 schema,还是回头查模型能力。

一套够用的检查顺序可以很朴素。

  • 先检查 tool selection,应该调用的有没有调用,不该调用的有没有乱调
  • 再用发送给模型的同一份 JSON Schema 验证返回参数,拦截非法 JSON、漏字段、类型错、无效 enum 和多余字段
  • 再检查关键值,schema 合法不等于业务正确
  • 多步流程才检查 trajectory,且只在调用顺序真有业务意义时要求严格一致
  • 如果多条路径可以得到同一个正确状态,就验结果状态或必要调用集合,不要把参考轨迹当圣旨

这块和 Spring 的接口校验很像。@Valid 能保证一个字段是字符串,不能保证这个字符串恰好是当前用户的订单号。Schema 是最低一层护栏,不是业务正确性的证明。

还有一个容易被遗忘的案例,no-tool case。Agent 已经有足够信息时,正确动作是不调用任何外部工具。只统计「有没有工具调用」会奖励乱调用,成本和风险都被悄悄放大。一个会主动查敏感系统的 Agent,比一个回答慢一点的 Agent 更值得担心。

用固定用例集,别用会移动的靶子

评测真正难的地方不在 CI YAML,而在你是否愿意把案例锁住。

OpenRouter 的建议是从 20 到 50 个简单任务开始,并优先从真实失败里补案例。这是一个很好的起点。20 个不是统计学上的神奇数字,但足够让团队看见行为面,而不至于在第一个月就被维护成本拖垮。

每次线上出现一个新失败,就补一个最小复现用例。久而久之,用例集会长成你们自己业务的失败史。它比公开 benchmark 更有用,因为它测的不是通用智力,而是用户真的会怎样把你们的 Agent 用坏。

锁住还有另一个原因。你一边改 prompt,一边重写测试问题,分数变好毫无解释力。是 Agent 变好了,还是题变容易了?没有人知道。

把 case、prompt、模型配置和评分规则放进同一个仓库。任何改动都走 PR,让 reviewer 同时看见「我修改了什么行为」和「我如何证明它还没坏」。对 eval set、prompt、脚本和 workflow 配置上 CODEOWNERS 也很有必要,不然最短的过门禁路径,就是把刚刚失败的测试删掉。

有一点我想额外强调。模型版本必须写 concrete slug,不要只写一个永远指向最新版的 family alias。

alias 对线上很方便,升级没有配置动作。但它会让你的 baseline 自动漂移。你今天以为是在复测 prompt,实际可能已经换了模型。至少在评测运行里,固定模型、provider 路由和推理参数,记录服务端实际返回的 model 字段。等你真要做模型切换,再只改变模型这一项做 A/B 对照。

不然结果没有归因价值。

CI 门禁的正确姿势,是只在该跑时跑

很多人听到 eval gate,第一反应是成本。这个担心合理。每个用例都是真实模型调用,多个 sample 再乘上 judge,账单会很快出现。

但解决方法不是把它塞进每一次 unit test,也不是完全靠人记得手工运行。

更稳的做法是把 eval 单独设成 CI job,只在真正可能影响 Agent 行为的文件改变时运行。典型路径包括 prompts、agents、tools schema、retrieval 配置、eval set、评测脚本和 workflow 本身。普通的 CSS 改动不该为它付钱。

这里藏着一个 GitHub Actions 的坑。很多人会用 workflow 级别的 path filter,路径不匹配时整个 workflow 被跳过。如果这个 workflow 是 required check,PR 上会留下 Pending,反而卡死无关改动。

官方教程给的解法更细。workflow 保持触发,先跑一个永远执行的 changes job 判断是否命中相关路径,再让 eval job 根据它的输出决定是否执行。GitHub 把被条件跳过的 job 视作通过,刚好适合让无关 PR 放行。

这套设计并非没有风险。glob 写错,eval 也会被绿色跳过。所以 changes job 本身应该成为 required check,并且第一次提交 prompt 改动时,真的去看 run log,确认 eval job 发生过。CI 配置和业务代码一样,不能只靠看起来合理。

门禁脚本的退出码也要区分三种状态。

  • 0,评测跑完且达到门槛
  • 1,评测跑完,Agent 行为没有达到门槛
  • 2,评测根本没跑起来,比如空用例集、密钥缺失或 provider 超时

把第三种伪装成通过,会让你的质量报表很好看,直到线上替你发现它从未真正执行。把第三种直接当成模型退化也不公平,排障方向会被带偏。

不要拍脑袋定 90 分

最常见的门槛是 0.9 或 0.95。它看起来专业,其实只是一个数字。

LLM 有波动,即便仓库没有任何改动。某些模型支持 temperature 和 seed,有些不支持。你如果假设所有候选模型都能用同一组解码参数,比较从一开始就不公平。

一个实用办法是,先在没有改动的基线分支连续跑多次,测出用例通过率的自然波动。若模型不能可靠固定采样参数,就对每个 case 跑奇数次,用多数投票得出该 case 的结论。然后再设一个超过波动带的阈值或下降幅度。

这样门禁拦的是可解释的退化,不是一次偶然采样。

我有时候觉得,分数本身不该是唯一信号。硬不变量的单次破坏直接失败。软质量分数下降到超过波动范围,进入人工 review。成本、耗时、升级率和 no-tool 误调用率也应该被记录。一个 95 分的 Agent 如果把每个问题都查三次外部系统,它未必比 92 分的版本更好。

评测要服务决策,不是服务排行榜。

一个适合 Spring 团队的最小落地版本

如果你现在维护的是 Java 服务加一个 LangGraph 或自研 Agent 层,我会建议按下面的节奏落地。

第一周,挑 20 个真实任务。别追求覆盖率报表,先覆盖高频路径、两个模糊输入、两个拒绝或澄清场景,以及三个绝不能越过的业务边界。把 case 和预期工具行为以 JSON 或 YAML 放进仓库。

第二周,写一层独立 runner。它不需要侵入生产服务,可以调用 staging Agent API 或直接构造 Agent 图。返回结果至少记录 trace id、实际模型版本、调用工具列表、参数、最终状态、耗时和 token 成本。敏感用户数据要脱敏,评测日志也属于数据资产。

第三周,补 deterministic evaluator。工具名、参数 JSON Schema、关键参数值和最终状态优先用代码判断。对确实需要语义判断的少数 case,再接 Judge,并把 judge prompt 当成受审查的代码。

第四周,再把 runner 放进 PR 的独立 job。初期只报告不阻断,连续跑一两周拿到波动、成本和误报数据。等硬不变量和门槛有把握,再把它设成 required check。

这个慢一点的过程值得。直接把一个还没校准过的概率评测设成合并门禁,很容易把开发者逼成绕过规则的专家。

反过来,只有 Dashboard 没有门禁,评测又会沦为周报里一条漂亮曲线。工程系统最终还是要在关键处做决策。

评测不是给 Agent 发成绩单

回到开头那行「14 天变 30 天」的 prompt 改动。

真正的问题不是模型偶尔会答错,任何复杂软件都会出错。问题是团队已经有能力在客户遇到前发现它,却还沿用「改一改,手工聊两句,感觉可以」的发布方式。

Agent 会漂移。模型会升级,工具会扩张,检索会重构,历史上下文也会变长。把这些当成普通配置,迟早会在一次看似无害的 PR 里交学费。

而一个固定、版本化、能在 CI 里真正失败的 eval set,做的事情很朴素。它让每次改动都重新回答那句工程问题,你改完之后,那个该转人工的请求还会转人工吗?那个不该调工具的请求还会安静吗?

能稳定答上来,才算把 Agent 从演示带进了服务。

参考资料