Skip to content

解码采样与稳定性测试

企业里最常见的一句抱怨是"这个 case 明明昨天能过,今天怎么又不行了"。很多时候,问题不在模型突然失忆,而在解码策略让概率分布的微小变化被一路放大。你不能把"不稳定"都甩给随机性,而要能拆成温度、采样、结构化约束、服务端实现和模型版本共同作用的结果。

教学导读

**定位:**这一章专门回答"不稳定到底是模型随机、参数配置、结构化约束,还是服务实现差异造成的"。 **前置依赖:**建议已经理解 logits、softmax 和基本生成流程。 **适用场景:**稳定性回归、JSON/schema 输出、低温事实问答、创意任务评测、Agent 规划输出。 **学完产出:**你应该能把 deterministic 任务和允许多样性的任务分开设计评测,不再用同一套断言硬压所有场景。

先给一个最重要的判断。

同一模型同一提示词并不天然等于同一输出。即使 temperature 设置很低,模型服务端调度、模型微版本、结构化输出策略、seed 是否固定、上游上下文是否发生轻微变化,都可能让生成分支改变。测试的目标不是追求绝对不变,而是把"允许波动的范围"和"不可接受的漂移"区分开。

01. 从 logits 到概率:模型每一步都在做什么

Transformer 最后输出的是 logits,一组对下一个 token 候选的未归一化分数。经过 softmax 后,才会变成概率分布。理解这个过程是理解所有采样策略的基础。

Softmax 的数学定义

P(token_i) = exp(z_i / T) / Σ_j exp(z_j / T) 其中 z_i 是第 i 个 token 的 logit 值,T 是温度参数。这个公式有几个重要性质:

  • **归一化:**所有概率之和严格等于 1。
  • **单调性:**logit 越大的 token,概率越高。
  • **温度控制:**T 越小,概率分布越尖锐(头部集中);T 越大,概率分布越平坦(更均匀)。
  • **指数放大:**即使 logit 差距很小,经过指数运算后差距会被放大。
假设 3 个候选 token 的 logits 为:
A = 4.0
B = 3.5
C = 1.0

T = 1.0 (默认温度):
  exp(4.0) = 54.60
  exp(3.5) = 33.12
  exp(1.0) =  2.72
  sum = 90.44
  P(A) = 0.604,  P(B) = 0.366,  P(C) = 0.030

T = 0.5 (低温):
  exp(4.0/0.5) = exp(8.0) = 2980.96
  exp(3.5/0.5) = exp(7.0) = 1096.63
  exp(1.0/0.5) = exp(2.0) =    7.39
  sum = 4084.98
  P(A) = 0.730,  P(B) = 0.268,  P(C) = 0.002

T = 2.0 (高温):
  exp(4.0/2.0) = exp(2.0) = 7.39
  exp(3.5/2.0) = exp(1.75)= 5.75
  exp(1.0/2.0) = exp(0.5) = 1.65
  sum = 14.79
  P(A) = 0.500,  P(B) = 0.389,  P(C) = 0.111

观察:
- 低温让 A 的优势更加明显(0.604 → 0.730)
- 高温让分布更平坦(C 从 0.030 → 0.111)
- 这就是为什么创意任务用高温、事实问答用低温

01.5 手算一次 softmax:为什么小差距会被放大

logits = [2, 1, 0]
exp(logits) = [e², e¹, e⁰] ≈ [7.39, 2.72, 1.00]
sum = 11.11

softmax ≈ [0.665, 0.245, 0.090]

验证:0.665 + 0.245 + 0.090 = 1.000 ✓

注意这里原始差值只有 1,但归一化后第一个 token 的优势已经很明显了。生成时只要前两名候选靠得很近,一点微扰就会影响最终分支。

实际词表规模下的 softmax

