☰
把Agent决策从自由生成改成做选择题:延迟降低近10倍的改造实录
2026/10/13 8:36:22 网站建设 项目流程

标题里这句话,是我一个多月前改完某轻量级 Agent 之后在群里说的原话。当时那个小 Agent 的主要任务是做意图路由和工具选择——用户进来一句话,它得判断该调哪个 API、该走哪条业务流。一开始它表现并不差,准确率能看,就是慢,慢得用户都去倒了杯水回来它还没回话。我一度以为是模型太小、算力不够,后来把推理的"输出方式"从自由生成改成"做选择题",端到端延迟直接掉了近一个数量级,而且响应稳定了很多。这篇就把整个改造思路、踩过的坑和实测数据摊开讲清楚。

这个方案适合谁?如果你也在做轻量级 Agent、垂直领域的对话分发、工具调用的路由层,或者你手上有个模型推理延迟压不下去的小服务,这篇应该能帮你省掉不少弯路。核心就一句话:很多看似需要"思考"的决策,本质是分类问题,而分类问题根本不需要让模型写一大段话。

1. 生成式推理的账本:10倍差距到底浪费在哪

1.1 自回归解码的一次次“许愿”

先别急着聊优化,得算清楚原来那套为什么慢。所有现代对话模型都是自回归解码:它每生成一个 token,都要拿已经生成的整段内容去跑一次前向计算,然后从词表里挑一个概率最高的 token(或者按温度采样一个)。你可以把它理解成一个人在玩"接龙"——每说一个字,都得回忆一遍前面所有字,才能决定下一个字说什么。

这带来的直接后果是:输出越长,计算量线性增长,而且每一步都不能并行(至少原生解码是这样的)。一个模型每秒能吞吐多少 token,基本上决定了你的延迟天花板。我当时用的那个轻量模型,单机部署,解码速度大概每秒三四十个 token,听上去不算慢,但架不住它每次都要输出一大段"分析过程"。

我抓过它一次实际请求的返回,光是"思考"部分就写了 200 多个 token:先复述了一遍用户问题,再说"用户在询问订单状态,因此我应该查询订单接口",最后才给一个 JSON 格式的动作。整个链路下来,端到端两三秒是常态,高峰期能到四五秒。用户那头的体感就是:这机器人是不是卡死了。

1.2 真正浪费的不是 tokens,而是“自由”

有人可能会说:那我把 system prompt 改成"不要输出多余内容,直接给动作"不就行了吗?我试过,有效果,但是很有限。原因有二。一是模型在自由生成模式下,即便指令再明确,它也会时不时把推理过程带出来,尤其是面对没见过的表述时,话痨属性根本压不住。二是就算它严格只输出一个动作词,序列长度从 200 降到 20,延迟依然要几百毫秒——因为每个 token 都得过一遍完整的前向。

深层的问题在于"自由"本身。生成模式每一步都在全词表里挑 token,词表通常是几万甚至十几万大小。模型需要在这么大的空间里"自由发挥",而我们的业务场景里,它明明只需要从 8 个固定动作里选一个。这就像一个学生参加考试,明明全是选择题,你却让它每道题都写一篇小作文来论证答案。时间花在哪了?全花在"组织语言"上了,而不是在"思考选哪个"上。

1.3 10倍是怎么算出来的:延迟加减法

我这边实测过一组数,改造前和改造后的对比,先把账算明白。

环节改造前(自由生成)改造后(选择题)
首 token 延迟200~400ms200~400ms(不变)
输出长度150~300 tokens1 token(或几个候选共 1 次打分)
解码耗时3~8 秒20~80ms
端到端 P50约 2.7s约 0.31s
端到端 P95约 5.2s约 0.42s

首 token 延迟没有变化,因为这部分取决于输入长度和模型前向计算,跟输出方式无关。真正的差距全在"解码长度"上:从一两百个 token 压到 1 个 token,时间自然就省下来了。P95 改善尤其明显——自由生成时偶尔会"话痨爆发"写出 400 多个 token,P95 直接被拖到 5 秒开外;选择题模式下输出长度被锁死,怎么飘都飘不到哪去。

所以 10 倍不是玄学,就是延迟加减法:把生成从"写作文"变成"打勾",省掉的不是思考时间,而是把思考结果"翻译成人话"的时间。

2. 从写作文到打勾:Small Agent 的决策机制改造逻辑

2.1 决策的本质是分类,不是作文

