强弱模型路由跑起来就成了黑盒?我给每趟路由记了笔账,命中率、白升级一目了然,分级表越调越准

2 阅读11分钟

上一篇把"便宜模型先干、验收不过自动升级旗舰"的路由搭了起来,结尾我留了个话头:这套路由跑起来之后,怎么知道它配得好不好、怎么持续往下调。这篇就把这件事讲透——不是再给你一套路由,而是给路由装一双眼睛:每趟路由都记一笔账,回头从账里算出便宜档命中率、哪些活总在升级、哪些升级其实是白升,再用这些数反过来调那张分级表。

先说清楚我为什么要补这一层。路由刚搭好那会儿,我以为能跑通就完事了,结果跑了一批任务下来,心里完全没底:到底有多少活在便宜档就被收走了、哪些类型的任务便宜模型死活干不了、验收是不是把本来能用的产出也误杀了,我一个都说不上来。没有这些数,那张分级表就是拍脑袋定的,模型一更新,更是凭感觉。具体模型名、分档和定价以官方当期文档为准,这篇只讲怎么把路由变成一个看得见、能持续优化的东西。

一、路由跑起来就不管了?你会遇到三个两眼一抹黑的问题

我把刚上路由时踩的坑归成三类,你对照一下,基本都躲不过。

第一,不知道钱和时间到底花在哪。 路由的意义是让便宜档扛大头、旗舰只收难活。可你要是不记账,跑一批下来,只知道账单涨了,却说不清有多少比例的任务真的走了便宜档、有多少升级到了旗舰。万一便宜档命中率很低,一半任务都白跑一轮再升级,那这套路由不仅没省,反而多花了一轮便宜档的调用、还更慢。

第二,不知道哪些活该往上提、哪些能往下放。 分级表是你一开始按经验定的,但不同类型的任务,便宜模型表现差别很大。不看数据,你会发现某类任务每次都升级、每次都白白先让便宜档试一遍,你却一直没把它从"便宜先试"挪到"直接旗舰";反过来,有些活便宜档已经稳定能拿下,你也没敢把相邻更难的活往下放。

第三,不知道验收是在帮忙还是在添乱。 验收是路由的命门,但验收规则是人写的,可能过松、也可能过严。过松了,错的产出漏过去;过严了,便宜档本来能用的东西被反复打回、甚至升级到旗舰让它再做一遍,结果旗舰改完跟便宜档差不多——这就是"白升级"。不记录每一次验收失败的原因,你根本发现不了。

这三个问题根子是同一个:路由是个自动决策的东西,可它决策的过程和结果全是黑的。 可观测要解决的,就是把这个黑盒拆开。

二、一趟路由到底要记哪些字段:少了哪个都没法回头复盘

我给每趟路由都建一条记录,字段是反复精简过的,每一个都对应一个事后要回答的问题。用标准库的 dataclass 写,直接抄:

from dataclasses import dataclass, field
import time

@dataclass
class RouteRecord:
    task_id: str              # 任务唯一标识,出问题能回查到具体这条
    task_type: str            # 任务类型,必须和分级表的分类对齐
    started_at: float = 0.0   # 开始时间
    tier: str = "cheap"       # 最终落在哪个档位:cheap / strong
    escalated: bool = False   # 这一趟有没有走过升级
    cheap_attempts: int = 0   # 便宜档一共试了几次
    fail_reasons: list = field(default_factory=list)  # 每次验收失败的原因
    model: str = ""           # 实际用的模型名,以官方当期文档为准
    latency_ms: int = 0       # 这一趟从头到尾耗时
    ok: bool = False          # 最终有没有通过验收
    unnecessary: bool = False # 是不是白升级(事后抽检填)

挑几个容易漏的字段说。

task_type 是最关键的一个,它必须和你分级表里的分类用同一套词,否则你后面没法按类型算升级率。你分级表里写的是"批量分类""抽取""格式转换",记录里就也用这几个词,别一会儿写"打标签"、一会儿写"分类任务",否则数据全散了。

fail_reasons 要记成一个列表,而不是只记最后一次。便宜档可能试了两次、被打回两次,每一次失败的原因都有价值——它告诉你便宜模型到底卡在哪个具体要求上。

