Next-token prediction,一个字一个字地猜

outlier 15 分钟 5909 字 0 0
系列教程Agent从入门到放弃 第 8 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:它在解释里算对了,却坚持错误的答案

有一次我让模型算一道应用题,为了方便程序解析,我在 prompt 里要求:"第一行写答案,然后再解释过程。"

它的回复是这样的(我凭记忆复述一下大意):

答案:1240

解释:每天进货 47 箱,连续 8 天,一共 376 箱。卖掉 23 箱,剩下 353 箱。翻 4 倍,就是 1412 箱。所以答案是 1240 箱。

你没看错。它在解释里一步一步算出了 1412,然后在最后一句话里,又回到了开头那个 1240。

我盯着屏幕愣了几秒。这是什么操作?明明算对了,为什么不改?

后来我明白了:它没法"改"。 第一行那个 1240 写出来的时候,后面的推理过程还不存在。等它推理完,那个错误的答案已经白纸黑字地躺在上下文里了,而它最擅长的事情,就是让后面写的东西和前面写的东西保持一致。

这一篇讲模型是怎么生成文字的。机制本身很简单,但它解释了一个特别实用的现象:让模型"先推理后结论"和"先结论后解释",效果完全不同。 读完本篇,你应该能回答这几个问题:

  • 模型是怎么一个 token 一个 token 地生成一段话的?
  • logits、softmax、概率分布分别是什么?
  • 为什么输入很快、输出很慢,输出还更贵?
  • 为什么说"模型没有草稿纸"?
  • 这对写 prompt、设计 Agent 有什么启示?

一、一次只猜一个

1.1 自回归:又一个循环

第 01 篇说过,大模型每次只做一件事:根据前面所有的 token,预测下一个 token。 那一整段回答是怎么来的?答案是又一个循环:

// 模型生成一段话的过程(示意)
func Generate(prompt []Token) []Token {
    ctx := prompt
    var out []Token
    for len(out) < maxTokens {
        dist := LLM(ctx)     // 1. 根据目前所有的 token,算出下一个 token 的概率分布
        next := sample(dist) // 2. 按概率选一个(第 08 篇细讲)
        if next == EOS {     // 3. 选中了"结束"标记,就停
            break
        }
        out = append(out, next)
        ctx = append(ctx, next) // 4. 拼回输入,继续猜下一个
    }
    return out
}

这种"用自己的输出作为下一步的输入"的方式,叫自回归(autoregressive)。

flowchart LR C1["今天天气"] --> M1(("模型")) --> T1["很"] T1 --> C2["今天天气很"] --> M2(("模型")) --> T2["好"] T2 --> C3["今天天气很好"] --> M3(("模型")) --> T3["。"] T3 --> C4["今天天气很好。"] --> M4(("模型")) --> T4["‹结束›"] style T4 fill:#ffcdd2,stroke:#b71c1c

有意思的是,这和第 02 篇 Agent 的那个循环几乎是同一个形状:模型输出一点东西,拼回上下文,再调用一次模型。 只不过 Agent 的循环一圈是"一次模型调用 + 一次工具执行",而生成的循环一圈是"一个 token"。

flowchart TB subgraph A["外层:Agent 循环(第 02 篇)"] direction LR A1["调用模型"] --> A2["执行工具"] --> A3["结果拼回历史"] --> A1 end subgraph G["内层:生成循环(本篇)"] direction LR G1["预测下一个 token"] --> G2["拼回上下文"] --> G1 end A1 -.->|"每一次调用模型,
里面都在转这个"| G style A fill:#fff3e0,stroke:#e65100 style G fill:#e3f2fd,stroke:#1565c0

Agent 是一个循环套着另一个循环。 外层循环里,每一次"调用模型",内层都在一个 token 一个 token地转。

1.2 什么时候停

生成的循环有两种停法:

  1. 模型自己说停:词表里有一个特殊的"结束"token,模型预测出它,就表示"我说完了"。这对应 API 里的 stop_reason: end_turn(DeepSeek 叫 finish_reason: stop);
  2. 被程序叫停:

    • 达到了 max_tokens 上限,对应 max_tokens(DeepSeek 叫 length);
    • 生成的文字里出现了你指定的停止序列,对应 stop_sequence。第 03 篇 ReAct 里那个 Observation: 就是这么用的。

