《AI Agent 工程实战》2.3 模型路由与降级:真/假模型开关,离线默认假模型

配置与密钥就位后,本节决定 ResearchCopilot 用哪个模型。我们实现一个 ModelRouter:按配置在真实 Anthropic 适配器与假模型之间切换,离线默认假模型(零凭据可跑);并让主模型失败时按链式顺序自动降级。我们用假模型真跑一次「主模型抛错 → 自动降级 → 请求仍然成功」的完整轨迹,并给出路由决策表。

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_modeldegrade_to_fake后端链典型场景
True(默认)无关[假模型]单测、CI、本地开发
FalseTrue[真主, 真备, 假兜底]生产(要求高可用)
FalseFalse[真主, 真备]生产(宁可失败不要降级质量)

选哪种,是一道明确的工程取舍: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 资源建模 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距