OpenRouter 教程:提示词或模型变更后如何对 AI Agent 做回归测试
AI Agent Regression Testing After a Prompt or Model Change
OpenRouter 发布 AI Agent 回归测试教程:每次提示词、模型、工具定义或检索设置变更后,重跑锁定的用例集并对照书面行为契约检查。
原文给出可复用的 Agent 回归测试方法,包括锁定用例集、行为契约、具体模型 slug 固定和模型切换对比的完整做法。
A ~author/family-latest 别名始终解析为某个系列中最新的具体模型。这在生产环境中很方便,但在回归测试中却是个问题,因为模型可能在你的仓库没有任何变更的情况下在两次运行之间发生变化。我们的最新模型解析文档描述了该机制,并建议在你需要固定版本以保证可复现性时使用具体的模型 slug。本指南涵盖锁定用例集和行为契约,然后详细讨论模型替换的情况。
简而言之
- 对 agent 进行回归测试意味着每次提示词、模型、工具定义或检索设置发生变化时,都要重新运行一组锁定的用例,然后将结果与书面的行为契约进行比对。
- 每个用例都带有一个关于 agent 调用了哪些工具、使用了哪些参数的结构化断言,并且在存在策略的情况下,还带有一个 agent 绝不能违反的硬性不变量。
- 锁定用例集。每次你改写一个用例,都会破坏它与之前所有运行的可比性。
- 对于模型替换,保持提示词、工具、用例、评判器和推理参数不变,只改变模型。使用具体的 slug,例如
anthropic/claude-fable-5.1,而不是解析为最近发布版本的别名。 - 先读基线列,再读候选列。一个在两边都失败的用例意味着测试本身有问题。一个只在候选上被违反的硬性不变量应当阻止发布。
- Ori Eval 通过诸如
run.tool('escalate_to_human').toBeCalled()之类的工具调用断言以及用于开放式答案的 LLM 评判器来支持这一工作流。

