你明明沒有換模型、Prompt 也一樣,答案卻突然變短、推理層級失真,甚至 tool call 開始吐錯 JSON。這不一定是模型「退化」;同一個 model slug 背後,可能換了不同的 serving endpoint。這篇 OpenRouter Provider Pinning 實戰會帶你拆開 endpoint accuracy、量化、輸出上限、reasoning template、tool parsing 與 fallback,最後做出一份能重跑的 12 題 pass/fail sheet。
如果你讀過 AlphaLab 的模型路由小型 Eval,那篇是在「換模型、固定任務」;本文反過來:固定同一模型,換 endpoint。讀完你會拿到兩種 production policy:品質優先,以及價格/延遲優先。
先說結論:你買到的不是 model slug
最重要的心智模型只有一行:
實際輸出 = 模型版本 × Endpoint 實作 × Routing policy
- Model slug 告訴你要哪個模型家族,不保證每個 endpoint 的量化、context、output cap 或模板完全相同。
- Provider pinning 讓 A/B 測試有乾淨歸因;但 pin 成功不代表品質合格,仍要跑自己的題目。
- Endpoint Accuracy Index 是 Artificial Analysis 的專有參考指標;本文自己的表只叫 task conformance/pass rate,兩者都不是「provider 通用智商分數」。