真实模型的词表通常有 30K-100K+ 个 token。在这种规模下,softmax 的计算有几个关键特点:

  • 绝大多数 token 的概率接近于 0,只有少数 token 有显著概率。
  • 概率分布的形态(尖锐 vs 平坦)直接决定了生成的稳定性。
  • 当多个 token 的 logit 非常接近时,采样结果高度不稳定。

01.6 Softmax 的数值稳定性问题

直接计算 exp(z_i) 在实际中会遇到数值溢出问题。当 logits 值很大时,exp() 会溢出到 infinity:

python
import numpy as np

def naive_softmax(logits):
    """不稳定的实现 — 会溢出"""
    exp_logits = np.exp(logits)
    return exp_logits / np.sum(exp_logits)

def stable_softmax(logits):
    """数值稳定的实现"""
    # 减去最大值,数学上等价但避免溢出
    shifted = logits - np.max(logits)
    exp_logits = np.exp(shifted)
    return exp_logits / np.sum(exp_logits)

# 数学证明为什么减去最大值是等价的:
# softmax(z_i - c) = exp(z_i - c) / Σ exp(z_j - c)
#                  = exp(z_i) · exp(-c) / Σ exp(z_j) · exp(-c)
#                  = exp(z_i) / Σ exp(z_j)
#                  = softmax(z_i)
#
# 取 c = max(z) 保证所有指数的输入 ≤ 0,
# 因此 exp 值 ≤ 1,不会溢出

# 演示
logits = np.array([1000.0, 999.5, 998.0])

try:
    print(naive_softmax(logits))  # 可能输出 [nan, nan, nan]
except:
    print("溢出!")

print(stable_softmax(logits))    # [0.622, 0.378, 0.000] ← 正确

为什么测试人员需要知道这个?

因为不同推理框架对 softmax 的实现精度可能不同。FP16 和 FP32 的精度差异在 logits 边界 case 上可能导致不同的采样结果。如果你在切换推理框架后发现"同一个 case 表现不一致",数值精度是一个可能的排查方向。

02. Temperature、Top-k、Top-p 分别在改变什么

参数作用机制数学表达测试影响
temperature (T)缩放 logits 后再做 softmaxP_i = exp(z_i/T) / Σ exp(z_j/T)影响多样性与稳定性平衡
top-k只保留概率最高的前 k 个 token截断后重新归一化降低尾部噪声,但可能压制合理长尾
top-p (nucleus)保留累计概率达到 p 的最小集合动态截断,自适应调整候选数更灵活,但边界变化敏感
presence penalty对已出现 token 的 logit 减固定值z_i -= α · 1(token_i ∈ context)抑制重复,影响长文生成
frequency penalty按出现次数递减 logitz_i -= β · count(token_i)更强的重复抑制

Temperature 的完整推导

# Temperature 对概率分布的精确影响

import numpy as np

def softmax_with_temperature(logits, temperature):
    """带温度的 softmax"""
    scaled = logits / temperature
    shifted = scaled - np.max(scaled)
    exp_vals = np.exp(shifted)
    return exp_vals / np.sum(exp_vals)

logits = np.array([4.0, 3.5, 3.0, 1.0, 0.5])
tokens = ['A', 'B', 'C', 'D', 'E']

print("Temperature 对概率分布的影响:")
print(f"{'T':>6} | {'A':>8} {'B':>8} {'C':>8} {'D':>8} {'E':>8} | {'熵':>6}")

for T in [0.1, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 5.0]:
    probs = softmax_with_temperature(logits, T)
    entropy = -np.sum(probs * np.log2(probs + 1e-10))
    print(f"{T:>6.1f} | {probs[0]:>8.4f} {probs[1]:>8.4f} "
          f"{probs[2]:>8.4f} {probs[3]:>8.4f} {probs[4]:>8.4f} "
          f"| {entropy:>6.3f}")

# 输出(近似):
# T=0.1: A 几乎独占 (≈1.000),熵 ≈ 0
# T=0.5: A 仍然主导 (≈0.73),熵 ≈ 1.1
# T=1.0: 分布较均匀 (A≈0.40),熵 ≈ 1.7
# T=5.0: 几乎均匀分布,熵 ≈ 2.2

