A~author/family-latestalias 总是解析到某个系列中最新的具体模型。这在生产环境中很方便,在回归测试中却是个问题,因为模型可能在两次运行之间发生变化,而你的代码仓库没有任何改动。我们的最新模型解析文档描述了这一机制,并建议在你需要固定版本以保证可复现性时使用具体的模型 slug。本指南涵盖锁定用例集和行为契约,然后详细讨论模型替换这一情形。

Tl;dr

  • 对智能体做回归测试,意味着每当提示词、模型、工具定义或检索设置发生变化时,就重新运行一组锁定的用例,然后将结果与一份写好的行为契约进行比对。
  • 每个用例都带有一个结构性断言,说明智能体调用了哪些工具、传入了哪些参数;并且在存在策略的情况下,还带有一个智能体绝不能违反的硬性不变量。
  • 锁定用例集。每当你改写一个用例,就会破坏它与之前所有运行结果的可比性。
  • 对于模型替换,保持提示词、工具、用例、评判器和推理参数不变,只改变模型。使用具体的 slug,例如anthropic/claude-fable-5.1而不是会解析到最近发布版本的 alias。
  • 先读基线列,再读候选列。一个用例在两边都失败,说明测试本身有问题。一个硬性不变量只在候选列上被破坏,就应该阻止发布。
  • Ori Eval通过工具调用断言支持这一工作流,例如run.tool('escalate_to_human').toBeCalled()以及用于开放式回答的 LLM 评判器。

Diagram of the regression-testing pattern: a locked case set, one change to a prompt line, model slug, or tool or retrieval setting, a pinned re-run with the same cases and harness, and two outcomes, behavior held or behavior broke on an un-escalated refund

智能体回归测试与代码回归测试有何不同

代码回归测试建立在已知输入、已知正确输出,以及一个能在输出变化时告诉你的 diff 之上。智能体的三个特性打破了这一点。

两个正确答案很少长得一样。针对标准答案做文本 diff,会在那些从来就没出错的行为上失败。真正保持不变的是结构。你要检查智能体是否用正确的参数调用了正确的工具、是否遵守了策略,以及是否索取了它缺失的那条信息。

模型是一个会变的部件。通过~author/family-latestalias 选中的模型可能在你代码仓库没有任何提交的情况下发生变化,而发生变化的那部分正是承担大部分推理工作的部件。这个model每个 OpenRouter 响应中的 field 都会报告实际处理请求的具体模型。把它读回来,是发现响应你调用的模型已不再是你测试过的那个模型的最省事的方法。

当基线发生变化时,通行证就失效了。每次都拿同一组固定用例做对比,才能把“看起来没问题”变成你能站得住脚的说法。

需要跑回归测试的三类变更

智能体会在传统测试套件没有理由关注的变更上发生漂移。我们把它们归为三类。

变了什么什么会移动什么能抓住它
系统提示词里的一行语气、啰嗦程度、智能体首先伸手去拿哪个工具对每个用例的工具调用做结构性断言
别名背后的模型替换或版本升级策略遵循度、工具参数准确性、拒答行为针对两侧的具体 slug 重跑整套测试
工具 schema、检索设置,或更长的对话历史智能体做决定时眼前摆着什么一个依赖最容易被埋没的那个字段的用例

第三行是最容易漏掉的那一行。检索文档的新分块策略、工具响应里新增的一个字段,或者更长的历史,都可能把智能体所依赖的内容挤出它的视野,而这些都不会碰到提示词。你看到的很少是报错。一个过去能准确引用退款政策支持智能体开始凭记忆转述它,因为它所依赖的那段话现在落在了检索分块之外,而两种情况下对话记录读起来都一样流畅。提示词编辑也有同样的性质。为了修一个投诉而收紧一句话,可能会改变一个不相关用例上触发的是哪个工具。

构建一个锁定的用例集和一份行为契约

下游的一切都依赖这个用例集,所以在考虑自动化之前先把它建好。

