多模型编排降低评测成本:路由、级联与缓存实践
事情的起因很朴素:一条自动化评测流水线,全量跑一遍,账单出来的时候团队里没人愿意认领。单条用例上下文动辄五六千 token,输出还要一千多,两千条跑一轮就是一笔不小的开销,而更让人难受的是——其中相当一部分用例,用最小的模型就能做对。这就是我后来一头扎进多模型编排的原因。标题里说的“不再让一个模型写到底”,本质上就是把一条端到端的任务链拆开,让不同规模的模型各管一段,中间用路由、级联、缓存和融合层串起来。我们内部把这套编排层叫 HydraFusion,取自“多头 + 融合”的意思。它解决的问题很具体:在保证评测通过率不下降的前提下,把单位评测成本压下去,实测下来整体降幅约 67%,同时 p50 延迟还变短了。这篇东西适合两类人看:一类是正在为模型调用账单发愁、想知道钱到底花在哪儿的工程师;另一类是想在自己项目里落地多模型协作、但不想一上来就搞复杂 Agent 框架的开发者。我会把架构取舍、路由阈值怎么定、缓存怎么防污染、成本账怎么复算,全部摊开讲。
1. 单模型端到端在评测流水线里到底卡在哪
很多人第一反应是“换更便宜的模型不就行了”。我试过,直接换小模型,成本确实下来了,但通过率掉得比成本还快,最后人工复核的人力成本反而更高。问题的根子不在模型贵不贵,而在于我们让一个模型同时干了四件性质完全不同的事:读懂仓库上下文、定位要改的地方、写出正确的补丁、判断这个补丁对不对。这四件事对模型能力的要求差异极大,用一个模型统一扛,就等于按最难的那件事付费。
1.1 任务异质性:一条流水线塞了四种活
我大概统计过我们那条流水线的 token 分布:真正用于“推理出正确改法”的部分,占比不到四成;剩下的六成消耗在阅读理解上——把几十个文件的上下文塞进去、把历史讨论塞进去、把规范文档塞进去。而阅读理解这件事,中小模型的表现和大模型的差距,远没有“写出正确补丁”这件事的差距那么大。换句话说,我们花了最贵的钱,去干了一件相对便宜的事。
这就是任务异质性带来的浪费。举个生活里的类比:你请一位资深架构师帮你改一行配置文件的缩进,他当然能改对,但你付的是架构师的时薪。多模型编排的第一层价值,就是把“缩进”这类活识别出来,交给一个便宜的角色去做,把资深角色的时间留给真正需要判断力的部分。这个识别动作,就是后面要讲的路由。
还有一个常被忽略的点:单模型端到端意味着上下文必须一次性全给。因为模型没有“回头再看一眼”的能力,你只能在一次调用里把所有可能相关的信息都塞进去,宁可塞多了浪费,也不敢塞少了漏掉。这种“防御性塞满”在长尾用例上尤其致命——少数超长用例的输入能到两三万 token,一条就顶几十条普通用例的成本。
1.2 成本曲线不是线性的,长尾才是账单刺客
把两千条用例的成本按单条排序画出来,你会发现它不是均匀分布的,而是典型的长尾:头部 70% 的用例,单条成本只占总额的两成多;尾部 10% 的用例,能吃掉将近一半的预算。原因是这些长尾用例往往同时具备三个特征:仓库上下文特别大、需要多轮修正、输出特别长。
这里有个反直觉的结论:优化长尾的绝对成本,不如优化头部的相对成本收益高。因为头部用例数量多,哪怕单条只省一点点,乘上基数也很可观;而长尾用例你费很大劲优化,省下的绝对金额有限,还容易把通过率搞崩。我们最后采用的策略是“头部做极致压缩,长尾做有条件的升级”,这个思路直接决定了后面的级联设计。
顺带说一句,成本统计的口径一定要提前定死。我们最开始是按“调用次数”估的,结果实际账单差了将近一倍,因为忽略了输出 token 的单价通常是输入的好几倍,而长尾用例恰恰是输出最长的。后来改成按“输入 token × 输入单价 + 输出 token × 输出单价”逐条累加,账单才对得上。这个坑看着低级,但我见过不止一个团队踩。
1.3 自己评自己:单模型评测的可信度问题
这一条跟成本没关系,但跟“官方评测”四个字直接相关。当生成答案和评判答案的是同一个模型时,评测结果天然带有自洽偏差——它倾向于认为自己写的风格是对的。我们在早期做过一个小实验:让同一个模型先写补丁,再让它按评分标准给自己打分,结果它给自己的分数比人工评分平均高出 12 个百分点,而且偏差集中在“风格偏好”这一类主观维度上。
多模型编排顺手解决了这个问题:生成用一个模型,审查用另一个不同来源的模型,打分再由第三个独立配置的实例来做。三个角色的提示词、温度、甚至上下文截断策略都不一样。这不是为了“ ensemble 一定更强”这种玄学,而是为了让错误不同源——同一个模型犯的错往往高度相关,换一个来源,错误的概率分布就变了,融合层才有价值。
2. HydraFusion 的编排骨架:把“写到底”拆成四段
把整条链路拆开之后,我们的划分是这样的:规划段负责读懂 diff 和上下文,输出“要改哪几个位置、每个位置大概改什么”;生成段负责按规划写具体补丁;审查段负责挑毛病,只输出定位和建议,不直接改写;验证段跑静态检查和可执行测试,给出通过/不通过以及失败原因。四段之间用统一的结构化契约传递数据,每一段都可以独立换模型、独立调参、独立计费。
2.1 四段流水线与三个角色
这里有个关键设计决定:审查段不直接产出最终代码。我一开始图省事,让审查模型直接把改好的完整文件吐出来,结果发现两个问题:一是输出 token 暴涨,审查一次的成本快赶上生成了;二是审查模型经常“顺手”改动一些跟本次任务无关的地方,导致 diff 变得不可控。后来改成审查只输出“行号 + 问题类型 + 建议片段”,由融合层决定采纳哪几条,输出量降了一个数量级。
三个角色的分工也要说清楚。路由角色(最便宜的那档)不参与内容生成,只做分类决策;执行角色分 S/M/L 三档,按难度选用;裁判角色是固定配置的独立实例,负责按评分标准给分,它的配置在整个评测周期内不允许随意改动,否则历史数据就没法比了。这个“裁判配置冻结”的规矩是我们踩坑之后立的,后面会细说。
段落之间传的东西,我建议用严格的 JSON 结构,字段名固定,缺字段直接报错而不是让模型自由发挥。原因很实际:自由文本在段间传递,下游模型得先“理解”上游在说什么,这一步的理解偏差会累积,而且没法定位是哪一段出的问题。结构化契约让每一段的输入输出都能被程序直接校验,出问题时一眼就能看出是哪一段的锅。
2.2 路由器的三种实现与取舍
路由器是整个方案的核心,它决定了每条用例走哪条路。我实际试过三种实现,各自的适用场景差别很大。
第一种是纯规则路由,靠特征阈值判断,比如 diff 行数小于 20、只涉及单文件、语言在支持列表内、没有触碰依赖清单文件,就判为简单档。优点是零成本、零延迟、完全可解释、调试极其方便。缺点是需要人工维护规则,遇到新模式就得补。我们上线第一周用的就是这个,覆盖了大约六成的流量,效果已经不错。
第二种是小模型分类器,用几百条人工标注的样本微调或者直接 few-shot,输入是精简后的特征描述(不是完整上下文),输出是难度档位加置信度。它的好处是能捕捉规则覆盖不到的组合特征,比如“文件数不多但涉及接口签名变更”这种。代价是每条多一次调用,成本不高但不能忽略,而且它的判断质量依赖标注样本的代表性。
第三种是级联置信度,不用单独分类,直接让 S 档模型先做,同时输出一个自评置信度,低于阈值就升级。这个方案最省事,但我不太推荐作为主力,因为它有个隐蔽问题:小模型的自评置信度很不准,尤其在它“自认为做对了但其实错了”的样本上,置信度反而偏高。这种自信的错误最难拦。
我们最终采用的是规则打底 + 分类器兜底 + 级联校验的混合方式:先用规则把明确简单的和明确复杂的分出来,中间那段灰度区间交给分类器,分类器置信度低于 0.6 的直接升到 M 档,高于 0.85 且规则也认为简单的才降到 S 档。这个组合把误判率压到了 4% 以内。
2.3 融合层不是投票,是契约下的合并
很多资料一讲多模型就讲投票、讲多数一致。这在选择题上管用,在代码生成上基本没用——两个模型写出来的补丁可能逻辑都对,但变量命名、缩进风格、错误处理方式完全不同,投票没法投。我们用的是“骨架 + 补丁建议”的合并策略:L 档模型产出的完整补丁作为骨架,审查段提出的建议以“行号区间 + 替换片段 + 严重级别”的形式返回,融合层按严重级别从高到低依次尝试应用,每应用一条就重新跑一次轻量校验,不通过就回滚这条。
这套机制的关键在于严重级别的定义必须是可执行的,不能是“严重/一般/轻微”这种主观词。我们把它拆成了四类:语法错误、类型不匹配、可执行测试失败、逻辑与需求描述不一致。前两类机器能判,第三类靠测试跑,第四类才需要模型判断。实际数据是,前三类占了审查建议的八成以上,也就是说绝大部分审查价值是可以被程序直接验证的,这让整个融合过程相当可靠。
融合层还有一个职责是冲突消解。当两条建议改同一个位置时,按严重级别优先,同级别按建议来源的可信度排序(这个可信度是离线统计出来的,不是拍脑袋)。如果两条建议互相矛盾且都通过不了校验,就直接放弃自动融合,标记为“需要升级”,交给更高档位重做。宁可多花一次钱,也别强行合出一个四不像。
3. 省钱的关键细节:级联、缓存与预算护栏
架构画出来只是第一步,真正把成本压下去的是三个工程细节:级联早退、多层缓存、预算护栏。这三样东西单独看都不复杂,但组合起来的效果是乘法的,而且每一样都有自己的污染边界,处理不好反而会拖累通过率。
3.1 级联早退:把升级当成异常而不是常态
级联的核心思想是“先用便宜的试,不行再升级”。听起来简单,但工程上有两个容易做错的地方。
第一个是升级条件的判定。千万别只用模型自评置信度。我们现在的升级触发有四类:模型自评低于阈值、输出没能通过结构化校验(比如 JSON 解析失败、补丁格式不对)、静态检查报错、可执行测试失败。四类里只要命中一类就升级。这四类信号里,后两类是硬信号,几乎不会误判;前两类是软信号,主要用来兜住那些“格式对但内容明显跑偏”的情况。
第二个是最大升级次数。这个必须设上限,我们设的是 2 次,也就是 S→M→L 之后就不再升了,直接标记失败进入人工队列。不设上限的后果我深有体会:有一条用例在 S 和 M 之间来回振荡了五次,最后一次跑到了 L,单条成本是普通用例的二十多倍。虽然这种用例占比不到 1%,但它们贡献了将近 20% 的总成本——这就是前面说的长尾。
还有一个优化点:升级时不要重跑全流程。规划段的结果在 S 档和 M 档之间通常是通用的,升级只重跑生成段和审查段就行。我们算过,光是这一条就让升级路径的成本降了三成多。当然前提是规划段本身没被判为不可靠,如果规划都错了,那就得从头来。
3.2 三层缓存与它们的污染边界
缓存是成本优化的最大杠杆,但也是最容易出事故的地方。我们做了三层,每层的命中判据和失效策略都不一样。
L1 精确缓存:键是提示词全文 + 模型标识 + 采样参数的哈希。这层最安全,理论上不可能误命中,因为输入完全一致。它的命中率取决于评测集里有多少重复用例,我们这边大概 8%,主要来自同一个 bug 在多分支上重复出现。
L2 语义近似缓存:把 diff 和上下文的关键部分做嵌入,相似度超过阈值就认为可以复用。这层收益最大,但风险也最大。我们的阈值定得比较保守,0.95 以上才命中,而且加了两个硬约束:必须是同一门语言、必须是同一个仓库路径前缀。这两个约束看起来粗暴,但确实把误命中率压下来了。早期我们只用了相似度,结果把两个不同模块里长得像但语义相反的改动搞混了,出了几次错之后才加的约束。
L3 片段缓存:针对高频的小模式,比如补 import、加空值判断、修典型的拼写错误。这层不缓存完整答案,只缓存“建议片段”,由融合层决定用不用。它的价值在于把一些几乎确定的小修小补提前干掉,减少主模型的输出长度。
提示:语义缓存一定要有“污染回收”机制。我们每周会从命中样本里随机抽 30 条人工核对,一旦发现误命中,除了删掉对应条目,还要把该仓库路径下的缓存整体失效一次。别心疼,误命中带来的通过率损失远大于缓存省下的钱。
3.3 预算护栏与降级顺序
没有预算护栏的编排系统,在遇到异常流量时会以你意想不到的速度烧钱。护栏要分三层设:全局单轮预算、单用例预算、单阶段预算。全局超了直接终止本轮,单用例超了强制返回当前最好结果并标记未完成,单阶段超了跳过该阶段的可选步骤。
超预算之后的降级顺序必须提前定死,不能临时决定。我们的顺序是:先关审查段的交叉验证(省得最多,质量损失最小),再把灰度区间的用例整体下调一档,最后才动缓存策略。这个顺序不是拍脑袋的,是拿历史数据离线模拟出来的——按这个顺序降级,在同样的预算削减幅度下,通过率损失最小。
另外强烈建议给每个阶段单独记账,而不是只记总账。我们最早只看总数,结果发现某个月成本异常,排查了两天才定位到是审查段的平均输出长度翻倍了,原因是某次提示词改版不小心去掉了“输出不超过 200 字”的约束。有了分阶段记账,这种问题在日报里就能看出来。
4. 一套能跑起来的实现:配置、代码与成本复算
理论讲完,落到能跑的东西上。我尽量把代码写成与具体供应商无关的形式,你可以直接对着改成自己环境的版本。核心抽象只有三个:模型提供方接口、编排配置、预算计数器。
4.1 编排配置长什么样
配置文件我坚持用声明式,因为编排策略会频繁调整,写在代码里每次都要改逻辑,很容易引入回归。下面是我们现在用的配置骨架,删掉了环境相关部分。
pipeline: stages: [plan, generate, review, verify] tiers: s: model: m-small max_input_tokens: 3200 max_output_tokens: 700 unit_price: {input: 0.8, output: 2.0} m: model: m-mid max_input_tokens: 5200 max_output_tokens: 1000 unit_price: {input: 1.5, output: 6.0} l: model: m-large max_input_tokens: 6400 max_output_tokens: 1300 unit_price: {input: 3.0, output: 15.0} router: model: m-tiny unit_price: {input: 0.2, output: 0.6} routing: rules: - name: trivial when: "diff_lines < 20 and files <= 1 and not touches_manifest" target: s confidence: 0.95 - name: heavy when: "files >= 6 or diff_lines > 300 or touches_manifest" target: l confidence: 0.98 classifier: fallback_tier: m low_confidence_threshold: 0.60 high_confidence_threshold: 0.85 cascade: max_upgrades: 2 upgrade_on: - low_self_confidence - schema_violation - static_check_failed - test_failed reuse_plan_on_upgrade: true cache: l1_exact: {enabled: true} l2_semantic: enabled: true similarity_threshold: 0.95 require_same_language: true require_same_path_prefix: true l3_snippet: {enabled: true, max_entries: 500} budget: per_run: 30000 per_case: 80 per_stage: {plan: 12, generate: 45, review: 15, verify: 8} degrade_order: [disable_cross_review, downgrade_mid, relax_cache]这份配置里最值得琢磨的是per_case: 80这个数。它是怎么来的?我们取了正常用例成本的 95 分位数再乘 2,而不是平均值。用平均值做上限,等于把长尾用例直接掐死;用 99 分位数又太松,起不到护栏作用。95 分位乘 2 是个折中,既能兜住绝大多数正常的长尾,又能拦住那些真正失控的。
4.2 路由与级联的代码骨架
下面这段是核心逻辑的骨架,去掉了重试、日志、指标上报这些基础设施,只保留决策路径,方便你直接看懂数据怎么流动。
from dataclasses import dataclass from typing import Optional @dataclass class Decision: tier: str reason: str confidence: float def route(case, rules, classifier, budget) -> Decision: # 1. 硬规则优先,规则命中直接返回,不花一分钱 for rule in rules: if rule.matches(case): return Decision(rule.target, rule.name, rule.confidence) # 2. 灰度区间交给轻量分类器 features = extract_features(case) # 只取特征,不送全文 out = classifier.classify(features) # 返回 (tier, confidence) budget.charge("router", out.usage) # 3. 低置信度统一向上兜,宁可多花钱也别猜 if out.confidence < 0.60: return Decision("m", "low_conf_floor", out.confidence) if out.confidence > 0.85 and out.tier == "s": return Decision("s", "high_conf_small", out.confidence) return Decision(out.tier, "classifier", out.confidence) def run_case(case, decision, cfg, budget): tier_order = ["s", "m", "l"] idx = tier_order.index(decision.tier) plan = None trace = [] for attempt in range(cfg.cascade.max_upgrades + 1): tier = tier_order[min(idx + attempt, len(tier_order) - 1)] # 升级时复用规划,避免重复付费 if plan is None or not cfg.cascade.reuse_plan_on_upgrade: plan = call_stage("plan", tier, case, budget) patch = call_stage("generate", tier, case, plan, budget) review = call_stage("review", tier, case, plan, patch, budget) \ if tier != "s" else None merged = fuse(patch, review) verdict = verify(merged, case) # 静态检查 + 可执行测试 trace.append({"tier": tier, "verdict": verdict.status, "cost": budget.case_spent()}) if verdict.ok: return merged, trace if budget.case_exceeded() or verdict.fatal: break return None, trace这段代码里有三个设计点值得单独说。第一,规则命中时完全不调用模型,连路由器都不调,这是最省的一档。第二,verdict.fatal是个逃生口,语法彻底炸掉或者触发安全校验的用例直接放弃升级,因为升级也救不回来,硬升只是烧钱。第三,trace全程记录每一档的花费和判定结果,这是后面做消融分析和故障定位的唯一依据,千万别省。
4.3 成本账:67% 是怎么算出来的
数字不能拍脑袋给,我把假设摊开,你可以直接换成自己的价格表重算。口径是:2000 条用例跑一轮,单价按上表假设值(每千 token),不含人工复核成本。重试率按 3% 计入。
| 项目 | 单条输入 | 单条输出 | 单条成本 | 条数 | 小计 |
|---|---|---|---|---|---|
| 基线:全量 L 档端到端 | 6000 | 1200 | 36.00 | 2000 | 72000 |
| 路由判定(全部用例) | 800 | 60 | 0.20 | 2000 | 392 |
| 缓存命中,仅付路由 | - | - | 0.20 | 240 | 48 |
| S 档直出(占有效量 50%) | 3000 | 600 | 3.60 | 880 | 3168 |
| M 档(占有效量 33%) | 5000 | 900 | 12.90 | 581 | 7492 |
| L 档 + 独立审查(占有效量 17%) | 6000+2500 | 1200+400 | 42.15 | 299 | 12611 |
| 重试与失败回放(3%) | - | - | - | - | 712 |
| 编排方案合计 | - | - | - | 2000 | 24423 |
基线 72000,编排后 24423,降幅 66.1%,我们对外取整说 67%。平均单条成本从 36 降到 12.2。这个结构里最重要的不是绝对数字,而是比例:接近一半的用例被最低档吃掉了,只花了总成本的 13%,而 17% 的高难用例花了超过一半的钱。这个分布是健康的,说明路由确实把简单活分出去了。
单条成本的计算方法是输入token/1000 × 输入单价 + 输出token/1000 × 输出单价。拿 L 档加审查举例:生成部分 6×3 + 1.2×15 = 36,审查部分 2.5×1.5 + 0.4×6 = 6.15,合计 42.15。审查用的是 M 档而不是 L 档,这是我们做过对比后定的——用 L 档审查,检出率只高了 3 个百分点,成本却翻了快一倍,不划算。
延迟方面的账也一并报了:基线 p50 是 16.8 秒,编排后整体 p50 降到 5.1 秒,因为一半流量走了 S 档,三秒出头就返回了。p95 反而从 31 秒涨到 33 秒,原因是 L 档多了串行的审查和校验。这个取舍我们认为可以接受,评测场景对 p50 敏感、对 p95 相对宽容。
4.4 上线的灰度节奏
别一次全量切。我们的灰度分了四步,每步观察两天。
第一步只开规则路由,不启用分类器和缓存,观察路由的覆盖率和你对规则质量的信心。这一步的意义是验证“简单用例真的简单”,如果发现规则判定的简单用例通过率掉得厉害,说明规则写得有问题,得回去改。
第二步打开级联,但仍然不全量,只对规则命中率为“中等”的那批用例生效,占比大约三成。这一步主要看升级率和升级后的通过率提升幅度,如果升级率超过 25%,说明路由整体偏保守,钱没省到点子上。
第三步开 L1 和 L3 缓存,这两种缓存风险低,先跑起来积累信心。第四步才开 L2 语义缓存,而且阈值先设 0.97,跑一周没问题再降到 0.95。L2 是我见过最容易翻车的一环,慢一点没坏处。
5. 评测体系要跟着改,否则数字会骗人
编排上线之后,原来的评测口径就不够用了。因为一条用例可能被尝试了多次,也可能部分走了缓存,如果只报一个“通过率”,你根本不知道这个数字是怎么来的。这一节讲我们怎么把指标体系重做了一遍。
5.1 五个必须同时看的指标
只报一个数一定会被误导,我们现在的日报固定看五个:首次通过率、最终通过率、平均单条成本、人工采纳率、p95 延迟。
首次通过率是第一次尝试就过的比例,它反映的是路由和首档模型的质量;最终通过率包含升级后成功的部分,反映的是整条链路的上限。这两个数的差就是级联的贡献。如果差值很大,说明路由偏保守,可以往下调;如果差值很小但成本很高,说明升级次数太多,得查是不是某个档位的提示词退化了。
人工采纳率是抽样人工复核后“认为可以直接用”的比例,这个指标不能省。我们遇到过最终通过率 82% 但人工采纳率只有 61% 的情况,仔细看是模型学会了讨好自动校验器——测试能过,但写法完全不符合项目规范。
平均单条成本要按档位分开报,光报总数看不出结构变化。最后一个 p95 延迟主要是为了防回归,编排链路变长之后,某一档的超时很容易把长尾拖垮。
5.2 消融实验的最小集合
要证明“成本下降不是靠牺牲质量换来的”,消融实验是必须做的。我们固定做四组对照,每组跑同样的 2000 条用例。
| 对照项 | 关闭的模块 | 关注指标 | 我们实测的结论 |
|---|---|---|---|
| A 组 | 关闭全部路由,全量走 M 档 | 通过率、成本 | 成本降 61%,通过率降 4.3 个点,不划算 |
| B 组 | 关闭级联升级 | 最终通过率 | 成本再降 9%,通过率降 6.8 个点,长尾崩了 |
| C 组 | 关闭 L2 语义缓存 | 成本、误命中 | 成本升 11%,通过率升 0.4 个点,说明缓存基本安全 |
| D 组 | 关闭独立审查段 | 人工采纳率 | 通过率几乎不变,人工采纳率降 9 个点,审查不能省 |
C 组那个 0.4 个点的通过率提升特别值得说。它说明语义缓存确实存在极少量误命中,但代价很小。如果这个数字超过 1.5 个点,就得考虑把阈值调回去了。做消融最忌讳只看总账,一定要把“质量变化”和“成本变化”放在一起看,否则很容易得出“关掉某个模块更省钱”这种自欺欺人的结论。
5.3 可复现清单
评测结果最怕的就是“上周还是 82%,这周变 79%,但没人知道为什么”。我们立了一份强制记录清单,每次跑评测必须落盘:编排配置的完整快照、每个模型的版本标识、所有提示词的版本号和哈希、路由规则的版本、缓存状态(是否预热、命中率)、随机种子、以及每条用例的完整 trace。
有了这份清单,任何一次指标波动都能在半小时内定位到是哪次改动引起的。我们靠它抓到过一次很隐蔽的问题:某次常规的模型版本升级,接口没变、价格没变,但输出风格变保守了,导致审查段的建议数量下降了三成,最终通过率掉了 2 个点。如果没有版本快照,这种问题根本查不出来。
还有一条规矩:裁判配置在整个对比周期内冻结。评分标准、裁判模型版本、温度参数,一个都不许动。想调可以,调完之后所有历史数据作废,重新建立基线。这条规矩立起来的时候挺多人有意见,但坚持下来之后,我们的数据可信度上了一个台阶。
6. 踩坑实录与速查表
这一段是我觉得最有价值的部分,因为下面这些问题在文档里基本看不到,全是拿账单和事故换来的。
6.1 故障速查表
| 现象 | 最可能的根因 | 排查动作 | 处理办法 |
|---|---|---|---|
| 成本突然翻倍,通过率没变 | 某档输出长度暴涨 | 看分阶段 token 统计,对比历史均值 | 检查该档提示词是否被改,补上长度约束 |
| 升级率从 12% 涨到 30% | 路由阈值漂移或分类器退化 | 用金标集重跑路由,看混淆矩阵 | 重新校准阈值,必要时补标注样本 |
| 少数用例反复升级 | 长尾用例触发全链路 | 按单条成本排序,看 top 20 | 设max_upgrades=2,超限直接进人工 |
| 通过率高但人工不认可 | 模型在讨好自动校验器 | 抽样人工复核 50 条 | 加强规范类检查,引入风格维度评分 |
| 语义缓存误命中 | 相似度阈值过低或跨模块复用 | 抽查命中样本,看是否同语言同路径 | 提高阈值,加路径前缀约束,失效该路径缓存 |
| 同一用例两次结果不同 | 采样参数未固定或缓存状态不同 | 对比 trace 里的种子和缓存标记 | 评测路径强制 temperature=0,禁用随机采样 |
| 账单和自记成本对不上 | 只统计了输入 token | 核对输出 token 和重试次数 | 改成逐条计价,重试单独记账 |
| 某阶段超时率升高 | 该档模型限流或长尾输入 | 看 p95 延迟分布和错误码 | 加超时降级,超时直接返回上一档结果 |
6.2 三个印象最深的坑
第一个坑:把“格式正确”当成“内容正确”。我们早期在生成段加了严格的 JSON 校验,结果发现模型学会了输出格式完美但内容空洞的结果——字段全在,值全是占位符。后来加了字段级的语义校验,比如补丁字段必须包含至少一个有效行号区间,描述字段不能是“修改相关代码”这类空话,问题才算解决。这个教训是通用的:任何校验一旦被模型感知到,它就会针对校验做优化,所以校验要分层,硬校验管格式,软校验管语义,人工抽检管规范。
第二个坑:缓存把评测集“记住”了。这个最阴。我们的评测集是固定的,跑了几轮之后缓存命中率一路涨到 35%,成本好看得不像话。后来做了一次留出集验证,发现真实场景的通过率比评测集低了 7 个点。原因是缓存把评测集的答案固化了,等于开卷考试。解决办法是评测时强制区分“冷缓存跑”和“热缓存跑”两个数字,报指标一律用冷缓存的结果,热缓存只用来估算线上成本。这个区分非常关键,不然所有优化数字都是虚的。
第三个坑:升级路径上的上下文丢失。我们从 S 升到 M 的时候,为了省 token,把规划段的输出压缩了一段再传下去,结果 M 档拿到的规划信息不完整,反而比直接用 M 从头做还差。这种现象在小模型上尤其明显,因为它对输入完整性的敏感度更高。后来定的规矩是:升级时规划段结果原样传递,一个字段都不裁,要省就省在别处。升级本来就是个低频动作,在这上面抠 token 属于捡芝麻丢西瓜。
注意:编排链路越长,越要警惕“每段都优化了但整体变差”的情况。我们每加一个环节,都会在全量集上对比“加之前”和“加之后”的最终通过率和总成本,只要出现通过率下降超过 1 个点,就要重新审视这个环节是不是真的有必要。多一个模型不等于多一份保险,也可能多一个甩锅的地方。
最后分享一个我个人的体会:多模型编排这件事,真正难的不是调度,而是判断每个环节到底值不值。我们前后加过又砍掉过三个环节——一次额外的自我反思、一次基于历史相似用例的检索注入、一次双重审查。砍掉的理由都一样:成本涨了 8% 到 15%,通过率提升不到 1 个点,人工采纳率一点没变。反而是几个看起来最不起眼的改动收益最大:把规划段的输出改成结构化、把审查建议从文本改成行号加片段、把升级时的规划复用起来。这三个改动加起来,成本又降了将近两成。所以如果你也想动这个方向,我的建议是先别急着设计复杂的编排图,先把你现有流水线的 token 分布和长尾成本摊开看一遍,钱花在哪儿、哪儿花了冤枉钱,看完那张表,该拆几段、该设几个档位,答案基本自己就浮出来了。