# 信息熵的含义:
# 熵越低 → 分布越确定 → 生成越稳定
# 熵越高 → 分布越均匀 → 生成越多样/不稳定

2.1 为什么 top-p 在边缘 case 上特别敏感

如果前几个 token 的概率差距很小,那么一点点 logits 波动就会改变"累计概率到 p 为止"包含哪些 token,于是整个候选集合发生变化。后续生成路径也就一起变了。

02.5 一个 top-p 截断例子

候选 token 概率(已按概率降序排列):
A = 0.40
B = 0.28
C = 0.18
D = 0.09
E = 0.05

top-p = 0.7 的截断过程:
  累计: A(0.40) → A+B(0.68) → A+B+C(0.86)
  0.86 ≥ 0.7 → 停在 C
  采样集合: {A, B, C}
  重新归一化: P(A)=0.465, P(B)=0.326, P(C)=0.209

如果 logits 微扰后概率变成:
A = 0.34
B = 0.22
C = 0.12
D = 0.18  ← 注意 D 和 C 互换了位置
E = 0.14

top-p = 0.7 的截断:
  排序后: A(0.34) → B(0.22) → D(0.18) → E(0.14) → C(0.12)
  累计: 0.34 → 0.56 → 0.74
  0.74 ≥ 0.7 → 停在 D
  采样集合: {A, B, D}  ← C 被排除,D 被纳入

微扰的后果:
1. 候选集合从 {A,B,C} 变成了 {A,B,D}
2. 后续生成可能完全不同
3. 这种"边界效应"在 top-p 附近尤为明显

测试启发。

不要只在一个参数组合下做回归。至少要覆盖:低温 deterministic 组合、中温默认组合、结构化输出专用组合。否则你测到的只是某一个狭窄模式。

02.6 采样参数的组合效应

实际使用中,temperature、top-k、top-p 通常会组合使用。理解它们的组合效应很重要:

python
def combined_sampling(logits, temperature=1.0, top_k=0, top_p=1.0):
    """完整的采样流程"""
    
    # Step 1: Temperature scaling
    scaled_logits = logits / temperature
    
    # Step 2: Top-k 截断
    if top_k > 0:
        top_k_indices = np.argsort(scaled_logits)[-top_k:]
        mask = np.full_like(scaled_logits, -np.inf)
        mask[top_k_indices] = scaled_logits[top_k_indices]
        scaled_logits = mask
    
    # Step 3: Softmax
    probs = stable_softmax(scaled_logits)
    
    # Step 4: Top-p (nucleus) 截断
    if top_p < 1.0:
        sorted_indices = np.argsort(probs)[::-1]
        sorted_probs = probs[sorted_indices]
        cumsum = np.cumsum(sorted_probs)
        
        # 找到累计概率超过 top_p 的位置
        cutoff_idx = np.searchsorted(cumsum, top_p) + 1
        
        # 将超出范围的概率置零
        mask_indices = sorted_indices[cutoff_idx:]
        probs[mask_indices] = 0
        
        # 重新归一化
        probs = probs / probs.sum()
    
    # Step 5: 按概率采样
    token_idx = np.random.choice(len(probs), p=probs)
    return token_idx, probs[token_idx]

常用参数组合推荐

场景temperaturetop-ptop-k理由
事实问答0.0-0.21.01追求一致性,greedy 或接近 greedy
结构化输出0.0-0.11.01格式优先于多样性
客服对话0.3-0.50.90需要一定自然度但控制风险
创意写作0.7-1.00.9540鼓励多样性但避免噪声
代码生成0.2-0.40.950准确性优先,适度多样
Agent 规划0.3-0.50.90步骤合理性比创意更重要

03. Greedy 和 Sampling:稳定不等于更好

Greedy Decoding

每一步都选当前最高概率 token。优点是更稳定、可复现,缺点是容易模板化、陷入重复循环、局部最优。 数学表达:
x_t = argmax P(x | x_{<t})

