跳到正文
openrouter blog·· 1 天前精選AI 評分67

在 CI 中用 LLM 評估門檻阻止 PR 合併

How to Gate Pull Requests on LLM Evals in CI

AI 導讀

這篇指南說明如何在 CI 流程中使用固定 LLM 評估集,將合併門檻設為評分閾值,防止錯誤代理程式被合併。

  • 評估集:將測試案例以 JSON 形式提交至 repo,僅透過審核 PR 變更。
推薦理由

此指南示範如何在 CI 中用固定 eval 集阻止錯誤代理合併,並說明成本與閾值設定。

正文 · AI 翻譯

只改一行支援代理的系統提示,就可能推出一個告訴客戶退款期限為 30 天,而實際政策是 14 天的代理。正常的 CI 管道不會檢查模型所說的內容,因此建置會通過,第一個看到錯誤答案的人就是客戶。

以固定評估集合為條件限制 Pull Request 的流程,與限制失敗的單元測試方式相同。你將測試案例儲存在倉庫中,當提示變更時執行它們,並在失敗案例過多時阻止合併。

在本指南中,你將為支援代理撰寫評估集合,並使用呼叫 OpenRouter 的指令碼進行評分。你測量在未改動任何內容時各次執行結果的波動幅度,接著將指令碼接入 GitHub Actions 作為必須通過的狀態檢查。

簡而言之

  • 固定評估集合是提交至倉庫的一組測試案例。它僅能透過已審核的 Pull Request 進行變更。
  • 請在工作(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

LLM 評估在 CI 中的含義

評估是一個針對代理的測試案例。它包含輸入以及判斷模型答案是否可接受的規則。固定評估集合是提交至倉庫的一系列案例,僅能透過已審核的 Pull Request 進行變更。流水線部分即為你的 CI 設定,決定何時執行案例以及在失敗案例過多時對 Pull Request 進行何種處理。

判斷模型答案是否良好是另一個問題,一種選項是將其交給另一個擔任評分者的模型。這種技術稱為 LLM-as-a-judge,我們在LLM-as-a-judge 指南中進行說明。工具呼叫迴圈指南則涵蓋建立代理本身。本指南聚焦於兩者之間的流水線。

在你能設定任何門檻之前需要準備的事項

在門檻能提供有用資訊之前,必須先準備好三項要素。

  • 一個固定且版本化的評估集合。 Anthropic 的代理評估指南建議以 20 至 50 個來自真實失敗的簡單任務 作為起始集合。我們這裡只使用三項,以保持示例簡短。
  • 評分方法與閾值。 評分方法將單一答案轉換為可計數的通過或失敗。閾值適用於整個執行。本指南使用字串斷言;評分規準或評分模型亦可使用。
  • 執行結果足夠可重複以信賴。 門檻應阻止回歸,而非噪音。多次抽樣並採取多數投票適用於所有模型。temperature 與 seed 只對列出它們的模型有效,使用前請先檢查 模型的 supported_parameters。

你還需要一個以 OPENROUTER_API_KEY 匯出的 OpenRouter API 金鑰、Node 20 或更新版本,以及 jq。

以四個步驟構建門檻

完成這四個步驟後,任何觸及提示的 Pull Request 將自動執行評估集合,若分數下降則無法合併。

  1. 僅在可能破壞代理的變更上觸發評估。
  2. 將評估集合放入倉庫,與其所測試的提示同一目錄。
  3. 撰寫 CI 工作將執行的指令碼。
  4. 測量決定是否阻止合併的閾值。

步驟 1:僅在相關變更時觸發

在提示、代理邏輯、工具模式、評估集、評估指令碼或工作流程本身變更時執行評估。最後兩項很容易忽略。如果它們未包含在篩選條件中,破壞計分邏輯或編輯閘道的拉取請求會跳過評估並未測試就合併。

你可以用兩個工作流程完成。第一個工作永遠執行。它將拉取請求更改的檔案與一個路徑清單比對,並根據是否有匹配輸出 true 或 false。第二個工作執行評估,僅在第一個工作返回 true 時啟動。

以 .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 會在有人再次推送到同一分支時取消先前的執行,因此連續多次推送的拉取請求不會為每一次都執行評估。

這裡可能出現兩個問題。

第一個問題是有綠勾但沒有背後的評估。當第一個工作返回 false 時,GitHub 會跳過評估工作,而 跳過的工作也會滿足必須的狀態檢查。這就是讓不相關的拉取請求能合併的原因。它也意味著打錯的 glob 會通過檢查而不執行任何案例,且 changes 工作直接失敗也有相同效果,因為 GitHub 會跳過依賴失敗的 needs 工作。步驟 3 通過將 changes 也設為必需檢查來關閉第二個漏洞。對於第一個,第一次提示變更通過時開啟執行日誌並確認評估已執行。

第二個問題是你放置過濾器的位置。不要將其移到工作流程級別作為 on.pull_request.paths。GitHub 關於 跳過工作流程執行 的檔案指出,當工作流程因路徑過濾而被跳過時,“與該工作流程相關聯的檢查將保持在‘待處理’狀態”,而需要這些檢查的拉取請求將被阻止合併。

步驟 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 規則後,並在分支保護規則中啟用「要求 Code Owner 審核」。否則,最簡單的方式突破失敗的閘道是放寬捕捉回歸的測試。單獨的 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" },
  },
});

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 為 false,導致零評估通過門檻。--threshold 為 90% 或負數 --samples 也會以同樣方式通過,而空白 --threshold 變為 0,任何通過率都無法低於此值,因此檢查也會拒絕 0。