如果模型想调用工具,它会生成一段特殊格式的内容,API 把它解析成 tool_use,然后以 stop_reason: tool_use 停下来。所以 function calling 本质上也是"生成文字",只是生成的文字有特殊的格式,被 API 识别出来了。

二、logits、softmax 和概率分布

2.1 模型每一步到底输出什么

"预测下一个 token"说得笼统,具体是怎么预测的?

模型每一步的直接输出,是一长串数字,词表里有多少个 token,这串数字就有多长。每个数字是模型给对应那个 token 打的"分",分越高,表示它越觉得下一个应该是这个 token。这串原始分数叫 logits。

logits 可以是任意的正数或负数,没法直接当概率用。所以要经过一个叫 softmax 的函数,把它变成一组加起来等于 1 的概率。

2.2 softmax 做了什么

不用记公式,记住它做的两件事就够了:

  1. 把每个分数变成正数:取指数,分数越高,变出来的数越大,而且是"指数级"地大;
  2. 归一化:每个数除以总和,让它们加起来等于 1。

拿一个只有三个候选词的小例子算一下。假设在"今天天气"后面,模型给三个候选打的分是:

候选logit取指数除以总和(概率)
很2.07.390.66
不1.02.720.24
真0.11.110.10
合计 11.211.00

分数 2.0 比 1.0 只高了 1,但概率是 0.66 比 0.24,高了将近三倍。softmax 会放大分数之间的差距,这是它被设计成取指数的原因。

