跳到正文
原文
OpenRouter:Blog(Announcements)·· 2026-10-01精选AI 评分63

OpenRouter 教程:如何在 CI 中用 LLM 评测拦截 Pull Request 合并

How to Gate Pull Requests on LLM Evals in CI

文章要点
  • 1OpenRouter 发布教程讲解在 CI 中用固定评测集测试提示词,低于阈值即阻止 PR 合并
  • 2路径过滤须设在 job 层而非 workflow 层,否则必需检查停在 Pending 状态阻塞合并
  • 3脚本对每例多次采样取多数判定,并把请求固定单一服务商,避免把服务商差异误判成回归
  • 4阈值应取自未改动分支多次重复运行的最低通过率,而非凭空挑选严格数字
决策要点

给提示词加 CI 门禁的技术门槛已经很低,真正的难点是把模型非确定性噪声与真实回归区分开。

  • 影响:已有智能体上线的团队可给提示词和评测集建立与单元测试同级的合并防线,工具本身零依赖;每次门禁按用例数、采样数和相关 PR 频率产生 API 费用与 CI 时长;评测集若不纳入代码评审,放宽测试会成为绕过门禁的最快路径。
  • 参考:可考虑先选一个提示词固定的智能体场景,用教程脚本起步,并在未改动分支重复运行多次测出通过率下限后再定阈值;值得在提高采样数或扩充用例前先读脚本打印的单次成本;同时给提示词和评测集目录配置 CODEOWNERS 强制评审。
  • 依据:教程为 OpenRouter 官方博客内容,附完整脚本与工作流配置;445 与 267 的模型统计为 2026 年 9 月 18 日官方目录数据;阈值与用例规模建议引自 Anthropic 智能体评测指南,暂无独立验证。
正文 · AI 翻译

在客服智能体的系统提示词里改动一行,就可能上线一个告诉客户退款窗口为 30 天的智能体,而你的政策写的明明是 14 天。常规的 CI 流水线中没有任何环节会检查模型说了什么,因此构建照常通过,而第一个看到错误答案的人就是客户。

用固定的评估集来把关拉取请求(pull request),其工作方式与用失败的单元测试把关完全相同。你把测试用例保存在仓库里,当提示词发生变化时运行它们,并在过多用例失败时阻止合并。

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

太长不看版

  • 固定评估集是提交到你仓库中的一份测试用例列表。它只能通过经过审查的拉取请求来修改。
  • 在作业(job)级别而非工作流(workflow)级别进行过滤。GitHub 会把被 if 条件跳过的作业报告为通过的检查,而被路径过滤器跳过的工作流会让必需检查一直处于等待状态,从而阻塞合并。
  • 当通过率低于你的阈值时,评估脚本会以非零值退出,正是这个退出码导致作业失败。
  • 阈值应通过对未改动分支的多次重复运行来测量得出,而不是直接选定一个严格的数字。
  • 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 评估是什么意思

评估(eval)是智能体的一个测试用例。它包含一个输入,以及一条用于判断模型回答是否可接受的规则。固定评估集是提交到你仓库中的这样一组用例的列表,它只能通过经过审查的拉取请求来修改。流水线部分指的是你的 CI 配置,它决定这些用例何时运行,以及在过多用例失败时对拉取请求作何处理。

判断模型的回答是否合格是另一个问题,一种做法是把它交给另一个充当评分员的模型。这种技术称为 LLM-as-a-judge(以大语言模型为裁判),我们在我们的 LLM-as-a-judge 指南中介绍过它。我们的工具调用循环指南则介绍如何构建智能体本身。本指南讲的是两者之间的流水线。

开始把关之前需要准备什么

在这个关卡能给你提供任何有用信息之前,需要先备齐三样东西。

  • 固定且纳入版本管理的评估集。Anthropic 的智能体评估指南建议以从真实失败案例中提取的 20 到 50 个简单任务作为起步集合。这里我们只使用三个,以便示例保持简短。
  • 一种评分方法和一个阈值。评分方法把单个回答转换成可统计的通过或失败。阈值则作用于整次运行。本指南使用字符串断言。评分细则(rubric)或裁判模型同样可行。
  • 足够可复现、值得信赖的运行结果。关卡应该在出现真正的性能退化时拦截,而不是被随机噪声触发。带多数投票的重复采样适用于所有模型。temperature 和 seed 只对列出这些特性的模型有效,所以在依赖它们之前,请先查看该模型的 supported_parameters。

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

分四步构建这个关卡