它會多次執行每個案例,且每一次執行都算作一個樣本。它會取樣本中的多數裁決,因為相同提示不一定產生相同答案。

它還將每個請求路由到單一供應商。像 anthropic/claude-sonnet-5 這樣的模型識別符號會透過 OpenRouter 由多個供應商提供。撰寫時,其端點包括 Anthropic、Amazon Bedrock、Azure 與 Google。若沒有路由偏好,同一評估的兩次執行可能會到達不同供應商,答案差異會被誤認為提示回歸。provider.order 是供應商識別符號的優先順序列表,allow_fallbacks: false 告訴我們不要嘗試列表外的任何供應商。若列出的供應商失敗,請求會直接失敗而非切換到備援,指令碼會以 2 結束。provider selection docs 覆蓋這兩個欄位。pin 會與你所命名的模型繫結。若將 --model 改為其他供應商的模型,亦須改變 order 值,否則沒有供應商會匹配,所有請求都會失敗。

最後一行顯示的成本來自我們在每個非串流回應中包含的 usage 物件。usage accounting docs 描述了這些欄位。

先在本機執行。

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 工作的輸出,並阻止評估在無法破壞代理的 pull request 上執行。timeout-minutes: 15 阻止卡住的供應商為 default six hours 持有執行器。每個動作都被釘到完整的提交 SHA,並在尾註中標註相對應的版本。像 v4 這樣的標籤可移動至新程式碼,而此工作保留你的 API 金鑰,因此釘到 SHA 意味著執行的程式碼無法在未更改倉庫的情況下改變。GitHub’s security hardening guide 建議同樣做法。

在門檻能阻止任何操作之前,請完成三件事。

  1. 將你的金鑰作為儲存庫機密新增到 Settings > Secrets and variables > Actions,名稱為 OPENROUTER_API_KEY。
  2. 開啟一個觸及 prompts/ 的 pull request,以便工作流程執行一次。
  3. 將 changes 和 eval-gate 兩者作為 required status checks 新增到你的分支保護規則。

第三步將評估轉變為門檻。若沒有它,失敗的評估會在 pull request 上顯示紅叉,但 pull request 仍可合併。將 changes 也設為必須,可覆蓋篩選工作本身失敗的情況,例如檢出錯誤。GitHub 會跳過 eval-gate,而跳過的工作算作通過,因此僅要求 eval-gate 時,提示變更可能在未評估的情況下合併。若同時要求 changes,失敗的篩選工作會阻止合併。與此無關的 pull request 仍可合併,因為 changes 會通過且 eval-gate 被跳過。

