☰
给AI Agent装上Model Router:模型路由与故障自愈实战
2026/10/13 13:43:19 网站建设 项目流程

1. 为什么我必须给 Pi Agent 装一台 Model Router

前阵子我把 Pi Agent 从"单模型直连"改成了"Model Router 统一调度"的架构,改动不算大,但效果立竿见影。所谓给 Agent 装自动挡,本质上就是这回事:你不再每次硬编码"这个请求走哪个模型",而是把请求交给一个路由层,由它根据任务情况自动挂挡、自动转移、自动兜底。这套 Model Router 带来的直接收益是——简单请求的成本降下来了,复杂请求的成功率稳住了,而且模型厂商那边的任何抖动,都不再直接演变成 Agent 的全面停摆。

这篇文章不打算讲太多理论,就说说我实际是怎么设计两挡、怎么定义路由规则、怎么把故障自愈做成三层防线的。中间夹杂大量踩坑记录,尤其是那些"看起来没问题,跑起来全完蛋"的细节。如果你也在跑 Agent,被多轮调用的成本、超时、限流折磨过,这篇值得你花十分钟看完。

1.1 单模型直连的三个老问题

先说为什么必须加路由层。Pi Agent 的典型请求长什么样?一个用户问题进来,Agent 可能要拆成多个子任务:先规划、再检索、然后调用工具、最后汇总回答。这中间任何一个环节的模型调用都有可能短到几十个 token 的简单问答,也可能长到上下文超过 2 万 token 的复杂推理。

在单模型直连时代,我只用一个主力模型扛所有流量,问题非常明显。

第一个问题是成本刺刀。简单轮次(比如"天气怎么样"、从一句话里提取参数)根本不需要那昂贵的推理能力,但大模型的价格是照着"思考能力"收的。我算过一笔账:Pi Agent 的请求里大约 70% 都是简单轮,如果全走高端推理模型,一个月光简单轮就白白烧掉成本,几乎是给模型厂商打工。

第二个问题是失败一锅端。单个模型 IP 限流、超时、返回垃圾 JSON,整个 Agent 就跟着卡住。更难受的是没有任何兜底逻辑:模型一抖,用户看到的就是"服务暂不可用"。作为开发者你只能干瞪眼,因为连"要不要换一个模型再试一次"这种自动决策都做不了。

第三个问题是质量分层无从谈起。有些用户请求很重要(比如金融计算、代码生成),有些就是闲聊。但在直连架构里,所有请求的待遇完全一样,没有优先级,没有分级保护。重要请求可能被普通请求挤掉,也可能被同一个限流策略误伤。

这几个问题叠加起来,结论很清晰:Agent 需要一个"派单员",而不是把自己裸奔在单一模型上。这就是 Model Router 存在的意义。

1.2 Router 不是代理,是带状态的调度核心

很多人在 Agent 里加一个"路由器",做的就是一个 HTTP 转发,从openai 客户端换成某个自带路由的 SDK,配置几个模型的名字,完事。这不是我说的 Model Router。

我理解的 Router 至少要承担三件别人不愿意干的事:

  • 挡位识别:每次请求进来,判断这是"普通活"还是"硬骨头",决定走哪条服务链。
  • 故障转移:某个模型超时、报错、连续失败时,能把请求平滑地切到备用方案,而不是把错误原样抛给用户。
  • 健康状态维护:持续记录每个模型端点的失败次数、熔断状态、恢复探测时间,为故障转移提供依据。

换句话说,Router 是一个有记忆、有状态的调度核心。它不负责生成文字,但负责保证生成文字的链路永远有人接盘。这也是"自动挡"这个比喻的由来:你不关心具体挂几档齿轮怎么咬合,只管踩油门,剩下的交给它。

在动手前我给自己定了一条设计原则:代码里的任何分支都不允许"直接指定具体的模型厂商 A 或 B",只能指定"走 Light 挡"或"走 Pro 挡"。具体选 A 还是 B,是配置层的事。这样故障切换时,我可以调整整个挡位的模型池,而不用去改业务代码。

2. 两挡怎么切才顺手:挡位划分与路由判定

2.1 我的挡位定义:Light 挡与 Pro 挡

