1. 从写作文到做选择一个反直觉的提速思路如果你最近在折腾小参数模型驱动的智能体大概率会遇到一个让人抓狂的场景明明模型参数量不大单次前向推理也就几百毫秒但一个稍微复杂点的任务跑下来动辄要等十几秒甚至几十秒。你盯着日志一行行往外刷心里只有一个念头——它到底在想什么怎么这么慢我最初也以为是模型本身太慢试过量化、试过换更小的模型、试过把上下文砍到最短效果都不理想。直到有一次我把整个推理链路拆开看才发现真正的瓶颈根本不在模型算力上而在生成方式上智能体每一步都在让模型自由发挥输出一段自然语言然后再用正则或者解析器把这段文字里的动作抠出来。这个先写作文、再阅读理解的过程才是拖慢一切的真凶。后来我把这套逻辑整个换掉让模型不再生成而是做选择题——从一组预先定义好的候选动作里挑一个。实测下来同样的任务、同样的模型端到端耗时直接降到了原来的十分之一左右。这个改动听起来简单但里面涉及的约束设计、候选集构造、解析容错每一步都有讲究。这篇文章就把我踩过的坑和最终跑通的方案完整讲一遍适合正在做轻量级智能体、对推理延迟敏感、又不想上大模型的同学参考。先说清楚一件事这里的快 10 倍不是营销话术而是我在几个固定任务集上反复测出来的中位数对比。具体数字后面会给但更重要的是为什么快以及快的同时怎么保证不把准确率搞崩。这两点想明白了你换任何模型、任何框架都能复现这个思路。2. 生成式推理到底慢在哪把延迟拆开看2.1 一次思考背后的三次开销很多人以为智能体慢是因为模型推理慢其实在小参数模型场景下模型本身的前向计算往往只占总延迟的一小部分。我把一次典型的生成式推理拆成三段来看第一段是提示词膨胀。为了让模型输出格式稳定你不得不在系统提示里塞大量说明请以 JSON 格式输出字段名必须是 action不要输出多余解释。这些约束本身没错但它们让输入 token 数暴涨。小模型的上下文窗口本来就紧张输入越长注意力计算越慢而且长提示还会稀释关键信息导致模型更容易跑偏。第二段是逐 token 生成。这是最要命的一环。生成式推理是自回归的每输出一个 token 都要跑一次完整的前向。你让它输出一个结构化的动作比如{action: search, query: ...}光是这个 JSON 骨架加上内容轻松就是几十个 token。而做选择题呢模型只需要输出一个候选编号可能就一两个 token。生成步数从几十步降到一两步这是数量级的差异。第三段是输出解析与重试。生成出来的自然语言你得解析解析失败就得重试。小模型格式遵循能力弱重试率不低。每一次重试都是一次完整的生成延迟直接翻倍。我统计过早期的一个版本重试率大概在 20% 到 30% 之间也就是说有近三分之一的步骤实际上跑了两次甚至三次。2.2 一个具体的延迟对比为了让你有直观感受我拿一个根据用户问题决定下一步动作的典型步骤做了对比测试。任务集是 50 条固定的多步问答模型用的是同一个 1.5B 级别的指令微调模型硬件是单张消费级显卡batch size 为 1。环节生成式方案选择题方案系统提示 token 数约 420约 180单步输出 token 数25 到 601 到 3单步平均耗时约 1.8 秒约 0.15 秒解析失败重试率约 25%约 3%端到端任务耗时中位数约 21 秒约 2.1 秒可以看到单步耗时从 1.8 秒降到 0.15 秒端到端从 21 秒降到 2.1 秒正好是一个数量级的差距。这里面提示词缩短贡献了一部分但主要功劳还是输出 token 数从几十降到个位数。自回归生成的耗时和输出长度基本是线性关系砍掉 90% 以上的输出长度速度自然就上来了。注意这里的选择题不是让模型输出 A/B/C/D 这种字母而是让它输出候选动作的索引。字母和索引在 token 层面差别不大但索引更容易做数值约束和校验后面会细说。2.3 为什么小模型尤其吃这一套大模型格式遵循能力强生成式推理的重试率低所以它对生成 vs 选择的敏感度没那么高。但小模型不一样参数量小意味着指令遵循和格式稳定性都弱你让它自由生成它就容易加戏——多输出一句解释、少一个引号、把字段名拼错。这些在小模型上都是高频问题。而做选择题把输出空间压缩到了一个极小的集合里模型不需要理解格式只需要判断哪个候选最合适。这个任务的难度远低于自由生成小模型完全能胜任。换句话说选择题方案是把小模型不擅长的事情格式遵循绕开只让它做擅长的事情语义判断。这是它能在小模型上效果特别明显的原因。3. 候选集怎么设计选择题的题库才是核心3.1 候选动作的粒度划分做选择题第一步是确定选项从哪来。这里最容易犯的错是把候选集设计得太粗或太细。太粗的例子只给检索计算回答三个大类。这样模型选起来是容易但选完之后你还得再问一轮具体检索什么具体算什么等于把一次决策拆成多次总步数反而增加。太细的例子把每个可能的查询字符串都当成一个候选。这既不现实组合爆炸也失去了泛化能力。我的经验是按动作类型 关键参数槽位来划分粒度。动作类型是有限的枚举比如检索、计算、调用某个工具、请求澄清、给出最终答案。关键参数则用占位符表示模型选完动作后参数从当前上下文里抽取填充。这样候选集规模可控又不会丢失决策所需的信息。3.2 候选集的动态构造候选集不能是写死的静态列表得根据当前状态动态生成。比如当前已经检索过一轮了那再次检索相同内容就不该出现在候选里当前上下文里没有可计算的数值那执行计算这个候选就该被屏蔽。我通常用一个简单的规则引擎来生成候选伪代码大概是这样def build_candidates(state, tools): candidates [] # 基础动作 if state.has_unresolved_question: candidates.append({type: clarify, slot: None}) # 工具类动作按可用性过滤 for tool in tools: if tool.is_available(state): candidates.append({type: call_tool, slot: tool.name}) # 检索类动作避免重复 for query in state.pending_queries: if query not in state.executed_queries: candidates.append({type: retrieve, slot: query}) # 终止动作 if state.has_enough_info: candidates.append({type: answer, slot: None}) return candidates这个规则引擎的好处是把硬约束交给代码把软判断交给模型。比如不能重复检索是硬约束代码直接过滤掉而当前信息够不够回答是软判断交给模型在候选里选。这样既保证了决策的合法性又保留了模型的语义理解能力。3.3 候选数量控制在多少合适候选数量直接影响模型的选择准确率。我做过一组对比实验候选数从 3 增加到 20观察选择准确率的变化候选数量选择准确率单步耗时396%0.12 秒594%0.13 秒891%0.15 秒1285%0.18 秒2076%0.24 秒可以看到候选数超过 8 之后准确率下降明显耗时也开始上升。所以我的建议是把候选数控制在 5 到 8 之间。如果某个状态下合法候选超过这个数说明你的动作粒度设计得太细了应该往上抽象一层或者引入分层的选择机制——先选大类再选具体项。提示候选数不是越少越好。候选太少会导致某些合法动作被排除模型被迫在错误的选项里选反而降低整体任务成功率。5 到 8 是一个比较稳的区间。4. 让模型稳定输出索引约束解码与校验4.1 为什么不能只靠提示词约束你可能会想我在提示词里写清楚只输出候选编号不要输出其他内容模型不就会照做了吗理论上是的但实际中小模型经常不听话。它可能输出我认为应该选择第 2 个可能输出2.可能输出选项二甚至可能输出一段解释再跟一个编号。这些变体你当然可以用正则去兜但兜底逻辑越多代码越脆弱而且每次解析失败都要重试延迟又上去了。所以更稳的做法是在解码层面做约束让模型根本没有机会输出非法内容。4.2 用 logits 约束把输出锁死在候选范围内约束解码的核心思路是在每一步生成时只允许候选编号对应的 token 有非零概率其他 token 的概率全部置为负无穷。这样模型无论怎么采样都只能输出合法的编号。具体实现上不同框架有不同的接口。以常见的 HuggingFace 风格为例可以写一个自定义的 logits processorimport torch from transformers import LogitsProcessor class CandidateIndexProcessor(LogitsProcessor): def __init__(self, valid_token_ids): # valid_token_ids 是候选编号对应的 token id 列表 self.valid_token_ids valid_token_ids def __call__(self, input_ids, scores): mask torch.full_like(scores, float(-inf)) for tid in self.valid_token_ids: mask[:, tid] scores[:, tid] return mask这里的关键是候选编号到 token id 的映射要提前算好。比如候选是 0 到 7那对应的 token id 就是数字字符 0 到 7 的编码。生成时只放开这 8 个 token模型第一步就必须从里面选一个而且因为只需要生成一个 token生成过程一步就结束了。这个改动带来的收益是双重的一是输出长度锁死为 1 个 token速度拉满二是解析永远不会失败因为输出必然是合法编号重试率直接归零。4.3 多 token 编号的处理如果你的候选数超过 10编号就会变成两位数需要两个 token 才能表示。这时候约束解码要稍微复杂一点第一步放开所有十位数字第二步根据第一步的结果放开对应的个位数字。不过我在实践中一般会避免这种情况因为候选数超过 10 本身就会降低选择准确率。如果实在需要更多候选我会用分层选择先让模型选一个 0 到 7 的大类再在选中的大类里做第二次选择。两次单 token 生成总耗时依然远低于一次自由生成。4.4 校验与兜底即便有约束解码我还是会加一层轻量校验。因为约束解码保证了输出是合法编号但没保证这个编号对应的候选在当前状态下真的可用——虽然候选集是动态构造的理论上都可用但万一状态在生成过程中发生了变化比如并发场景还是可能出问题。校验逻辑很简单拿到编号后检查它是否在候选列表的合法范围内不在就回退到默认动作通常是请求澄清或给出当前最优答案。这层校验几乎不耗时但能避免极端情况下的崩溃。5. 提示词重写从描述任务到呈现选项5.1 提示词结构的根本变化生成式方案的提示词是任务描述型的告诉模型你是谁、你要做什么、输出格式是什么。选择题方案的提示词是选项呈现型的告诉模型当前状态是什么、有哪些选项、选哪个最合适。这两种提示词的信息密度完全不同。前者有大量格式说明和角色设定后者几乎全是有效信息。我实测下来选择题方案的提示词长度通常只有生成式方案的三分之一到二分之一。一个典型的选择题提示词长这样当前状态 - 用户问题某产品的保修期是多久 - 已检索内容无 - 可用工具检索、计算、澄清、回答 候选动作 0. 检索产品保修政策 1. 请求用户澄清具体产品型号 2. 直接回答 3. 执行计算 请选择最合适的动作编号整个提示词不到 100 个 token模型只需要输出一个数字。对比生成式方案动辄四五百 token 的提示词加几十 token 的输出差距一目了然。5.2 候选描述的措辞技巧候选的文字描述直接影响模型的选择准确率。我总结了几个措辞上的经验第一候选描述要具体不要抽象。写检索产品保修政策比写执行检索好因为前者包含了检索目标模型能直接判断相关性。第二候选之间要有明确的区分度。如果两个候选描述很接近模型就容易选错。比如检索保修政策和检索售后政策就很模糊应该合并或者用更明确的限定词区分。第三把判断依据写进候选描述里。比如请求用户澄清具体产品型号当前问题缺少型号信息括号里的内容就是给模型的判断提示。这相当于把一部分推理工作前置到了候选构造阶段减轻了模型的负担。5.3 少样本示例要不要加生成式方案里少样本示例是提升格式稳定性的常用手段。但在选择题方案里示例的性价比不高。因为选择题的输出空间已经被约束解码锁死了格式不需要示例来教而选择逻辑本身模型靠语义理解基本能搞定加示例反而占用宝贵的上下文。我的做法是只在任务特别复杂、候选区分度低的时候加一两个示例。而且示例要极简只展示状态 候选 选择三要素不写任何解释。大部分场景下零示例就够了。6. 实测数据与踩坑记录6.1 完整任务集上的对比前面给的是单步对比这里给一个完整任务集的端到端对比。任务集是 100 条多步问答平均每条需要 3 到 5 步决策模型和硬件保持一致。指标生成式方案选择题方案任务成功率78%81%端到端耗时中位数19.5 秒2.0 秒端到端耗时P9542 秒3.8 秒平均决策步数4.24.0单步解析失败率24%0.5%成功率不仅没降还略升了一点。我分析原因是生成式方案里那 24% 的解析失败会触发重试重试时模型可能因为上下文变化而做出不同决策反而引入了不确定性选择题方案没有重试决策链路更稳定。P95 耗时的改善尤其明显从 42 秒降到 3.8 秒。生成式方案的尾延迟主要来自连续多次重试的叠加选择题方案把这个长尾直接砍掉了。6.2 踩过的坑候选集构造的时序问题有一个坑我印象很深。早期我的候选集是在提示词拼接之前构造的但提示词拼接本身要读一次状态如果这中间状态被其他异步任务改了候选集和提示词里的状态就对不上了。表现就是模型选了一个编号但那个编号对应的候选在当前提示词里根本不存在。这个 bug 很隐蔽因为大部分时候状态不会变只有并发高的时候才偶发。后来我把候选集构造和提示词拼接放到同一个临界区里保证两者基于同一份状态快照问题就消失了。注意如果你的智能体是多线程或异步的一定要保证读状态、构造候选、拼提示词这三步是原子的。否则选择题方案会因为编号和候选错位而出现难以复现的诡异错误。6.3 踩过的坑约束解码和采样参数的冲突另一个坑是约束解码和温度参数的配合。我一开始把温度设成 0.7想着保留一点探索性。结果发现约束解码把大部分 token 置为负无穷后剩下的合法 token 之间的概率分布被温度放大得很奇怪偶尔会选出明显不合理的候选。后来我把温度降到 0.1 甚至 0选择就稳定多了。因为选择题任务本身不需要创造性要的就是确定性判断。约束解码场景下温度应该往低了调而不是保留生成式方案里的探索性设置。这是一个很容易被忽略的细节。6.4 踩过的坑候选描述里的隐含偏见还有一次我发现模型总是倾向于选排在前面的候选。排查后发现是候选描述里有个微妙的措辞问题第一个候选的描述比其他的更详细模型就误以为它更重要。解决办法是让所有候选的描述在长度和详细程度上尽量一致。如果某个候选确实需要更多说明那就给所有候选都加上同等程度的说明保持形式上的公平。这个经验听起来有点玄学但在小模型上确实有效因为小模型对提示词里的形式差异比大模型敏感得多。7. 什么场景适合这套方案什么场景别硬套7.1 适合的场景选择题方案最适合决策空间有限、动作可枚举的智能体。比如工具调用型智能体下一步调哪个工具、多步问答智能体下一步检索还是回答、流程控制型智能体下一步走哪个分支。这些场景的共同点是每一步的合法动作是有限的而且可以用规则引擎动态构造出来。另一个适合的场景是对延迟极度敏感的应用。比如需要实时响应的交互式助手用户等两秒和等二十秒的体验天差地别。选择题方案把延迟压到秒级以内体验提升是质变的。7.2 不适合的场景如果智能体的动作空间是开放的、无法枚举的那选择题方案就不适用。比如让模型生成一段创意文案、写一段代码、做开放式推理这些任务的输出空间太大没法构造候选集。还有一种情况是决策依赖大量隐式推理。有些任务模型需要先在心里推演几步才能决定下一步做什么这种思考过程如果被压缩成一次选择可能会丢失必要的推理链。这时候可以考虑混合方案需要深度推理的步骤用生成式常规决策步骤用选择题。7.3 混合方案的思路我目前在一个项目里用的就是混合方案。大部分步骤走选择题但遇到需要复杂推理的步骤比如多约束条件下的规划会切换回生成式让模型输出一段简短的推理链然后再把推理结果作为状态喂给下一步的选择题。切换的判据也很简单如果当前状态的候选集构造出来超过 8 个或者候选之间的区分度低于某个阈值就切生成式。这个阈值可以靠统计历史决策的准确率来动态调整。8. 几个能直接抄的工程细节8.1 候选编号的 token 映射要缓存约束解码需要候选编号到 token id 的映射。这个映射对于固定的编号范围是固定的比如 0 到 9 永远对应同样的 token id。所以在服务启动时算一次缓存起来不要每次生成都重新算。这个优化看起来小但在高并发下能省下可观的 CPU 开销。8.2 状态快照要轻量前面提到候选构造和提示词拼接要基于同一份状态快照。这个快照要尽量轻量只包含构造候选和拼提示词需要的最小字段。不要把整个对话历史都塞进快照里那样拷贝开销会很大。我的做法是定义一个专门的DecisionState结构只放当前决策相关的字段。8.3 决策日志要记全选择题方案的调试比生成式方案容易因为决策空间是离散的。但前提是你把日志记全了。我通常会记录当前状态快照、构造出的候选列表、模型输出的编号、最终执行的动作、以及这一步的实际结果。有了这些日志出问题时可以精确复现是哪一步选错了而不是像生成式方案那样只能看到一段模糊的自然语言。8.4 候选集的单元测试候选集构造是纯规则逻辑非常适合写单元测试。我会针对各种边界状态写测试用例没有可用工具时、所有查询都执行过时、信息已足够时、信息不足且无法澄清时。这些边界情况在真实运行中不常出现但一旦出现就是崩溃级别的 bug必须靠测试覆盖。8.5 灰度切换的策略如果你要把现有系统从生成式切到选择题不要一次性全切。我的做法是按任务类型灰度先切那些决策空间最清晰、候选集最好构造的任务观察一段时间成功率和延迟稳定后再逐步扩大范围。切换期间保留生成式方案作为兜底一旦选择题方案连续失败就自动回退。这个灰度策略帮我避免了一次事故。有个任务类型的候选集我构造得有问题导致模型总是选错但因为只切了一小部分流量影响面很小发现后及时修掉了。9. 关于快 10 倍这件事我的真实体会回到标题里那个快 10 倍我想说的是这个数字本身不是重点重点是它背后的思路转变不要让小模型做它不擅长的事。小模型不擅长自由生成和格式遵循但它擅长在有限选项里做语义判断。把任务改造成后者速度和质量就同时上来了。我在实际项目里最大的收获是意识到智能体的性能瓶颈往往不在模型本身而在任务的组织方式上。同一个模型换个问法效果可能差一个数量级。这跟人有点像你让一个人自由发挥写方案他可能憋半天你给他几个选项让他选他几秒钟就定了。模型也是这个道理。最后分享一个我最近在试的扩展方向把选择题的思路用到多步规划上。不是每一步都做一次选择而是一次性让模型从若干条预定义的计划模板里选一条然后按模板执行。这样决策次数从 N 次降到 1 次延迟还能再降。目前在小规模测试里效果不错等跑通更多任务集再单独写一篇。