Sampling

按概率分布随机抽样,输出更自然更多样,但回归更难。多次运行可能产生不同结果。 数学表达:
x_t ~ P(x | x_{<t})

测试启发

事实问答、结构化抽取适合更低温;创意写作、文案生成不该被强行按确定性标准评判。测试策略应匹配任务特性。

3.1 一个常见误区

把 temperature 设成 0 并不总能换来"完全一致输出"。不同服务实现里,temperature 为 0 往往只是近似 greedy;再加上后端并发、模型微调版本、候选 token 分数接近等因素,仍可能看到波动。

# temperature=0 的不同实现方式
def greedy_decode(logits):
    """纯 greedy — 完全确定性"""
    return np.argmax(logits)

def near_greedy_decode(logits, epsilon=1e-8):
    """某些框架的 temperature≈0 实现"""
    # 可能因为浮点精度导致并列最高分
    probs = stable_softmax(logits / epsilon)
    # 当多个 token 概率相等时,不同实现可能选不同的
    return np.random.choice(len(probs), p=probs)

# 问题场景:
logits = np.array([5.0000001, 5.0000000, 3.0])
# FP32 下可能看到差异,FP16 下可能完全相等
# 不同 GPU kernel 的归约顺序也可能影响结果

03.5 Beam Search:在质量和效率之间折中

Beam Search 不是只选一条路径(greedy),也不是纯随机采样,而是同时维护 B 条最优候选路径:

Beam Search 工作过程(beam_size = 3):

Step 1: 从起始 token 扩展
  候选 1: "我" (score=-0.3)
  候选 2: "该" (score=-0.8)
  候选 3: "这" (score=-1.1)

Step 2: 每条候选再扩展,保留 top-3
  候选 1: "我认为" (score=-0.7)
  候选 2: "我觉得" (score=-0.9)
  候选 3: "该订单" (score=-1.2)

Step 3: 继续扩展...
  最终选择累计 score 最高的完整序列

优点:
- 比 greedy 找到更优的全局序列
- 比纯采样更稳定

缺点:
- 计算成本是 greedy 的 B 倍
- 倾向生成"安全但无聊"的输出
- 在开放式任务中效果不如采样

Score(y) = Σ log P(y_t | y_{<t}, x) + length_penalty(|y|) Length penalty 用于防止 beam search 偏好短序列(因为短序列的概率乘积更大)。常见形式是 ((5 + |y|) / 6)^α,其中 α 通常取 0.6-1.0。

03.6 高级解码策略

在选择下一个 token 时,不仅考虑概率,还考虑与已生成内容的"区别度"。减少重复和模板化。 score = (1-α)·P(x) + α·max_sim(x, context)

Typical Sampling

不是选概率最高的 token,而是选"信息量最典型"的 token。基于 entropy 做截断,排除概率过高(无聊)和过低(噪声)的 token。

Mirostat

自适应温度调整策略。根据生成过程中的实际困惑度(perplexity)动态调整截断阈值,保持一致的"惊喜度"。

Min-P Sampling

设定最小概率阈值。只保留概率 ≥ min_p × max_prob 的 token。比 top-p 更直觉化,不需要计算累计概率。

# Min-P Sampling 实现
def min_p_sampling(logits, min_p=0.1, temperature=1.0):
    """
    Min-P: 保留概率 >= min_p * max_prob 的 token
    比 top-p 更直觉化
    """
    probs = stable_softmax(logits / temperature)
    max_prob = np.max(probs)
    threshold = min_p * max_prob
    
    # 只保留超过阈值的 token
    mask = probs >= threshold
    filtered_probs = probs * mask
    
    # 重新归一化
    filtered_probs = filtered_probs / filtered_probs.sum()
    
    return np.random.choice(len(filtered_probs), p=filtered_probs)