有段时间我犹豫过要不要做三挡:超便宜挡、性价比挡、高能力挡。后来实测完,我决定砍成两挡,理由后面会说。现在先看两挡的参数设计。

维度Light 挡Pro 挡
目标场景简单问答、参数提取、短摘要、工具调用结果解析多步推理、代码生成、长文本分析、复杂函数调用规划
模型池低延迟、低单价的快速模型组强推理能力的高上限模型组
超时阈值8~12 秒30~60 秒
最大重试次数2 次(快速换端点重试)1 次(贵,重试要克制)
失败转移方向自动切到 Pro 挡切到备用端点或维持重试
最大输入上下文默认 12K token默认 32K token 以上

你可能会觉得 Light 挡超时设 8 秒太短。其实这是故意为之:简单任务的模型响应本来就该快,如果 8 秒还没出结果,大概率是那个端点已经堵死了。与其干等,不如早一点触发重试或换挡。Pro 挡的超时长是给复杂推理留时间,这类请求经常要"思考"很久,不能用快任务的标准去卡。

还有一个很多人忽略的细节:Pro 挡的重试次数我只给了 1 次。因为贵模型的调用成本高,无脑重试只会把成本放大三倍。宁可接受偶尔一次失败然后走降级告警,也不要因为重试把预算打穿。这就是"挡位契约"——每个挡位不只是换一个模型,而是换了整套超时、重试、转移策略。

2.2 路由判定:哪些请求自动进哪一挡

挡位定义好了,接下来就是判定规则。我最后采用的策略是五层级联,每层看过之后给出决定,优先级从高到低:

  1. 显式标记优先:如果业务层明确要求走 Pro(比如 VIP 用户、关键链路、后台任务),直接 Pro,不参与后续判断。
  2. 任务类型打标:Agent 在规划阶段会给每个子任务打一个"复杂度标签"。带 code_gen、multi_step_reasoning、long_plan 标签的,走 Pro;chat、extract、summarize 标签的,走 Light。
  3. 输入上下文体积:如果输入 token 超过 Light 挡的最大上下文(比如 12K),强制抬到 Pro,防止小模型在长上下文场景下丢失关键信息。
  4. 动态复杂度评分:用一个轻量的 heuristics 函数,结合工具调用数量、历史相似任务的成功率给出一个 0~100 的分数,超过 70 分走 Pro。
  5. 默认兜底:以上都不命中,走 Light。

代码上大概是这个形态:

def route_request(req: AgentRequest) -> TierDecision: # 第 1 层:业务显式指定 if req.priority == Priority.HIGH or req.tier_hint == "pro": return TierDecision(tier="pro", reason="explicit_hint") # 第 2 层:任务类型标签 if req.task_tag in {"code_gen", "multi_step_reasoning", "long_plan"}: return TierDecision(tier="pro", reason="task_tag") # 第 3 层:上下文长度兜底 if req.input_tokens > TIER_CONFIG["light"]["max_input_tokens"]: return TierDecision(tier="pro", reason="context_too_long") # 第 4 层:复杂度评分 score = estimate_complexity(req) if score >= COMPLEXITY_PRO_THRESHOLD: return TierDecision(tier="pro", reason="complexity_score") # 第 5 层:默认 return TierDecision(tier="light", reason="default")

实际跑下来,第 2 层打的标签命中率最高,能覆盖绝大多数该走 Pro 的请求。第 4 层的 heuristics 函数反而是最容易出 bug 的,我后面会讲。

2.3 为什么是三挡砍成两挡,以及"自动"的含义

我最初的三挡方案长这样:Lite(便宜快速)、Standard(均衡)、Pro(强推理)。配置表看起来很美,但跑了两周,我主动砍掉 Standard,理由特别实际:

故障转移的方向变得混乱。Lite 挂了是提升到 Standard 还是直接跳 Pro?Standard 挂了又该降到哪里?每一个边界都要写规则、要观测、要调参数,维护成本翻倍。而两挡的话,方向非常干净——Light 挂了向上切 Pro,Pro 挂了向下降 Light 保持基础服务,只有两个方向,没有中间态。

另外,Agent 的大多数请求天然是"要么很简单、要么很复杂",真正的中间态其实很少。Standard 挡不仅没有提高命中率,反而因为多了一层判断,拉高了路由延迟。

