自我一致性实战:用多次采样投票提升大模型推理稳定性

发布时间:2026/11/2 21:58:54
自我一致性实战:用多次采样投票提升大模型推理稳定性

在Prompt工程里摸爬滚打一段时间之后,只要你对同一道题连续提问两三次,就会遇到那种让人心里发毛的时刻:题目没变、提示词没变、模型也没变,答案却从A漂到B,再漂到C。单次调用拿到的那个答案,本质上是模型在一次随机采样中“随手”给出的一个可能解。**自我一致性(Self-Consistency)**这一套方法解决的正是这个痛点——它放弃“一次答对”这个不现实的期待,改让模型沿多条不同的推理路径各走一遍,再对最终答案做投票,用群体的一致性来抵消单次采样带来的偶然性。它特别适合那些答案有明确对错、但推理过程可以有很多种的场景,比如数学应用题、符号推理、逻辑判断、结构化信息抽取,甚至部分代码修复任务。如果你正被“同一个Prompt跑十遍出五个结果”折腾得难受,或者在给别人做Prompt方案评审时被问“怎么保证稳定性”,这套东西值得认真过一遍。下面我按自己的实际使用顺序,把这套方法的原理、参数、实现和踩过的坑完整铺开来说。

1. 自我一致性到底是什么:从“一次作答”变成“多次投票”

1.1 先看清单路径推理到底脆在哪里

大部分人第一次接触思维链(Chain of Thought)时,会有一种“模型变聪明了”的错觉:只要在提示词里加一句“请一步一步思考”,原本算错的题忽然算对了。但多跑几次就会发现,这个“对”非常不稳定。原因不复杂——思维链本质上只是让模型把中间推理步骤显式地写出来,而解码过程依然是逐token的概率采样。每一步选哪个词、往哪个方向推,都带着随机性;前面一步走岔了,后面会沿着错误的中间结论一路推到底,最后给出一个看起来很“有理有据”的错答案。

我印象很深的一次,是拿一批两位数乘法的应用题做回归测试。同一道题,提示词固定、温度固定,跑十次,七次是对的,三次错,而且错的那三次各自错法不一样:有一次是单位换算少乘了100,有一次是把“剩下”理解成了“总共”,还有一次中间过程全对、最后抄答案时抄错了。三个错误之间毫无关联,这意味着你没办法靠“修提示词”一次性把这三个坑全堵上——它们不是提示词的漏洞,而是采样噪声的外在表现。

这就是单路径推理的结构性弱点:它把整个任务的成败押在一次抽签上。模型的概率分布里本来就同时存在正确路径和错误路径,贪心解码或者单次采样只是从这堆路径里挑了一条,挑中哪条纯看运气。你看到的“稳定”,往往只是因为题目太简单,正确路径的概率高到几乎垄断了抽样——一旦题目难度上来,概率分布变平,稳定性立刻崩。

1.2 自我一致性的核心机制:边际化而非求最优

自我一致性的思路,用一句话概括就是:不去纠结“哪条推理路径最正确”,而是承认每条路径都可能出错,然后对所有路径的最终答案做统计,取出现频率最高的那个。

展开讲分三步。第一步,把提示词改造成“先输出推理过程,最后单独一行用固定格式输出答案”的结构,这一步是为了让后续的答案抽取有稳定的抓手。第二步,用非零温度对同一个问题采样k次(通常是5到40次),每次采样都会得到一条不同的推理链和一个最终答案。第三步,把所有最终答案做归一化处理后统计票数,票数最高者为最终输出;票数分布本身还可以当作置信度使用。

这里有个关键点容易被误解:自我一致性不是让模型“自己检查自己”。它不做二次校验、不做反思、不要求模型判断“我上一次答得对不对”。很多人把自我一致性跟“自我反思”“自我验证”混为一谈,其实完全是两回事。反思类方法是在原答案基础上做二次加工,而自我一致性是从零开始的多次独立采样,路径之间互不影响,投票器只看最终答案,不看推理过程。

