1. 从"写作文"到"做选择":一次推理范式的彻底翻转
第一次看到这个思路的时候,我正被一个本地小模型的响应速度折磨得够呛。一个简单的多步任务,它要花十几秒甚至几十秒才能给出结果,中间还经常跑偏。后来我把它的输出方式从"自由生成"改成了"从候选里挑一个",实测下来整体思考速度提升了接近一个数量级。这个变化听起来很朴素,但背后的逻辑值得好好拆一拆。
所谓 Small Agent,指的是跑在本地或者边缘设备上的轻量级智能体,参数量通常不大,算力预算也有限。它要完成的任务往往是多步的:先理解意图,再决定调用哪个工具,再根据工具返回的结果决定下一步。传统做法是让模型每一步都"自由生成"一段文本,比如生成一个工具名、生成一段参数、生成一句下一步的说明。问题在于,自由生成是一个开放式的搜索过程,模型要在整个词表上做概率分布,还要一个 token 一个 token 地往外吐,token 越多,耗时越长,而且越往后越容易发散。
"做选择题"的思路则完全不同。我们预先准备好一组候选动作,让模型只在这几个选项里挑一个。这样模型的输出空间从几万维的词表骤降到几个候选,计算量、解码步数、出错概率全都跟着下降。打个比方,自由生成像是让一个人在白纸上写作文,做选择题则是给他一张答题卡让他涂选项。写作文要考虑遣词造句、结构铺陈,涂答题卡只需要判断哪个最合适。对于算力紧张的小模型来说,这个差别就是能不能跑得动的差别。
这篇文章我想聊的就是这套思路的完整落地:为什么自由生成慢、选择题快,候选集怎么设计,打分怎么做,怎么和工具调用串起来,以及我在实操中踩过的那些坑。适合正在做本地智能体、边缘推理、或者任何对延迟敏感场景的同行参考。哪怕你用的是大模型,这套思路在需要高频决策的环节同样能省下大量时间。
2. 为什么"自由生成"是 Small Agent 的性能瓶颈
2.1 自回归解码的固有成本
要理解提速的来源,得先搞清楚自由生成到底慢在哪。现在主流的语言模型都是自回归的,也就是一次只预测一个 token,然后把预测出来的 token 拼回输入,再预测下一个。这个过程的耗时和输出长度是线性关系。生成 50 个 token 和生成 5 个 token,时间差就是 10 倍。
对于 Small Agent 来说,这个成本被放大了。因为智能体的每一步决策往往都要生成一段结构化文本,比如一个 JSON 格式的工具调用:
{"tool": "search_flight", "params": {"from": "北京", "to": "上海", "date": "2024-06-01"}}这一串下来少说三四十个 token。如果任务有五六步,光解码就要几百个 token 的时间。而小模型的单 token 解码速度本来就不如大模型,因为它的算力预算小,KV 缓存也小,批量推理的吞吐上不去。两头一叠加,延迟就非常可观了。
更麻烦的是,自由生成的结果是不确定的。同一个输入,模型可能这次生成search_flight,下次生成find_flight,再下次生成查询航班。你的下游解析代码就得写一堆兼容逻辑,稍微没覆盖到就报错。这种不确定性在演示的时候可能看不出来,一旦上量就是灾难。
2.2 小模型的"发散"问题
大模型有个好处是它见过足够多的语料,生成结构化内容时比较稳。小模型就不一样了,参数量小意味着它对格式的把握能力弱。你让它生成 JSON,它可能漏个引号;你让它生成工具名,它可能自己造一个不存在的。这种发散在自由生成模式下几乎无法根治,因为模型本质上是在做概率采样,你只能通过提示词和温度参数去约束,但约束力有限。
我做过一个对比测试,同一个 3B 级别的模型,让它自由生成工具调用,100 次里有 12 次格式错误或者工具名不存在。改成从 8 个候选工具里选,错误率直接降到 0。这个差距不是靠调提示词能补上的,是范式本身的差异。
2.3 延迟敏感场景的硬约束
有些场景对延迟的要求是硬性的。比如语音助手,用户说完一句话,你如果两秒内没反应,体验就崩了。比如实时游戏里的 NPC 决策,每一帧都有时间预算。再比如工业设备上的本地智能体,算力就那么点,还要留出余量给其他任务。这些场景里,自由生成的那几百毫秒到几秒的延迟是致命的。
把推理改成做选择题,本质上是把"开放式搜索"降级成"封闭式分类"。分类问题的计算复杂度远低于生成问题,这是信息论层面的优势,不是工程优化能比的。你不需要模型去"想"怎么表达,只需要它判断哪个候选最合适。判断比表达容易得多,这个直觉在模型身上同样成立。
3. 候选集设计:选择题的题库怎么出
3.1 候选动作的粒度划分
做选择题,第一件事是出题库。候选集的粒度直接决定了这套方案能不能用。粒度太粗,比如只给"查询""修改""删除"三个大类,模型选完了你还得再生成具体参数,等于没省。粒度太细,比如把每个可能的参数组合都列成一个候选,候选数量爆炸,打分成本反而上去了。
我的经验是把候选集分成两层。第一层是动作类型,比如"调用搜索工具""调用计算工具""回复用户""请求澄清"。这一层候选通常控制在 5 到 10 个。第二层是参数填充,这一层不用生成,而是用槽位填充的方式,从预定义的枚举或者上下文里抽取。比如搜索工具的出发地,直接从用户输入里抽实体,不需要模型生成。
这样设计的好处是,模型只需要在第一层做选择题,第二层是确定性的抽取逻辑。既省了生成时间,又避免了参数幻觉。
3.2 候选数量的甜点区
候选数量不是越多越好。我实测过不同候选数量下的延迟和准确率,大致有这么个规律:
| 候选数量 | 单步决策延迟 | 选择准确率 | 备注 |
|---|---|---|---|
| 3-5 | 极低 | 高 | 适合动作类型少的场景 |
| 6-10 | 低 | 较高 | 大多数智能体的甜点区 |
| 11-20 | 中等 | 开始下降 | 需要更好的打分模型 |
| 20 以上 | 接近自由生成 | 明显下降 | 失去选择题的优势 |
候选超过 20 个之后,模型要在更多选项里做区分,打分的不确定性上升,而且候选本身的编码也要占输入长度,反而拖慢速度。所以我的建议是把候选控制在 10 个以内,超出的部分用分层或者路由的方式拆解。
3.3 候选的动态裁剪
固定候选集有个问题:有些候选在当前上下文里根本不可能。比如用户问天气,你没必要把"转账""订机票"这些候选也放进去。动态裁剪就是根据当前上下文,先把明显不可能的候选过滤掉,只留下相关的几个。
裁剪的逻辑可以很简单,用关键词匹配或者轻量级的意图分类器先过一遍。比如检测到用户输入里有"天气""温度""下雨"这些词,就只保留天气相关的候选。这一步几乎不耗时间,但能把候选数量从 10 个降到 3 个,进一步提速。
注意:动态裁剪的过滤逻辑要保守一点,宁可多留几个候选,也不要把正确选项误删了。我一开始为了追求速度把过滤写得很激进,结果经常把正确候选筛掉,模型只能在剩下的错误选项里选,准确率反而崩了。
4. 打分机制:让模型给每个候选"投票"
4.1 从生成到打分的转换
候选集有了,接下来是让模型给每个候选打分。这里的关键转换是:不再让模型生成答案,而是让它对每个候选输出一个分数或者概率。实现方式有几种,我逐个说一下。
最直接的是把候选拼成一段文本,让模型输出每个候选的得分。比如输入是用户 query 加上候选列表,输出是一串数字。但这种方式还是依赖生成,只是生成的内容变短了。
更高效的是利用模型的对数概率。把每个候选作为一个续写目标,计算模型在该候选上的对数概率,取概率最高的那个。这种方式不需要模型"生成"任何东西,只需要做一次前向计算,拿到每个候选位置的概率分布就行。速度比生成快得多,因为只算一次,不用自回归地一步步解码。
还有一种是用专门的分类头。如果模型支持,可以在最后一层接一个分类层,直接输出每个候选的分数。这种方式最快,但需要模型架构支持,不是所有现成模型都能直接用。
4.2 对数概率打分的实操细节
我目前用得最多的是对数概率打分,因为它对模型改动最小,兼容性最好。具体做法是把候选构造成"问题 + 候选"的形式,然后看模型在候选部分的概率。
举个例子,用户问"帮我查一下明天北京的天气",候选是:
- 调用天气查询工具
- 调用日历工具
- 回复用户
我把输入构造成:
用户:帮我查一下明天北京的天气 助手应该:然后分别计算"调用天气查询工具""调用日历工具""回复用户"这三个续写的对数概率。概率最高的就是模型的选择。
这里有个细节要注意:候选文本的长度不一样,直接比总对数概率会偏向短候选。所以要用平均对数概率,也就是总对数概率除以 token 数。这样才公平。
4.3 温度与归一化
打分的时候温度参数也有讲究。温度高,概率分布平,候选之间的差距被抹平,容易选错。温度低,分布尖,模型更自信,但也可能过度自信。我一般用温度 1.0 做打分,保持模型原本的概率分布,不做额外缩放。
归一化方面,如果候选数量少,直接用 softmax 归一化就行。如果候选多,可以考虑用 top-k 或者阈值过滤,把明显不可能的候选概率压到零,减少干扰。
实操心得:打分之前一定要把候选的文本格式统一。比如都加上相同的后缀,或者都用相同的模板。格式不一致会让模型在无关的 token 上产生概率差异,干扰判断。我踩过这个坑,候选 A 是"调用搜索",候选 B 是"调用搜索工具",多了两个字,概率就偏了。
5. 与工具调用的串联:把选择题嵌进智能体循环
5.1 单步决策的完整流程
把选择题嵌进智能体的循环,单步的流程大致是这样:
- 接收当前上下文,包括用户输入、历史对话、上一步工具返回的结果
- 根据上下文动态裁剪候选集
- 对每个候选计算对数概率
- 选出概率最高的候选
- 如果是工具调用,抽取参数并执行
- 把执行结果拼回上下文,进入下一步
这个流程里,第 3 步是核心,也是提速的关键。因为只做一次前向计算,不做自回归解码,单步耗时通常能压到几十毫秒级别。相比自由生成的几百毫秒到几秒,提升非常明显。
5.2 多步任务的终止判断
多步任务什么时候结束?传统做法是让模型生成一个"结束"标记。在选择题框架下,我们把"结束"也做成一个候选。当模型选中"结束"候选时,循环终止,输出最终结果。
这样设计的好处是终止判断也变成了一次分类,不需要额外的生成。而且"结束"候选的概率可以作为一个置信度指标,如果模型在"结束"和其他候选之间犹豫,说明它不确定任务是否完成,这时候可以触发澄清或者重试。
5.3 错误恢复与重试
选择题框架下的错误恢复比自由生成简单。因为候选是有限的,如果选中的工具执行失败,可以直接把失败信息拼回上下文,重新做一次选择题。模型看到失败信息后,大概率会选另一个候选或者选"请求澄清"。
我一般会设置一个重试上限,比如连续失败三次就强制终止,避免死循环。这个上限不用太高,因为选择题的准确率本来就高,连续失败三次说明确实有问题,再试也是浪费。
6. 性能实测:10 倍提速从哪来
6.1 延迟拆解对比
我拿同一个 3B 模型做了对比测试,任务是一个五步的行程规划:查天气、查航班、查酒店、算预算、汇总。自由生成和选择题两种模式的延迟拆解如下:
| 环节 | 自由生成 | 选择题 | 说明 |
|---|---|---|---|
| 单步决策 | 约 800ms | 约 60ms | 自由生成平均 40 token,选择题一次前向 |
| 参数抽取 | 含在生成里 | 约 10ms | 选择题用规则抽取 |
| 工具执行 | 约 200ms | 约 200ms | 两者相同 |
| 五步总计 | 约 5s | 约 1.35s | 不含工具执行约 3.5 倍,含执行约 3.7 倍 |
单看决策环节,提速是 13 倍左右。整体任务因为工具执行时间固定,被稀释到 3 到 4 倍。如果工具执行更快,或者步数更多,整体提速会更接近决策环节的倍数。标题里说的 10 倍,指的是纯推理环节的提速,这个数字在决策密集的任务里是能复现的。
6.2 准确率的变化
提速不能以牺牲准确率为代价。实测下来,选择题模式的准确率反而更高。同样是 100 次测试,自由生成的工具选择准确率是 88%,选择题是 97%。原因前面说过,选择题把输出空间压缩了,模型不用在几万维的词表里搜索,只需要在几个候选里判断,出错概率自然低。
参数抽取的准确率两者差不多,因为这部分本来就是确定性的。但选择题模式下参数幻觉明显减少,因为参数不是生成的,是从上下文抽取的。
6.3 资源占用的差异
除了延迟,资源占用也值得说。自由生成因为要维护 KV 缓存、要自回归解码,显存和内存的峰值占用更高。选择题只需要一次前向,峰值占用低不少。在边缘设备上,这个差异可能决定了能不能跑起来。
我在一台 8GB 显存的设备上测试,自由生成模式跑 3B 模型已经比较吃力,批量稍微大一点就 OOM。选择题模式因为不需要缓存解码过程,同样的设备能跑更大的批量,吞吐上去了,单位成本就下来了。
7. 常见问题与排查技巧实录
7.1 候选概率都很低怎么办
有时候模型对每个候选的概率都不高,最高的也就 0.3 左右。这种情况通常说明候选集里没有真正合适的选项,或者上下文信息不足。我的处理方式是设一个置信度阈值,比如最高概率低于 0.5 就触发澄清,让模型选"请求澄清"候选,向用户追问。
阈值不用设太高,0.5 是个比较稳的值。设太高会频繁触发澄清,体验差;设太低会强行选一个不靠谱的候选,错误率高。
7.2 候选之间概率接近怎么处理
如果前两个候选的概率很接近,比如 0.45 和 0.42,说明模型在犹豫。这时候直接选最高的可能选错。我的做法是看差值,如果差值小于 0.1,就把这两个候选都执行一遍,看哪个的结果更合理。或者干脆触发澄清,让用户来定。
这个策略在工具调用场景下特别有用。比如模型在"查航班"和"查火车"之间犹豫,两个都查一下,把结果都给用户,让用户选。多花一点时间,但避免了选错。
7.3 候选集需要更新怎么办
业务变化了,候选集要加新选项或者删旧选项,怎么办?我的建议是把候选集做成配置,不要硬编码在代码里。这样更新的时候改配置就行,不用动代码。同时要做好版本管理,每次更新候选集都记录一下,方便回溯。
更新候选集之后要重新测准确率,因为新候选可能和旧候选有语义重叠,导致模型混淆。比如加了"查询天气"和"查询气象",这两个太像了,模型分不清。遇到这种情况要么合并,要么在候选描述里写清楚区别。
7.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 所有候选概率都低 | 候选集不匹配或上下文不足 | 检查候选是否覆盖当前意图 | 补充候选或触发澄清 |
| 前两候选概率接近 | 候选语义重叠 | 检查候选描述是否区分度够 | 合并候选或细化描述 |
| 选中候选后执行失败 | 参数抽取错误 | 检查抽取规则 | 修正抽取逻辑或加校验 |
| 延迟没有明显下降 | 候选数量过多 | 统计候选数量 | 动态裁剪到 10 个以内 |
| 准确率下降 | 候选集更新引入混淆 | 对比新旧候选集 | 回滚或重新设计候选 |
避坑技巧:候选集的维护是个持续工作,不是一劳永逸的。业务在变,用户在变,候选集也要跟着变。我一般每个月 review 一次候选集的使用情况,把长期没被选中的候选删掉,把频繁触发澄清的场景补上新候选。
8. 适用边界与扩展思路
8.1 什么场景适合这套方案
选择题框架最适合的是决策空间有限、动作类型可枚举的场景。比如工具调用、意图分类、下一步动作选择。这些场景里候选集是天然存在的,不需要额外构造。
不太适合的是开放式生成任务,比如写文章、编故事、做翻译。这些任务的输出空间是开放的,没法枚举候选。硬要做选择题,候选集要么覆盖不全,要么数量爆炸,得不偿失。
还有一种边界情况是候选集很大但可以分层。比如电商客服,动作类型有几十种,但可以按业务域分成几层,每层做一次选择题。这样每层的候选数量可控,整体还是能享受选择题的提速。
8.2 和投机解码的关系
有人会问,这和投机解码是不是一回事。不是。投机解码是用一个小模型快速生成草稿,再用大模型验证,本质还是生成,只是加速了生成过程。选择题是彻底放弃了生成,改成分类。两者可以叠加,但思路不同。
选择题的优势在于它连草稿都不用生成,直接一次前向出结果。在候选集小的情况下,比投机解码更省。但候选集大的时候,投机解码可能更灵活,因为它不受候选集限制。
8.3 后续可以怎么扩展
这套框架还能往几个方向扩展。一个是候选集的自动学习,用历史数据统计哪些候选经常被选中,动态调整候选的优先级和数量。另一个是打分的多模态化,不光看文本概率,还结合上下文特征、用户画像等做综合打分。
还有一个方向是把选择题和生成结合起来,用选择题做粗粒度的决策,用生成做细粒度的表达。比如先选"回复用户"这个动作,再生成具体的回复内容。这样既享受了选择题的决策速度,又保留了生成的灵活性。
我个人在实际操作中的体会是,这套方案的价值不在于某个具体的技术点,而在于它改变了思考问题的方式。以前遇到延迟问题,第一反应是换更快的模型、加更多的算力。现在会先问一句:这个环节真的需要生成吗,能不能改成选择?很多时候答案是可以的,而且改完之后效果立竿见影。最后再分享一个小技巧:候选集的描述文本尽量用动词开头,比如"查询天气"而不是"天气查询",模型对动词开头的候选区分度更高,实测准确率能再提一两个点。