2.3 模型路由与降级:真/假模型开关,离线默认假模型
前两节解决了「配置从哪来」和「密钥怎么安全流转」。现在 ResearchCopilot 手上有了一份校验过的 Settings、一个可能存在的 Anthropic key,但它还没回答一个核心问题:这次请求,到底走真模型还是假模型? 更要命的是,真实调用随时可能失败——限流、超时、上游抖动。一个能上线的服务不能「主模型一挂就整体 500」,它必须能降级。
本节把 ResearchCopilot 推进到「模型可切换、可降级」:实现
ModelRouter,离线默认假模型、真模型模式挂上主备链,主后端抛错时自动落到下一个,直到有后端成功。
2.3.1 一个开关:真模型还是假模型
路由的第一层决策最简单:看配置里的 use_fake_model。
use_fake_model=True(默认):返回假模型,零凭据、零网络、完全确定。CI、单测、本地开发全走这条。use_fake_model=False:返回真实适配器,但外面套一层可降级的链。
为什么默认是假模型?因为本卷的立场很明确:能不能在没有密钥的机器上跑通,是一个工程基线。新同事 git clone 之后应该立刻能跑测试,而不是先去找人要 key。假模型不是「权宜之计」,而是让确定性成为默认状态的手段——真实的、不确定的、要花钱的那条路径,才需要显式打开。
2.3.2 后端可换的前提:Model 协议
路由能成立,靠的是卷一立好的 Model 协议——只要对象有签名匹配的 complete(messages, tools),就能塞进路由。这个协议我们在卷一 2.1 Model 协议设计
里已经讲透,这里不重复,只重申它对工程的价值:真适配器、假模型、降级包装器,对调用方完全等价。ResearchCopilot 的循环代码只认协议,从不知道背后是真 API 还是一个脚本。
下面是本节要用的最小契约(与卷一一致):
from dataclasses import dataclass, field
from typing import Any, Literal, Protocol
Role = Literal["system", "user", "assistant", "tool"]
@dataclass
class ToolCall:
id: str
name: str
arguments: dict[str, Any]
@dataclass
class Message:
role: Role
content: str = ""
tool_calls: list[ToolCall] = field(default_factory=list)
tool_call_id: str | None = None
@dataclass
class ModelResponse:
message: Message
stop_reason: str = "end_turn"
usage: dict[str, int] = field(default_factory=dict)
class Model(Protocol):
def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse: ...
FakeModel 沿用卷一的脚本式实现,calls 记录每次调用的二元组 (消息列表, 工具 schema 列表),方便断言:
class FakeModel:
def __init__(self, script: list[Any], name: str = "fake") -> None:
self._script = list(script)
self._i = 0
self.name = name
self.calls: list[tuple[list[Message], list[dict]]] = []
def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
self.calls.append((list(messages), list(tools)))
step = self._script[self._i]
self._i += 1
if isinstance(step, str):
return ModelResponse(message=Message(role="assistant", content=step))
return ModelResponse(
message=Message(role="assistant", tool_calls=list(step)),
stop_reason="tool_use",
)
2.3.3 ModelRouter:按顺序尝试,第一个成功的胜出
降级的本质是顺序尝试:把候选后端排成一条链,从头试起,谁先成功就用谁。失败的不抛给上层,而是记录后继续。写出来不到二十行:
class ModelRouter:
"""按顺序尝试若干后端,第一个成功的胜出。"""
def __init__(self, backends: list[Model]) -> None:
assert backends, "至少要有一个后端"
self._backends = backends
self.trace: list[tuple[str, str]] = [] # (后端名, 结果)
def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
last: Exception | None = None
for b in self._backends:
try:
resp = b.complete(messages, tools)
except Exception as e:
self.trace.append((b.name, f"FAIL {type(e).__name__}: {e}"))
last = e
continue
self.trace.append((b.name, "OK"))
return resp
assert last is not None
raise last
三个细节值得点出:
ModelRouter自己也是Model(有complete方法),所以它能被继续套进另一个ModelRouter——路由可以组合。- 失败被降级成一次记录,而不是异常。
trace保存了「哪个后端、什么结果」,是排查降级原因的关键证据,也让测试可以断言降级确实发生过。 - 全部失败才把最后一个异常抛出。降级不是「永不失败」,而是「尽最大努力后如实失败」。
2.3.4 工厂:按配置组装后端链
把开关和降级链组装起来的是一个工厂函数。默认返回假模型;真模型模式下组装「主 → 备 → 假模型兜底」:
from dataclasses import dataclass
@dataclass
class Settings:
use_fake_model: bool = True
model_name: str = "claude-sonnet-4-5"
fallback_model: str = "claude-haiku-4-5"
degrade_to_fake: bool = True
class UnavailableModel:
"""模拟真实适配器:网络 / 额度问题直接抛错。"""
def __init__(self, name: str, err: str = "429 rate_limit") -> None:
self.name = name
self.err = err
def complete(self, messages: list[Message], tools: list[dict]) -> ModelResponse:
raise RuntimeError(self.err)
def build_anthropic(name: str) -> Model:
return UnavailableModel(name) # 真实项目里是 AnthropicModel(api_key=..., model=name)
def build_model(s: Settings) -> Model:
if s.use_fake_model:
return FakeModel(["(离线假模型)已收到你的问题。"], name="fake-default")
chain: list[Model] = [build_anthropic(s.model_name), build_anthropic(s.fallback_model)]
if s.degrade_to_fake:
chain.append(FakeModel(["(降级)上游暂不可用,已用离线模型作答。"], name="fake-last-resort"))
return ModelRouter(chain)
注意链尾那个假模型:degrade_to_fake=True 时,即使所有真实后端都挂了,请求仍能返回一个有内容的答复(虽然质量下降)。这比直接 500 体验好得多,代价是用户要能识别这是降级结果——所以假模型的回复文本里写明了「已用离线模型作答」。降级要透明,不能假装成功。
2.3.5 真跑:主模型抛错 → 自动降级 → 请求仍然成功
跑一遍看完整轨迹。两段:默认配置走假模型;关掉开关后主备都抛错、最终由假模型兜底:
print("== ① 默认配置:离线假模型,零凭据 ==")
m = build_model(Settings())
print("后端类型:", type(m).__name__)
print("回复:", m.complete([Message(role="user", content="ResearchCopilot 是什么?")], []).message.content)
print()
print("== ② use_fake_model=False:主 -> 备 -> 假模型兜底 ==")
router = build_model(Settings(use_fake_model=False))
resp = router.complete([Message(role="user", content="ResearchCopilot 是什么?")], [])
print("最终回复:", resp.message.content)
print("降级轨迹:")
for name, outcome in router.trace:
print(f" {name:<20} {outcome}")
print("请求成功:", resp.message.content != "")
真实输出(venv Python 3.14.6):
== ① 默认配置:离线假模型,零凭据 ==
后端类型: FakeModel
回复: (离线假模型)已收到你的问题。
== ② use_fake_model=False:主 -> 备 -> 假模型兜底 ==
最终回复: (降级)上游暂不可用,已用离线模型作答。
降级轨迹:
claude-sonnet-4-5 FAIL RuntimeError: 429 rate_limit
claude-haiku-4-5 FAIL RuntimeError: 429 rate_limit
fake-last-resort OK
请求成功: True
这条轨迹把降级的全过程摊开了:主模型 claude-sonnet-4-5 抛 429 rate_limit,路由记一笔失败、继续;备用 claude-haiku-4-5 同样失败,再记一笔;链尾的 fake-last-resort 成功,请求最终返回了内容。两次失败没有一次冒泡到调用方——调用方拿到的仍是一个正常的 ModelResponse,只是内容来自降级后端。
关键收益是可用性:上游完全不可用时,请求成功率从「0」变成了「1(降级)」。这在 Agent 服务里尤其重要,因为一次 Agent 运行可能包含多次模型调用,任何一次 429 都足以让整条运行失败——把降级做在单次调用层,等于给整条链路上了一层保险。
2.3.6 路由决策表
把配置组合与对应行为整理成一张表:
use_fake_model | degrade_to_fake | 后端链 | 典型场景 |
|---|---|---|---|
True(默认) | 无关 | [假模型] | 单测、CI、本地开发 |
False | True | [真主, 真备, 假兜底] | 生产(要求高可用) |
False | False | [真主, 真备] | 生产(宁可失败不要降级质量) |
选哪种,是一道明确的工程取舍:degrade_to_fake=True 换来可用性,代价是用户可能拿到假模型生成的、质量不高的答复;False 保住质量下限,代价是上游全挂时直接失败。ResearchCopilot 默认选前者,但把「这是降级结果」写进了回复文本,让上层(第 15 章的护栏、第 13 章的指标)能识别并计数。
2.3.7 什么错误该降级、什么不该
「所有异常都降级」是危险的写法。降级会把真实问题掩盖掉,所以只有特定类别的错误才值得降级:
| 错误类别 | 例子 | 是否降级 | 理由 |
|---|---|---|---|
| 上游不可用 | 429 限流、503、超时 | 是 | 换个后端可能就好了 |
| 认证失败 | 401 无效 key | 否 | 换后端也一样错,是配置问题 |
| 请求非法 | 400 参数错、上下文超长 | 否 | 重试无意义,应直接暴露 |
| 内容策略拒绝 | 模型拒答 | 否 | 不是故障,是正常结果 |
这条界线在真实实现里通常落在适配器的错误分类上:适配器把 SDK 异常归一化成 RetryableError / FatalError 两类,路由只对 RetryableError 降级,FatalError 直接上抛。卷一 14.1 错误分类
已经建立了这套分类,本卷直接复用。本节为了聚焦路由逻辑,用了一个「什么都抛」的 UnavailableModel,真实版本务必按上表收窄。
2.3.8 验收清单与真实环境
模型路由的验收:
- 默认配置(
use_fake_model=True)零凭据即可跑通,不触网。 - 真模型模式下主后端失败会自动落到下一个后端,
trace记录完整。 - 只有可重试类错误触发降级,认证/参数类错误直接上抛。
- 降级结果对上层可识别(回复文本或元数据里带标记)。
真实环境里这套链路还会长出三层。其一,退避与重试:同一后端 429 时先退避重试,仍失败再换后端,而不是一失败就切;其二,熔断:某后端连续失败就临时把它移出链,过一阵再放回来,避免每次都白等一个必挂的后端;其三,按任务路由:简单任务走小模型、复杂任务走大模型,这属于第 14 章成本优化的范畴。本节先把「可切换 + 可降级」这条主干立住,加固是后面的事。
配置、密钥、模型路由三件事凑齐,ResearchCopilot 已经能稳定地「拿到一个模型、拿到一份配置」了。下一章进入对外接口:把 Agent 能力包成 REST API。先看 3.1 REST 设计:session / run / message 资源建模 。想回看密钥是怎么安全注入的,翻 2.2 密钥管理:Anthropic key / DB / Redis 本地注入 。
阅读导航:上一节:2.2 密钥管理:Anthropic key / DB / Redis 本地注入 · 下一节:3.1 REST 设计:session / run / message 资源建模 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。