unnecessary(白升级)这个字段没法在当下自动知道,要靠事后抽检来填,后面第四节细讲。

started_at 和 latency_ms 是用来发现另一种浪费的:有些任务便宜档反复试、每次都差一点,耗的时间比直接上旗舰还长。 把耗时记下来,这种"磨洋工"的类型才看得出来。

三、把记账织进路由:不污染主逻辑的做法

字段定好了,接下来是把记录动作塞进上一篇的路由里。这里有个原则:记账是顺带的事,不能为了记账把路由主逻辑搞得又乱又难读。 我把记录对象从外面传进去,在路由的几个关键节点顺手更新,结构和上一篇完全一致:

import time

def run_with_escalation_observed(task, cheap_call, strong_call, validate,
                                 record, cheap_model, strong_model,
                                 feedback=None, max_cheap_attempts=1):
    start = time.time()
    current = task
    last_result = None
    record.started_at = start
    record.task_type = task.get("type", "unknown")

    # 便宜档先做,做完立刻过验收
    for attempt in range(max_cheap_attempts):
        record.cheap_attempts += 1
        record.model = cheap_model
        last_result = cheap_call(current)
        ok, reason = validate(current, last_result)
        if ok:
            record.tier = "cheap"
            record.ok = True
            record.latency_ms = int((time.time() - start) * 1000)
            return {"tier": "cheap", "result": last_result, "escalated": False}
        record.fail_reasons.append(reason)
        if feedback is None:
            break
        current = feedback(task, last_result, reason)

    # 验收不过,连同便宜档产出和失败原因升级旗舰
    record.model = strong_model
    strong_task = {"original": task, "cheap_attempt": last_result,
                   "why_it_failed": record.fail_reasons[-1:]}
    result = strong_call(strong_task)
    ok, reason = validate(task, result)
    record.tier = "strong"
    record.escalated = True
    record.ok = ok
    if not ok:
        record.fail_reasons.append("strong:" + reason)
    record.latency_ms = int((time.time() - start) * 1000)
    return {"tier": "strong", "result": result, "escalated": True}

这样每趟路由跑完,record 就被填好了,你可以直接 append 到一个列表里,或者攒一批落盘。提醒一句:别在路由热路径里做写文件、发网络请求这种重活,先把记录堆在内存里,跑完一批再统一落盘,否则观测本身会把批量任务拖慢。

四、从一堆记录里算出关键几个数:命中率、升级率、白升级

记录攒下来之后,要能算出东西才有意义。我固定算这么几个数,全部用标准库,一段聚合函数搞定:

from collections import Counter, defaultdict

def summarize(records):
    total = len(records)
    if total == 0:
        return {"total": 0}

    escalated = [r for r in records if r.escalated]
    cheap_hit = total - len(escalated)

    # 按任务类型算升级率:这是调分级表的核心依据
    by_type = defaultdict(lambda: {"total": 0, "escalated": 0})
    for r in records:
        v = by_type[r.task_type]
        v["total"] += 1
        if r.escalated:
            v["escalated"] += 1

    # 失败原因分布:集中在某一条,说明不是模型不行,是某个具体要求没满足
    reasons = Counter()
    for r in escalated:
        for why in r.fail_reasons:
            reasons[why] += 1

    # 白升级率:抽检后被标记为 unnecessary 的占比
    unnecessary = sum(1 for r in records if r.unnecessary)

    return {
        "total": total,
        "cheap_hit_rate": round(cheap_hit / total, 3),
        "escalation_rate": round(len(escalated) / total, 3),
        "unnecessary_rate": round(unnecessary / total, 3),
        "type_escalation": {
            t: round(v["escalated"] / v["total"], 3)
            for t, v in by_type.items()
        },
        "top_fail_reasons": reasons.most_common(5),
    }

重点说"白升级"怎么识别。最直接的办法是抽检:升级到旗舰之后,让旗舰先判断一下便宜档的产出是不是其实已经能用、只是没过那条验收;如果它看下来便宜档产出没问题,就把这条标记为 unnecessary。不用每条都判,按比例抽一部分就够,否则为了算白升级又得多花一轮旗舰的钱,得不偿失。

