跳转至

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。