解码采样与稳定性测试
企业里最常见的一句抱怨是"这个 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:
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 后再做 softmax | P_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 | 按出现次数递减 logit | z_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 通常会组合使用。理解它们的组合效应很重要:
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]常用参数组合推荐
| 场景 | temperature | top-p | top-k | 理由 |
|---|---|---|---|---|
| 事实问答 | 0.0-0.2 | 1.0 | 1 | 追求一致性,greedy 或接近 greedy |
| 结构化输出 | 0.0-0.1 | 1.0 | 1 | 格式优先于多样性 |
| 客服对话 | 0.3-0.5 | 0.9 | 0 | 需要一定自然度但控制风险 |
| 创意写作 | 0.7-1.0 | 0.95 | 40 | 鼓励多样性但避免噪声 |
| 代码生成 | 0.2-0.4 | 0.95 | 0 | 准确性优先,适度多样 |
| Agent 规划 | 0.3-0.5 | 0.9 | 0 | 步骤合理性比创意更重要 |
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 高级解码策略
Contrastive Search
在选择下一个 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" 为什么常常是幻觉
企业里最危险的不是随机性本身,而是误以为系统完全确定。以下因素都会造成伪随机或伪确定:
- **模型微版本:**模型服务商在后台切了微版本,权重微调但没有通知。
- **检索漂移:**上游 retrieval 顺序轻微变化,影响上下文内容。
- **Prompt 差异:**工具 schema 或 system prompt 有微小文本差异。
- **Logits 接近:**候选 logits 极其接近,任何微扰都会改变分支。
- **服务端实现:**流式实现或并发策略改变了解码路径。
- **硬件差异:**不同 GPU 型号的浮点运算精度略有不同。
- **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. 稳定性测试设计清单
- 对核心 case 跑多次(推荐 10-30 次),统计通过率,不要只跑一次。
- 把任务分成"要求确定"和"允许多样"两类,分别设断言。
- 参数变更时,同时观察通过率、平均分、方差和失败模式分布。
- 对结构化输出做"接近上限 + 长 schema + 多轮 history"联动测试。
- 把 seed、temperature、top-p、模型版本写入评测元数据。
- 对高风险场景实施多数投票(majority voting)。
- 建立波动基线,区分"正常波动"和"异常漂移"。
06.5 稳定性测试框架实现
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 课堂练习
- 给一个必须稳定输出 JSON 的任务,说明你会怎么设置 temperature、top-p,以及为什么。并设计一个测试来验证你的参数选择。
- 如果某个事实问答在 10 次回归里 7 次通过、3 次失败,计算 95% 置信区间,并解释这个结果意味着什么。
- 设计一组样本,专门观察 top-p 从 0.9 调到 0.7 后失败模式如何变化。需要包含至少 3 类不同的任务。
- 实现一个多数投票 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。
- 针对不同任务类型选择合适的采样参数组合。