1. 为什么我要给 Pi Agent 做一套 Model Router
Pi Agent 这类编码智能体,本质上是一个“会自己调工具、自己读文件、自己改代码”的循环执行器。它每一轮都要做一件事:把当前上下文丢给某个模型,拿到回复,解析出下一步动作,然后继续跑。跑得多了你就会发现一个很现实的问题——不是每一步都值得用最贵的模型。
我最初用 Pi Agent 跑一个中型项目的重构任务,全程挂同一个高配模型,结果一个下午烧掉的额度够我吃一周午饭。更难受的是,很多步骤其实只是“读一下文件、列一下目录、确认一下变量名”,这种活儿用高配模型纯属浪费。但你要是全程换成便宜模型,遇到复杂推理、跨文件依赖分析、报错定位的时候,它又开始胡言乱语,给你改出一堆新 bug。
这就是我做 Model Router 的起点:让 Pi Agent 根据当前任务的性质,自动在“高配推理挡”和“轻量执行挡”之间切换。我把它叫做“自动挡”,对应的是原来那种“手动指定一个模型跑到底”的“手动挡”。
这套东西解决的核心问题有三个。第一是成本,把大量低价值步骤下沉到轻量模型,实测能省下六到八成的调用开销。第二是速度,轻量模型响应快,Agent 的整体循环节奏明显更顺。第三是稳定性,当某个模型接口抽风、超时、返回格式错乱时,Router 要能自己兜住,而不是让整个 Agent 卡死在那儿。
适合谁来参考?如果你正在用 Pi Agent 或者类似的编码智能体框架,已经跑通了基本流程,开始被成本和稳定性折磨,那这篇就是写给你的。如果你还没上手 Pi Agent,建议先把基础跑通再来看路由这块,不然容易一头雾水。
我下面会从整体设计思路讲起,然后拆核心细节、实操过程、故障自愈,最后把我踩过的坑整理成速查表。全程都是我自己项目里跑出来的东西,不是纸上谈兵。
2. Model Router 的整体设计与两挡思路拆解
2.1 为什么是“两挡”而不是“多挡”
一开始我也想过做多挡,比如搞个五档变速:超轻、轻、中、重、超重。但真动手的时候发现,挡位越多,判定逻辑越复杂,调参成本越高,而且实际收益并不明显。原因很简单——模型的能力和价格并不是线性分布的,中间那几挡往往性价比尴尬,用起来还不如直接二选一。
所以最后我收敛成两挡:
- 轻量挡(执行挡):负责读文件、列目录、简单文本处理、格式转换、状态确认这类“确定性高、推理需求低”的步骤。
- 推理挡(重挡):负责跨文件依赖分析、复杂 bug 定位、架构级重构、多步逻辑推演这类“需要真正动脑子”的步骤。
两挡的好处是判定边界清晰。你只需要回答一个问题:这一步到底需不需要“想”?不需要想,走轻量挡;需要想,走推理挡。中间那些模棱两可的情况,我后面会用一套打分机制来处理,但整体上就两个出口,维护起来轻松很多。
提示:两挡设计的关键不是“挡位少”,而是“判定标准要单一且可解释”。如果你的判定逻辑自己都说不清楚,那路由一定会乱。
2.2 路由判定的三个信号来源
Router 要决定走哪挡,得有依据。我用的是三个信号源,按优先级从高到低排列。
第一个是显式指令。Pi Agent 的每一步动作其实都带类型标签,比如read_file、list_dir、apply_patch、analyze_error。这些标签本身就是最强的信号。读文件、列目录,直接判轻量;分析错误、生成补丁,直接判推理。这部分覆盖了大概七成的步骤,而且几乎不会判错。
第二个是上下文特征。有些步骤标签一样,但难度差很多。比如同样是apply_patch,改一个变量名和重构一个模块完全是两码事。这时候我会看几个特征:涉及的文件数量、代码块的行数、是否跨目录、是否触及核心配置文件。这些特征加权算一个分数,超过阈值就走推理挡。
第三个是历史反馈。如果轻量挡连续两次在同类任务上返回了低质量结果(比如补丁应用失败、解析出错),Router 会把这类任务临时提升到推理挡,跑一段时间再降回来。这是一个自适应的过程,相当于给 Router 加了个“记忆”。
三个信号源的权重我设的是 0.6 / 0.3 / 0.1。显式指令占大头,因为它最可靠;上下文特征做补充;历史反馈做微调。这个比例不是拍脑袋定的,是我拿一批历史任务回放调出来的,后面实操部分会讲怎么调。
2.3 两挡之间的“换挡时机”
换挡时机是这套设计里最容易被忽略、但最影响体验的部分。我见过有人把 Router 做成“每步独立判定”,结果 Agent 在轻量和推理之间疯狂横跳,上下文风格不统一,输出质量反而下降。
我的做法是以任务段为单位换挡,而不是以单步为单位。一个任务段指的是一段连续的、目标一致的执行序列。比如“定位某个报错”可能包含读日志、读相关文件、分析调用链、生成修复补丁这几步,这整个序列算一个段,段内尽量保持同一挡位,只在段与段之间切换。
这样做的好处是上下文连贯。同一个模型连续处理一段任务,它的输出风格、变量命名习惯、推理深度都是一致的,Agent 解析起来更稳。如果每步都换模型,你会看到前一步还在用简洁风格,下一步突然变得啰嗦,解析器很容易懵。
段边界的判定我用了两个规则:一是动作类型发生大类切换(比如从“读取类”切到“修改类”),二是显式指令里带了新的任务目标。这两个规则覆盖了绝大多数情况,实测下来换挡频率大概每五到八步一次,节奏比较舒服。
2.4 为什么把“故障自愈”和路由绑在一起做
很多人会把路由和容错当成两件事,分开做。我一开始也是这么想的,后来发现不行。因为路由本身就引入了新的故障点:轻量模型可能返回格式不对的内容,推理模型可能超时,两个模型的输出结构可能不一致。如果容错不跟着路由一起设计,你会遇到一堆“路由判对了但执行挂了”的情况。
所以我把故障自愈直接内建在 Router 里,核心思路是每一挡都有对应的降级和重试策略。轻量挡挂了,先原地重试一次,还不行就升挡到推理挡兜底;推理挡挂了,先重试,再不行就降挡用轻量挡跑一个简化版本,同时标记这个任务段需要人工确认。
这套机制后面会单独用一节讲,这里先记住一个原则:路由决定走哪条路,自愈保证这条路走不通时还有退路。两者是一体的。
3. 核心细节解析与实操要点
3.1 动作类型到挡位的映射表
这是 Router 最基础的一张表,我直接放在配置里,改起来方便。下面是我实际用的映射,你可以根据自己的任务类型调整。
| 动作类型 | 默认挡位 | 说明 |
|---|---|---|
| read_file | 轻量 | 纯读取,无推理 |
| list_dir | 轻量 | 目录枚举 |
| search_text | 轻量 | 关键词检索 |
| format_output | 轻量 | 格式整理 |
| apply_patch | 推理 | 涉及代码修改,需谨慎 |
| analyze_error | 推理 | 错误定位 |
| plan_steps | 推理 | 多步规划 |
| refactor_module | 推理 | 架构级改动 |
| summarize | 轻量 | 摘要生成 |
| verify_result | 轻量 | 结果校验 |
这张表不是死的。比如summarize如果摘要的是整个项目的架构文档,那它其实需要推理,这时候就要靠上下文特征把它顶上去。所以映射表只是第一层,后面还有加权修正。
注意:映射表里我把
apply_patch默认放推理挡,是因为代码修改一旦出错,回滚成本很高。宁可多花点额度,也别让轻量模型乱改代码。这是我踩过坑之后的血泪教训。
3.2 上下文特征的打分公式
上下文特征这块我用了一个简单的加权打分,公式不复杂,但每个权重都是调出来的。
score = w1 * file_count + w2 * code_line_count / 100 + w3 * cross_dir_flag + w4 * core_config_flag + w5 * error_depth各权重的取值和含义:
w1 = 0.8:涉及文件数,每多一个文件加 0.8 分。跨文件是复杂度上升的最直接信号。w2 = 0.5:代码行数除以 100 后加权。行数多意味着上下文长,轻量模型容易丢信息。w3 = 1.5:是否跨目录,是则加 1.5。跨目录往往意味着模块间依赖,推理需求高。w4 = 2.0:是否触及核心配置,是则加 2.0。核心配置改错影响面大,必须重挡。w5 = 1.2:错误堆栈深度,越深越可能是根因问题,需要推理。
阈值我设的是score >= 3.0走推理挡,否则轻量挡。这个阈值调过好几轮,3.0 是我这边任务分布下的甜点值。你的项目如果文件普遍偏大,可以把阈值往上提;如果任务偏碎,可以往下调。
3.3 历史反馈的滑动窗口设计
历史反馈这块我用了一个长度为 10 的滑动窗口,记录每个任务段的路由结果和质量评分。质量评分来自两个地方:一是补丁是否应用成功,二是解析器是否能正常解析输出。两个都成功记 1 分,有一个失败记 0 分。
当某个动作类型在窗口内的平均分低于 0.6 时,Router 会把这类任务临时提升一挡,持续 20 个任务段,然后再评估是否降回来。这个机制解决了一个很实际的问题:有些任务看起来简单,但轻量模型就是搞不定。比如某些特定框架的配置文件解析,表面上是读文件,实际上格式很绕,轻量模型经常读错。历史反馈能自动把这类任务识别出来。
提示:滑动窗口长度别设太短,太短会抖动;也别太长,太长反应慢。10 到 20 之间比较合适,我用的 10。
3.4 输出格式的统一约定
两挡模型不一样,输出格式很容易不一致。我的做法是在 Router 层做格式归一化,不管哪个模型返回什么,都先过一层适配器,转成统一的内部结构再交给 Agent。
统一结构大概长这样:
{ "action": "apply_patch", "target": "src/utils/parser.js", "payload": "...", "confidence": 0.85, "model_tier": "reasoning" }model_tier字段很关键,它让下游知道这一步是谁产出的,方便做质量追踪。confidence是模型自评的置信度,低于 0.5 的时候 Router 会考虑升挡重跑。
格式归一化这层看起来不起眼,但它是我这套 Router 能稳定跑起来的关键。没有它,Agent 的解析器要同时兼容两套输出,维护成本翻倍。
4. 实操过程与核心环节实现
4.1 环境准备与依赖确认
动手之前先把环境理清楚。我用的是 Node.js 环境,Pi Agent 本身跑在 Node 20 以上,Router 作为中间层嵌在 Agent 的模型调用环节。
需要确认的几件事:
- Pi Agent 版本要支持自定义模型调用钩子。老版本没有这个钩子,Router 插不进去。
- 两个模型的接口凭证都配好,轻量挡和推理挡各一套。
- 日志系统要能记录每次路由决策,不然调参的时候两眼一抹黑。
日志这块我单独说一句。我一开始没做详细日志,结果调阈值的时候完全靠猜,浪费了两天。后来加了结构化日志,每次决策记录动作类型、打分、最终挡位、执行结果,调参效率直接起飞。
4.2 Router 核心代码结构
Router 的主体我拆成四个模块,职责清晰,方便单独测试。
class ModelRouter { constructor(config) { this.actionMap = config.actionMap; this.weights = config.weights; this.threshold = config.threshold; this.history = new SlidingWindow(10); } decide(context) { const baseTier = this.actionMap[context.action] || 'light'; const score = this.computeScore(context); const historyTier = this.history.suggest(context.action); return this.finalize(baseTier, score, historyTier); } computeScore(context) { const w = this.weights; return w.fileCount * context.fileCount + w.codeLines * (context.codeLines / 100) + w.crossDir * (context.crossDir ? 1 : 0) + w.coreConfig * (context.coreConfig ? 1 : 0) + w.errorDepth * context.errorDepth; } finalize(baseTier, score, historyTier) { if (historyTier === 'reasoning') return 'reasoning'; if (score >= this.threshold) return 'reasoning'; return baseTier; } }decide是入口,computeScore算上下文分,finalize做最终裁决。历史反馈优先级最高,因为它代表实际表现;其次是上下文分;最后才是基础映射。
4.3 参数调优的实操记录
调参这块我拿了一批历史任务做回放,大概 200 个任务段,覆盖读、写、分析、重构四类。调参目标是两个:路由准确率尽量高,同时推理挡的使用比例尽量低。
第一轮我用的是拍脑袋的权重,结果推理挡使用率高达 65%,成本没省下来。分析日志发现w2代码行数权重给太高了,很多大文件其实只是读一下,不需要推理。把w2从 1.0 降到 0.5 之后,推理挡使用率降到 42%。
第二轮发现有些跨目录的简单任务被误判成推理挡,把w3从 2.0 降到 1.5,使用率降到 35%,准确率没掉。
第三轮调阈值,从 2.5 提到 3.0,使用率降到 28%,这时候开始出现少量误判——有几个复杂任务被降到轻量挡,补丁应用失败。把历史反馈窗口从 5 调到 10,误判被自动纠正,最终稳定在推理挡使用率 30% 左右,准确率 92%。
这个 30% 是我这边的甜点值。你的项目如果任务偏复杂,这个比例会更高,别硬套。
4.4 换挡边界的实现细节
换挡边界我用了一个简单的状态机,记录当前任务段的挡位,只有满足换挡条件才切换。
class TierStateMachine { constructor() { this.currentTier = null; this.segmentAction = null; } shouldSwitch(nextAction, nextTier) { if (this.currentTier === null) return true; if (this.isMajorCategoryChange(this.segmentAction, nextAction)) return true; if (nextTier !== this.currentTier && this.hasNewGoal(nextAction)) return true; return false; } isMajorCategoryChange(prev, next) { const readGroup = ['read_file', 'list_dir', 'search_text']; const writeGroup = ['apply_patch', 'refactor_module']; return readGroup.includes(prev) !== readGroup.includes(next); } }isMajorCategoryChange判断是否从读取类切到修改类,这是最典型的换挡点。hasNewGoal判断显式指令里是否带了新目标,这个需要 Agent 在动作里带上目标标记,我在 Pi Agent 的 prompt 里加了一个字段来实现。
4.5 故障自愈的实现
自愈这块我分三层做,从轻到重。
第一层是原地重试。任何模型调用失败,先重试一次,间隔 500 毫秒。这一层能解决大部分网络抖动和偶发超时。
第二层是升挡兜底。轻量挡重试还失败,或者返回内容解析不了,直接升到推理挡重跑这一步。推理挡模型更强,通常能给出可解析的结果。
第三层是降挡简化。推理挡也失败的话,降回轻量挡,但把任务简化——比如把“重构整个模块”降级成“只改这一个函数”,先保证 Agent 能往前走,同时打标记让后续人工确认。
async function callWithHealing(router, context) { const tier = router.decide(context); try { return await callModel(tier, context); } catch (err) { const retry = await callModel(tier, context).catch(() => null); if (retry) return retry; const fallbackTier = tier === 'light' ? 'reasoning' : 'light'; const simplified = simplifyContext(context); return await callModel(fallbackTier, simplified); } }这套三层自愈跑下来,我这边 Agent 因为模型问题卡死的概率从原来的每天好几次降到基本为零。
5. 常见问题与排查技巧实录
5.1 路由判定不准的排查思路
判定不准是最常见的问题,表现是轻量挡老出错,或者推理挡用得太频繁。排查我一般按这个顺序走。
先看日志里动作类型的分布。如果某个动作类型频繁出错,先检查映射表是不是把它放错挡了。我遇到过一次verify_result被放轻量挡,但实际校验逻辑很复杂,轻量模型经常漏判,后来挪到推理挡就好了。
再看上下文打分。把出错任务的打分打出来,看看是不是卡在阈值附近。如果大量任务分数在 2.8 到 3.2 之间,说明阈值设得不合适,要么调阈值,要么调权重让分布更分散。
最后看历史反馈有没有生效。如果某类任务反复出错但历史反馈没把它顶上去,检查滑动窗口是不是太短,或者质量评分的判定逻辑有问题。
5.2 两挡输出风格不一致怎么办
这个问题我前面提过,根子在格式归一化。如果你发现 Agent 解析经常出错,先确认归一化层是不是覆盖了所有字段。我踩过的坑是只归一化了action和payload,忘了target字段,结果轻量模型返回的 target 格式和推理挡不一样,解析器直接崩。
解决办法很简单,把所有下游会用到的字段都列出来,逐个确认两挡输出都能映射到统一格式。宁可多写几行适配代码,也别让格式问题漏到下游。
5.3 成本没降下来的原因分析
有人做完路由发现成本没怎么降,通常是三个原因。
一是推理挡使用率太高。回去看日志,如果超过 50%,说明判定太保守,权重或阈值需要调。
二是轻量挡模型选得太贵。轻量挡要选真正便宜的模型,别选那种“中等价位”的,不然省不下来。
三是重试和自愈带来的额外调用。如果自愈触发太频繁,说明基础稳定性有问题,得先解决模型接口的稳定性,而不是靠自愈硬扛。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 轻量挡频繁出错 | 映射表放错挡 | 检查动作类型映射 |
| 推理挡使用率过高 | 阈值太低或权重偏大 | 调阈值、降权重 |
| 解析器频繁报错 | 格式归一化不全 | 补全字段适配 |
| 成本没降 | 轻量挡模型太贵 | 换更便宜的模型 |
| 自愈触发频繁 | 模型接口不稳定 | 先解决基础稳定性 |
| 换挡太频繁 | 段边界判定太细 | 放宽换挡条件 |
| 历史反馈不生效 | 窗口太短或评分逻辑错 | 调窗口长度、查评分 |
5.5 我踩过的几个坑
第一个坑是一开始没做段级换挡,每步独立判定,结果 Agent 输出风格横跳,解析器天天报错。改成段级之后立刻稳定。
第二个坑是历史反馈窗口设成 3,太短,导致挡位抖动,一会儿升一会儿降。改成 10 之后平滑多了。
第三个坑是自愈没有上限,有一次模型接口挂了,自愈疯狂重试,把额度烧光了。后来加了重试上限,超过三次直接标记任务失败,交给人工。
第四个坑是日志没记 model_tier,出问题的时候不知道是哪挡出的错,排查全靠猜。加上这个字段之后,定位问题快了一倍。
提示:这四个坑里,段级换挡和日志字段是最值得优先做的,投入小、收益大。
6. 一些实操后的个人体会
这套 Model Router 我在自己的 Pi Agent 项目里跑了大概三个月,中间迭代了四五版。最大的感受是,路由的核心不是模型选得多聪明,而是判定逻辑多稳定。你不需要一个完美预测任务难度的系统,你需要一个大部分时候判对、判错时能自愈的系统。
两挡设计对我来说刚刚好。挡位再少就没法区分,再多就维护不动。故障自愈和路由绑在一起做,是我觉得最正确的决定,它让整套系统从“能跑”变成“敢让它自己跑”。
如果你准备动手,我的建议是先别急着调权重,先把日志和格式归一化做好。这两块是地基,地基不稳,后面调什么都是白搭。等日志能清楚告诉你每次决策的依据,调参就是个体力活,不再是玄学。
最后分享一个小技巧:历史反馈的滑动窗口,你可以按动作类型分别维护,而不是全局一个窗口。这样不同任务的反馈互不干扰,判定会更准。我最近刚改成这样,效果还在观察,但初步看误判率又降了一点。