想通一个问题,整个改造方向就清晰了:Agent 在路由、工具选择、意图识别这类场景里,核心任务是把输入映射到一个离散动作集合。这个过程本质是分类,跟"写一段自然语言分析"没有关系。

我打个比方。你去银行柜台办业务,柜员问你"办什么",你说"我要查一下卡里余额"。柜员脑子里的处理流程是:这句话匹配到"查询余额"这个业务 → 引导你去自助机 → 结束。他不会先给你写一篇"根据您的需求,我判断您属于查询余额类别,理由是……"的作文再办事。

模型也一样。让它把内心的分类结果直接暴露出来,而不是先包装成一段话,速度和稳定性都会上一个台阶。你可能会担心:那模型的"推理过程"去哪了?答案是:在参数里。一个训练充分的模型,面对常见意图时,"该选哪个"这个判断在前向计算时就已经有了倾向,根本不需要靠输出文字来"思考"。

2.2 选择题的三种实现姿势:硬限流、logprob 判分、编号输出

把"做选择题"落到工程上,有三种常见姿势,我按推荐程度排个序。

第一种是硬限流,也叫受限解码。如果你用的模型服务支持 logit bias 或者自定义解码策略,可以直接把词表限制在候选答案对应的 token 集合上,让模型只能在选项里挑。这种方式最干净,输出天然合法,不会出现"模型选了个不在选项里的东西"的尴尬。缺点是不少服务不支持,或者说配置起来比较麻烦。

第二种是 logprob 判分,也是我最终采用的方案。具体做法:把每个候选选项转成一个固定的"答案 token 序列",然后让模型服务返回这些候选序列的打分(logprob),取分数最高的那个作为决策结果。这个过程不需要模型真正"写"出答案,而是让它同时评估所有选项"哪个更可能被接在后面"。

第三种是编号输出,让模型输出选项的编号(比如"3"),然后程序里做映射。实现最简单,兼容性最好,但有一个隐患:模型偶尔会输出"3。"或者"选项3"这种带格式的内容,解析时得做清洗,而且相比前两种多了解析和容错的成本。这个方案适合当作降级通道,不建议当主通道。

2.3 为什么“做选择题”比“少写几个字”快更多

这里有个容易误解的地方,我得说清楚:选择题模式省时间的原理,不只是"输出短",而是"解码步骤变少"。

自由生成哪怕你让它只输出一个词,它也是一步一步解码的:先预测第一个 token,再预测第二个(如果有的话)。而 logprob 判分方案是:一次前向计算,同时拿到所有候选序列的分数,没有"逐 token 拼接"的过程。相当于阅卷老师直接看每个学生的最终答案,而不是等学生把答案一个字一个字念出来。

所以你会看到一个反直觉的现象:选项有 8 个还是 16 个,对延迟几乎没影响。因为打分是并行的,候选数量增加带来的只是排序那点开销。真正影响延迟的还是输入长度和模型大小。这也是为什么这招在小模型上效果尤其显著——小模型解码速度本来就不快,把解码步数压到 1,等于直接砍掉了最大的延迟来源。

3. 实操改造链路:选项设计到降级策略

3.1 出题比答题更重要:选项互相排斥、可判定

把决策变成一个选择题,第一步不是写代码,而是"出题"。出题出得烂,模型再聪明也没用。我总结了一套选项设计的五条原则,踩过不少坑之后沉淀下来的。

  • 互斥性:任意两个选项不能有语义重叠。比如"查询库存"和"查询商品信息"就很危险,模型可能觉得两个都对,分数被摊薄。
  • 穷尽性:所有常见输入都要有落点。你预判不到的输入怎么办?那就得有"以上都不是"或者"转人工"这种兜底选项。
  • 可区分性:选项描述要具体到"模型能一眼看出差别",最好每个选项绑定一个明确的执行动作。比如"查库存"后面跟的是库存 API,description 里写清楚触发条件和输出字段。
  • 数量克制:选项数量建议控制在 16 个以内。超过这个数,模型对相似选项的区分度会明显下降,准确率跟着崩。
  • 短描述优先:选项文本越短,模型打分越稳定。长描述会导致模型抓不住重点,而且 logprob 的长度归一化问题会更难处理。

