说实话,我第一次给 Pi Agent 选模型的时候,思路特别朴素:挑一个能力最强的模型,把 key 配好,所有请求都往这一个接口打。结果上线第一周就被现实教育了。一个"查天气然后提醒我带伞"的小任务,因为整条调用链都在等大模型的慢响应,硬生生拖了四十多秒;更糟的是上游接口一出抖动,整条任务链直接原地瘫痪,用户那边看到的就是一个转圈转半天的死窗口。
后来我花了两周,给 Pi Agent 装上了一套 Model Router,参考自动变速箱的思路做了两挡设计——快挡走省油小模型,慢挡走动力大模型——又配了一套故障自愈机制:重试、熔断、降级、兜底链。这篇文章就是这套系统的完整复盘,包括路由打分、换挡逻辑、熔断降级、实测数据,以及我在调参过程里踩过的那些坑。适合正在做 Agent 应用、被模型不稳定和 token 成本折磨的开发者参考。
1. 单挡直驱的代价:简单任务不该为大模型买单
1.1 一次真实事故:查天气为什么会花四十秒
我最早版本的 Pi Agent 架构特别简单:一个主模型负责所有环节。用户说"帮我看看今天要不要带伞",Agent 的处理链路其实有四段:先让模型识别意图,再让模型决定调哪个工具,然后调天气接口取数,最后再让模型把结果组织成一句人能听懂的话。在单模型方案里,这四段全部要等同一个大模型接口返回,每段少则两三秒,多则七八秒,串起来轻松超过二十秒。
如果只是慢,咬咬牙还能忍。真正让我决定改架构的是那次事故:上游接口在高峰期出现了一次持续约三分钟的抖动,我的 Agent 没有任何容错逻辑,第一个请求超时后直接失败,任务链里后面所有等待这个结果的子任务全部跟着失败。用户重新触发,又撞上抖动窗口,继续失败。那三分钟里,所有走这个 Agent 的任务全军覆没,而我翻日志只能看到一堆孤零零的 timeout 报错,连个降级方案都没有。
这件事给我两个教训。第一,把所有请求都压在一个模型上,等于把鸡蛋全放一个篮子里,模型上游随便抖一下,我的业务就跟着抖。第二,不同任务对模型能力的需求差异极大,全部用大模型处理,既慢又贵,还把故障面拉得特别大。
1.2 单模型方案的三个隐性成本
后来整理需求的时候,我把单模型方案的隐性成本归纳成三条,这里列个表对比一下。
| 维度 | 单模型(全部走大模型) | 两挡路由后的预期 |
|---|---|---|
| 简单任务延迟 | 单轮也要等大模型慢推理,普遍 5~10 秒以上 | 小模型秒级返回,通常 1~3 秒 |
| 费用消耗 | 每个 token 都按大模型价格计费,简单任务也在烧钱 | 简单任务走低价模型,综合成本大幅下降 |
| 故障爆炸半径 | 一个模型挂了,全部任务挂 | 快挡挂了走慢挡,慢挡挂了走兜底,业务不断 |
延迟成本最直观:简单任务其实不需要链式推理,但单模型方案里它必须跟着大模型走完整个生成过程,推理时间全被浪费了。费用成本更隐蔽,很多团队到月底看账单才意识到,光是"查时间""查天气""翻译一句话"这类高频简单任务,就烧掉了大模型预算的三成以上。故障爆炸半径则是三个成本里最致命的——单模型方案没有备选路径,上游一断,业务直接归零。
所以我的结论是:Agent 需要一个会换挡的路由层,至少把"手速活"和"技术活"分开处理。这就是 Model Router 的最初动机。
2. 两挡怎么划:"省油挡"管手速,"动力挡"管硬活
2.1 快挡:小模型负责高频率、低复杂度的活
我给两挡的定义很简单:快挡是"省油挡",用延迟低、价格便宜的小参数模型,负责大量重复、推理深度浅的任务。在 Pi Agent 里,快挡负责的典型任务包括:意图识别、工具名选择、实体抽取、回答格式整理、简单的礼貌性回复。
为什么这些活适合小模型?因为它们本质上都是"根据输入做一个不太复杂的判断",不需要多步推理,也不需要综合大量上下文。比如"用户这句话是想查天气还是想定闹钟",给一个小模型足够清晰的指令和候选列表,它完全可以稳定完成。实测下来,快挡模型在意图识别这类任务上的准确率能达到大模型的 95% 以上,但延迟只有后者的三分之一,价格可能只有几十分之一。
不过快挡模型有一个底线要求:它必须能稳定遵循简单指令。我之前试过一个压得太狠的极小模型,连"从这句话里抽取城市名"这种活都会漏抽,那就不配当快挡了。快挡可以弱,但不能蠢到影响路由本身。
2.2 慢挡:大模型负责需要"想清楚"的活
慢挡是"动力挡",用强推理、大参数量的模型,负责需要真正想清楚的任务。在 Pi Agent 里,慢挡负责四类活:多步任务规划、复杂代码生成、长文档综合总结、以及需要综合多个工具结果再下判断的场景。
这些任务的共同点是:答案不是"从输入里直接找出来"的,而是需要模型在内部完成推理链。比如用户说"帮我订一个周五下午适合谈事的咖啡馆,最好离地铁近、安静一点",Agent 需要先拆解约束条件,再决定查哪些数据源,最后把多个结果综合筛选。这种任务交给小模型很容易出现漏条件、乱猜、编造选项的情况,交给大模型虽然慢一点贵一点,但正确率高得多。
我自己的切分原则是一句话:凡是需要多步推理才能得到答案的任务,一律走慢挡;凡是单步判断就能完成的,一律走快挡。
2.3 为什么两挡就够了,而不是三挡四挡
有人可能会问,既然要路由,为什么不搞三挡四挡,按任务难度精细分流?我一开始也这么想过,后来发现得不偿失。挡位越多,意味着需要越精确的难度度量,而任务难度的判断本身就是模糊的;每多一个挡,就要多一组阈值、多一套监控、多一堆边界情况。
两挡加一条兜底链,是在"分流效果"和"系统复杂度"之间比较划算的平衡点。快挡解决 80% 的高频简单任务,慢挡解决 15% 的复杂任务,剩下 5% 的极端情况和模型全挂的故障场景,交给兜底链处理,而不是再设一个"中间挡"。中间挡最大的问题是:它和大挡、小挡的边界很难划清,路由器天天在纠结"这活算中等还是偏难",反而制造更多不稳定。
2.4 挡位配置示例
路由器的挡位配置我放在一个 YAML 文件里,方便调整:
router: fast_gear: provider: local-small timeout_ms: 3000 max_retries: 2 note: "意图识别/抽取/格式化" slow_gear: provider: cloud-large timeout_ms: 15000 max_retries: 3 note: "多步规划/代码生成/综合判断"两个挡位的超时时间和重试次数是分开设置的。快挡要求 3 秒内必须返回,因为它的任务本身很快;慢挡给到 15 秒,是因为复杂推理确实需要更长时间,也避免误杀正常的长响应。这里有个经验:超时时间不要所有模型统一,必须按挡位分别设置,否则要么快挡频繁超时,要么慢挡被过早掐断。
3. Model Router 的换挡控制:打分决定踩油门还是降挡
3.1 换挡信号:从哪些特征判断任务难度
路由器要决定挂哪个挡,首先要能做"难度预判"。我的方案是对任务特征做打分,分数高就挂慢挡,分数低就挂快挡。目前 Pi Agent 用了五个信号:
- 任务类型标签:如果 Agent 的任务系统本身有分类,直接做映射,比如"规划类"强制慢挡,"查询类"默认快挡。
- 工具调用数量:只需要调一个工具的任务,和需要串起两三个工具再综合分析的任务,难度完全不是一个量级。
- 输入长度:长上下文往往意味着需要全局理解,短问题则大概率是单点判断。
- 是否开放式生成:有固定 schema 的抽取任务简单,没有约束的开放性写作就难。
- 推理强度需求:涉及数值计算、逻辑比较、条件分支的任务,对推理能力要求高。
打分公式我写得很朴素,就是一个加权和:
score = 0.35 * 任务复杂分级 + 0.25 * 工具调用数归一值 + 0.20 * 输入长度归一值 + 0.20 * 推理需求标记权重是拍脑袋定的初值,然后靠实测数据调。怎么调后面讲,这里先说思路:分数不是精确科学,它的任务是"把明显简单的和明显复杂的分开",临界模糊地带反而要交给迟滞逻辑去稳定。
3.2 阈值与迟滞:防止挡位来回跳
有了分数,接下来就是阈值。最朴素的方案是单阈值:score 超过 0.6 就挂慢挡,否则挂快挡。但这个方案在线上会出一个很典型的问题——临界任务的分数经常在 0.58 和 0.62 之间波动,导致同一类任务一会儿走快挡一会儿走慢挡,延迟忽快忽慢,费用也不稳定。
这就是自动变速箱里说的"挡位抖动":变速箱在相邻两个挡位之间反复横跳,既费油又顿挫。汽车工程里的解决办法是换挡延迟逻辑,我给路由器也做了同样的设计,用双阈值加迟滞带:
class TwoGearRouter: def __init__(self, low_threshold=0.45, high_threshold=0.65): self.low_threshold = low_threshold self.high_threshold = high_threshold self.current_gear = "fast" def decide_gear(self, score: float) -> str: if score >= self.high_threshold: self.current_gear = "slow" elif score <= self.low_threshold: self.current_gear = "fast" else: pass # 迟滞带内保持当前挡位 return self.current_gear这段代码的核心逻辑是:分数大于 0.65 才升慢挡,小于 0.45 才降回快挡,中间 0.45 到 0.65 是一个缓冲区间,路由器保持当前挡位不变。实测这个简单改动就能把挡位抖动率降掉九成以上。除了迟滞带,我还加了一个最小保持时间:一次换挡后至少保持 60 秒,防止单个任务的分数波动带崩整个会话的挡位稳定性。
3.3 手动模式:任务声明里直接指定挡位
自动换挡不是万能的,所以我给任务定义里加了一个可选的挡位字段,允许任务自己声明"我要走哪个挡"。
{ "task": "帮我把这段代码改成异步版本", "gear": "slow" }这个字段有三个取值:fast、slow、auto。默认是 auto,交给路由打分;但涉及代码生成、数据分析这类高风险任务,任务定义里可以直接写死 slow;纯查询类任务可以写死 fast,连打分都省了。这就是汽车上的换挡拨片——自动挡归自动挡,但驾驶员想强制降挡超车时,必须有控制权。在 Pi Agent 里,凡是涉及删除数据、转账、发送消息这类不可逆操作的任务,我都要求强制指定挡位,绝不交给自动打分。
4. 故障自愈:真正的"自动挡"是挂了也能继续开
4.1 故障清单:先把"会坏在哪"列清楚
装自动挡的初衷本来就是为了应对路上的意外。在模型路由这个场景里,故障来源比想象的多,我先把踩过的故障类型列一个表。
| 故障类型 | 典型表现 | 是否适合重试 |
|---|---|---|
| 超时 | 请求发出后长时间无响应 | 适合,但要注意重试次数 |
| 5xx 服务端错误 | 上游返回 500/502/503 | 适合,通常短暂抖动 |
| 429 限流 | 请求被上游限流拒绝 | 适合,但要退避等待 |
| 返回格式非法 | 响应 JSON 解析失败 | 可重试,最好在重试时附加强制格式指令 |
| 空响应/内容过滤 | 上游返回空内容 | 不适合,重试大概率还是空 |
| 工具调用参数错误 | function call 的字段缺失 | 可重试一次,附带纠错提示 |
这个表看着简单,其实是我挨个踩出来的。比如"空响应"这种故障,最开始我以为重试一次就能好,结果发现内容过滤命中的请求重试一百次也是空,白白浪费时间和费用。所以故障自愈的第一原则是:先判断这个故障是不是"可重试的",再决定要不要重试,而不是无脑重试。
4.2 重试:第一道防线也要讲策略
重试是所有自愈手段里成本最低的,但策略不对反而会帮倒忙。我给 Pi Agent 定的重试规范是三条:第一,只对超时、5xx、429 这类临时性故障重试;第二,用指数退避加随机抖动,间隔从 200 毫秒起步,按 200ms、800ms、2000ms 递增,避免所有请求在同一时间点打向上游;第三,每次重试都附带当前重试次数,方便在上游接口排查问题时对齐上下文。
关于 429 限流有个小细节:如果返回头里带了 Retry-After 字段,直接按它给的时长等,比自己的退避策略更靠谱。有一次我图省事没读这个字段,结果退避间隔还没到就重试,连续三次都被限流拍回来,还不如等一次。
4.3 熔断器:别让重试变成重试风暴
光有重试还不够,因为重试本身会放大故障。当上游已经处于不稳定状态时,所有请求失败后立刻重试,等于给上游制造了一波更大的流量,这叫重试风暴。我的解法是在路由层加熔断器,状态机就是经典的 closed / open / half-open 三态。
熔断器的逻辑是:连续失败次数达到阈值,熔断器从 closed 切到 open,接下来一段时间内所有请求直接走降级路径,不再打到上游;过了恢复时间窗,切到 half-open,放两个探活请求试试水,成功就回到 closed,失败就继续 open。
class CircuitBreaker: def __init__(self, fail_threshold=5, window_sec=30, recovery_sec=30): self.fail_threshold = fail_threshold self.window_sec = window_sec self.recovery_sec = recovery_sec self.failures = [] self.state = "closed" self.open_since = None def allow_request(self) -> bool: if self.state == "closed": return True if self.state == "open": if time.time() - self.open_since >= self.recovery_sec: self.state = "half_open" return True return False return True # half_open 放少量流量试探 def record_failure(self): self.failures.append(time.time()) self.failures = [t for t in self.failures if time.time() - t < self.window_sec] if len(self.failures) >= self.fail_threshold: self.state = "open" self.open_since = time.time()参数上我的经验是"慢开快试":熔断阈值不能太小,否则上游一个小抖动就把熔断器触发,把所有流量都赶到兜底路径,造成新的压力;恢复窗口也不能太长,否则上游已经恢复了,系统还在降级。我现在用的初值是 30 秒窗口内 5 次失败触发熔断,恢复等待 30 秒,半开状态放 2 个探活请求。
4.4 降级:从动力挡切到省油挡,也要让用户知道
熔断器触发或者慢挡连续失败之后,下一步是降级。在 Pi Agent 里,降级有两层含义。第一层是挡位降级:慢挡挂了,同一个任务改用快挡模型跑,用更简化的提示词让它先给一个"虽然不完整但能用"的结果。第二层是结果降级:快挡也挂了,就走到规则引擎,用模板回答兜住"查时间""查天气"这类结构清晰的请求。
降级有个特别重要的细节:必须给结果打标记。我会在返回的响应元数据里加上"degraded": true和"degraded_from": "slow_gear",下游业务拿到这个标记后,可以决定是给用户展示"当前响应质量可能下降"的提示,还是干脆走人工确认。这个设计我后面专门再讲,因为它是这次项目里最大的教训之一。
4.5 兜底链:模型全部不可用时,任务不能裸奔
自愈体系的最后一道防线是一条兜底链。模型这层全部不可用的极端情况下,任务依然要有一个"最差也能跑"的出路。
FALLBACK_CHAIN = [ ("slow_gear", call_slow_model), ("fast_gear", call_fast_model), ("local_model", call_local_model), ("rule_engine", call_rule_engine), ] def invoke_with_fallback(task, context): for name, caller in FALLBACK_CHAIN: try: result = caller(task, context) if validate(result): return mark_result(result, caller_name=name) except Exception: continue return build_failed_response(task)兜底链的顺序是精心排过的:先慢挡后快挡,确保正常情况优先用高质量模型;本地模型作为第三顺位,适合网络完全断掉但本机还有小模型的场景;规则引擎是最后的底牌,只对模式清晰的请求有效。我见过一些团队的兜底链顺序是乱的,快挡放在慢挡前面,结果慢挡一抖动,流量全部压到快挡,快挡也跟着过载,整个链路反而更快崩溃。兜底链的正确用法是"逐级后撤",而不是"哪里便宜往哪里塞"。
5. 实测结果与调参踩坑:自动挡也要磨合期
5.1 装上 Router 前后的对比数据
这套两挡路由和自愈机制上线后,我在模拟项目 X 上连续统计了 7 天,和生产环境跑了一周,数据差距非常明显。
| 指标 | 单模型方案 | 两挡路由后 | 变化 |
|---|---|---|---|
| P50 延迟 | 8.2 秒 | 2.1 秒 | 下降 74% |
| P95 延迟 | 21 秒 | 5.4 秒 | 下降 74% |
| 每千次成本 | 100 单位 | 37 单位 | 下降 63% |
| 任务成功率 | 92% | 98.7% | 提升 6.7 个百分点 |
这里有个数据值得展开:成功率提升不是靠"更强的模型",而是靠故障自愈把原本会直接失败的任务接住了。单模型方案里,上游一抖动,任务直接失败,成功率跌到谷底;两挡方案里,慢挡失败会切快挡,快挡失败有兜底链,单次故障对用户几乎无感。成功率提升的大头恰恰来自重试、熔断和降级这几层"看不见的机制"。
5.2 踩坑一:挡位横跳,Agent 像个新司机
刚上线的头两天,我盯着监控发现一个诡异现象:同一类任务的延迟曲线一会儿 1.8 秒一会儿 7 秒,锯齿状来回跳。查下去才发现是路由器的打分在阈值附近波动,同一个任务有时候得分 0.63 挂快挡,有时候得分 0.68 挂慢挡,挡位频繁切换。
这个问题的根子在于我没有做迟滞处理,单阈值方案在边界上太敏感。修复方式就是前面提到的双阈值加最小保持时间,改动只有十几行,但效果立竿见影——挡位抖动率从每百次任务 7 次降到 0.3 次以下。教训是:路由器的第一优先级是稳定,不是每次都给"最优挡位"。偶尔让一个中等任务走慢挡多花点钱,比挡位反复横跳让延迟和费用都失控要好得多。
5.3 踩坑二:熔断器该"断"的时候没断
熔断器上线后遇到过一个反向问题:上游明明已经在剧烈抖动了,我的熔断器却迟迟不触发,导致所有请求都还在往故障点打。查日志发现是熔断计数窗口设置得太长,5 次失败分布在 30 秒的窗口内,但实际业务量一秒钟可能才 2 个请求,30 秒都未必能攒够 5 次失败,熔断器变成了摆设。
后来我把触发条件改成"连续失败次数"而不是"窗口内累计失败次数",连续 3 次失败就直接熔断,效果立刻不一样。这个参数要根据业务请求量来调:请求量大的系统,窗口内累计阈值合适;请求量小的系统,连续失败阈值更靠谱。没有放之四海的参数,只有适合自己流量的参数。
5.4 踩坑三:降级把错误答案包装成了正确答案
这是整个项目里教训最重的一个。有段时间我发现知识库里的回答质量突然下降,排查了很久才找到原因:慢挡接口不稳定,降级逻辑把任务切给了快挡模型,快挡模型返回了一个明显不完整的答案,但我的下游解析器把它当成慢挡的完整答案存进了知识库。降级机制本意是"有总比没有好",结果因为少了质量标记,反而把低质量结果变成了正式结果。
修复方案是两部分的:第一,所有降级路径返回的响应必须带degraded=true标记;第二,下游解析器遇到降级标记的结果,只做临时展示,不写入知识库,必须经过二次确认。这个改动之后,降级路径就从"偷偷摸摸地坏"变成了"明明白白地降级",用户能看到提示,系统也知道这是临时方案。
5.5 调参清单:可以直接照抄的初始值
最后把我的调参清单整理成一张表,给想复刻的读者一个起点。
| 参数 | 初始值 | 说明 |
|---|---|---|
| 快挡超时 | 3 秒 | 简单任务应该快速失败 |
| 慢挡超时 | 15 秒 | 留足复杂推理时间 |
| 重试退避 | 200ms / 800ms / 2000ms | 指数退避加随机抖动 |
| 熔断连续失败阈值 | 3 次 | 连续失败即熔断 |
| 熔断恢复等待 | 30 秒 | 半开后放 2 个探活请求 |
| 路由低阈值 | 0.45 | 低于则回快挡 |
| 路由高阈值 | 0.65 | 高于则升慢挡 |
| 最小保持时间 | 60 秒 | 换挡后保持当前挡位 |
这些参数全部来自实测,不同业务量级可能要调整,但作为起步值,它们能帮你绕过我之前踩的大部分坑。
6. 想抄作业?先把这几条落地经验记住
6.1 分阶段上线,别一口气全上
如果你现在就要给 Agent 装这套自动挡,我建议分三个阶段走。第一阶段只做两挡路由和基础重试,先验证快慢挡的划分是否合理;第二阶段加熔断和降级,重点调熔断参数;第三阶段再上兜底链和降级质量标记。一次全上,出了问题你根本定位不到是哪个环节,我在项目里就吃过这个亏,上线第一天熔断器疯狂误触发,搞得所有任务都在绕行,还以为是路由打分写错了。
6.2 监控必须按挡位拆开
路由系统的监控不能只看总体成功率,一定要按挡位、按故障类型、按降级路径分别统计。我现在的监控面板至少有这几个视图:快挡成功率、慢挡成功率、熔断器状态变化、降级调用占比、每挡 token 消耗。其中"降级调用占比"尤其重要,它直接反映自愈机制的负担程度,如果长期高于 10%,说明上游稳定性有问题,或者挡位划分不合理,该调参了。
6.3 定期回看"打错挡"的任务样本
两挡路由不是配完就完事。我会每周抽一批任务样本,重点看两类:一类是该走慢挡却走了快挡、导致回答质量差的任务;一类是该走快挡却走了慢挡、导致延迟和费用虚高的任务。把这些样本的分数拉出来,调整打分公式的特征权重。比如头两周我发现输入长度这个特征的权重给高了,很多长文搬运任务被误判成复杂任务,白白走了慢挡,调低权重后费用立刻下来一截。
6.4 风险操作必须手动挡
最后一条,也是我认为最重要的一条:自动路由只适合低风险任务。凡是涉及删除、修改、转账、对外发送消息这类不可逆操作,任务定义里必须强制指定挡位,甚至强制指定具体的模型链路,绝不交给自动打分。自动挡解决的是"性价比"问题,手动挡解决的是"安全"问题,两者不能互相替代。
我在这个项目里最大的体会是:给 Agent 做 Model Router,本质上是在把"对模型能力的预期管理"沉淀成系统能力。模型永远可能抽风,上游永远可能抖动,但系统只要设计了退路,这些意外就只是链路里的一个分支,而不是业务的一次事故。这套自动挡装完之后,我再也没有半夜爬起来看上游故障告警——因为它已经学会自己换路了。