从概率角度看,这是在做边际化(marginalization):最终答案的正确性不再依赖于某一条具体路径,而是对所有可能路径做加权求和。只要正确路径在分布中占据的“总概率质量”够大——注意是总质量,不是单条路径的概率——那么采样的次数够多,正确答就会在票数上胜出。哪怕每一条正确路径单独看都不是最高概率的那条,只要它们加起来压过所有错误路径,投票就能赢。这正是自我一致性比贪心解码强的理论根基。

1.3 它跟思维链、best-of-n、自检的区别

这几种方法经常被混着讲,实际差别挺大,我用一张表把边界划清楚:

方法采样次数如何选最终答案适用前提主要成本
纯思维链(CoT)1次(贪心)直接采用题目难度不高最低
自我一致性(SC)k次(非零温度)对最终答案多数投票答案可比对、答案空间收敛k倍推理成本
best-of-nn次用奖励模型/打分器排序取最高分有一个可靠打分器n倍推理 + 打分成本
自我反思/自检1到2次让模型评审并修改原答案模型具备较好的自我纠错能力低,但收益不稳定

区别在哪?best-of-n 需要一个外部裁判,自我一致性不需要。自我一致性完全靠内部票数分布决胜负,实现上更轻,不依赖额外的打分模型。代价是它只对“答案空间小且可归一化”的任务有效——你得能判断两个答案“是不是同一个”,否则投票无从谈起。而自我反思的收益非常看模型自身的纠错能力,实际用下来,它在“格式类错误”上很有效,在“推理链根本就是错的”这种情况下基本无效,模型往往会固执地为自己的错误辩护。

2. 为什么这条路走得通:原理层与工程层的双向拆解

2.1 采样温度是这套方法的命门,设错等于白跑

这是我在实际项目里见过最多的低级失误:有人兴冲冲写好了投票逻辑,把k设成10,跑完发现十个答案一模一样,投票环节形同虚设,于是得出结论“自我一致性没用”。一查参数,温度设的是0,或者用的某个默认贪心接口。

温度控制的是输出概率分布的陡峭程度。温度趋近于0时,模型几乎总是选概率最高的那个token,于是每次采样都走同一条路径、给出同一个答案——你花了10倍的钱,做了10次完全冗余的推理。自我一致性的全部收益都来自路径的多样性,而多样性直接由温度供给。

那温度该设多少?我的经验区间是0.6到1.0。低于0.5,路径趋同严重,票数分布会极度集中,投票失去意义;高于1.1,模型的输出开始出现明显的退化——推理链条断裂、答非所问、格式崩坏,噪声路径的数量压过了有效路径,投票反而把错误答案推上去。我一般默认用0.7,这是多样性和输出质量之间比较稳的一个平衡点;如果任务本身答案空间很大、容易一题多解,可以抬到0.9。另外top-p(核采样)建议保持在0.9到0.95,别开到1.0,留一点截断能有效减少胡言乱语的长尾。

还有一点值得单独提:温度设好之后要验证多样性。做法很简单,先跑k=10看去重后有几个不同答案。如果去重后只有1到2个,说明温度不够或者题目太简单,投票没意义,直接退回单次调用省成本;如果去重后有8到10个且答案彼此差异巨大、没有明显聚集,说明温度过高或者题目超出了模型能力边界,投票结果不可信,这时候该换更强的模型或者拆解问题,而不是硬上自我一致性。

2.2 采样多少次才划算:一条典型的边际递减曲线

k取多少,是成本和质量之间最直接的博弈。理论上k越大票数越稳,但收益严格递减,而且不是线性的。

粗算一下:假设单次推理平均输出200 token、提示词300 token,k=10 相当于一次问答的 token 消耗涨到10倍。如果这条链路在线上每天要跑上万次,这就是实打实的成本差异。所以我通常按“每次追加采样带来的准确率提升是否还值这一次推理的钱”来定档,而不是拍脑袋定一个k。

