听懂了 R1 却不踏实?陪读篇:把"思考"兑换成代码、张量形状和手算数字

上一篇 R1 解剖文的实现层陪读。R1 的全部"神秘"只由两个循环构成:推理时的采样循环和训练时的 GRPO 循环。这篇把每个抽象名词兑换成具体的东西:chat template 的真实字符串、温度 0.6 改的是哪个数组、一条 32k 思考链的 2.3GB 显存账单、奖励函数的十几行正则、G=4 的优势手算、KL 惩罚的两个数值——读完你能用纸笔把一次推理和一次训练更新走一遍。

上一篇讲完 R1 的四层链路后,一个诚实的困惑是:每句话都能听懂,“激励""涌现""解剖”这个故事也能复述——但心里不踏实。这种不踏实有明确的病因:叙事层的名词在你脑子里没有对应的可执行物。“模型学会了反思”——落到代码上是哪一行?”奖励答对的回答”——奖励怎么变成参数的变化?”思考 32k token”——显存里多了几个 GB?这篇陪读只做一件事:把上一篇的每个抽象名词,兑换成代码、张量形状或一个你能手算的数字。读完的检验标准:给你纸和笔,你能把一次推理和一次训练更新用具体数字走一遍。

一句话主线

R1 的全部”神秘”只由两个循环构成:推理时的采样循环(十几行)和训练时的 GRPO 循环(几十行)。所谓”学会思考”,就是第二个循环反复微调参数,让第一个循环更常采样出能答对的长草稿。

下面两条主线各走一遍,所有数字都可以在纸上复算。


一、推理侧:“思考”就是一个 while 循环

1.1 输入的真实样子:一个拼接出来的字符串

“给 R1 一道题”在实现层是纯粹的字符串拼接。按官方 README 的使用建议(不加 system prompt、数学题注明分步作答并把答案放进 \boxed{}强制以 <think>\n 开头),真正进入模型的字符串长这样:

<|begin▁of▁sentence|><|User|>Please reason step by step, and put your
final answer within \boxed{}. 求 1+2+...+100 的值。<|Assistant|><think>

注意最后那个 <think>:它不是模型自己”决定思考”后写下的,而是提前塞进输入里的——README 原话是”为确保模型进行充分推理,我们建议强制模型在每次输出的开头以 <think>\n 起始”。也就是说,“进入思考模式”这件事,一半是训练出来的习惯,另一半是推理框架用模板硬保证的。第一个抽象名词兑换完毕:“思考模式” = 模板里预填的一个标记。

这个字符串接着被 tokenizer 切成整数序列。<think></think>、“Wait” 在 V3/R1 的词表里就是 129,280 个条目中的普通几个(词表大小来自 V3 官方 configvocab_size: 129280)——没有任何专门的”推理电路”入口,它们和”苹果”这个词在机制上完全平等。

1.2 采样循环:十几行,没有别的了

接下来发生的全部事情:

ids = tokenize(template(question))        # 整数列表,长度 ~50
while True:
    logits = model(ids)[-1]               # [129280],只取最后一个位置
    probs  = softmax(logits / 0.6)        # 温度 0.6,官方推荐值
    probs  = top_p_filter(probs, 0.95)    # 砍掉累计概率 95% 以外的长尾
    next_id = multinomial_sample(probs)   # 掷一次骰子
    ids.append(next_id)
    if next_id == EOS: break

这个循环跑几千到几万圈,就是一次完整的”思考 + 回答”。几个关键兑换:

“温度 0.6”改的是哪个数字?就是 logits / 0.6 这一步——除以一个小于 1 的数,大 logit 更大、小 logit 更小,softmax 之后分布变尖。手算一个三词玩具词表,logits 为 [2.0, 1.0, 0.0][2.0,\ 1.0,\ 0.0]:

词 A词 B词 C
温度 1.00.6650.2450.090
温度 0.60.8170.1540.029

温度从 1.0 降到 0.6,首选词概率从 66% 涨到 82%,长尾词被压到 3% 以下——更收敛,但仍有随机性。

“模型决定反思”是什么?是某一圈循环里,“Wait” 这个 id 的概率恰好被骰子掷中。没有 if 语句判断”我该反思了”,只有一个概率分布在那一步恰好给了 “Wait” 较高的值。上一篇说的”重构循环启动标志”,实现层就是这一次采样的结果

“思考结束”是什么?</think> 被采样出来。没有定时器、没有外部控制器——什么时候停止思考,本身也是从分布里掷出来的。这就是为什么上一篇说 R1”没有完全学会何时停止思考”:停止时机只是个学出来的概率,不是个保证。

1.3 “思考的代价”记在哪:一笔可以手算的显存账

