evalgate 把 AI 模型输出质量当成 build artifact 追踪,通过与 main 分支的 baseline diff 检测质量回归,自动破坏不达标的构建。
prompt 会悄无声息地退化。我换了一个模型、调整了 system prompt、添加了一个工具,一切仍然可以正常运行。没有抛出异常,没有测试变红,JSON 依旧能够解析。只是输出质量在不知不觉中变差了,而且通常是用户告诉我之后,我才发现问题,而不是从 CI 中得知。单元测试并不适合处理这种问题,因为根本没有异常可供捕获:这里的失败模式不是程序崩溃,而是质量下降。
于是,我构建了 evalgate。它是一个小型 TypeScript 工具,把 prompt 和 Agent 的质量视为构建产物。你可以编写一套声明式 eval 测试套件,由 evalgate 负责运行、评分并保存基线。之后,每次发起 pull request 时,它都会重新运行这套测试,计算当前分支相对于基础分支的质量变化,并在分数退化时让构建失败。随后,它还会把变化对比表发布为 PR 评论。
最重要的设计决策,是明确 CI 可以提出什么问题。“这个 prompt 好不好?”是一个主观问题,不可能通过自动化门禁得到令人信服的答案。“它是否比 main 分支上的版本更差?”则是一个客观且可以回答的问题。evalgate 正是围绕第二个问题构建的。你只需捕获一次基线,从那以后,每一次变更都会根据它相对于基线的变化量进行评判,而不是对照某种绝对的“优秀”标准。
第二个设计决策是,整套工具必须能够在零 API key 的情况下运行。evalgate 内置了一个确定性的 mock provider,因此你可以完全离线地运行测试套件、保存基线、比较不同运行结果,并执行完整的测试套件。项目本身包含 67 个测试,其中没有任何一个会访问网络。每项功能都必须先在 mock 模式下正常工作,才算真正完成。
测试套件是一个 YAML(或 JSON)文件,与它所检查的代码一起纳入版本控制。每个测试用例都包含输入、预期参考值,以及一个或多个评分器。下面是一个最小示例:
name: my-agent
provider: mock # works with no API key
threshold: 0.9 # mean score required to pass
cases:
- id: greeting
input:
prompt: |
Reply with the standard greeting.
exactly: Hi there! How can I help you today?
expected: "Hi there! How can I help you today?"
scorers:
- type: exact-match
- type: latency
budgetMs: 500
只有当一个测试用例的所有评分器都通过时,该用例才算通过;它的数值分数则是各个评分器分数的加权平均值。评分器目录中共有 10 种评分器,覆盖了你真正需要对模型输出进行断言的各种场景:
exact-match、regex、contains 和 not-contains 用于字符串级别的检查,其中 contains 在匹配多个子字符串时会给予部分分数。
json-schema 用于检查结构化输出,让你能够断言模型返回的是符合指定 schema 的有效 JSON。
embedding-similarity 通过余弦相似度判断语义是否“足够接近”。
llm-judge 和 rubric 用于更柔性、基于评判标准的判断。
latency 和 cost 用于设置预算门禁,因此,如果某项变更让 Agent 变慢或成本变高,同样可以触发门禁失败。
其中有两种评分器支持插件化,并内置了确定性的离线 fallback,这让 mock-first 原则不只是说说而已。如果 provider 提供了 embed(),embedding-similarity 就会使用它;否则,它会 fallback 到一种稳定的、本地运行的“哈希词袋”嵌入算法。llm-judge 会调用真实 provider,并解析其返回的 {score, reason} JSON;但使用 mock provider 时,它会改为计算可复现的词语重合分数。因此,即使测试套件使用了 judge 和 embedding,也仍然可以在没有任何 API key 的情况下,在每台机器上以完全一致的方式运行。
整个工作流只需要三个命令:先运行测试套件,再保存一份基线,之后将后续运行结果与基线进行比较:
npx @royalpinto007/evalgate run suite.eval.yaml
npx @royalpinto007/evalgate baseline suite.eval.yaml --out baseline.json
npx @royalpinto007/evalgate compare suite.eval.yaml --base baseline.json --tolerance 0.01
当任何测试用例的退化程度超出 tolerance 时,compare 都会以非零状态码退出,而这个非零状态码会让 CI job 失败。tolerance 非常重要,因为模型输出并非完全稳定;通常需要允许一定程度的轻微波动,超出之后才应被认定为真正的退化。
真正让它像 CI、而不只是一个脚本的部分,是 GitHub Action。每次发起 pull request 时,它都会重新运行测试套件,并 upsert 一条评论:它会直接编辑自己之前发布的评论,而不是每次 push 都叠加一条新评论。退化结果会呈现为下面这样:
### evalgate: support-agent
FAIL - Quality regressed. 5 case(s) got worse.
Overall score: 94.2% (base) -> 60.1% (head) = -34.2pp
| Case | Base | Head | Delta | Change |
| ------------------- | ------ | ----- | -------- | ------ |
| refund-intent-json | 100.0% | 0.0% | -100.0pp | down |
| order-id-format | 100.0% | 0.0% | -100.0pp | down |
| greeting-exact | 100.0% | 66.7% | -33.3pp | down |
| judge-helpfulness | 73.6% | 69.1% | -4.5pp | down |
| paraphrase-quality | 86.0% | 84.6% | -1.3pp | down |
在代码合并之前,你就能直接在评审中看到以百分点表示的整体变化,以及精确展示哪些测试用例变差了的逐项明细。底层使用的同一套逻辑也以 library 的形式开放,因此,如果你想把 evalgate 接入 Action 之外的其他系统,可以直接导入 loadSuite、runSuite、compareRuns 和 renderCompareMarkdown。评分器和 provider 也都支持注册,因此你可以添加自己的实现。
evalgate 会告诉你分数发生了变化,但不会告诉你新分数是否正确。如果你的 prompt 确实变得更好了,而参考预期已经过时,evalgate 仍然会标记这个变化,此时需要由你来更新基线。这个门禁是变更检测器,而不是神谕。更柔性的评分器同样如此:llm-judge 和 embedding-similarity 的可信程度,取决于 judge 模型以及你编写的评判标准。如果评判标准本身很薄弱,那么一套全绿的测试只会带来虚假的安全感。对于任何需要严格验证的内容,我更依赖 exact、regex 和 schema 评分器;而模型驱动的分数,我会将其视为方向性信号,而不是真实答案。
我想实现的目标很简单:让 prompt 质量也能像类型错误或测试失败一样,成为导致 pull request 失败的原因。evalgate 通过一套声明式测试、一个由基线与变化量组成的引擎、10 种评分器,以及一个可以直接在 PR 中评论退化结果的 GitHub Action,实现了这一点。借助确定性的 mock provider,所有功能都可以完全离线运行。
Repo:https://github.com/royalpinto007/evalgate
Package:https://www.npmjs.com/package/@royalpinto007/evalgate
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。