除了 GITHUB_TOKEN,GitHub 不會將機密傳遞給由 forked repository 觸發的工作流程,因此來自 fork 的 pull request 會因缺少 key 而失敗。如果你的 repository 接受 fork 貢獻,請在 push 到 main 時也執行 eval,以確保透過 fork 進來的更改仍會被檢查。若你需要記錄 merge-blocking 執行的檢查專案,請將執行輸出上傳為工作流程 artefact。

現在閘門會在每一次可能破壞代理的 Pull Request 上執行。它仍然使用一個沒有人證實的閾值,因此第 4 步進行測量。

第 4 步:測量閾值

將 eval 集合在你未變更的 main 分支上執行六次。執行之間不要改動任何東西。記錄下你看到的最低成功率。你想要的是最差的執行,而非典型的執行,因此如果你能負擔,請多執行幾次。那最低成功率就是你的底線,你可以將閾值設定在底線之上或等於它。

有三個案例時,其中一個案例在六次執行中失敗,會得到 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 migration guide 表示 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 提升至底線不再變動。三是一個合理的預設值,已經比單次執行多三倍成本,故在提升至五之前先做測量。

小規模集合有一個值得了解的特性。以三個案例為例,成功率只能是 0、0.33、0.67 或 1.00,因此 0.9 的閾值意味著所有三個案例都必須通過。這也是將集合擴大到 20 或更多案例的另一個理由。

當 Pull Request 只因一個案例失敗而被閘門阻擋時,對 main 執行相同的 eval。若 main 也失敗,則失敗為噪音,你需要更多樣本或降低閾值。若 main 通過,則將失敗視為 Pull Request 的回歸。

這就是完整的閘門。其餘本指南說明在字串檢查不再足夠時該怎麼做,以及何時值得將簡單指令碼換成能儲存執行歷史的平臺。

測試一個使用 Ori Eval 呼叫工具的代理

上述指令碼只送出一則訊息並讀取一次回覆。若您的代理程式會呼叫工具,這樣還不夠。僅以字串檢查無法判斷代理程式是否呼叫了正確的工具,或是否呼叫了應該避免的昂貴工具。

Ori Eval 是我們為代理程式設計的評估工具。工具是執行評估的程式。它會執行代理程式、記錄代理程式的行為,並將結果與您的斷言做比對。Ori 評估檔案為 .eval.ts 檔,斷言則關於代理程式的行為。

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(...) 是純指令碼無法完成的部分。assertModelIsLive 若 slug 離開目錄,會以明確訊息失敗執行,讓檔案直接失敗,而非測試已不存在的模型。於讓判斷器失敗建置前,請先自行評分一批相同執行並將判斷與判斷器結果做比較。Anthropic 的指南建議將 LLM 評分器校準為人類專家,原因相同。

Ori 以 Bun 執行評估檔案,且當 CI 為真時不會為您安裝 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.md

ORI_RELEASE 與 ORI_SHA256 分別是目前穩定版及其 ori-linux-x64 摘要。當您升級至新版時,請同步更新兩者。從同一版頁面下載檢查碼檔案不會帶來任何安全性,因為任何能取代二進位檔的人亦可取代旁邊的檢查碼。將摘要保留於工作流程中,能確保二進位檔變更時會失敗 sha256sum 檢查。

ori eval 會搜尋目前目錄下的每個 *.eval.ts 檔並將其交給 bun test。其退出碼為 bun test 的退出碼,因此評估失敗會使工作失敗。--report 產生一份 Markdown 報告,您可以將其上傳為 artefact 或附加至工作摘要。

每一次 Ori 執行都會向真實模型傳送請求並產生費用,請將這些評估排除於每次提交時執行的工作之外。Ori Eval 文件說明瞭如何從人員啟動的工作或排程中執行它們。

將純指令碼與評估平臺做比較

上述指令碼即為完整的評估閘門。平臺則提供儀錶板、可繪圖的執行歷史,並讓非工程人員在不開啟 CI 日誌的情況下閱讀結果。