循环每转一圈,这个 token 的注意力键值(KV)要存进显存,后续每个 token 都要回看它——这就是 KV cache。“架构让长思考付得起账”(上一篇 2.0 节)兑换成数字:

V3/R1 的 MLA 把每个 token 每层的 KV 压成一个 512 维潜向量加一个 64 维位置键(config 里的 kv_lora_rank: 512qk_rope_head_dim: 64),共 61 层,BF16 每个数 2 字节:

(512+64)×61×2 字节70 KB / token(512 + 64) \times 61 \times 2 \text{ 字节} \approx 70\ \text{KB / token}

一条拉满 32,768 token 的思考链:70 KB×327682.3 GB70\ \text{KB} \times 32768 \approx 2.3\ \text{GB}

作为对照,若用传统 128 头多头注意力(每头 128 维,K、V 各存一份):2×128×128×61×23.8 MB/token2 \times 128 \times 128 \times 61 \times 2 \approx 3.8\ \text{MB/token},同样 32k 就是约 131 GB——一张卡都装不下,是 MLA 的 57 倍DeepSeek 官方数字正是 70KB/token。所以”长思考在经济上可行”不是修辞:它是 2.3 GB 和 131 GB 的区别。


二、训练侧:一次 GRPO 更新的完整数据流

2.1 “奖励函数”是十几行普通 Python,不是模型

上一篇说 R1-Zero 只用”规则奖励”,兑换成代码就是这种东西:

def reward(completion: str, ground_truth: str) -> float:
    # 准确性奖励:从 \boxed{} 里抠出答案,和标准答案比对
    m = re.search(r"\\boxed\{(.+?)\}", completion)
    acc = 1.0 if m and math_equal(m.group(1), ground_truth) else 0.0
    # 格式奖励:思考过程是否包在 think 标签里
    fmt = 1.0 if re.fullmatch(r"<think>.+?</think>.+", completion,
                              re.DOTALL) else 0.0
    return acc + fmt

没有神经网络裁判,没有对推理过程的任何评价——正则表达式加字符串比对。它看不见你写了多漂亮的推导,只看得见方框里的答案对不对、格式在不在。上一篇说”奖励只认对错”,指的就是这个函数的返回值只有那几种。

2.2 一个训练 step 的张量流水账

R1 的真实配置(Nature 版披露):每步 32 道题、每题采样 16 份答卷、单份最长 32,768 token。把每个中间量的形状列出来:

步骤产物形状说明
取一批题prompts32 条字符串数学/代码题,带标准答案
采样(跑第一节的循环 512 次)completions[512, ≤32768]32 题 × 16 份答卷
逐份打分(2.1 的函数)rewards[512]每份答卷一个标量
组内标准化advantages[512]每 16 份一组算均值/标准差
前向重算对数概率logprobs[512, T]每个 token 一个数
加权求和loss标量一个数,backward 的起点

一步的信息量值得体感一下:512 份答卷,若平均每份几千 token,一次参数更新背后就是百万级 token 的梯度信号——而指引这一切的,只有 512 个由正则表达式算出来的标量。

2.3 手算一次更新(缩小到 G = 4)

把组缩小到 4 份答卷方便手算。某道题的 4 份答卷,奖励算出来是 [1,0,0,1][1, 0, 0, 1](两份答对,两份答错):

  • 组均值:rˉ=0.5\bar r = 0.5;组标准差:σ=0.5\sigma = 0.5;
  • 优势:A^i=(ri0.5)/0.5=[+1, 1, 1, +1]\hat A_i = (r_i - 0.5)/0.5 = [+1,\ -1,\ -1,\ +1]

关键的一步来了:这个优势值被广播到整份答卷的每一个 token 上(结果监督下,一份答卷内所有 token 共享同一个优势)。答卷 1 有 3,000 个 token?这 3,000 个 token 的优势全是 +1。首次更新时新旧策略概率比 ρ=1\rho = 1,损失对参数的作用方向就是:

推高答对答卷中每个 token 的对数概率(力度×1),压低答错答卷中每个 token 的(力度×1)\text{推高答对答卷中每个 token 的对数概率(力度} \times 1\text{),压低答错答卷中每个 token 的(力度} \times 1\text{)}

现在可以兑换上一篇最”玄”的那句话——“aha moment 是涌现的”:

没有任何一行代码点名表扬 “Wait”。它只是恰好频繁出现在答对的答卷里,于是随着一次次更新被”连坐”推高概率;概率高了,下一轮采样它出现得更频繁;带 “Wait” 的答卷如果继续更常答对,它就继续被推高。所谓”模型学会了反思”,在实现层只是这个正反馈的宏观读数。回答变长同理:长答卷更常答对 → 长答卷里的 token 更常被推高 → 分布向长答卷偏移。

