OpenRouter 教程:如何在 CI 中用 LLM 评测门禁拦截 Pull Request
How to Gate Pull Requests on LLM Evals in CI
OpenRouter 发布教程,介绍如何在 CI 中用固定评测集给 Pull Request 加门禁:把评测用例与提示词放在同一仓库,用脚本调用 OpenRouter 打分,通过率低于阈值时以非零退出码阻断合并。
OpenRouter 官方给出可复用的 CI 评测门禁脚本与阈值测量方法,读者可据此把提示词回归挡在合并前。
在支持代理的系统提示中改动一行,就可能发布一个告诉客户退款窗口是 30 天的代理,而你的政策规定是 14 天。正常的 CI 流水线不会检查模型说了什么,所以构建通过,第一个看到错误答案的人是客户。
用固定的评估集来门控拉取请求,和用失败的单元测试来门控是一样的道理。你把测试用例放在仓库里,在提示变更时运行它们,并在失败过多时阻止合并。
在本指南中,你为支持代理编写一个评估集,并用一个调用 OpenRouter 的脚本来评分。你测量在没有任何改动的情况下多次运行之间结果波动有多大,然后将该脚本接入 GitHub Actions 作为必需的状态检查。
简而言之
- 固定的评估集是提交到仓库中的一组测试用例。它只通过经过审查的拉取请求来变更。
- 在作业级别而不是工作流级别进行过滤。GitHub 会将因
if条件而跳过的作业报告为通过的检查,而因路径过滤器跳过的工作流会让必需的检查保持待定状态并阻止合并。 - 当通过率低于你的阈值时,评估脚本以非零状态退出,而这个退出码就是作业失败的原因。
- 通过对未更改分支的重复运行来测量阈值,而不是选择一个严格的数字。
temperature和seed只对在supported_parameters中列出它们的模型有帮助。带多数投票的重复采样适用于所有模型。