完成这四步之后,任何涉及提示词的拉取请求都会自动运行你的评估集,并且如果分数下降就无法合并。

  1. 只在可能破坏智能体的改动上触发评估。
  2. 把评估集放进仓库,与它所测试的提示词放在一起。
  3. 编写 CI 作业所运行的脚本。
  4. 衡量决定合并是否被阻止的阈值。

步骤 1:仅在相关变更时触发

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

你需要用两个 job 来实现。第一个始终运行,它把 pull request 修改的文件与一个路径列表进行比对,并根据是否有匹配输出 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'

concurrency 搭配 cancel-in-progress 会在有人再次向同一分支推送时取消上一次运行,这样连续推送多次的 pull request 就不必为每次推送都支付一次评测运行的费用。

这里可能出两个问题。

第一个是背后没有任何评测的绿色对勾。当第一个 job 返回 false 时,GitHub 会跳过评测 job,而 被跳过的 job 也能满足必需的状态检查。正是这一点让无关的 pull request 得以合并。这也意味着一个写错的 glob 能在不运行任何用例的情况下通过检查,而一个彻底失败的 changes job 也会产生同样的效果,因为 GitHub 会跳过 needs 依赖失败的 job。步骤 3 通过把 changes 也设为必需检查来堵住第二个漏洞。对于第一个问题,在提示词变更首次通过时打开运行日志,确认评测确实运行了。

第二个问题在于过滤器的放置位置。不要把它上移到工作流级别作为 on.pull_request.paths。GitHub 关于 跳过工作流运行 的文档指出,当工作流因路径过滤而被跳过时,“与该工作流关联的检查将保持‘Pending’状态”,需要这些检查的 pull request 将被阻止合并。

步骤 2:将评测集放进仓库

把评测集放在与它所测试的提示词相同的仓库里。当有人编辑某个提示词时,对应的测试也会在同一个 pull request 中修改,同一个审阅者就能同时看到两者。

四个文件并排放在一起。

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);
}

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

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

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

它还会把每个请求路由到同一个提供商。像 anthropic/claude-sonnet-5 这样的模型 slug 会通过 OpenRouter 由多个提供商提供服务。截至撰写本文时,它的端点包括 Anthropic、Amazon Bedrock、Azure 和 Google。如果没有路由偏好,同一个 eval 的两次运行可能到达两个不同的提供商,而它们答案之间的差异看起来就像一次提示词回归。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。

现在把第二个 job 添加到 .github/workflows/eval-gate.yml 中,放在 changes job 的下方。

  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.9

if: 这一行读取 changes job 的输出,正是它阻止 eval 在那些不可能破坏 agent 的 pull request 上运行。timeout-minutes: 15 防止挂起的提供商把 runner 占用默认的六个小时。每个 action 都固定到一个完整的 commit SHA,并在尾随注释中标注对应的发布版本。像 v4 这样的 tag 可以被移动以指向新代码,而这个 job 持有你的 API key,因此固定到 SHA 意味着只要你的仓库不变,所运行的代码就不会改变。GitHub 的安全加固指南也建议这样做。

在门禁能够阻止任何东西之前,先做三件事。

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

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

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

这个门禁现在会在每一个可能破坏 agent 的 pull request 上运行。它使用的阈值仍无人论证过,因此第 4 步将测量一个。

第 4 步:测量阈值

针对你未更改的 main 分支运行 eval 集合六次。两次运行之间不要更改任何内容。记下你看到的最低通过率。你要的是最差的一次运行而非典型的那次,因此如果负担得起,可以多运行几次这个集合。这个最低通过率就是你的下限,你应将阈值设为等于或低于它。

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

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

首先,检查你自己的断言。上面的 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 时,不支持请求中所有参数的 provider 仍然可以接收请求,并忽略它不认识的参数。使用 require_parameters: true 时,请求只会被路由到支持所有这些参数的 provider。

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

小集合有一个值得了解的特性。对于三个用例,通过率只能是 0、0.33、0.67 或 1.00,因此 0.9 的阈值意味着所有三个用例都必须通过。这也是将集合扩充到 20 个或更多用例的另一个理由。

当 pull request 因单个用例未通过门禁时,针对 main 运行相同的 eval。如果 main 也失败,那么该失败就是噪声,你需要更多样本或更低的阈值。如果 main 通过,则将该失败视为 pull request 中的回归。

这就是完整的门禁。本指南的其余部分将介绍:当字符串检查不再够用时该怎么做,以及什么时候值得用存储运行历史的平台来替换普通脚本。

使用 Ori Eval 测试调用工具的 agent

上面的脚本会发送一条消息并读取一条回复。如果你的 agent 会调用工具,这还不够。字符串检查无法告诉你 agent 是否调用了正确的工具,或者是否调用了本应避开的昂贵工具。

