模块 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 积分。内容经审核后公开。

正在加载讨论…