小模型Agent提速近10倍:从生成式推理到选择式决策的工程实践 做 Agent 开发的同学应该都有过类似体验本地跑一个小参数量的模型7B、13B 这一档让它承担一个循环决策的角色。模型每次思考都要先生成一大段“推理过程”再从中抽取出动作。逻辑上没错但线上用户等不住。我一个同事有个思路挺直接的——“你让它自己想下一步不如直接把下一步列出来让它选。”就这么一个改动整个 agent 的决策响应时间从 2.8 秒压到了 0.3 秒左右几乎是一个数量级的差距。这篇文章就把这个思路拆开讲清楚为什么小模型做“生成式推理”会慢怎么把 agent 的决策链路改造成“选择题”以及改造过程中容易踩的坑。内容更适合两类人看一类是在做本地化或私有化部署、对模型推理延迟敏感的开发者另一类是正在用中小模型搭 Agent、但觉得“速度就是上不去”的同学。1. 为什么小模型“自由发挥”时又慢又飘1.1 生成式推理的固定成本在“逐字”大多数开源小模型都是自回归结构输出是逐 token 生成的。也就是说模型每生成一个“字”都要跑一次完整的前向计算。假设一次 agent 决策需要模型生成 150 个 token内部就要跑 150 次前向。注意这里的输入还在不断变长——每生成一个 tokenKV 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、4logits 之间的比较最干净。如果候选项是一个词甚至一个短语就需要更小心的设计后面会在踩坑部分展开。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 到选择式 Agent3.1 改造前的原始链路大多数生成式 agent 是这样一个循环把当前状态和观察结果拼进 prompt模型生成一段思考和分析从生成文本中解析出下一步动作执行工具回到第 1 步我见过最简单的实现是这样写的while not task_finished: prompt build_prompt(state, observation) response model.generate(prompt, max_new_tokens200) 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_tensorspt) 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, keyscores.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.8s0.31s快约 9 倍平均每次决策消耗 token 数18713降低约 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 里那些“其实只有固定几条路”的分支改成选择题让模型在真正需要发挥创造力的地方去做生成这样的架构又稳又快。