同一个 prompt 发两次,一次干净利落,一次绕圈说废话。有人归因于"模型不稳定",其实多半是采样参数——模型每步输出的是一整条概率分布,采样是从这条分布里抽下一个 token 的规则。
它同时决定三件事:多样性、可复现性、结构化任务能否稳定交付。三件事混在一起调,就永远说不清是哪一步坏的。
1. 采样在解决什么
| 需求 | 想要的性质 | 参数取向 |
|---|---|---|
| 创意写作、头脑风暴 | 每次不同,敢于走偏 | 高温 + 宽候选 |
| 事实抽取、分类判定 | 稳定、可复现 | 贪心或近贪心 |
| 代码生成 | 语法正确优先,允许少量多样 | 低温 + 停止序列 |
| 结构化输出(JSON) | 格式零容错 | 约束解码,参数只做微调 |
| 回归测试 | 同输入必须同输出 | 固定种子 + 固定参数 + 固定版本 |
先确认自己在哪一行,再谈参数值。
2. 三个参数的机制
设模型对第 t 步输出 logits z(词表大小 V):
p_i = softmax(z_i / T) # temperature 缩放
top-k: 只保留概率最高的 k 个,其余置 0 后重归一
top-p: 按概率降序累加,保留累计 ≥ p 的最小集合,其余置 0 后重归一
| 参数 | 作用对象 | 调大的效果 | 调小的效果 | 风险 |
|---|---|---|---|---|
temperature |
整条分布的陡峭度 | 分布变平,低概率 token 更容易被抽到 | 分布变尖,趋向 argmax | 过高开始语无伦次 |
top-k |
候选集合大小 | 候选多,多样性升 | 候选少,趋向安全词 | 太小会砍掉合理续写 |
top-p |
累计概率阈值 | 候选多 | 候选少 | 分布尖时几乎无效 |
temperature 改形状,top-k/top-p 改范围。同时设置时,通行顺序是:按 T 缩放 → top-k 截断 → top-p 截断 → 重归一 → 采样。
# 等价实现,便于理解生效顺序(实际由引擎 kernel 完成)
import torch
def sample(logits, T=1.0, k=0, p=1.0, generator=None):
logits = logits / max(T, 1e-5)
if k and k > 0:
kth = torch.topk(logits, k).values[..., -1, None]
logits = logits.masked_fill(logits < kth, float("-inf"))
if p < 1.0:
sorted_logits, idx = torch.sort(logits, descending=True)
probs = torch.softmax(sorted_logits, dim=-1).cumsum(-1)
drop = probs - torch.softmax(sorted_logits, dim=-1) > p
sorted_logits = sorted_logits.masked_fill(drop, float("-inf"))
logits = torch.full_like(logits, float("-inf")).scatter(-1, idx, sorted_logits)
probs = torch.softmax(logits, dim=-1)
return torch.multinomial(probs, 1, generator=generator)
少数实现先 top-p 再 top-k。分布平坦时差异明显,跨引擎迁移参数要按引擎文档核对。
{
"temperature": 0.7,
"top_p": 0.95,
"top_k": 40,
"max_tokens": 512,
"stop": ["\n\n用户:", "</answer>"],
"seed": 20261109
}
同时给三个值不算错,同时改三个再比较结果才是错:无法归因。常见做法是固定 top_p=1.0、top_k=0,只调 temperature;或反过来。
3. temperature=0 不等于确定性
T=0 直觉上就是"永远选最大概率的 token"。它实际只保证每一步的 argmax 在当前浮点值下相同,而浮点值本身会变。
| 非确定性来源 | 机制 | 影响 |
|---|---|---|
| 浮点归约顺序 | 不同 batch 形状下 GEMM/reduction 顺序不同 | logits 末位不同,接近并列时翻转 |
| 批处理组成 | 同一请求在不同批里的位置不同 | 同上,且难复现 |
| 并行策略 | TP/PP 切分改变累加顺序 | 多卡与单卡结果可能有差 |
| 引擎版本 | kernel 实现变更 | 升级后输出漂移 |
| 前缀缓存命中 | 命中与未命中的计算路径不同 | 同一请求两次命中状态不同 |
# 验证:同一请求发三次,看 finish 与 token 数是否一致
for i in 1 2 3; do
curl -s http://HOST:8000/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"MODEL","messages":[{"role":"user","content":"PROMPT"}],
"temperature":0,"seed":42,"max_tokens":128}' \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print(d["choices"][0]["finish_reason"], len(d["choices"][0]["message"]["content"]))'
done
三次一致只说明"这次没撞上临界情况"。要更稳,得固定引擎版本、固定 batch 策略、关闭前缀缓存,或接受"近似可复现"并在评测里多次采样取多数。
4. 重复惩罚与副作用
| 参数 | 机制 | 副作用 |
|---|---|---|
frequency_penalty |
按 token 已出现次数线性扣分 | 惩罚必要的重复(列表编号、代码缩进、字段名) |
presence_penalty |
出现过就扣一次固定分 | 让模型回避主题词,答非所问 |
repetition_penalty |
logits 除以惩罚因子 | 乘法式惩罚,取值稍大就伤语义 |
{"frequency_penalty": 0.3, "presence_penalty": 0.0, "repetition_penalty": 1.05}
repetition_penalty 是乘法,比线性惩罚猛,1.2 已足以压住代码里的 self、def、return。代码与结构化输出场景建议三个都保持默认:多数"复读"其实是 temperature 太低或停止序列没配。
5. max_tokens 与截断判定
输出被截断时,引擎会在 finish_reason 里写明原因:
finish_reason |
含义 | 处理 |
|---|---|---|
stop |
命中停止序列 | 检查停止词是否把正文切了 |
length |
达到 max_tokens |
输出不完整,考虑续写或加预算 |
content_filter |
被内容策略截断 | 换措辞或换路径 |
tool_calls |
转向工具调用 | 正常路径 |
r = client.chat.completions.create(model="MODEL", messages=msgs, max_tokens=512)
fin = r.choices[0].finish_reason
if fin == "length":
# 续写:把已生成内容拼回 messages,注意别重复 system prompt
msgs = msgs + [{"role": "assistant", "content": r.choices[0].message.content}]
靠加大 max_tokens 治截断通常治不好:真相多是提示词没约束长度、模型自问自答、或停止序列写错。它只是安全阀。
6. 停止序列的正确用法
停止序列是"一出现就切断"的字面匹配,它不理解语义。
| 用法 | 例子 | 注意 |
|---|---|---|
| 分隔多轮模板 | "\n\n用户:" |
要在模板里真实存在 |
| 切代码块结束 | "```" |
正文里出现代码块就会被切 |
| 切结构化标签 | "</answer>" |
标签未闭合时会切在半截 |
| 切工具调用 | `"< | tool_call |
误截断的根因永远是"停止词在正文里合法出现"。做法:选正文不会自然出现的组合(带换行、带模板标记);必须闭合的输出(JSON)改用约束解码。
7. 结构化输出:低温还是约束解码
| 方案 | 保证程度 | 代价 |
|---|---|---|
| 低温 + 提示词要求 JSON | 概率性,偶发破损 | 无额外开销 |
| JSON mode(引擎校验) | 语法层面基本保证 | 需引擎支持,重试成本 |
| 约束解码(grammar / schema) | 每步按语法掩码,结构必合法 | 略降吞吐,需维护语法 |
# 约束解码:结构由语法保证,参数只需防止语义跑偏
resp = client.chat.completions.create(
model="MODEL",
messages=msgs,
temperature=0.2,
response_format={"type": "json_schema",
"json_schema": {"name": "answer", "schema": SCHEMA}},
max_tokens=512,
)
分层结论:格式靠约束解码,内容靠低温与提示词。温度调到 0.05 也挡不住漏一个右括号。
8. 任务基线与调整顺序
| 任务 | temperature | top_p | 备注 |
|---|---|---|---|
| 事实抽取 / 字段抽取 | 0–0.2 | 1.0 或 0.9 | 优先约束解码 |
| 分类判定 | 0 | 1.0 | 配合 logprobs 看置信度 |
| 代码生成 | 0.2–0.4 | 0.95 | 停序列必配,禁重复惩罚 |
| 摘要 / 改写 | 0.3–0.7 | 0.9–0.95 | 视"忠实度 vs 流畅度" |
| 创意写作 | 0.8–1.1 | 0.95–1.0 | 温度过高开始跑题 |
| 多轮对话 | 0.6–0.8 | 0.9 | 与人格设定一起调 |
顺序固定四步:定任务 → 定评测集 → 只动一个参数 → 记全部参数。跑同一批样本看位移方向。这条纪律比任何具体数值都值钱。
# 参数网格:一次只变一个轴,结果落文件
for T in 0.0 0.3 0.7 1.0; do
python eval.py --temperature $T --top_p 0.95 --seed 42 \
--out runs/t${T}.jsonl --model MODEL
done
9. 可复现性的工程做法
| 措施 | 作用 | 局限 |
|---|---|---|
固定 seed |
采样随机数可复现 | 不覆盖浮点非确定性 |
| 记录全部参数 | 事后可回放 | 需连引擎版本一起记 |
| 固定模型版本/摘要 | 排除权重变更 | 托管服务可能静默更新 |
| 固定并发与 batch | 减少归约顺序差异 | 生产流量下难做到 |
| 关闭前缀缓存 | 消除路径差异 | 牺牲吞吐 |
{
"model": "MODEL@sha256:TAG",
"engine": "vllm 0.x.y",
"params": {"temperature": 0.3, "top_p": 0.95, "top_k": 0, "seed": 42,
"max_tokens": 512, "stop": ["\n\n"], "presence_penalty": 0.0},
"runtime": {"max_num_seqs": 32, "prefix_caching": false}
}
10. 排错:五个高频坑
| 现象 | 根因 | 处理 |
|---|---|---|
| 换了参数说不出哪个起作用 | 同时改了多个 | 一次只动一个,其余固定 |
| 输出总在句中断 | 停止序列命中正文 / max_tokens 截断 |
先看 finish_reason,再看停止词 |
| 提高"准确率"靠调低温度 | 温度只改采样,不改知识 | 从提示词、检索、约束解码入手 |
| 新模型沿用旧参数后变差 | 不同模型的分布温度标定不同 | 重跑基线网格,重新定值 |
| 同一请求两次结果不同 | 浮点/batch/缓存差异 | 见第 3 节 |
11. 边界:参数管不到的地方
| 问题 | 采样参数能否解决 | 该做什么 |
|---|---|---|
| 模型不知道这个知识 | 否 | 检索增强、微调、换模型 |
| 输出格式偶发破损 | 部分 | 约束解码 |
| 首 token 太慢 | 否 | 调度与 prefill 优化 |
| 事实错误(幻觉) | 否 | 校验链、引用、拒答策略 |
| 输出不像品牌口吻 | 部分 | 提示词与 few-shot |
12. 自测
- temperature 与 top-p 分别改变分布的形状还是范围?
- 三者同时设置时,通行的生效顺序是什么?为什么顺序会影响结果?
temperature=0时输出仍可能不同,列出至少三个非确定性来源。- 代码生成任务为什么通常要关掉重复惩罚?
- 输出被截断时,先看哪个字段判断是停止序列还是长度上限导致的?
- 要让结构化输出"格式零容错",该用低温还是约束解码?为什么?