Ori Eval 是我们面向 agent 的评测框架。框架(harness)是运行你的评测的程序。它运行 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 会以明确的报错信息使运行失败,因此文件会显式报错失败,而不是去测试一个已不存在的模型。在让 judge 使构建失败之前,先自己对同一批运行中的样本进行评分,并将你的判断与 judge 的判断进行比较。出于同样的原因,Anthropic 的指南建议对照人类专家校准 LLM 评分器。

Ori 使用 Bun 运行评测文件,当 CI 为 true 时它不会替你安装 Bun。在 workflow 中,你需要安装 Bun,然后下载固定版本的 Ori 发布包并在运行前验证其校验和,因为该 job 持有你的 API key。设置 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.md

ORI_RELEASE 和 ORI_SHA256 是撰写本文时的稳定版发布及其 ori-linux-x64 摘要的名称。迁移到更新的发布版本时,要同时更新两者。从同一个发布页面下载校验和文件不会带来任何额外保障,因为任何能替换二进制文件的人也能替换它旁边的校验和。把摘要保存在你的 workflow 中意味着二进制文件一旦被改动,sha256sum 检查就会失败。

ori eval 会找到当前目录下的所有 *.eval.ts 文件并交给 bun test 处理。它的退出码就是 bun test 的退出码,因此评测失败会导致 job 失败。--report 会生成一份 Markdown 报告,你可以将其作为 artifact 上传,或附加到 job 摘要中。

每一次 Ori 运行都会向真实模型发送请求并产生费用,因此不要把这些评测放进每次提交都会运行的 job 里。Ori Eval 文档介绍了如何从人工启动的 job 或按计划运行这些评测。

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

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

方案在 CI 中的运行方式锁定程度最适用场景
普通脚本自己编写 CI 步骤和退出码逻辑无,因为代码是你自己的一到两个评测集,可完全控制评分方式
Ori Eval在 job 中运行 ori eval,评测失败时以非零码退出低,因为评测文件保留在你的仓库中,且可针对目录中的任何模型运行调用工具的 agent,或在自己的 agent 上比较模型
DeepEval在 pytest 下运行 deepeval test run,低于各指标阈值时由 assert_test() 抛出异常低,因为评分库是开源的现成的指标,例如答案相关性和任务完成度
Braintrust一个已发布的 GitHub Action,会运行评测并在 pull request 上发表摘要评论中等,因为评分历史保存在他们的平台上分数变化在审查中呈现,而非出现在 CI 日志里
Arize通过 SDK 以普通 Python 步骤调用 client.experiments.run(),其文档中提供了示例 workflow中等,因为实验 API 属于他们的平台已经在使用 Arize 做可观测性的仓库
Galileo通过 SDK 调用 run_experiment,多轮 agent 则使用 create_experiment中等,因为指标和历史记录都是平台原生的评测历史之上的托管仪表盘

从脚本开始。无论如何,你的评估集和评分逻辑都保留在你自己的仓库中,之后你随时可以将某个平台指向它们。离开一个平台则意味着要移植针对其 SDK 编写的评分逻辑,并丢失存储在该平台上的运行历史。

常见的失败模式

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

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

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

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

常见问题

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

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

什么是固定评估集?为什么它需要与代码一起进行版本管理?

固定评估集是一份签入仓库的测试输入和评分标准列表,只能通过经过审查的拉取请求来修改。如果它存放在仓库之外,就会与提示词逐渐失去同步,不再测试实际发布的内容。

能否基于评估分数阻止拉取请求合并?

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

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

LLM-as-a-judge 是针对单次运行的评分方法,由第二个模型给答案打分。而在 CI 中运行评估则是围绕任何评分方法构建的流水线。它决定用例何时运行、针对哪个固定集合运行,以及分数过低时对拉取请求作何处理。

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

只在涉及提示词、智能体逻辑、工具 schema 或评估集的更改时运行。要在作业级别而非工作流级别进行过滤。GitHub 会把被 if 条件跳过的作业报告为通过的检查,而被路径过滤器跳过的工作流则会让必需的检查一直处于挂起状态并阻止合并。

在每个 PR 上运行 LLM 评估要消耗多少 token 和 CI 分钟数?

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

有哪些工具支持在合并前用固定的评测集对 PR 进行门禁?

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

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

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

LLM-as-a-judge(LLM 即评判者)是否足够可靠,可以据此让构建失败?

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

结论

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

参考资料

来源:OpenRouter:Blog(Announcements) · openrouter.ai