# Typical Sampling 实现
def typical_sampling(logits, typical_p=0.95, temperature=1.0):
    """
    Typical Sampling: 选择信息量最"典型"的 token
    """
    probs = stable_softmax(logits / temperature)
    
    # 每个 token 的信息量(负对数概率)
    info = -np.log(probs + 1e-10)
    
    # 平均信息量(熵)
    entropy = np.sum(probs * info)
    
    # 每个 token 的信息量与熵的偏差
    deviation = np.abs(info - entropy)
    
    # 按偏差从小到大排序(越接近平均信息量越"典型")
    sorted_indices = np.argsort(deviation)
    sorted_probs = probs[sorted_indices]
    
    # 累计概率截断
    cumsum = np.cumsum(sorted_probs)
    cutoff = np.searchsorted(cumsum, typical_p) + 1
    
    # 只保留典型的 token
    mask = np.zeros_like(probs)
    mask[sorted_indices[:cutoff]] = probs[sorted_indices[:cutoff]]
    mask = mask / mask.sum()
    
    return np.random.choice(len(mask), p=mask)

04. 为什么 JSON / Schema 输出更像"约束解码"而不是普通生成

当你要求模型输出 JSON Schema,系统通常会在解码层做额外约束,限制非法 token 出现。这能显著提高结构完整率,但也会引入新的表现差异:

  • 候选 token 空间被裁剪,生成更保守。
  • 复杂 schema 会增加推理负担和上下文预算消耗。
  • 如果输出空间不足,约束也救不了截断。
  • 字段之间存在依赖时,局部失误会传递成整体格式失败。
一个非常典型的结构化回归:
旧版本 schema:字段 6 个,嵌套 1 层
新版本 schema:字段 14 个,嵌套 3 层,包含枚举和数组

结果:
1. 解析成功率从 99.2% 下降到 93.8%
2. TTFT 从 200ms 增加到 450ms
3. 长上下文时尾部字段缺失率从 2% 上升到 12%

所以 schema 设计本身就应该被纳入性能与稳定性评测。

04.5 约束解码的实现原理

约束解码的核心思想是:在每一步生成时,根据当前状态限制合法的下一个 token 集合。

# 约束解码的简化实现
class JsonConstrainedDecoder:
    """基于有限状态机的 JSON 约束解码器"""
    
    # JSON 生成的状态机
    STATES = {
        'START':       {'next': ['{']},
        'OBJECT_OPEN': {'next': ['"', '}']},
        'KEY':         {'next': [':']},
        'COLON':       {'next': ['"', '{', '[', 'true', 
                                 'false', 'null', '0-9']},
        'VALUE':       {'next': [',', '}', ']']},
        'COMMA':       {'next': ['"']},
    }
    
    def __init__(self, schema: dict):
        self.schema = schema
        self.state_stack = ['START']
        self.required_keys = schema.get('required', [])
        self.generated_keys = []
    
    def get_allowed_tokens(self, vocab: list[str]) -> list[int]:
        """根据当前状态返回允许的 token ID 列表"""
        current_state = self.state_stack[-1]
        allowed_patterns = self.STATES.get(current_state, {}).get('next', [])
        
        allowed_ids = []
        for i, token in enumerate(vocab):
            if self._matches_any_pattern(token, allowed_patterns):
                allowed_ids.append(i)
        
        return allowed_ids
    
    def apply_constraint(self, logits: np.ndarray, 
                         vocab: list[str]) -> np.ndarray:
        """将不合法的 token 的 logit 设为 -inf"""
        allowed = set(self.get_allowed_tokens(vocab))
        
        constrained_logits = logits.copy()
        for i in range(len(logits)):
            if i not in allowed:
                constrained_logits[i] = -np.inf
        
        return constrained_logits

约束解码对性能的影响。

约束解码会减少每一步的有效候选 token 数量,这通常使生成更加确定(因为概率集中在更少的 token 上)。但当约束过于严格时,可能强制模型选择不符合其"意图"的 token,导致语义质量下降。这是结构完整性和语义质量之间的 trade-off。