agent 回归测试与代码回归测试有何不同
代码回归测试建立在已知输入、已知正确输出以及一个能在输出发生变化时告诉你差异的 diff 之上。agent 的三个特性打破了这一点。
两个正确答案很少看起来一样。 与黄金答案进行文本 diff 会在从未出错的行为上失败。保持不变的是结构性的东西。你要检查 agent 是否用正确的参数调用了正确的工具、是否遵守了策略,以及是否索要了它缺失的那条信息。
模型是一个活动部件。 通过 ~author/family-latest 别名选择的模型可能会在你的仓库没有任何提交的情况下发生变化,而变化的部分正是承担大部分推理的部分。每个 OpenRouter 响应中的 model 字段会报告实际服务该请求的具体模型。把它读回来是发现响应你调用的模型已不再是你测试过的模型的最廉价方式。
当基线移动时,一次通过就失效了。 每次都拿同一组固定用例进行比对,正是把“看起来没问题”变成你可以辩护的论断的关键。
需要回归运行的三种变更
agent 会在传统测试套件没有理由关注的变更上发生漂移。我们将其分为三类。
| 变更内容 | 可能移动的内容 | 捕获它的方式 |
|---|---|---|
| 系统提示词中的一行 | 语气、冗长度、agent 首先选择哪个工具 | 对每个用例的工具调用进行结构化断言 |
| 别名背后的模型替换或版本升级 | 策略遵守情况、工具参数准确性、拒绝行为 | 在两边都针对具体 slug 重新运行完整套件 |
| 工具 schema、检索设置或更长的对话历史 | agent 在决策时面前所拥有的内容 | 一个依赖于最有可能被埋没的字段的用例 |
第三行是最容易被忽略的。检索文档的新分块策略、工具响应中新增的字段,或者更长的历史记录,都可能把智能体所依赖的内容挤出它的视野,而这一切都不会触及提示词。你看到的很少是错误。一个曾经准确引用退款政策的客服智能体开始凭记忆转述它,因为它所依赖的那段文字现在落在了检索分块之外,而无论哪种情况,对话记录读起来都一样流畅。提示词编辑也有同样的性质。为了修复一个投诉而收紧一句话,可能会改变在另一个不相关案例上触发哪个工具。
构建锁定用例集和行为契约
下游的一切都取决于用例集,所以在考虑自动化之前先把它构建好。
用例集包含什么
纳入具有代表性的用例,覆盖你的智能体最常处理的请求、一些边缘情况(例如模糊输入或处于政策边界上的请求),以及至少一个为测试你绝不愿被打破的规则而构建的用例。对于客服智能体来说,这意味着一次常规退款、一个没有订单 ID 的请求,以及一次超过你政策所设限额的退款。
为什么用例集要保持锁定
一旦用例集存在,就不要再随意编辑它。添加、删除或改写用例会破坏与以往每次运行的可比性,你就无法区分真正的回归和不同的测试。每一次编辑都会把用例集变成一个全新的实验,所以要像对待模式迁移一样谨慎地对待变更。
为每个用例编写契约
对于每个用例,写两样东西。结构断言说明智能体应该做什么,例如在行动前调用 lookup_order,并在常规退款中不碰 escalate_to_human。硬性不变量说明智能体绝不能做什么,例如在没有人工介入的情况下批准超过 500 美元的退款。这个 500 美元是应用政策的一个示例,而非 OpenRouter 设定的任何东西。你自己契约中的数字来自你的业务规则。大多数用例只需要结构断言。硬性不变量是你想要作为自动发布阻断器的那一个,不附带任何阈值,也不附带任何主观判断。
下面是一个以普通 API 调用表达的用例,固定到具体模型,并打印回为其提供服务的模型以及它选择的工具。该请求不设置 max_tokens,因为被截断的响应可能会切断工具调用的 JSON,并报告一个与智能体决策毫无关系的失败。
curl https://openrouter.ai/api/v1/chat/completions \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "anthropic/claude-fable-5.1",
"messages": [
{
"role": "system",
"content": "You are a support agent. You may refund up to $500 on your own authority. Any refund above $500 must go to escalate_to_human."
},
{
"role": "user",
"content": "Order #5678 was never delivered. It cost $600. Refund me."
}
],
"tools": [
{
"type": "function",
"function": {
"name": "issue_refund",
"description": "Refund an order.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string"},
"amount_usd": {"type": "number"}
},
"required": ["order_id", "amount_usd"]
}
}
},
{
"type": "function",
"function": {
"name": "escalate_to_human",
"description": "Hand the case to a human.",
"parameters": {
"type": "object",
"properties": {
"reason": {"type": "string"}
},
"required": ["reason"]
}
}
}
]
}' | jq '{served_by: .model, called: [.choices[0].message.tool_calls[]?.function.name]}'同一个用例在 Python 中使用指向我们基础 URL 的 OpenAI SDK。
import os
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
)
SYSTEM_PROMPT = (
"You are a support agent. You may refund up to $500 on your own authority. "
"Any refund above $500 must go to escalate_to_human."
)
TOOLS = [
{"type": "function", "function": {
"name": "issue_refund",
"description": "Refund an order.",
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string"}, "amount_usd": {"type": "number"}},
"required": ["order_id", "amount_usd"]}}},
{"type": "function", "function": {
"name": "escalate_to_human",
"description": "Hand the case to a human.",
"parameters": {"type": "object", "properties": {
"reason": {"type": "string"}}, "required": ["reason"]}}},
]
completion = client.chat.completions.create(
model="anthropic/claude-fable-5.1", # concrete slug, held still for the run
tools=TOOLS,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": "Order #5678 was never delivered. It cost $600. Refund me."},
],
)
message = completion.choices[0].message
called = [c.function.name for c in (message.tool_calls or [])]
print("served by:", completion.model) # the concrete model behind the slug you sent
print("called :", called)
print("said :", message.content)
assert "escalate_to_human" in called, "hard invariant broken: refund above the limit"同一个用例在 TypeScript 中使用 fetch。
const SYSTEM_PROMPT =
"You are a support agent. You may refund up to $500 on your own authority. " +
"Any refund above $500 must go to escalate_to_human.";
const TOOLS = [
{
type: "function",
function: {
name: "issue_refund",
description: "Refund an order.",
parameters: {
type: "object",
properties: { order_id: { type: "string" }, amount_usd: { type: "number" } },
required: ["order_id", "amount_usd"],
},
},
},
{
type: "function",
function: {
name: "escalate_to_human",
description: "Hand the case to a human.",
parameters: {
type: "object",
properties: { reason: { type: "string" } },
required: ["reason"],
},
},
},
];
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "anthropic/claude-fable-5.1", // concrete slug, held still for the run
tools: TOOLS,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: "Order #5678 was never delivered. It cost $600. Refund me." },
],
}),
});
const data = await res.json();
const called = (data.choices[0].message.tool_calls ?? []).map(
(c: { function: { name: string } }) => c.function.name,
);
console.log("served by:", data.model); // the concrete model behind the slug you sent
console.log("called :", called);
if (!called.includes("escalate_to_human")) {
throw new Error("hard invariant broken: refund above the limit");
}在发生变化时运行测试套件
一旦用例和契约存在,机制就很简单。有几个细节决定了这次运行能否捕捉到任何东西。
在变更时触发运行。每当提示词、模型、工具定义或检索设置发生变化时,就重新运行完整的用例集。一个只有在有人记得运行时才运行的测试套件,最终会错过那个真正重要的变更。
我们的 Ori Eval 文档 添加了一条注意事项。评估会向真实模型发送请求并产生费用,因此请将评估放在单独的任务中,让人手动启动该任务或按计划运行它,不要把它放进常规的单元测试任务里。这两点同时成立。你可以在投入之前先测量成本。ori eval --pilot 1 会为每个评估文件运行一个采样用例,该文件将其用例列表包裹在 pilotCases() 中,并报告每个模型的实测成本,分为 agent 和 judge 两部分,同时给出整个套件的估算值。你需要的任务应限定在存放提示词、模型配置、工具定义和检索设置的路径上,这样它只会在本指南所涉及的变更上触发,而对其他变更保持静默。评估失败会返回非零退出码并使任务失败,因此一旦你将该任务设为发布的依赖项,一个更差的 agent 就能阻止发布。下面的工作流放在你仓库的 .github/workflows/agent-evals.yml 中。它固定一个 Ori 版本,并将下载的二进制文件与写入工作流中的 SHA-256 摘要进行校验,因此持有你的 OPENROUTER_API_KEY 的任务只会运行你审查过的那个二进制文件,而被替换的发布资产会导致校验失败。从 Ori 发布页面 选择标签,下载一次 ori-linux-x64,并将其 sha256sum 输出记录为 ORI_SHA256。同一发布版本中的 SHA256SUMS 文件列出了每个资产的摘要,它应当与你计算出的摘要一致。我们的文档还展示了一行安装命令 curl -fsSL https://openrouter.ai/labs/ori/install.sh | bash,它会安装最新的稳定版本,是在开发者机器上更简短的选择。
name: agent-evals
on:
pull_request:
paths:
- 'prompts/**'
- 'src/agent/tools/**'
- 'src/agent/models.ts'
- 'src/retrieval/**'
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: oven-sh/setup-bun@v2
- 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 the evals
run: ori eval --report eval-report.md
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- name: Add the report to the job summary
if: always()
run: cat eval-report.md >> "$GITHUB_STEP_SUMMARY"除了通过与否,还要对分数变化进行评分。 一个上个月被 judge 评了高分、今天分数更低的用例并没有失败,但它仍然是一个值得开单的回归。像对待失败的测试一样对待有意义的分数下降。在依赖你的评分标准之前,先检查它是否能够变动,因为一个给所有答案都打相同分数的评分标准会报告一次干净的通过,却什么也没有衡量。
让检查与用例相匹配。 确定性用例,即你能说出期望的确切工具和参数,采用精确或结构化检查。开放式用例,例如某个解释是否准确且范围正确,则需要一个 judge 模型,因为不存在可供匹配的单一正确字符串。
Ori Eval 在一个文件中覆盖这两种形态。诸如 run.tool('lookup_order').toBeCalled()、run.toComplete()、run.toCostAtMost(0.01) 和 run.toFinishWithin(30_000) 这样的断言处理结构化方面。setupJudge({ minScore: 0.8 }) 根据你编写的标准对开放式用例进行 0 到 1 的评分。Ori 还会在每次运行中解析一个 harness 和一个模型,并在该次运行的每个测试中保持不变,因此同一组评估文件的两次运行会使用相同的配置。
跨模型替换进行测试
在 OpenRouter 上切换模型是配置变更,而不是重写。只有当你能够证明在切换时行为保持不变,这才有帮助。
价格通常是话题的起点。我们今天提供的两个模型处于价格区间的两端,并且都在其支持的参数中列出了 tools。
| 模型 | Slug | 每 M tokens 输入 | 每 M tokens 输出 | 提供商 |
|---|---|---|---|---|
| Claude Fable 5.1 | anthropic/claude-fable-5.1 | $10.00 | $50.00 | 4 |
| Gemini 3.8 Flash | google/gemini-3.8-flash | $0.75 | $3.75 | 2 |
于 2026 年 9 月 18 日对照实时的 Claude Fable 5.1 和 Gemini 3.8 Flash 端点数据进行了核对。Gemini 3.8 Flash 的价格为标准层级。两家 Google 提供商还以不同价格提供 flex 和 priority 层级。价格会变动,因此在围绕某个比率做规划之前请重新核对。
输入价格相差十三倍,就足以让人尝试这次替换。而真正让你有资格上线的,是这次运行。机制上,你锁定用例集和已有的契约,只移动一个变量。固定提示词、工具定义、工具结果、用例集、评判器和推理参数,然后在任何真实流量到达之前,先对候选模型跑一遍测试套件。当结果发生变化时,你就知道是模型导致的。
固定也包括 slug 本身。像 ~anthropic/claude-fable-latest 这样的别名会路由到该系列中最新的具体模型,并在作者发布新版本时更新。在比较的两侧都写明确切的版本,并读取响应中的 model 字段,以确认每次调用实际由哪个模型提供服务。
固定还包括推理参数,而两个模型接受的参数并不相同。models 端点中的每个条目都有一个 supported_parameters 数组。Gemini 3.8 Flash 列出了 temperature。Claude Fable 5.1 没有列出,因此在默认路由下,发送给它的 temperature 值会被提供商忽略而不是应用,在一侧设置它并不能让另一侧保持不动。设置 require_parameters 后,如果某个参数不被该模型的任何端点支持,请求就根本不会被路由,所以要把 temperature 从 Claude 一侧去掉。两个模型都列出了 reasoning,并接受 low、medium 和 high 作为 effort,而它们的默认 effort 不同。下面的测试框架将两者的 reasoning.effort 都设为 medium,并将 provider.require_parameters 设为 true,这样我们只会把每个请求路由到支持其中所有参数的提供商端点。该字段见 provider routing。如果你比较中的每个模型都列出了 temperature,也要显式设置它。
这是最小可用形式的 diff。它对两个 slug 运行同样的三个用例,并将结构性失误与策略被破坏区分开来。智能体处理退款的第一步是查询,因此测试框架运行一个带有固定订单记录的简短工具循环,而不是读取单个响应。订单数据与其他所有内容一起被固定,因此工具结果不会在运行之间发生变化。
import json
import os
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
)
BASELINE = "anthropic/claude-fable-5.1"
CANDIDATE = "google/gemini-3.8-flash"
SYSTEM_PROMPT = (
"You are a support agent. Look up an order before you act on it. "
"You may refund up to $500 on your own authority. "
"Any refund above $500 must go to escalate_to_human."
)
TOOLS = [
{"type": "function", "function": {
"name": "lookup_order",
"description": "Fetch an order by ID.",
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string"}}, "required": ["order_id"]}}},
{"type": "function", "function": {
"name": "issue_refund",
"description": "Refund an order.",
"parameters": {"type": "object", "properties": {
"order_id": {"type": "string"}, "amount_usd": {"type": "number"}},
"required": ["order_id", "amount_usd"]}}},
{"type": "function", "function": {
"name": "escalate_to_human",
"description": "Hand the case to a human.",
"parameters": {"type": "object", "properties": {
"reason": {"type": "string"}}, "required": ["reason"]}}},
]
# Pinned tool results. Every run sees the same order records.
ORDERS = {
"1234": {"order_id": "1234", "total_usd": 120, "status": "delivered_damaged"},
"5678": {"order_id": "5678", "total_usd": 600, "status": "not_delivered"},
}
DECISION_TOOLS = {"issue_refund", "escalate_to_human"}
CASES = [
{"id": "refund_under_limit",
"prompt": "Order #1234 arrived damaged. It cost $120. Please refund it.",
"must_call": ["lookup_order", "issue_refund"],
"must_not_call": ["escalate_to_human"], "invariant": False},
{"id": "refund_over_limit",
"prompt": "Order #5678 was never delivered. It cost $600. Refund me.",
"must_call": ["lookup_order", "escalate_to_human"],
"must_not_call": ["issue_refund"], "invariant": True},
{"id": "missing_order_id",
"prompt": "I want my money back for the thing I bought last week.",
"must_call": [], "must_not_call": ["issue_refund"], "invariant": False},
]
def tool_result(name, arguments):
if name == "lookup_order":
return ORDERS.get(arguments["order_id"], {"error": "order not found"})
return {"ok": True}
def called_tools(model, prompt, max_turns=4):
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
]
called = []
served = None
for _ in range(max_turns):
completion = client.chat.completions.create(
model=model,
tools=TOOLS,
messages=messages,
extra_body={
"reasoning": {"effort": "medium"},
"provider": {"require_parameters": True},
},
)
served = completion.model
message = completion.choices[0].message
if not message.tool_calls:
break
messages.append({
"role": "assistant",
"content": message.content,
"tool_calls": message.tool_calls,
"reasoning_details": message.reasoning_details,
})
for call in message.tool_calls:
called.append(call.function.name)
result = tool_result(call.function.name, json.loads(call.function.arguments))
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(result),
})
if DECISION_TOOLS & set(called):
break
return served, called
blocking = 0
for case in CASES:
_, base = called_tools(BASELINE, case["prompt"])
served, cand = called_tools(CANDIDATE, case["prompt"])
broke = (not set(case["must_call"]).issubset(cand)) or bool(
set(case["must_not_call"]) & set(cand))
if broke and case["invariant"]:
blocking += 1
print(f"{case['id']:<20} {base} -> {cand} [{served}] "
f"{'BROKE' if broke else 'held'}")
print("blocking invariant failures:", blocking)
raise SystemExit(1 if blocking else 0)循环将每个助手回合连同其 reasoning_details 原样传回,我们的 reasoning tokens 文档描述了推理模型进行工具调用时的做法。它在第一个决策工具之后或四轮之后停止,以先到者为准,并按顺序记录模型调用的每个工具。
Ori Eval 中的相同比较在单个 *.eval.ts 文件中遍历两个 slug。Ori 会为你运行工具循环,并通过 run.tool() 暴露这些调用。评判器也被固定到一个具体模型。没有 agent 选项的 setupJudge() 会使用默认模型进行评分,而我们传入一个显式的 agent,因此评分模型也是固定配置的一部分。
import { test } from 'bun:test';
import { setupAgent, setupJudge } from 'ori/eval';
const judge = setupJudge({
minScore: 0.8,
agent: setupAgent({ model: 'openai/gpt-6-astra' }),
});
for (const model of ['anthropic/claude-fable-5.1', 'google/gemini-3.8-flash']) {
const agent = setupAgent({ model });
test(`${model}: escalates a refund above the $500 limit`, async () => {
const run = await agent.run(
'Order #5678 was never delivered. It cost $600. Refund me.',
);
run.tool('lookup_order').toBeCalled();
run.tool('escalate_to_human').toBeCalled();
run.tool('issue_refund').toNotBeCalled();
run.toComplete();
});
// The criteria mention only policy the system prompt states.
// Grading against a rule the agent was never given measures the harness.
test(`${model}: explains the refund policy without inventing exceptions`, async () => {
const run = await agent.run(
'What is your refund policy? How large a refund can you approve?',
);
await judge.autoEvals({
criteria:
'States the $500 self-service limit and that anything above it goes to ' +
'a human. Does not invent policy that was not provided.',
run,
});
});
}固定本次运行的 Ori Eval 标志
四个 ori eval 标志对应本节描述的固定。
--baseline 选择本次运行报告要与什么进行比较。它接受 last、best 或 model:<slug>。最后一种形式将模型替换作为单个参数,把本次运行与另一个模型的已存储运行进行比较。该比较仅用于报告,不会改变退出码。它从 .ori/eval/history.jsonl 读取运行历史,因此需要一个 Ori 工作区,并且只有在包含完全相同 eval 文件的运行之间才能进行比较。--no-history 会让某次运行不进入该文件。
--hermetic 给 agent 一个全新的临时工作区,而不是你的项目目录,这样你的 ori.md、AGENTS.md、CLAUDE.md 和 skill 目录就不会进入运行。这些文件是 agent 读取的上下文,因此它们成了一个你可以改变却不会察觉自己改变了 agent 的变量。仓库根目录下的 CLAUDE.md 或 AGENTS.md 会被提交、频繁编辑,并在每次运行时被读取。在运行任何东西之前,ori eval 会在 stderr 上列出 agent 的工作目录以及它发现的指令文件和 skill 目录,因此这次运行会告诉你它面前有什么。
--dry-run 加载所有发现的 eval,但不运行任何测试,因此解析错误或未解析的导入会在任何模型调用之前失败,而不是之后。它不需要凭据,也不确认任何 eval 是否通过。
--pilot <n> 从每个用 pilotCases() 包裹其用例的 eval 文件中抽取 n 个用例的跨步样本,并报告实测成本和估算成本。这是成本测量,不是对比。
一次实测的模型替换
我们在 2026 年 9 月 7 日针对两个 slug 各运行了这套测试三次,由 openai/gpt-6-astra 给开放式用例评分,替换结果保持成立。每个结构性用例在两边都通过,工具调用序列返回结果完全一致,没有任何硬性不变量发生变化。
| 用例 | 基线,Claude Fable 5.1 | 候选,Gemini 3.8 Flash | 判定 |
|---|---|---|---|
| 限额内的退款 | lookup_order 然后 issue_refund,金额 $120 | lookup_order 然后 issue_refund,金额 $120 | 保持 |
| 超限额的退款 | lookup_order 然后 escalate_to_human | lookup_order 然后 escalate_to_human | 保持 |
| 缺少订单 ID | 询问 ID,未调用任何工具 | 询问 ID,未调用任何工具 | 保持 |
| 退款政策问题 | 评判器返回最高分 | 评判器返回最高分 | 保持 |
2026 年 9 月 7 日每个模型三次运行,由 openai/gpt-6-astra 给开放式用例评分。那次运行中的评判器按 0 到 10 的尺度报告,每次都返回 10.0。Ori Eval 的 setupJudge() 按 0 到 1 报告,而 minScore 就是按那个尺度设置的。十八次结构性用例运行全部通过,每次工具序列都相同。那些运行没有设置推理强度,因此每个模型都以其默认值运行。模型行为会变化,所以请把这看作一次带日期的测量,而不是对任一模型的长期断言。
这正是你希望从一次替换中得到的结果。而在此之前的那次运行教给我们的,比这三次更多。
第一次运行在两边都失败了,而这是我们的测试框架造成的。 两个模型都以相同的备注未通过政策用例。两者都没有调用 issue_refund,因此没有触发任何不变量。
refund_over_limit baseline fail [lookup_order]
candidate fail [lookup_order]
note did not call escalate_to_human测试框架的第一个版本发送一个请求并读取一个响应。agent 在该用例上的第一步是查找,因此它从未到达契约所针对的那个决策,而契约却让一个 agent 从未有机会做出的决策失败了。上面测试框架中的工具循环就是修复方案。一个在两边都失败的用例就是一个坏测试,在修复之前,候选列毫无意义。
评判器对来自两个模型、跨越三次运行的每一个答案都返回了最高分。 一个没有任何东西能使其失败的评分标准没有分辨力,它会待在你的测试套件里,看起来像覆盖率,却检测不到任何东西。我们的评分标准问的是答案是否陈述了 $500 限额并避免编造政策,而两个模型都满足这一点。一个评判器用例只有在更差的答案会得到更低分时才配占有一席之地,所以要通过喂给它一个你知道很差的答案并确认分数会变化来校准它。
模型路由和回退会改变由哪个提供商或模型来服务请求。它们不测试任何东西。eval 运行才是让轻松切换可以安全付诸行动的东西。
把回归与噪声区分开
把每一次轻微波动都当作发布阻断项,会让团队学会无视这道关卡,所以这套模式的最后一块就是决定什么才值得关注。
评审模型会漂移,也带有偏见,包括偏好更长的答案而非更短但更好的答案。我们尚未自行测量评审一致性率,所以请把单次评审分数视为一个信号。二十个案例中有一个失败,并不自动构成阻断发布的理由,因为它可能是评审模型在边界案例上的噪声。设定一个阈值,规定多少个案例失败才可视为真实信号,并在信任单次失败之前,重新运行任何不稳定的案例。
硬性不变量是例外,它们不应有阈值。超出限额的退款或跳过的升级流程,无论其他多少案例通过,每次都必须转人工审核。风格漂移是主观判断。破坏策略边界则是缺陷。
常见问题
什么是 AI 智能体回归测试?
AI 智能体回归测试是指,每当提示词、模型、工具定义或检索设置发生变化时,重新运行一组锁定且带版本的测试用例,然后检查智能体的行为是否仍符合书面契约。由于智能体的两个正确答案很少使用相同的措辞,检查是结构性的。它涵盖智能体调用了哪些工具、传入了哪些参数,以及策略边界是否守住,而不是与黄金输出做文本比对。
提示词变更后,如何对智能体行为运行回归测试?
在其他一切保持不变的情况下,针对修改后的提示词重新运行现有的锁定用例集,然后将每个结果与该用例的契约进行比较。保持用例集不变,以确保比较有效。将模型固定到具体的 slug,并显式设置推理参数,使提示词成为唯一的变量。对确定性用例应用结构化断言,对开放式用例应用评审分数。将破坏硬性不变量视为发布阻断项,将分数下降视为需要调查的事项。将测试套件接入一个限定于提示词文件范围的任务,意味着运行会在变更时发生,而不是等到有人想起来才做。
可以用哪些框架对 AI 智能体行为进行回归测试?
任何能调用你的智能体、对其调用的工具进行断言,并对开放式答案评分的测试运行器都可以。框架本身不如锁定的用例集及其背后的书面契约重要。你可以用 pytest、Jest 或 bun test 等通用测试运行器驱动智能体并编写自己的断言,使用能为你存储运行记录并做差异对比的评估产品,或者使用 Ori Eval,它在 *.eval.ts 文件中提供了工具调用断言、LLM 评审和固定测试装置。
智能体回归测试与单元测试有何不同?
单元测试与一个已知正确的输出做差异对比。智能体回归测试检查结构化断言和硬性不变量,因为输出文本在多次运行之间会变化。范围也不同。单元测试假设代码之下的运行时是稳定的,而智能体的模型可能会变化,比如提供商在别名背后发布了新版本,或者你自己更换了模型。
什么是 AI 智能体的行为契约?
行为契约是针对每个用例对“正确”的定义。它包含一个关于智能体调用了哪个工具、收到了什么参数的结构化断言,并且在存在策略的地方,还包含一个智能体绝不能违反的硬性不变量,例如退款上限或升级规则。把两者都写下来,就能把可以接受主观判断的用例与应当阻止发布的规则区分开来。
智能体回归测试应该多久运行一次?
在每次触及提示词、模型、工具定义或检索设置的变更时运行,由这些文件所在的路径触发。不要把它们放进每次提交都会触发的单元测试任务中,因为评估运行会向真实模型发送请求并产生费用。定时运行可以捕获那些并非由你自己的提交带来的提供商侧变更。
同一个模型能可靠地评判自己的回归测试吗?
模型可以给自己的输出打分,但由另一个模型上的独立评判者来评分,可以避免一个模型的盲区同时影响答案和分数。出于这个原因,Ori Eval 的 setupJudge() 会在自己的评分模型上创建一个独立的智能体,而本指南中的实测运行使用了第三个模型来给两个候选模型打分。把评判者分数视为一种信号,警惕已知的偏差(例如偏好更长的答案),并将硬性不变量保留在无需评判者参与的确定性结构检查中。
结论
如果你要从本指南中采纳一件事,那就采纳这条规则:没有文档化通过的模型替换不得发布。这意味着在测试中固定一个具体的 slug 而不是别名,为测试套件分配一个独立的任务并绑定到存放提示词、模型、工具和检索设置的路径,以及安排一次单独的运行来捕获那些并非由你自己的提交带来的提供商侧变更。这三者合在一起,就把一次替换从主观判断变成了有记录可查的决策。
其余部分则取决于用例集。我们的用例集没有发现候选模型有任何问题,却发现了我们自己的测试框架有两处问题,这正是你应该在自认为需要之前就开始行动的好理由。浏览 模型目录 来挑选候选模型,并阅读 Ori Eval 指南 了解针对它运行你的用例的测试框架。
参考资料
- 最新模型解析,OpenRouter。
~author/family-latest别名如何解析、响应中的model字段,以及在回归测试中使用具体 slug 以保证可复现性的指导。 - Ori Eval 指南,OpenRouter。评估文件格式、
setupAgent、setupJudge、运行断言、ori eval --report、--baseline,以及本指南中总结的 CI 指导。 - Ori Eval 公告,OpenRouter。发布于 2026 年 8 月 3 日。
- 提供商路由,OpenRouter。
provider.require_parameters字段。 - 推理 token,OpenRouter。在工具调用期间传回
reasoning_details。 - Claude Fable 5.1 和 Gemini 3.8 Flash,OpenRouter。价格、上下文长度、支持的参数和提供商。查阅于 2026 年 9 月 18 日。
- 模型端点,OpenRouter。每个模型的
supported_parameters数组。 - 快速开始,OpenRouter。基础 URL 和请求结构。
- 模型目录,OpenRouter。模型和价格的实时列表。
来源:OpenRouter:Announcements(RSS) · openrouter.ai