至于"自动挡"到底自动在哪:不是说你不能手动挂挡。业务层随时可以显式指定 "此请求必须走 Pro",这相当于开车时拨一下换挡拨片。自动的意思是,绝大多数请求你不用管,路由层在毫秒级按规则帮你挂了合适的挡。用户问一个简单问题,系统悄悄用便宜模型快速响应;用户问一个需要深度推理的问题,系统自动用贵模型慢慢思考。你感知不到挡位切换,只感觉得到体验变好了、账单变低了。

3. 故障自愈的三层防线:从重试到熔断再到降级

3.1 第一层:单次调用的超时重试与跨挡转移

故障自愈的第一步,永远是把一次"坏请求"在局部解决掉,不要让它扩散。我实现的逻辑是对每次模型调用套一层壳:

async def execute_with_failover(req, tier_name): pool = TIER_CONFIG[tier_name]["pool"] max_attempts = TIER_CONFIG[tier_name]["max_retries"] + 1 for attempt in range(max_attempts): endpoint = select_endpoint(pool, tier_name) try: result = await call_model(endpoint, req) record_success(endpoint) return result except TimeoutError: record_failure(endpoint) except RateLimitError: record_failure(endpoint) except ServerError: record_failure(endpoint) else: continue if attempt < max_attempts - 1: # 指数退避 + 随机抖动,防止重试风暴 await asyncio.sleep(BASE_DELAY * (2 ** attempt) + random.uniform(0, 0.2)) # 本挡重试全部失败 -> 尝试跨挡转移 if tier_name == "light": return await execute_with_failover(req, "pro") raise AgentModelError(f"tier={tier_name} all endpoints failed")

注意几个细节。第一,重试必须带指数退避和抖动,否则 100 个请求同时超时,重试也同时发出,等于把故障放大 100 倍打回去。第二,跨挡转移只允许从 Light 转到 Pro,不允许反过来。把 Pro 挡的请求降到 Light 挡,语义上等于"认输",除非你确定任务简单到 Light 能搞定,否则不要这么干。第三,每挡的重试次数是配置在挡位契约里的,不是全局一个值。

这个函数看起来简单,但是整条自愈链路的地基。如果这层没做好,后面的熔断和降级都是在给稻草堆上压最后一根骆驼。

3.2 第二层:健康评分与熔断窗口

只靠单次重试不够。如果一个模型端点已经在持续超时,那它大概率是瘫了,继续把新请求发过去等于拿头撞墙。这时候需要"熔断"。

我的设计是给每个端点维护一个健康状态机:

@dataclass class EndpointHealth: model_id: str tier: str state: str = "CLOSED" # CLOSED -> OPEN -> HALF_OPEN -> CLOSED consecutive_failures: int = 0 failure_window: deque = field(default_factory=lambda: deque(maxlen=20)) last_probe_at: float = 0.0 def record_failure(self, now: float): self.consecutive_failures += 1 self.failure_window.append(1) if self.state == "CLOSED" and self.consecutive_failures >= 5: self.state = "OPEN" self.last_probe_at = now def record_success(self, now: float): self.consecutive_failures = 0 self.failure_window.append(0) if self.state == "HALF_OPEN": self.state = "CLOSED"

策略是这样的:

  • 连续失败达到 5 次,状态从 CLOSED 翻转为 OPEN,之后一段时间内不再把流量打到这个端点。
  • OPEN 状态持续 60 秒后,允许放 1 个探测请求进去,状态变为 HALF_OPEN。如果探测成功,恢复正常;如果探测失败,重新回到 OPEN 并重置时间戳。
  • 除了连续失败计数,我再叠加一个"窗口错误率"指标:最近 20 次调用里错误占比超过 40%,也会触发熔断。原因是有些模型端点不是完全挂,而是持续丢包,连续失败计数的反应不够灵敏。

这里最容易被忽视的是:健康状态必须按端点记录,而不是按挡位记录。挡位是两个,端点可能各有两三个,A 端点不行了 B 端点还是好的。如果只记录到挡位粒度,一个端点挂了你会把整个挡位都停掉,白白浪费可用容量。