主流约束解码框架对比

框架方法优点缺点
OpenAI structured output服务端约束用户无需关心实现黑盒,难以调试
Outlines正则 FSM 约束灵活、开源复杂 schema 性能开销大
Guidance模板化生成直觉化编程模型学习曲线较陡
LMQL查询语言声明式约束框架绑定较深
vLLM guided decode引擎内置约束性能好功能还在完善中

05. "Determinism" 为什么常常是幻觉

企业里最危险的不是随机性本身,而是误以为系统完全确定。以下因素都会造成伪随机或伪确定:

  1. **模型微版本:**模型服务商在后台切了微版本,权重微调但没有通知。
  2. **检索漂移:**上游 retrieval 顺序轻微变化,影响上下文内容。
  3. **Prompt 差异:**工具 schema 或 system prompt 有微小文本差异。
  4. **Logits 接近:**候选 logits 极其接近,任何微扰都会改变分支。
  5. **服务端实现:**流式实现或并发策略改变了解码路径。
  6. **硬件差异:**不同 GPU 型号的浮点运算精度略有不同。
  7. **Batch 效应:**Continuous batching 中与其他请求的组合可能影响数值精度。

05.5 不确定性来源的系统分析

不确定性的分层分析:

Layer 0: 硬件层
├── GPU 浮点精度 (FP16 vs FP32 vs BF16)
├── 不同 GPU kernel 的归约顺序
├── Multi-GPU 通信的非确定性
└── 内存分配策略

Layer 1: 推理引擎层
├── Attention 实现 (FlashAttention v1 vs v2 vs 标准)
├── KV Cache 管理策略
├── Continuous batching 的请求组合
├── Prefix caching 命中率差异
└── Speculative decoding 的接受/拒绝随机性

Layer 2: 采样层
├── Temperature / top-p / top-k 参数
├── Random seed 是否固定
├── Repetition penalty 实现差异
└── 结构化约束解码的不同实现

Layer 3: 应用层
├── Prompt 模板的微小变化
├── RAG 检索结果的顺序/内容变化
├── 多轮对话历史的截断策略
├── System prompt 更新
└── 工具描述文本变化

Layer 4: 运营层
├── 模型服务商后台更新
├── API 版本升级
├── 负载均衡路由到不同副本
└── 区域/数据中心切换
任务类型推荐策略测试口径可接受的波动
结构化抽取T=0 + schema 约束字段正确率和完整率< 2% 波动
客服问答T=0.3 + top-p=0.9一致性、helpfulness、拒答边界< 10% 波动
创意生成T=0.8 + top-p=0.95质量分布,不追求逐字一致允许高波动
Agent 规划T=0.3 + 轨迹断言步骤合法性和成功率< 5% 波动
安全检测T=0 + 多次投票一致性拒绝率0% 允许漏放

06. 稳定性测试设计清单

  1. 对核心 case 跑多次(推荐 10-30 次),统计通过率,不要只跑一次。
  2. 把任务分成"要求确定"和"允许多样"两类,分别设断言。
  3. 参数变更时,同时观察通过率、平均分、方差和失败模式分布。
  4. 对结构化输出做"接近上限 + 长 schema + 多轮 history"联动测试。
  5. 把 seed、temperature、top-p、模型版本写入评测元数据。
  6. 对高风险场景实施多数投票(majority voting)。
  7. 建立波动基线,区分"正常波动"和"异常漂移"。

06.5 稳定性测试框架实现

python
import numpy as np
from collections import Counter
from scipy import stats