下面这个表是我在一批数学应用题(约200题)上做的粗略区间估计,用来定档够用了,具体数字随任务变化很大,不必照抄:

k值相对准确率(以k=40为基准)相对成本适用判断
1(贪心)约0.721x只适合做基线对照
5约0.865x性价比最高的起步档
10约0.9210x大多数线上场景的选择
20约0.9720x有明确准确率KPI时才上
401.0040x基本只用于离线评测基准

可以看到,k从1到5这一段涨幅最猛,从10到20这一段已经很平了。我的默认档位是k=10,压测阶段用k=20做一次性回归,确认k=10已经吃到了主要收益,再回到10上线。如果业务对准确率极其敏感,比如自动判卷、单据核对,可以上20;再往上基本是拿钱买安慰。

还有一个更省的做法:分两段采样。先采5次,看票数分布。如果最高票已经拿到4票以上(占比超过80%),说明答案高度一致,直接返回,不再追加;如果票数分散(最高票只有2到3票),再追加5次。实际跑下来,简单题大多在第一段就收敛了,只有少数难题会走到第二段,平均成本能压到k=10的六成左右,准确率基本不掉。这套早停逻辑我在后面的第4节还会展开。

2.3 什么任务适合,什么任务纯粹是烧钱

自我一致性有用,但它不是万能药,用错地方就是纯浪费。判断标准就一条:答案能不能被机械地比对。

先说适合的。数学计算、单位换算、符号逻辑题、日期推算、分类打标、从长文本里抽实体和数值、代码修复(通过单元测试判定对错)、结构化JSON输出(按字段比对)——这些任务的答案都落在一个相对收敛的空间里,两个答案是不是同一个,程序能判断。

再说反例。开放式文案生成、创意写作、主观性问答、多轮对话回复,这类任务没有“同一个答案”的概念,你没法给两段不同的文字投票。硬要做的话,只能退化成“用模型做裁判”,那本质上是best-of-n,不是自我一致性了,还多引入一层不确定性。还有一种特别隐蔽的坑:答案空间不收敛的任务。比如“给这个函数起三个变量名”,十个采样给出十个组合,每个组合都不一样,投票结果会是十个1票的平局,得不出任何结论。

即便在适合的任务里,也有一类要绕开:推理链极长、错误具有强相关性的任务。如果模型在某个知识点上系统性地理解错了(比如某个概念的固定误解),那么十条路径会朝同一个方向错,投票只会把错误答案投得更自信。自我一致性只能消解随机噪声,消解不了系统性偏差。遇到这种情况,正确的动作是回去改提示词、加示例、换模型,而不是加k。

3. 动手写一个可复用的自我一致性流水线

3.1 整体架构与关键参数选型

我承认一开始是直接写脚本来跑的,跑了两周发现每个任务都在重复抄同样的采样和投票逻辑,而且每抄一次就漏掉一个边角处理——这次忘了归一化单位,下次忘了处理空答案。所以后来我把它整理成一个固定结构,共五个环节,顺序很关键:

  1. 提示词改造:强制模型先输出推理过程,最后单独一行用固定标记输出答案。
  2. 并行采样:用非零温度发起k次独立请求,注意要让请求之间互不带上下文。
  3. 答案抽取:从每条输出里把最终答案抠出来,抠不到就标记为无效样本。
  4. 答案归一化:统一数值、单位、大小写、标点、同义表述,让“同一个答案”能被程序认出来。
  5. 投票与置信度:统计票数,输出最高票答案及票数占比;票数过于分散时触发降级策略。

参数上我通常这样起步:k=10、temperature=0.7、top_p=0.95、max_tokens给足(至少留出完整推理链的空间,别让输出被截断在中间,被截断的样本是无效票)。并发上把k次请求全部并发发出,而不是串行——串行的话延迟直接乘以k,用户体验会很难看;并发的话总延迟约等于最慢那一次请求,代价是瞬时并发压力变大,需要做好令牌桶限流。

提示词这一环有一个我强烈建议的写法,是把答案格式写死在下游能解析的位置。下面这种结构,实测解析成功率最高:

请一步步推理,然后严格按下面两行格式输出,不要在答案行后再写任何内容: 推理过程:<你的逐步推理> 最终答案:<只写答案本身,不要带单位说明和解释>

关键在“只写答案本身”这几个字。少了这句话,模型会在答案行里塞进“约等于”“大概是”“因此答案为”这类修饰,归一化环节的处理量会翻好几倍。

3.2 核心代码:采样、抽取、归一化、投票

下面是剥掉业务耦合之后的核心骨架,Python写,依赖只用了标准库加一个通用的模型调用函数,你可以直接把call_model换成自己那套封装:

import re import json from concurrent.futures import ThreadPoolExecutor from collections import Counter def call_model(prompt: str, temperature: float = 0.7, top_p: float = 0.95) -> str: """占位:换成你自己的模型调用封装,返回纯文本""" raise NotImplementedError ANSWER_PATTERN = re.compile(r"最终答案[::]\s*(.+)") def extract_answer(text: str): """抽取最终答案;抽不到返回 None,这类样本不参与投票""" if not text: return None # 优先取最后一次出现的标记,防止推理过程中提到过“最终答案”字样 matches = ANSWER_PATTERN.findall(text) if not matches: return None raw = matches[-1].strip() raw = raw.split("\n")[0].strip() # 只取答案行,砍掉后面的废话 return raw or None def normalize(answer: str) -> str: """归一化:这里只做通用处理,业务相关的单位/同义词映射另外加表""" if answer is None: return "" s = answer.strip().lower() s = s.replace(",", ",").replace("。", ".").replace(" ", "") s = re.sub(r"^(答案是|答案为|所以|因此)[::]?", "", s) s = re.sub(r"[。..!!??]+$", "", s) # 去掉句末标点 # 数值统一:去掉千分位、前导零、多余小数位 num = re.fullmatch(r"-?\d[\d,]*\.?\d*", s) if num: s = s.replace(",", "") try: f = float(s) s = str(int(f)) if f == int(f) else f"{f:.4f}".rstrip("0").rstrip(".") except ValueError: pass return s def run_self_consistency(question: str, k: int = 10, temperature: float = 0.7): prompt = build_prompt(question) # 用 3.1 里的固定格式模板 with ThreadPoolExecutor(max_workers=k) as pool: outputs = list(pool.map(lambda _: call_model(prompt, temperature), range(k))) votes, invalid = Counter(), 0 for out in outputs: ans = extract_answer(out) key = normalize(ans) if not key: invalid += 1 continue votes[key] += 1 if not votes: return {"answer": None, "confidence": 0.0, "invalid": invalid, "votes": {}} best, cnt = votes.most_common(1)[0] valid = sum(votes.values()) return { "answer": best, "confidence": cnt / valid, # 有效票中的得票占比 "invalid": invalid, "votes": dict(votes), "all_outputs": outputs, # 留作离线分析用 }

这段代码有三处是我踩坑之后补上的,值得单独说。第一处是matches[-1]:一定要取最后一次出现的“最终答案”标记。早期我用第一个匹配,结果有些样本的推理链中途会写一句“如果按这个思路,最终答案是……但是还要考虑单位”,真正结论在后面,取第一个就抽错了。第二处是raw.split("\n")[0]:模型经常在答案行后面继续补一句解释,不砍掉的话,同一个数值答案会因为尾巴不同而被归一化成不同字符串。第三处是把无效样本单独计数:invalid高得离谱的时候,问题一定出在提示词格式上,而不是投票逻辑上,这个计数是最好的报警信号。

3.3 归一化才是真正的胜负手

如果只让我保留一个优化点,我会选归一化,而不是调k、不是换模型。原因很直接:投票的前提是能认出“这两个答案是一个东西”,而归一化就是那个识别器。识别器不准,票就被打散,k加得再多也没用。