3.3 第三层:整挡降级与备用通道

单请求转移解决的是"局部失败",熔断解决的是"单个端点失效"。但如果一个挡位的所有端点都在熔断状态,或者模型厂商那边整体出了问题,就要启动第三层:整挡降级。

降级规则也很简单,我用一张表维护好:

故障场景动作备注
Light 挡所有端点不可用全量提升到 Pro 挡成本会上升,但至少保证 Agent 还能回话
Pro 挡所有端点不可用降级到 Light 挡处理简单请求,复杂请求返回"稍后再试"必须限制复杂请求进入,否则 Light 也会被打满
两个挡位都不可用进入全局 fail-safe直接返回缓存答案或排队提示,不调用任何模型

整挡降级的关键在于"降级后的流量要求"和"降级前的流量要求"完全不同。举个例子:Pro 挡挂了,降级到 Light 挡之后,原本每秒 30 个复杂请求全压到 Light 模型上,Light 供应商可能 10 秒内就限流。所以整挡降级必须配合"流量削减":只放原本 50% 的请求进 Light,剩下的排队或者返回稍后重试。宁肯损失一部分用户请求,也不要让降级后的系统再次雪崩。

第三层防线的实现代码我给个简版示意:

def get_available_tier(preferred_tier: str) -> str: if not tier_is_available(preferred_tier): if preferred_tier == "light": return "pro" if tier_is_available("pro") else "fail_safe" else: return "light" if tier_is_available("light") else "fail_safe" return preferred_tier

逻辑不复杂,"自动挡跳挡"就是干这个。但注意,每次整挡降级都要记一条带事件时间的日志,因为降级往往是事故的开始,而不是结束。

3.4 自愈状态不要到处乱塞

我犯过的第一个架构级错误是把健康状态散落在各个业务模块里:路由模块一个计数器,调用模块一个布尔值,观察模块一个时间戳。结果就是"路由说没熔断,调用说已经降级,监控面板上什么也看不出来"。

后来我把所有自愈状态统一放在一个全局 Registry 里,只有它维护 EndpointHealth,路由模块和调用模块都通过这个 Registry 查询状态。状态变更通过事件发布出来,比如 "endpoint:xxx state:OPEN at:xxx",观测系统直接订阅这些事件即可。

这块的状态数据结构不复杂,但值得认真设计,因为后续所有能力(指标统计、成本估算、故障复盘)都建立在它上面。

4. 实测中的翻车现场与经验修补

4.1 翻车一:重试风暴把备胎端点也打挂了

新系统上线第三天,我亲眼看到一次"完美"的雪崩。主端点开始超时,重试机制触发,一批请求切到备胎端点。备胎端点本来扛得住 60% 的流量,但那次主端点故障持续了十几分钟,重试和前调过来的流量叠加,备胎端点直接被压到 99% 错误率。

问题出在哪?重试总量没有上限,转移后的流量没有配额。

修补方案我做了两个:

  • 全局并发闸门:在任何时刻,允许发往所有模型端点的总在途请求数不超过 N。超过的请求直接排队,不让重试无限放量。
  • 备胎流量配额:从主端点转移过来的流量,在备胎端点上最多占其总容量的一半。如果备胎已经达到配额,剩下的请求宁可返回"稍后再试",也不要继续塞进去。

这两个约束看起来跟"自愈"矛盾——你明明想尽可能把故障请求救回来,我却让你主动拒绝一部分。但自愈的目标是"系统整体平稳",不是"每一个请求都必须成功"。放弃一部分请求,换来整个链路不崩,这个买卖很划算。

4.2 翻车二:把"模型质量差"当成了"模型挂了"

这是我最冤的一次事故,也是最容易复现的坑。

某个版本里,Agent 调用模型后要解析 JSON 工具调用结果。有几次模型返回了合法的 JSON 但内容完全不对(比如该调天气 API 调了日历 API),解析层报错了。我一开始图省事,把解析异常也当成端点失败计入熔断计数。结果呢?一个实际上完全健康的模型,因为"业务质量差"被连续记录了 5 次失败,直接被熔断 60 秒。然后流量切到备胎模型,备胎模型也会偶尔输出错 JSON,也跟着被熔断。不到十分钟,两个端点全被"误杀",系统宣告整体降级。

