在支持智能体的系统提示中改一行,就可能上线一个告诉客户退款窗口是 30 天的智能体,而你的政策写的是 14 天。正常的 CI 流水线里没有任何东西会检查模型说了什么,所以构建通过,第一个看到错误答案的人是客户。

用固定的评估集来卡住 pull request,和用失败的单元测试来卡住是一样的道理。你把测试用例放在仓库里,在提示变更时运行它们,当失败太多时就阻止合并。

在本指南中,你为一个支持智能体编写评估集,并用一个调用 OpenRouter 的脚本给它打分。你测量在没有任何改动的情况下多次运行之间结果波动有多大,然后把脚本接入 GitHub Actions 作为一个必需的状态检查。

简而言之

  • 固定评估集是一份提交到仓库里的测试用例列表。它只通过经过审查的 pull request 来变更。
  • 在 job 层级而不是 workflow 层级做过滤。GitHub 会把因if条件而被跳过的 job 报告为通过的检查,而因路径过滤被跳过的 workflow 会让必需检查一直处于待定状态并阻止合并。
  • 当通过率低于你的阈值时,评估脚本以非零状态退出,而这个退出码就是让 job 失败的原因。
  • 阈值应该通过对未改动分支的重复运行来测量,而不是直接挑一个严格的数字。
  • temperature和seed只在那些把它们列在supported_parameters中的模型上有帮助。带多数投票的重复采样对每个模型都有效。

Diagram of the eval gate in GitHub Actions: a pull request triggers a changes job that runs a path filter, an eval-gate job that either runs the fixed eval set or is skipped, and a merge that is allowed when the pass rate meets the threshold or the job was skipped and blocked when the pass rate is below the threshold

CI 中的 LLM 评估是什么意思

评估就是智能体的一个测试用例。它有一个输入,以及一条判断模型答案是否可接受的规则。固定评估集是一份提交到仓库里的这类用例列表,它只通过经过审查的 pull request 来变更。流水线部分就是你的 CI 设置。它决定用例何时运行,以及当失败用例太多时对 pull request 做什么处理。

判断模型的答案好不好是一个单独的问题,其中一个选择是把它交给另一个充当评分者的模型。这种技术叫做 LLM-as-a-judge,我们在我们的 LLM-as-a-judge 指南中介绍了它。我们的工具调用循环指南讲的是构建智能体本身。本指南讲的是两者之间的流水线。

在你能卡住任何东西之前需要什么

在 gate 能告诉你任何有用信息之前,需要先准备好三件事。

  • 一个固定的、带版本的评估集。Anthropic 的智能体评估指南建议从真实失败中抽取 20 到 50 个简单任务作为起始集合。这里我们只用三个,以便示例保持简短。
  • 一种评分方法和一个阈值。评分方法把一个答案转成可计数的通过或失败。阈值则适用于整次运行。本指南使用字符串断言。评分标准或评判模型也可以。
  • 运行要足够可重复,才能被信任。gate 应该因回归而拦截,而不是因噪声而拦截。重复采样加多数投票在每个模型上都有效。temperature和seed只在列出它们的模型上有效,所以要先查看该模型的supported_parameters再依赖它们。

你还需要一个OpenRouter API key导出为OPENROUTER_API_KEY,Node 20 或更新版本,以及jq。

分四步构建 gate

完成这四步后,一个触及你提示词的 pull request 会自动运行你的评估集,并且如果分数下降就无法合并。

  1. 只在可能破坏你智能体的变更上触发评估。
  2. 把评估集放在仓库里,紧挨着它所测试的提示词。
  3. 编写 CI 任务运行的脚本。
  4. 测量决定是否阻止合并的阈值。

第 1 步:只在相关变更上触发

当提示词、智能体逻辑、工具 schema、评估集、评估脚本或工作流本身发生变化时运行评估。最后两项很容易被遗漏。如果它们不在过滤器中,一个破坏评分逻辑或修改 gate 的 pull request 就会跳过评估并在未测试的情况下合并。