我自己处理过一个反面教材:某个场景里"改地址"和"改收货信息"两个选项并存,模型经常把改地址的请求判到"改收货信息"上,准确率掉了 5 个点。后来把两个选项合并成"修改订单信息",准确率立刻回来了。原则就一条:在你把选择题交给模型之前,先问自己一句——这道题,让一个刚入职的实习生来做,会不会有歧义?如果有,改题目,不要指望模型替你消歧。

3.2 用 logprob 还是第一个 token?评分要小心

确定了选项之后,接着要解决"怎么打分"的问题。这里坑特别多,我一个个说。

首先是序列 logprob 和首 token logprob 的区别。有些模型服务只返回每个位置 token 的概率,不直接给整句序列的分数。你要是拿第一个 token 的 logprob 当选项分数,遇到"查余额"和"查订单"这种首字相同的选项就废了——它们第一个 token 可能都是"查",分数几乎一样,区分不出来。稳妥的做法是:给每个选项定义一个短小的"标准答案序列"(比如"查余额"),然后用整个序列的 logprob 求和(或平均)来排序。

其次是长度归一化。两个选项长度不一样,比如"查余额"(3 个 token)和"转人工"(3 个 token)还好,但遇到"查询最新促销活动"(8 个 token)和"退差价"(3 个 token)这种,长序列的 logprob 天然更小(因为每一步概率都小于 1,连乘起来越来越小)。所以要么统一选项长度,要么对 logprob 做长度归一化。我偷懒的做法是尽量把所有选项描述写成差不多的长度。

再一个是温度和采样参数对打分的影响。打分阶段我有意把温度调低(接近 0),让输出更确定。如果你的服务不支持按请求设置采样参数,那就保持默认并接受一点随机性,但要在下游加一层阈值保护(下面讲)。

3.3 阈值、兜底和把“不会做”也当成选项

选择题模式最大的隐患是:模型会硬选。就算它根本没看懂用户说什么,只要候选列表在那儿,它也会给你挑一个分数最高的。这是所有"分类式"方案的共性问题,解决办法是加阈值和兜底。

我在工程上做了两层保护。第一层,每个选项都是"带条件的动作",条件不满足时模型不该选它——但这依赖模型自觉,不保险。第二层才是关键:显式地加一个"无法判断/转人工"选项,并且要求模型分数达到一定水平才允许走自动流程,否则一律转人工。分数阈值怎么定?我建议先跑一两周无阈值模式,把所有请求的分值分布存下来,然后取 top1 概率的分位数来定。比如你希望最多 5% 的请求落到"拿不准"区域,就看看这 5% 分位的概率值是多少,把它设成阈值。

阈值本身的写法也有讲究。我见过有人用"top1 绝对概率大于 0.5",这个对二选一还行,但对 8 个选项的场景太苛刻。更合理的做法是看 top1 和 top2 的差距:如果第一名只比第二名高一点点,说明模型也在犹豫,这时候转人工比硬猜靠谱。我在生产里是两者结合:top1 概率低于 0.35 或者 top1 与 top2 差值低于 0.1,就触发兜底。

3.4 改造代码骨架:一条从上到下的管线

最后给个代码骨架,算是把前面几节串起来。我用的是 Python 风格的伪代码,方便你迁移到自己的工程里。

# 选项定义:description 是给模型看的,action 是给业务用的,answer_tokens 是评分用的标准序列 OPTIONS = [ {"name": "lookup_order", "desc": "查询订单状态和物流信息", "answer_tokens": [1132, 4587]}, {"name": "check_stock", "desc": "查询商品库存情况", "answer_tokens": [1204, 3789]}, {"name": "calc_shipping", "desc": "计算商品运费", "answer_tokens": [1567, 3345]}, {"name": "human_handoff", "desc": "无法判断,转人工处理", "answer_tokens": [2089, 6012]}, ] def select_action(user_input: str) -> str: # 1. 请求模型服务,对每个候选 answer_tokens 打分 scores = model.score_candidates( prompt=build_prompt(user_input, OPTIONS), candidates=[opt["answer_tokens"] for opt in OPTIONS], temperature=0.1, ) # 2. 长度归一化后排序 normalized = [] for opt, score in zip(OPTIONS, scores): norm_score = score / len(opt["answer_tokens"]) normalized.append((opt, norm_score)) normalized.sort(key=lambda x: x[1], reverse=True) top1, sc1 = normalized[0] top2, sc2 = normalized[1] # 3. 阈值保护:犹豫了就转人工 if sc1 < 0.35 or (sc1 - sc2) < 0.10: return "human_handoff" return top1["name"]