我见过最典型的一例是金额计算题。模型给出的正确答分别是1280、1280元、1,280、1280.00、一千二百八十。五个样本其实全对,但归一化只处理了标点和千分位,1280元和一千二百八十各成一票,最后票型是1280:2、1280元:1、1280.00:1、一千二百八十:1,得票最高的1280只有2票,置信度算出40%,被下游的降级策略直接判成“低可信”转人工了。白白浪费了五次全对的推理。

所以归一化的处理清单,我一般是这几层依次过:去修饰词(答案前缀、语气词)→统一标点与大小写→数值标准化(去千分位、前导零、统一小数位)→单位剥离或映射(元/块/RMB 归到同一类,注意别把“万元”和“元”粗暴合并,这类要按业务规则乘系数)→同义词与别名映射表(比如“是/对/正确/True”统一归一)→结构化答案按字段排序序列化(返回JSON的,按键名排序后比对)。中文数字转阿拉伯数字我一般单独写个小函数,只覆盖常用的百、千、万级别,够用了。

还有一个细节:归一化之后保留一份原始答案映射,这样最终输出给用户的还是可读的原始表述,而不是归一化后的干瘪字符串。投票时用归一化键,输出时用该键下第一次出现的原始值,用户体验会好很多。

3.4 置信度怎么用:别只输出答案,输出可信程度

投票做完了,最有价值的副产品其实是票数分布。我把置信度定义成“最高票数 ÷ 有效票数”,并且按实际效果划了三档处理策略:

置信度区间判断建议动作
≥ 0.8高度一致直接采用,可跳过人工复核
0.5 ~ 0.8存在分歧采用最高票,但打标进入抽检池
< 0.5严重分歧降级:转人工、换更强模型重跑、或拆解子问题

这套策略实际用起来很省人力。业务侧的反馈是,被标为“高度一致”的那部分,人工抽检的错误率极低,复核成本几乎可以忽略;而“严重分歧”的那批样本,人工看下来确实大多是真难题或者提示词没覆盖到的边界情况,值得深挖。

注意:置信度只反映模型内部的一致性,不反映正确性。十条路径一起错,置信度会是1.0。所以它适合当“分流的信号”,不适合当“正确性的保证”。我一般会在离线评测集上单独统计一下各置信度档位的实际准确率,画出对应关系,心里就有数了——通常0.8以上那档的准确率能到95%以上,0.5以下那档可能只有六成。

4. 实战调优:成本、延迟与稳定性的三角平衡

4.1 成本账要怎么算才不心虚

很多人对自我一致性的第一反应是“贵”。贵是事实,但得算清楚贵在哪、值不值。

直接成本就是字面意义上的k倍:k=10,token消耗涨10倍。但这笔账要跟“错误答案造成的损失”放在一起看。如果一次错误的结构化抽取需要人工返工5分钟,而10次推理的额外成本折合下来远低于5分钟的人力,那这笔投入就是划算的。反过来,如果错误后果很轻(比如给用户的推荐标签错了一个,用户根本感知不到),那k=10就是过度设计,k=3甚至单次就够。

还有两个降低成本的实操手段。一是模型分层:不是所有请求都值得上自我一致性。我会先用一个便宜的小模型跑一次,如果它给出的答案是高置信度的(比如结构化字段全齐、格式完全合规、走的是一个明确的快速通道),就直接采用;只有小模型表现出犹豫或格式异常时,才升级到大模型跑k次投票。这套路由下来,大概七成请求走的是便宜路径,整体成本能压到全量k=10的三分之一左右,准确率损失在可接受范围内。

二是缓存:同一道题(精确匹配或语义近似匹配)的投票结果可以直接缓存复用。业务里重复问题的比例比想象中高,尤其是模版化查询,缓存命中率能到三四成,这部分等于白赚。

4.2 早停:把k从固定值改成动态值

固定k最蠢的地方在于,它对简单题和难题一视同仁。简单题5次就全票一致了,还在傻等剩下5次;难题20次还是各说各话,早早停在10次又不够。所以我把k改成了动态。