你用两个 job 来实现。第一个总是运行。它把 pull request 更改的文件与一组路径列表进行比对,并输出true或false,取决于是否有匹配。第二个 job 运行评估,只有当第一个 job 返回true时才会启动。

从.github/workflows/eval-gate.yml工作流头部和第一个 job 开始。列在agent:下面的路径就是你要为自己的仓库修改的路径。

name:eval-gate
on:pull_request
concurrency:
group:eval-gate-${{ github.ref }}
cancel-in-progress:true
jobs:
changes:
runs-on:ubuntu-latest
outputs:
agent:${{ steps.filter.outputs.agent }}
steps:
-uses:actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1# v7.0.1
-uses:dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d# v4.0.3
id:filter
with:
filters:|
agent:
- '.github/workflows/eval-gate.yml'
- 'prompts/**'
- 'agents/**'
- 'tools/**/schema.json'
- 'eval-sets/**'
- 'scripts/run-evals.mjs'

concurrencywithcancel-in-progress当有人再次向同一分支推送时取消上一次运行,因此一个连续推送多次的 pull request 不会为每次推送都付出一次 eval 运行的代价。

这里有两件事可能出错。

第一是有绿色勾选但背后没有 eval。当第一个 job 返回false时,GitHub 会跳过 eval job,而一个被跳过的 job 会满足必需的状态检查。这正是让不相关的 pull request 得以合并的原因。这也意味着一个拼错的 glob 会在没有运行任何一个用例的情况下通过检查,而一个changes直接失败的 job 也有同样的效果,因为 GitHub 会跳过一个其needs依赖失败的 job。第 3 步通过让changes也成为必需检查来堵住第二个缺口。对于第一个,在 prompt 变更第一次通过时打开运行日志,确认 eval 确实运行了。

第二是把过滤器放在哪里。不要把它上移到 workflow 层级作为on.pull_request.paths。GitHub 关于跳过 workflow 运行的文档说,当一个 workflow 被路径过滤跳过时,“与该 workflow 关联的检查将保持‘Pending’状态”,而要求这些检查的 pull request 会被阻止合并。

第 2 步:把 eval 集放进仓库

把 eval 集和它所测试的 prompt 放在同一个仓库里。当有人编辑一个 prompt 时,针对它的测试会在同一个 pull request 中一起变更,一位审查者能同时看到两者。

四个文件并排放置。

your-repo/
.github/workflows/eval-gate.yml  -> 第 1 步中的 workflow
prompts/support-agent.md         -> 系统 prompt
eval-sets/support-agent.json     -> 测试它的用例
scripts/run-evals.mjs            -> 第 3 步中的脚本

把 eval 集保存为eval-sets/support-agent.json。每个用例都有一个 input、答案必须包含的字符串,以及答案必须不包含的字符串。mustMention中的条目也可以是一个列表,如第三个用例那样,此时答案只需包含其中的一个字符串即可。单个字面字符串在模型写出“a person”或“our team”而你期望的是“human”时就会失败,所以只要不止一种措辞可以接受,就用列表。

[
{
"id":"refund-window",
"input":数字下载的退款申请期限是多久?,
"mustMention":["14 天"],
"mustNotMention":["30 天"]
},
{
"id":"refund-exception",
"input":"我在 60 天前购买了一个下载内容。我还能退款吗?",
"mustMention":["14 天"],
"mustNotMention":["是的,你可以"]
},
{
"id":"escalation",
"input":"你们的产品删除了我的文件,我要找律师。",
"mustMention"[["human","person","our team","specialist"]],
"mustNotMention":["14 天"]
}
]

这些用例所测试的提示词就在它旁边,位于prompts/support-agent.md。

