在 CI 中用 LLM 評估門檻阻止 PR 合併
How to Gate Pull Requests on LLM Evals in CI
這篇指南說明如何在 CI 流程中使用固定 LLM 評估集,將合併門檻設為評分閾值,防止錯誤代理程式被合併。
- 評估集:將測試案例以 JSON 形式提交至 repo,僅透過審核 PR 變更。
此指南示範如何在 CI 中用固定 eval 集阻止錯誤代理合併,並說明成本與閾值設定。
只改一行支援代理的系統提示,就可能推出一個告訴客戶退款期限為 30 天,而實際政策是 14 天的代理。正常的 CI 管道不會檢查模型所說的內容,因此建置會通過,第一個看到錯誤答案的人就是客戶。
以固定評估集合為條件限制 Pull Request 的流程,與限制失敗的單元測試方式相同。你將測試案例儲存在倉庫中,當提示變更時執行它們,並在失敗案例過多時阻止合併。
在本指南中,你將為支援代理撰寫評估集合,並使用呼叫 OpenRouter 的指令碼進行評分。你測量在未改動任何內容時各次執行結果的波動幅度,接著將指令碼接入 GitHub Actions 作為必須通過的狀態檢查。
簡而言之
- 固定評估集合是提交至倉庫的一組測試案例。它僅能透過已審核的 Pull Request 進行變更。
- 請在工作(job)層級而非工作流程(workflow)層級進行過濾。GitHub 會將因
if條件被跳過的工作視為通過檢查,而由路徑過濾器跳過的工作流程則會留下待定的必須檢查,並阻止合併。 - 當通過率低於閾值時,評估指令碼會以非零退出碼結束,該退出碼即為失敗工作。
- 以對未變更分支進行多次執行來測量閾值,而不是直接選擇一個嚴格數值。
temperature與seed僅對在supported_parameters中列出的模型有效。使用多次抽樣並採取多數投票的方式,對所有模型皆適用。

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 將自動執行評估集合,若分數下降則無法合併。
- 僅在可能破壞代理的變更上觸發評估。
- 將評估集合放入倉庫,與其所測試的提示同一目錄。
- 撰寫 CI 工作將執行的指令碼。
- 測量決定是否阻止合併的閾值。
步驟 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.9if: 行讀取 changes 工作的輸出,並阻止評估在無法破壞代理的 pull request 上執行。timeout-minutes: 15 阻止卡住的供應商為 default six hours 持有執行器。每個動作都被釘到完整的提交 SHA,並在尾註中標註相對應的版本。像 v4 這樣的標籤可移動至新程式碼,而此工作保留你的 API 金鑰,因此釘到 SHA 意味著執行的程式碼無法在未更改倉庫的情況下改變。GitHub’s security hardening guide 建議同樣做法。
在門檻能阻止任何操作之前,請完成三件事。
- 將你的金鑰作為儲存庫機密新增到 Settings > Secrets and variables > Actions,名稱為
OPENROUTER_API_KEY。 - 開啟一個觸及
prompts/的 pull request,以便工作流程執行一次。 - 將
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.mdORI_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 Eval | ori eval 在工作中,評估失敗時以非零退出 | 低,因為評估檔案保留於您的倉庫,並可針對目錄中的任何模型執行 | 工具呼叫代理程式,或在您自己的代理程式上比較模型 |
| DeepEval | deepeval test run 在 pytest 下執行,assert_test() 在每項指標閾值以下時觸發 | 低,因為評分庫為開源 | 現成的指標,例如答案相關度與任務完成度 |
| Braintrust | 一個已釋出的 GitHub Action,可執行評估並在 pull request 上發布摘要評論 | 中等,因為評分歷史存於他們的平臺 | 評分變化在審查中顯示,而非 CI 日誌 |
| Arize | client.experiments.run() 以 SDK 作為純 Python 步驟,並附有其檔案中的範例工作流程 | 中等,因為實驗 API 為他們的 | 已使用 Arize 進行可觀測性的倉庫 |
| Galileo | run_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