用例集里包含什么

包含能覆盖你的智能体最常处理请求的代表性案例、一些边缘案例(例如模糊输入或处于策略边界的请求),以及至少一个用来测试你绝不愿被打破的规则的案例。对于一个客服智能体来说,这意味着一次常规退款、一个没有订单 ID 的请求,以及一次超过你策略所设限额的退款。

为什么案例集要保持锁定

一旦案例集存在,就不要随意编辑它。增加、删除或改写某个案例都会破坏与所有过往运行的可比性,你也就无法区分真正的回归和不同的测试。每一次编辑都会把案例集变成一个全新的实验,所以要像对待 schema 迁移一样谨慎地对待变更。

为每个案例编写契约

为每个案例写两样东西。结构性断言说明智能体应该做什么,例如调用lookup_order再行动,并在常规退款时保持escalate_to_human不变。硬性不变量说明智能体绝不能做什么,例如在没有人工介入的情况下批准超过 $500 的退款。这个 $500 是示例应用策略,而不是 OpenRouter 设定的任何东西。你自己契约中的数字来自你的业务规则。大多数案例只需要结构性断言。硬性不变量是你想要作为自动发布阻断器的那一条,不附带任何阈值,也不附带任何主观判断。

下面是一个以普通 API 调用表达的案例,固定到具体模型,并回显服务它的模型以及它选择的工具。该请求没有设置max_tokens,因为被截断的响应可能会切断工具调用的 JSON,并报告一个与智能体决策毫无关系的失败。

curlhttps://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": "将案件转交给人工处理。",
"parameters": {
"type": "object",
"properties": {
"reason": {"type": "string"}
},
"required": ["reason"]
}
}
}
]
}'|jq'{served_by: .model, called: [.choices[0].message.tool_calls[]?.function.name]}'

用 Python 和指向我们 base URL 的 OpenAI SDK 实现同样的案例。

importos

fromopenaiimportOpenAI

client=OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"],
)

SYSTEM_PROMPT=(
"你是一名客服智能体。你可以自行决定最多退款 $500。"
"任何超过 $500 的退款都必须交给 escalate_to_human。"
)

TOOLS=[
{"type":"function","function": {
"name":"issue_refund",
"description":"为一笔订单退款。",
"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":"将案件转交给人工处理。",
"parameters": {"type":"object","properties": {
"reason": {"type":"string"}},"required": ["reason"]}}},
]

completion=client.chat.completions.create(
model="anthropic/claude-fable-5.1",# 具体 slug,在本次运行中保持不变
tools=TOOLS,
messages=[
{"role":"system","content":SYSTEM_PROMPT},
{"role":"user","content":"订单 #5678 从未送达。它花了 $600。给我退款。"},
],
)

message=completion.choices[0].message
called=[c.function.nameforcin(message.tool_callsor[])]

print("served by:", completion.model)# 你发送的 slug 背后的具体模型
print("called   :", called)
print("said     :", message.content)

assert"escalate_to_human"incalled,"硬性不变量被破坏:退款超过限额"

用 TypeScript 实现同样的案例,使用fetch.

constSYSTEM_PROMPT=
"你是一名客服智能体。你可以自行决定最多退款 $500。"+
"任何超过 $500 的退款都必须交给 escalate_to_human。";

constTOOLS=[
{
type:"function",
function: {
name:"issue_refund",
description:"为一笔订单退款。",
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:"将案件转交给人工。",
parameters: {
type:"object",
properties: { reason: { type:"string"} },
required: ["reason"],
},
},
},
];

constres=awaitfetch("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",// 具体 slug,在本次运行中保持不变
tools:TOOLS,
messages: [
{ role:"system",content:SYSTEM_PROMPT},
{ role:"user",content:"订单 #5678 从未送达。它花了 $600。给我退款。"},
],
}),
});

constdata=awaitres.json();
constcalled=(data.choices[0] .message.tool_calls??[]).map(
(c:{function:{name:string} })=>c.function.name,
);