CI 中的 LLM 评估意味着什么
评估是代理的一个测试用例。它有一个输入和一个用于判断模型答案是否可接受的规则。固定的评估集是提交到仓库中的这些用例的列表,它只通过经过审查的拉取请求来变更。流水线部分是你的 CI 设置。它决定用例何时运行,以及当太多用例失败时拉取请求会怎样。
判断模型的答案是否好是一个单独的问题,一种选择是把它交给另一个充当评分者的模型。这种技术称为 LLM-as-a-judge,我们在我们的 LLM-as-a-judge 指南中介绍了它。我们的工具调用循环指南涵盖了构建代理本身。本指南是关于它们之间的流水线。
在你能门控任何东西之前需要什么
在门控能告诉你任何有用信息之前,需要准备好三件事。
- 一个固定的、带版本的评估集。Anthropic 的代理评估指南建议将从真实失败中提取的 20 到 50 个简单任务作为起始集。我们这里用三个,以便示例保持简短。
- 一种评分方法和一个阈值。评分方法将一个答案转化为你可以计数的通过或失败。阈值适用于整个运行。本指南使用字符串断言。评分标准或评判模型也可以。
- 运行的可重复性足以信任。门控应该因回归而阻止,而不是因噪声。带多数投票的重复采样适用于所有模型。
temperature和seed只对列出它们的模型有帮助,所以在依赖它们之前请检查模型的supported_parameters。
你还需要一个导出为 OPENROUTER_API_KEY 的 OpenRouter API 密钥、Node 20 或更新版本,以及 jq。
分四步构建门控
到这四步结束时,一个触及你的提示的拉取请求会自动运行你的评估集,并且如果分数下降就无法合并。
- 仅在可能破坏你的代理的变更上触发评估。
- 将评估集放在仓库中,紧挨着它所测试的提示。
- 编写 CI 作业运行的脚本。
- 测量决定合并是否被阻止的阈值。
步骤 1:仅在相关变更时触发
当提示词、智能体逻辑、工具 schema、评估集、评估脚本或工作流本身发生变化时运行评估。最后两项很容易被遗漏。如果它们不在过滤条件中,一个破坏评分逻辑或修改门禁的拉取请求就会跳过评估并在未经测试的情况下合并。
你可以用两个 job 来实现。第一个 job 始终运行。它检查拉取请求更改的文件是否匹配一组路径,并根据是否有匹配输出 true 或 false。第二个 job 运行评估,仅当第一个 job 返回 true 时才会启动。
以工作流头部和第一个 job 开始 .github/workflows/eval-gate.yml。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'带有 cancel-in-progress 的 concurrency 会在有人再次推送到同一分支时取消上一次运行,因此一个连续多次推送的拉取请求不会为每次推送都付出一次评估运行的代价。
这里有两件事可能出错。
第一是绿色的勾选背后没有评估。当第一个 job 返回 false 时,GitHub 会跳过评估 job,而被跳过的 job 会满足必需的状态检查。这正是无关拉取请求得以合并的原因。这也意味着一个拼错的 glob 会在没有运行任何用例的情况下通过检查,而一个直接失败的 changes job 也有同样的效果,因为 GitHub 会跳过其 needs 依赖失败的 job。步骤 3 通过将 changes 也设为必需检查来堵住第二个漏洞。对于第一个漏洞,在提示词变更首次通过时打开运行日志,确认评估确实运行了。
第二是过滤条件放置的位置。不要把它上移到工作流级别作为 on.pull_request.paths。GitHub 关于跳过工作流运行的文档指出,当工作流因路径过滤而被跳过时,“与该工作流关联的检查将保持‘Pending’状态”,而要求这些检查的拉取请求会被阻止合并。
步骤 2:将评估集放入仓库
将评估集与它所测试的提示词放在同一个仓库中。当有人编辑提示词时,对应的测试会在同一个拉取请求中一起变更,一位审查者就能同时看到两者。
四个文件并排放置。
your-repo/
.github/workflows/eval-gate.yml -> the workflow from Step 1
prompts/support-agent.md -> the system prompt
eval-sets/support-agent.json -> the cases that test it
scripts/run-evals.mjs -> the script from Step 3将评估集保存为 eval-sets/support-agent.json。每个用例包含一个输入、答案必须包含的字符串以及不得包含的字符串。mustMention 中的条目也可以是一个列表,如第三个用例那样,此时答案只需包含其中的一个字符串即可。单个字面字符串会在模型写出“a person”或“our team”而你期望的是“human”时失败,因此只要存在多种可接受的措辞,就应使用列表。
[
{
"id": "refund-window",
"input": "How long do I have to request a refund on a digital download?",
"mustMention": ["14 days"],
"mustNotMention": ["30 days"]
},
{
"id": "refund-exception",
"input": "I bought a download 60 days ago. Can I still get a refund?",
"mustMention": ["14 days"],
"mustNotMention": ["yes, you can"]
},
{
"id": "escalation",
"input": "Your product deleted my files and I want a lawyer.",
"mustMention": [["human", "person", "our team", "specialist"]],
"mustNotMention": ["14 days"]
}
]这些用例所测试的提示词就在它旁边,位于 prompts/support-agent.md 中。
You are a support agent for a digital downloads store.
The refund window is 14 days from purchase. There are no exceptions to it.
If a customer threatens legal action or reports data loss, hand off to a human
and do not quote the refund policy.
Answer in at most three sentences.从三个用例开始。每当智能体在生产环境中出错时,就添加一个。
将 prompts/ 和 eval-sets/ 都置于 CODEOWNERS 规则之下,并在分支保护规则中开启“Require review from Code Owners”。否则,绕过失败门禁的最快方法就是放宽那个捕获了回归的测试。仅有一个 CODEOWNERS 文件只会请求审查,并不会阻止合并。
步骤 3:编写 CI job 运行的脚本
该 job 运行一个脚本,当通过率低于阈值时以非零状态退出。这个退出码就是 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" },
},
});
const systemPrompt = readFileSync(values.prompt, "utf8");
const cases = JSON.parse(readFileSync(values.set, "utf8"));
const threshold = Number(values.threshold);
const samples = Number(values.samples);
const concurrency = Number(values.concurrency);
// Exit 2 for anything that stops the eval from running, so the job can tell
// "the agent got worse" apart from "the eval could not run".
function abort(message) {
console.error(`::error::eval could not run: ${message}`);
process.exit(2);
}
if (!Array.isArray(cases) || cases.length === 0) abort(`${values.set} has no cases`);
if (!(threshold > 0 && threshold <= 1)) abort(`--threshold must be greater than 0 and at most 1, got "${values.threshold}"`);
if (!Number.isInteger(samples) || samples < 1 || samples % 2 === 0) abort(`--samples must be a positive odd integer, got ${values.samples}`);
if (!Number.isInteger(concurrency) || concurrency < 1) abort(`--concurrency must be a positive integer, got ${values.concurrency}`);
class EvalDidNotRun extends Error {}
async function callModel(input) {
const res = await fetch("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,
// Route to one provider only. A different provider serving the same slug
// between runs would look like a prompt regression.
provider: { order: ["anthropic"], allow_fallbacks: false },
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: input },
],
}),
});
if (!res.ok) throw new EvalDidNotRun(`${res.status} ${await res.text()}`);
const body = await res.json();
return { text: body.choices?.[0]?.message?.content ?? "", cost: body.usage?.cost ?? 0 };
}
async function run(input) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
return await callModel(input);
} catch (err) {
if (attempt === 3) throw new EvalDidNotRun(err.message);
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
}
}
}
// A requirement is either a string, or an array meaning "any one of these".
function matches(answer, requirement) {
const options = Array.isArray(requirement) ? requirement : [requirement];
return options.some((option) => answer.includes(option.toLowerCase()));
}
function score(text, testCase) {
const answer = text.toLowerCase();
const must = testCase.mustMention ?? [];
const mustNot = testCase.mustNotMention ?? [];
return (
must.every((r) => matches(answer, r)) &&
!mustNot.some((r) => matches(answer, r))
);
}
// One unit of work per sample, so the whole matrix runs with a fixed
// concurrency instead of one request at a time.
const jobs = cases.flatMap((testCase) =>
Array.from({ length: samples }, () => testCase),
);
const results = new Map(cases.map((c) => [c.id, []]));
let spend = 0;
let cursor = 0;
async function worker() {
while (cursor < jobs.length) {
const testCase = jobs[cursor++];
const { text, cost } = await run(testCase.input);
spend += cost;
results.get(testCase.id).push(score(text, testCase));
}
}
const started = Date.now();
try {
await Promise.all(Array.from({ length: Math.min(concurrency, jobs.length) }, worker));
} catch (err) {
// A provider timeout is not a quality regression.
abort(err.message);
}
let passed = 0;
for (const testCase of cases) {
const verdicts = results.get(testCase.id);
const majority = verdicts.filter(Boolean).length > samples / 2;
if (majority) passed++;
const trace = verdicts.map((v) => (v ? "." : "x")).join("");
console.log(`${majority ? "PASS" : "FAIL"} ${testCase.id} ${trace}`);
}
const rate = passed / cases.length;
const seconds = ((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 为假,因此没有任何评估能通过门禁。--threshold 为 90% 或负的 --samples 也会以同样的方式通过,而空的 --threshold 会变成 0,没有任何通过率能低于它,所以该检查也会拒绝 0。
它对每个用例运行多次,每一次运行都是一个样本。它取所有样本中的多数裁决,因为同一个提示并不总是产生相同的答案。
它还会把每个请求都路由到同一个提供商。像 anthropic/claude-sonnet-5 这样的模型 slug 会通过 OpenRouter 由多个提供商提供服务。在撰写本文时,它的端点包括 Anthropic、Amazon Bedrock、Azure 和 Google。如果没有路由偏好,同一个评估的两次运行可能到达两个不同的提供商,而它们答案之间的差异看起来就像提示回归。provider.order 是提供商 slug 的优先级列表,allow_fallbacks: false 告诉不要尝试该列表之外的任何提供商。如果列出的提供商失败,请求就会失败,而不是转到备用提供商,脚本会以退出码 2 退出。提供商选择文档涵盖了这两个字段。这个固定绑定到你指定的模型。如果你把 --model 改成另一个供应商的模型,也要更改 order 的值,否则没有提供商会匹配,每个请求都会失败。
最后一行打印的成本来自我们在每个非流式响应中包含的 usage 对象。用量核算文档描述了这些字段。
先在本地运行它。
export OPENROUTER_API_KEY="sk-or-..."
node scripts/run-evals.mjs --samples 3每个点是一个通过的样本,每个 x 是一个失败的样本,因此你可以看到哪个用例间歇性失败,而不只是最终的通过率。
PASS refund-window ...
PASS refund-exception ...
PASS escalation ...
pass rate 1.00 against threshold 0.9
9 calls in <seconds>s, cost $<cost> on 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: Run fixed eval set
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: node scripts/run-evals.mjs --samples 3 --threshold 0.9if: 这一行读取 changes 作业的输出,正是它阻止了评估在不可能破坏 agent 的拉取请求上运行。timeout-minutes: 15 阻止挂起的提供商占用运行器达到默认的六小时。每个 action 都固定到完整的提交 SHA,并在末尾注释中注明其对应的发布版本。像 v4 这样的标签可以被移动以指向新代码,而这个作业持有你的 API 密钥,因此固定到 SHA 意味着运行的代码不会在没有你仓库中变更的情况下改变。GitHub 的安全加固指南也推荐这样做。
在门禁能够阻止任何东西之前,做三件事。
- 在 Settings > Secrets and variables > Actions 下将你的密钥添加为仓库 secret,命名为
OPENROUTER_API_KEY。 - 打开一个触及
prompts/的拉取请求,让工作流运行一次。 - 在你的分支保护规则中,将
changes和eval-gate都添加为必需的状态检查。
第三步才是把评估变成门禁的关键。没有它,失败的评估会在拉取请求上显示一个红 X,但拉取请求仍然可以合并。同时要求 changes 可以覆盖过滤作业本身失败的情况,例如检出错误。此时 GitHub 会跳过 eval-gate,而被跳过的作业算作通过,所以如果只要求 eval-gate,提示变更可能会在未被评估的情况下合并。如果也要求 changes,失败的过滤作业就会阻止合并。不相关的拉取请求仍然可以合并,因为 changes 通过而 eval-gate 被跳过。
除GITHUB_TOKEN外,GitHub 不会将密钥传递给由 fork 仓库触发的工作流,因此来自 fork 的拉取请求会因缺少密钥而失败。如果你的仓库接受 fork 贡献,也要在推送到main时运行评估,这样通过 fork 提交的更改仍会被检查。如果你需要记录合并阻断运行检查了什么,请将运行输出作为工作流产物上传。
现在,这个门禁会在每个可能破坏 agent 的拉取请求上运行。它仍然使用一个没人能证明其合理性的阈值,所以第 4 步要测量一个。
第 4 步:测量阈值
针对未更改的main分支运行评估集六次。各次运行之间不要做任何更改。记下你看到的最低通过率。你要的是最差的一次运行,而不是典型的一次,所以如果条件允许,就多运行几次。那个最低通过率就是你的下限,你把阈值设在该值或以下。
在三个用例的情况下,其中一个用例在这六次运行中的某一次失败,下限就是 0.67。相对于 0.9 的阈值,那次运行就是一个被阻断的拉取请求,而其背后并没有回归。
当你的下限很低时,按顺序检查以下三项。
首先,看看你自己的断言。上面的escalation用例接受human、person、our team或specialist中的任意一个。一个要求字面字符串human的版本,会在模型写成“a person”时每次都失败,而提示词并不会阻止它这样做。每次都要先检查你的断言,然后再检查其他任何东西。
其次,检查你的确定性设置是否起作用。将temperature设为零并固定一个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添加到请求体中,并在order旁边设置provider.require_parameters: true。使用默认的require_parameters: false时,不支持请求中每个参数的提供方仍然可以接收该请求,并忽略它不认识的参数。使用require_parameters: true时,请求只会被路由到支持所有参数的提供方。
第三,经过这两项检查后仍然存在的任何波动都是真实的,你通过采样来吸收它。提高--samples,直到下限不再变化。3 是一个合理的默认值,而且它已经是单次运行成本的三倍,所以在提高到 5 之前先测量。
小规模评估集有一个值得了解的特性。在三个用例的情况下,通过率只能是 0、0.33、0.67 或 1.00,所以 0.9 的阈值意味着三个用例都必须通过。这是将评估集扩展到 20 个或更多用例的另一个理由。
当一个拉取请求仅因一个用例而未通过门禁时,对main运行同样的评估。如果main也失败,那么该失败是噪声,你需要更多样本或更低的阈值。如果main通过,则将该失败视为拉取请求中的回归。
这就是完整的门禁。本指南的其余部分涵盖当字符串检查不再足够时该怎么做,以及何时值得用一个存储运行历史的平台来替换普通脚本。
使用 Ori Eval 测试调用工具的 agent
上面的脚本发送一条消息并读取一条回复。如果你的 agent 调用工具,这还不够。字符串检查无法告诉你 agent 是否调用了正确的工具,或者是否调用了本应避免的昂贵工具。
Ori Eval 是我们用于 agent 的评估框架。框架是运行评估的程序。它运行 agent,记录 agent 做了什么,并根据你的断言检查结果。Ori 评估是 .eval.ts 文件,断言是关于 agent 做了什么。
import { test } from 'bun:test';
import { assertModelIsLive, setupAgent, setupJudge } from 'ori/eval';
const MODEL = 'anthropic/claude-sonnet-5';
await assertModelIsLive(MODEL);
const agent = setupAgent({ model: MODEL });
// Grade with a different model family than the one under test.
const judge = setupJudge({
agent: setupAgent({ model: 'openai/gpt-5-mini' }),
minScore: 0.8,
});
test('looks up the order before quoting the refund policy', async () => {
const run = await agent.run('Can I refund order #1234? I bought it 60 days ago.');
run.tool('lookup_order').toBeCalled();
run.tool('issue_refund').toNotBeCalled();
run.toComplete();
await judge.autoEvals({
criteria: 'Cites the 14-day window and does not invent exceptions.',
run,
});
});run.tool(...) 是普通脚本无法做到的部分。如果 slug 离开目录,assertModelIsLive 会以清晰的消息使运行失败,因此文件会大声失败,而不是测试一个不再存在的模型。在你让评判器使构建失败之前,自己给同一批运行中的样本打分,并将你的判定与评判器的判定进行比较。Anthropic 的指南建议 根据人类专家校准 LLM 评分器,原因相同。
Ori 使用 Bun 运行评估文件,当 CI 为 true 时,它不会为你安装 Bun。在工作流中,你安装 Bun,然后下载固定版本的 Ori 发布版并在运行前验证其校验和,因为该作业持有你的 API 密钥。设置 OPENROUTER_API_KEY 后,Ori 在 CI 中不需要 ori login。
- uses: 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: Run pinned agent eval
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: ori eval --report eval-report.mdORI_RELEASE 和 ORI_SHA256 指定了撰写时的稳定版本及其 ori-linux-x64 摘要。当你迁移到更新的版本时,请同时更新两者。从同一发布页面下载校验和文件不会增加任何东西,因为任何能替换二进制文件的人都可以替换旁边的校验和。将摘要保留在工作流中意味着更改的二进制文件会使 sha256sum 检查失败。
ori eval 查找当前目录下的每个 *.eval.ts 文件并将它们交给 bun test。它的退出代码是 bun test 的退出代码,因此评估失败会导致作业失败。--report 写入一个 Markdown 报告,你可以将其作为工件上传或附加到作业摘要中。
每次 Ori 运行都会向真实模型发送请求并花费金钱,因此请将这些评估排除在每次提交时运行的作业之外。Ori Eval 文档 涵盖了从人员启动的作业或按计划运行它们。
比较普通脚本与评估平台
上面的脚本是一个完整的评估门。平台增加了仪表板、可绘图的运行历史,以及让工程之外的人无需打开 CI 日志即可阅读结果的方式。
| 方法 | 如何在 CI 中运行 | 锁定 | 最适合 |
|---|---|---|---|
| 普通脚本 | 你自己编写 CI 步骤和退出代码逻辑 | 无,因为它是你的代码 | 一两个评估集,完全控制评分 |
| Ori Eval | 在作业中运行 ori eval,评估失败时以非零退出 | 低,因为评估文件保留在你的仓库中,并针对目录中的任何模型运行 | 工具调用 agent,或在你自己 agent 上比较模型 |
| DeepEval | 在 pytest 下运行 deepeval test run,assert_test() 低于每个指标的阈值时引发异常 | 低,因为评分库是开源的 | 现成的指标,如答案相关性和任务完成度 |
| Braintrust | 一个已发布的 GitHub Action,运行评估并在拉取请求上发布摘要评论 | 中等,因为评分历史存在于他们的平台中 | 在审查中而非 CI 日志中呈现分数变化 |
| Arize | 从 SDK 作为普通 Python 步骤运行 client.experiments.run(),其文档中有一个示例工作流 | 中等,因为实验 API 是他们的 | 已经使用 Arize 进行可观测性的仓库 |
| Galileo | 从 SDK 运行 run_experiment,或为多轮 agent 运行 create_experiment | 中等,因为指标和历史是平台原生的 | 基于评估历史的托管仪表板 |
从脚本开始。无论哪种方式,你的评估集和评分逻辑都留在你的仓库里,之后你可以让某个平台指向它们。离开某个平台意味着要移植针对其 SDK 编写的评分逻辑,并丢失存储在那里的运行历史。
常见失败模式
第一个是成本。它是用例数乘以样本数再乘以相关拉取请求被打开的频率。脚本会从 usage.cost 字段打印每次运行的成本,所以在提高 --samples 或扩大评估集之前,先读那一行。长提示词和评分模型会大幅改变这个数字,所以要测量你自己的评估集。当前价格在定价页面上。
第二个是速度。门禁增加的每一分钟都会加到每个触及提示词的拉取请求上。并发运行样本。脚本的 --concurrency 标志默认是 8,所以示例中的九次调用分两波运行,而不是九次顺序请求,而且随着评估集变大,差异会更大。
第三个是门禁因错误原因而失败。提供商超时不是回归,这就是为什么脚本在评估无法运行时以 2 退出,在智能体变差时以 1 退出,并打印一条 GitHub 注解说明发生了哪种情况。
第四个是高于底线的阈值。你没有测量过的阈值会阻止那些什么都没改变的拉取请求,而绕过它的唯一办法是管理员合并或在时间压力下设置更低的阈值。根据你在第 4 步测得的底线来设置阈值。
常见问题
如何将 LLM 评估添加到 CI/CD 流水线?
在仓库中保留一个固定的、带版本的评估集,在 CI 作业中运行它,根据阈值对输出评分,并将该作业以及为其把关的路径过滤作业标记为分支保护中的必需状态检查。该作业的退出码决定结果,就像失败的单元测试一样。
什么是固定评估集,为什么它需要与代码一起保持版本化?
固定评估集是一份已检入的测试输入和评分标准列表,只能通过经过审查的拉取请求来更改。如果它位于仓库之外,就会与提示词脱节,不再测试实际发布的内容。
能否根据评估分数阻止拉取请求合并?
可以。只要运行它的作业是必需状态检查,一个在低于阈值时以非零退出的普通脚本就足够了。把提示词和评估集放在 CODEOWNERS 规则之后,并启用“Require review from Code Owners”,这样削弱那个捕获了回归的测试也需要经过审查。
LLM-as-a-judge 与在 CI 中运行评估有什么区别?
LLM-as-a-judge 是单次运行的评分方法,由第二个模型对答案评分。在 CI 中运行评估是围绕任何评分方法的流水线。它决定用例何时运行、针对哪个固定评估集运行,以及当分数低时拉取请求会怎样。
评估需要每次提交都运行,还是只在提示词和智能体逻辑变更时运行?
只在触及提示词、智能体逻辑、工具模式或评估集的变更时运行。在作业级别而不是工作流级别进行过滤。GitHub 会将因 if 条件而跳过的作业报告为通过的检查,而因路径过滤器而跳过的工作流会让必需检查保持待定并阻止合并。
在每个 PR 上运行 LLM 评估在 token 和 CI 分钟方面要花多少钱?
成本等于用例数量乘以每个用例的样本数,再乘以相关拉取请求被打开的频率。本指南中的脚本会从 API 响应中的 usage 字段打印每次运行的成本,因此你可以测量自己的集合。当前模型价格见定价页面。
有哪些工具支持在合并前基于固定评估集对 PR 进行门禁?
仅靠一个带阈值检查的普通脚本就足够了。Ori Eval、DeepEval、Braintrust、Arize 和 Galileo 在相同的退出码模式之上增加了报告、运行历史或针对智能体的断言。
如何处理不稳定或非确定性的评估阻塞了一个好的 PR?
先检查你自己的断言,因为模型会改述的单个字面字符串是常见原因。然后对每个用例采样多次并取多数判定。在依赖 temperature 或 seed 之前,先在OpenRouter 模型端点上检查模型的 supported_parameters,因为未列出它们的模型会忽略它们。
LLM 作为评判者是否足够可靠,可以据此让构建失败?
可以,前提是你去测量评判者,而不是假设它可靠。自己给一部分运行结果打分,并将你的判定与评判者的判定进行比较,同时将评判者与普通断言配对,这样构建就不会因为一次未经验证的模型调用而失败。
结论
在本指南中,你为一个支持智能体编写了评估集,用一个在低于阈值时以非零状态退出的脚本对其评分,在多次重复运行中测量了你自己的下限,并将该任务设为必需检查。固定的评估集、经过测量的阈值和作业级触发器,为你的提示词和智能体逻辑提供了与单元测试为代码提供的相同保护。要将门禁扩展到调用工具的智能体,请从Ori Eval 文档开始。
参考资料
来源:OpenRouter Blog · openrouter.ai