你写的 Agent 里大概有一行这样的配置:
model = "xxx"。它今天能用,不代表下周还能用——模型会下线、会改名、会被地区限制、会因为你的max_tokens太小而返回一个"HTTP 200 但内容为空"的完美假成功。
这篇文章讲的是多模型路由:怎么知道一个端点"活着但能不能推理"、哪些错误该换模型、哪些错误换了也没用、以及怎么按真实价格和真实延迟选路。文中所有响应体、耗时、token 数、价格都是 2026-09-25 在本机实测抓下来的。
路由的目的一般被误解成"用更强的模型"。不是。 路由解决的是三个跟模型强弱无关的问题:
第三条最容易被忽略,也最贵。把配置错误当成网络抖动去重试,等于把一个 5 分钟的修复变成一个 40 分钟的故障——因为你的降级链会把所有模型都试一遍,全部失败,然后日志里只有一句"所有模型都不可用",真正的原因(模型 ID 打错了)被埋在中间某行。
所以整篇文章的组织方式是:先做错误分类,再做降级,最后才是选路。
我用一个测试期已结束的模型 ID 发请求,拿到的原文是:
HTTP 404
{"error":{"message":"Thank you for participating in the Stealth Ox Alpha testing period.
This model was ZAI's GLM-5.3 Flash. Use it now:
https://openrouter.ai/z-ai/glm-5.3-flash","code":404},
"user_id":"user_3IHYSY75rfdcrf1QtNybuGd4rAY"}
这类"墓碑响应"非常友好,但它只在聚合平台才可能存在。直连厂商 API 时,模型下线通常就是一个光秃秃的 404 或 400,你只能靠告警发现。
顺手抓了同一时刻的模型列表:GET https://openrouter.ai/api/v1/models 返回 460 个模型(755,701 字节 JSON)。里面既有 stealth/space-bunny-alpha 这种测试期代号模型(免费、1M 上下文、支持工具调用),也有大量你没有听说过的厂商 ID。
结论:模型 ID 是一个会腐烂的配置项。任何一个把模型名硬编码在业务代码里的系统,都需要一条"模型已下线"的告警路径,而不是等用户报错。
这是本次实测里最有价值的一条。我用 max_tokens=16 发了两条最小请求,两个模型都返回 HTTP 200:
| 模型 | HTTP | finish_reason | completion_tokens | reasoning_tokens | content |
|---|---|---|---|---|---|
| z-ai/glm-5.3-flash | 200 | length | 16 | 16 | ''(空) |
| openai/gpt-oss-20b | 200 | length | 16 | 13 | ''(空) |
看清楚:这次请求全部 16 个 completion token 都被 reasoning(思考)吃掉了,一个字正文都没留下。HTTP 层没有任何异常,usage 也正常返回、正常计费(glm-5.3-flash 这次花了 $1.085e-05)。
如果你的 Agent 流程只检查 HTTP 状态码,它会认为这一步成功了,然后拿着空字符串继续往下走——最后在生产环境的某个角落爆出一个莫名其妙的下游错误。
复现与修复验证(同一批模型,只改 max_tokens):
| 模型 | max_tokens=16 | max_tokens=64 |
|---|---|---|
| z-ai/glm-5.3-flash | content='',finish=length,completion=16,reasoning=16,$1.085e-05 |
content='ok',finish=stop,completion=46,reasoning=48,$2.585e-05 |
| openai/gpt-oss-20b | content='',finish=length,completion=16,reasoning=13,$3.98e-06 |
content='ok',finish=stop,completion=32,reasoning=21,$6.38e-06 |
所以判据很明确:
finish_reason == "length"且content为空 = 失败,不是成功。
尤其当usage.completion_tokens_details.reasoning_tokens接近max_tokens时,这不是模型的问题,是你的预算给少了。
本地 llama.cpp 服务没启动时,GET http://127.0.0.1:8080/v1/models 直接连接失败(URLError,2004ms 后返回)。这还好,至少你知道它死了。
真正危险的是反面:服务起来了、/v1/models 毫秒级秒回、但 /chat/completions 超时。这在本地推理里非常常见——模型权重(比如 18GB 的 31B Q4)放进 20GB 显存,KV cache 溢出到系统内存,元数据接口照样秒回,推理请求直接卡死或 OOM。
这就是为什么健康检查必须分两层:
| 探测 | 请求 | 回答的问题 | 耗时级别 |
|---|---|---|---|
| liveness | GET {base}/models |
进程活着吗?网络通吗? | 毫秒 |
| readiness | POST {base}/chat/completions(1~16 token) |
现在能不能真的推理? | 秒级 |
只做 liveness 的健康检查,等于用体温计判断人能不能跑马拉松。
这是整个路由逻辑的核心。下面每一行都是我实测抓到的真实响应体:
| HTTP / 异常 | 分类 | 真实响应体(节选) | 能不能换下一个模型解决 |
|---|---|---|---|
| 400 | config | {"error":{"message":"no-such-model-xyz-9999 is not a valid model ID","code":400}} |
❌ 不能。ID 打错了,换谁都错 |
| 401 | config | 密钥无效/未授权 | ❌ 不能 |
| 402 | config | 余额不足 | ❌ 不能。换个模型可能更贵,但根源是钱 |
| 403 | config | {"error":{"message":"This model is not available in your region.","code":403,"metadata":{"routing_funnel":[{"step":"Initial Endpoints","endpoint_count":1}],"failed_routing_step":"Gate Endpoints with Geo Restrictions"}} |
❌ 不能。地区限制是账号/网络属性 |
| 404 | gone | 见故障 1 的墓碑文案 | ✅ 能,且必须告警(配置在腐烂) |
| 408 / 409 / 425 | transient | 请求超时 / 冲突 / 过早 | ✅ 能 |
| 429 | transient | 限流,看 Retry-After |
✅ 能(退避后) |
| 5xx | transient | 上游错误 | ✅ 能 |
| 连接失败 / 读超时 | transient | URLError / TimeoutError |
✅ 能 |
| 200 + content 为空 | empty | 见故障 2 | ✅ 能(但先检查 max_tokens) |
规则的形状就一句话:
config 类错误立即终止降级链并告警;gone 类降级并告警;transient / empty 类降级。
为什么 config 类必须终止?因为它的存在说明"配置与现实的契约已经破了"。如果这种错误也走降级,你的系统会用第二个、第三个模型接着失败,最终把"配置错误"这个唯一真因淹没在一堆下游噪音里——降级链会把显式故障变成隐式故障。
注意 403 的一个细节:那个响应体里的 metadata.routing_funnel / failed_routing_step 字段是聚合平台给的归因信息(这里明确说是"Gate Endpoints with Geo Restrictions"这一步挂的)。这类字段值得直接写进你的日志,因为它是少见的"上游主动告诉你为什么"。
下面这个脚本我实际跑过,就是它产生了上文那张 trace 表。设计上有三个刻意的地方:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""route_probe.py — 多模型路由的"体检 + 降级链"最小可运行实现。"""
from __future__ import annotations
import json, os, time, urllib.error, urllib.request
from dataclasses import dataclass, field
CALLER = "route-probe/1.0"
@dataclass
class Route:
name: str
base_url: str
model: str
key_env: str | None = None
ctx: int = 0
price_in: float = 0.0 # 美元 / 百万 input token
price_out: float = 0.0
max_tokens: int = 16
@dataclass
class Attempt:
route: str
status: int | None = None
cls: str = "ok" # ok/config/gone/transient/empty
ms: int = 0
detail: str = ""
content: str = ""
usage: dict = field(default_factory=dict)
def _key(route: Route) -> str:
if not route.key_env:
return "none"
val = os.environ.get(route.key_env, "").strip()
if val:
return val
# 允许从 .env 读取,避免把密钥写进代码
for path in (os.path.expanduser("~/AppData/Local/hermes/.env"),
os.path.expanduser("~/.env")):
if os.path.isfile(path):
for line in open(path, encoding="utf-8", errors="replace"):
if line.startswith(route.key_env + "="):
return line.split("=", 1)[1].strip().strip('"')
return ""
def _post_json(url: str, payload: dict, key: str, timeout: float):
req = urllib.request.Request(url, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {key}")
req.add_header("User-Agent", CALLER)
data = json.dumps(payload).encode("utf-8")
with urllib.request.urlopen(req, data=data, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8", "replace"))
def _get(url: str, key: str, timeout: float):
req = urllib.request.Request(url)
req.add_header("Authorization", f"Bearer {key}")
req.add_header("User-Agent", CALLER)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
def classify_http(status: int, body: str) -> tuple[str, str]:
"""把 HTTP 状态码翻译成「能不能靠换模型解决」。"""
try:
msg = json.loads(body).get("error", {}).get("message") or body[:160]
except Exception:
msg = body[:160]
if status in (400, 401, 403, 422):
return "config", msg
if status == 404:
return "gone", msg
if status in (408, 409, 425, 429) or status >= 500:
return "transient", msg
return "config" if status >= 400 else "ok", msg
def probe_liveness(route: Route, timeout: float = 5.0) -> tuple[bool, int, str]:
"""只读元数据:判断「进程活没活」。秒回不代表能推理(VRAM 不足的典型表现)。"""
url = route.base_url.rstrip("/") + "/models"
t0 = time.time()
try:
status, _ = _get(url, _key(route), timeout)
return status == 200, int((time.time() - t0) * 1000), f"HTTP {status}"
except urllib.error.HTTPError as e:
return False, int((time.time() - t0) * 1000), f"HTTP {e.code}"
except Exception as e:
return False, int((time.time() - t0) * 1000), type(e).__name__
def probe_readiness(route: Route, timeout: float = 60.0) -> Attempt:
"""真跑一次最小推理。这里才是「能不能用」的判据。"""
url = route.base_url.rstrip("/") + "/chat/completions"
payload = {"model": route.model,
"messages": [{"role": "user", "content": "Reply with the single word: ok"}],
"max_tokens": route.max_tokens,
"temperature": 0}
t0 = time.time()
try:
status, body = _post_json(url, payload, _key(route), timeout)
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", "replace")
cls, detail = classify_http(e.code, raw)
return Attempt(route.name, e.code, cls, int((time.time() - t0) * 1000), detail)
except Exception as e:
return Attempt(route.name, None, "transient",
int((time.time() - t0) * 1000), type(e).__name__)
ms = int((time.time() - t0) * 1000)
if status != 200:
cls, detail = classify_http(status, json.dumps(body))
return Attempt(route.name, status, cls, ms, detail)
choice = (body.get("choices") or [{}])[0]
content = ((choice.get("message") or {}).get("content") or "").strip()
finish = choice.get("finish_reason")
usage = body.get("usage") or {}
if not content:
# HTTP 200 但没有正文:最常见的原因是 token 预算被 reasoning 吃光,
# 或上游 safety 过滤返回空。这不是成功。
return Attempt(route.name, status, "empty", ms,
f"finish_reason={finish} usage={usage}", content, usage)
return Attempt(route.name, status, "ok", ms, f"finish_reason={finish}", content, usage)
def run_chain(chain: list[Route], ledger_path: str | None = None,
per_timeout: float = 60.0) -> tuple[Attempt | None, list[Attempt]]:
"""按顺序体检 + 推理,第一个真正返回正文的模型胜出。"""
trace: list[Attempt] = []
for route in chain:
live, lms, ldetail = probe_liveness(route)
if not live:
trace.append(Attempt(route.name, None, "transient", lms,
f"liveness failed: {ldetail}"))
continue
a = probe_readiness(route, timeout=per_timeout)
trace.append(a)
if a.cls == "ok":
_write_ledger(ledger_path, route, a, trace)
return a, trace
if a.cls == "config":
# 配置错误换模型解决不了,继续试只会把真因埋掉
_write_ledger(ledger_path, route, a, trace)
return None, trace
return None, trace
def _write_ledger(path: str | None, route: Route, winner: Attempt, trace: list[Attempt]):
if not path:
return
row = {"ts": int(time.time()), "winner": route.name, "model": route.model,
"attempts": [{"route": a.route, "cls": a.cls, "status": a.status,
"ms": a.ms, "detail": a.detail} for a in trace]}
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
if __name__ == "__main__":
OR = "https://openrouter.ai/api/v1"
chain = [
Route("local-llama", "http://127.0.0.1:8080/v1", "Muse-Glimmer-30B", None, 131072),
Route("removed-id", OR, "stealth/ox-alpha", "OPENROUTER_API_KEY", 1000000, 0.075, 0.3),
Route("glm-5.3-flash", OR, "z-ai/glm-5.3-flash", "OPENROUTER_API_KEY", 1300000, 0.075, 0.3),
Route("gpt-oss-20b", OR, "openai/gpt-oss-20b", "OPENROUTER_API_KEY", 131072, 0.018, 0.09),
Route("mistral-nemo", OR, "mistralai/mistral-nemo", "OPENROUTER_API_KEY", 131072, 0.019, 0.03),
]
winner, trace = run_chain(chain, ledger_path="route_ledger.jsonl")
print(f"{'route':<16}{'status':>7}{'cls':>11}{'ms':>8} detail")
for a in trace:
print(f"{a.route:<16}{str(a.status):>7}{a.cls:>11}{a.ms:>8} {a.detail[:70]}")
print("\nwinner:", winner.route if winner else "NONE", "|",
(winner.content if winner else ""))
route status cls ms detail
local-llama None transient 2004 liveness failed: URLError
removed-id 404 gone 789 Thank you for participating in the Stealth Ox Alpha testing period...
glm-5.3-flash 200 empty 2429 finish_reason=length usage={'prompt_tokens': 19, ...
gpt-oss-20b 200 empty 1835 finish_reason=length usage={'prompt_tokens': 74, ...
mistral-nemo 200 ok 1175 finish_reason=stop
winner: mistral-nemo | ok
一次运行同时命中四类失败:transient(端点死)、gone(模型下线)、empty(假成功)×2,最后靠链尾最便宜的模型($0.019/M in、$0.030/M out)成功返回。
{"ts": 1790316165, "winner": "mistral-nemo", "model": "mistralai/mistral-nemo",
"attempts": [
{"route": "local-llama", "cls": "transient", "status": null, "ms": 2004,
"detail": "liveness failed: URLError"},
{"route": "removed-id", "cls": "gone", "status": 404, "ms": 789,
"detail": "Thank you for participating in the Stealth Ox Alpha testing period..."},
{"route": "glm-5.3-flash", "cls": "empty", "status": 200, "ms": 2429,
"detail": "finish_reason=length usage={... 'reasoning_tokens': 16 ...}"},
{"route": "gpt-oss-20b", "cls": "empty", "status": 200, "ms": 1835,
"detail": "finish_reason=length usage={... 'reasoning_tokens': 13 ...}"},
{"route": "mistral-nemo", "cls": "ok", "status": 200, "ms": 1175,
"detail": "finish_reason=stop"}]}
账本里要记的不是"成功了",而是"谁在第几位、因为什么被淘汰"。 否则你永远不知道降级链是不是每天都在悄悄救火。
| 约束 | 判据 | 为什么必须有 |
|---|---|---|
| 上下文长度 | 模型 ctx ≥ 实测最长输入 × 1.2 | 贴边必然在某一批长输入上翻车 |
| 工具调用能力 | supported_parameters 含 tools |
不带工具能力的模型无法进入 Agent 循环 |
| 地区可用性 | 见上文 403 类的 failed_routing_step |
账号/网络属性,跟模型质量无关 |
| 数据合规 | 是否允许把数据发给该厂商 | 这是法律约束,不是性能约束 |
| 延迟预算 | 交互场景 vs 批处理场景 | 交互场景延迟 >10s 就等于不可用 |
我在实测那一刻抓的模型列表里:460 个模型,其中 392 个支持工具调用,416 个上下文 ≥128K,175 个 ≥1M。约束过滤完通常还能剩几百个候选——所以"候选不足"几乎从来不是问题,"选错判据"才是。
同一时刻从 GET /api/v1/models 算出来的输入价格分布(美元 / 百万 token,已剔除 5 个用负值表示"动态路由"的伪模型):
| 分位 | 输入价格 |
|---|---|
| min | $0(24 个免费模型) |
| p25 | $0.117 |
| 中位 | $0.42 |
| p75 | $1.25 |
| p90 | $3.00 |
| max | $150.00 |
最贵与中位相差 357 倍。 这个数字的意义是:如果你按"榜单第一名"选模型,你很可能为一个只需要 20 分类的任务付出 100 倍成本。反过来,满足"≥128K 上下文 + 支持工具调用"的最便宜付费档在实测中是:
| 模型 | 输入 | 输出 | 上下文 |
|---|---|---|---|
| ibm-granite/granite-4.0-h-micro | $0.017/M | $0.112/M | 131,000 |
| openai/gpt-oss-20b | $0.018/M | $0.090/M | 131,072 |
| mistralai/mistral-nemo | $0.019/M | $0.030/M | 131,072 |
做法:把价格当成过滤器,而不是决策器。 先用价格切出"预算内可接受集",再在集合里用你自己的样本选(见第六节)。价格表会变,而且变得比你想的快——所以价格要定期重抓,不能写死在代码里。
同一次实测里三个小模型的耗时:glm-5.3-flash 1252ms、gpt-oss-20b 2706ms、mistral-nemo 1175ms(同样是"回一个词"的最小请求)。同一时刻、同一个平台、同样的请求,延迟差 2.3 倍。 榜单上的综合分跟你的具体请求形态相关性很弱。
写法:每个路由维护一个 EWMA 延迟(new = 0.7 * old + 0.3 * observed),超过预算就临时降权,而不是永久移除。延迟是环境变量(上游排队、地域、时段),不是模型属性。
这是路由里唯一一个会让你"降级反而更贵"的坑。
实测证据:openai/gpt-oss-20b 那次调用返回的 usage 里 prompt_tokens=74,其中 cached_tokens=64。也就是说 86% 的输入命中了前缀缓存,按缓存价计费(cost_details.upstream_inference_prompt_cost 只有 $1.58e-06)。
而换一个模型 = 换一套前缀缓存 = 缓存命中率归零。所以:
| 场景 | 路由策略 |
|---|---|
| 同一次多轮对话 / 同一个 Agent 任务 | 粘性(sticky):一次选定,全程不换。中途降级只应发生在真正的 transient/gone 失败之后,并且从头重放上下文 |
| 不同任务之间 | 可以自由换。任务边界是唯一的免费换模型时机 |
| 周期性批量任务 | 按批选路,批内固定 |
| 低成本小任务(分类/打分/抽取) | 允许每次单独选,缓存收益本来就小 |
一条推论:"为了省钱"而把一次长对话里的模型切来切去,通常会让总成本上升。 换模型省下的是单价,丢掉的是缓存命中率——两者经常后者更大。
现代聚合平台会在每个响应里回传真实成本。实测:
| 调用 | completion_tokens | 真实 cost |
|---|---|---|
| glm-5.3-flash(max_tokens=16,空返回) | 16 | $1.085e-05 |
| glm-5.3-flash(max_tokens=64,成功) | 46 | $2.585e-05 |
| gpt-oss-20b(max_tokens=16,空返回) | 16 | $3.98e-06 |
| gpt-oss-20b(max_tokens=64,成功) | 32 | $6.38e-06 |
两个记账纪律:
总花费 / 有效产出数(有效产出 = 通过你下游校验的产物)。如果降级链让请求数翻倍但产出没变,你的单位产出成本是上升的。再进一步:把成本按路由分桶(哪个模型贡献了多少花费、多少有效产出),这是唯一能回答"这条链里最贵的一环是谁"的读数。
| 反模式 | 为什么会出事 | 改成 |
|---|---|---|
| 把 config 类错误也走降级 | 所有模型都试一遍全失败,真因(ID 打错/没余额)被埋 | config 类立即终止并告警 |
| 同一错误无限重试同一模型 | 重试的是自己的固执,不是上游 | 同因失败两次就停:要么分类错误,要么告警 |
只用 GET /models 做健康检查 |
服务活着但推理不可用(VRAM 溢出)照样放行 | liveness + readiness 双探测 |
| 只检查 HTTP 状态码 | HTTP 200 且 content 为空会被当成功 | 判据加"正文非空 + finish_reason 合理" |
| 静默降级不告警 | 链条每天救火,你却以为一切正常 | gone 类降级必须告警(配置腐烂信号) |
| 按榜单/口碑硬编码模型 | 价差 357 倍,且榜单跟你任务无关 | 约束 → 价格 → 自测样本 |
| 长对话中途为了省钱切模型 | 缓存命中率归零,总成本反而上升 | 任务内粘性,任务边界才换 |
探测层
GET /models)与 readiness(1 token 推理)分开实现分类层
finish_reason == "length" 时同时检查 reasoning_tokens 占比选路层
记账层
模型路由的工程本质一句话:把"哪个模型"从代码里的常量,变成一个在运行时被证据驱动的决策。
而这套东西的价值全部集中在分类上——因为只有"该换"和"不该换"分得清,降级才是修复而不是掩盖。 HTTP 200 却返回空内容的那次实测最能说明问题:状态码说了算的系统认为那是成功,判据说了算的系统知道那是失败。
先把错误分类表和探测逻辑写出来,剩下的(价格、延迟、粘性)都是它的自然推论。
本文是「AI Agent 工程实战」系列之一。相关系列:
- AI Agent成本优化实战指南
- AI Agent长任务实战指南:断点续跑、幂等与进度对账
- AI Agent自动化可靠性实战指南
- AI Agent评测与可观测性实战指南
- AI Agent工具调用实战指南
📚 更多创作资源: https://afdian.com/a/felix007