具体逻辑是:按批采样,每批5次。每批结束后检查两个条件——条件A:最高票数已经超过当前总票数的一半,且领先第二名至少2票;条件B:总采样数达到上限(比如20)或者达到下限(比如10)且满足条件A。满足就停,不满足就继续下一批。

def early_stopping_vote(question, batch=5, max_k=20, min_k=10, temperature=0.7): votes, outputs = Counter(), [] while len(outputs) < max_k: batch_out = sample_batch(question, batch, temperature) outputs.extend(batch_out) for out in batch_out: key = normalize(extract_answer(out)) if key: votes[key] += 1 if len(outputs) < min_k: continue total = sum(votes.values()) if not total: continue (top, top_cnt), *rest = votes.most_common(2) or [(None, 0)] second = rest[0][1] if rest else 0 if top_cnt > total / 2 and top_cnt - second >= 2: break return votes

按我这边的实测,简单题平均在第一批或第二批就收敛,难题会一路跑到上限。整体平均k落在6到8之间,比固定k=10省了大概三成推理量,准确率因为没有牺牲采样上限,几乎没变。

4.3 工程落地时几个不显眼但很要命的约束

并发与限流。k次请求全并发发出去,瞬时QPS会放大k倍,很容易撞上上游的速率限制。我的做法是给整条链路套一个令牌桶,桶容量按上游配额的六成设置,留出余量给其他调用方;同时给单次调用加超时和重试(重试次数最多1次,别搞指数退避到把整条链路拖死),超时的样本直接不计入投票,而不是当作错误答案计一票。

截断就是废票。max_tokens设小了,推理链会在中途被切断,这时候抽取逻辑要么抽不到答案,要么抽到一个残缺的错误答案。我的处理是把被截断的样本单独标记出来(调用返回里一般有 finish_reason 之类的字段),一律不计票,同时统计截断率;截断率超过5%就要去调大max_tokens或者精简提示词。

结果可追溯。每次投票我都把k条原始输出和各自的归一化结果落盘存下来。这不是为了合规,是为了排查。出了问题能立刻看到“到底有几条算对了、为什么没投上去”——往往是归一化漏了某种表述,看到原始输出一眼就能定位。没有这个日志,你只能对着最终答案猜。

别在提示词里做few-shot堆叠的同时上高温度。示例给得多,模型的输出趋同度会大幅提升,高温度带来的多样性被示例压掉了,投票效果打折。如果确实要带示例,温度可以往上提0.1到0.2来补偿。

5. 常见问题与排查速查表

5.1 几个典型症状与对应病灶

症状一:十次采样答案全一样,置信度1.0,但正确答案明显不是它。这通常不是“模型很自信”,而是采样根本没多样性——先查温度是不是0或者接近0,再查是不是提示词里的示例太多、把模型锁死在一条路径上,最后查是不是上游做了结果缓存但你不知道。有一次我排查了半天代码,最后发现是调用封装里硬编码了温度,外面传的参数被静默覆盖了。

症状二:票数极度分散,最高票只有2票,置信度算出来0.2。按可能性排序:答案空间不收敛(任务不适合自我一致性)→ 归一化太弱(同一个答案被拆成好几票)→ 温度过高(噪声压过信号)→ 提示词没强制约束输出格式。排查顺序我一般是先看原始输出,人工扫十眼看票到底该不该分散,这一步能省掉大量瞎猜。

症状三:置信度高,人工抽检错误率也高。这是最危险的情况,说明模型存在系统性偏差,十条路径一起走偏。这时候加k是纯粹浪费,得回去查提示词是不是埋了误导信息、任务定义本身有没有歧义、模型的领域知识够不够。我遇到过一道关于闰年判断的题,提示词里给了个错误示例,模型忠诚地沿用了那个错误逻辑,十次采样十次都错,投票结果“高度一致”得让人非常难受。

症状四:准确率上去了,但延迟翻了三倍。大概率是k次请求被写成了串行,或者没做早停,或者缓存没生效。检查并发是不是真的并发(有些SDK的封装看起来是并发,实际上是内部排队)。

