跳到主要內容

【2026 最新】同一個模型為何換 Provider 就變笨?OpenRouter Provider Pinning 與 Endpoint Accuracy 實戰

最後更新: ·
OpenRouter Provider Pinning 與 Endpoint Accuracy 實戰首圖

你明明沒有換模型、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 通用智商分數」。
同一模型經 OpenRouter router 走向不同 endpoint,因量化、輸出上限與解析器而得到不同結果的流程圖
同一個 slug 只固定模型名稱;endpoint 實作與 routing policy 仍會改變你真正拿到的服務。

獨立研究機構 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/bf16deepinfra/turbo 都標為 BF16、context 都是 131,072,但最大輸出分別是 131,072 與 16,384。Artificial Analysis 的點時測量也測到兩者分數不同;其整體結果另指出 output limit 或較少 reasoning tokens 會伴隨更短的輸出。兩個現象同時出現,不能證明這組差距由 output cap、量化或任何單一因素造成。

3. Reasoning/chat template:參數有送到,不代表語意相同

Reasoning API 常把 lowhighxhigh 等級轉成模型自己的 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 auditprovider_name。詳細 routing 欄位以 router metadata 文件為準。

用 12 題 Eval 驗證 OpenRouter Provider Pinning

10–20 題適合當 exploratory smoke test,不足以宣布某 provider 在所有工作上最好。這裡採 12 題、每題 3 trials、兩個 endpoints,合計 72 requests;比只跑一次多一點抗隨機性,又不至於讓第一次實驗失控。若你還不熟 grader、回歸測試與資料集版本,可先讀AI Evals 七步教學

OpenRouter endpoint 12 題 smoke test,分成 correctness、format and tools、context and output、reasoning 四組
12 題不是通用排行榜,而是一張能固定版本、重跑與追蹤退化的最小工作表。

四組題目怎麼設計

  1. C01–C04 Correctness:精確算術、數字比較、可機器判定的邏輯、固定日期運算。答案用 exact match 或明確 regex,不靠另一個 LLM 猜分。
  2. C05–C08 Format/Tools:三行固定格式、strict JSON Schema、正確 function+arguments、禁止多餘參數。這組最容易抓出 template 與 tool parser 分歧。
  3. C09–C10 Context/Output:把 canary 放在長文中段,以及要求完整 N 項+結尾 sentinel;同時檢查 finish_reason,避免把 length 截斷誤判成錯答。
  4. 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

從同一份 endpoint eval 產出 accuracy-first 與 price latency-first 兩種 OpenRouter routing policy
兩種 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_latencypreferred_min_throughput 使用近五分鐘統計做軟偏好,不會硬排除 endpoint,也不是 SLA。要硬限制價格才用 max_price,其中 prompt/completion 的單位是每百萬 tokens 美元。別忘了 production fallback 開啟後,每次仍要記實際命中者。

六個最常見的誤判

  1. 把 base provider 當 exact pin:deepinfra 可能含多個 variant;重抓完整 tag。
  2. 看到 HTTP 200 就算成功:還要看 body error、finish_reason:error、空輸出與 tool calls。
  3. 看到短答案就說模型笨:先檢查 finish_reason:length、output cap、timeout 與 unsupported parameter。
  4. 看到 FP4 就直接判輸:量化影響依任務與 recipe 而異;unknown 更不等於 full precision。
  5. 用標價取代實際成本:reasoning、cache、request 或 image 費用可能存在,以 response 的 usage.cost 與 cost per pass 為準。
  6. 封一個永久冠軍: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 的課程與學習資源

AlphaLab 精選

接著閱讀

ALPHALAB 社群

有問題?來 Telegram 聊

和 Terry、編輯、其他網友一起討論這篇文章。提問、分享觀點,回覆更即時。

加入 Telegram 討論

📩 訂閱 AlphaLab 電子報

每週一封,第一時間收到新文章與投資觀察。

我們不會 spam,隨時可退訂。