模型只做一件事:预测下一个词元
让模型亮出每一步的候选词元和概率,看一段回答是怎么一个词元一个词元生成的;再讲它从续写机器变成助手的过程,以及幻觉从哪里来。
- 约 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?",它可能接着写出另外十个类似的问题,因为网上的问题列表经常长这样。
它能变成一个会回答问题的助手,大致经过了三个阶段:
- 预训练:在海量文本(网页、书、代码)上练习"预测下一个词元"。为了预测得准,模型不得不学会语法、事实、推理的模式。这一步决定了模型"知道"什么,花掉了绝大部分算力。
- 指令微调:用大量"问题 → 好回答"的对话样本继续训练,让它学会"看到问题就回答问题"。
- 偏好对齐:让人或者别的模型给回答打分,训练它更倾向于给出有帮助、诚实、安全的回答。
第 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 里解决。
练习
- 用
show_candidates试几个不同的开头:"我最喜欢的编程语言是"、"1+1="、"从前有座山"。哪个的候选最集中,哪个最分散? - 把
top_logprobs改成 20,看看"周末我打算去"的候选里有没有你意想不到的。 - 问模型一个你非常了解、但比较冷门的具体问题(你们公司的产品细节、你家乡的一条小街、你熟悉的一个开源库的某个版本),关掉思考,看它会不会编。再开着思考问一次,有没有区别?把你的发现记下来。
自测
1. 模型怎么知道一段回答应该在什么时候结束?
它并不"知道"。词表里有一个特殊的"结束"词元,模型在某一步预测出这个词元的概率很高并抽中了它,程序就停止生成。另一种情况是达到了 max_tokens 上限,被强行截断。
2. 为什么模型写到一半发现前面写错了,很难自己改正?
模型每一步都把之前写出的所有内容当作输入,只往后预测一个词元,没有回头修改的机制。前面的错误会成为后面的输入,影响后续的预测。思考模式在一定程度上缓解了这个问题:模型可以在思考过程里打草稿、纠错,最后只把结论写进正式回答。
3. 模型为什么会编造一个不存在的 CVE 编号?
模型每一步选的是"最像接下来会出现的内容",而不是"真实的内容"。它见过大量"某版本修复了某安全问题,编号 CVE-xxxx"这样的文字,当它对具体事实不了解时,就会按这种模式生成一段看起来合理的内容。越具体、越冷门的事实越容易被编造,所以这类信息一定要核实。
提问与讨论
这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。
提问 +3 积分,回答别人 +6 积分。内容经审核后公开。
正在加载讨论…