你是一家数字下载商店的客服智能体。
退款窗口为购买后 14 天。没有例外。
如果客户威胁采取法律行动或报告数据丢失,请转交给人工
并且不要引用退款政策。
最多用三句话回答。

从三个用例开始。每当智能体在生产环境中出错一次,就添加一个。

把两者都prompts/和eval-sets/置于一个CODEOWNERS规则之下,并在你的分支保护规则中开启“Require review from Code Owners”。否则,绕过失败门禁的最快方式就是放宽那个捕获到回归的测试。单独一个CODEOWNERS文件只会请求审查,并不会阻止合并。

第 3 步:编写 CI 任务运行的脚本

该任务运行一个脚本,并在通过率低于阈值时以非零状态退出。这个退出码就是 CI 阻止合并所需的全部。

该脚本加载评估集,将每个用例发送给模型,检查答案,并以一个告知 CI 发生了什么的代码退出。它只使用 Node 内置模块,因此无需安装任何东西。将其保存为scripts/run-evals.mjs。

import{ readFileSync }from"node:fs";
import{ parseArgs }from"node:util";

const{values}=parseArgs({
options: {
set: { type:"string", default:"eval-sets/support-agent.json"},
prompt: { type:"string", default:"prompts/support-agent.md"},
model: { type:"string", default:"anthropic/claude-sonnet-5"},
threshold: { type:"string", default:"0.9"},
samples: { type:"string", default:"3"},
concurrency: { type:"string", default:"8"},
},
});

constsystemPrompt=readFileSync(values.prompt,"utf8");
constcases=JSON.parse(readFileSync(values.set,"utf8"));
constthreshold=Number(values.threshold);
constsamples=Number(values.samples);
constconcurrency=Number(values.concurrency);

// 任何导致评测无法运行的情况都退出码 2,这样任务就能区分
“智能体变差了”和“评测无法运行”。
functionabort(message) {
console.error(`::error::eval 无法运行:${message}`);
process.exit(2);
}

if(!Array.isArray(cases)||cases.length===0)abort(`${values.set} 没有用例`);
if(!(threshold>0&&threshold<=1))abort(`--threshold 必须大于 0 且至多为 1,实际为 "${values.threshold}"`);
if(!Number.isInteger(samples)||samples<1||samples%2===0)abort(`--samples 必须是正奇数,实际为 ${values.samples}`);
if(!Number.isInteger(concurrency)||concurrency<1)abort(`--concurrency 必须是正整数,实际为 ${values.concurrency}`);

classEvalDidNotRunextendsError{}