console.log("served by:",data.model);// 你发送的 slug 背后的具体模型
console.log("called   :",called);

if(!called.includes("escalate_to_human")) {
thrownewError("硬性不变量被破坏:退款超过限额");
}

当有变化时运行测试套件

一旦用例和契约存在,机制就很简单了。有几个细节决定了这次运行能否捕捉到任何问题。

在变更时触发运行。每当提示词、模型、工具定义或检索设置发生变化时,就重新运行完整的用例集。一个只有靠人记得才运行的测试套件,最终会错过那个真正重要的变更。

我们的Ori Eval 文档补充了一条提醒。评估会向真实模型发送请求并产生费用,所以要把评估放在单独的任务中,让人来启动该任务或按计划运行,不要把它放进常规的单元测试任务里。这两点同时成立。你可以在投入之前先衡量成本。ori eval --pilot 1对每个评估文件运行一个抽样用例,该文件将其用例列表包裹在pilotCases()并报告每个模型的实测成本,区分智能体和评判器,同时给出整套测试的估算值。你想要的作业范围限定在存放提示词、模型配置、工具定义和检索设置的路径上,因此它只会在本指南所涉及的变更上触发,对其他变更保持静默。评估失败会返回非零退出码并使作业失败,所以一旦你把该作业设为发布的依赖项,表现更差的智能体就能阻止发布。下面的工作流放在你的仓库中,路径为.github/workflows/agent-evals.yml。它固定一个 Ori 版本,并将下载的二进制文件与写入工作流的 SHA-256 摘要进行校验,因此持有你OPENROUTER_API_KEY的作业只会运行你审查过的二进制文件,而被替换的发布资产会导致校验失败。从Ori releases 页面选择标签,下载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:安装 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:ori eval --report eval-report.md
env:
OPENROUTER_API_KEY:${{ secrets.OPENROUTER_API_KEY }}
-name:将报告添加到作业摘要
if:always()
run:cat eval-report.md >> "$GITHUB_STEP_SUMMARY"

既要评估通过与否,也要评估差值。一个上个月评委打分很高的案例今天得分更低,这并不算失败,它仍然是一个值得开单的回归问题。把有意义的分数下降当作失败的测试来对待。在你依赖评分标准之前,先确认它能够变动,因为一个给所有答案都打相同分数的评分标准会报告一次干净的通过,却什么也没衡量。

让检查与案例相匹配。确定性的案例,即你能说出期望的确切工具和参数,采用精确或结构化检查。开放式的案例,例如某个解释是否准确且范围正确,则需要评委模型,因为不存在单一的正确字符串可供匹配。

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 和一个模型,并在该次运行的每个测试中保持不变,因此同一组 eval 文件的两次运行使用相同的配置。

跨模型替换进行测试

在 OpenRouter 上切换模型是一种配置变更,而不是重写。只有当你能够证明在做出切换时行为保持不变,这才有帮助。

价格通常是对话的起点。我们今天服务的两个模型处于价格区间的两端,并且都在其支持的参数中列出了tools。

模型Slug每 M tokens 输入每 M tokens 输出提供商
Claude Fable 5.1anthropic/claude-fable-5.1$10.00$50.004
Gemini 3.8 Flashgoogle/gemini-3.8-flash$0.75$3.752

于 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,因此我们只会把每个请求路由到支持其中所有参数的提供商端点。关于该字段,参见提供商路由。如果你比较中的所有模型都列出了temperature,也要显式设置它。

下面是最小可用形式的 diff。它针对两个 slug 运行同样的三个用例,并把结构性缺失与策略失效区分开来。智能体在处理退款时的第一步是查询,因此测试框架运行一个简短的工具循环,使用固定顺序的记录,而不是读取单个响应。订单数据与其他所有内容一起被固定,因此工具结果在不同运行之间不会变化。

importjson
importos