class StabilityTestRunner:
    """稳定性回归测试框架"""
    
    def __init__(self, model_client, judge, n_runs=10):
        self.model_client = model_client
        self.judge = judge
        self.n_runs = n_runs
    
    def run_stability_test(self, case: dict, 
                           params: dict) -> StabilityReport:
        """对单个 case 运行多次,分析稳定性"""
        results = []
        outputs = []
        
        for i in range(self.n_runs):
            output = self.model_client.generate(
                input_text=case["input"],
                context=case.get("context", ""),
                **params
            )
            judgment = self.judge.evaluate(
                output=output,
                expected=case["expected"],
                rubric=case.get("rubric", None)
            )
            results.append(judgment)
            outputs.append(output)
        
        return self._analyze(case, results, outputs, params)
    
    def _analyze(self, case, results, outputs, params) -> StabilityReport:
        scores = [r.score for r in results]
        passed = [r.passed for r in results]
        
        pass_rate = sum(passed) / len(passed)
        mean_score = np.mean(scores)
        std_score = np.std(scores)
        
        # 输出一致性分析
        output_similarity = self._output_similarity(outputs)
        
        # 失败模式分析
        failure_modes = Counter(
            r.failure_type for r in results if not r.passed
        )
        
        # 稳定性评级
        stability_grade = self._grade_stability(
            pass_rate, std_score, case.get("task_type", "general")
        )
        
        return StabilityReport(
            case_id=case["id"],
            n_runs=self.n_runs,
            pass_rate=pass_rate,
            mean_score=mean_score,
            std_score=std_score,
            output_similarity=output_similarity,
            failure_modes=dict(failure_modes),
            stability_grade=stability_grade,
            params=params,
            ci_95=self._confidence_interval(scores)
        )
    
    def _output_similarity(self, outputs: list[str]) -> float:
        """计算多次输出之间的平均相似度"""
        if len(outputs) <= 1:
            return 1.0
        
        similarities = []
        for i in range(len(outputs)):
            for j in range(i+1, len(outputs)):
                sim = self._text_similarity(outputs[i], outputs[j])
                similarities.append(sim)
        
        return np.mean(similarities)
    
    def _text_similarity(self, a: str, b: str) -> float:
        """基于 token 重叠的简单相似度"""
        tokens_a = set(a.split())
        tokens_b = set(b.split())
        if not tokens_a and not tokens_b:
            return 1.0
        intersection = tokens_a & tokens_b
        union = tokens_a | tokens_b
        return len(intersection) / len(union) if union else 0.0
    
    def _confidence_interval(self, scores, confidence=0.95):
        """Bootstrap 置信区间"""
        n_bootstrap = 1000
        means = []
        for _ in range(n_bootstrap):
            sample = np.random.choice(scores, len(scores), replace=True)
            means.append(np.mean(sample))
        
        lower = np.percentile(means, (1-confidence)/2 * 100)
        upper = np.percentile(means, (1+confidence)/2 * 100)
        return (lower, upper)
    
    def _grade_stability(self, pass_rate, std, task_type):
        """根据任务类型给出稳定性评级"""
        if task_type in ("structured", "factual"):
            # 确定性任务:高标准
            if pass_rate >= 0.95 and std < 0.1:
                return "A"
            elif pass_rate >= 0.85:
                return "B"
            elif pass_rate >= 0.70:
                return "C"
            return "D"
        else:
            # 创意/对话任务:更宽容
            if pass_rate >= 0.80 and std < 0.3:
                return "A"
            elif pass_rate >= 0.65:
                return "B"
            return "C"

class MajorityVotingJudge:
    """多数投票 Judge — 用于高风险场景"""
    
    def __init__(self, model_client, n_votes=5):
        self.model_client = model_client
        self.n_votes = n_votes
    
    def evaluate(self, input_text, context="", **params):
        """生成多次,取多数结果"""
        outputs = []
        for _ in range(self.n_votes):
            output = self.model_client.generate(
                input_text=input_text,
                context=context,
                **params
            )
            outputs.append(output)
        
        # 对于分类任务,直接投票
        votes = Counter(outputs)
        winner, count = votes.most_common(1)[0]
        confidence = count / self.n_votes
        
        return {
            "result": winner,
            "confidence": confidence,
            "agreement_rate": confidence,
            "n_unique_outputs": len(votes),
            "all_outputs": outputs
        }