这里的教训非常深刻:调用失败和业务失败必须分开计数。模型网络错误、限流、5xx,这些属于调用失败,可以计入熔断。模型返回了东西但不符合 Agent 的预期,属于业务失败,应该走"重解析、提示词修正、质量检查"这条线,而不是去扣端点健康的帽子。

代码上我把异常分类做得更细:

class ModelCallError(Exception): ... # 网络、限流、5xx class ModelOutputInvalid(Exception): ... # 空响应、非法JSON、字段缺失 class ModelOutputLowQuality(Exception): ... # 语义质量分过低

只有 ModelCallError 会触发端点熔断,后两类走业务重试或者任务返工。这个区分直接决定了熔断系统是不是可靠,别偷懒。

4.3 翻车三:多副本状态不同步,流量来回横跳

Pi Agent 是多副本部署的,每个副本是一个独立进程。最初我把健康状态放在进程内内存里,结果出现了一个特别滑稽的现象:

副本 A 认为端点 X 挂掉了,于是把 20% 流量切到端点 Y;副本 B 认为端点 Y 挂掉了,把 20% 流量切到端点 X。网关层看到的就是流量在两个端点之间反复横跳,谁都没吃饱,谁的错误率都在上升。更难受的是,由于每个副本的熔断窗口不一致,A 和 B 的判断还会互相"传染"——Y 端点因为接了 B 切过来的流量,响应变慢,A 记录的失败率也上升了,于是 A 更坚定地认为 Y 不行。

解法有两种路径,我推荐第二种:

  • 路径一:把健康状态放到集中存储(比如某个键值数据库),所有副本读写同一份状态。缺点是引入外部依赖,而且高频写入会拖慢路由。
  • 路径二(我最终采用):副本本地保存细粒度状态,但每隔 30 秒向中心节点汇总心跳和指标,中心节点只做两件事——下发"全局熔断白名单/黑名单"和统一调整挡位容量。副本之间不直接同步,避免写入竞争。

路径二的本质是"本地快速决策 + 全局慢速校正"。本地状态保证毫秒级的自愈响应,全局状态保证多副本不会各打各的。现在跑下来很稳,流量横跳彻底消失了。

4.4 必须沉淀的观测指标

没有观测的自愈等于盲人开车。我最终沉淀了以下指标,缺一不可:

指标说明我关注的阈值
挡位命中分布Light/Pro 各自的请求占比Light 长期低于 50% 说明判定太保守
挡位成功率每个挡位的成功请求 / 总请求低于 95% 就要排查端点健康度
跨挡转移次数每秒有多少请求从 Light 升到 Pro连续分钟级上升说明 Light 池容量不够
熔断时长端点从 OPEN 到恢复正常的时间超过 5 分钟记一次告警
备胎流量占比落在备用端点上的流量百分比长期高于 30% 说明主端点需要扩容
估算成本通过 token 数和单价估算当日费用成本突增往往和跨挡转移正相关

另外我说一下成本怎么估算:用路由日志里的 token 数乘以每个端点的单价,每天跑一个离线任务汇总。不需要很精确,关键是要能看出趋势。有一次我靠这个指标发现某个任务类型突然大量走 Pro,定位到最后是复杂度评分函数的参数被某个特征值扭曲了,这类问题平时不看成本曲线根本不可能发现。

4.5 自愈日志如何避免变成噪音

自愈系统天生容易刷屏:每个请求超时都打一条 Error,每条失败都记录堆栈。日志量翻十倍,有用的信息反而被淹没。

我调整后的日志策略是:

  • 状态翻转才记 Info:比如端点从 CLOSED 变 OPEN,从 OPEN 变 HALF_OPEN,这些是事件,值得留痕。
  • 每次失败记 Debug:方便事后排查,但不进告警通道。
  • 告警只设在两个地方:任一挡位成功率连续 3 分钟低于 90%,或全局触发 fail-safe。其他的异常让系统自己消化,不要半夜把人吵醒。

这样设置之后,有一次模型厂商抖动 5 分钟,我看到的日志不超过 20 行,但每一行都能讲清楚发生了什么:哪个端点先失败、几次失败触发熔断、流量切到哪里、过了多久恢复。周报复盘的时候,全靠这些事件日志串成完整的时间线。