fromopenaiimportOpenAI

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=(
"你是一名支持智能体。在对订单采取行动之前先查询它。"
"你可以自行决定最多退款 $500。"
"任何超过 $500 的退款都必须交给 escalate_to_human。"
)

TOOLS=[
{"type":"function","function":{"name"
:"lookup_order","description"
:"按 ID 获取订单。","parameters":{
"type":"object","properties":{"order_id":{"type":
"string"}},"required":["order_id"]}}},{"type":
"function","function":{"name":"issue_refund",
"description":"为订单退款。",
"parameters":{"type":"object"
,"properties":{"order_id":{"type":"string"},"amount_usd":{
"order_id": {"type":"string"},"amount_usd": {"type":"number"}},
"required": ["order_id","amount_usd"]}}},
{"type":"function","function": {
"name":"escalate_to_human",
"description":"将案件转交给人工。",
"parameters": {"type":"object","properties": {
"reason": {"type":"string"}},"required": ["reason"]}}},
]

# 固定的工具结果。每次运行看到的都是相同的订单记录。
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":"订单 #1234 到货时已损坏。花费 $120。请退款。",
"must_call": ["lookup_order","issue_refund"],
"must_not_call": ["escalate_to_human"],"invariant":False},
{"id":"refund_over_limit",
"prompt":"订单 #5678 从未送达。花费 $600。给我退款。",
"must_call": ["lookup_order","escalate_to_human"],
"must_not_call": ["issue_refund"],"invariant":True},
{"id":"missing_order_id",
"prompt":"我想为我上周买的东西退款。",
"must_call": [],"must_not_call": ["issue_refund"],"invariant":False},
]


deftool_result(name, arguments):
ifname=="lookup_order":
returnORDERS.get(arguments["order_id"], {"error":"order not found"})
return{"ok":True}


defcalled_tools(model, prompt, max_turns=4):
messages=[
{"role":"system","content":SYSTEM_PROMPT},
{"role":"user","content": prompt},
]
called=[]
served=None
for_inrange(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
ifnotmessage.tool_calls:
break
messages.append({
"role":"assistant",
"content": message.content,
"tool_calls": message.tool_calls,
"reasoning_details": message.reasoning_details,
})
forcallinmessage.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),
})
ifDECISION_TOOLS&set(called):
break
returnserved, called


blocking=0
forcaseinCASES:
_, base=called_tools(BASELINE, case["prompt"])
served, cand=called_tools(CANDIDATE, case["prompt"])
broke=(notset(case["must_call"]).issubset(cand))orbool(
set(case["must_not_call"])&set(cand))
ifbrokeandcase["invariant"]:
blocking+=1
print(f"{case['id']:<20}{base}->{cand}[{served}] "
f"{'BROKE'ifbrokeelse'held'}")

print("blocking invariant failures:", blocking)
raiseSystemExit(1ifblockingelse0)

循环会把每个 assistant 轮次连同其reasoning_details原样传回,我们的推理 token文档中描述了推理模型进行工具调用时的做法。它会在第一个决策工具之后或四轮之后停止,以先到者为准,并按顺序记录模型调用的每一个工具。

Ori Eval 中的相同比较会在单个*.eval.ts文件中遍历这两个 slug。Ori 会为你运行工具循环,并通过run.tool()暴露这些调用。评判模型也被固定到一个具体模型。setupJudge()在没有agent选项时会使用默认模型进行评分,而我们传入一个显式的智能体,使评分模型成为固定配置的一部分。

import{ test }from'bun:test';
import{ setupAgent, setupJudge }from'ori/eval';

constjudge=setupJudge({
minScore:0.8,
agent:setupAgent({ model:'openai/gpt-6-astra'}),
});