07. 企业实战案例

7.1 案例:金融报告生成的稳定性治理

某金融科技公司使用 LLM 生成投资研究报告摘要,发现以下问题:

问题现象:
- 同一份年报,生成 10 次摘要,关键财务数字一致率仅 72%
- 有时候"净利润增长 15%",有时候"净利润增长 14.8%"
- 结论性判断(买入/持有/卖出)的一致率仅 60%

根因分析:
1. Temperature 使用了默认值 1.0(太高)
2. 没有对数字提取使用结构化输出
3. 年报 PDF 解析结果有微小差异影响上下文
4. 模型对"增长百分比"的四舍五入方式不一致

解决方案:
1. 数字提取部分使用 T=0 + JSON Schema 约束
   → 数字一致率从 72% 提升到 98%

2. 结论判断使用 T=0.2 + 5 次多数投票
   → 结论一致率从 60% 提升到 89%

3. 年报解析结果做哈希校验,确保上下文一致
   → 消除了上游漂移导致的误差

4. 建立稳定性回归基线
   → 每次模型更新前跑 50 份历史年报对比

7.2 案例:客服 Agent 的稳定性监控

监控体系设计:

1. 每日自动回归(100 条核心 case,每条跑 5 次)
   → 计算通过率、方差和失败模式分布

2. 波动报警规则:
   - 通过率下降 > 5%    → P1 告警
   - 方差上升 > 50%     → P2 告警
   - 新失败模式出现     → P2 告警
   - 安全类 case 任何失败 → P0 告警

3. 月度稳定性报告:
   - 各 slice 的通过率趋势
   - 参数敏感性分析(T/top-p 变化的影响)
   - 模型版本切换前后的对比
   - 高波动 case 的根因分析

7.3 课堂练习

  1. 给一个必须稳定输出 JSON 的任务,说明你会怎么设置 temperature、top-p,以及为什么。并设计一个测试来验证你的参数选择。
  2. 如果某个事实问答在 10 次回归里 7 次通过、3 次失败,计算 95% 置信区间,并解释这个结果意味着什么。
  3. 设计一组样本,专门观察 top-p 从 0.9 调到 0.7 后失败模式如何变化。需要包含至少 3 类不同的任务。
  4. 实现一个多数投票 Judge,说明在什么场景下使用、投票次数如何确定。

7.4 参考答案要点

  • 结构化任务通常倾向 T=0 或 T=0.1,top-p=1.0(无截断),并结合 schema 约束解码。目标是稳定满足格式而不是追求多样性。验证方式:跑 20 次计算格式合规率和字段一致率。
  • 10 次中 3 次失败,通过率 p=0.7。95% 置信区间用 Wilson 区间:(0.395, 0.904)。区间很宽说明样本量不足,需要跑更多次。但即使保守估计,通过率可能低至 40%,这已经是不可接受的服务风险。
  • top-p 变化的测试应包含:(a) 事实问答 → 看是否从"事实错"变成"重复/截断";(b) 创意任务 → 看是否从"多样"变成"模板化";(c) 列表生成 → 看列表长度和内容是否受影响。
  • 多数投票适合高风险、低频的判断场景(如安全分类、合规审核)。投票次数通常为 3-7 次奇数,确保不会出现平票。置信度阈值建议 ≥ 0.8(即 5 次中至少 4 次一致)。

7.5 自测标准

学完这一页后,你应该能:

  • 解释 logits、softmax、temperature 的数学关系,包括数值稳定性问题。
  • 知道 greedy、top-k、top-p、beam search 各适合什么任务。
  • 理解为什么 temperature=0 也不保证绝对一致,能列出至少 5 个不确定性来源。
  • 设计真正有统计意义的稳定性回归(多次运行 + 置信区间 + 失败模式分析)。
  • 理解约束解码的原理和对性能/质量的 trade-off。
  • 针对不同任务类型选择合适的采样参数组合。