这段代码的核心并不在具体语法,而在三个决定:候选怎么定义、分数怎么归一化、阈值怎么卡。你把这三个决定做对了,适配自己的模型服务和业务场景就只是改配置的问题。

4. 实测数据:从 2.7s 到 0.31s 的每一步都花在哪

4.1 一个典型的场景:让它自己选该调哪个接口

为了不让数据显得太抽象,我说一下具体的测试场景。这是一个订单客服场景的轻量 Agent,输入是用户的一句话,输出是一个动作。动作候选有五个:查订单、改地址、算运费、退货申请、转人工。

改造前,模型的 system prompt 写的是:"请判断用户意图并输出 JSON,格式为 {'action': 'xxx'},不要输出其他内容。"看着挺严格吧?实际跑起来,模型还是会絮叨。有一回用户说"我地址写错了帮我改一下",它输出的 JSON 前面还垫了一句"好的,我来帮您处理地址修改问题"。就这一句废话,多了十来个 token,每多一个 token 都是在烧用户的耐心。

改造后,我把同一个请求送进 logprob 打分接口,五个候选选项同时计算分数。结果非常干净:改地址这个选项的归一化分数明显领先第二名一大截,动作直接落到"update_address"。整条链路从用户输入到拿到动作,耗时 300ms 上下。注意,这里还没算下游真正执行 API 的时间,但已经把 Agent 自身的"思考"成本压缩到了极限。

4.2 延迟细项对照表:钱花在哪了,一目了然

我把一次典型请求的耗时拆开来看,这样你就能明白 10 倍从哪来的。

阶段改造前耗时改造后耗时备注
网络传输与排队80ms80ms不变
输入 prefill180ms180ms由输入长度决定
解码首 token120ms120ms不变
后续解码3300ms0ms这是大头
解析与后处理20ms10msJSON 解析变成数字比较
合计约 3.7s约 390ms其中含部分固定开销

可以看到,prefill 和首 token 的耗时一点没省——这部分由模型大小和输入长度决定,跟输出方式无关。我们省的纯粹是"后续解码"那段。在没有模型服务日志之前,我一度以为延迟都花在"模型思考"上了,拆完才发现,所谓的思考在计算上只占那么一点,绝大多数时间都耗在把思考结果一个字一个字念出来。

4.3 稳定性:P95、失败率和 token 账单一起看

延迟中位数降低是意料之中,更让我惊喜的是 P95 的改善。自由生成模式下,模型偶尔会抽风写出一大段解释,直接把那次请求拖到五六秒。选择题模式把输出长度上限锁死,P95 几乎贴着 P50,用户体感非常稳定。这不光是体验问题,还直接影响下游超时设置。以前超时阈值得留到 10 秒才能保证不被误杀,现在设 1 秒都绰绰有余,下游服务的压力也小了很多。

token 账单的变化更是直接。改造前一次请求平均输出 180 个 token,高峰期能到 300 多;改造后每请求只消耗 1 个 token 的生成量(打分本身可能算在输入侧,但输出侧基本可以忽略)。按一天十万次请求算,输出 token 成本直接省掉了 99%。这对于自部署的模型来说就是算力释放,对于按量付费的 API 来说就是账单上的真金白银。

准确率方面,我在一万条带标注的历史数据上做了回测:改造前意图分类准确率 91.2%,改造后 93.6%。提升了,原因我猜是选择题的选项描述比自由生成的 prompt 更聚焦,模型不用边猜格式边猜意图。当然这个提升幅度不一定能完全复制到你的场景,但至少说明"做选择题"不是拿准确率换速度——很多时候是两头都赚。

5. 这招的边界:什么时候该回去用“生成”

5.1 选项覆盖不足,就像考卷出了考纲外的题

选择题方案有一个天然前提:你的业务场景可以枚举。如果用户的输入五花八门,而且新的意图随时可能出现,那么选项列表就永远覆盖不全。覆盖不全的后果不是"模型选一个错误的选项"这么简单,而是模型会硬凑——把一段完全没见过的话塞到某个语义最接近的选项里,然后下游拿着错误动作执行,比"我不知道"危害大得多。

我踩过一次这样的坑。某个边缘场景里,用户说"你们这个包裹是不是丢在驿站了",我的选项里只有"查物流"和"转人工"。模型实在看不出这两个哪个合适,最后以微弱优势选了"查物流",下游真的去调了物流接口,返回的却是"无此运单",用户收到个乱码式的回复。如果当时有"无法判断"的选项且阈值设得够狠,这次请求本来就该转人工。