flowchart LR C["上下文:今天天气"] --> M(("模型")) M --> L["logits
很 2.0 / 不 1.0 / 真 0.1 / ……"] L --> S["softmax"] S --> P["概率
很 66% / 不 24% / 真 10% / ……"] P --> X["采样:选中「很」"] style S fill:#fff9c4 style X fill:#fff3e0,stroke:#e65100

有了概率分布之后,从里面选一个出来,这一步叫采样。是每次都选概率最高的那个,还是按概率随机选,选的时候要不要把分布"压扁"或"拉尖",这就是 temperature、top-p 这些参数控制的东西,第 08 篇专门讲。

2.3 它不是在"查答案"

理解了这个过程,你会对模型的行为多一份直觉:它每一步都只是在回答"根据前面这些,下一个最可能是什么"。

它没有在某个数据库里查到答案再"念"出来。它也没有一份写好的完整腹稿:可解释性方面的研究发现,模型内部对"后面大概要说什么"会有一些预期,比如写诗时会提前"想好"押韵的字。但这些预期只是影响每一步的打分,真正落到纸面上的,永远是一次一个 token,而且每一个都是在前面所有 token 确定之后才选出来的。问它"法国的首都是",它输出"巴黎",是因为在它见过的海量文本里,这几个字后面最常跟着的就是"巴黎"。这解释了它为什么在常见问题上非常可靠,在冷门问题上却会一本正经地胡说:冷门问题上,"最可能的下一个 token"不一定是对的,但它总能找出一个"看起来最像"的。第 13 篇讲幻觉时会回到这一点。

三、输入很快,输出很慢

3.1 两个阶段

一次模型调用,其实分成两个阶段:

  • 读输入(prefill):输入的 token 是已知的,模型可以并行地一次把它们全部处理完;
  • 写输出(decode):每生成一个 token,都要等上一个 token 出来之后才能算,只能串行地一个一个来。
sequenceDiagram participant U as 你 participant M as 模型 U->>M: 发送 2000 个 token 的输入 Note over M: prefill:2000 个 token
并行处理,很快 M-->>U: 第 1 个输出 token(首 token 延迟) M-->>U: 第 2 个 M-->>U: 第 3 个 Note over M: decode:每个 token
都要等上一个 M-->>U: ……第 500 个 M-->>U: 结束

这就解释了几个你一定遇到过的现象:

  • 流式输出时,文字是一点一点"蹦"出来的:那就是 decode 阶段,一个 token 一个 token 地生成。第 29 篇会实现流式输出;
  • 输出比输入贵:第 06 篇那张价目表里,DeepSeek 的输出单价是未命中缓存输入的四倍。并行处理的输入,硬件利用率高;一个一个生成的输出,利用率低,自然更贵;
  • 让模型"写长一点",时间几乎是线性增长的:输出 500 个 token 大约是输出 50 个 token 的十倍时间。而输入多 10 倍,增加的时间往往没那么夸张。

3.2 KV cache:不用每个 token 都从头算

看到这里你可能会有个疑问:内层循环每猜一个 token,都要"把上下文过一遍模型"。上下文有 2000 个 token,生成第 500 个输出 token 时,上下文已经有 2499 个了,难道每一步都把这 2000 多个 token 重新算一遍?

不用。第 09 篇会讲,模型在处理每个 token 时,会算出一些中间结果(叫 key 和 value),后面的 token 要"回头看"前面的内容,看的就是这些中间结果。前面的 token 一旦算过,它们的 key 和 value 就不会再变,因为后面新来的 token 不会改变前面 token 的计算。

所以推理服务会把它们存起来,这就是 KV cache。每生成一个新 token,只需要算这一个新 token 自己的部分,再去"查看"缓存里前面所有 token 的 key 和 value。

这个机制顺带解释了两件事:

  • prefill 为什么是一次性的大计算:它就是在给输入的所有 token 建 KV cache;
  • 第 10 篇的 prompt caching 为什么能省钱:如果两次请求的开头完全一样,服务端可以把上一次请求为这段开头建好的 KV cache 直接拿来用,跳过这部分 prefill。这也是为什么缓存要求前缀完全一致:中间改了一个 token,从那个位置往后的 key 和 value 就全都不一样了。
flowchart LR P["输入 2000 个 token"] -->|"prefill:一次算完"| K["KV cache
2000 份"] K --> D1["生成第 1 个:
只算 1 个新 token"] D1 -->|"追加进缓存"| K K -.->|"下次请求前缀相同"| R["直接复用
(prompt caching)"] style K fill:#fff9c4 style R fill:#e8f5e9,stroke:#2e7d32

3.3 对 Agent 意味着什么

Agent 的每一圈,大部分 token 是输入(越来越长的历史),只有一小部分是输出(模型说下一步干什么)。这有两个推论:

  1. 控制输出长度,比控制输入长度更能省时间:让模型少说废话、直接调工具,每一圈都能快不少;
  2. 输入虽然便宜,但它在每一圈都要重新发一遍:所以第 10 篇的缓存才那么重要,它把"重复的输入"变得非常便宜。

四、模型没有草稿纸

4.1 每个 token,花的"力气"是固定的

现在说本篇最重要的一个认知。

模型生成每一个 token 时,做的计算量大致是固定的:把上下文过一遍模型的所有层,算出下一个 token。不管这个 token 是一个简单的"的",还是一道复杂算术题的答案,它能花的"思考力气"都差不多。

人做一道复杂的算术题,会在草稿纸上一步步算,最后把答案写到答题卡上。模型没有草稿纸,它写出来的每一个字,就是它全部的思考过程。它要想"多想一会儿",唯一的办法就是多写几个 token:把中间步骤写出来,后面的 token 就能"看到"这些中间结果。

4.2 两种顺序,两种命运

回到开场那道题。对比一下两种顺序:

flowchart TB subgraph A["先答案后解释"] direction TB A1["答案:1240
(这时候推理还不存在,
只能凭一个 token 的力气猜)"] --> A2["解释:47 × 8 = 376 ……
= 1412"] A2 --> A3["所以答案是 1240
(和前面保持一致)"] end subgraph B["先推理后答案"] direction TB B1["47 × 8 = 376"] --> B2["376 - 23 = 353"] B2 --> B3["353 × 4 = 1412"] B3 --> B4["答案:1412
(前面每一步都看得见)"] end style A1 fill:#ffcdd2,stroke:#b71c1c style A3 fill:#ffcdd2,stroke:#b71c1c style B4 fill:#e8f5e9,stroke:#2e7d32
  • 先答案后解释:答案的那几个 token 生成的时候,后面的推理过程还不存在。模型只能用"一个 token 的力气"直接猜一个多步计算的结果。猜完之后,后面的解释无论写得多漂亮,都只是在给一个已经说出口的答案找补;
  • 先推理后答案:每一步的中间结果都写在了上下文里,生成最后那个答案的时候,模型能"看到"前面所有的计算。答案是推出来的,不是猜出来的。

开场那个"算对了却坚持错误答案"的现象,也就解释通了:已经写出来的 token,模型没法收回。 它只能顺着往下写。而它被训练得特别擅长保持前后一致,所以在解释的最后,它倾向于回到开头那个答案,哪怕中间的推理指向了另一个数。

4.3 解释不等于真实的思考过程

这还引出一个有点反直觉的推论:模型在答案之后写的解释,不一定是它"真实"的思考过程。 那个解释是在答案已经确定之后才生成的,它的目标是"让这段解释读起来合理",而不是"如实还原答案是怎么来的"。

所以当你看到模型给出一个结论、再附上一段头头是道的理由时,要知道:理由可能是事后编的。 想让理由真的支撑结论,就要求它先写理由、再下结论。

4.4 Agent 里的"说出口就收不回"

这个机制在 Agent 里还有一个更隐蔽的表现:不只是一次回答内部,整个消息历史都有"保持一致"的惯性。

举个例子。Agent 在第 3 圈读了一个文件,然后在 text 块里写了一句:"看来问题出在缓存失效上。"这个判断可能是错的。但从第 4 圈开始,这句话就躺在消息历史里了,之后的每一圈,模型都会"看到"自己说过这句话。它更倾向于去找支持这个判断的证据,而不是推翻它。如果后面的工具结果不够明确,它可能在一个错误的方向上越走越远。

人也有类似的毛病,叫"确认偏误"。但对模型来说,这几乎是它的工作原理本身:让后面写的东西和前面写的东西保持一致,正是它被训练出来的本事。

几个应对办法:

  • 用事实打断它:给它一个明确的反例,比如跑一个测试证明缓存是正常的。工具结果是新的输入,足够明确的证据能扭转方向;
  • 换一个干净的上下文:方向彻底错了的时候,与其在原来的会话里反复纠正,不如开一个新会话,把已经确认的事实写清楚重新开始。第 16 篇讲 context rot、第 28 篇实现 /reset 时会再说这件事;
  • 让它先列假设、再验证:在 system prompt 里要求"先列出两三个可能的原因,逐一验证",而不是一上来就认定一个。这是把"先推理后结论"用到了 Agent 的排查过程上。

五、对写 prompt 和设计 Agent 的启示

5.1 写 prompt

  • 复杂的问题,要求它先分析再结论:"先一步一步分析,最后一行给出结论",比"直接告诉我答案"可靠得多;
  • 但简单的问题不需要:问一个事实、做一个简单的改写,让它先写一大段分析,只是在浪费时间和输出 token(输出更贵);
  • "只输出答案"要慎用:为了方便解析,我们常常要求模型"只输出一个数字""只回答 YES 或 NO"。这等于收走了它的草稿纸。对简单的判断没问题,对需要推理的判断,就会明显变差。

5.2 设计结构化输出

这一点特别容易踩坑:JSON 里字段的顺序也很重要。 模型是按顺序生成 JSON 的,如果 schema 里 "answer" 字段排在 "reasoning" 前面,它就会先写答案、再写理由,又回到了"先答后想"。

配套仓库第 36 篇的 LLM 评审(evals/judge.go)就专门注意了这一点,注释里写着:

//   - 先要理由、后要结论(第 07 篇:模型没有草稿纸),structured outputs 保证能解析;

它的 schema 里,逐条核对的 checks(每一条都带着 evidence 依据)排在最终的 pass 前面。

5.3 设计 Agent

  • 让它先想清楚再动手:很多编码 Agent 都有"计划模式",先让模型写出一份计划,再开始改代码。这是把"先推理后行动"用到了 Agent 层面;
  • 推理模型把这件事内置了:现在很多模型在回答之前,会先生成一段"思考",再给出正式的回答。思考过程同样是 token,同样按输出计费。第 12 篇会专门讲它,以及什么时候值得开、什么时候开了反而又慢又贵。

六、动手:先答还是先想

实验:同一批题,两种顺序

配套仓库的 examples/ch07-reasoning-order/main.go 会随机生成一批多步算术应用题,答案由程序算好:

a, b, c, d := 20+r.Intn(80), 3+r.Intn(9), 10+r.Intn(40), 2+r.Intn(5)
ans := (a*b - c) * d
ps = append(ps, problem{
    text: fmt.Sprintf("一个仓库每天进货 %d 箱,连续进货 %d 天,然后一次性卖掉 %d 箱。之后把剩下的箱数翻 %d 倍。最后一共有多少箱?",
        a, b, c, d),
    answer: ans,
})

然后同一批题,分别加上两种要求:

const (
    answerFirst = "先在第一行写「答案:<数字>」,然后再解释你的计算过程。"
    reasonFirst = "先一步一步写出计算过程,最后一行写「答案:<数字>」。"
)

用正则把"答案:后面的数字"抠出来,和标准答案比对。判分时有个小细节:先答后想取第一个"答案:",先想后答取最后一个。 前者要的是它一开口就认定的那个答案,后面解释时改口不算数;后者是因为推理过程中间也可能出现"答案"两个字。

这个实验要接真实模型才有意义,在线 Playground 演示不了:先 export DEEPSEEK_API_KEY=sk-... 再跑。每题调用 2 次,-n 20 就是 40 次调用。
export DEEPSEEK_API_KEY=sk-...
go run ./examples/ch07-reasoning-order -n 20

有一个关键的设置:程序默认关掉了 DeepSeek 的思考模式。DeepSeek 的模型默认会先思考再回答,思考发生在可见的回答之前,等于它已经在草稿纸上算过了,那"先答后想"就不吃亏了,实验就看不出差别。

你通常会看到"先推理后答案"的正确率明显更高。具体高多少,取决于题目难度和模型。把你跑出来的数字记下来,再做下一个对照:

go run ./examples/ch07-reasoning-order -n 20 -think

打开思考模式再比一次。这次两种顺序的差距通常会大幅缩小,因为模型在给出可见答案之前,已经在思考里把题算过了。这就是第 12 篇推理模型的伏笔。

-seed 参数控制题目的随机种子,固定之后两次运行的题目一样,方便对比。不设 Key 直接跑也可以,用的是离线演示模型,但它不会算题,两种模式都接近 0 分,只能看看实验是怎么设计的。

动手作业

  1. 把题目改得更难一点(比如多加一步运算),看看两种顺序的差距会不会变大;
  2. 把 answerFirst 改成"只输出答案,不要任何解释",看看正确率和"先答案后解释"比有什么变化;
  3. 找一个你自己工作里的判断题(比如"这段代码有没有并发问题"),分别用"先给结论"和"先分析"两种方式问几次,对比结论的稳定性。
  4. 在一次多轮对话里,先故意给模型一个错误的前提(比如"我们已经确认问题出在数据库连接池"),再给它一些和这个前提无关的线索,看它会不会一直围着这个前提打转;然后开一个新对话,只给线索、不给前提,对比一下它的结论。

七、踩坑记录:和"一个字一个字地猜"有关的六个坑

坑 1:为了好解析,让模型"只输出答案"。
简单的分类没问题,需要推理的判断就会变差。如果既要好解析、又要它推理,可以让它先写分析,再在最后一行或者一个固定的 JSON 字段里写结论,程序只解析那一部分。

坑 2:JSON 里结论字段排在理由前面。
{"pass": true, "reason": "..."} 这种 schema,模型会先决定 pass,再编理由。改成 {"reason": "...", "pass": true},同样的模型,判断往往更靠谱。

坑 3:把模型的解释当成它真实的思考。
答案之后的解释可能是事后找补。用模型做评审、做决策时,要让它先写依据再写结论,并且检查依据本身对不对,而不是看它的语气有多肯定。

坑 4:对所有任务都要求"一步一步推理"。
推理是要花 token 的,输出 token 最贵,也最慢。改个错别字、翻译一句话,用不着推理。推理用在需要多步计算、多条件判断的地方,其他地方让它直接答。

坑 5:判分时抠错了答案。
本篇实验里那个"取第一个还是最后一个"的细节,我第一版没注意,统一取了第一个。结果"先推理后答案"有好几道题被判错:模型在推理中途写了一句"答案应该接近 1400",正则先抠到了它。判分逻辑本身也要被验证,第 35、36 篇会反复强调这一点。

坑 6:在同一个会话里反复纠正一个跑偏的 Agent。
它认定了一个错误的方向,你说"不对",它道歉,换个说法继续沿着原来的思路走;你再说"不对",它再道歉。几轮下来,消息历史里堆满了错误的推理和道歉,它被这些内容拖得越来越偏。4.4 节说过,已经写进历史的东西,会持续影响后面的每一圈。与其纠正第五次,不如开一个新会话,第一句话就把已知的事实和排除掉的方向写清楚。

一句话总结

模型一次只预测一个 token,logits 经过 softmax 变成概率,选中的 token 拼回上下文再预测下一个;它没有草稿纸,写出来的就是它的思考,所以"先推理后结论"比"先结论后解释"可靠得多。

flowchart LR C["上下文"] --> M(("模型")) --> L["logits"] --> S["softmax"] --> P["概率"] --> T["选一个 token"] T -->|"拼回上下文"| C style T fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