溫度和取樣:同一個問題為什麼每次回答不一樣
拿到模型真實的候選詞元機率,用 numpy 親手實現溫度和 top_p 取樣,再用 API 實測溫度對回答多樣性的影響,並解釋溫度為 0 為什麼也不能保證結果一樣。
- 約 40 分鐘
- 難度:入門
- 實測:2026-09-14 deepseek-flash,numpy 2.5
程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。
同一個問題問兩遍,大模型的回答常常不一樣。寫文案時這是好事,你可以多要幾個版本挑一挑。可如果你在做一個"從合同裡提取甲方名稱"的程式,今天提取出"某某有限公司",明天變成"某某公司",就很麻煩了。
控制這件事的旋鈕叫溫度(temperature)。這一課先弄明白隨機性從哪來,再用程式碼親手實現溫度,最後用真實的 API 呼叫看它的效果。
隨機性從哪來
上一課講過,模型每一步都會給出一個機率分佈:下一個詞元是"爬山"的機率多少,是"圖書館"的機率多少……然後由一個叫取樣器的程式,按這個分佈抽一個詞元出來。模型本身每次算出的分佈幾乎是一樣的,隨機性來自這個抽籤的環節。
這個分佈可以直接看到。讓 deepseek-flash 續寫"週末我打算去",開啟 logprobs 參數(上一課講過怎麼用),它返回的第一個詞元的前 10 個候選是:
| 候選 | 機率 |
|---|---|
| 爬山 | 68.8% |
| 圖書館 | 14.9% |
| 公園 | 8.0% |
| 山裡 | 2.4% |
| 郊 | 1.6% |
| 超市 | 1.6% |
| 逛街 | 1.1% |
| 逛 | 0.6% |
| 書店 | 0.3% |
| 露營 | 0.1% |
最常見的做法不是永遠選機率最高的那個,而是按機率抽:68.8% 的時候選"爬山",14.9% 的時候選"圖書館",偶爾也會抽中"露營"。
我在實驗裡碰到過一個很說明問題的情況。讓模型續寫"我今天中午吃了",第一個詞元機率最高的是"一碗"(70.8%),其次是"我今天"(27.8%)。那次它偏偏抽中了第二名,於是整個回答變成了把原句又複述一遍。一步抽中了不太好的詞元,後面每一步都在它的基礎上繼續,回答就朝另一個方向去了。這也是為什麼一次回答質量不好時,重新生成一次常常就好了。
溫度做了什麼
溫度的作用是在抽籤之前,把這個分佈壓尖或者攤平。
具體的演算法是:把每個候選的機率取對數,除以溫度,再重新變成機率(這一步叫 softmax)。寫成公式是 新概率_i ∝ exp(log(p_i) / T),等價於 p_i 的 1/T 次方再歸一化。
- 溫度小於 1,大的機率變得更大,小的變得更小,分佈變尖,結果更穩定。
- 溫度等於 1,分佈不變。
- 溫度大於 1,分佈變平,排名靠後的候選機會變多,結果更多樣,也更容易跑偏。
- 溫度趨近於 0,永遠選機率最高的那個,這叫貪心解碼。
用上面那個真實的分佈,親手做一遍:
import numpy as np
candidates = ["爬山", "图书馆", "公园", "山里", "郊", "超市", "逛街", "逛", "书店", "露营"]
probs = np.array([0.6879, 0.1489, 0.0797, 0.0244, 0.0158, 0.0157, 0.0107, 0.0062, 0.0029, 0.0013])
probs = probs / probs.sum() # 只取了前 10 个,重新归一化让它们加起来等于 1
def apply_temperature(p, t):
# 温度作用在对数概率上:先取对数,除以温度,再变回概率(softmax)
logits = np.log(p) / t
e = np.exp(logits - logits.max()) # 减去最大值是为了防止 exp 溢出,不影响结果
return e / e.sum()
def show(title, p, n=1000, seed=0):
rng = np.random.default_rng(seed)
draws = rng.choice(len(p), size=n, p=p)
counts = np.bincount(draws, minlength=len(p))
print(title)
for word, prob, count in zip(candidates, p, counts):
if prob > 0.0005 or count:
print(f" {word: <4} 概率 {prob:6.1%} 抽中 {count:4d} 次")
print()
show("原始分布(温度 1.0),抽 1000 次:", probs)
show("温度 0.5:", apply_temperature(probs, 0.5))
show("温度 1.5:", apply_temperature(probs, 1.5))
執行結果:
原始分布(温度 1.0),抽 1000 次:
爬山 概率 69.2% 抽中 673 次
图书馆 概率 15.0% 抽中 166 次
公园 概率 8.0% 抽中 83 次
山里 概率 2.5% 抽中 29 次
郊 概率 1.6% 抽中 11 次
超市 概率 1.6% 抽中 15 次
逛街 概率 1.1% 抽中 12 次
逛 概率 0.6% 抽中 8 次
书店 概率 0.3% 抽中 2 次
露营 概率 0.1% 抽中 1 次
温度 0.5:
爬山 概率 94.1% 抽中 944 次
图书馆 概率 4.4% 抽中 41 次
公园 概率 1.3% 抽中 14 次
山里 概率 0.1% 抽中 0 次
超市 概率 0.0% 抽中 1 次
温度 1.5:
爬山 概率 49.6% 抽中 467 次
图书馆 概率 17.9% 抽中 188 次
公园 概率 11.8% 抽中 127 次
山里 概率 5.4% 抽中 64 次
郊 概率 4.0% 抽中 40 次
超市 概率 4.0% 抽中 40 次
逛街 概率 3.1% 抽中 30 次
逛 概率 2.1% 抽中 21 次
书店 概率 1.3% 抽中 16 次
露营 概率 0.8% 抽中 7 次
溫度 0.5 時,"爬山"從 69% 漲到 94%,後面幾個候選基本沒機會了。溫度 1.5 時,"爬山"降到一半左右,"露營"從 1000 次裡抽中 1 次變成 7 次。
注意,這裡只是第一個詞元。一個回答有幾十上百個詞元,每一步都這樣抽一次。每一步的差別累積起來,溫度高時整段回答的差異會非常大。
top_p:砍掉長尾
另一個常見參數是 top_p,也叫核取樣(nucleus sampling)。它不改變機率的相對大小,而是把候選按機率從高到低排好,從頭開始累加,加到 top_p 就停,後面的全部丟掉,再在剩下的裡面抽。
def top_p_filter(p, top_p):
order = np.argsort(p)[::-1]
cumulative = np.cumsum(p[order])
# 保留累计概率刚好达到 top_p 的那几个,其余的概率清零
keep = order[: np.searchsorted(cumulative, top_p) + 1]
q = np.zeros_like(p)
q[keep] = p[keep]
return q / q.sum()
show("温度 1.0 + top_p 0.9:", top_p_filter(probs, 0.9))
温度 1.0 + top_p 0.9:
爬山 概率 75.1% 抽中 733 次
图书馆 概率 16.2% 抽中 183 次
公园 概率 8.7% 抽中 84 次
前三個候選的機率加起來剛過 90%,於是只在它們裡面抽,"露營"、"超市"這些再也不會出現。它的好處是能擋住那些機率很低但一旦被抽中就會讓回答跑偏的詞元。
溫度和 top_p 通常只調一個就夠了,同時調兩個很難判斷到底是哪個起的作用。
用 API 實測
上面是在本地模擬。真實呼叫時效果如何?我讓 deepseek-flash 寫一句關於秋天的比喻,在三種溫度下各問 20 次:
import os
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from openai import OpenAI
client = OpenAI(
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)
MODEL = os.environ.get("LLM_MODEL", "deepseek-flash")
QUESTION = "用一句话写一个关于秋天的比喻,不超过十五个字,只输出这句话。"
def ask(temperature):
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": QUESTION}],
temperature=temperature,
# 思考模式下 temperature 不起作用,所以要关掉思考
extra_body={"thinking": {"type": "disabled"}},
)
return response.choices[0].message.content.strip()
for t in [0, 0.7, 1.3]:
# 10 个线程同时发请求,省点时间
with ThreadPoolExecutor(10) as pool:
answers = list(pool.map(ask, [t] * 20))
counts = Counter(answers)
print(f"temperature={t}: 20 次里有 {len(counts)} 种")
for text, n in counts.most_common(5):
print(f" {n:2d}× {text}")
我執行的結果(你的會不一樣):
temperature=0: 20 次里有 2 种
11× 秋天像一封写满离别的信。
9× 秋天像一封缓缓飘落的信。
temperature=0.7: 20 次里有 6 种
10× 秋天像一封写满离别的信。
4× 秋天像一封慢慢变黄的信。
3× 秋天像一封缓缓飘落的信。
1× 秋天像一封缓缓拆开的旧信。
1× 秋天像一封缓缓展开的旧信。
temperature=1.3: 20 次里有 15 种
4× 秋天像一封缓缓飘落的信。
2× 秋天像一封写满离别的信。
2× 秋天像一封慢慢变黄的信。
1× 秋天像一封写给大地的金色信笺。
1× 秋天是把金色小提琴,风一拉就落叶。
(只顯示了每種溫度下最常見的 5 種。)
溫度從 0 到 1.3,20 次裡不同回答的數量從 2 種增加到 15 種。溫度 1.3 時出現了"秋天是把金色小提琴,風一拉就落葉"這種前面從沒出現過的寫法。
兩個現象值得說一下。
溫度為 0,結果也不是每次一樣。理論上溫度為 0 應該永遠選機率最高的詞元,結果完全確定。但實際上 20 次裡出現了兩種回答。原因在伺服器那一側:你的請求和別人的請求被放在一起批次計算,GPU 上浮點數運算的順序不同,結果會有極小的差別。平時無所謂,但當兩個候選的機率非常接近時("寫滿離別的"和"緩緩飄落的"大概就是這樣),這點差別就足以讓選擇翻轉。一旦前面某一步選了不同的詞元,後面就全不一樣了。所以別指望溫度 0 能讓結果百分之百可復現。需要穩定輸出的程式,要在程式裡做校驗,而不是賭模型每次都一樣。
模型很有把握的時候,調高溫度也沒用。我另外試過讓模型"給一家賣手工咖啡的小店起一個名字",溫度 1.5 下問了 5 次,5 次都是"豆語咖啡"。我的推測是,第一個詞元"豆語"的機率已經高到接近 100%,就算把分佈攤平,它依然佔絕對優勢。想要多樣的結果,與其調高溫度,不如直接在提示詞裡要求"給出 10 個風格不同的名字"。
思考模式下溫度不生效
DeepSeek 的文件寫明:思考模式下 temperature 參數不起作用(設定了也不會報錯,只是被忽略),top_p 小於 0.95 的值也會被自動調高到 0.95。所以上面的程式碼都關掉了思考。
這意味著,想用溫度控制輸出時,必須關掉思考模式。反過來,開著思考時,就別指望調溫度能讓輸出更穩定。
該用多少
DeepSeek 官方給出的建議值(截至 2026 年 9 月):
| 場景 | 溫度 |
|---|---|
| 寫程式碼、做數學題 | 0.0 |
| 資料清洗、資料分析 | 1.0 |
| 日常對話 | 1.3 |
| 翻譯 | 1.3 |
| 創意寫作、寫詩 | 1.5 |
DeepSeek 的預設溫度是 1.0。這張表只對 DeepSeek 有效,別的服務商的模型對溫度的敏感程度不一樣,同樣的數字效果可能差很多。
我的做法是:提取、分類、改格式這類有標準答案的任務,溫度設為 0;寫作、起名、頭腦風暴用預設值或者往上調;拿不準就先用預設值,不滿意再調。
練習
- 把
sampling.py裡的溫度改成 0.1 和 3.0,看看抽樣結果變成什麼樣。溫度 3.0 時"露營"能被抽中多少次? - 把
top_p改成 0.7 和 0.99,看看保留了幾個候選。 - 用
temperature_api.py換一個問題:讓模型把"The quick brown fox jumps over the lazy dog"翻譯成中文,在溫度 0 和 1.3 下各問 20 次,數一數有幾種不同的翻譯。翻譯任務和寫比喻相比,溫度的影響是更大還是更小?想想為什麼。 - 用上一課講的
logprobs參數,看看讓模型"給一家賣手工咖啡的小店起一個名字"時,第一個詞元的候選和機率是什麼。它能驗證我對"豆語咖啡"現象的推測嗎?
自測
1. 溫度調低,模型的回答為什麼會更穩定?
溫度作用在每一步的候選機率分佈上:溫度越低,機率高的候選變得更高,機率低的變得更低,分佈更"尖"。抽籤時幾乎總是抽中同一個詞元,回答自然更穩定。溫度趨近於 0 時,就是每一步都選機率最高的那個。
2. 你把溫度設成 0,發現同一個問題問 20 次,還是出現了兩種不同的回答。這正常嗎?
正常。伺服器批次處理請求時,浮點運算的順序會變,導致機率有極小的差別。當兩個候選的機率非常接近時,這點差別足以讓選擇翻轉,後面的輸出就都不一樣了。所以不能指望溫度 0 帶來完全可復現的結果。
3. 在 DeepSeek 上開著思考模式,把溫度從 1.0 調到 0,輸出沒有變得更穩定。為什麼?
DeepSeek 在思考模式下會忽略 temperature 參數,設了也不起作用。要用溫度控制輸出,需要通過 extra_body={"thinking": {"type": "disabled"}} 關掉思考。
提問與討論
這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。
提問 +3 點,回答別人 +6 點。內容經審核後公開。
正在載入討論…