症状五:离线评测很好,线上效果一般。常见原因是评测集和线上分布不一致,或者线上为了省钱偷偷把k降到了3。还有一种很隐蔽的:线上请求的提示词经过了模版拼接,拼接后多加了一个空格或换行,导致答案抽取的正则匹配不上,大量样本沦为无效票,最后全部走了降级路径。

5.2 问题速查表

现象最可能的原因快速验证方式处理动作
采样结果高度重复温度为0或示例过多看去重后答案数温度提到0.7,精简示例
票数碎片化归一化弱 / 答案空间大手扫原始输出补归一化规则,或换任务
无效票比例高输出格式没约束住统计抽取失败率强化答案格式模板
置信度高但错误系统性偏差抽检高置信样本改提示词或换模型,别加k
延迟成倍增长串行采样 / 未早停看单次请求耗时占比改并发,上早停
成本超预算k固定过大统计平均k分层路由 + 缓存
线上比离线差提示词拼接差异对比线上实际入参统一模板,加固归一

5.3 我踩过的坑,直接抄结论就行

第一个坑,把自我一致性当成了“提高模型能力”的手段。它不提高能力,它只是把模型已有能力里“偶然答对”的那部分,更稳定地提取出来。如果模型压根不会做这类题,k加到100也是100个错答案。判断方法很土但很有效:先看k=10的去重答案里,正确答案出现了几次。如果十次里正确答案一次都没出现,别再优化投票了,去解决能力问题。

第二个坑,在提示词里写“请确保答案正确”这类话。早期我觉得这能提升质量,实际上它只会让模型在错误路径上更“自信”地输出,反而降低了路径多样性。真正有用的是把答案格式和判定标准写清楚,至于正确性,交给投票。

第三个坑,拿一个小评测集调参调到过拟合。我在30道题上调k、调温度、调归一化,调到准确率97%,换到300道题的测试集上掉到81%。后来改成固定参数、只在足够大的集合上验证,心态平和多了。经验是:调参用的集合至少200题,而且要从线上真实分布里抽,别全挑难题也别全挑简单题。

第四个坑,忽略推理过程的诊断价值。一开始我只看最终答案的票数,后来发现把k条推理链按“投给了最高票答案”和“没投给”分成两组对比着看,能极其高效地定位提示词的模糊点——错误组往往会在同一个措辞上栽跟头。这个习惯帮我改进了好几版提示词,比单纯调参有效得多。

第五个坑,在有严格时延要求的场景硬上k=20。并发能压住大部分延迟,但P99还是会被最慢的那条请求拖累。这种场景我的做法是限时:给整批采样设一个总时限,到点就用手上已有的票出结果,剩下的请求直接丢弃。准确率会掉一点,但可用性保住了。

5.4 什么时候该果断放弃这套方法

说句实在话,自我一致性不是越多用越好,有几种情况我会明确建议别用。

当任务本身是开放式生成、答案无法机械比对时,别用,改用best-of-n配一个像样的打分器,或者干脆靠人工审核。当单次调用的准确率已经高到95%以上时,别用,边际收益撑不起k倍成本,不如把预算投到别处。当模型在某个任务上存在系统性偏差时,别用,先去修提示词和知识覆盖,投票帮不上忙。当答案是长文本、只能靠语义相似度判断是否“同一个”时,慎用,相似度阈值本身就是一个新的调参黑洞,投出来的结果往往比单次还难解释。

真正适合上这条流水线的,是那些答案有明确对错、模型有一定能力但不够稳定、错误代价不低的任务。我目前保留着自我一致性的两个场景:一个是结构化单据信息的抽取,另一个是带数值计算的对账类问答。这两个场景的共同点是答案空间收敛、归一化规则明确、错误后果需要人工返工,投票带来的稳定性提升能实实在在换成人力成本的下降。其他地方,能单次跑稳就单次跑,别为了“看起来更严谨”给链路加不必要的复杂度——这是我用了一年多之后最实在的一个体会。