獨立研究機構 Artificial Analysis 在 2026 年 8 月推出的 Endpoint Accuracy Index,就是用官方權重的參考部署,對照 serverless endpoints 的 tool calling、科學推理與長上下文表現。它的 100% 代表與參考結果相當(還要看信賴區間),不是所有任務都答對。這個指標很有用,但仍是特定模型、特定題組、特定時間的快照;本文 12 題則量「你的 request contract 有沒有被遵守」,不拿來重製或冒充這個 Index。
OpenRouter Provider Pinning 前,先看懂五個差異層
1. 量化:位元變少不等於每題都等比例變差
量化(quantization)是用較低精度儲存或運算模型權重,換取更低成本與更高吞吐。FP8、FP4、BF16 可能在簡單聊天看不出差異,卻在長推理、程式或工具參數上放大誤差;效果也可能因模型與量化 recipe(量化方法、校準與混合精度的整套配方)而不同。想先理解推論堆疊,可搭配LLM 推論引擎教學。重點不是先認定「精度越低一定越笨」,而是把量化欄位存進 snapshot,再讓任務 Eval 決定。
2. Context 與 output cap:能讀多少、能寫多少是兩件事
Context length 是整個輸入與輸出可用的窗口;max_completion_tokens 則是單次最多能生成多少。輸出被截斷時,模型可能還沒完成推理就撞牆,表面看起來就像「變笨」。
一個可核對的例子:OpenRouter 的 gpt-oss-120b endpoint API 在 2026-08-08 顯示,deepinfra/bf16 與 deepinfra/turbo 都標為 BF16、context 都是 131,072,但最大輸出分別是 131,072 與 16,384。Artificial Analysis 的點時測量也測到兩者分數不同;其整體結果另指出 output limit 或較少 reasoning tokens 會伴隨更短的輸出。兩個現象同時出現,不能證明這組差距由 output cap、量化或任何單一因素造成。
3. Reasoning/chat template:參數有送到,不代表語意相同
Reasoning API 常把 low、high、xhigh 等級轉成模型自己的 token budget 或提示前綴。Provider 的 serving stack 若套錯 chat template、控制 token 或 reasoning prefix,同一個 level 就可能變成不同指令。OpenAI 的 gpt-oss implementation verifier 也提醒,Harmony 格式映射錯誤會連帶傷害 function calling。
社群在 DeepSeek V4 Flash 上曾用官方 encoder 的 prompt-token signature 當 canary(像煙霧警報器一樣的小探針);那是很好的「模板是否照版本實作」檢查,但數字只適用特定 checkpoint 與 encoder revision,不能抄成所有模型的永恆門檻。想看 DeepSeek-specific 案例,可接著讀DeepSeek V4 Flash API 與 reasoning_effort 實戰。
4. Tool parsing:JSON 能解析,不等於任務做對
工具能力至少要拆成三層:會不會選對 function、arguments 是否符合 schema、工具結果回來後能否完成任務。某 endpoint 可以在一般回答過關,卻因 parser 或控制 token 讓 tool call 失敗。所以 Eval 不能只問知識題,也不能只檢查「有 JSON」;要解析函式名、必填欄位、型別與額外欄位。
5. Router 與 fallback:你以為測 A,實際可能拿到 B
OpenRouter 預設會考量可用性、價格與負載分配。這對 production uptime 有幫助,卻會污染 endpoint A/B:A 失敗後若靜默 fallback 到 B,你記下的答案就不能歸因給 A。更細的陷阱是 base slug,例如 deepinfra 可能匹配該 provider 的多個 variant;要做乾淨實驗,必須用 endpoint API 回傳的完整 tag,例如 deepinfra/turbo。
OpenRouter 自己的 provider performance guide 也建議用自己的 prompts 評估延遲、吞吐、uptime 與量化,不把 leaderboard 當最終答案。本文再多加一層:先關 fallback 做 conformance,確認合格後才談 production routing。
OpenRouter Provider Pinning 實作:先抓 exact endpoint tag
第一步不是憑記憶寫 provider 名稱,而是即時抓 metadata。下面指令會列出 tag、量化、context、輸出上限與宣告支援的參數;欄位會變,請在每輪 Eval 一起保存抓取時間。
curl -sS \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
'https://openrouter.ai/api/v1/models/openai/gpt-oss-120b/endpoints' |
jq '.data.endpoints[] | {
provider_name, tag, quantization, context_length,
max_prompt_tokens, max_completion_tokens,
supported_parameters, pricing, status
}'
第二步才是 strict pin。依 OpenRouter provider selection 官方文件,乾淨 A/B 至少需要三個設定:
{
"provider": {
"order": ["exact-provider/variant-tag"],
"allow_fallbacks": false,
"require_parameters": true
}
}
order放完整 endpoint tag,固定候選與順序。allow_fallbacks:false禁止跑去清單外;單一 tag 才能固定路由候選,但不保證輸出逐字 deterministic。require_parameters:true排除未宣告支援 request 參數的 endpoint。它只是第一道篩選,不保證 reasoning level 或 tool parser 實作正確。
再加上 request header X-OpenRouter-Metadata: enabled。成功回應的 openrouter_metadata 可提供 route attempt、候選與 pipeline 線索;streaming 時在最後一個 chunk。Cache hit 一律不含 metadata,HTTP 500 也會省略;因此還要保存 response ID,必要時再查 generation audit 的 provider_name。詳細 routing 欄位以 router metadata 文件為準。
用 12 題 Eval 驗證 OpenRouter Provider Pinning
10–20 題適合當 exploratory smoke test,不足以宣布某 provider 在所有工作上最好。這裡採 12 題、每題 3 trials、兩個 endpoints,合計 72 requests;比只跑一次多一點抗隨機性,又不至於讓第一次實驗失控。若你還不熟 grader、回歸測試與資料集版本,可先讀AI Evals 七步教學。

