
llama.cpp 的采样策略深度解读Top-p、Top-k、Temperature 的底层实现与组合效果一、采样参数的玄学三个参数排列组合出截然不同的输出大模型推理中最常被调整的三个参数是 Temperature、Top-k 和 Top-p。多数用户凭感觉调参——想要更创意就加 Temperature想要更保守就降 Temperature。但很少有人理解这三个参数在底层是如何相互作用的以及为什么某些组合如 Top-p0.9 Temperature1.5会产生极不稳定的输出。采样的本质是从语言模型输出的 logits未归一化的 log 概率分布中选取下一个 token。如果直接选 argmax贪心采样生成的文本会陷入重复。如果在所有 token 上随机采样可能选出低概率的无意义 token。三个参数是在探索和质量之间的三种策略。二、三个采样参数的数学定义与执行顺序graph LR A[模型输出的 Logits] -- B[Temperature 缩放] B -- C[Softmax → 概率分布] C -- D{Top-k 截断} D --|保留 Top K| E{Top-p 截断} E --|保留累积概率 ≤ p| F[归一化] F -- G[多项式采样] G -- H[选中的 Token ID] style B fill:#1a1a2e,stroke:#e94560,color:#fff style D fill:#16213e,stroke:#0f3460,color:#fff style E fill:#16213e,stroke:#0f3460,color:#fff执行顺序是严格的Temperature → Top-k → Top-p → 采样。这个顺序不是任意的——每一步都依赖前一步的输出。Temperature (T)是最先应用的变换scaled_logits[i] logits[i] / T当 T 1 时logits 的差异被放大高概率 token 更突出分布变尖。当 T 1 时差异被压缩分布趋于均匀。极端情况T → 0 退化到 argmax确定性T → ∞ 退化到均匀采样。Top-k 截断保留概率最高的 k 个 token其余 token 概率置为 0。这是最直接的质量保证——完全忽略长尾中的低概率 token。**Top-pNucleus Sampling**保留概率最高的若干 token直到累积概率达到阈值 p。与 Top-k 不同Top-p 的动态性在于当分布尖时保留少 token当分布平时保留多 token——自适应截断。为什么 Top-k 和 Top-p 的顺序重要如果先执行 Top-p 再执行 Top-kTop-p 已经将候选 token 集合缩减为累积概率 ≤ p 的子集再对这个子集取 Top-k 可能得到不理想的结果——因为 Top-p 可能已经包含了一些低概率 token而 Top-k 之后可能丢掉了高概率 token。三、采样引擎的 Rust 实现与错误保护use rand::Rng; use rand::distributions::WeightedIndex; /// 采样配置 /// /// 为什么分开 temperature、top_k、top_p /// 它们作用于采样的不同阶段组合使用但独立配置 /// 某些场景只需要其中一个如仅 Temperature 采样 #[derive(Debug, Clone)] pub struct SamplingConfig { /// 温度参数 /// 为什么默认 0.8 而非 1.0 /// 1.0 是不做变换0.7~0.9 是对话场景的甜区 pub temperature: f32, /// Top-k保留最高概率的 k 个 token /// 为什么默认 40 而非常用的 50 /// 40 在绝大多数场景下与 50 效果无差异但可减少排序开销 pub top_k: usize, /// Top-p累积概率阈值 /// 为什么默认 0.9 而非 0.95 /// 0.9 在创造性和连贯性之间有更好的平衡 /// 0.95 及以上在对话场景中容易产生发散输出 pub top_p: f32, /// 最小概率阈值低于此值的 token 直接丢弃 /// 为什么需要 min_p 作为额外的安全阀 /// Top-p0.9 在极端平的分布下可能保留数千 token /// min_p 强制丢弃概率极低的 token pub min_p: f32, /// 重复惩罚因子 pub repeat_penalty: f32, /// 重复惩罚的作用范围最近 N 个 token pub repeat_last_n: usize, } impl Default for SamplingConfig { fn default() - Self { Self { temperature: 0.8, top_k: 40, top_p: 0.9, min_p: 0.0, repeat_penalty: 1.1, repeat_last_n: 64, } } } /// 采样器 pub struct Sampler { config: SamplingConfig, rng: rand::rngs::StdRng, } impl Sampler { pub fn new(config: SamplingConfig) - Self { Self { config, rng: rand::rngs::StdRng::from_entropy(), } } /// 执行采样 /// /// 为什么返回 Resultu32 而非直接 u32 /// 采样可能失败——如所有 token 概率都被过滤为 0 /// 此时应 fallback 到 argmax安全但降级 pub fn sample( mut self, mut logits: Vecf32, previous_tokens: [u32], ) - Resultu32, SampleError { let vocab_size logits.len(); // Step 1: Temperature 缩放 if (self.config.temperature - 1.0).abs() f32::EPSILON { let inv_temp 1.0 / self.config.temperature; for logit in logits.iter_mut() { *logit * inv_temp; } } // Step 2: Softmax → 概率分布 // 数值稳定的 softmax let max_logit logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max); let mut probs: Vecf32 logits.iter() .map(|l| (l - max_logit).exp()) .collect(); let sum: f32 probs.iter().sum(); if sum 0.0 { for p in probs.iter_mut() { *p / sum; } } // Step 3: Top-k 截断 if self.config.top_k 0 self.config.top_k vocab_size { // 创建 (概率, 索引) 对并排序 // 为什么需要保留原始索引 // 最终需要返回 token ID排序后必须能映射回原始 ID let mut indexed: Vec(usize, f32) probs.iter() .enumerate() .map(|(i, p)| (i, p)) .collect(); // 偏序排序只需要前 k 个最大的不需要完整排序 // 为什么用 select_nth_unstable_by // 完整排序 O(n log n)select_nth 是 O(n) // vocab_size 通常 32000~128000完整排序耗时可观 let k self.config.top_k.min(vocab_size); indexed.select_nth_unstable_by(k - 1, |a, b| { b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal) }); // 保留前 k 个其余置零 let top_k_indices: Vecusize indexed[..k].iter().map(|(i, _)| *i).collect(); let top_k_probs: Vecf32 indexed[..k].iter().map(|(_, p)| *p).collect(); let mut new_probs vec![0.0f32; vocab_size]; for (idx, prob) in top_k_indices.iter().zip(top_k_probs.iter()) { new_probs[*idx] prob; } probs new_probs; } // Step 4: Top-p (Nucleus) 截断 if self.config.top_p 0.0 self.config.top_p 1.0 { // 对保留的 token 按概率降序排列 let mut indexed: Vec(usize, f32) probs.iter() .enumerate() .filter(|(_, p)| p 0.0) .map(|(i, p)| (i, p)) .collect(); indexed.sort_unstable_by(|a, b| { b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal) }); // 计算累积概率保留到 threshold 为止 let mut cumulative 0.0f32; let mut cutoff indexed.len(); for (idx, (_, prob)) in indexed.iter().enumerate() { cumulative prob; if cumulative self.config.top_p { cutoff idx 1; // 包含当前 token break; } } // 截断保留 cutoff 之后的置零 let mut new_probs vec![0.0f32; vocab_size]; for (i, prob) in indexed[..cutoff].iter() { new_probs[*i] *prob; } probs new_probs; } // Step 5: Min-p 过滤 if self.config.min_p 0.0 { let max_p probs.iter().cloned().fold(0.0f32, f32::max); let threshold max_p * self.config.min_p; for p in probs.iter_mut() { if *p threshold { *p 0.0; } } } // Step 6: 重新归一化并采样 let sum: f32 probs.iter().sum(); if sum f32::EPSILON { // 所有概率都被过滤掉了降级为 argmax let max_idx logits.iter() .enumerate() .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap()) .map(|(i, _)| i as u32) .unwrap_or(0); return Ok(max_idx); } for p in probs.iter_mut() { *p / sum; } // 加权随机采样 let dist WeightedIndex::new(probs) .map_err(|_| SampleError::DistributionError)?; let sampled_idx self.rng.sample(dist) as u32; Ok(sampled_idx) } /// 应用重复惩罚 fn apply_repeat_penalty( self, logits: mut [f32], previous_tokens: [u32], ) { let penalty self.config.repeat_penalty; if (penalty - 1.0).abs() f32::EPSILON { return; } let recent if previous_tokens.len() self.config.repeat_last_n { previous_tokens[previous_tokens.len() - self.config.repeat_last_n..] } else { previous_tokens }; for token_id in recent { let id token_id as usize; if id logits.len() { // 为什么已经是负值的 logit 保持不变 // 重复惩罚只应用于正 logit模型倾向的 token // 负 logit 表示模型已经倾向于避免该 token无需额外惩罚 if logits[id] 0.0 { logits[id] / penalty; } else { logits[id] * penalty; // 使负值更负进一步抑制 } } } } } #[derive(Debug)] pub enum SampleError { DistributionError, AllFiltered, } /// 批量采样用于 speculative decoding 的验证阶段 /// /// 为什么需要批量采样 /// Speculative decoding 中需要验证 draft model 生成的 token 序列 /// 需要对每个位置独立采样并比较 impl Sampler { pub fn batch_sample( mut self, batch_logits: [Vecf32], config: SamplingConfig, ) - VecResultu32, SampleError { batch_logits.iter() .map(|logits| { let mut s Sampler::new(config.clone()); s.sample(logits.clone(), []) }) .collect() } }关键性能优化点select_nth_unstable_by 的选择Top-k 需要找出概率最高的 k 个 token。完整排序 O(n log n)对于 vocab_size128000 的模型完整排序耗时可观。select_nth_unstable_by是部分排序partition复杂度 O(n)适合只需要前 k 个元素的场景。四、三个参数的组合效应与调试策略Temperature × Top-p 的不稳定区域当 Temperature 1.2 且 Top-p 0.5 时会出现一种特殊现象Temperature 压平了分布很多低概率 token 被提升到相似的概率水平但 Top-p 只保留累积概率 0.5 的 token——在一个平坦的分布中累积到 0.5 可能只覆盖极少 token。结果是最多 2~3 个 token 具有相似的高概率采样高度随机。参数调优建议场景TemperatureTop-kTop-p代码生成0.2~0.520~300.9对话0.7~0.940~500.9创意写作0.9~1.280~1000.95事实问答0.1~0.3100.5禁用场景固定格式输出如 JSON Schema 生成应完全禁用随机采样使用 constrained decoding 或 grammar-based sampling。五、总结采样参数的执行顺序是 Temperature → Top-k → Top-p → 采样顺序不可改变Top-k 是固定截断Top-p 是自适应截断——当分布尖时保留少 token分布平时保留多 tokenselect_nth_unstable_byO(n)替代完整排序O(n log n)是 vocab_size 50K 时的重要优化Temperature 1.2 配合小 Top-p 会产生不稳定输出应避免这种组合代码生成和固定格式输出应降低随机性对话和创意写作可适当提高探索范围