所以我的建议是:选项列表不是一成不变的,至少每月要复盘一次"未命中/转人工"的记录,看有没有高频的新意图值得单独开一个选项。选择题方案做得好不好,长期看取决于你的选项列表迭代快不快。

5.2 长尾输入和选项爆炸:当选择题比作文还难出

另一个边界是选项数量。我前面说控制在 16 个以内,这是有代价的——它逼着你把细粒度的意图合并成粗粒度的动作。如果你的业务确实需要 50 个不同的动作,那选择题方案会迅速劣化:选项之间相似度太高,模型打分区分度不足,准确率暴跌,而且阈值调不准。

这种情况下我建议换个思路:先做一个粗粒度路由(比如 10 个以内的粗分类),命中后再做第二层细分(又是不超过 10 个的细选项)。两级选择题比一级 50 选 1 准确率高得多,而且每一级的延迟都是可控的。代价是两次请求,但总延迟依然远小于写一篇 200 token 的作文。

还有一类输入不适合选择题:开放式的创作型任务、需要引用具体数据的问答、以及那些答案本身就不在预定义集合里的需求。遇到这种,老老实实回到自由生成,别硬套。

5.3 复杂推理还是会"想"给你看:选择题不适合什么

说到底,"做选择题"适合的是"决策型"任务——输入映射到一个离散动作。但如果是"推理型"任务,比如数学应用题、多步逻辑判断、代码生成,强制让它做选择题,等于把推理过程压进一个窄小的输出空间,模型反而容易出错。

这里有个隐约的趋势:很多模型的推理能力是通过"链式思考"体现的,你把输出管死,它可能不会"默默思考再给答案",而是直接把思考过程丢掉,导致答案质量下降。这是我实测中确实碰到过的问题——在一个多步判断场景里,选择题方案的准确率比自由生成低了 7 个点。所以我的判断标准很简单:如果这道题"选哪个"本身就能代表答案,用选择题;如果答案必须包含过程、推导或新内容,别用。

那有没有中间态?有。可以保留自由生成,但用结构化输出约束它"只输出最终结论",或者让模型分两步走:先自由生成简短的中间结论,再把它映射到选项。这种混合模式复杂一些,适合那些"介于分类和开放性之间"的场景。我的经验是:先按最极端的"纯选择题"试跑一版,如果准确率不行,再往外松绑,比反过来做要省时间。

6. 落地这个方案后,我愿意提醒你的几件事

最后说几个我在实际落地过程中觉得特别容易被忽略的点,都是拿试错换来的。

第一个是选项的"标准答案序列"要跟模型的 tokenizer 对齐。我一开始图省事,直接用中文文本当 answer_tokens,结果发现不同 tokenizer 对同一句话的分词结果不一样,归一化之后分数被搞乱了。建议先用模型服务的编码接口把每个选项描述转成 token id,存起来,不要运行时现转。

第二个是打分接口的并发。选择题方案省了生成时间,但如果你把请求都积压在打分接口上,延迟优势会被排队吃掉。务必确认你的模型服务支持批量打分,并且做到多个候选在同一次请求里返回。如果服务不支持批量候选打分,这方案的价值至少打对折,你可能得考虑换服务或自己包一层并行请求。

第三个是日志里一定要留下"top1 概率"和"top1 与 top2 的差值"这两个字段。一开始我只记了最终动作,后来调阈值时根本没有历史数据可参考,只能重新跑回归。加上这两个字段之后,每次阈值调整都能基于一周的真实分布来定,省了太多事。

第四个是选项描述跟用户真实用词之间的"翻译成本"。模型很擅长从口语映射到书面选项,但前提是选项描述本身覆盖了足够的同义表达。我建议把高频用户原话直接揉进选项描述里——比如"改地址"的选项描述写成"修改收货地址、更改配送信息",效果比只写"地址修改"好不少。

这套"选择题化"的改造,坦白说不是什么高深技术,它只是把"该给模型出的题"出对了。但很多 Agent 项目恰恰就死在这一点上:让模型用最昂贵的方式做最廉价的事。如果你现在手上也有一个慢吞吞的轻量 Agent,不妨先别急着换大模型、加算力,试着把它的决策层改成选择题试试,说不定你也能在群里甩出一句"快了 10 倍"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询