Self-Consistency 解码策略——让 AI 自我投票选最好的答案
Self-Consistency 解码策略——让 AI 自我投票选最好的答案
几个月前,我们做的一个智能客服系统上线之后,有一个数学计算类的工单分类准确率一直不稳定,同一个问题,今天准确率 80%,明天 70%,后天又 85%,完全没有规律。
排查了一圈,问题出在 LLM 的随机性上。Temperature 设了 0.7,每次推理路径都不一样,运气好的时候走了正确的推理链,运气差的时候在关键步骤上拐了个弯。
当时的解决方案之一就是 Self-Consistency:对同一个问题让模型跑 5 次,取 5 个答案里出现最多的那个。效果立竿见影,分类准确率稳定到了 88% 左右,而且方差大幅缩小。
但这个方案带来了明显的成本增加——5 次调用的费用比 1 次贵了差不多 4-5 倍(还有 batch 折扣和 token 差异,不是精确的 5 倍)。所以这个方案最终只在高价值工单上启用了,日常的普通问题还是单次调用。
这就是 Self-Consistency 的全貌:它是一个有效的准确率提升手段,但不是免费的。
Self-Consistency 是什么
Self-Consistency 来自 2022 年的一篇论文《Self-Consistency Improves Chain of Thought Reasoning in Language Models》,作者是 Google Research 的团队。
核心思想用一句话说:对同一个问题,用 CoT Prompt 生成多个不同的推理路径,然后取推理结论中出现最频繁的答案。
论文里把这叫做"在推理路径空间上边缘化"(marginalize over reasoning paths),听起来很学术,实际上就是多数投票。
为什么需要这个
LLM 的生成本质上是概率采样,即使对同一个问题,每次生成的推理路径都不完全相同。对于简单问题,几乎每次都走对;但对于需要多步推理的复杂问题,某些关键步骤上存在"岔路口",模型有时候走正确的路,有时候走错误的路。
Self-Consistency 的逻辑是:如果正确的推理路径确实是最有逻辑的,那么在多次独立采样中,它出现的概率应该比错误路径更高。通过聚合多次结果,我们可以抑制偶然性错误,增强一致性强的答案。
一个直观的类比:你让 5 个不同的人独立解同一道数学题,他们用了不同的方法,但大多数人得到了 42 这个答案——那 42 大概率是对的,即使有 1-2 个人算错了。
Self-Consistency 的执行流程
用 Mermaid 画出来:
注意两个关键点:
采样时用相对高的 Temperature(0.7-1.0)。这是故意的,目的是让每次采样走不同的推理路径,而不是每次都走同一条确定性路径。如果 Temperature 为 0,5 次采样结果完全一样,没有任何意义。
聚合的是"答案"而不是"推理路径"。推理路径千变万化,很难直接比较,但最终答案通常是有限集合(分类标签、数字结果、yes/no 等),可以直接投票。
与 CoT 的关系
很多人问我:Self-Consistency 和 CoT 是什么关系?
CoT 是 Self-Consistency 的前提。Self-Consistency 依赖于多样的推理路径,没有 CoT,模型直接给答案,路径就是"输入 -> 输出",没有中间过程,也就无从谈路径多样性。
加了 CoT 之后,模型在生成最终答案之前会经过多个推理步骤,每个步骤都有随机性,这就产生了真正意义上的"不同推理路径"。
从效果来看:
- 单次 CoT:比直接回答准确率高,但仍有随机性
- Self-Consistency(基于 CoT):在 CoT 基础上进一步提升,且结果更稳定
论文里报告的数据显示,在几个标准推理 benchmark 上,Self-Consistency 相比单次 CoT 的提升在 5-20 个百分点之间,具体取决于任务和模型。
实际工程实现
基础版本:简单多数投票
@Service
@Slf4j
public class SelfConsistencyService {
private final ChatClient chatClient;
// 默认采样次数
private static final int DEFAULT_SAMPLES = 5;
public SelfConsistencyService(ChatClient.Builder builder) {
this.chatClient = builder
.defaultOptions(OpenAiChatOptions.builder()
.withTemperature(0.8f) // 高温度,增加推理路径多样性
.build())
.build();
}
/**
* Self-Consistency 投票,适用于答案是有限集合的场景
* @param question 问题
* @param cotPrompt 包含 CoT 引导的 Prompt
* @param samples 采样次数
*/
public SelfConsistencyResult<String> vote(String question,
String cotPrompt,
int samples) {
List<String> allResponses = new ArrayList<>();
List<String> extractedAnswers = new ArrayList<>();
// 并行执行多次采样以节省时间
List<CompletableFuture<String>> futures = IntStream.range(0, samples)
.mapToObj(i -> CompletableFuture.supplyAsync(() -> {
String response = callModelWithCoT(question, cotPrompt);
log.debug("第 {} 次采样结果:{}", i + 1, response);
return response;
}))
.collect(Collectors.toList());
// 收集结果
for (CompletableFuture<String> future : futures) {
try {
String response = future.get(30, TimeUnit.SECONDS);
allResponses.add(response);
String answer = extractFinalAnswer(response);
extractedAnswers.add(answer);
} catch (Exception e) {
log.warn("某次采样失败,跳过", e);
}
}
if (extractedAnswers.isEmpty()) {
throw new RuntimeException("所有采样均失败");
}
// 投票
Map<String, Long> voteCounts = extractedAnswers.stream()
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
String winner = voteCounts.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElseThrow();
double confidence = (double) voteCounts.get(winner) / extractedAnswers.size();
log.info("Self-Consistency 投票结果:{} (置信度 {:.1f}%,{}/{} 票)",
winner, confidence * 100, voteCounts.get(winner), extractedAnswers.size());
return SelfConsistencyResult.<String>builder()
.answer(winner)
.confidence(confidence)
.voteCounts(voteCounts)
.allResponses(allResponses)
.sampleCount(extractedAnswers.size())
.build();
}
private String callModelWithCoT(String question, String cotPrompt) {
String fullPrompt = cotPrompt + "\n\n问题:" + question +
"\n\n请一步步思考,然后在最后一行以「最终答案:」开头给出答案。";
return chatClient.prompt()
.user(fullPrompt)
.call()
.content();
}
/**
* 提取最终答案
* 约定模型输出的最后一行以「最终答案:」开头
*/
private String extractFinalAnswer(String response) {
String[] lines = response.split("\n");
for (int i = lines.length - 1; i >= 0; i--) {
String line = lines[i].trim();
if (line.startsWith("最终答案:") || line.startsWith("Final Answer:")) {
return line.replaceFirst("最终答案:|Final Answer:", "").trim();
}
}
// 如果没找到标记,取最后一行非空内容
for (int i = lines.length - 1; i >= 0; i--) {
if (!lines[i].isBlank()) {
return lines[i].trim();
}
}
return response.trim();
}
}数值答案的聚合策略
对于数值类答案,纯投票可能不够好——5 次采样得到了 [100, 102, 98, 100, 500],最高频是 100,但中位数也是 100,500 是明显的异常值。对这类答案,我用了一个更鲁棒的策略:
@Service
public class NumericSelfConsistencyService {
private final ChatClient chatClient;
public NumericConsistencyResult aggregateNumericAnswers(String question,
String cotPrompt,
int samples) {
List<Double> numericAnswers = new ArrayList<>();
List<String> rawResponses = new ArrayList<>();
// 并行采样
List<CompletableFuture<String>> futures = IntStream.range(0, samples)
.mapToObj(i -> CompletableFuture.supplyAsync(
() -> callModel(question, cotPrompt)))
.collect(Collectors.toList());
for (CompletableFuture<String> future : futures) {
try {
String response = future.get(30, TimeUnit.SECONDS);
rawResponses.add(response);
extractNumericAnswer(response).ifPresent(numericAnswers::add);
} catch (Exception e) {
log.warn("采样失败", e);
}
}
if (numericAnswers.isEmpty()) {
throw new RuntimeException("无法提取数值答案");
}
// 计算统计量
double mean = numericAnswers.stream()
.mapToDouble(Double::doubleValue).average().orElse(0);
double median = calculateMedian(numericAnswers);
double stdDev = calculateStdDev(numericAnswers, mean);
// 用中位数作为最终答案(对异常值更鲁棒)
// 计算一致性分数:标准差越小,一致性越高
double consistencyScore = 1.0 / (1.0 + stdDev / (Math.abs(mean) + 1e-6));
return NumericConsistencyResult.builder()
.finalAnswer(median)
.mean(mean)
.stdDev(stdDev)
.consistencyScore(consistencyScore)
.allAnswers(numericAnswers)
.sampleCount(numericAnswers.size())
.build();
}
private Optional<Double> extractNumericAnswer(String response) {
// 提取最终答案中的数字
Pattern pattern = Pattern.compile("最终答案[::][\\s]*([\\d,.]+)");
Matcher matcher = pattern.matcher(response);
if (matcher.find()) {
try {
String numStr = matcher.group(1).replace(",", "");
return Optional.of(Double.parseDouble(numStr));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
return Optional.empty();
}
private double calculateMedian(List<Double> numbers) {
List<Double> sorted = new ArrayList<>(numbers);
Collections.sort(sorted);
int size = sorted.size();
if (size % 2 == 0) {
return (sorted.get(size/2 - 1) + sorted.get(size/2)) / 2.0;
} else {
return sorted.get(size/2);
}
}
private double calculateStdDev(List<Double> numbers, double mean) {
double variance = numbers.stream()
.mapToDouble(n -> Math.pow(n - mean, 2))
.average().orElse(0);
return Math.sqrt(variance);
}
private String callModel(String question, String cotPrompt) {
return chatClient.prompt()
.user(cotPrompt + "\n\n" + question + "\n\n请一步步计算,最终答案格式:最终答案:[数值]")
.call()
.content();
}
}带置信度自适应采样
固定 N 次采样是有浪费的。如果前 3 次采样已经全部一致,没必要再跑第 4、5 次。我实现了一个自适应采样策略:
@Service
@Slf4j
public class AdaptiveSelfConsistencyService {
private final ChatClient chatClient;
// 提前终止的置信度阈值
private static final double EARLY_STOP_THRESHOLD = 0.8;
// 最少采样次数
private static final int MIN_SAMPLES = 3;
// 最多采样次数
private static final int MAX_SAMPLES = 10;
public AdaptiveResult adaptiveVote(String question, String cotPrompt) {
List<String> answers = new ArrayList<>();
int totalSamples = 0;
while (totalSamples < MAX_SAMPLES) {
// 批量采样(每批 MIN_SAMPLES 次)
int batchSize = Math.min(MIN_SAMPLES, MAX_SAMPLES - totalSamples);
List<String> batchAnswers = executeBatch(question, cotPrompt, batchSize);
answers.addAll(batchAnswers);
totalSamples += batchSize;
// 检查是否可以提前终止
if (totalSamples >= MIN_SAMPLES) {
String currentWinner = getMajorityVote(answers);
double currentConfidence = getConfidence(answers, currentWinner);
log.debug("当前采样 {} 次,领先答案:{},置信度:{:.2f}",
totalSamples, currentWinner, currentConfidence);
if (currentConfidence >= EARLY_STOP_THRESHOLD) {
log.info("置信度达到 {:.2f},提前终止采样(共 {} 次)",
currentConfidence, totalSamples);
return AdaptiveResult.builder()
.answer(currentWinner)
.confidence(currentConfidence)
.actualSamples(totalSamples)
.earlyStopped(true)
.build();
}
}
}
String finalAnswer = getMajorityVote(answers);
double finalConfidence = getConfidence(answers, finalAnswer);
return AdaptiveResult.builder()
.answer(finalAnswer)
.confidence(finalConfidence)
.actualSamples(totalSamples)
.earlyStopped(false)
.build();
}
private List<String> executeBatch(String question, String cotPrompt, int size) {
return IntStream.range(0, size)
.parallel()
.mapToObj(i -> {
String response = chatClient.prompt()
.user(cotPrompt + "\n\n" + question)
.call()
.content();
return extractAnswer(response);
})
.collect(Collectors.toList());
}
private String getMajorityVote(List<String> answers) {
return answers.stream()
.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()))
.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("");
}
private double getConfidence(List<String> answers, String winner) {
long winCount = answers.stream().filter(a -> a.equals(winner)).count();
return (double) winCount / answers.size();
}
private String extractAnswer(String response) {
// 简化版:取最后一行有内容的文字
String[] lines = response.split("\n");
for (int i = lines.length - 1; i >= 0; i--) {
String line = lines[i].trim();
if (!line.isEmpty()) return line;
}
return response;
}
}成本 vs 准确率的权衡分析
这是生产环境最关键的决策。我做过一个简单的数值分析:
假设单次调用成本为 1 个单位,准确率基线为 P_0。
| 采样次数 | 成本倍数 | 准确率提升(估算) |
|---|---|---|
| 1 次 | 1x | P_0 |
| 3 次 | ~3x | P_0 + 8-12% |
| 5 次 | ~5x | P_0 + 12-18% |
| 10 次 | ~10x | P_0 + 15-20% |
| 20 次 | ~20x | P_0 + 17-22% |
从 1 次到 5 次,准确率提升是成本最高效的区间;5 次到 10 次,收益递减明显;10 次以上,基本边际效益很低了。所以大多数场景选 5 次是比较合理的平衡点。
什么场景值得用 Self-Consistency
值得用:
- 高价值决策(涉及金额大、风险高的场景)
- 模型准确率不够稳定的任务(标准差大)
- 需要对用户展示置信度的场景
- 多步推理类任务(数学计算、逻辑推断)
不值得用:
- 简单的事实查询
- 创意生成类任务(多样性本来就是优点)
- 成本敏感的高频调用场景
- 实时性要求极高(多次采样会增加延迟,即使并行也有上限)
一个实用的优化:答案归一化
投票的准确性很大程度上依赖于答案的归一化处理。如果模型第 1 次输出"是",第 2 次输出"对",第 3 次输出"Yes",这三个其实是同一个意思,但简单的字符串匹配会把它们当成三个不同答案。
@Component
public class AnswerNormalizer {
private static final Map<String, String> EQUIVALENCE_MAP = new HashMap<>();
static {
// 布尔类答案
EQUIVALENCE_MAP.put("是", "是");
EQUIVALENCE_MAP.put("对", "是");
EQUIVALENCE_MAP.put("yes", "是");
EQUIVALENCE_MAP.put("correct", "是");
EQUIVALENCE_MAP.put("true", "是");
EQUIVALENCE_MAP.put("正确", "是");
EQUIVALENCE_MAP.put("否", "否");
EQUIVALENCE_MAP.put("不是", "否");
EQUIVALENCE_MAP.put("no", "否");
EQUIVALENCE_MAP.put("incorrect", "否");
EQUIVALENCE_MAP.put("false", "否");
EQUIVALENCE_MAP.put("错误", "否");
// 风险级别
EQUIVALENCE_MAP.put("高风险", "高风险");
EQUIVALENCE_MAP.put("high risk", "高风险");
EQUIVALENCE_MAP.put("high", "高风险");
EQUIVALENCE_MAP.put("h", "高风险");
EQUIVALENCE_MAP.put("中风险", "中风险");
EQUIVALENCE_MAP.put("medium risk", "中风险");
EQUIVALENCE_MAP.put("medium", "中风险");
EQUIVALENCE_MAP.put("m", "中风险");
EQUIVALENCE_MAP.put("低风险", "低风险");
EQUIVALENCE_MAP.put("low risk", "低风险");
EQUIVALENCE_MAP.put("low", "低风险");
EQUIVALENCE_MAP.put("l", "低风险");
}
public String normalize(String answer) {
if (answer == null) return "";
String cleaned = answer.toLowerCase().trim()
.replaceAll("[。!?.!?]$", "") // 去掉句末标点
.trim();
return EQUIVALENCE_MAP.getOrDefault(cleaned, answer.trim());
}
/**
* 对数字答案做归一化:去除单位、货币符号等
*/
public String normalizeNumeric(String answer) {
// 去掉货币符号、单位等
return answer.replaceAll("[¥$€£,,元万亿千百]", "").trim();
}
}总结
Self-Consistency 是一个简单但有效的准确率提升手段,核心就是:多次采样 + 多数投票。
工程上需要考虑的几个点:
- Temperature 要高(0.7-1.0),保证路径多样性
- 答案提取要做归一化,避免等价答案分票
- 用自适应采样减少不必要的调用
- 对数值类答案用中位数而不是众数,对异常值更鲁棒
- 只在准确率提升价值高于成本增加的场景使用
Self-Consistency 最适合的场景是:有明确正确答案、需要多步推理、准确率波动较大的任务。对于创意类任务,它没有意义,甚至可能压制多样性。