5. 这套"自动挡"适合谁复用:使用边界与扩展方向

5.1 什么时候值得抄这套设计,什么时候别硬上

每次发技术分享都有人问"我能不能照搬这套搞法"。我的回答是分情况的。

适合上 Model Router 的信号有三个:Agent 已经有多轮子任务调度,模型切换本来就是刚需;请求量够大,成本差异已经肉眼可见;团队能接受"多一层复杂度"并愿意维护健康状态数据。

不适合的也很明显:如果你做的只是单轮问答 Demo,流量一天几百次,那加路由层纯属自找麻烦,不如直接在业务代码里加一个 try/except 兜底。如果你的团队完全没有任何监控面板,错误日志靠肉眼看,那也不要先上熔断降级——因为你没有数据判断状态翻转是否合理,熔断开关等于盲拧。

还有一个边界:这套设计假设你有多个可用的模型端点。如果你只有单一模型且没有备胎,故障自愈只能做到"重试再重试",挡位和降级都无从谈起。这种情况先解决"多一个模型来源"的问题,再谈设计。

5.2 可以立刻上手的扩展一:超时梯度联动

目前我的挡位超时是固定值,但这其实不够精细。更合理的做法是"超时梯度联动":第一次调用用挡位基础超时时间,重试时超时放宽一点,跨挡转移时再放宽一点。

举个例子:Light 挡基础超时 8 秒,同端点重试给到 12 秒,切到 Pro 挡后给到 30 秒。这么设计的原因是——如果网络出现瞬时抖动,第一次 8 秒没回来,第二次 12 秒大概率能回来;如果 12 秒还没回来,说明不是抖动是端点真有问题,那就该换模型而不是继续等。

这个改进我到现在还没完全落地,因为涉及大量线上压测来确定每个梯度值,但思路是对的,建议你直接按这个方向做。

5.3 可以立刻上手的扩展二:挡位画像动态修正

另一件我想做但还没做完的事,是根据历史表现动态修正路由判定。

目前的任务标签和复杂度评分都是静态规则。但实际中同一个任务类型在不同时间段的成功率差异可能很大。动态修正的思路是:

  • 记录每一类任务(按标签 + 输入特征)在两个挡位下的成功率和质量分。
  • 每天跑一个离线任务,计算"这类任务走 Light 挡的成功率是否低于 Pro 挡超过 5 个百分点"。
  • 如果确实低,就把这类任务的默认挡位上浮一格;如果两边几乎没差别,就把默认挡位下调,省钱。

这样系统就不只是"自动挂挡",而是会学习哪些任务该挂哪挡。逻辑执行起来不难,但需要足够的历史数据采样,前期至少要跑两周以上才敢动规则。

5.4 可以立刻上手的扩展三:流控前置与过载保护

最后一个扩展方向,也是我下一阶段打算重点做的:把"流量准入控制"放在路由之前。

现在的架构是"请求进来 -> 路由判断 -> 调用模型",自愈只管模型调用的失败。但如果入口流量本身就超过系统处理能力(比如用户突然暴涨,或者某个自动化脚本开始高频刷接口),再强的自愈也没用,因为问题不出在模型端,而出在入口。

前置一个简单的令牌桶或者并发信号量处理,超限请求直接排队或返回"系统繁忙,请稍后",能避免大量无效请求冲入路由层,把模型端点全部打满。从某种程度上说,流控前置是比故障自愈更靠前的"免疫防线",把它做好,自愈系统身上的压力会小很多。

我给 Pi Agent 装上这套 Model Router 之后,最大的变化其实不是指标变好了多少,而是"开车的姿势"变了。以前每次模型厂商那边出点动静,我这边就是鸡飞狗跳地手动切模型;现在模型挂了我连告警邮件都懒得立刻看,因为挡位自己会跳、流量自己会转、该放弃的请求自己会放弃。如果你也在为 Agent 的模型调用头疼,建议从最小的两挡开始:一个便宜快挡,一个能扛事的高挡,再加一层精确到端点的熔断。跑两周看数据和日志,你会回来谢我的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询