for(constmodelof['anthropic/claude-fable-5.1','google/gemini-3.8-flash'] ) {
constagent=setupAgent({ model });

test(`${model}: escalates a refund above the $500 limit`,async()=>{
construn=awaitagent.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();
});

// 这些标准只提及系统提示中陈述的策略。
// 依据智能体从未被告知的规则进行评分,衡量的是测试框架。
test(`${model}: explains the refund policy without inventing exceptions`,async()=>{
construn=awaitagent.run(
'What is your refund policy? How large a refund can you approve?',
);

awaitjudge.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为智能体提供一个全新的临时工作区,而不是你的项目目录,从而将你的ori.md、AGENTS.md、CLAUDE.md以及技能目录排除在运行之外。这些文件是智能体读取的上下文,因此它们是一个变量,你可以在没有察觉的情况下改变智能体。仓库根目录下的CLAUDE.md或AGENTS.md会被检入、经常编辑,并在每次运行时被读取。在运行任何内容之前,ori eval会在 stderr 上列出智能体的工作目录以及它发现的指令文件和技能目录,因此运行会告诉你它面前有什么。

--dry-run会加载所有发现的 eval 并且不运行任何测试,因此解析错误或未解析的导入会在任何模型调用之前失败,而不是之后。它不需要凭据,也不确认任何 eval 是否通过。

--pilot <n>会从每个 eval 文件中按步长抽样运行n个用例,这些文件将其用例包裹在pilotCases()中,并报告实测成本和估算成本。这是成本测量,不是比较。

一次实测的模型替换

我们在 2026 年 9 月 7 日针对两个 slug 各运行了这套测试三次,使用openai/gpt-6-astra对开放式用例进行评分,替换结果保持成立。每个结构性用例在两侧都通过,工具调用序列返回结果完全一致,没有任何硬性不变量发生变化。

用例基线,Claude Fable 5.1候选,Gemini 3.8 Flash判定
限额内退款lookup_order然后issue_refund以 $120lookup_order然后issue_refund以 $120保持成立
超限额退款lookup_order然后escalate_to_humanlookup_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      未调用 escalate_to_human

测试框架的第一个版本发送一个请求并读取一个响应。智能体在该用例上的第一步是查找,所以它从未到达契约所针对的那个决策,而契约在一个智能体从未有机会做出的决策上失败了。上面测试框架中的工具循环就是修复方案。一个双方都失败的用例是一个坏掉的测试,在修复之前,candidate 列毫无意义。

评判器对来自两个模型、跨越全部三次运行的每一个答案都返回了最高分。一个没有任何东西能失败的评分标准没有区分能力,它会留在你的测试套件里,看起来像覆盖率,却什么也检测不到。我们的评分标准问的是答案是否陈述了 $500 限额并避免编造政策,而两个模型都通过了。一个评判器用例只有在更差的答案会得到更低分时才配占有一席之地,所以通过喂给它一个你知道很差的答案并确认分数会变化来校准它。

模型路由和回退会改变由哪个提供商或模型来服务请求。它们不测试任何东西。评估运行才是让轻松切换可以安全采取行动的东西。

将回归与噪声区分开

把每一次下降都当作发布阻碍,会训练团队忽视这道关卡,所以这个模式的最后一块是决定什么值得关注。

评判者会漂移,也会带有偏见,包括偏好更长的答案而非更短但更好的答案。我们尚未自行测量评判者一致率,因此请将单个评判者评分视为一个信号。二十个案例中有一个失败,并不自动构成阻止发布的理由,因为它可能是边缘案例上的评判噪声。设定一个阈值,规定在将其视为真实信号之前允许多少个案例失败,并在信任单次失败之前重新运行任何不稳定的内容。

硬性不变量是例外,它们不应有阈值。超出限额的退款或跳过的升级,每次都必须进入人工审核,无论有多少其他案例通过。风格漂移是主观判断。破坏策略边界则是缺陷。

常见问题

什么是 AI 智能体回归测试?

AI 智能体回归测试是指,每当提示词、模型、工具定义或检索设置发生变化时,重新运行一组锁定且带版本的测试案例,然后检查智能体的行为是否仍符合书面契约。由于智能体给出的两个正确答案很少使用相同的措辞,因此检查是结构性的。它涵盖智能体调用了哪些工具、使用了哪些参数,以及策略边界是否守住,而不是与黄金输出进行文本差异比对。

提示词变更后,如何对智能体行为运行回归测试?

在其他一切保持不变的情况下,针对编辑后的提示词重新运行现有的锁定案例集,然后将每个结果与该案例的契约进行比较。保持案例集不变,以便比较有效。将模型固定到具体的 slug,并显式设置推理参数,使提示词成为唯一的变量。对确定性案例应用结构性断言,对开放式案例应用评判者评分。将破坏硬性不变量视为发布阻断项,将评分下降视为需要调查的事项。将测试套件接入一个限定于你的提示词文件的任务,意味着运行会在变更时发生,而不是等有人想起来才发生。

可以使用哪些框架对 AI 智能体行为进行回归测试?

任何能调用你的智能体、对其调用的工具进行断言,并对开放式答案打分的测试运行器都可以。框架本身不如锁定的用例集及其背后的书面契约重要。你可以用通用测试运行器(如 pytest、Jest 或 bun test)配合自己的断言来驱动智能体,使用能为你存储运行结果并做差异对比的评估产品,或者使用Ori Eval,它提供工具调用断言、LLM 评判器,以及固定在*.eval.ts文件中的测试框架。

智能体回归测试与单元测试有何不同?

单元测试是与一个已知正确的输出做差异对比。智能体回归测试检查的是结构性断言和硬性不变量,因为输出文本在每次运行之间都会变化。范围也不同。单元测试假定你代码之下的运行时是稳定的,而智能体的模型可能会变化——当提供商在某个别名背后发布新版本时,或者当你自己更换模型时。

什么是 AI 智能体的行为契约?

行为契约是对每个用例定义什么才算正确。它包含一条关于智能体调用了哪个工具、接收了哪些参数的结构性断言,并且在存在策略的地方,还包含一条智能体绝不能违反的硬性不变量,例如退款上限或升级规则。把两者都写下来,就能把可以接受主观判断的用例与应当阻止发布的规则区分开。

智能体回归测试应该多久运行一次?

在每一次触及提示词、模型、工具定义或检索设置的变更时运行它们,由这些文件所在的路径触发。把它们排除在每次提交都会触发的单元测试任务之外,因为评估运行会向真实模型发送请求并产生费用。定时运行可以捕捉到并非由你自己的提交所带来的提供商侧变更。

同一个模型能可靠地评判自己的回归测试吗?

模型可以给自己的输出打分,但由不同模型担任的独立评判器可以阻止单个模型的盲区同时影响答案和评分。Ori Eval 的setupJudge()因此会在其自己的评分模型上创建一个单独的智能体,而本指南中实测的那次运行使用了第三个模型来给两个候选模型打分。把评判分数当作一种信号,警惕已知的偏差,比如偏好更长的回答,并在不涉及评判者的地方用确定性的结构检查来守住硬性不变量。

结论

如果你要从本指南中采纳一件事,那就采纳这条规则:没有记录在案的通过结果,就不发布任何模型替换。这意味着在测试中固定一个具体的 slug 而不是别名,给测试套件分配一个独立的任务并绑定到存放你的提示词、模型、工具和检索设置的路径,并安排一次单独的运行来捕捉那些并非由你自己的提交带来的供应商侧变更。这三者合在一起,就把一次替换从主观判断变成了有记录可查的决策。

其余的都从用例集推导而来。我们的用例集没有发现候选模型有任何问题,却发现了我们自己的测试框架有两处问题,这正是让你在自认为需要之前就开始动手的好理由。浏览模型目录来挑选一个候选模型,并阅读Ori Eval 指南了解用哪个测试框架来对它运行你的用例。

参考资料

  • 最新模型解析,OpenRouter。别名如何~author/family-latest解析、响应中的model字段,以及为回归测试的可复现性使用具体 slug 的指导。
  • Ori Eval 指南,OpenRouter。Eval 文件格式、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。模型和价格的实时列表。