做 Agent 开发的同学应该都有过类似体验:本地跑一个小参数量的模型(7B、13B 这一档),让它承担一个循环决策的角色。模型每次思考都要先生成一大段“推理过程”,再从中抽取出动作。逻辑上没错,但线上用户等不住。我一个同事有个思路挺直接的——“你让它自己想下一步,不如直接把下一步列出来让它选。”就这么一个改动,整个 agent 的决策响应时间从 2.8 秒压到了 0.3 秒左右,几乎是一个数量级的差距。
这篇文章就把这个思路拆开讲清楚:为什么小模型做“生成式推理”会慢,怎么把 agent 的决策链路改造成“选择题”,以及改造过程中容易踩的坑。内容更适合两类人看:一类是在做本地化或私有化部署、对模型推理延迟敏感的开发者;另一类是正在用中小模型搭 Agent、但觉得“速度就是上不去”的同学。
1. 为什么小模型“自由发挥”时又慢又飘
1.1 生成式推理的固定成本在“逐字”
大多数开源小模型都是自回归结构,输出是逐 token 生成的。也就是说,模型每生成一个“字”,都要跑一次完整的前向计算。假设一次 agent 决策需要模型生成 150 个 token,内部就要跑 150 次前向。注意,这里的输入还在不断变长——每生成一个 token,KV Cache 和 attention 的计算量都会有变化,实际耗时不是简单的线性关系,但总体趋势摆在那。
如果 agent 逻辑是“生成思考 → 提取动作 → 调用工具 → 观察结果 → 再生成思考”,那每一轮都要重复这个成本。一个任务跑 3 轮,可能就要生成 300 到 500 个 token。在小模型上,这已经不是“快不快”的问题,而是用户体感上明显的卡顿。
1.2 小模型的“发散”比大模型更严重
生成式推理对小模型并不友好。参数少意味着模型的短期记忆和逻辑链跟踪能力都弱。让它写一段分析,经常会写着写着开始重复、跑题,甚至在没有把握时编造中间步骤。我见过一个项目,让 7B 模型判断工单类型,它在开头正常输出“该用户反馈商品破损”,后面就开始铺垫“如果是物流问题则需要联系快递公司,如果是商品质量问题则需要联系商家……”——内容没错,但完全不是下一步操作需要的。
这个问题本质上是“生成”任务的困难程度远高于“判别”任务。让小模型做开放式的推理生成,相当于让一个普通员工在台上即兴演讲;而让它从几个预设选项里判断,那只是让他做一道判断题。后者显然更稳。
1.3 很多 agent 设计把简单事搞复杂了
很多 Agent 框架默认让模型自由输出 JSON、自由输出动作,看起来灵活,但实际业务里,一个 agent 在每个决策点的候选动作往往是有限的。
举例来说,一个售后工单 agent 的动作空间基本就是:退款、补发、转人工、标记完成、查询物流。这些动作在系统里已经定义好了,根本不需要模型“想”出来。让模型从一个封闭集合里选一个,比让它“自由生成”要合适得多。
所以核心问题变成了:能不能在不动模型能力的前提下,把“生成”这个昂贵操作改成一次“选择”?
2. 把决策链路改造成“选择题”的核心思路
2.1 第一步:画出动作空间,枚举候选答案
改造的第一步,是把 agent 在某个环节的所有可能动作穷举出来。这步看起来简单,但很容易做粗。
我当时让同事先画了一张表:在哪一个流程节点上,模型最多有哪几种选择。拿工单分流举例:
| 候选编号 | 候选动作 | 触发条件 |
|---|---|---|
| A | 自动退款 | 用户明确要求退款,金额在权限内 |
| B | 补发物流 | 物流丢件、破损、发错 |
| C | 转人工客服 | 客户情绪激烈或涉及复杂纠纷 |
| D | 标记已完成 | 问题已解答,无需后续动作 |
枚举候选时要注意三个点:候选之间要互斥、覆盖要完整、描述要精炼。互斥性是避免模型在两个相似选项之间反复横跳;覆盖完整是为避免出现“以上都不是”的情况;描述精炼则是为了给模型减少不必要的上下文噪音。
如果确实存在“不确定”的情况,那就把“不确定”本身作为一个候选,而不是强行让模型选一个。否则错误率会显著上升。
2.2 用 logits 做“选择”,而不是用采样做“生成”
大模型内部实际上每输出一个 token 时,会计算一个概率分布,这就是 logits。做生成本质上是根据这个分布去采样。而做选择题,可以直接拿这个分布来用——我们只需要比对各候选 token 的分数,取最高的那个。
这里的关键点是:不需要让模型真的“说出”答案,只需要取模型在最后一个位置给出的 logits,找到候选项对应 token 的分数。这种操作在推理时只做一次前向计算,耗时就是单次 token 生成的时间。
这跟“限制采样”或“约束解码”还不一样。约束解码仍然要走完整生成路径,只是每一步限制了候选 token 范围。而 logits 选择是彻底跳过了多步生成,直接判定。
还有一个非常实用的细节:如果候选项是单 token(如 A、B、C、D 或者 1、2、3、4),logits 之间的比较最干净。如果候选项是一个词甚至一个短语,就需要更小心的设计,后面会在踩坑部分展开。
2.3 为什么能做到 10 倍提速
这里做一个简单的量化估算。假设一次决策在生成式模式下平均需要写 100 个 token,那么模型内部要跑 100 次前向推理。其中每一次前向推理的耗时差异不大(在同一 KV Cache 长度级别),所以一次决策的耗时大约相当于 100 个 token 的生成时间。
改造为选择题后,只需要跑 1 次前向推理,然后从 logits 里取最大值。如果候选是单 token,那这一步基本没有额外开销。理论上一次决策的耗时从“100 个 token 生成时间”降为“1 个 token 生成时间”,理想情况下是 100 倍差距。
但实际端到端跑不到这个数。原因在于 agent 管线里还有 prompt 预填充(prefill)、工具调用、结果解析等固定开销。此外,很多 agent 在选择题之后还要再生成一段最终回复或执行参数,这部分仍然是生成式的。所以整体端到端速度提升在 8 到 15 倍之间是比较合理的区间,10 倍是常态化表现。
如果原来的决策链是“先思考几十个 token,再区分情况”的那种结构,改造后光是在模型推理上就能省掉绝大部分时间。
3. 实操:从生成式 Agent 到选择式 Agent
3.1 改造前的原始链路
大多数生成式 agent 是这样一个循环:
- 把当前状态和观察结果拼进 prompt
- 模型生成一段思考和分析
- 从生成文本中解析出下一步动作
- 执行工具
- 回到第 1 步
我见过最简单的实现是这样写的:
while not task_finished: prompt = build_prompt(state, observation) response = model.generate(prompt, max_new_tokens=200) action = parse_action(response) # 从文本里抠出动作 observation = execute(action) state.update(observation)这种写法的稳定性完全取决于parse_action能不能从模型生成的自由文本里准确提取意图。一旦模型输出格式轻微变化,解析就会失败,然后整个 agent 就卡住了。
3.2 改造后的选择式链路
改造后的链路分成了两层:模型只负责决策,工具执行和后续生成由代码接管。
第一步,把所有候选动作组装到 prompt 里,作为模型的“选项列表”。第二步,模型对最后一个 token 位置输出 logits,代码直接取各候选项的分数。第三步,argmax 拿到选中的动作,然后执行。
下面是简化后的实现示例:
import torch from transformers import AutoModelForCausalLM, AutoTokenizer model_id = "your-small-model-path" tokenizer = AutoTokenizer.from_pretrained(model_id) model = AutoModelForCausalLM.from_pretrained(model_id) # 候选动作集合:按实际业务定义 candidates = { "A": "自动退款", "B": "补发物流", "C": "转人工客服", "D": "标记已完成", } def build_prompt(ticket_text: str) -> str: options_text = " ".join(f"{k}. {v}" for k, v in candidates.items()) return f"""你是一个工单分流助手。 工单内容:{ticket_text} 请从以下选项中选择最合适的处理方式,直接输出选项编号。 {options_text} 答案:""" def choose_action(ticket_text: str) -> str: prompt = build_prompt(ticket_text) inputs = tokenizer(prompt, return_tensors="pt") with torch.no_grad(): outputs = model(**inputs) # 取最后一个 token 位置的 logits last_logits = outputs.logits[:, -1, :] scores = {} for label in candidates: token_id = tokenizer.convert_tokens_to_ids(label) scores[label] = last_logits[0, token_id].item() return max(scores, key=scores.get)这段代码里没有采样、没有max_new_tokens、没有循环生成。模型只做一次前向,得到的是对每个候选编号的打分,拿最高分当作动作。
有一点需要注意:这里用的是“最后一个位置”的 logits。因为 prompt 以“答案:“结尾,模型的下一个 token 就是它认为的答案。我们取这个位置的分布,刚好是模型对下一个 token 的预测分布。
3.3 实际工程里不能只做“纯选择”
纯选择题在业务上是不够的。比如一个客服 agent,判断出“需要退款”之后,还得生成退款金额、退款原因等参数。这部分是开放式的,不可能枚举成选项。
所以我在实际的改造中,把 agent 拆成了“决策层 + 执行层”:
- 决策层:用选择题判断“下一步做什么”
- 执行层:根据动作类型,再用生成或规则模板来填充参数
举一个常见的组合:模型先选择题判断出“调用查询物流接口”,然后从接口返回的数据中提取物流状态。这里的提取本身可以继续用选择题——比如“物流状态是:正常/异常/已签收”——也可以直接走正则。
这个混合模式保留了“选择式”的速度优势,同时没有牺牲灵活性。动作类型的数量通常有限,参数生成则交给真正需要“生成”的场景。
3.4 模板措辞和候选设计的几个经验
同样是选择题,模板和候选措辞的质量对准确率影响非常大。我调过几个版本,总结了一些经验:
候选描述要尽量口语化、行为导向。比如“处理退款”和“给用户退款”,后者更接近实际操作,模型更容易理解。
候选之间要避免模糊地带。比如“转人工客服”和“升级处理”很容易让模型困惑——什么时候转人工?什么时候升级?这两个动作本质可能一样。如果业务上确实区分不开,那就合并成一个。
候选顺序不要固定不变。如果每次都把“退款”放在第一位,模型学到的可能不是“退款最合理”,而是“第一个选项概率最高”。这在训练数据里如果有位置偏差,会直接体现在线上表现上。
我建议在候选描述前面加上编号(A、B、C、D),并让模型只输出字母,取字母 token 的 logits。这样后续代码里判断逻辑最简单,也最不容易受到模型生成格式漂移的影响。
4. 实测效果与调优中的踩坑记录
4.1 一个可参考的数据对比
我在这套思路改造前后做过一个内部测试,任务就是售后工单分流,模型是同一个 7B 级别的小模型,在同样的 GPU 上跑的。数据大概是这样的:
| 指标 | 生成式推理 | 选择式推理 | 变化 |
|---|---|---|---|
| 单次决策平均耗时 | 2.8s | 0.31s | 快约 9 倍 |
| 平均每次决策消耗 token 数 | 187 | 13 | 降低约 93% |
| 工单分流准确率 | 87.2% | 92.6% | 提升约 5 个百分点 |
| 偶发解析失败率 | 3.8% | 0.2% | 显著降低 |
准确率提升的原因不复杂:模型不再需要承担“生成一段可被解析的文本”这个额外任务,只需要做一次判断。判断比生成可靠,这是模型的天然能力分布决定的。
这里要声明一下,这组数据是我自己项目里的参考值,不同场景一定有自己的偏移。但它反映出来的趋势是有代表性的——选择式在速度和稳定性上都会有明显改善。
4.2 候选 token 的 logits 尺度问题
这是我在初期踩过最深的一个坑。候选动作如果是“A”“B”“C”“D”这种单字母,每个 token 都是一样的长度,logits 之间可以直接比较。但一旦候选变成短语或句子,比如“需要退款”和“需要转人工”,它们的 token 长度可能不同,logits 的数值尺度也就不可比。
解决方式有两种。首选是把候选编号设计成单 token,编号和描述绑定,模型只用选择编号。其次,如果你实在需要用完整短语作为候选项,那可以取候选项所有 token logits 的平均值或首 token logits 作为近似,但这个近似方式必须统一,否则比较就没意义。
还有一个更激进但效果不错的方案:把多 token 候选拆成多个二分类判断题。每个候选单独问一次“这个工单是否应该退款?”,然后取“是”概率最高的一项。这个方法会多跑几次前向,但避免了 logits 不可比的问题。我用它处理过一些候选语义非常接近的场景。
4.3 位置偏置:模型会“偏爱”某些选项
做选择题最明显的毛病是位置偏置(position bias)。同一个候选项放在第一个位置和放在最后一个位置,模型给出的分数可能不一样。这在生成式解码里不太明显,因为生成不依赖选项的相对位置;但在 logits 直接比较时,位置偏置会被放大。
表现是这样的:模型总是倾向于选“A”或总是倾向选“D”。如果候选顺序一直固定,线上表现看着还行,可是一旦业务方在中间插入一个新选项,准确率会突然塌下去。
我试过的解法里,最有效的是“动态排序+多次投票”。也就是每次请求时随机打乱候选顺序,并让模型做两次判断(前后顺序不一样),最后取两次都支持的选项。这个方案会把决策耗时从一次前向变成两次,但仍然远快于生成式推理。
如果延迟预算特别紧张,不能承受两次前向,那就只能用“静态顺序+写死在 prompt 里的强指令”。效果差一些,但比完全不管位置偏置要好。
4.4 候选“语义太近”导致分数压不扁
另一个常见问题是两个候选在业务上不同,但在模型的理解里高度相似。这时候模型输出分数的差距会很小,甚至出现分数差不大于 0.1 的情况。一旦后面接了阈值判断,就可能来回跳。
我遇到过“退款”和“部分退款”这两个候选。在用户的投诉里,如果表达了“不满意,想退”,模型很难判断到底是全额退还是部分退。后来我们把“部分退款”改成了“退款但需要用户补充凭证”,把操作条件写进候选描述里,模型立刻就能区分开了。
这个经验的核心是:候选描述不要只写“动作名”,要把触发条件或执行边界带进去。就算描述长一点,只要模型能更准确地区分,这点 prompt 长度开销完全能接受。
4.5 决策结果不要直接信,要有兜底
即便模型选得再准,也一定有极小概率选错。我在工程线上加了一层规则兜底:如果模型给出的最高分低于某个阈值,或者第二高分与最高分差距极小,就默认走“人工介入”分支。这相当于把不确定性显式地交给流程去处理。
阈值怎么定?我一般查看日志里历史合法决策的分数分布,取最低可接受值。比如正常决策分数在 15 到 28 之间,那就把兜底阈值设置为 12 左右。低于阈值的不一定是错,但至少说明模型没有足够把握,这时候宁慢勿错。
4.6 其他容易被忽略的细节
还有几个零碎但实用的点:
- 需要确认 tokenizer 对候选编号的解码确实是单 token。比如中文模型对“A”通常没问题,但某些特殊字符可能被拆成多个 token。
- 如果用了 quantized 模型,logits 的数值稳定性会略差,但不会影响 argmax 的逻辑。只要候选分数差距明显,量化模型也能用得比较稳。
- prompt 里的“答案:”后面不要带空格,或者说,后续接的是一个独立 token 的起始位置。不同的 tokenizer 对空格的处理不同,测试时最好固定格式,避免因格式变动导致分数异常。
5. 这个方案适合哪些场景,哪些场景别硬套
5.1 适合:动作空间有限、高频决策的 Agent
最典型的就是工具调用和路由分配。比如把请求分给不同类型的处理器、判断调用哪个 API、决定是否满足某个安全条件。这些能力都可以预先把候选枚举出来,非常适合选择式。
第二个适合的场景是高频、低延迟的接口。哪怕是同一个大模型,生成式决策把延迟从 2.8 秒压到 0.3 秒,对用户的体感影响也是巨大的。如果你的产品里 agent 是直接面向用户的,这个改动几乎是无痛的性能优化。
第三个很适合的是端侧部署和资源受限环境。小模型在端侧做生成式推理,一方面是速度不够,另一方面是时延不稳定。选择式能让推理时间变得可控、可预测,对资源调度友好很多。
5.2 不适合:开放式创作和长链路复杂推理
如果任务是写文章、做头脑风暴、生成代码,那选择式显然不适用。这类任务的核心是模型必须“创造”出候选空间里不存在的内容,任何枚举都会限制它的能力。
还有一些任务表面是决策,但需要模型先做一个复杂推理才能得出答案,比如“这个需求要不要做,理由是什么”。在这种场景里,直接把结论变成选择题,会让模型跳过推理步骤,准确率反而不如生成式。
我的建议是:不要在整个 agent 里一刀切地全部改成选择式,而是找到 agent 中“决策点”进行局部替换。动作选择、分支判断用选择题;内容生成、摘要、参数构造用生成式。两者分工,才能兼顾速度和能力。
5.3 可以往这个方向继续扩展
一个很自然的扩展是“把复杂决策蒸馏成选择题序列”。也就是说,把一个需要多步推理的任务,拆成一组有顺序的选择题。模型每做一道选择题,就前进一步,直到最终输出。这实际上是一种把隐式思维链显式化、并把成本压缩到极致的办法。
另外一个扩展方向是:给选择题动作自动补充候选。当一个 agent 在运行中频繁遇到“不确定”分支,我们可以下线分析这些样本,把新出现的动作补进候选集合里。这样 agent 的动作空间会随着线上数据迭代而扩大,不会被困死在初始设计的候选列表里。
我个人在实际操作中的体会是:这个改造成本很低,但收益非常直接,非常适合作为 Agent 优化的一号优先事项。但也别指望所有思考都能被“选择题化”。判断和选择在本质上比生成容易,这是模型的天性,我们只是顺势而为。先把 agent 里那些“其实只有固定几条路”的分支改成选择题,让模型在真正需要发挥创造力的地方去做生成,这样的架构又稳又快。