跳到正文
原文
OpenRouter:Announcements(RSS)·· 2 小时前精选AI 评分67

OpenRouter 教程:如何在 CI 中用 LLM eval 门禁拦截 Pull Request

How to Gate Pull Requests on LLM Evals in CI

AI 导读

OpenRouter 发布教程,讲解如何用固定的 eval 集在 CI 中门禁 pull request,当通过率低于阈值时脚本以非零退出码阻止合并,做法与单元测试门禁一致。

推荐理由

原文给出了从脚本到 GitHub Actions 的完整可复现流程,并强调了路径过滤须放 job 层、阈值应实测等易错点。

正文 · AI 翻译

在支持代理的系统提示中改一行,就可能发布一个告诉客户退款窗口是 30 天的代理,而你的政策规定是 14 天。正常的 CI 流水线不会检查模型说了什么,所以构建通过,第一个看到错误答案的人是客户。

用固定的评估集来门控拉取请求,就像用失败的单元测试来门控一样。你把测试用例保存在仓库中,在提示更改时运行它们,并在太多失败时阻止合并。

在本指南中,你为支持代理编写一个评估集,并用一个调用 OpenRouter 的脚本来评分。你测量在没有任何更改的情况下多次运行之间结果的波动程度,然后将该脚本接入 GitHub Actions 作为必需的状态检查。

简而言之

  • 固定的评估集是提交到仓库中的测试用例列表。它仅通过经过审查的拉取请求进行更改。
  • 在作业级别而不是工作流级别进行过滤。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 评估意味着什么

评估是代理的测试用例。它有一个输入和一个用于判断模型答案是否可接受的规则。固定的评估集是提交到仓库中的这些用例的列表,并且仅通过经过审查的拉取请求进行更改。流水线部分是你的 CI 设置。它决定用例何时运行,以及当太多用例失败时拉取请求会发生什么。

判断模型答案是否良好是一个单独的问题,一种选择是将其交给另一个充当评分者的模型。这种技术称为 LLM-as-a-judge,我们在 我们的 LLM-as-a-judge 指南 中介绍了它。我们的工具调用循环指南 涵盖了构建代理本身。本指南是关于它们之间的流水线。

在你能门控任何东西之前需要什么

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

  • 一个固定的、版本化的评估集。 Anthropic 的代理评估指南建议将 从真实失败中提取的 20 到 50 个简单任务 作为起始集。我们这里使用三个,以便示例保持简短。
  • 一种评分方法和一个阈值。 评分方法将一个答案转化为你可以计数的通过或失败。阈值适用于整个运行。本指南使用字符串断言。评分标准或评判模型也可以。
  • 运行足够可重复以值得信赖。 门控应基于回归而不是噪声进行阻止。使用多数投票的重复采样适用于所有模型。temperature 和 seed 仅在列出它们的模型上有帮助,因此在依赖它们之前请检查 模型的 supported_parameters。

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

分四步构建门控

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

  1. 仅在可能破坏你的代理的更改上触发评估。
  2. 将评估集放在仓库中,与其测试的提示放在一起。
  3. 编写 CI 作业运行的脚本。
  4. 测量决定合并是否被阻止的阈值。

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

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

你可以用两个 job 来实现。第一个 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 中的条目也可以是一个列表,如第三个用例那样,此时答案只需包含其中的一个字符串即可。当模型在你期望“human”的地方写成“a person”或“our team”时,单个字面字符串就会失败,因此在有多种措辞可接受的地方请使用列表。

[
  {
    "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.

从三个用例开始。每当 agent 在生产环境中出错时,就添加一个。

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

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

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

  1. 在 Settings > Secrets and variables > Actions 下将你的密钥添加为仓库机密,命名为 OPENROUTER_API_KEY。
  2. 打开一个触及 prompts/ 的拉取请求,以便工作流运行一次。
  3. 在你的分支保护规则中将 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 的 eval 测试框架。测试框架是运行 eval 的程序。它运行 agent,记录 agent 做了什么,并根据你的断言检查结果。Ori eval 是 .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 运行 eval 文件,当 CI 为 true 时,它不会为你安装 Bun。在工作流中,你先安装 Bun,然后下载一个固定版本的 Ori 发布包并在运行前验证其校验和,因为该任务持有你的 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 摘要。当你迁移到更新的版本时,请同时更新两者。从同一个发布页面下载校验和文件不会增加任何东西,因为任何能替换二进制文件的人都能替换它旁边的校验和。将摘要保留在工作流中意味着二进制文件一旦改变,sha256sum 检查就会失败。

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

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

比较普通脚本与 eval 平台

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

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

从脚本开始。无论哪种方式,你的评估集和评分逻辑都留在你的仓库里,之后你可以让平台指向它们。离开平台意味着要移植针对其 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:Announcements(RSS) · openrouter.ai