asyncfunctioncallModel(input) {
constres=awaitfetch("https://openrouter.ai/api/v1/chat/completions", {
method:"POST",
signal: AbortSignal.timeout(60_000),
headers: {
Authorization:`Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type":"application/json",
},
body:JSON.stringify({
model: values.model,
// 只路由到一个提供商。不同提供商在多次运行之间
// 提供相同的 slug 会看起来像提示词回归。
provider: { order: ["anthropic"], allow_fallbacks:false},
messages: [
{ role:"system", content: systemPrompt },
{ role:"user", content: input },
],
}),
});
if(!res.ok)thrownewEvalDidNotRun(`${res.status} ${awaitres.text()}`);
constbody=awaitres.json();
return{ text: body.choices?.[0]?.message?.content??"", cost: body.usage?.cost??0};
}

asyncfunctionrun(input) {
for(letattempt=1; attempt<=3; attempt++) {
try{
returnawaitcallModel(input);
}catch(err) {
if(attempt===3)thrownewEvalDidNotRun(err.message);
awaitnewPromise((resolve)=>setTimeout(resolve, attempt*2000));
}
}
}

// 一个要求要么是字符串,要么是表示“其中任意一个”的数组。
functionmatches(answer,requirement) {
constoptions=Array.isArray(requirement)?requirement:[requirement];
returnoptions.some((option)=>answer.includes(option.toLowerCase()));
}

functionscore(text,testCase) {
constanswer=text.toLowerCase();
constmust=testCase.mustMention??[];
constmustNot=testCase.mustNotMention??[];
return(
must.every((r)=>matches(answer, r))&&
!mustNot.some((r)=>matches(answer, r))
);
}

// 每个样本一个工作单元,因此整个矩阵以固定的
// 并发度运行,而不是一次一个请求。
constjobs=cases.flatMap((testCase)=>
Array.from({ length: samples }, ()=>testCase),
);
constresults=newMap(cases.map((c)=>[c.id, []]));
letspend=0;
letcursor=0;

asyncfunctionworker() {
while(cursor<jobs.length) {
consttestCase=jobs[cursor++];
const{text,cost}=awaitrun(testCase.input);
spend+=cost;
results.get(testCase.id).push(score(text, testCase));
}
}

conststarted=Date.now();
try{
awaitPromise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, worker));
}catch(err) {
// 提供商超时不是质量回退。
abort(err.message);
}

letpassed=0;
for(consttestCaseofcases) {
constverdicts=results.get(testCase.id);
constmajority=verdicts.filter(Boolean).length>samples/2;
if(majority) passed++;
consttrace=verdicts.map((v)=>(v?".":"x")).join("");
console.log(`${majority?"PASS":"FAIL"}  ${testCase.id}  ${trace}`);
}

constrate=passed/cases.length;
constseconds=((Date.now()-started)/1000).toFixed(1);
console.log(`\npass rate ${rate.toFixed(2)} against threshold ${threshold}`);
console.log(`${jobs.length} calls in ${seconds}s, cost $${spend.toFixed(4)} on ${values.model}`);

if(rate<threshold) {
console.error(`::error::eval gate failed: ${passed}/${cases.length} cases passed`);
process.exit(1);
}

除了调用模型之外,该脚本还做三件事。

当评估集为空或某个选项格式错误时,它会拒绝运行,并在发送请求之前以退出码 2 退出。如果没有这些检查,一个被意外替换为[]的评估集会产生NaN的通过率,而NaN < threshold为 false,因此零个评估会通过门禁。一个--threshold为90%或负的--samples也会以同样的方式通过,而一个空白的--threshold会变成0,没有任何通过率能低于它,因此该检查也会拒绝0。

它会把每个用例运行多次,其中每一次运行都是一个样本。它取样本中的多数判定,因为同一个提示并不总是产生相同的答案。

它还会把每个请求路由到同一个提供商。像这样的模型 sluganthropic/claude-sonnet-5通过 OpenRouter 由多个提供商提供服务。在撰写本文时,其端点包括 Anthropic、Amazon Bedrock、Azure 和 Google。如果没有路由偏好,同一评估的两次运行可能会到达两个不同的提供商,而它们答案之间的差异看起来就像提示词回归。provider.order是一个提供商 slug 的优先级列表,而allow_fallbacks: false告诉我们要不要尝试该列表之外的任何提供商。如果列出的提供商失败,请求就会失败,而不是转移到备用提供商,脚本会以退出码 2 退出。提供商选择文档涵盖这两个字段。该固定绑定到你指定的模型。如果你将--model改为来自另一家供应商的模型,也要更改order的值,否则没有提供商会匹配,每个请求都会失败。

最后一行打印的成本来自usage我们在每个非流式响应中包含的对象。用量核算文档描述了这些字段。

先在本地运行它。

exportOPENROUTER_API_KEY="sk-or-..."
nodescripts/run-evals.mjs--samples3

每个点是通过的样本,而每个x是失败的样本,因此你可以看到哪个用例间歇性失败,而不仅仅是最终的通过率。

PASS  refund-window  ...
PASS  refund-exception  ...
PASS  escalation  ...

通过率 1.00 对比阈值 0.9
9 次调用用时 <seconds>s,成本 $<cost>,使用 anthropic/claude-sonnet-5

当某个用例的大多数样本通过时,该用例计为通过,因此..x仍然是PASS。

现在将第二个作业添加到.github/workflows/eval-gate.yml,位于changes作业下方。

eval-gate:
needs:changes
if:needs.changes.outputs.agent == 'true'
runs-on:ubuntu-latest
timeout-minutes:15
steps:
-uses:actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1# v7.0.1
-uses:actions/setup-node@820762786026740c76f36085b0efc47a31fe5020# v7.0.0
with:
node-version:'24'
-name:运行固定评估集
env:
OPENROUTER_API_KEY:${{ secrets.OPENROUTER_API_KEY }}
run:node scripts/run-evals.mjs --samples 3 --threshold 0.9

该if:行读取来自changes作业的输出,正是它阻止了评估在不可能破坏智能体的拉取请求上运行。timeout-minutes: 15阻止挂起的提供商占用运行器长达默认六小时。每个 action 都固定到完整的提交 SHA,并在末尾注释中注明其对应的发布版本。像这样的标签v4可以指向新的代码,而这个 job 持有你的 API key,所以固定到 SHA 意味着运行的代码不会在你仓库未变更的情况下发生变化。GitHub 的安全加固指南也推荐这样做。

在 gate 能拦截任何东西之前,做三件事。

  1. 在 Settings > Secrets and variables > Actions 下把你的 key 添加为仓库 secret,命名为OPENROUTER_API_KEY。
  2. 打开一个触及prompts/的 pull request,让 workflow 运行一次。
  3. 把changes和eval-gate都添加为必需的状态检查在你的分支保护规则中。

第三步才是把 eval 变成 gate 的关键。没有它,失败的 eval 会在 pull request 上显示一个红色 X,但 pull request 仍然可以合并。要求changes同样也能覆盖 filter job 本身失败的情况,例如 checkout 出错。GitHub 随后会跳过eval-gate,而被跳过的 job 算作通过,所以只要求eval-gate时,一个 prompt 变更可能在未被评估的情况下合并。如果也要求changes,失败的 filter job 就会阻止合并。一个无关的 pull request 仍然可以合并,因为changes通过而eval-gate被跳过。

除了GITHUB_TOKEN之外,GitHub 不会把 secret 传递给由 fork 仓库触发的工作流,所以来自 fork 的 pull request 会因为缺少 key 而失败。如果你的仓库接受 fork 贡献,也在推送到main时运行 eval,这样通过 fork 到达的变更仍然会被检查。如果你需要记录一次阻止合并的运行检查了什么,把运行输出作为 workflow artifact 上传。

现在这个 gate 会在每一个可能破坏智能体的 pull request 上运行。它仍然使用一个没人论证过的阈值,所以 Step 4 来测量一个。

Step 4:测量阈值

针对你未变更的main分支运行 eval 集六次。各次运行之间不要做任何改动。记下你看到的最低通过率。你要的是最差的一次运行,而不是典型的那次,所以如果可以承受就多运行几次。那个最低通过率就是你的下限,你把阈值设在下限或以下。

有三个用例时,其中一个用例在这六次运行中的某一次失败,就得到 0.67 的下限。对照 0.9 的阈值,那次运行就是一个被阻止的 pull request,而其背后并没有回归。

当你的下限很低时,按顺序检查以下三项。

首先,看看你自己的断言。上面那个escalation用例接受human、person、our team或specialist. 一个要求字面字符串的版本human每次模型写成“a person”时都会失败,而提示词并不能阻止它这样做。每次都要先检查你的断言,再做其他任何事。

第二,检查你的确定性设置是否真的起作用。将temperature设为 0 并固定一个seed只有在模型支持这些参数时才有用。下面的命令会打印出模型支持的参数,这样你就能看到temperature和seed是否在列表中。

curl-s"https://openrouter.ai/api/v1/models"\
|jq-r'.data[] | select(.id=="anthropic/claude-sonnet-5") | .supported_parameters'

Claude Sonnet 5两者都没有列出,这就是上面的脚本不发送它们的原因。我们的Claude Sonnet 5 迁移指南说temperature、top_p和top_k对该模型会被静默忽略。2026 年 9 月 18 日,目录中列出了 445 个模型,其中 267 个同时列出了seed和temperature。其余 178 个,大约五分之二,只列出了其中之一或两者都没有。这个命令会统计同时列出两者的模型数量,你可以重新运行它以获得当前数字。

curl-s"https://openrouter.ai/api/v1/models"|jq'
[.data[] | select(.supported_parameters | index("seed") and index("temperature"))] | length'

如果你的模型确实列出了它们,就把temperature: 0和seed: 42加入请求体,并在provider.require_parameters: true旁边设置order。使用默认的require_parameters: false,不支持请求中所有参数的提供商仍然可以接收请求,并忽略它不认识的参数。使用require_parameters: true,请求只会被路由到支持所有参数的提供商。

第三,经过这两项检查后仍然存在的波动是真实的,你通过采样来吸收它。提高--samples直到下限不再移动。3 是一个合理的默认值,而且它已经是单次运行成本的三倍,所以在提高到 5 之前先测量一下。

小规模集合有一个值得了解的属性。只有三个用例时,通过率只能是 0、0.33、0.67 或 1.00,所以阈值 0.9 意味着三个用例必须全部通过。这是把集合扩大到 20 个或更多用例的另一个理由。

当一个拉取请求仅因一个用例而未通过门禁时,对main运行同样的评估。如果main也失败,那这个失败就是噪声,你需要更多样本或更低的阈值。如果main通过,就把这个失败当作拉取请求中的回归。

这就是完整的关卡。本指南的其余部分会讲当字符串检查不再够用时该怎么做,以及什么时候值得用一个能存储运行历史的平台来替换一个普通脚本。

用 Ori Eval 测试一个会调用工具的智能体

上面的脚本发送一条消息并读取一条回复。如果你的智能体调用工具,这就不够了。字符串检查无法告诉你智能体是否调用了正确的工具,或者是否调用了一个本应放手的昂贵工具。

Ori Eval是我们用于智能体的评估框架。框架是运行你的评估的程序。它运行智能体,记录智能体做了什么,并根据你的断言检查结果。Ori 评估是.eval.ts文件,而断言是关于智能体做了什么。

import{ test }from'bun:test';
import{ assertModelIsLive, setupAgent, setupJudge }from'ori/eval';

constMODEL='anthropic/claude-sonnet-5';
awaitassertModelIsLive(MODEL);

constagent=setupAgent({ model:MODEL});

// 用与被测模型不同的模型家族来评分。
constjudge=setupJudge({
agent:setupAgent({ model:'openai/gpt-5-mini'}),
minScore:0.8,
});

test('在引用退款政策前先查询订单',async()=>{
construn=awaitagent.run('我可以退款订单 #1234 吗?我是 60 天前买的。');
run.tool('lookup_order').toBeCalled();
run.tool('issue_refund').toNotBeCalled();
run.toComplete();
awaitjudge.autoEvals({
criteria:'引用 14 天窗口期,且不编造例外情况。',
run,
});
});

run.tool(...)是普通脚本做不到的部分。assertModelIsLive如果 slug 离开了目录,会以清晰的消息让本次运行失败,因此该文件会大声失败,而不是去测试一个已不存在的模型。在你让评判器使构建失败之前,先自己给同一批运行中的一部分评分,并将你的判定与评判器的判定进行比较。Anthropic 的指南建议用人类专家校准 LLM 评分器,出于同样的原因。

Ori 用以下命令运行评估文件Bun,而当CI为 true 时它不会为你安装 Bun。在工作流中,你先安装 Bun,然后下载一个固定版本的 Ori 发布包并在运行前校验其校验和,因为该任务持有你的 API 密钥。设置OPENROUTER_API_KEY后,Ori 在 CI 中不需要ori login。

-使用:oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6# v2.2.0
-name:Install Ori
env:
ORI_RELEASE:cli-0.15.0-531912d
ORI_SHA256:d2545db7a686f29ebae5bbf7e134d89a409cd00c760c1f24a5f8a88692c5947d
run:|
base="https://github.com/OpenRouterLabs/ori-releases/releases/download/$ORI_RELEASE"
curl -fsSL --proto '=https' -o ori "$base/ori-linux-x64"
echo "$ORI_SHA256  ori" | sha256sum -c -
mkdir -p "$HOME/.local/bin"
install -m 0755 ori "$HOME/.local/bin/ori"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
-name:运行固定版本的智能体评测
env:
OPENROUTER_API_KEY:${{ secrets.OPENROUTER_API_KEY }}
run:ori eval --report eval-report.md

ORI_RELEASE以及ORI_SHA256在撰写本文时命名稳定版本及其ori-linux-x64摘要。当你迁移到更新的版本时,请同时更新两者。从同一个发布页面下载校验和文件不会带来任何好处,因为任何能够替换二进制文件的人都可以替换它旁边的校验和。将摘要保留在工作流中意味着二进制文件一旦被更改就会导致sha256sum检查失败。

ori eval查找当前目录下的每个*.eval.ts文件并将它们交给bun test。它的退出码就是bun test的退出码,因此评测失败会导致任务失败。--report写入一份 Markdown 报告,你可以将其作为产物上传或追加到任务摘要中。

每次 Ori 运行都会向真实模型发送请求并产生费用,因此请将这些评测排除在每次提交都会运行的任务之外。Ori Eval 文档涵盖了如何从人工启动的任务或按计划运行它们。

将普通脚本与评测平台进行比较

上面的脚本是一个完整的评测门禁。平台则增加了仪表盘、可绘制图表的运行历史,以及让工程团队之外的人无需打开 CI 日志就能阅读结果的方式。

方式在 CI 中如何运行锁定最适合
普通脚本你自己编写 CI 步骤和退出码逻辑无,因为这是你自己的代码一到两个评测集,完全控制评分
Ori Evalori eval在任务中运行,当评测失败时以非零码退出低,因为评测文件保留在你的仓库中,并可针对目录中的任何模型运行工具调用智能体,或在你自己的智能体上比较模型
DeepEvaldeepeval test run在 pytest 下运行,配合assert_test()低于每个指标的阈值时抛出异常低,因为评分库是开源的现成的指标,如答案相关性和任务完成度
Braintrust一个已发布的 GitHub Action,运行评测并在拉取请求上发布摘要评论中等,因为评分历史存储在他们的平台上评分变化出现在评审中,而不是 CI 日志里
Arizeclient.experiments.run()从 SDK 中作为普通 Python 步骤调用,其文档中有一个示例工作流中等,因为实验 API 是他们的已经使用 Arize 做可观测性的仓库
Galileorun_experiment从 SDK 中调用,或者create_experiment用于多轮智能体中等,因为指标和历史记录是平台原生的基于评估历史的托管仪表盘

从脚本开始。无论哪种方式,你的评估集和评分逻辑都留在你的仓库中,之后你可以让平台指向它们。离开某个平台意味着要移植针对其 SDK 编写的评分逻辑,并丢失存储在那里的运行历史。

常见失败模式

第一个是成本。它是用例数乘以样本数再乘以相关拉取请求被打开的频率。脚本会从usage.cost字段打印每次运行的成本,所以在提高--samples或扩大集合之前先读那一行。长提示词和评分模型会大幅改变这个数字,所以要测量你自己的集合。当前价格在定价页面上。

第二个是速度。门禁增加的每一分钟都会加到每一个触及提示词的拉取请求上。并发运行样本。脚本的--concurrency标志默认为 8,所以示例中的九次调用分两波运行,而不是九次顺序请求,而且这个差异会随着集合规模增大而增大。

第三个是门禁因错误原因而失败。提供商超时不是回归,这就是为什么脚本在评估无法运行时以 2 退出,在智能体变差时以 1 退出,并打印一条 GitHub 注解说明发生了哪种情况。

第四个是高于底线的阈值。你没有测量过的阈值会阻止那些什么都没改变的拉取请求,而绕过它的唯一办法是管理员合并或在时间压力下设置更低的阈值。根据你在第 4 步中测量到的底线来设置阈值。

常见问题

如何将 LLM 评估添加到 CI/CD 流水线中?

在仓库中保留一个固定的、带版本的评估集,在 CI 作业中运行它,根据阈值对输出评分,并将该作业以及为其设置门禁的路径过滤作业标记为分支保护中的必需状态检查。作业的退出码决定结果,就像失败的单元测试一样。

什么是固定评估集,为什么它需要与代码一起保持版本化?

固定评估集是一份签入的测试输入和评分标准列表,只通过经过审查的 pull request 进行更改。如果它位于仓库之外,就会与提示词脱节,不再测试实际发布的内容。

你能根据评估分数阻止 pull request 合并吗?

可以。一个在低于阈值时以非零状态退出的普通脚本就足够了,只要运行它的作业是必需的状态检查。把提示词和评估集放在一个CODEOWNERS启用了“Require review from Code Owners”的规则后面,这样削弱那个捕获了回归的测试也需要经过审查。

LLM-as-a-judge 和在 CI 中运行评估有什么区别?

LLM-as-a-judge 是单次运行的评分方法,由第二个模型给答案打分。在 CI 中运行评估是围绕任何评分方法的流水线。它决定用例何时运行、针对哪个固定集合运行,以及当分数偏低时 pull request 会怎样。

评估需要每次提交都运行,还是只在提示词和智能体逻辑变更时运行?

只在涉及提示词、智能体逻辑、工具 schema 或评估集的变更时运行。在作业层面而不是工作流层面进行过滤。GitHub 会把因if条件而跳过的作业报告为通过的检查,而因路径过滤器跳过的工作流会让必需的检查保持待定并阻止合并。

在每个 PR 上运行 LLM 评估在 token 和 CI 分钟上要花多少钱?

成本等于用例数乘以每个用例的样本数,再乘以相关 pull request 被打开的频率。本指南中的脚本会从 API 响应中的usage字段打印每次运行的成本,这样你就能测量自己的集合。当前的模型价格在定价页面上。

有哪些工具支持在合并前根据固定评估集对 PR 进行门控?

一个带阈值检查的普通脚本本身就足够了。Ori Eval、DeepEval、Braintrust、Arize 和 Galileo 在同样的退出码模式之上增加了报告、运行历史或针对智能体的断言。

你如何处理不稳定或非确定性的评估阻止了一个好的 PR?

先检查你自己的断言,因为模型改述的单个字面字符串是常见原因。然后对每个用例多次采样并取多数判定。检查模型的supported_parameters在OpenRouter models 端点之前依赖temperature或seed,因为未列出它们的模型会忽略它们。

LLM-as-a-judge 可靠到足以让构建失败吗?

可以,前提是你去测量评判者而不是假设它。自己给一批运行样本打分,并将你的判定与评判者的判定进行比较,同时把评判者与普通断言配对,这样构建就不会因为一次未经核实的模型调用而失败。

结论

在本指南中,你为一个支持智能体编写了评估集,用一个在低于阈值时以非零状态退出的脚本对其评分,在多次重复运行中测量了你自己的下限,并把该任务设为必需检查。固定的评估集、经过测量的阈值以及任务级触发,为你的提示词和智能体逻辑提供了与单元测试为代码提供的相同保护。要将这道关卡扩展到调用工具的智能体,请从Ori Eval 文档开始。

参考资料