实测踩坑:77 类分类一上 Nimble 类模型就翻车,高基数场景先想清楚
从 Jev 把「决策模型」这个概念带火开始,开源社区在不到一个月里涌现了一批复刻与平替:有专用编码器的、有 Qwen 微调的,也有把结构化打分接口直接塞进 Ollama 本地运行的。Bespoke Labs 开源的 Bespoke-Nimble-9B 是其中相当有代表性的一个:在 Qwen3.5-9B 上叠一个约 165 MiB 的 LoRA 适配器,不做生成、只对「允许的答案 token」打分,用一次 softmax 输出类型化结论和概率。官方口径里,它在 324 条留出样例上与参考标签的吻合度达到 90.12%,而裸 Qwen3.5-9B 只有 66.36%。
但热度背后藏着一个社区实测反复撞上的坑:当选项从 10 个涨到 77 个,这类模型开始批量翻车。开源平替 Laya 的官方说明里直接写着「适用于 25 类以内的决策场景,高基数分类(如 77 类 Banking77)表现受限」;一份对 8 个开源 Jev 复刻项目的横向复盘也把「高基数选项泛化」列为当前开源方案与 Jev 的核心差距之一。翻车不是偶发,而是这类「单 token 打分」架构在选项维度上的系统性短板。这篇文章不贴榜单、不做软广,而是直接打开 Bespoke-Nimble-9B 的源码,把「10 类能用、77 类崩掉」这件事从代码层面拆清楚,再给出三条可落地的规避路径。
一、决策模型到底是怎么「答题」的
先看 Bespoke-Nimble-9B 的答题机制。仓库根目录的 inference.py 是官方参考评分器,核心链路非常短:先按 schema 把每个字段的候选答案映射成「码」(candidate token),再取出模型在最后一个位置对这几个码的 logits,做一次softmax(logits / temperature),argmax 就是预测结果:
def decision_result(row, logits, temperature=DEFAULT_TEMPERATURE): scaled_logits = logits / temperature probs = scaled_logits.softmax(-1) index = logits.argmax().item() ...serving_schema.py 里的candidate_logits用logits.gather(1, candidate_ids)只挑出候选码的分数,其余 token 全部用-inf屏蔽——也就是说,模型从头到尾没有「读」过 77 个选项的语义,它只是在给 77 个位置占位符打分。每个字段独立评分,字段之间互不可见。整个流程没有 JSON 解析、没有自由文本生成,这也是它能做到毫秒级延迟的原因。
二、复现现场:同一个任务从 10 类加到 77 类发生了什么
把同一个分类任务的选项数从 10 加到 77,表面上看只是往choices列表里多塞了 67 个字符串,但代码层面发生了四次静默切换。
第一次切换:26 是条分水岭。parallel_schema.py 的validate_schema硬性规定 enum 的 choices 只能是 1–26 个非空字符串。而 serving_schema.py 的prepare_prompts会按宽度自动分流:宽度 ≤26 走parallel_schema,超过 26 走 extended_schema.py。注意后者的模块 docstring 原话是「Experimental 255-choice prompts. Legacy ≤26-choice prompts remain byte-identical」——超过 26 类时,你已经离开了训练时所用的提示词契约,进入一个实验性的服务端扩展路径。且extended_schema里保留了注释:"Wide questions use the existing extended encoder",宽选项路径并没有对应的稳定训练支撑。
第二次切换:码本从 A–Z 变成了 AA/JT 这种两字母码。在 ≤26 类时,候选码就是 A、B、C……Z 共 26 个单 token;超过 26 类后,extended_schema.py 的extended_codes从 Qwen3.5 分词器词表里筛出「恰好能切成一个 token」的大写组合来扩充码本,凑满 255 个。码本受 BPE 词表约束,不是所有组合都在——对照 serving_config.json 里落盘的码本可以看到,BP 之后直接跳到 BR(BQ 缺失),J 段更是缺了 JF/JG/JH/JQ/JU 多个组合。同时 system prompt 的措辞也从「one-letter codes」悄悄变成「short codes」(见 schema_config.json 的wide_system_prompt)。模型被迫在每次推理时重新建立「AA=第 27 个选项、AB=第 28 个选项」这种纯粹位置性的映射,而这些码与选项语义毫无内在关联。
第三次切换:提示词急剧膨胀,逼近硬上限。每个选项在 JSON schema 里都是一条带 code、value、description 的记录,77 个选项意味着 77 条定义塞进上下文。官方对超限的处理是「reject 而不是 truncate」(README 与 extended_schema.py 中的报错信息"Nothing was truncated."),上下文硬上限是 8192 token。当 schema 本身吃掉大半上下文时,留给真正证据文本的窗口就被严重压缩。
第四次切换:打分层的概率摊薄。无论 10 类还是 77 类,最后都是一次 softmax。但候选数从 10 涨到 77,同一个 logits 分布要被摊到 77 个码上,top-1 概率和 top-1/top-2 margin 都会系统性收缩。更麻烦的是位置偏置:码就是位置占位符,模型学的是「第 k 个位置 ≈ 某语义」的关联,选项顺序一变、或相似选项相邻摆放,argmax 就变得很不稳。而官方评估数据里validation_order明确写着「original inference choice order; no shuffling」(见 schema_config.json 的 data_audit),留出集根本没做选项乱序压力测试——顺序鲁棒性问题就是这样被漏过去的。
三、翻车根因:选项维度上的校准失效与鲁棒性下降
把现象归纳完,根因可以收敛为两条:概率校准随选项数恶化,以及训练契约与高基数服务路径的错位。
先看校准。仓库里 temperature_config.json 记录得很清楚:当前 checkpoint 的temperature是 1.0,temperature_fitted为 false,注释特别强调「prior releases calibration does not apply」。为什么温度值得单独拟合?因为原始版本拟合过 2.179,拟合动机就是 T=1 下模型过度自信——官方报告里,第二组 300 条样例上,模型给 top-1 的平均概率高达 0.89,但 top-1 实际正确率只有 73%,期望校准误差(ECE)高达 0.128;调到 2.179 后 ECE 降到 0.066、log loss 从 0.692 降到 0.555、Brier 从 0.348 降到 0.295。也就是说,这个模型在 ≤26 类的场景里、在未经温度拟合时就已经系统性高估自信度。选项数翻到 77,概率被摊薄后,阈值语义进一步漂移——你原来定的 0.9 阈值、margin 阈值全部失效,而新 checkpoint 连一个拟合温度都没给你兜底。README 里的原话值得背诵:「A probability of 0.9 still does not mean that the answer is right 90% of the time on your data」。
再看训练与服务的错位。schema_config.json 的 data_audit 显示,v3(12026 行)训练数据里 choice 类任务有 8080 条,但训练契约的prompt_code_sha256锁定的是 26 类以内的 legacy 提示词(inference.py 里对parallel_schema.py的哈希校验可以佐证);extended_schema只是服务端的「实验性」补丁。v3 训练集确实混入了 2000 条 public-banking77(77 类),但对比训练集的整体构成——大量来自对比式数据构造(contrastive data curation)的二元翻转样本,选项集普遍很小——77 类这种高基数形态在训练分布里占比极低。训练分布里没有的东西,服务端再宽容也补不回来。这与社区的横评结论完全对上了:8 个开源项目在「结构化决策接口」上高度复现 Jev,但在「概率校准、高基数选项泛化、跨任务基准统一性」三个维度仍是差距。
四、规避方案:分层决策、缩小选项集、兜底回退
翻车根因清楚了,规避方案也就不再是玄学。核心原则只有一句:永远不要让一次单 token 打分面对 30 个以上的候选。三条路可以叠加使用。
方案一:分层决策,把 77 拆成「粗分 + 细分」两级。第一层只做 ≤26 类(最好是 ≤10 类)的粗路由,第二层再在被命中的子集内部做细分类。每一层都留在单 token 码(A–Z)区间,也就是训练覆盖最充分的区间。用仓库自带的评分接口落地很直接:
model = NimbleModel("nimble-model") stage1 = { "category": { "type": "enum", "description": "Which top-level team should own this ticket?", "choices": ["BILLING", "TECH_SUPPORT", "SHIPPING", "RETURNS", "ACCOUNT", "OTHER"], } } r1 = model.score(context, stage1) probs = r1["fields"]["category"]["probabilities"] top1 = max(probs.items(), key=lambda kv: kv[1])[0] # 命中粗类后,再在粗类对应的 ≤10 个细类上做第二层决策 r2 = model.score(context, stage2_schemas[top1])注意两层是两次独立的score()调用,天然符合「字段之间互不可见」的约束,不存在跨层信息泄露问题。
方案二:先检索、后打分,缩小选项集。77 个类别里真正可能命中的往往只有几个。上线前用 embedding 相似度、关键词规则或历史统计把候选收敛到 top-k(k ≤ 10,上限别超过 26),再动态构造 schema。这一步把高基数问题在进入模型之前就消解掉,成本最低、效果最直接——毕竟码本、温度和训练分布都建立在「小选项集」的前提上,我们能做的最聪明的事就是不给模型出难题。
方案三:兜底回退,把「不知道」显式建模。高基数翻车的另一面是:77 个选项本来就可能没有正确答案。官方 README 明确建议「If it is possible that none of your answers fit, add an answer that means 'no match'」——即显式增加"NONE"/"OTHER"之类的候选,让模型有权拒绝。在此基础上叠加置信度闸门,读 inference.py 返回的probabilities字段,对 top-1 概率和 top-1/top-2 差值设阈值,不达标就走带推理的通用 LLM 或人工兜底:
items = sorted(probs.items(), key=lambda kv: kv[1], reverse=True) p1, p2 = items[0][1], items[1][1] if p1 < 0.7 or (p1 - p2) < 0.15: return fallback_reasoning_model(context) # 回退:LLM 推理或人工两个伴随动作必须同时做:其一,阈值必须在自己业务的真实数据上重新标定,官方拟合的温度只对官方留出集负责,T=1.0 的 checkpoint 与旧版 2.179 的校准完全不能互相套用(temperature_config.json 与本地 README 都明确警告了这一点);其二,模型一升级、选项一重排,就要重新跑一轮阈值回归,因为这类模型的概率语义对选项数极其敏感。
五、写在最后:高基数场景先把边界想清楚
Bespoke-Nimble-9B 这类模型的价值是真实的:单 token 打分架构换来毫秒级延迟、结构化输出零解析成本、Ollama 0.35 把决策模型塞进本地后连 API 成本都省了。但「快」和「准」是两个维度。源码里 26 类的契约硬边界、实验性的宽选项路径、未拟合的温度、不做乱序的留出集,这些细节共同画出了它的能力边界:它是为有界、低基数、可枚举的决策而设计的。77 类分类不是它的主战场,除非你先做好分层、收敛和兜底。高基数场景的正确姿势,是把它放进一个更大的决策流水线里当那个「最快的第一跳」,而不是让它独自扛下全部 77 个选项——想清楚这一点,踩坑就能变成抄近路。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考