四組題目怎麼設計
- C01–C04 Correctness:精確算術、數字比較、可機器判定的邏輯、固定日期運算。答案用 exact match 或明確 regex,不靠另一個 LLM 猜分。
- C05–C08 Format/Tools:三行固定格式、strict JSON Schema、正確 function+arguments、禁止多餘參數。這組最容易抓出 template 與 tool parser 分歧。
- C09–C10 Context/Output:把 canary 放在長文中段,以及要求完整 N 項+結尾 sentinel;同時檢查
finish_reason,避免把length截斷誤判成錯答。 - C11–C12 Reasoning conformance:同一題跑模型 metadata 當下宣告支援的兩個 effort levels,記答案與
reasoning_tokens中位數。沒有回傳 reasoning token 就標unobservable,不要硬填 0。
先凍結控制變因
每輪先保存 model canonical slug、endpoint metadata、request body、system prompt、tool schema、grader 版本與 prompt hash;provider/case 順序隨機化,在相同 client region、相近 concurrency 下至少跨兩個時段重跑。即使 temperature:0,GPU kernel(底層運算程式)、parallelism(多卡切分方式)、batching(請求如何湊批)或 serving stack 不同,也不保證逐字一致。
長 context 題要明確關閉 context compression,並檢查 metadata pipeline;若輸入被改寫,就把該 run 標成 invalid,另開一輪測 compression,而不是混進 endpoint accuracy。延遲測試則用 streaming 同時記第一個生成 token 的 TTFT 與第一個可見答案 token 的 TTFA;reasoning model 的兩者可能差很多。
完整 12 題 Python runner
下面是本文提供的完整 smoke-test runner:內含 12 題、tool/JSON/long-context graders、endpoint metadata preflight、timeout 記錄、generation audit 與 JSONL 輸出。它會先確認 exact tag 仍存在;若 model metadata 缺少 reasoning selector,或要求的 level 不在宣告清單內,也會在付費 request 前停止。按下執行前,仍要先用前面的 metadata 指令重抓當日 endpoint snapshot。
展開完整 endpoint_eval.py
#!/usr/bin/env python3
"""Small, repeatable OpenRouter endpoint smoke test (12 cases × N trials)."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import random
import statistics
import time
from pathlib import Path
import requests
BASE_URL = "https://openrouter.ai/api/v1"
API_URL = f"{BASE_URL}/chat/completions"
def long_context_prompt() -> str:
lines = [f"段落 {i:03d}:這是用來拉開上下文距離的無關填充文字。" for i in range(1, 321)]
lines[236] += " 唯一通關碼是 OR-PIN-731。"
return "閱讀全文,只回答唯一通關碼,不要解釋。\n" + "\n".join(lines)
CASES = [
{"id": "C01", "prompt": "2400 的 7.5% 是多少?只回答數字。", "kind": "exact", "want": "180"},
{"id": "C02", "prompt": "9.9 與 9.11 哪一個比較大?只回答較大的數字。", "kind": "exact", "want": "9.9"},
{"id": "C03", "prompt": "所有銅製物都導電;物件 X 不導電。只回答「X 不是銅製」,不得加其他文字。", "kind": "exact", "want": "X 不是銅製"},
{"id": "C04", "prompt": "2026-08-08 往後 17 天是哪一天?只用 YYYY-MM-DD。", "kind": "exact", "want": "2026-08-25"},
{"id": "C05", "prompt": "精確輸出三行:第一行 ONE、第二行 TWO、第三行 THREE。不得有其他文字。", "kind": "lines", "want": ["ONE", "TWO", "THREE"]},
{
"id": "C06",
"prompt": "回傳台北三日旅行資料,city 必須是 Taipei、days 必須是 3。",
"kind": "json",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "trip",
"strict": True,
"schema": {
"type": "object",
"properties": {"city": {"type": "string"}, "days": {"type": "integer"}},
"required": ["city", "days"],
"additionalProperties": False,
},
},
},
},
{"id": "C07", "prompt": "請查台北攝氏氣溫,必須使用工具。", "kind": "weather_tool"},
{"id": "C08", "prompt": "請用工具計算 17 + 25,不要加入未定義參數。", "kind": "add_tool"},
{"id": "C09", "prompt": long_context_prompt(), "kind": "contains", "want": "OR-PIN-731", "max_tokens": 80},
{"id": "C10", "prompt": "逐行列出 1 到 80,格式為「項目 N」,最後另起一行輸出 ENDPOINT_OK。", "kind": "sentinel", "want": "ENDPOINT_OK", "max_tokens": 600},
{"id": "C11", "prompt": "某正整數除以 2、3、4、5,餘數依序為 1、2、3、4。最小值是多少?只回答數字。", "kind": "exact", "want": "59", "reasoning": "low"},
{"id": "C12", "prompt": "某正整數除以 2、3、4、5,餘數依序為 1、2、3、4。最小值是多少?只回答數字。", "kind": "exact", "want": "59", "reasoning": "high"},
]
TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "取得城市氣溫",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["city", "unit"],
"additionalProperties": False,
},
},
},
{
"type": "function",
"function": {
"name": "add",
"description": "兩個整數相加",
"parameters": {
"type": "object",
"properties": {"a": {"type": "integer"}, "b": {"type": "integer"}},
"required": ["a", "b"],
"additionalProperties": False,
},
},
},
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--model", required=True, help="固定的 OpenRouter model slug")
parser.add_argument("--endpoint", action="append", required=True, help="exact endpoint tag;可重複")
parser.add_argument("--trials", type=int, default=3)
parser.add_argument("--out", type=Path, default=Path("endpoint-results.jsonl"))
parser.add_argument("--seed", type=int, default=731)
parser.add_argument("--effort-low", default="low", help="C11 使用;須是 model metadata 宣告支援的 level")
parser.add_argument("--effort-high", default="high", help="C12 使用;須是 model metadata 宣告支援的 level")
return parser.parse_args()
def request_body(model: str, endpoint: str, case: dict) -> dict:
body = {
"model": model,
"messages": [
{"role": "system", "content": "遵守格式;不知道就明說,不可捏造。"},
{"role": "user", "content": case["prompt"]},
],
"temperature": 0,
"max_completion_tokens": case.get("max_tokens", 220),
"provider": {
"order": [endpoint],
"allow_fallbacks": False,
"require_parameters": True,
},
}
if "reasoning" in case:
body["reasoning"] = {"effort": case["reasoning"]}
if "response_format" in case:
body["response_format"] = case["response_format"]
if case["id"] == "C09":
body["plugins"] = [{"id": "context-compression", "enabled": False}]
if case["kind"] in {"weather_tool", "add_tool"}:
body["tools"] = TOOLS
body["tool_choice"] = "auto"
return body
def tool_args(message: dict, name: str) -> dict | None:
calls = message.get("tool_calls") or []
if len(calls) != 1:
return None
fn = calls[0].get("function") or {}
if fn.get("name") != name:
return None
try:
return json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
return None
def score(case: dict, message: dict, finish_reason: str | None) -> tuple[bool, str]:
text = (message.get("content") or "").strip()
kind = case["kind"]
if kind == "exact":
return text == case["want"], text
if kind == "contains":
return case["want"] in text, text
if kind == "lines":
return text.splitlines() == case["want"], text
if kind == "json":
try:
obj = json.loads(text)
return obj == {"city": "Taipei", "days": 3}, text
except json.JSONDecodeError:
return False, text
if kind == "weather_tool":
args = tool_args(message, "get_weather")
ok = bool(args) and args.get("city") in {"台北", "臺北", "Taipei"} and args.get("unit") == "celsius" and set(args) == {"city", "unit"}
return ok, json.dumps(message.get("tool_calls"), ensure_ascii=False)
if kind == "add_tool":
args = tool_args(message, "add")
ok = bool(args) and args.get("a") == 17 and args.get("b") == 25 and set(args) == {"a", "b"}
return ok, json.dumps(message.get("tool_calls"), ensure_ascii=False)
if kind == "sentinel":
return case["want"] in text and finish_reason != "length", text
return False, text
def percentile(values: list[float], p: float) -> float | None:
if not values:
return None
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int((len(ordered) - 1) * p))]
def generation_audit(session: requests.Session, generation_id: str | None) -> dict:
if not generation_id:
return {}
for pause in (0, 0.5, 1.5):
if pause:
time.sleep(pause)
try:
response = session.get(f"{BASE_URL}/generation", params={"id": generation_id}, timeout=30)
except requests.RequestException:
continue
if response.ok:
return response.json().get("data") or {}
return {}
def selected_provider(metadata: dict) -> str | None:
available = (metadata.get("endpoints") or {}).get("available") or []
selected = next((item for item in available if item.get("selected")), None)
return (selected or {}).get("provider")
def main() -> int:
args = parse_args()
key = os.environ.get("OPENROUTER_API_KEY")
if not key:
raise SystemExit("請先設定 OPENROUTER_API_KEY")
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"X-OpenRouter-Metadata": "enabled",
"X-OpenRouter-Cache": "false",
})
author, slug = args.model.split("/", 1)
endpoint_doc = session.get(f"{BASE_URL}/models/{author}/{slug}/endpoints", timeout=30).json()["data"]
endpoint_by_tag = {item["tag"]: item for item in endpoint_doc["endpoints"]}
missing = sorted(set(args.endpoint) - set(endpoint_by_tag))
if missing:
raise SystemExit(f"exact endpoint tag 已變更或不存在,請先重抓 metadata:{missing}")
model_doc = session.get(f"{BASE_URL}/model/{author}/{slug}", timeout=30).json()["data"]
reasoning_doc = model_doc.get("reasoning") or {}
if "supported_efforts" not in reasoning_doc:
raise SystemExit("model metadata 未提供 reasoning effort selector;不要直接假設 level 可用")
supported_efforts = reasoning_doc.get("supported_efforts")
if isinstance(supported_efforts, list):
bad_efforts = sorted({args.effort_low, args.effort_high} - set(supported_efforts))
if bad_efforts:
raise SystemExit(f"model 未宣告支援這些 reasoning levels:{bad_efforts};supported={supported_efforts}")
elif supported_efforts is not None:
raise SystemExit(f"supported_efforts schema 無法辨識:{supported_efforts!r}")
cases = []
for original in CASES:
case = dict(original)
if case["id"] == "C11":
case["reasoning"] = args.effort_low
elif case["id"] == "C12":
case["reasoning"] = args.effort_high
cases.append(case)
jobs = [(trial, endpoint, case) for trial in range(1, args.trials + 1) for endpoint in args.endpoint for case in cases]
random.Random(args.seed).shuffle(jobs)
rows = []
args.out.parent.mkdir(parents=True, exist_ok=True)
with args.out.open("w", encoding="utf-8") as handle:
for trial, endpoint, case in jobs:
body = request_body(args.model, endpoint, case)
started = time.perf_counter()
try:
response = session.post(API_URL, json=body, timeout=180)
except requests.RequestException as exc:
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
row = {
"timestamp_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"model": args.model, "requested_endpoint": endpoint,
"case_id": case["id"], "trial": trial, "passed": False,
"http_status": None, "error_type": type(exc).__name__,
"elapsed_ms": elapsed_ms,
}
rows.append(row)
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
handle.flush()
print(endpoint, case["id"], "FAIL", type(exc).__name__)
continue
elapsed_ms = round((time.perf_counter() - started) * 1000, 1)
try:
data = response.json()
except ValueError:
data = {"raw": response.text[:1000]}
choice = (data.get("choices") or [{}])[0]
message = choice.get("message") or {}
finish_reason = choice.get("finish_reason")
passed, answer = score(case, message, finish_reason) if response.ok else (False, "")
usage = data.get("usage") or {}
details = usage.get("completion_tokens_details") or {}
metadata = data.get("openrouter_metadata") or {}
generation_id = response.headers.get("X-Generation-Id") or data.get("id")
audit = generation_audit(session, generation_id)
actual_provider = audit.get("provider_name") or selected_provider(metadata) or data.get("provider")
endpoint_snapshot = endpoint_by_tag[endpoint]
body_error = data.get("error") or choice.get("error")
error_meta = body_error.get("metadata", {}) if isinstance(body_error, dict) else {}
pipeline = metadata.get("pipeline") or []
compression_seen = any("context_compression" in str(item) for item in pipeline)
row = {
"timestamp_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"model": args.model,
"requested_endpoint": endpoint,
"expected_provider": endpoint_snapshot.get("provider_name"),
"actual_provider": actual_provider,
"endpoint_snapshot": {key: endpoint_snapshot.get(key) for key in (
"quantization", "context_length", "max_prompt_tokens",
"max_completion_tokens", "supported_parameters", "pricing", "status"
)},
"case_id": case["id"],
"trial": trial,
"prompt_sha256": hashlib.sha256(case["prompt"].encode()).hexdigest(),
"reasoning_effort": case.get("reasoning"),
"http_status": response.status_code,
"error_type": error_meta.get("error_type"),
"body_error": body_error,
"passed": passed and finish_reason != "error" and not compression_seen,
"invalid_context_compression": compression_seen,
"answer": answer,
"finish_reason": finish_reason,
"elapsed_ms": elapsed_ms,
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
"reasoning_tokens": details.get("reasoning_tokens"),
"cost": usage.get("cost"),
"generation_id": generation_id,
"routing_summary": metadata.get("summary"),
"routing_attempts": metadata.get("attempts"),
"routing_attempt": metadata.get("attempt"),
"routing_pipeline": pipeline,
"generation_audit": {key: audit.get(key) for key in (
"provider_name", "latency", "generation_time", "native_tokens_prompt",
"native_tokens_completion", "native_tokens_reasoning", "total_cost",
"finish_reason", "native_finish_reason", "num_fetches", "is_byok"
)},
}
rows.append(row)
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
handle.flush()
print(endpoint, case["id"], "PASS" if row["passed"] else "FAIL", response.status_code)
for endpoint in args.endpoint:
subset = [row for row in rows if row["requested_endpoint"] == endpoint]
elapsed = [row["elapsed_ms"] for row in subset if row["http_status"] == 200]
costs = [row.get("cost") for row in subset if isinstance(row.get("cost"), (int, float))]
rate = sum(row["passed"] for row in subset) / len(subset)
errors = sum(row.get("http_status") != 200 or row.get("finish_reason") == "error" or bool(row.get("body_error")) for row in subset) / len(subset)
print(json.dumps({
"endpoint": endpoint,
"pass_rate": round(rate, 4),
"error_rate": round(errors, 4),
"latency_p50_ms": percentile(elapsed, 0.50),
"latency_p90_ms": percentile(elapsed, 0.90),
"total_cost": round(sum(costs), 8),
}, ensure_ascii=False))
for effort, case_id in [("low", "C11"), ("high", "C12")]:
tokens = [row.get("reasoning_tokens") for row in subset if row["case_id"] == case_id and isinstance(row.get("reasoning_tokens"), int)]
if tokens:
print(endpoint, effort, "reasoning_tokens_median", statistics.median(tokens))
return 0
if __name__ == "__main__":
raise SystemExit(main())
安裝、設定與執行
python3 -m venv .venv
source .venv/bin/activate
python -m pip install requests
export OPENROUTER_API_KEY='你的金鑰'
python endpoint_eval.py --model openai/gpt-oss-120b --endpoint deepinfra/bf16 --endpoint deepinfra/turbo --trials 3 --out endpoint-results.jsonl
上面的兩個 tag 是 2026-08-08 catalog 快照;若 endpoint 已改名,runner 會在發出付費 request 前停止。它會把隨機化後的進度與 endpoint summary 印到終端,逐筆結果寫入 endpoint-results.jsonl,格式如下:
{
"requested_endpoint": "deepinfra/bf16",
"actual_provider": "DeepInfra",
"case_id": "C01",
"trial": 1,
"passed": true,
"finish_reason": "stop",
"elapsed_ms": 0.0,
"reasoning_tokens": null,
"cost": 0.0,
"generation_id": "gen-..."
}
這一行只是欄位形狀,不是本文宣稱的 benchmark 結果;真正數字由你的帳號、地區與執行時間產生。若要量 TTFT/TTFA,請把同一 request 改成 streaming,分別在第一個 reasoning/content delta 到達時打 monotonic timestamp;不要用非 streaming 的總 elapsed 冒充 TTFT。
一筆 run 要按四層順序判讀
第一層先看路由完整性。Request 中的 exact tag 必須仍存在,allow_fallbacks 為 false,metadata 的 attempt 應是 1,generation audit 的 provider name 要與 endpoint snapshot 對得上。回應通常只告訴你 provider display name,不一定回傳完整 variant tag,所以證據鏈要由「事前 snapshot+strict request+事後 attempt/provider」三段拼起來。
第二層看執行是否完整。HTTP 200 之後仍要排除 body error、finish_reason:error、空輸出與 finish_reason:length。例如 C10 少了 ENDPOINT_OK,而 finish reason 又是 length,這一筆應記為 truncated,不是籠統塞進 wrong answer;工具題的 content 可以合法為空,只要 tool_calls 完整。
第三層才跑 task grader。Correctness 用 exact/regex,JSON 與 tool call 用 parser/schema,reasoning conformance 看同版本多次的正確率與 token 分布。最後一層才比較 economics:把總成本除以通過題數,再看 p90 elapsed。如此才能避免某 endpoint 因為大量失敗、短答或截斷,看起來反而「又快又省」。四層分欄也讓事故回溯有明確責任邊界:究竟是路由污染、服務中斷、格式不合約,還是真的答錯。
Pass/fail sheet 要記什麼,才找得到真正原因?
至少保留六組欄位:
- Lineage:run time、suite version、client region、prompt/tool schema hash。
- Request:model、exact endpoint tag、reasoning effort、temperature、output cap、fallback 設定。
- Endpoint snapshot:provider name、quantization、context、max completion、supported parameters、pricing、status。
- Route:actual provider、attempt、attempts、pipeline、generation ID;metadata 缺失就明寫 unknown。
- Outcome:answer、tool calls、grader pass、HTTP、body error、empty output、
finish_reason與 truncation。 - Performance/cost:TTFT、TTFA、elapsed、tokens、reasoning tokens、實際
usage.cost。
總表不要只給平均分。至少列 overall pass、tool/schema pass、error/empty/truncation rate、p50/p90 latency、平均 cost per call,以及更實用的 cost per pass=總成本 ÷ 通過題數。短測試不應宣稱 production p99 或長期 uptime;20 題過 16 題仍是小樣本,適合做 gate,不適合包裝成普遍定律。
如果是工具敏感的工作流,一組可落地的起始 gate 可以是:12 題的 correctness 至少 11 題三次都過、兩題 tool/schema 必須 6/6、smoke run 不得出現 fallback/empty/truncation,再用已通過者的 p90 與 cost per pass 決勝。這些欄位是部署規則,不是學術 benchmark;日後新增真實事故題時,suite version 與門檻要一起更新。
持續運行後,可把同一份欄位接到Agent Observability:endpoint 版本或 error rate 改變時,自動觸發 regression suite。這比事故後才看平均 latency 更早發現 silent drift。
從同一張表,產出兩種 routing policy

Policy A:Accuracy-first
先要求 correctness、tool/schema 與可靠度全部過 gate,再依答案品質排 exact tags。allow_fallbacks:false 的意思不是完全失去備援,而是只在你列出的合格清單內依序嘗試,不滑向未知 endpoint。
{
"provider": {
"order": ["winner-exact-tag", "runner-up-exact-tag"],
"allow_fallbacks": false,
"require_parameters": true
}
}
這適合 tool agent、財務工作流或錯一次就很貴的任務。代價是合格清單同時故障時會明確失敗,因此要由你的Agent Harness負責 retry、降級與人工接手。
Policy B:Price/latency-first
先從 Eval 產生 passed_tags allowlist,再讓 router 在合格池裡按價格或延遲排序。這不是「選全站最便宜」,而是「在任務品質不跌破底線後選最便宜」。
{
"provider": {
"only": ["passed-tag-a", "passed-tag-b"],
"sort": "price",
"allow_fallbacks": true,
"require_parameters": true,
"max_price": {"prompt": 1, "completion": 3}
}
}
sort:"latency" 可改成速度優先;preferred_max_latency 與 preferred_min_throughput 使用近五分鐘統計做軟偏好,不會硬排除 endpoint,也不是 SLA。要硬限制價格才用 max_price,其中 prompt/completion 的單位是每百萬 tokens 美元。別忘了 production fallback 開啟後,每次仍要記實際命中者。
六個最常見的誤判
- 把 base provider 當 exact pin:
deepinfra可能含多個 variant;重抓完整 tag。 - 看到 HTTP 200 就算成功:還要看 body error、
finish_reason:error、空輸出與 tool calls。 - 看到短答案就說模型笨:先檢查
finish_reason:length、output cap、timeout 與 unsupported parameter。 - 看到 FP4 就直接判輸:量化影響依任務與 recipe 而異;
unknown更不等於 full precision。 - 用標價取代實際成本:reasoning、cache、request 或 image 費用可能存在,以 response 的
usage.cost與 cost per pass 為準。 - 封一個永久冠軍:tag、runtime、模型版本與 provider 政策都會變;metadata 有變或每週/每次 release 就重跑。
FAQ:Endpoint Accuracy 與 Provider Pinning
同一模型、同一量化,輸出就應該完全一樣嗎?
不會。硬體、kernel、parallelism、batching、模板與 serving runtime 都可能造成差異;評估重點是任務 conformance,不是逐 token identical。
只用 provider.order 就算 pin 嗎?
不算嚴格 pin。乾淨 A/B 要用單一 exact endpoint tag,再加 allow_fallbacks:false;base provider slug 仍可能命中不同 variant。
require_parameters:true 能保證 reasoning effort 正確嗎?
不能。它只排除未宣告支援 request 參數的 endpoint;語意是否正確仍靠 conformance test。
Reasoning high 的 token 一定要比 low 多嗎?
不要把它當跨模型定律。比較同模型、同版本、同題目的多次分布;若 high 長期少於 low 且正確率下降,把它標成複查訊號。
小型 Eval 至少跑幾次?
第一次可從每題 3 trials 開始。之後針對高變異或高風險題加樣本,並跨時段重跑;不要把 10–20 題宣稱成全域 benchmark。
測延遲只看平均值可以嗎?
不夠。至少看 p50、p90、error rate;streaming reasoning model 還要分 TTFT 與 TTFA。小樣本不要假裝有可信的 p99。
Production 應該關掉所有 fallback 嗎?
Eval 階段應關;production 視風險決定。若要 resilience,只在已通過品質 gate 的 exact tag allowlist 內 fallback,並記錄實際路由。
什麼時候要重跑 Endpoint Eval?
模型 canonical slug、endpoint metadata、價格、runtime 或你的 prompt/tools 變更時都要重跑。高風險產線再加固定週期與異常觸發。
下一步:把「感覺變笨」變成可追查的事故
真正可靠的順序是:抓 endpoint snapshot → 單一 exact tag 關 fallback → 12 題各跑 3 次 → 分開記錯答、截斷、錯誤與成本 → 先設品質 hard gate → 再產生 accuracy-first 或 price/latency-first policy。這套流程不會保證某家永遠最好,卻能讓你在 provider 變動時知道「哪一層改了、哪一題先壞」。
若你正把這套能力放進自己的 AI 系統,可從最小 Agent Harness 實作開始,把 endpoint snapshot、grader 與 routing policy 當成 deployment gate;想系統化學習整條 AI 實作路線,也可以查看 AlphaLab 的課程與學習資源。



