自我一致性(Self-Consistency)是提示词工程里少数几个“不需要改模型、不需要训练、加几行采样代码就能见效”的方法。它的做法并不复杂:同一个提示词配上思维链,让模型用非零温度生成多条推理路径,再对最终答案做多数投票。听起来像是“多问几遍取众数”,但真正落地时会撞上一堆细节——温度设多少、采样几条、答案怎么归一化、投票什么时候停、一致性分数能不能当置信度用。这些问题在概念介绍里基本看不到,却是决定方案能否交付的地方。下面这些内容来自我把这套方法塞进若干实际任务后的记录,覆盖方案选型、参数推断、代码实现和翻车现场,适合已经会用提示词、想把准确率再往上顶一个档位的同学,也适合刚接触思维链、想搞清楚“为什么多采样几次就更准”的入门读者。
1. 自我一致性到底解决了什么问题
1.1 一次贪心解码的隐患:模型把错误走成了唯一的路
先说清楚它在对抗什么。常规调用大模型时,多数人用的是贪心解码或极低温度采样,也就是每一步都挑概率最高的那个词。这个策略的问题是,一旦模型在某一步选错了方向,后面所有的推理都会沿着这条错路继续往下走,而且走得非常自信。算术题里抄错一个数字、阅读理解里认错一个主体、分类任务里被一个干扰词带偏,都属于这一类。
关键在于,单次解码只暴露了模型的一条思路,而这条思路可能只是概率分布里排名第一的那条,不代表它是正确的。用生活里的类比,你问一个同事要方案,他张嘴就给你一个,你没法判断他是深思熟虑还是随口一说;但如果你问十个人,八个人给出同一个答案,你对这个答案的信心就完全不同了。自我一致性就是把“问一次”改成“问多次并统计”,用分布代替单点。
这里有个容易被忽略的前提:贪心解码的“确定性”只是假象。同一个提示词在不同批次、不同上下文长度下,输出可能微妙漂移,你以为的稳定复现其实并不牢靠。与其依赖一条不可控的路径,不如主动引入多样性再收敛,把不可控变成可统计。
1.2 把推理路径当成隐变量:投票为什么在数学上站得住
自我一致性的理论解释很干净:模型生成答案的过程可以拆成两步,先隐式地选一条推理路径,再由这条路径得出答案。单次解码相当于只采样了一条路径,而正确答案往往能通过多条不同的合法路径到达,错误答案则更依赖于某条特定的错路。
举个具体例子。一道应用题问“三天共卖出多少件”,正确解法可以先算每天平均、再乘三天,也可以先算总量再核对;这些路径写法不同,但终点都是同一个数字。而如果模型在第一天就抄错了原始数字,它有可能错成 120、也有可能是 150,错误方向并不统一。于是正确答案的“票源”是分散但集中的,错误答案的票源是分散且稀疏的,多数投票自然把正确的那一簇选出来。
这个解释同时给出了适用边界:错误必须是相对独立的,投票才有增益。如果模型对某类题目存在系统性误解,比如把“同比”理解成“环比”,那它十次采样里有十次都会错,投票只会把错误答案的置信度从 0.6 顶到 1.0,反而更危险。我在早期做财务报表问答时就踩过这个坑,投票一致性极高的那批样本,人工抽检错误率反而最高——因为系统性偏差被投票放大了。
1.3 三类不适合上自我一致性的任务
不是所有任务都值得这么折腾,我筛下来有三类建议直接别用。
第一类是答案不可判定或答案空间开放的生成任务。写摘要、写文案、写邮件,十条输出十个样子,没有“同一个答案”可以投票,多数投票直接失效。这类任务要么做通用自我一致性(让模型自己挑最一致的那条,见 5.1 节),要么老老实实换别的评估手段。
第二类是单次准确率已经接近随机猜测的任务。投票是放大已有信号的,前提是模型本身对这类问题有一定辨别力。如果单次准确率只有 30%,说明模型的内部表征里根本没有抓住关键特征,采一百次也只是把噪声投得更均匀。这种情况应该先回去改提示词、补示例、拆任务,别急着加采样。
第三类是延迟极其敏感的交互场景。自我一致性的成本是线性的,一次请求要跑 n 次推理,端到端延迟至少翻几倍。如果产品要求 2 秒内出结果,你只能采 2 到 3 条,增益有限。我的做法是把自我一致性放在异步链路或离线批次里,同步链路只做轻量校验。
注意:判断“能不能用”,最快的办法是拿 200 条标注样本,固定提示词,分别跑 n=1 和 n=10,看准确率曲线有没有明显分离。如果两条曲线几乎重合,说明这套方法对这个任务是无效的,别再往下投入。
2. 方案设计与关键取舍
2.1 提示词模板:先把思维链写稳,再谈投票
自我一致性依赖一个前提——模型输出的答案是“从推理路径里推导出来的”,而不是直接拍脑袋。没有思维链,多采样得到的只是同一个直觉的不同表述,投票没有意义。所以提示词的第一个任务是强制模型把中间步骤写出来,并且把最终答案放在一个固定、可解析的位置。
我习惯的模板结构是四段:任务说明、约束条件、推理要求、答案格式。推理要求里不要写“请仔细思考”这种空话,要写具体的动作,比如“先列出题目中的已知量,再写出计算式,最后给出结果”。约束条件要明确单位和精度,不然同一道题会同时出现 3.5 和 3.50,投票时被当成两个答案。
答案格式是整套方案的基础。我的标准做法是用一个显式标记把最终答案包起来,比如要求模型在最后一行输出“答案:X”,或者用\boxed{}包裹。标记要足够独特,避免正文里出现同款字符导致解析歧义。同时要求模型“只输出一个最终答案,不要给区间或备选”,否则你在聚合阶段会收到一堆“约为 120 或 130”这种没法投票的字符串。
提示:不要在提示词里写“如果不太确定可以说可能”,这种话会污染答案格式。明确要求模型必须给唯一确定的答案,哪怕它是错的,你才能在后面的环节用投票筛掉它。
模板还有一个细节值得强调:示例(few-shot)要克制。示例太多会让模型的输出高度同质化,十条采样里九条的推理路径一模一样,温度再高也没用。做自我一致性时,我通常只用 1 到 2 个示例,把多样性留给采样本身。
2.2 采样条数与温度的账怎么算
温度和采样条数是这套方法里唯一两个必须调的超参,而且它们互相耦合。
温度太低,比如 0.1,多条采样会高度趋同,路径重复率可能超过 90%,你花了 n 倍的钱拿到了 1.2 倍的信息量。温度太高,比如 1.5 以上,模型开始胡言乱语,推理链断裂,答案格式崩坏,归一化阶段一片狼藉。我的经验区间是0.5 到 1.0,top_p 配 0.9 到 0.95。如果任务的推理链很长、步骤很多,往 0.6 靠;如果是短分类、短问答,可以到 0.9。
采样条数 n 的取值一般落在 5、10、20 三档。下面这张表是我在自己几个任务上跑出来的粗略感受,不是权威数据,但能帮你建立成本直觉。
| 采样条数 n | 相对单次调用的 token 开销 | 典型准确率增益 | 我实际用在什么场景 |
|---|---|---|---|
| 1 | 1 倍 | 基线 | 格式转换、简单抽取 |
| 3 | 3 倍 | +2 到 4 个点 | 同步链路,延迟硬约束 |
| 5 | 5 倍 | +4 到 6 个点 | 线上主力配置 |
| 10 | 10 倍 | +6 到 9 个点 | 效果与成本的平衡点 |
| 20 | 20 倍 | +8 到 12 个点 | 离线批量、高价值单据 |
| 40 | 40 倍 | 边际增益常低于 1 个点 | 抽样审计、基准校准 |
增益随 n 增长是明显递减的,因为投票的方差按 n 的平方根下降,而成本是线性上涨。我通常把 10 作为默认值,只有当投票份额低于阈值时才升级到 20 条,这个“按需升级”的策略能把平均成本压到固定 20 条的一半左右。
成本估算要算全。假设单条采样的提示词 600 token、输出 400 token,n=10,那么一次用户请求消耗约 10000 token。日请求量 5000 次,就是 5000 万 token 的日消耗。这个数在预算表上很扎眼,所以必须配合去重、早停和缓存,具体做法在 4.3 节展开。另外别忘了那个最容易漏的项——开发阶段的试错成本,调温度、调 n、调提示词,每一轮都是全量重跑,我在第一个项目上光评估就烧掉了正式流量的三成。
2.3 聚合规则的三档选择:多数投票、加权投票、聚类投票
投票规则决定最终输出,它不是只有“数票数”一种写法。
最基础的是严格多数投票:把归一化后的答案分组计数,票最多的获胜。平票时要有确定的裁决规则,我一般按“取第一条出现的”或者“取数值居中的”,必须写死,不能随机,否则线上结果不可复现。
第二档是加权投票:每个样本带一个权重,再求和。权重可以来自模型返回的 token 对数概率(虽然很多接口不直接给,但可以用长度归一化的困惑度近似),也可以来自一次额外的自校验调用——让模型对每条推理链打分,再按分数加权。加权投票在长推理链任务上效果明显,因为长链里那些“一眼错”的样本会被低分压下去。
第三档是聚类投票,适用于答案不能精确匹配的场景。比如答案是一段短句,字面不同但语义相同,这时先用相似度把答案聚成簇,再在簇之间投票。相似度可以用字符级编辑距离,也可以用一次轻量的语义比较调用。聚类投票的代价是复杂度上去了,我一般只在答案空间是自然语言短句时才用。
还有一个容易被跳过的判定——投票份额本身就是信息。份额 0.95 说明模型高度自洽,可以直接放行;份额 0.4 说明众说纷纭,这时候正确答案往往不在票数第一的组里。我的表结构里固定存一个vote_share字段,后面对它的利用率比投票结果本身还高,具体用法见 3.4 节。
3. 实操落地:从零搭一个可复用的自一致性流水线
3.1 第一步:把任务改造成可判定的答案形式
动手写代码之前,先改造任务。判断标准很简单:两个正确答案放在一起,能不能用一行代码判断它们相等。能,就适合投票;不能,就先想办法让它能。
改造手段有三招。第一招是限制输出空间,多选题只允许输出选项字母,数值题只允许输出纯数字并规定小数位,分类任务只允许输出预设标签之一。第二招是附加标准化说明,比如要求“结果保留两位小数,不要带单位,不要写过程”。第三招是在代码侧做归一化,把常见变体映射到统一形式,比如“1/2”映射成“0.5”,“三点五”映射成“3.5”。
下面这个归一化函数是我在数值类任务里用了很久的版本,逻辑很直白但覆盖了大部分坑:
import re def normalize_answer(raw: str) -> str: if not raw: return "" s = str(raw).strip() # 优先取 \boxed{} 包裹的内容,避免正文里的数字干扰 m = re.search(r"\\boxed\{([^{}]*)\}", s) if m: s = m.group(1) # 统一大小写、去掉千分位与空白 s = s.lower() s = re.sub(r"[,,\s]", "", s) # 去掉句末标点,避免 "120." 与 "120" 被当成两个答案 s = re.sub(r"[。..!!??;;::'\"“”‘’]", "", s) # 分数转小数 m = re.fullmatch(r"(-?\d+)/(-?\d+)", s) if m and int(m.group(2)) != 0: v = int(m.group(1)) / int(m.group(2)) s = f"{v:.6f}".rstrip("0").rstrip(".") # 百分号转小数 if s.endswith("%"): try: s = f"{float(s[:-1]) / 100:.6f}".rstrip("0").rstrip(".") except ValueError: pass return s这段代码有几个刻意为之的地方。先取 boxed 内容,是因为模型的正文里经常出现中间计算结果,直接抓最后一个数字容易抓错。去标点必须在分数转换之前,否则“3/4。”这种带句号的输入会匹配失败。百分号转换放在最后,是因为它的输入形态依赖前面的清洗结果。
注意:归一化函数一定要配套一份单元测试。我见过最隐蔽的 bug 是归一化把负数处理错了,导致投票时正负答案被合并,准确率看曲线还挺正常,实际是在稳定输出错误结果。
3.2 第二步:采样循环、并发与早停
采样层要做三件事:并发跑 n 次、去重相同路径、够票就停。串行跑 10 次推理,端到端延迟是单次的十倍以上,线上根本没法接受,所以并发是必须的。
并发实现上,我用线程池调用同步接口,或者用异步客户端配信号量限流。关键点是每个请求单独设置随机种子或温度参数,别指望框架帮你保证多样性。有些接口在同一个连接里连续调用会复用某些内部状态,导致输出趋同,实测下来加个微小的时间戳扰动或者显式传不同的 seed 更保险。
早停规则可以做得非常确定。假设计划采样 n 条,已经采了 k 条,当前第一名 c1 票、第二名 c2 票,剩余名额 r = n - k。只要c1 - c2 > r,也就是剩下的票全给第二名也翻不了盘,就可以立刻收工。这个判断是精确的,不会牺牲任何准确率。
from collections import Counter def should_stop(counts: Counter, sampled: int, planned: int) -> bool: if sampled >= planned or not counts: return sampled >= planned ranked = sorted(counts.values(), reverse=True) c1 = ranked[0] c2 = ranked[1] if len(ranked) > 1 else 0 remaining = planned - sampled return (c1 - c2) > remaining实测下来,这套早停在答案集中度高的问题上能省掉 40% 到 60% 的调用量,尤其是在 n=20 的配置下效果最明显。答案分散的难题反而省不下来,但那种题本来就需要更多票。
采样循环里还要加两个保护。一是路径去重:把归一化后的完整推理链哈希一下,如果重复率超过 90%,说明温度太低或模型输出空间太窄,直接在日志里打警告,必要时自动提高温度重跑一轮。二是格式失败重试:解析不出答案的样本不要直接丢,给它一次带格式纠正的重试机会,成功率能提回来一半以上。
3.3 第三步:投票、置信度与兜底分流
投票本身只有几行,难的是围绕它的分流逻辑。我的做法是把结果分成三档:高置信直接采纳、中置信二次校验、低置信拒答或转人工。
def aggregate(answers, weights=None): cleaned = [normalize_answer(a) for a in answers] cleaned = [a for a in cleaned if a] if not cleaned: return None, 0.0, {} cnt = Counter(cleaned) if weights: score = Counter() for a, w in zip(cleaned, weights): score[a] += w top = score.most_common(1)[0][0] share = score[top] / sum(score.values()) else: top = cnt.most_common(1)[0][0] share = cnt[top] / len(cleaned) return top, share, dict(cnt)阈值的设定不能凭感觉。我的方法是拿验证集跑一遍,把样本按 vote_share 分桶,统计每个桶的准确率。典型的分布长这样:份额 0.9 以上的桶准确率能到 97% 左右,0.6 到 0.8 的桶掉到 80% 出头,0.5 以下的桶常常只有五成多。这说明份额是有区分度的,可以直接当置信度用。
于是分流规则就出来了。份额大于 0.8 的直接输出;0.5 到 0.8 的触发一次“升级采样”,把 n 从 10 加到 20 再投一次,或者走一次自校验;小于 0.5 的直接进人工队列,宁可慢也不要错。这套分流把自动通过率控制在七成左右,人工量降到了原来的三分之一,是我们那条链路最早见效的优化。
还有一条经验值得单独说:平票不要用随机。平票时我优先取数值型答案里的中位数,非数值型取第一次出现的那个。原因很现实,随机会让同一个输入在不同时间给出不同输出,用户投诉“你们系统前后不一致”时你很难解释。
3.4 第四步:离线评估与灰度上线
评估环节最容易被做糊。我的标准动作是固定三份集合:一份 500 条以上的标注验证集、一份 200 条的困难集(专门收集历史错例)、一份 100 条的回归集(每次改提示词都跑)。三份都要,缺一份就会出现“整体涨了但老问题复发”。
评估要看的不只是最终准确率,还有三条曲线。第一条是准确率随 n 的变化,用来定 n 的上限;第二条是vote_share 与正确率的关系,用来定分流阈值;第三条是成本与延迟曲线,用来回复老板的“为什么这么慢”。这三条线上跑一次大概花掉半天时间,但能省掉后面两周的反复。
灰度的做法是把自我一致性做成一个开关,按流量比例放量。第一天 5%,看准确率指标和人工复核通过率;第三天 20%,看成本是否超出预算;第七天 50%,观察有没有长尾输入导致的成本尖峰。我印象很深的一次是放量到 30% 时突然发现某个格式异常的输入会触发无限重试,直接把配额烧穿了,幸好只在灰度阶段,影响面很小。
指标上除了准确率,我会额外埋两个:平均采样条数(反映早停的有效性)和低置信占比(反映模型对当前输入分布的适应度)。后一个指标掉得快,往往意味着上游的数据分布变了,提示词该更新了。
4. 常见问题与排查实录
4.1 采样越多反而越差:三种典型翻车现场
第一种是系统性偏差被放大。前面提过,模型如果对某类问题有稳定的误解,投票会把错误答案的置信度顶满,你的分流规则一看份额 0.95 就自动放行,错得又快又自信。排查方法是拿错误样本看 vote_share 分布,如果错例的份额普遍偏高,那问题不在投票而在提示词,得回去修任务描述。
第二种是温度过高导致推理链断裂。采样到链接的中后段开始跑偏,答案格式崩坏,解析失败率上升,归一化后剩不下几条有效票。表现在指标上是“n 从 5 加到 20,准确率反而掉了两个点”。排查办法很简单:统计解析失败率,超过 10% 就说明温度该降。
第三种是提示词里的示例太少或太多导致的多样性失衡。示例太多、太相似,路径重复率飙升,投票等于同一个答案投了十遍;示例太少,模型对输出格式的理解不稳定,每条采样都换一个格式。我的经验是把示例控制在一到两个,且格式必须严格遵守,内容可以笼统一点。
下面这张表是我整理的排查速查表,遇到指标不对时按顺序过一遍,通常能定位到八成的问题。
| 现象 | 最可能原因 | 快速验证方法 | 处理动作 |
|---|---|---|---|
| 加采样后准确率不升反降 | 温度过高,链断了 | 统计解析失败率 | 温度降到 0.6 以下重跑 |
| 准确率不动,成本猛涨 | 路径重复率过高 | 统计推理链去重后的数量 | 提高温度或减少示例 |
| 高份额样本错误率高 | 系统性偏差 | 抽检 vote_share>0.9 的错例 | 改提示词,别指望投票 |
| 平票比例异常高 | 答案空间过于离散 | 看分组计数的分布 | 加强归一化或限制输出 |
| 延迟翻了好几倍 | 早停没生效 | 看平均采样条数 | 检查早停阈值逻辑 |
4.2 答案抽取环节的静默失败
这是最阴的一类问题,因为它不报错。解析器把“答案:约 120 件”解析成了“约120件”,和“120”不是一个字符串,投票时被分成两组,两个组各拿一半票,最后平票。或者更糟,解析器抓了正文里的某个中间数字,把正确答案覆盖掉了。
我的做法是给解析器加三道关卡。第一道是格式校验,如果模型没按约定的标记输出答案,直接标记为解析失败,而不是猜。第二道是类型校验,数值题的结果必须能被转成 float,转不了就是失败。第三道是范围校验,比如订单金额不可能超过某个上限,超了就是解析错了。三道关卡都过才进投票池,其余走重试。
实操心得:解析失败率这个指标一定要单独监控。它掉下去的时候准确率往往不动,因为失败的样本被静默丢弃了,投票池变小,看起来还挺正常。我在一个项目里就是靠这个指标发现了模型升级后输出格式的微妙变化。
4.3 成本与延迟压不住怎么办
成本问题有四个抓手,按性价比排序。
第一是早停,前面算过,答案集中的任务能省一半调用量,几乎零成本就能上线。第二是缓存,把提示词的哈希加上输入内容做键,命中直接返回。自我一致性的输入往往是重复的(同一个用户反复问、同一批单据格式相同),缓存命中率能到 20% 以上。第三是分流,先用一次低成本调用做难度判断,简单题直接走 n=3,难题才走 n=20。第四是模型分层,采样用便宜的小模型,最终校验用大模型,这个组合在很多任务上比全程用大模型更划算。
延迟问题基本靠并发解决,但并发有上限,接口侧的 QPS 限制是硬约束。我的做法是把 n 次采样拆成两批,第一批 5 条先返回一个初步结果,如果份额够高就直接给用户,剩下 5 条在后台补跑,用于统计和后续校准。用户感知的延迟只有一半,代价是在少数情况下后台结果和前台结果不一致,需要在日志里做好标记。
5. 几个值得关注的变体与延伸玩法
5.1 通用自我一致性:答案自由文本时怎么投
标准自我一致性要求答案可精确比较,遇到写解释、写建议这类自由文本就没法用了。通用自我一致性的思路是让模型自己当裁判:先采 n 条候选输出,再把这些候选一起塞回提示词,让模型判断哪一条和其余的“最一致”。
这个方法的妙处在于它不需要外部评分器,成本只多一次调用。缺点是引入了裁判偏差——模型倾向于选最长最像样的那条,而不是最正确的那条。我的用法是把它当粗筛,先砍掉明显跑偏的候选,剩下的再交给人工或规则校验。实测在开放式问答上,它能过滤掉大部分格式混乱和答非所问的输出,但对细微的事实错误无能为力。
5.2 跨模型投票:让错误互相抵消
同模型多次采样,错误的独立性是有限的,毕竟参数一样、偏置一样。跨模型投票是把同一个提示词发给两三个不同的模型,再把所有答案汇总投票。不同模型的训练数据、对齐策略、词表都不同,错误的耦合度低得多,我在几个事实性问答任务上看到准确率比单模型内采样高出三到五个点。
代价很明显:要维护多套接口、要处理各家的格式差异、成本还要乘以模型数量。我的建议是只在错误代价极高的场景用,比如合同条款抽取、金额核算这类错了要赔钱的地方。日常任务用单模型采样加早停就够了。
5.3 用一致性分数做置信度与数据筛选
一致性分数最被低估的用途,不是提升单次准确率,而是当一个免费的置信度信号。模型对某个输入很“确定”时,多次采样的答案会高度集中;很“犹豫”时答案会散开。这个信号不需要标注、不需要额外模型,几乎是白送的。
我用它做两件事。一是主动学习:把份额最低的那批样本挑出来优先标注,标注效率比随机抽样高好几倍,因为模型最不确定的地方往往就是数据分布的边界。二是伪标签筛选:在无标注数据上跑自我一致性,只保留份额高于 0.9 的样本当训练数据,噪声率明显低于直接取单次输出。我们有一次用这个方法扩充了一批训练集,模型微调后的效果比用三倍规模但未筛选的数据还好。
还有一个衍生用法是做回归检测。把固定测试集上的平均一致性分数记下来当基线,每次改提示词或换模型版本就重跑一次。分数突然掉了,说明模型对原来那批输入的确定性变差了,即使准确率还没掉,也是个早期预警信号。
最后分享一个我个人踩过好几次坑之后才想明白的点:自我一致性不是“精度旋钮”,拧大一点就变准。它更像是一次统计校正,前提是模型的错误是随机分布的。如果模型的错误是成体系的,采样再多也只是把偏见固化。所以每次加完采样、看到准确率涨了,我都会额外抽一批高份额样本人工看一眼,确认这批“高置信”的答案是真的对,而不是被投票包装过的错。这个习惯救过我两次,都是在上线前的最后检查里发现的,不然线上表现会是“用户越信任的答案,错得越离谱”。
另外,如果你手头还没有标注集,别急着上自我一致性,先把那 200 条标注做出来。没有它,你调温度、定阈值、选 n 全是盲调,最后只能靠感觉判断“好像变好了”。数据先行这件事,在这个方法上体现得格外明显。