def mark_unnecessary(record, cheap_result, strong_result, judge):
    # judge 让旗舰判:便宜档产出是否已经可用;只抽一部分,控制成本
    if judge(cheap_result, strong_result):
        record.unnecessary = True

你最后会拿到四个层面的数:整体命中率/升级率告诉你路由总体省不省、分类型升级率告诉你哪类活该挪档位、失败原因分布告诉你具体卡在哪、白升级率告诉你验收是不是过严。这几个数凑齐,分级表才有的放矢。

五、用这些数反过来调分级表:什么信号对应什么动作

可观测不是为了看个热闹,落点是把第二节那张分级表越调越准。我把自己反复用到的判断整理成一张对照表,你照着信号找动作就行:

你在账里看到的信号说明问题对应的调整动作
某类任务升级率持续偏高便宜档稳定扛不住这类活直接从"便宜先试"提到"直接旗舰",省掉白跑的一轮
某类便宜档稳定通过、几乎不升级便宜档已经能稳定拿下保持,并把相邻更难的同类活也试着往下放
白升级率偏高、集中在某类这类的验收过严、误杀了能用的产出放宽该类的验收阈值或规则
失败原因集中在某一条不是模型不行,是某个具体要求没满足改 prompt、补示例、在交接契约里把这条写死
某类便宜档耗时反而更长(反复试)便宜档来回磨,不如直接旗舰减少这类的尝试次数,或干脆直接升级
升级后旗舰也没过验收任务没说清、或验收本身有问题回到任务定义和验收规则,不是档位的事
某类失败原因很杂、没有规律便宜档在这类活上确实不稳往上提档位,别在这类活上硬省

举个最典型的用法。我发现"格式转换"这类任务升级率很低、便宜档稳定通过,就把更难一档的"按模板整理长材料"也试着放下去;同时发现"抽取"类任务每次都卡在"漏字段"这一条原因上,这不是模型不行,是我没把"必须返回哪些字段"说死,于是在交接契约里把字段列全、补了两个示例,升级率立刻就下来了。你看,这一类调整根本不是换模型,是把要求讲清楚——这种发现,不记账是永远不会有的。

六、可观测最容易做歪的几个坑

这一层我也走过弯路,列几个最容易犯的,给你避坑。

只记成功、不记失败。 这是最常见的。成功的记录看着好看,但真正告诉你怎么优化的,恰恰是那些升级、失败、被打回的记录。失败记录一条都不能丢。

样本太少就急着调表。 一个类型总共就三五条任务,这时候算出来的升级率没有任何意义,可能纯属这几条刚好都难。攒到一定样本量再下结论,别被偶然波动带着改来改去。

任务类型切得太细。 task_type 要是分了几十种,每个格子里就几条样本,永远算不出规律。分类要粗一点、和分级表对齐,先保证每类有足够样本,真有需要再往下细分。

只看整体命中率、不看分类型。 整体命中率挺好看,可能掩盖了某一类活的全面崩溃——总量被大量简单任务冲淡了。真正决定怎么调表的是分类型那组数。

把可观测做成了告警。 可观测是给你定期复盘、调表用的,不是一升级就弹个通知。要真为每趟升级都报警,那纯属给自己添噪音,批量任务升级本来就是正常现象。

写在最后

给路由加可观测,听上去是"多写了点日志",其实是把一套自动分工从"能用"变成"能持续变好":每趟路由记清档位、失败原因和耗时,回头算出命中率、分类型升级率和白升级率,再用这些数把分级表、验收规则和交接契约一个个调准。 不然你那张分级表永远停在上线第一天的样子,模型在进步、你的路由却没跟上。

评论区留个数字,说说你现在到哪一步了:1、路由跑了但没记过账,好不好全凭感觉;2、随手记了点成功失败的日志,但没算过命中率;3、命中率、升级率都算了,但还没用来反调分级表;4、已经在用账里的数据定期调表了。 票数最高的那档,我下一篇往细里拆。

下一篇我打算写:把分级表、验收规则、模型档位从代码里抽出来做成配置——现在调一次表还得改代码,配置化之后,你改个配置就能调档位、换模型,路由才真正算交付。

觉得有用,点个赞、收藏起来,等你自己的路由跑上一批、准备回头优化时,照着这套记账和聚合代码搭一遍;顺手关注一下,这个系列我一篇篇往下更。