方法在 CI 中的執行方式鎖定程度最佳適配
純指令碼您自行編寫 CI 步驟與退出碼邏輯無,因為這是您的程式碼一至兩套評估,完全掌控評分
Ori Evalori eval 在工作中,評估失敗時以非零退出低,因為評估檔案保留於您的倉庫,並可針對目錄中的任何模型執行工具呼叫代理程式,或在您自己的代理程式上比較模型
DeepEvaldeepeval test run 在 pytest 下執行,assert_test() 在每項指標閾值以下時觸發低,因為評分庫為開源現成的指標,例如答案相關度與任務完成度
Braintrust一個已釋出的 GitHub Action,可執行評估並在 pull request 上發布摘要評論中等,因為評分歷史存於他們的平臺評分變化在審查中顯示,而非 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 工作項中執行,並以閾值評分輸出,將該工作項及其路徑過濾工作項設定為分支保護中的必須狀態檢查。工作項的退出碼決定結果,與失敗的單元測試方式相同。

什麼是固定評估集,為什麼它需要與程式碼保持版本化?

固定評估集是一份已檢入倉庫的測試輸入與評分標準清單,僅能透過審核過的拉取請求進行更改。若它位於倉庫之外,將會與提示失去同步,並停止測試已釋出的內容。

可以根據評估分數阻止拉取請求合併嗎?

可以。只要有一個在閾值以下時非零退出的純指令碼即可,只要執行它的工作項是必須的狀態檢查。將提示與評估集置於 CODEOWNERS 規則下,並啟用「要求 Code Owners 審核」,這樣削弱捕捉回歸的測試也需要審核。

LLM-as-a-judge 與在 CI 中執行評估的差別是什麼?

LLM-as-a-judge 是針對一次執行的評分方法,使用第二個模型對答案進行評分。將評估執行於 CI 是任何評分方法的流程管道。它決定何時執行案例、針對哪個固定集合,以及當分數低時拉取請求會發生什麼。

評估需要在每一次提交時執行,還是僅在提示與代理邏輯變更時執行?

僅在觸及提示、代理邏輯、工具模式或評估集的變更時執行。於工作項層級進行過濾,而非工作流程層級。GitHub 將因 if 條件被跳過的工作項視為通過檢查,而因路徑過濾被跳過的工作流程會留下待定的必須檢查並阻止合併。

在每個拉取請求上執行 LLM 評估,會以 token 與 CI 分鐘計算多少費用?

成本等於案例數量乘以每個案例的樣本數,再乘以相關 pull request 被開啟的頻率。此指南中的指令碼會從 API 回應的 usage 欄位印出每一次執行的成本,這樣你就能測量自己的設定。當前模型價格請參考 pricing page。

哪些工具支援在合併前以固定評估集來門控拉取請求?

僅使用一個帶閾值檢查的簡單指令碼就足夠。Ori Eval、DeepEval、Braintrust、Arize 與 Galileo 在相同的退出碼模式上加入報告、執行歷史或特定代理的斷言。

如何處理不穩定或非確定性的評估阻礙良好拉取請求?

先檢查你自己的斷言,因為模型在改寫單一文字字串時常會出錯。接著多次抽樣每個案例並採取多數決。先在 OpenRouter models endpoint 檢視模型的 supported_parameters,再決定是否依賴 temperature 或 seed,因為未列出的模型會忽略它們。

LLM‑as‑a‑judge 是否足夠可靠來失敗建置?

如果你測量判斷者而非假設它,它可以做到。自行評分一批執行結果,並將你的裁決與判斷者比對,將判斷者與簡單斷言配合,確保建置永不因單一未驗證的模型呼叫失敗。

結論

在本指南中,你為支援代理撰寫了一套 eval set,並用一個在低於閾值時非零退出的指令碼進行評分,透過多次執行測量自己的基準,並將該工作設為必須通過的檢查。固定的 eval set、測量得到的閾值以及工作層級的觸發器,能為你的提示與代理邏輯提供與單元測試對程式碼相同的保護。若想將此門檻擴充套件至使用工具的代理,請從 Ori Eval documentation 開始。

參考資料

來源:openrouter blog · openrouter.ai