news 2026/11/4 9:19:14

小参数智能体提速10倍:从生成式推理到选择题决策的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
小参数智能体提速10倍:从生成式推理到选择题决策的工程实践

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 processor:

import 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 秒
端到端耗时(P95)42 秒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 次,延迟还能再降。目前在小规模测试里效果不错,等跑通更多任务集再单独写一篇。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/3 13:27:44

Go Channel 源码深度剖析:锁、环形缓冲与等待队列

1. 从一次线上事故说起:我为什么非要啃 Channel 源码大概半年前,我维护的一个高并发推送服务突然出现诡异现象:上游业务方反馈消息延迟从平均 5ms 暴涨到 300ms,CPU 占用率倒是不高,但 goroutine 数量涨到了平时的十几…

作者头像 李华
网站建设 2026/10/18 16:04:04

artcraft:一种面向创作演化的模块化声明式范式

1. 项目概述:从“artcraft”这个词开始,我们到底在做什么?“artcraft”这个词乍看像一个合成词,拆开是 art(艺术)和 craft(工艺),合起来直译就是“艺术工艺”。但放在当下…

作者头像 李华
网站建设 2026/10/23 15:37:26

React 错误处理实战:从 Error Boundary 到四层兜底架构

1. 先从“白屏事故”说起如果你写过一段时间 React,大概率经历过这种场景:生产环境某个页面突然全白,用户刷了几次还是白,最后你登录后台一看,一条报错指向某个组件里undefined.xxx——渲染崩了,React 把整…

作者头像 李华
网站建设 2026/10/23 6:55:11

GeeLark 9月功能更新合集

AIGC・ 图生视频/文生视频新增 MiniMax H3 模型・ 支持查看成员 AIGC 使用情况&设置成员使用 AIGC 预算素材中心・ 支持批量修改标签・ 本地上传支持选择标签上传・ 支持卡片模式自动化・ 「 TikTok 发布视频 」支持循环发布素材标签下的视频・ 「 TikTok AI 养号 」新增高…

作者头像 李华
网站建设 2026/10/22 17:48:31

短剧小程序四端统一架构:播放器、分发与支付链路深度整合

简介:这是一套面向开发者与小程序创业者的一站式短剧小程序搭建实战教程,覆盖抖音、快手、百度、微信及H5多端适配,解决从零部署到上线运营的核心技术难点。资源共10个文件,含3个实操视频(含宝塔面板安装、服务端部署、…

作者头像 李华
网站建设 2026/10/22 12:28:02

Linux下C/C++编译环境搭建:从gcc安装到程序运行

1. 为什么在Linux里“编译”不是点一下就完事的事?很多人刚从Windows图形界面转到Linux终端,第一反应是:“我写了个hello.c,怎么双击打不开?”——这问题背后藏着一个根本性认知偏差:Linux不运行源代码&…

作者头像 李华