Voting:多份候选互相校准
同一个模型,同一个问题,每次回答都可能不同。旅游助手这次把茶叶博物馆放在下午,下次又换成龙井村。
如果答案短、可以标准化,最便宜的稳定器就是多采几次、投票。
一句话
Voting 把单次采样变成"多次采样 + 标准化 + 选赢家",用成本换低方差。
它修什么失败
| 问题 | 表面看起来 | 实际风险 |
|---|---|---|
| 单次采样带随机性 | 有时很好 | 同任务结果不稳定 |
| 第一个候选直接用 | 快 | 可能错过多数答案 |
| 长文本直接投票 | 听着民主 | 没有可比的答案键 |
它引入什么复杂度
| 谁 | 负责什么 |
|---|---|
| 模型 | 生成多个候选 |
| Normalizer | 把候选转成可比较的键 |
| Voter / Judge | 选赢家 |
| Python | 控制采样次数、trace 候选 |
和其他模式的关系
flowchart LR
VOT["Voting"] -.->|"需要事实检查"| COVE["CoVe"]
VOT -.->|"需要修订"| MC["Maker-Checker"]
VOT -.->|"候选需要多步探索"| LATS["LATS"]
- Maker-Checker:Voting 选最频繁的答案;Maker-Checker 通过反馈修订。如果候选质量都不够好,Voting 选不出好的,需要 Maker-Checker。
- CoVe:Voting 处理随机性,不处理系统性事实错误。如果多数答案都错,需要 CoVe。
- LATS:候选需要多步探索和评分时,Voting 不够,需要 LATS 的搜索树。
完整实现:旅游助手下午安排投票
"""voting_travel.py — 旅游下午行程的投票稳定"""
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
# ── 极简运行时 ──────────────────────────────────────────
@dataclass(frozen=True)
class Message:
role: str
content: str
class MockLLM:
def __init__(self, responses: list[str]):
self._responses = list(responses)
self._idx = 0
def complete(self, messages: list[Message]) -> str:
if self._idx >= len(self._responses):
raise RuntimeError("MockLLM 回复用完了")
out = self._responses[self._idx]
self._idx += 1
return out
# ── Voting 核心逻辑 ─────────────────────────────────────
def majority_vote(items: list[str]) -> str:
"""选出现次数最多的,平局选第一个出现的。"""
if not items:
raise ValueError("候选列表不能为空")
counts = Counter(items)
best_count = max(counts.values())
for item in items:
if counts[item] == best_count:
return item
return items[0]
def self_consistency(
model: MockLLM,
messages: list[Message],
*,
n: int = 5,
normalize=None,
) -> str:
"""
对同一个 prompt 采样 n 次,投票。
normalize 可选:把原始回复转成可比较的键。
"""
raw: list[str] = []
for _ in range(n):
raw.append(model.complete(messages))
print(f"[采样] 共 {n} 个候选:")
for i, r in enumerate(raw):
print(f" 候选 {i + 1}: {r}")
if normalize is None:
winner = majority_vote(raw)
else:
normalized = [normalize(x) for x in raw]
winner_norm = majority_vote(normalized)
# 返回赢家对应的原始文本
for r, norm in zip(raw, normalized):
if norm == winner_norm:
winner = r
break
else:
winner = raw[0]
return winner
# ── 运行 ────────────────────────────────────────────────
def main() -> None:
# 旅游场景:下午安排室内还是户外?
# 模型 5 次回答,3 次选茶叶博物馆,2 次选龙井村
model = MockLLM([
"茶叶博物馆",
"龙井村",
"茶叶博物馆",
"茶叶博物馆",
"龙井村",
])
messages = [
Message(role="user", content="杭州下午下雨,推荐一个景点(只回答名字)")
]
winner = self_consistency(
model,
messages,
n=5,
normalize=lambda s: s.strip(),
)
print(f"\n投票结果:{winner}")
if __name__ == "__main__":
main()
运行日志
[采样] 共 5 个候选:
候选 1: 茶叶博物馆
候选 2: 龙井村
候选 3: 茶叶博物馆
候选 4: 茶叶博物馆
候选 5: 龙井村
投票结果:茶叶博物馆
茶叶博物馆出现 3 次,胜出。
调试:trace 里看什么
| 要看的 | 怎么看 | 说明什么 |
|---|---|---|
| 候选分布 | 统计每个标准化键的出现次数 | 如果均匀分布(如 A:2, B:2, C:1),赢家不可靠 |
| 标准化后是否重复 | 对比 raw 和 normalized | "茶叶博物馆" vs "中国茶叶博物馆" 如果算两个键,投票失效 |
| 没有多数 | 每个候选只出现一次 | 增加 n 或改用 Judge / Maker-Checker |
| 多数答案本身是错的 | 3/5 都说了同一个假信息 | 系统性偏差,Voting 救不了,需要 CoVe 或工具 |
工程笔记
成本公式
总调用数 = n(采样次数)
延迟 ≈ max(单次延迟)(如果并行)或 n × 单次延迟(串行)
n=5 意味着 5 倍 token 消耗。只在短答案、高价值决策上值得。
常见坑
| 坑 | 现象 | 修法 |
|---|---|---|
| 没有多数 | A/B/C 平局 | 加 Judge 或退回 Maker-Checker |
| 无法标准化 | 长文本每条都不同 | 用结构化输出提取短键 |
| 系统性偏差 | 多数答案照样是错的 | 加检索、工具或 CoVe |
| 高成本 | n=5 就是 5 次调用 | 只把难样本路由到 Voting |
什么时候用
- 答案可以标准化成短键。
- 多采样的成本可以接受。
- 需要的是低随机性,不是外部事实。
- 失败来自偶发方差,不是系统性无知。
什么时候别用
- 需要检索或工具才能拿到事实。
- 长文本无法比较。
- 模型有系统性偏差。
- 延迟和成本紧张。
读完以后
Voting 处理随机性,不处理缺知识。
如果需要事实检查,读 CoVe。 如果需要基于反馈的修订,读 Maker-Checker。 如果需要在多步探索中选最佳路径,读 LATS。