再手算 KL 项。GRPO 用的无偏估计器(上一篇给过公式)对每个 token 算 πrefπθlnπrefπθ1\frac{\pi_{ref}}{\pi_\theta} - \ln\frac{\pi_{ref}}{\pi_\theta} - 1:

  • 若某 token 的新策略概率和参考模型差 1.2 倍:1.2ln1.210.0181.2 - \ln 1.2 - 1 \approx 0.018,惩罚几乎为零;
  • 若偏到 2 倍:2ln210.3072 - \ln 2 - 1 \approx 0.307,惩罚是前者的 17 倍

这就是”KL 约束防止策略跑到语言分布的荒郊野外”的具体含义:小偏移几乎免费,大偏移代价陡增——它不阻止学习,只阻止暴走。

2.4 循环长什么样,以及”这不是玩具”

把上面串起来,训练循环的骨架:

for step in range(num_steps):
    prompts = sample_questions(32)
    completions = model.generate(prompts, n=16,          # 512 份答卷
                                 max_tokens=32768)
    rewards = [reward(c, gt) for c, gt in zip(...)]      # [512],纯规则
    adv = groupwise_normalize(rewards, group_size=16)    # [512]
    logp = model.logprobs(completions)                   # [512, T]
    ratio = (logp - logp_old).exp()
    loss = -torch.min(ratio * adv,
                      ratio.clamp(1-eps, 1+eps) * adv).mean() \
           + beta * kl_penalty(logp, logp_ref)
    loss.backward()
    optimizer.step()                                     # 权重位移一小步

这个骨架不是我的示意图,它就是开源实现的真实接口:Hugging Face TRL 的 GRPOTrainer 里,reward_funcs 参数接收的就是 2.1 那样的普通 Python 函数,num_generations 就是组大小 GG。你今天就可以在一个 0.5B 的小模型上跑通同一套循环。

2.5 更新到哪了:没有”推理模块”,只有权重的位移

optimizer.step() 改的是和推理时同一套前向权重——没有单独的”思考模块”被训练出来。几千步更新的累积效果,就是第一节那个采样循环在每一步给出的概率分布变了:该 “Wait” 的地方 “Wait” 概率高了,该收束的地方 </think> 概率高了。

这也是上一篇机制层证据在实现层的自然对应:训练自始至终只是在原有参数空间里做小位移,所以”回溯”这样的行为最终表现为激活空间里一个可加减的线性方向、思维链里几个高因果权重的锚点句——行为没有藏在新长出来的结构里,它就摊在原来那套权重的位移里。


三、两个循环咬合:飞轮的实现层视图

flowchart LR
    W["权重 θ"] -->|"采样循环 ×512 份"| C["512 份带思考的答卷"]
    C -->|"正则 + 比对"| R["rewards [512]"]
    R -->|"组内标准化"| A["advantages [512]"]
    A -->|"clip 损失 + backward"| G["梯度"]
    G -->|"optimizer.step()"| W

眼熟吗?这和 MCTS 篇第五节 AlphaGo Zero 的飞轮同构:那边是”搜索产出比网络直觉更强的走法分布,回头当训练目标”;这边是”采样 + 结果打分产出比当前分布更好的答卷排序,回头当梯度方向”。区别只在探索的载体——AlphaGo 用显式的树,R1 用 16 份平行草稿。

四、自检清单:“踏实”的检验标准

合上文章,能答出这五问,说明兑换完成:

  1. “Wait” 在什么意义上被奖励了?(没有代码点名它;它因频繁出现在答对的答卷里被整卷连坐推高)
  2. 温度 0.6 具体改了哪个数组的哪一步?改完分布怎么变?(logits 除以 0.6 再 softmax;0.665 → 0.817)
  3. 一条 32k 思考链占多少显存?怎么算?(70KB/token × 32768 ≈ 2.3GB;公式 (512+64)×61×2(512+64)\times 61\times 2 字节)
  4. 同一份答卷里不同 token 的优势一样吗?正负号由什么决定?(结果监督下全卷共享;由这份答卷的奖励高于还是低于组均值决定)
  5. KL 项在防什么?偏移 1.2 倍和 2 倍的惩罚差多少?(防分布暴走;0.018 对 0.307,约 17 倍)

带走的模型

这篇的方法论比内容更值得带走:

叙事层的每个名词,都应该能兑换成一个张量形状、一行代码或一个能手算的数字;兑换不出来的名词,就是你还没懂的部分。“听懂了但不踏实”,病因几乎总是脑子里还留着几个没兑换的名词——把它们找出来,逐个兑换,踏实感就是兑换完成的回执。


参考来源

一手资料:

可上手的实现:

站内系列: