模組 01 · 第 2 課

模型只做一件事:預測下一個詞元

讓模型亮出每一步的候選詞元和機率,看一段回答是怎麼一個詞元一個詞元生成的;再講它從續寫機器變成助手的過程,以及幻覺從哪裡來。

  • 約 35 分鐘
  • 難度:入門
  • 實測:2026-09-14 deepseek-flash

程式碼和執行結果保留原樣(簡體中文),與實際執行時完全一致。

你大概聽過這個說法:"大模型本質上就是在預測下一個詞。"這句話是對的,但只聽這一句,很難想像一個"預測下一個詞"的程式怎麼能寫程式碼、解數學題、回答問題。更重要的是,這句話能解釋很多你以後會遇到的現象:為什麼它會一本正經地編造,為什麼同一個問題有時對有時錯,為什麼提示詞的寫法對結果影響那麼大。

這一課我們讓模型把它每一步的"想法"亮出來。

看它一步一步地選

上一課提到,API 有個 logprobs 參數,開啟它,模型會告訴你它輸出的每一個詞元的機率,還能附上同一位置上機率最高的幾個候選。DeepSeek 在非思考模式下支援這個參數:

import math
import os

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")


def show_candidates(prompt, max_tokens=6):
    response = client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": prompt}],
        max_tokens=max_tokens,
        logprobs=True,  # 返回每个输出词元的概率
        top_logprobs=5,  # 同时返回每一步概率最高的 5 个候选
        extra_body={"thinking": {"type": "disabled"}},
    )
    print(f"问:{prompt}")
    print(f"答:{response.choices[0].message.content}")
    for step, item in enumerate(response.choices[0].logprobs.content, 1):
        # 接口返回的是概率的自然对数,用 exp 变回 0~1 之间的概率
        options = "  ".join(f"{c.token}({math.exp(c.logprob):.1%})" for c in item.top_logprobs)
        print(f"  第 {step} 步选了 {item.token!r:8} 候选:{options}")
    print()


show_candidates("床前明月光,下一句是什么?只回答下一句。")
show_candidates("请续写这句话,只写接下来的几个字:周末我打算去")

我執行的結果(候選裡有幾個是換行符,打印出來會斷行,我把它們刪掉了):

问:床前明月光,下一句是什么?只回答下一句。
答:疑是地上霜。
  第 1 步选了 '疑'      候选:疑(100.0%)  疑似(0.0%)  下一(0.0%)  低头(0.0%)  举(0.0%)
  第 2 步选了 '是'      候选:是(100.0%)  是中(0.0%)  是全(0.0%)  <||end▁of▁sentence||>(0.0%)  是高(0.0%)
  第 3 步选了 '地上'     候选:地上(100.0%)  地上的(0.0%)  明月(0.0%)  银河(0.0%)  地下(0.0%)
  第 4 步选了 '霜'      候选:霜(100.0%)  <||end▁of▁sentence||>(0.0%)  妆(0.0%)  箱(0.0%)  光(0.0%)
  第 5 步选了 '。'      候选:<||end▁of▁sentence||>(96.4%)  。(3.6%)

问:请续写这句话,只写接下来的几个字:周末我打算去
答:爬山,顺便看看日出。
  第 1 步选了 '爬山'     候选:爬山(74.0%)  图书馆(12.7%)  公园(5.9%)  山里(3.6%)  超市(1.1%)
  第 2 步选了 ','      候选:,(67.0%)  。(31.9%)  <||end▁of▁sentence||>(0.7%)  放松(0.3%)  /(0.1%)
  第 3 步选了 '顺便'     候选:顺便(48.8%)  呼吸(44.9%)  放松(3.3%)  亲近(1.1%)  或者(0.8%)
  第 4 步选了 '看看'     候选:看看(51.6%)  看(20.3%)  拍(19.0%)  透(3.9%)  呼吸(3.4%)
  第 5 步选了 '日出'     候选:日出(86.1%)  山(4.7%)  日落(4.7%)  秋天的(2.7%)  春天的(0.3%)
  第 6 步选了 '。'      候选:。(100.0%)  <||end▁of▁sentence||>(0.0%)

"疑是地上霜"每一步都是 100%(四捨五入後),這句詩模型在訓練資料裡見過無數次,沒有任何懸念。

續寫"週末我打算去"就不一樣了。第 3 步,"順便"和"呼吸"幾乎打平,48.8% 對 44.9%。這次抽中了"順便",於是有了"順便看看日出";下次如果抽中"呼吸",後面可能就是"呼吸新鮮空氣"。一段回答裡有很多這樣的岔路口,每個岔路口選了哪條路,決定了整段回答的走向。這就是同一個問題每次回答不一樣的原因,第 3 課會細講。

還有一個細節:第一個問題的第 5 步,機率最高的是 <||end▁of▁sentence||>(96.4%),只有 3.6% 的機率是句號,結果抽中了句號。這個 end▁of▁sentence 是一個特殊的詞元,意思是"說完了"。模型並不知道自己什麼時候該停,它只是在某一步預測出了這個"結束"詞元,程式看到它就停止生成。上一課的 finish_reason: stop,就是這麼來的。

一段回答是怎麼生成的

把上面看到的串起來,模型生成一段回答的過程是一個迴圈:

输入:"床前明月光,下一句是什么?"
  → 模型算出下一个词元的概率分布 → 抽中"疑" → 把"疑"接到输入后面
输入:"床前明月光,下一句是什么?疑"
  → 模型算出下一个词元的概率分布 → 抽中"是" → 接到后面
输入:"床前明月光,下一句是什么?疑是"
  → ……
一直重复,直到抽中"结束"词元,或者达到 max_tokens 的上限

每一步,模型都把到目前為止的全部內容(你的問題加上它已經寫出來的部分)當作輸入,只預測一個詞元。它寫出的每個字都會成為下一步的輸入。

這個機制帶來幾個直接的後果:

  • 輸出越長越慢,越貴。生成 100 個詞元就要跑 100 步。這也是輸出比輸入貴的原因之一。
  • 它沒法回頭改。前面寫錯了一個詞,後面只能順著這個錯誤往下寫,或者在後文裡自己圓回來。思考模式之所以有用,部分原因是給了模型一個"打草稿"的地方,草稿裡可以寫錯再糾正,正式回答只寫最後的結論。
  • 前面的內容會影響後面。如果你讓模型"先給結論,再給理由",它會先寫結論,再為這個結論找理由;反過來"先分析,再給結論",結論是在分析的基礎上寫出來的。這兩種寫法的準確率可能差很多,02 模組第 3 課會做實驗。

從續寫機器到助手

一個只會"預測下一個詞"的模型,最初真的只會續寫。你給它"床前明月光",它會接著寫詩;你給它"怎麼學 Python?",它可能接著寫出另外十個類似的問題,因為網上的問題列表經常長這樣。

它能變成一個會回答問題的助手,大致經過了三個階段:

  1. 預訓練:在海量文本(網頁、書、程式碼)上練習"預測下一個詞元"。為了預測得準,模型不得不學會語法、事實、推理的模式。這一步決定了模型"知道"什麼,花掉了絕大部分算力。
  2. 指令微調:用大量"問題 → 好回答"的對話樣本繼續訓練,讓它學會"看到問題就回答問題"。
  3. 偏好對齊:讓人或者別的模型給回答打分,訓練它更傾向於給出有幫助、誠實、安全的回答。

第 09 模組第 7 課會詳細講這三步。現在只需要知道:助手的行為是訓練出來的,底層機制從頭到尾沒變過,仍然是預測下一個詞元

那麼對話裡的 system、user、assistant 是怎麼變成"一段文字"交給模型的?DeepSeek 公開的分詞器配置裡有一個"對話模板",它規定了訊息的拼接方式。去掉細節,一次簡單的對話會被拼成這樣:

<|begin▁of▁sentence|>你是一个说话简短的助手。<|User|>Python 里的列表和元组有什么区别?<|Assistant|>

<|User|><|Assistant|> 都是詞表裡的特殊詞元。拼好之後,模型要做的仍然是續寫:在 <|Assistant|> 後面接著往下預測。上一課提到"輸入詞元比訊息字數多",多出來的就是這些格式標記。(這是分詞器壓縮包裡附帶的模板,思考模式、工具呼叫用的模板更復雜,但道理一樣。)

幻覺從哪裡來

理解了"預測下一個詞元",就能理解模型為什麼會編造,也就是常說的幻覺(hallucination)。

模型每一步選的是"最像是接下來會出現的內容",而不是"真實的內容"。大多數時候兩者是一致的,因為訓練資料裡真實的內容最常見。但當模型對某件事瞭解很少,或者問題本身暗示了一個不存在的前提時,"最像的內容"和"真實的內容"就分開了。模型並沒有一個"我不知道"的開關,除非訓練讓它學會了在這種時候說"我不知道"。

我問了 deepseek-flash 一個關於 httpx 的具體問題(關掉思考):

问:httpx 0.27.2 这个版本的更新日志里具体修复了哪些 bug?逐条列出。

答:httpx 0.27.2 的更新日志中,没有列出任何 bug 修复。

该版本(发布于 2024 年 9 月 25 日)是一个纯安全修复版本,只包含一条内容:

- 修复了一个安全漏洞:在特定条件下,之前已重定向的请求在后续重定向中会错误地保留
  Authorization 头,可能导致凭证泄露到非同源主机。该问题通过 CVE-2024-46319
  相关补丁修复,行为改为在跨源重定向时剥离 Authorization 头。

(原回答後面還有一段,這裡省略了。)

語氣很肯定,有日期、有 CVE 編號、有技術細節,看起來非常可信。我去翻了 httpx 倉庫裡的 CHANGELOG.md,0.27.2 這一條的原文是:

## 0.27.2 (27th August, 2024)

### Fixed

* Reintroduced supposedly-private `URLTypes` shortcut. (#2673)

日期不對,內容不對,那個 CVE 編號和"純安全修復版本"的說法全是編的。模型見過很多"某某庫某版本修復了重定向時洩露 Authorization 頭"這樣的更新日誌,於是生成了一段"看起來像 httpx 更新日誌"的文字。

公平地說,模型在明顯的陷阱面前表現得不錯。我問它"httpx 的 Client 有一個 retry_on_status 參數,怎麼用"(這個參數不存在),它直接指出"沒有這個參數",還給出了正確的替代方案(我去核對了 httpx 文件和它推薦的第三方庫原始碼,都對得上)。我讓它推薦三篇研究 httpx 連線池的學術論文,它也說沒有找到這樣的論文,並提醒我不要讓它編。問題出在那些看起來很正常的問題上:一個具體的版本號、一段具體的歷史。越具體、越冷門,越容易編。

所以要記住:

  • 流暢和自信不代表正確。模型編造的內容和真實的內容讀起來一樣流暢。
  • 越具體越要核實。版本號、日期、數字、人名、API 參數、引用出處,都是重災區。
  • 最有效的辦法是讓模型"有據可查"。把真實的資料放進輸入裡,讓它照著資料回答,這就是 04 模組的 RAG;或者讓它能自己去查,這是 05 模組的智慧體。第一部分的貫穿專案 RepoBot,v1 版會故意保留這個問題,讓你親眼看到它答錯,再在 v2 裡解決。

練習

  1. show_candidates 試幾個不同的開頭:"我最喜歡的程式語言是"、"1+1="、"從前有座山"。哪個的候選最集中,哪個最分散?
  2. top_logprobs 改成 20,看看"週末我打算去"的候選裡有沒有你意想不到的。
  3. 問模型一個你非常瞭解、但比較冷門的具體問題(你們公司的產品細節、你家鄉的一條小街、你熟悉的一個開源庫的某個版本),關掉思考,看它會不會編。再開著思考問一次,有沒有區別?把你的發現記下來。

自測

1. 模型怎麼知道一段回答應該在什麼時候結束?

它並不"知道"。詞表裡有一個特殊的"結束"詞元,模型在某一步預測出這個詞元的機率很高並抽中了它,程式就停止生成。另一種情況是達到了 max_tokens 上限,被強行截斷。

2. 為什麼模型寫到一半發現前面寫錯了,很難自己改正?

模型每一步都把之前寫出的所有內容當作輸入,只往後預測一個詞元,沒有回頭修改的機制。前面的錯誤會成為後面的輸入,影響後續的預測。思考模式在一定程度上緩解了這個問題:模型可以在思考過程裡打草稿、糾錯,最後只把結論寫進正式回答。

3. 模型為什麼會編造一個不存在的 CVE 編號?

模型每一步選的是"最像接下來會出現的內容",而不是"真實的內容"。它見過大量"某版本修復了某安全問題,編號 CVE-xxxx"這樣的文字,當它對具體事實不瞭解時,就會按這種模式生成一段看起來合理的內容。越具體、越冷門的事實越容易被編造,所以這類資訊一定要核實。

提問與討論

這一課沒看懂的地方,在這裡問。看到別人的問題,也歡迎你來回答。

提問 +3 點,回答別人 +6 點。內容經審核後公開。

正在載入討論…