<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://jxlwqq.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jxlwqq.github.io/" rel="alternate" type="text/html" /><updated>2026-09-24T11:19:40+00:00</updated><id>https://jxlwqq.github.io/feed.xml</id><title type="html">jxlwqq 的技术博客</title><subtitle>欢迎来到我的个人博客</subtitle><author><name>jxlwqq</name><email>jxlwqq@gmail.com</email></author><entry><title type="html">LLM 的「误刹」：复杂任务中模型为何过早宣布完成</title><link href="https://jxlwqq.github.io/2026/09/04/how-llms-prematurely-declare-done/" rel="alternate" type="text/html" title="LLM 的「误刹」：复杂任务中模型为何过早宣布完成" /><published>2026-09-04T10:00:00+00:00</published><updated>2026-09-04T10:00:00+00:00</updated><id>https://jxlwqq.github.io/2026/09/04/how-llms-prematurely-declare-done</id><content type="html" xml:base="https://jxlwqq.github.io/2026/09/04/how-llms-prematurely-declare-done/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#一从刹车到误刹" id="markdown-toc-一从刹车到误刹">一：从「刹车」到「误刹」</a></li>
  <li><a href="#二模型为什么会误刹五个成因" id="markdown-toc-二模型为什么会误刹五个成因">二：模型为什么会「误刹」：五个成因</a>    <ul>
      <li><a href="#21-训练数据从未区分看起来完成和真的完成" id="markdown-toc-21-训练数据从未区分看起来完成和真的完成">2.1 训练数据从未区分「看起来完成」和「真的完成」</a></li>
      <li><a href="#22-局部完整被误当成全局完整" id="markdown-toc-22-局部完整被误当成全局完整">2.2 局部完整被误当成全局完整</a></li>
      <li><a href="#23-上下文里没有关于任务状态的地面真相" id="markdown-toc-23-上下文里没有关于任务状态的地面真相">2.3 上下文里没有关于任务状态的地面真相</a></li>
      <li><a href="#24-rlhf-偏好自信的收尾" id="markdown-toc-24-rlhf-偏好自信的收尾">2.4 RLHF 偏好自信的收尾</a></li>
      <li><a href="#25-协议层eos-是唯一的让权方式" id="markdown-toc-25-协议层eos-是唯一的让权方式">2.5 协议层：EOS 是唯一的让权方式</a></li>
    </ul>
  </li>
  <li><a href="#三怎么治三层框架下的对策" id="markdown-toc-三怎么治三层框架下的对策">三：怎么治：三层框架下的对策</a>    <ul>
      <li><a href="#31-协议层把验证做成终止的前置条件" id="markdown-toc-31-协议层把验证做成终止的前置条件">3.1 协议层：把验证做成终止的前置条件</a></li>
      <li><a href="#32-上下文工程把地面真相喂进上下文" id="markdown-toc-32-上下文工程把地面真相喂进上下文">3.2 上下文工程：把地面真相喂进上下文</a></li>
      <li><a href="#33-模型层用-reward-惩罚虚假的完成" id="markdown-toc-33-模型层用-reward-惩罚虚假的完成">3.3 模型层：用 reward 惩罚虚假的完成</a></li>
      <li><a href="#34-对策总览" id="markdown-toc-34-对策总览">3.4 对策总览</a></li>
    </ul>
  </li>
  <li><a href="#四总结同一枚硬币的两面" id="markdown-toc-四总结同一枚硬币的两面">四：总结：同一枚硬币的两面</a></li>
</ul>

<h2 id="一从刹车到误刹">一：从「刹车」到「误刹」</h2>

<p>上一篇<a href="/2026/09/04/how-llm-knows-when-to-stop/">《LLM 的「刹车」：下一个 Token 预测机如何知道何时停止》</a>里，我们拆解了 LLM 的停止机制：终止符是词表里的一个普通 Token，什么时候停是由训练分布塑形的软概率 $P(\text{EOS} \mid \text{上下文})$，再配合运行时层的 <code class="language-plaintext highlighter-rouge">max_tokens</code> 兜底。那篇文章留了一个尾巴：EOS 只是概率分布里的竞争者，<strong>停得准的前提是任务落在训练分布内</strong>。</p>

<p>这一篇看分布外的故事。用过长周期 Agent 的人多半见过这样的场面：代码只改了一半就开始总结陈词；测试一次都没跑，回复里却写着「所有测试已通过 ✅」；明确列了五条需求，做完了两条就宣布任务完成。这不是某个模型的偶发 bug，而是停止机制的必然推论：</p>

<blockquote>
  <p><strong>模型决定停下的判据，是「上下文看起来像完成了」；它没有任何直接手段检验「任务真的完成了」。简单任务里这两者几乎重合，复杂任务里差距最大。</strong></p>
</blockquote>

<p>一句话概括本文的核心观察：<strong>对模型来说，「完成」首先是一种文体，其次才是一个事实。</strong> 它在训练数据里见过成千上万份「完成报告」，极其擅长生成这种文体；而事实无法被生成，只能被验证。</p>

<blockquote>
  <p><strong>⚠️ 适用范围</strong>：本文解剖的失败模式和五个成因，主要来自<strong>早期模型 + 朴素 harness</strong> 组合下的观察。当前的 SOTA 模型配合成熟的工程实践（验证结果回灌、完成动作工具化、面向长程任务的推理训练），已经能够胜任相当长的长程任务——Claude Code、Codex 这类产品就是例证。但这不意味着本文的分析过时了：<strong>EOS 的底层机制没有变</strong>，变的是第三节那些对策被系统性地做进了模型训练和 harness 设计里。理解失败机制，恰恰是理解这些对策为什么有效的前提。</p>
</blockquote>

<h2 id="二模型为什么会误刹五个成因">二：模型为什么会「误刹」：五个成因</h2>

<h3 id="21-训练数据从未区分看起来完成和真的完成">2.1 训练数据从未区分「看起来完成」和「真的完成」</h3>

<p>SFT 数据几乎全是干净、完整、成功的演示轨迹。每一条都长这样：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;prompt&gt; ... &lt;assistant_start&gt; 做一些工作 ... 总结收尾 &lt;|assistant_end|&gt;
</code></pre></div></div>

<p>模型从这个分布里学到的「完成」判据是一组<strong>表面特征</strong>：干过活的痕迹（改了文件、写了代码）、一段收尾总结、自信的语气。训练数据里，「看起来完成」和「真的完成」从未被区分过——因为演示轨迹里它们总是同时出现。于是只要上下文凑齐这些表面特征，$P(\text{EOS})$ 就会冲高，哪怕活只干了一半。</p>

<p>还有一层更隐蔽的<strong>长度先验</strong>：演示轨迹短而整洁，真实任务长而乱。当实际轨迹的长度超出训练分布的典型长度后，EOS 概率会单纯因为「按惯例这时候该结束了」而上升——这是分布外任务长度触发的提前终止，与任务实际进度无关。</p>

<h3 id="22-局部完整被误当成全局完整">2.2 局部完整被误当成全局完整</h3>

<p>复杂任务是层级结构：一个函数写完、一个文件改完、一个计划步骤做完——每一个<strong>子结束</strong>在局部看都和「任务完成」同构：产物连贯、有收尾感。而 next-token 预测没有对终止的全局规划，EOS 决策是基于局部上下文（主要是近期 token）做出的，模型很容易在子结束点把<strong>局部连贯</strong>误判为<strong>全局完备</strong>。</p>

<p>上下文越长，这个偏差越严重：最初的任务要求在注意力里越来越远（长上下文的「中间迷失」衰减是公认现象），模型可能「真诚地」忘记早先的约束，认为活已经干完了——它不是在撒谎，它的上下文里确实「看起来」都做完了。</p>

<h3 id="23-上下文里没有关于任务状态的地面真相">2.3 上下文里没有关于任务状态的地面真相</h3>

<p>「完成」是世界状态的属性：测试通过、文件落盘、构建成功。但模型只看得见上下文窗口。如果 harness 不把验证结果喂回上下文，模型就是在<strong>未经检验的上下文</strong>上计算 $P(\text{EOS})$：</p>

<pre><code class="language-mermaid">flowchart LR
    subgraph Ctx["上下文窗口：模型唯一能看到的世界"]
        C1["改了三个文件，代码看起来合理"]
        C2["生成了总结：「测试通过，任务完成」"]
        C1 --- C2
    end
    subgraph World["世界状态：模型看不到"]
        W1["测试从未被运行"]
        W2["验收约束还有一条没满足"]
        W1 --- W2
    end
    Ctx -- "表面特征齐全，P(EOS) 冲高" --&gt; EOS["模型输出终止符"]
    World -. "地面真相，但被错过" .-&gt; EOS

    classDef terminal fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px;
    class EOS terminal;
</code></pre>

<p>最微妙的一点在这里：<strong>「测试已通过」这句话本身也是模型生成的</strong>。自述完成和实际完成走的是同一条生成通路——模型是在「合理地续写一份完成报告」，而不是在审计任何东西。SWE-bench 类基准上大量「看起来合理」的补丁无法通过测试，就是这个机制的直接体现：生成完成文体的能力，和把任务做完的能力，是两种能力。</p>

<h3 id="24-rlhf-偏好自信的收尾">2.4 RLHF 偏好自信的收尾</h3>

<p>偏好数据里，人类标注员稳定地更喜欢自信、干脆、完整的结论，而不是犹豫、冗长、留有余地的表述——这也是偏好训练已知的副作用（谄媚性 sycophancy 的来源之一）。这个偏好会泛化到终止行为上：干脆地宣布完成在偏好上得分高，继续检查显得啰嗦。于是对齐过程系统性地把模型往「尽早给出自信结论」的方向推了一把。提前终止，某种意义上是过度对齐「自信」的代价。</p>

<h3 id="25-协议层eos-是唯一的让权方式">2.5 协议层：EOS 是唯一的让权方式</h3>

<p>这一条不是模型的问题，是 harness 设计的问题。很多 Agent 框架里，<strong>结束轮次是模型交还控制权的唯一手段</strong>。模型想「阶段性收尾」「不知道下一步该干什么」时，可用的信号也只有终止符——于是「声明完成」被当成检查点来用。再加上 <code class="language-plaintext highlighter-rouge">max_tokens</code> 截断压力：被硬截断的回答没有收尾，对齐后的模型学到「宁可早点收尾也不要被截断」，进一步压低了继续工作的倾向。</p>

<h2 id="三怎么治三层框架下的对策">三：怎么治：三层框架下的对策</h2>

<p>沿用上一篇的三层分工（模型层 / 协议层 / 运行时层），每个成因都有对应的解法。</p>

<h3 id="31-协议层把验证做成终止的前置条件">3.1 协议层：把验证做成终止的前置条件</h3>

<p>最有效的一类手段：<strong>不允许未经检验的终止</strong>。harness 拦截 EOS，先强制运行验证，把真实结果喂回上下文再让模型重新决策：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 伪代码：把验证做成终止的前置条件
</span><span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
    <span class="n">token</span> <span class="o">=</span> <span class="n">sample</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">context</span><span class="p">)</span>
    <span class="n">context</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">token</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">token</span> <span class="o">==</span> <span class="n">EOS</span><span class="p">:</span>
        <span class="k">if</span> <span class="ow">not</span> <span class="n">context</span><span class="p">.</span><span class="n">has_run_verification</span><span class="p">():</span>     <span class="c1"># 从未运行过验证工具
</span>            <span class="n">inject</span><span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">run_tests</span><span class="p">())</span>           <span class="c1"># 把真实结果喂回上下文
</span>            <span class="n">prevent_stop</span><span class="p">()</span>                         <span class="c1"># 拦截本次终止
</span>            <span class="k">continue</span>
        <span class="k">if</span> <span class="n">context</span><span class="p">.</span><span class="n">last_verification_failed</span><span class="p">():</span>
            <span class="k">continue</span>                               <span class="c1"># 有失败记录，不许停
</span>        <span class="k">break</span>                                      <span class="c1"># 验证通过，允许终止
</span></code></pre></div></div>

<p>配套的流程：</p>

<pre><code class="language-mermaid">flowchart TD
    S["模型采样到终止符"] --&gt; G{"协议层检查：&lt;br/&gt;验证工具运行过吗？"}
    G -- "没有" --&gt; V["拦截终止&lt;br/&gt;强制运行测试/构建"]
    V --&gt; R["真实结果注入上下文"]
    R --&gt; S2["模型基于地面真相&lt;br/&gt;重新计算 P(EOS)"]
    S2 --&gt; S
    G -- "运行过" --&gt; P{"最近一次验证通过？"}
    P -- "通过" --&gt; DONE["允许终止"]
    P -- "失败" --&gt; R2["错误信息注入上下文&lt;br/&gt;模型继续修复"]
    R2 --&gt; S2

    classDef decision fill:#fff8e1,stroke:#f9a825;
    classDef action fill:#e8f5e9,stroke:#2e7d32;
    classDef terminal fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px;
    class G,P decision;
    class S,V,R,S2,R2 action;
    class DONE terminal;
</code></pre>

<p>另一个相关的协议设计是把「声明完成」本身工具化：不让模型用裸 EOS 结束任务，而是调用一个显式的完成工具（如 SWE-agent 的 <code class="language-plaintext highlighter-rouge">submit</code>）。好处是<strong>声明变成了显式动作，harness 可以在动作上做校验</strong>——这正是我之前<a href="/2025/12/14/how-to-build-a-coding-agent/">《如何构建一个编码 Agent》</a>里介绍的框架采用的模式。</p>

<h3 id="32-上下文工程把地面真相喂进上下文">3.2 上下文工程：把地面真相喂进上下文</h3>

<p>既然模型的判据只能是上下文里的内容，那就让上下文里<strong>有真相</strong>：</p>

<ul>
  <li><strong>验证结果回灌</strong>：每次工具调用（跑测试、构建、执行）的输出原样进入上下文，而不是让模型转述。「测试失败：3 errors」的真实输出，比模型自己写的「应该没问题」权重高得多。</li>
  <li><strong>验收清单重注入</strong>：在任务后段把最初的验收标准重新放到上下文末端，要求逐条核对后才能收尾。这是对 2.2（局部完整误判）和长上下文遗忘的直接对抗——把「全局」重新拉回模型的高注意力区。</li>
</ul>

<h3 id="33-模型层用-reward-惩罚虚假的完成">3.3 模型层：用 reward 惩罚虚假的完成</h3>

<p>RL 阶段的问题在于：如果 reward 只在终态给出（任务成败 0/1），<strong>提前终止和尝试后失败拿到相同的 reward</strong>，没有梯度压力区分两者。解法是把终止行为显式纳入 reward 塑形：</p>

<ul>
  <li>声明完成但验证失败 → 显式负奖励（比默默失败更重）；</li>
  <li>诚实的「还没完成，继续」→ 不惩罚甚至小幅奖励；</li>
  <li>验证通过后终止 → 最高奖励。</li>
</ul>

<p>这样模型才会学到「完成」与「完成文体」的区别——因为这个区别终于体现在了损失函数里。</p>

<h3 id="34-对策总览">3.4 对策总览</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">成因</th>
      <th style="text-align: left">所属层</th>
      <th style="text-align: left">对策</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">完成的表面特征被当成完成本身（2.1）</td>
      <td style="text-align: left">模型层</td>
      <td style="text-align: left">证据化完成声明，reward 惩罚虚假完成</td>
    </tr>
    <tr>
      <td style="text-align: left">局部完整误判为全局完整（2.2）</td>
      <td style="text-align: left">模型层</td>
      <td style="text-align: left">验收清单重注入上下文末端</td>
    </tr>
    <tr>
      <td style="text-align: left">上下文缺少地面真相（2.3）</td>
      <td style="text-align: left">协议层</td>
      <td style="text-align: left">验证结果强制回灌，EOS 前置校验</td>
    </tr>
    <tr>
      <td style="text-align: left">RLHF 偏好自信收尾（2.4）</td>
      <td style="text-align: left">模型层</td>
      <td style="text-align: left">reward 设计区分「自信且正确」与「自信但错误」</td>
    </tr>
    <tr>
      <td style="text-align: left">EOS 是唯一让权手段（2.5）</td>
      <td style="text-align: left">运行时层</td>
      <td style="text-align: left">完成动作工具化，提供中途汇报原语</td>
    </tr>
  </tbody>
</table>

<h2 id="四总结同一枚硬币的两面">四：总结：同一枚硬币的两面</h2>

<p>把两篇文章放在一起看，「刹不住车」和「提前误刹」是同一个机制的两面：EOS 概率被训练分布塑形，分布内任务停得又准又稳，分布外的长任务就两头出错。三个层各自的失职方式：</p>

<ul>
  <li><strong>模型层</strong>学到的是完成的文体而非完成的事实；</li>
  <li><strong>协议层</strong>没有要求「完成」必须可验证；</li>
  <li><strong>运行时层</strong>把唯一的让权通道和「任务完成」语义绑在了同一个 Token 上。</li>
</ul>

<p>最后回到那句话：<strong>对模型来说，「完成」首先是一种文体，其次才是一个事实。它极其擅长生成这种文体——而事实，只能靠把地面真相放进上下文、把验证挡在终止之前来兑现。</strong> 这不是模型要变得更聪明才能解决的问题，很大一部分是 harness 设计问题：把「宣布完成」从一句话，变成一个必须附带证据、且可被拦截校验的动作。</p>]]></content><author><name>jxlwqq</name><email>jxlwqq@gmail.com</email></author><category term="ai" /><category term="llm" /><category term="agent" /><category term="eos" /><category term="deep-dive" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">LLM 的「刹车」：下一个 Token 预测机如何知道何时停止</title><link href="https://jxlwqq.github.io/2026/09/04/how-llm-knows-when-to-stop/" rel="alternate" type="text/html" title="LLM 的「刹车」：下一个 Token 预测机如何知道何时停止" /><published>2026-09-04T04:00:00+00:00</published><updated>2026-09-04T04:00:00+00:00</updated><id>https://jxlwqq.github.io/2026/09/04/how-llm-knows-when-to-stop</id><content type="html" xml:base="https://jxlwqq.github.io/2026/09/04/how-llm-knows-when-to-stop/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#一一个悖论预测机为什么不会永远预测下去" id="markdown-toc-一一个悖论预测机为什么不会永远预测下去">一：一个悖论：预测机为什么不会永远预测下去</a></li>
  <li><a href="#二三层分工模型协议运行时" id="markdown-toc-二三层分工模型协议运行时">二：三层分工：模型、协议、运行时</a></li>
  <li><a href="#三nanochat-源码剖析" id="markdown-toc-三nanochat-源码剖析">三：nanochat 源码剖析</a>    <ul>
      <li><a href="#31-词表设计一个没有-eos-的词表" id="markdown-toc-31-词表设计一个没有-eos-的词表">3.1 词表设计：一个没有 EOS 的词表</a></li>
      <li><a href="#32-预训练停是从数据打包方式里学来的" id="markdown-toc-32-预训练停是从数据打包方式里学来的">3.2 预训练：「停」是从数据打包方式里学来的</a></li>
      <li><a href="#33-sft把终止符明确构造为训练目标" id="markdown-toc-33-sft把终止符明确构造为训练目标">3.3 SFT：把终止符明确构造为训练目标</a></li>
      <li><a href="#34-推理运行时采样循环里的停止判断" id="markdown-toc-34-推理运行时采样循环里的停止判断">3.4 推理运行时：采样循环里的停止判断</a></li>
      <li><a href="#35-一个反直觉的点token-流不全是模型预测的" id="markdown-toc-35-一个反直觉的点token-流不全是模型预测的">3.5 一个反直觉的点：Token 流不全是模型预测的</a></li>
    </ul>
  </li>
  <li><a href="#四失败模式为什么有时停不对" id="markdown-toc-四失败模式为什么有时停不对">四：失败模式：为什么有时停不对</a></li>
  <li><a href="#五从-nanochat-到生产系统" id="markdown-toc-五从-nanochat-到生产系统">五：从 nanochat 到生产系统</a></li>
  <li><a href="#六总结" id="markdown-toc-六总结">六：总结</a></li>
</ul>

<h2 id="一一个悖论预测机为什么不会永远预测下去">一：一个悖论：预测机为什么不会永远预测下去</h2>

<p>大语言模型的本质是一台<strong>下一个 Token 预测机（Next Token Predictor）</strong>：给定上下文，对整个词表输出一个概率分布，采样（或取最大）一个 Token，拼回上下文，再预测下一个。这个循环本身没有任何内在的终点——在每一步，模型能做的唯一事情就是从词表里再挑出一个 Token，「不输出」根本不在它的选项里。于是问题来了：</p>

<p><strong>终止的信号，是从哪一步、以什么形式冒出来的？</strong></p>

<p>这个问题看似哲学，答案却出奇地工程化。我们先把结论放在这里，再逐层拆开：</p>

<blockquote>
  <p><strong>「结束」本身就是词表里的一个普通 Token。停止不是模型的特殊能力，而是一次普通的 Token 预测；「真的停下来」这个动作，由外层的推理框架执行。</strong></p>
</blockquote>

<p>以 nanochat 的词表为例，它有 65,536 个条目，其中 <code class="language-plaintext highlighter-rouge">hello</code>、<code class="language-plaintext highlighter-rouge">的</code>、<code class="language-plaintext highlighter-rouge"> World</code> 这些常规 Token 之外，还混着一些看起来很特殊的字符串：<code class="language-plaintext highlighter-rouge">&lt;|assistant_end|&gt;</code>、<code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code>……它们和普通 Token 一样，只是词表中的一个 ID。模型每一步对<strong>全部</strong> 65,536 个条目输出概率，当采样落到终止符上时，生成结束。</p>

<p>本文先建立一个「三层分工」的概念框架，然后逐行剖析 <a href="https://github.com/karpathy/nanochat">nanochat</a>（我之前在 <a href="/2025/12/05/nanochat-training/">《从零构建超迷你 ChatGPT》</a> 中跑通过它的全流程训练）是怎么在每一层实现这套机制的——它没有抽象层，每一行代码都能对应到概念，是理解这个问题最好的教材。文中所有代码片段均逐字引自该仓库，引用处附有定位到具体行号的永久链接（permalink，锚定在笔者核对的 commit 上，不随上游更新漂移），点击即可跳转对照原文。</p>

<h2 id="二三层分工模型协议运行时">二：三层分工：模型、协议、运行时</h2>

<p>在展开源码之前，先给出全文的分析框架。「LLM 如何停止」这个问题，实际上是三个层的协作：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">层</th>
      <th style="text-align: left">职责</th>
      <th style="text-align: left">一句话概括</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>模型层</strong></td>
      <td style="text-align: left">学出 $P(\text{终止符} \mid \text{上下文})$</td>
      <td style="text-align: left">「停」是从数据里学出来的软概率，不是硬编码</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>协议层</strong></td>
      <td style="text-align: left">定义哪个 Token 意味着「结束」</td>
      <td style="text-align: left">词表和 Chat 模板共同约定终止符的语义</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>运行时层</strong></td>
      <td style="text-align: left">执行停止、剥除终止符、截断修补</td>
      <td style="text-align: left">推理引擎维护停止条件，模型只管预测</td>
    </tr>
  </tbody>
</table>

<p>这个分工有一个直接推论：<strong>模型在原理上没有任何机制保证它最终一定输出终止符</strong>。EOS 只是概率分布里的一个竞争者，每一步它都要和「继续写下去」的所有 Token 抢概率质量。所以运行时层永远需要一个 <code class="language-plaintext highlighter-rouge">max_tokens</code> 兜底——这是保险，不是冗余。</p>

<p>下面用 nanochat 逐层验证这个框架。</p>

<h2 id="三nanochat-源码剖析">三：nanochat 源码剖析</h2>

<blockquote>
  <p><strong>💡 代码引用说明</strong>：本节所有代码片段均逐字引自 nanochat <a href="https://github.com/karpathy/nanochat">仓库</a>的 commit <code class="language-plaintext highlighter-rouge">92d63d4</code>（与笔者本地跑训练的版本一致），每处引用附有带行号锚点的永久链接（permalink），不随上游更新漂移，可直接跳转对照上下文。</p>
</blockquote>

<h3 id="31-词表设计一个没有-eos-的词表">3.1 词表设计：一个没有 EOS 的词表</h3>

<p>nanochat 在 <a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/tokenizer.py#L9-L21"><code class="language-plaintext highlighter-rouge">nanochat/tokenizer.py</code></a> 中定义了全部 9 个特殊 Token：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">SPECIAL_TOKENS</span> <span class="o">=</span> <span class="p">[</span>
    <span class="s">"&lt;|bos|&gt;"</span><span class="p">,</span>           <span class="c1"># 文档分隔符
</span>    <span class="s">"&lt;|user_start|&gt;"</span><span class="p">,</span>    <span class="c1"># 用户消息
</span>    <span class="s">"&lt;|user_end|&gt;"</span><span class="p">,</span>
    <span class="s">"&lt;|assistant_start|&gt;"</span><span class="p">,</span>  <span class="c1"># 助手消息
</span>    <span class="s">"&lt;|assistant_end|&gt;"</span><span class="p">,</span>
    <span class="s">"&lt;|python_start|&gt;"</span><span class="p">,</span>  <span class="c1"># 助手调用 Python REPL 工具
</span>    <span class="s">"&lt;|python_end|&gt;"</span><span class="p">,</span>
    <span class="s">"&lt;|output_start|&gt;"</span><span class="p">,</span>  <span class="c1"># Python REPL 输出回助手
</span>    <span class="s">"&lt;|output_end|&gt;"</span><span class="p">,</span>
<span class="p">]</span>
</code></pre></div></div>

<p>第一个值得注意的细节：<strong>这个词表里没有 <code class="language-plaintext highlighter-rouge">&lt;|eos|&gt;</code></strong>。终止的角色由两个 Token 分担：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">&lt;|assistant_end|&gt;</code>：对话轮次终止符，等价于 Llama 3 的 <code class="language-plaintext highlighter-rouge">&lt;|eot_id|&gt;</code>、Qwen 的 <code class="language-plaintext highlighter-rouge">&lt;|im_end|&gt;</code>；</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code>：文档分隔符，被采样到时同样终止生成。</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code> 的双重身份是理解「EOS 本质」的钥匙。Karpathy 在 <a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/tokenizer.py#L70-L78"><code class="language-plaintext highlighter-rouge">tokenizer.py</code> 的 <code class="language-plaintext highlighter-rouge">from_pretrained</code></a> 里专门写了段注释解释这个历史上的命名混乱：</p>

<blockquote>
  <p>tiktoken calls the special document delimiter token <code class="language-plaintext highlighter-rouge">&lt;|endoftext|&gt;</code>… yes this is confusing because this token is almost always <strong>PREPENDED</strong> to the beginning of the document… so in nanoChat we always use <code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code> short for “beginning of sequence”, but historically it is often called <code class="language-plaintext highlighter-rouge">&lt;|endoftext|&gt;</code>.</p>
</blockquote>

<p>翻译过来：GPT-2 时代的文档分隔符叫 <code class="language-plaintext highlighter-rouge">&lt;|endoftext|&gt;</code>（「文本结束」），但它几乎总是被前插在文档<strong>开头</strong>当序列起点用。同一个 Token，既是「新文档的开始」，也是「旧文档的结束」——因为它就是<strong>文档边界本身</strong>。这个命名混乱之所以能存在，恰恰说明了 EOS 从来不是什么魔法：它只是一个在数据里出现位置有规律的普通 Token。</p>

<h3 id="32-预训练停是从数据打包方式里学来的">3.2 预训练：「停」是从数据打包方式里学来的</h3>

<p>模型层的学习发生在预训练。nanochat 的数据加载器（<a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/dataloader.py#L107"><code class="language-plaintext highlighter-rouge">nanochat/dataloader.py</code></a>）在 tokenize 每篇 FineWeb-Edu 文档时前插 <code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code>，然后用 Best-Fit 算法拼进定长训练序列。实际喂给模型的数据长这样：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;bos&gt; 文档1 &lt;bos&gt; 文档2 &lt;bos&gt; 文档3 &lt;bos&gt; 文档4 ...
</code></pre></div></div>

<p>关键在损失函数的构造方式：预训练的 targets 就是 inputs 右移一位，<strong>所有位置都参与损失</strong>（Best-Fit 打包保证 100% 利用率、无 padding）。这意味着「文档 3 写完之后，下一个 Token 是 <code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code>」这件事本身就是被监督的预测目标：</p>

\[\mathcal{L} = -\log P(t_{n+1} \mid x_{\le n}), \quad \text{其中 } t_{n+1} \text{ 经常是 } \texttt{&lt;|bos|&gt;}\]

<table>
  <tbody>
    <tr>
      <td>跑过上百亿 Token 之后，基座模型就学会了 $P(\texttt{&lt;</td>
      <td>bos</td>
      <td>&gt;} \mid \text{「一篇文章自然收尾的上下文」})$ 显著升高。<strong>模型会不会停，取决于你拼数据的方式</strong>——这是「停止是学出来的」在数据工程上的落点。</td>
    </tr>
  </tbody>
</table>

<p>一个佐证来自历史：没有对话训练的基座模型（GPT-3 时代）几乎不会好好停——要么无限写下去，要么在奇怪的地方断掉。当时只能靠停止字符串（比如匹配到 <code class="language-plaintext highlighter-rouge">"\n\nUser:"</code> 就截断）和 <code class="language-plaintext highlighter-rouge">max_tokens</code> 硬截断。终止符停得准，是后训练的产物，不是架构自带的。</p>

<h3 id="33-sft把终止符明确构造为训练目标">3.3 SFT：把终止符明确构造为训练目标</h3>

<p>预训练教会模型「文档有边界」，SFT 则把「一轮对话有边界」变成明确的监督信号。<a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/tokenizer.py#L140-L224"><code class="language-plaintext highlighter-rouge">tokenizer.py</code> 的 <code class="language-plaintext highlighter-rouge">render_conversation</code></a> 负责把对话渲染成 Token 序列，同时返回一个 mask——哪些位置是模型该学的（mask=1），哪些只是上下文（mask=0）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Token 序列:   &lt;bos&gt; &lt;user_start&gt; 用户问题 &lt;user_end&gt; &lt;assistant_start&gt; 模型回复 &lt;assistant_end&gt;
mask:          0      0            0        0           0                1             1
                                                                                          ↑
                                                                              终止符也被监督！
</code></pre></div></div>

<p>对应源码中的一行（<a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/tokenizer.py#L219"><code class="language-plaintext highlighter-rouge">tokenizer.py</code> L219</a>）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">add_tokens</span><span class="p">(</span><span class="n">assistant_end</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>  <span class="c1"># mask=1，参与损失
</span></code></pre></div></div>

<p>而 user 消息、<code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code>、工具输出全部是 mask=0。随后 <a href="https://github.com/karpathy/nanochat/blob/92d63d4/scripts/chat_sft.py#L285-L290"><code class="language-plaintext highlighter-rouge">scripts/chat_sft.py</code></a> 把 mask=0 位置的 target 设为 -1（cross-entropy 的 <code class="language-plaintext highlighter-rouge">ignore_index</code>）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Apply the loss mask from render_conversation (mask=1 for assistant completions,
# mask=0 for user prompts, BOS, special tokens, tool outputs). mask[1:] aligns
# with targets (shifted by 1). Unmasked positions get -1 (ignore_index).
</span><span class="n">mask_tensor</span> <span class="o">=</span> <span class="n">torch</span><span class="p">.</span><span class="n">tensor</span><span class="p">(</span><span class="n">mask_rows</span><span class="p">,</span> <span class="n">dtype</span><span class="o">=</span><span class="n">torch</span><span class="p">.</span><span class="n">int8</span><span class="p">)</span>
<span class="n">mask_targets</span> <span class="o">=</span> <span class="n">mask_tensor</span><span class="p">[:,</span> <span class="mi">1</span><span class="p">:].</span><span class="n">to</span><span class="p">(</span><span class="n">device</span><span class="o">=</span><span class="n">device</span><span class="p">)</span>
<span class="n">targets</span><span class="p">[</span><span class="n">mask_targets</span> <span class="o">==</span> <span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span>
</code></pre></div></div>

<p>所以 SFT 的训练目标可以概括为一句话：<strong>只在「给定对话前缀，生成助手回复 + 终止符」这些位置上更新参数</strong>。终止符不是训练的附加品，它就是序列的最后一个监督目标——SFT 数据里每一条完整回复都以 <code class="language-plaintext highlighter-rouge">&lt;|assistant_end|&gt;</code> 收尾，$P(\text{终止符} \mid \text{「回答完了」})$ 就这样被对齐出来。</p>

<h3 id="34-推理运行时采样循环里的停止判断">3.4 推理运行时：采样循环里的停止判断</h3>

<p><a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/engine.py#L176-L275"><code class="language-plaintext highlighter-rouge">nanochat/engine.py</code> 的 <code class="language-plaintext highlighter-rouge">generate</code> 方法`</a>是「LLM 如何停」的最小完整实现，值得逐段走读。主循环每步先对全词表采样，然后做终止判断（<a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/engine.py#L185-L250">节选自 L185-L250</a>）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># engine.py（节选，注释为原文）
</span><span class="n">assistant_end</span> <span class="o">=</span> <span class="n">get_special</span><span class="p">(</span><span class="s">"&lt;|assistant_end|&gt;"</span><span class="p">)</span>  <span class="c1"># if sampled, ends row
</span><span class="n">bos</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">tokenizer</span><span class="p">.</span><span class="n">get_bos_token_id</span><span class="p">()</span>           <span class="c1"># if sampled, ends row
</span>
<span class="k">while</span> <span class="bp">True</span><span class="p">:</span>
    <span class="c1"># Stop condition: we've reached max tokens
</span>    <span class="k">if</span> <span class="n">max_tokens</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="ow">and</span> <span class="n">num_generated</span> <span class="o">&gt;=</span> <span class="n">max_tokens</span><span class="p">:</span>
        <span class="k">break</span>
    <span class="c1"># Stop condition: all rows are completed
</span>    <span class="k">if</span> <span class="nb">all</span><span class="p">(</span><span class="n">state</span><span class="p">.</span><span class="n">completed</span> <span class="k">for</span> <span class="n">state</span> <span class="ow">in</span> <span class="n">row_states</span><span class="p">):</span>
        <span class="k">break</span>

    <span class="n">next_ids</span> <span class="o">=</span> <span class="n">sample_next_token</span><span class="p">(</span><span class="n">logits</span><span class="p">,</span> <span class="n">rng</span><span class="p">,</span> <span class="n">temperature</span><span class="p">,</span> <span class="n">top_k</span><span class="p">)</span>
    <span class="p">...</span>
    <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">state</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">row_states</span><span class="p">):</span>
        <span class="n">next_token</span> <span class="o">=</span> <span class="n">state</span><span class="p">.</span><span class="n">forced_tokens</span><span class="p">.</span><span class="n">popleft</span><span class="p">()</span> <span class="k">if</span> <span class="n">is_forced</span> <span class="k">else</span> <span class="n">sampled_tokens</span><span class="p">[</span><span class="n">i</span><span class="p">]</span>
        <span class="c1"># On &lt;|assistant_end|&gt; or &lt;|bos|&gt;, mark the row as completed
</span>        <span class="k">if</span> <span class="n">next_token</span> <span class="o">==</span> <span class="n">assistant_end</span> <span class="ow">or</span> <span class="n">next_token</span> <span class="o">==</span> <span class="n">bos</span><span class="p">:</span>
            <span class="n">state</span><span class="p">.</span><span class="n">completed</span> <span class="o">=</span> <span class="bp">True</span>
</code></pre></div></div>

<p>用流程图表示整个决策过程：</p>

<pre><code class="language-mermaid">flowchart TD
    S["对全词表采样 next token"] --&gt; F{"forced_tokens&lt;br/&gt;队列非空？"}
    F -- 是 --&gt; T1["取队首强制注入 token&lt;br/&gt;(mask=0)"]
    F -- 否 --&gt; T2["取采样 token&lt;br/&gt;(mask=1)"]
    T1 --&gt; C{"是 assistant_end&lt;br/&gt;或 bos？"}
    T2 --&gt; C
    C -- 是 --&gt; D["标记 completed&lt;br/&gt;该 token 不进输出"]
    C -- 否 --&gt; A["token 追加进输出"]
    D --&gt; E{"all completed 或&lt;br/&gt;num_generated ≥ max_tokens？"}
    A --&gt; E
    E -- 否 --&gt; S
    E -- 是 --&gt; END["结束生成"]

    classDef decision fill:#fff8e1,stroke:#f9a825;
    classDef action fill:#e8f5e9,stroke:#2e7d32;
    classDef terminal fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px;
    class F,C,E decision;
    class S,T1,T2,A action;
    class D,END terminal;
</code></pre>

<p>注意两道停止条件的分工：模型自己「想停」（采到终止符）是<strong>软</strong>条件，逐行独立生效；<code class="language-plaintext highlighter-rouge">max_tokens</code> 是<strong>硬</strong>条件，针对整个 batch。这正对应第二节说的「模型层的软概率 + 运行时层的硬规则」。</p>

<p>这里还有三个值得细品的工程细节：</p>

<p><strong>1. 已完成行的「僵尸生成」。</strong> 批量采样时 KV cache 是整个 batch 一起算的，单行无法中途退出。某行采到终止符后，循环并不会跳过它——它继续接收 Token、继续过 forward，只是结果在 <a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/engine.py#L277-L299"><code class="language-plaintext highlighter-rouge">generate_batch</code></a> 里被 <code class="language-plaintext highlighter-rouge">if not completed[i]</code> 过滤掉。这是朴素实现的处理方式；生产级推理引擎（vLLM / SGLang 的 continuous batching）做的优化正是在单行完成后把它从 batch 里换出去、填入新请求，避免算力浪费在僵尸行上。</p>

<p><strong>2. 终止符由框架剥掉，不进输出。</strong> <code class="language-plaintext highlighter-rouge">generate_batch</code> 里终止符只用来置 <code class="language-plaintext highlighter-rouge">completed</code> 标志，不 append 到结果序列——用户永远看不到 <code class="language-plaintext highlighter-rouge">&lt;|assistant_end|&gt;</code>，和真实 API 的行为一致。「终止符对用户不可见」是运行时层的职责，不是模型层的。</p>

<p><strong>3. 截断后的修补。</strong> <a href="https://github.com/karpathy/nanochat/blob/92d63d4/scripts/chat_cli.py#L92-L96"><code class="language-plaintext highlighter-rouge">scripts/chat_cli.py</code></a> 里有段容易被忽略但很关键的逻辑：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># we have to ensure that the assistant end token is the last token
# so even if generation ends due to max tokens, we have to append it to the end
</span><span class="k">if</span> <span class="n">response_tokens</span><span class="p">[</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span> <span class="o">!=</span> <span class="n">assistant_end</span><span class="p">:</span>
    <span class="n">response_tokens</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">assistant_end</span><span class="p">)</span>
<span class="n">conversation_tokens</span><span class="p">.</span><span class="n">extend</span><span class="p">(</span><span class="n">response_tokens</span><span class="p">)</span>
</code></pre></div></div>

<p>如果生成是被 <code class="language-plaintext highlighter-rouge">max_tokens=256</code> 硬截断的（而不是自然终止的），CLI 会强行补一个 <code class="language-plaintext highlighter-rouge">&lt;|assistant_end|&gt;</code> 再拼回会话历史。否则下一轮的上下文里 assistant 消息没有闭合标记——这种结构模型在训练时从未见过，多轮对话立刻退化。<strong>运行时必须维护协议的完整性，哪怕模型自己违反了协议。</strong></p>

<h3 id="35-一个反直觉的点token-流不全是模型预测的">3.5 一个反直觉的点：Token 流不全是模型预测的</h3>

<p><code class="language-plaintext highlighter-rouge">engine.generate</code> 里还内置了一个 Python 计算器工具（GSM8K 数学题用的），它揭示了下一个常被忽略的事实：<strong>真正的 Token 流是「模型采样」和「框架注入」的混合物</strong>。</p>

<p>当模型采样出 <code class="language-plaintext highlighter-rouge">&lt;|python_end|&gt;</code> 时，框架解码出表达式、调用 REPL 计算，然后把 <code class="language-plaintext highlighter-rouge">&lt;|output_start|&gt;</code> + 计算结果 + <code class="language-plaintext highlighter-rouge">&lt;|output_end|&gt;</code> 通过 <a href="https://github.com/karpathy/nanochat/blob/92d63d4/nanochat/engine.py#L237-L267"><code class="language-plaintext highlighter-rouge">forced_tokens</code> 队列</a><strong>强制注入</strong> Token 流。以「123 乘 456 等于多少」为例，一轮回复的完整 Token 流是这样的：</p>

<pre><code class="language-mermaid">flowchart LR
    P["assistant_start"] --- S1["好的，"] --- S2["让我算一下"] --- PS["python_start"] --- S3["123*456"] --- PE["python_end"] --- OS["output_start"] --- F["56088"] --- OE["output_end"] --- S4["所以答案是 56088"] --- AE["assistant_end"]

    classDef prompt fill:#ffebee,stroke:#c62828;
    classDef sampled fill:#e8f5e9,stroke:#2e7d32;
    classDef forced fill:#fff3e0,stroke:#ef6c00;
    classDef terminal fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px;

    class P prompt;
    class S1,S2,S3,S4 sampled;
    class PS,PE,OS,OE,F forced;
    class AE terminal;
</code></pre>

<p>其中绿色是模型采样出来的（SFT 时 mask=1），橙色是框架强制注入的（mask=0），紫色是终止符。这打破了「输出 = 一连串 next-token 预测」的朴素想象：终止判断和工具调用一样，都属于<strong>框架对 Token 流的调度权</strong>。</p>

<p>RL 阶段对这种混合的处理很严谨（<a href="https://github.com/karpathy/nanochat/blob/92d63d4/scripts/chat_rl.py#L138-L139"><code class="language-plaintext highlighter-rouge">scripts/chat_rl.py</code> L138-L139</a>）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">targets</span><span class="p">[</span><span class="n">mask_ids</span><span class="p">[:,</span> <span class="mi">1</span><span class="p">:]</span> <span class="o">==</span> <span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="o">-</span><span class="mi">1</span> <span class="c1"># &lt;-- inplace modification right here. -1 is the ignore index
# NOTE also that the Engine returns mask=0 for BOTH the prompt tokens AND the tool use tokens.
</span></code></pre></div></div>

<p>被注入的工具 Token 从 policy gradient 的 target 里挖掉，只对模型真正采样过的 Token 算梯度。另一个有趣的细节：RL batch 的 <a href="https://github.com/karpathy/nanochat/blob/92d63d4/scripts/chat_rl.py#L87">padding 直接用 <code class="language-plaintext highlighter-rouge">assistant_end</code> 填充</a>（注释写明「只当 padding 用，不进损失」）——终止符在 RL 损失里几乎是隐形的，RL 对停止行为的影响是间接的：<strong>答案没写完就停，GSM8K 的 reward 直接是 0；停得太晚则浪费采样预算</strong>（<code class="language-plaintext highlighter-rouge">max_completion_tokens=256</code> 兜底）。真正让终止时机变得可靠的，是 SFT 阶段那一个 mask=1。</p>

<h2 id="四失败模式为什么有时停不对">四：失败模式：为什么有时停不对</h2>

<p>回到第二节埋下的伏笔：终止符只是概率分布里的一个竞争者。在每一步：</p>

\[P(\text{继续}) = 1 - P(\text{终止符} \mid x_{\le n})\]

<p>模型「想停」的意愿和「想写 the / 的」的意愿在同一个 softmax 里竞争。分布偏移、温度过高、陷入复读循环时，普通 Token 的概率可能持续压过终止符，于是要么停不下来，要么在半句话处戛然而止。这也是为什么温度设高之后模型更容易刹不住车——高温把分布抹平，终止符原本的概率优势被稀释。</p>

<p>实践中的三类兜底手段，对应三个不同的层：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">现象</th>
      <th style="text-align: left">兜底手段</th>
      <th style="text-align: left">所属层</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left">永远停不下来</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">max_tokens</code> 硬截断</td>
      <td style="text-align: left">运行时层</td>
    </tr>
    <tr>
      <td style="text-align: left">停在奇怪的位置</td>
      <td style="text-align: left">停止字符串（如 <code class="language-plaintext highlighter-rouge">"\n\nUser:"</code>）匹配即截</td>
      <td style="text-align: left">运行时层</td>
    </tr>
    <tr>
      <td style="text-align: left">整体停得不准</td>
      <td style="text-align: left">更多/更好的 SFT 数据、RLHF 对「何时结束」给奖励</td>
      <td style="text-align: left">模型层</td>
    </tr>
  </tbody>
</table>

<p>调试时有个实用技巧：很多推理框架可以输出每个 Token 的 logprobs，盯着终止符的概率看——一个健康的模型在接近答案结尾时，终止符概率会一路爬升，最后几个 Token 处占据压倒性优势；如果它始终在低概率区徘徊，说明停止行为没有被对齐好。</p>

<h2 id="五从-nanochat-到生产系统">五：从 nanochat 到生产系统</h2>

<p>nanochat 那个几十行的生成循环，在生产系统里被工程化成了同一套逻辑的放大版。以 vLLM / SGLang 为例，<code class="language-plaintext highlighter-rouge">SamplingParams</code> 暴露的停止相关参数和 <code class="language-plaintext highlighter-rouge">engine.generate</code> 的停止条件一一对应：</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">stop_token_ids</code></strong>：终止 Token 列表（nanochat 里硬编码为 <code class="language-plaintext highlighter-rouge">assistant_end</code> + <code class="language-plaintext highlighter-rouge">bos</code> 两个）；还有一个 <code class="language-plaintext highlighter-rouge">ignore_eos</code> 参数，压力测试时用它强制模型不停，专门跑满 <code class="language-plaintext highlighter-rouge">max_tokens</code> 测吞吐；</li>
  <li><strong><code class="language-plaintext highlighter-rouge">stop</code></strong>：停止字符串列表，在解码后的文本上做子串匹配（对应 GPT-3 时代的 <code class="language-plaintext highlighter-rouge">"\n\nUser:"</code> 方案）；</li>
  <li><strong><code class="language-plaintext highlighter-rouge">max_new_tokens</code></strong>：硬截断兜底；</li>
  <li><strong>continuous batching</strong>：解决「僵尸行」问题——完成的请求立刻换出、新请求换入，这正是 nanochat 里被「算完再丢弃」浪费掉的那部分算力。</li>
</ul>

<p>我在之前 <a href="/2026/08/28/qwen38-27b-sglang-docker-benchmark-on-dgx-spark/">Qwen3.8-27B 的 SGLang 基准测试</a> 里开过 EAGLE 投机解码，那里也有一个和「停止」直接相关的细节：draft model 同样要对全词表（包括终止符）做预测，但它提议的每一个 Token——包括提前给出的错误 EOS——都要过 target model 的概率校验，错的会被拒掉重算。所以无论解码路径怎么加速，<strong>停止的最终裁决权始终在 target model 手里</strong>，这和 nanochat 的三层分工完全一致。</p>

<h2 id="六总结">六：总结</h2>

<p>把全文收拢回那张三层分工表，补上 nanochat 的实现和生产系统的对应关系：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">层</th>
      <th style="text-align: left">职责</th>
      <th style="text-align: left">nanochat 实现</th>
      <th style="text-align: left">生产系统对应</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>模型层</strong></td>
      <td style="text-align: left">学出 $P(\text{终止符} \mid \text{上下文})$</td>
      <td style="text-align: left">预训练的 <code class="language-plaintext highlighter-rouge">&lt;\|bos\|&gt;</code> 分隔打包；SFT 对 <code class="language-plaintext highlighter-rouge">assistant_end</code> 的 mask=1 监督</td>
      <td style="text-align: left">SFT / RLHF 数据构造</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>协议层</strong></td>
      <td style="text-align: left">定义「哪个 Token 意味着结束」</td>
      <td style="text-align: left">9 个特殊 Token + <code class="language-plaintext highlighter-rouge">render_conversation</code> 模板</td>
      <td style="text-align: left">Chat Template / <code class="language-plaintext highlighter-rouge">tokenizer_config.json</code></td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>运行时层</strong></td>
      <td style="text-align: left">执行停止、剥终止符、截断修补、注入工具 Token</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">engine.generate</code> 的两道停止条件 + CLI 补尾逻辑</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">stop_token_ids</code> / <code class="language-plaintext highlighter-rouge">stop</code> / <code class="language-plaintext highlighter-rouge">max_tokens</code> / continuous batching</td>
    </tr>
  </tbody>
</table>

<p>如果要给这篇文章提炼一句话，我会选这一句：</p>

<blockquote>
  <p><strong>LLM 把「结束」编码成了词表里的一个普通 Token，什么时候停是和语言一起从数据里学出来的；而「真的停下来」这个动作，由外层的推理框架执行，再加上 <code class="language-plaintext highlighter-rouge">max_tokens</code> 做保险。</strong></p>
</blockquote>

<p>nanoChat 最有启发性的一点，是 <code class="language-plaintext highlighter-rouge">&lt;|bos|&gt;</code> 的双重身份：一个 Token，既是「新文档的开始」也是「旧文档的结束」，因为它就是文档边界本身。它用一个 Token 说明了 EOS 从来不是魔法——词表里的每个条目都是平等的，是训练数据的结构和运行时的约定，赋予了其中某个条目「终止」的语义。</p>]]></content><author><name>jxlwqq</name><email>jxlwqq@gmail.com</email></author><category term="ai" /><category term="llm" /><category term="nanochat" /><category term="inference" /><category term="eos" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Qwen3.8-27B SGLang Docker 部署测速报告（GB10 / DGX Spark）</title><link href="https://jxlwqq.github.io/2026/08/28/qwen38-27b-sglang-docker-benchmark-on-dgx-spark/" rel="alternate" type="text/html" title="Qwen3.8-27B SGLang Docker 部署测速报告（GB10 / DGX Spark）" /><published>2026-08-28T04:00:00+00:00</published><updated>2026-08-28T04:00:00+00:00</updated><id>https://jxlwqq.github.io/2026/08/28/qwen38-27b-sglang-docker-benchmark-on-dgx-spark</id><content type="html" xml:base="https://jxlwqq.github.io/2026/08/28/qwen38-27b-sglang-docker-benchmark-on-dgx-spark/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#1-测试环境" id="markdown-toc-1-测试环境">1. 测试环境</a></li>
  <li><a href="#2-部署方式sglang-docker" id="markdown-toc-2-部署方式sglang-docker">2. 部署方式（SGLang Docker）</a></li>
  <li><a href="#3-模型文件" id="markdown-toc-3-模型文件">3. 模型文件</a></li>
  <li><a href="#4-测速方法" id="markdown-toc-4-测速方法">4. 测速方法</a></li>
  <li><a href="#5-测试结果" id="markdown-toc-5-测试结果">5. 测试结果</a>    <ul>
      <li><a href="#51-单流-decode-速度toks" id="markdown-toc-51-单流-decode-速度toks">5.1 单流 Decode 速度（tok/s）</a></li>
      <li><a href="#52-ttft-与-prefill" id="markdown-toc-52-ttft-与-prefill">5.2 TTFT 与 Prefill</a></li>
      <li><a href="#53-并发测试58k-输入4-路并发--512-输出" id="markdown-toc-53-并发测试58k-输入4-路并发--512-输出">5.3 并发测试（5.8K 输入，4 路并发 × 512 输出）</a></li>
      <li><a href="#54-内存与-kv-池" id="markdown-toc-54-内存与-kv-池">5.4 内存与 KV 池</a></li>
    </ul>
  </li>
  <li><a href="#6-结论" id="markdown-toc-6-结论">6. 结论</a></li>
  <li><a href="#7-过程中的环境备忘" id="markdown-toc-7-过程中的环境备忘">7. 过程中的环境备忘</a></li>
</ul>

<p>测试日期：2026-08-28</p>

<p>SGLang 官方 cookbook 给出了 Qwen3.8-27B 在 DGX Spark 上的完整启动配方（48 种组合全部验证过”能启动、能服务”），但<strong>没有给出任何吞吐数据</strong>——DGX Spark 是该页面上唯一只做了 boot-and-serve 验证的硬件。本文用实际的 Docker 部署 + curl 流式调用补上这块空白：两个 checkpoint（NVFP4 量化 / BF16 原版）× 两种推测解码（EAGLE 开 / 关），共 4 组配置，覆盖短上下文、8K、23K 三种输入规模和 4 路并发。</p>

<h2 id="1-测试环境">1. 测试环境</h2>

<table>
  <thead>
    <tr>
      <th>项目</th>
      <th>配置</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>GPU</td>
      <td>NVIDIA GB10（DGX Spark，aarch64，sm_121，128GB LPDDR5x 统一内存，官方标称带宽 273GB/s）</td>
    </tr>
    <tr>
      <td>驱动</td>
      <td>580.173.02（CUDA 13.0）；<code class="language-plaintext highlighter-rouge">nvidia-smi</code> 显存栏显示 Not Supported（与 CPU 共享）</td>
    </tr>
    <tr>
      <td>CPU / 内存</td>
      <td>20 核 ARM（10× Cortex-X925 + 10× Cortex-A725）；系统总内存 127.6GB</td>
    </tr>
    <tr>
      <td>SGLang</td>
      <td>0.0.0.dev0+qwen38.27b.g561c8f3（<code class="language-plaintext highlighter-rouge">lmsysorg/sglang:qwen38-27b</code> 镜像，Docker 部署）</td>
    </tr>
    <tr>
      <td>torch / flashinfer</td>
      <td>2.13.0+cu130 / 0.6.18（满足 MTP + FlashInfer 所需的 uniform_q_len 支持）</td>
    </tr>
    <tr>
      <td>部署方式</td>
      <td><code class="language-plaintext highlighter-rouge">make run</code> / <code class="language-plaintext highlighter-rouge">make run-eagle</code>（Makefile 封装 <code class="language-plaintext highlighter-rouge">docker run --gpus all --shm-size 32g --ipc=host</code>）</td>
    </tr>
  </tbody>
</table>

<h2 id="2-部署方式sglang-docker">2. 部署方式（SGLang Docker）</h2>

<p>启动参数完全对齐 <a href="https://docs.sglang.io/cookbook/autoregressive/Qwen/Qwen3.8-27B">官方 cookbook 的 DGX Spark 低延迟配方</a>：EAGLE（3 步 / topk 1 / 4 draft tokens）+ <code class="language-plaintext highlighter-rouge">--enable-linear-replayssm-spec</code> + <code class="language-plaintext highlighter-rouge">--mamba-full-memory-ratio 4.59</code> + <code class="language-plaintext highlighter-rouge">extra_buffer</code> + SSM float32 + KV fp8_e4m3 + flashinfer。Makefile 里的两个核心 target：</p>

<table>
  <thead>
    <tr>
      <th>target</th>
      <th>关键差异</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">make run</code></td>
      <td>基础服务，无推测解码，<code class="language-plaintext highlighter-rouge">--mem-fraction-static 0.85</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">make run-eagle</code></td>
      <td>在基础参数上追加 <code class="language-plaintext highlighter-rouge">--speculative-algorithm EAGLE --speculative-num-steps 3 --speculative-eagle-topk 1 --speculative-num-draft-tokens 4 --enable-linear-replayssm-spec</code></td>
    </tr>
  </tbody>
</table>

<p>几点部署相关的实测/对照信息：</p>

<ul>
  <li>术语澄清：被开关的技术是<strong>推测解码（Speculative Decoding）</strong>；EAGLE 是 SGLang 中该技术的算法实现，NEXTN 是它的旧名/别名（cookbook 明确两者等价）；而 <strong>MTP 头不是算法本身，是 checkpoint 内置的草稿权重</strong>——EAGLE 运行时用它充当草稿模型，一次猜多个 token、主模型一次前向并行验证，因此无需单独下载 draft checkpoint（BF16 checkpoint 加载时能看到单独的 <code class="language-plaintext highlighter-rouge">Qwen3_5ForCausalLMMTP</code>，约 4.9GB）。”关闭推测解码”即回到逐 token 的纯自回归解码。</li>
  <li><code class="language-plaintext highlighter-rouge">--mem-fraction-static 0.85</code> 在 DGX Spark 上有 earlyoom 风险（cookbook 的 48 格矩阵中 15 格在 0.85 被系统 OOM 守护进程以 -15 杀掉），官方 DGX Spark 配方定在 <strong>0.80</strong>。本文 NVFP4 组沿用本机 Makefile 默认 0.85（实测多次启停稳定），BF16 组按官方建议用 0.80。</li>
  <li>开启推测解码时若不显式设置 <code class="language-plaintext highlighter-rouge">--max-running-requests</code>，SGLang 会固定为 48（本机实测 NVFP4+EAGLE = 48，关闭推测后按显存自动算出 79）。</li>
</ul>

<h2 id="3-模型文件">3. 模型文件</h2>

<table>
  <thead>
    <tr>
      <th>路径</th>
      <th>格式</th>
      <th>大小</th>
      <th>来源</th>
      <th>加载耗时</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RadixArk/Qwen3.8-27B-NVFP4</td>
      <td>NVFP4 W4A4（FP8 注意力投影，FP4 lm_head，内置 MTP 头）</td>
      <td>21G</td>
      <td>ModelScope</td>
      <td>~110s</td>
    </tr>
    <tr>
      <td>Qwen/Qwen3.8-27B</td>
      <td>BF16 原版（18 个分片，含 MTP 头）</td>
      <td>52G</td>
      <td>ModelScope</td>
      <td>~295s</td>
    </tr>
  </tbody>
</table>

<p>两个模型主体权重结构相同（64 层：48 层 Gated DeltaNet 线性注意力 + 16 层 GQA 全注意力），差异只在量化和 lm_head 打包方式，是同一架构下”精度换带宽”的直接对照。</p>

<h2 id="4-测速方法">4. 测速方法</h2>

<p>用 <code class="language-plaintext highlighter-rouge">curl -sN</code> 流式调用 <code class="language-plaintext highlighter-rouge">/v1/chat/completions</code>（SSE），<code class="language-plaintext highlighter-rouge">stream_options.include_usage</code> 拿服务端 token 计数，<code class="language-plaintext highlighter-rouge">temperature 0</code>、<code class="language-plaintext highlighter-rouge">ignore_eos true</code> 固定输出长度。指标定义：</p>

<ul>
  <li><strong>TTFT</strong>：从发请求到收到首个内容块</li>
  <li><strong>Prefill</strong> = prompt_tokens / TTFT（只取冷缓存首跑，避免 radix cache 命中后虚高）</li>
  <li><strong>Decode</strong> = (completion_tokens - 1) / (末 token 时刻 - 首 token 时刻)</li>
</ul>

<table>
  <thead>
    <tr>
      <th>场景</th>
      <th>输入（服务端计数）</th>
      <th>输出</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>短上下文</td>
      <td>251 tokens</td>
      <td>256</td>
    </tr>
    <tr>
      <td>8K 级</td>
      <td>5,802 tokens</td>
      <td>1,024</td>
    </tr>
    <tr>
      <td>23K 级</td>
      <td>22,821 tokens</td>
      <td>512</td>
    </tr>
    <tr>
      <td>并发</td>
      <td>5,802 tokens × 4 路</td>
      <td>512 × 4</td>
    </tr>
  </tbody>
</table>

<p>每组跑 3 遍取后 2 遍的稳态值（首跑含冷启动单独标注）；thinking 模式保持默认开启。服务端日志的 <code class="language-plaintext highlighter-rouge">gen throughput</code> 与客户端聚合值相互印证（如 4 并发时服务端 84~88 tok/s vs 客户端 83~85 tok/s）。</p>

<h2 id="5-测试结果">5. 测试结果</h2>

<h3 id="51-单流-decode-速度toks">5.1 单流 Decode 速度（tok/s）</h3>

<table>
  <thead>
    <tr>
      <th>配置</th>
      <th>短上下文</th>
      <th>8K 级</th>
      <th>23K 级</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>NVFP4 + EAGLE</strong></td>
      <td><strong>28.7</strong></td>
      <td><strong>23.7</strong></td>
      <td><strong>25.5</strong></td>
    </tr>
    <tr>
      <td>NVFP4</td>
      <td>14.0</td>
      <td>12.0</td>
      <td>14.2</td>
    </tr>
    <tr>
      <td>BF16 + EAGLE</td>
      <td>11.3</td>
      <td>11.2</td>
      <td>11.0</td>
    </tr>
    <tr>
      <td>BF16</td>
      <td>4.85</td>
      <td>4.15</td>
      <td>4.87</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>EAGLE 带来的加速：NVFP4 上 <strong>1.8~2.1×</strong>，BF16 上 <strong>2.3~2.7×</strong>（8K 处收益最大）。服务端统计的平均接受长度：NVFP4 2.69/4 步，BF16 2.85/4 步。</li>
  <li>NVFP4 对 BF16 的加速：关闭推测解码时 <strong>2.9×</strong>，开启后 <strong>2.1×</strong>（EAGLE 摊薄了读取权重的开销，量化优势被部分抹平）。</li>
  <li>带宽模型验证：273GB/s ÷ 21GB ≈ 13 tok/s（NVFP4 理论上限），273GB/s ÷ 52.9GB ≈ 5.2 tok/s（BF16），与实测 12.0 和 4.15~4.87 基本吻合——<strong>单流 decode 就是权重带宽的物理极限</strong>，与此前<a href="https://developer.aliyun.com/article/1756323">另一份 GB10 测速报告</a>“~23 tok/s 天花板”（NVFP4+NEXTN 达 23.02）的结论一致，本文同配方实测 23.7~25.5。</li>
</ul>

<h3 id="52-ttft-与-prefill">5.2 TTFT 与 Prefill</h3>

<table>
  <thead>
    <tr>
      <th>配置</th>
      <th>短上下文 TTFT</th>
      <th>8K TTFT（冷/暖）</th>
      <th>Prefill 冷缓存（8K / 23K）</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>NVFP4 + EAGLE</strong></td>
      <td><strong>1.6~2.1s</strong></td>
      <td>4.2s / 2.7s</td>
      <td><strong>1,394 / 1,787</strong></td>
    </tr>
    <tr>
      <td>NVFP4</td>
      <td>3.4~3.8s</td>
      <td>7.0s / 4.5s</td>
      <td>831 / 1,453</td>
    </tr>
    <tr>
      <td>BF16 + EAGLE</td>
      <td>3.9~4.2s</td>
      <td>9.5s / 5.3~6.0s</td>
      <td>613 / 1,230</td>
    </tr>
    <tr>
      <td>BF16</td>
      <td>10.4~11.0s</td>
      <td>16.0s / 11.8s</td>
      <td>363 / 919</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>BF16 短上下文的首 token 也要 10 秒上下，NVFP4 只要约 2 秒——长 prompt 的 prefill NVFP4 快 1.7×，短请求的固定开销差 5×。</li>
  <li>长输入下 chunked prefill 摊薄了每块固定开销，各配置的 23K prefill 都高于 8K（NVFP4+EAGLE 冷缓存能到 ~1.8K tok/s）。</li>
  <li>radix cache 暖机后重复请求 TTFT 明显下降（如 NVFP4+EAGLE 8K 从 4.2s 降到 2.7s），但此时”prefill 速率”读数含缓存命中成分，不具可比性，故表中只列冷缓存值。</li>
</ul>

<h3 id="53-并发测试58k-输入4-路并发--512-输出">5.3 并发测试（5.8K 输入，4 路并发 × 512 输出）</h3>

<table>
  <thead>
    <tr>
      <th>配置</th>
      <th>每路 decode</th>
      <th>聚合 decode</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>NVFP4 + EAGLE</strong></td>
      <td><strong>24.2~24.9</strong></td>
      <td><strong>83~85</strong></td>
    </tr>
    <tr>
      <td>NVFP4</td>
      <td>~11.9</td>
      <td>42.0</td>
    </tr>
    <tr>
      <td>BF16 + EAGLE</td>
      <td>10.1~10.9</td>
      <td>35.6~36.6</td>
    </tr>
    <tr>
      <td>BF16</td>
      <td>4.76~4.83</td>
      <td>17.6</td>
    </tr>
  </tbody>
</table>

<ul>
  <li>最意外的结果：<strong>NVFP4 + EAGLE 在 4 并发下单路速度几乎不掉</strong>（24.9 vs 单流 23.7），推测解码的 draft/verify 随 batch 摊销得很好；聚合吞吐达到单流的 3.5×。</li>
  <li>BF16 关闭推测解码时批处理几乎”免费”：每路 4.8 ≈ 单流 4.85，聚合约线性扩展（17.6 ≈ 4.15 × 4.2）——纯带宽瓶颈下多路只是把权重读取摊给更多请求。</li>
  <li>对照 cookbook 的警告：推测解码会把 <code class="language-plaintext highlighter-rouge">max-running-requests</code> 重置为 48，本机 NVFP4+EAGLE 实测 KV 池 37.5 万 token、上限 48 路，4 并发只用了 KV 池的 2%。</li>
</ul>

<h3 id="54-内存与-kv-池">5.4 内存与 KV 池</h3>

<table>
  <thead>
    <tr>
      <th>配置</th>
      <th>权重</th>
      <th>KV 池（max_total_num_tokens）</th>
      <th>max_running_requests</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>NVFP4 + EAGLE</td>
      <td>21G</td>
      <td>374,793</td>
      <td>48（spec 固定）</td>
    </tr>
    <tr>
      <td>NVFP4</td>
      <td>21G</td>
      <td><strong>413,359</strong></td>
      <td>79（自动）</td>
    </tr>
    <tr>
      <td>BF16 + EAGLE</td>
      <td>52G + 4.9G（MTP）</td>
      <td>174,718</td>
      <td>32</td>
    </tr>
    <tr>
      <td>BF16</td>
      <td>52G</td>
      <td>204,597</td>
      <td>39（自动）</td>
    </tr>
  </tbody>
</table>

<p>NVFP4 把 31GB 的权重差省下来给了 KV 池：同等 <code class="language-plaintext highlighter-rouge">mem-fraction</code> 下可用上下文容量是 BF16 的约 2 倍（41.3 万 vs 20.5 万 token，均远超模型原生的 262K 上下文）。</p>

<h2 id="6-结论">6. 结论</h2>

<ol>
  <li><strong>推荐组合：NVFP4 + EAGLE（<code class="language-plaintext highlighter-rouge">make run-eagle</code> 默认配置）</strong>。单流 24~29 tok/s，4 并发聚合 84 tok/s 且单路体验不掉速，KV 池 37.5 万 token，加载仅 110 秒——在 GB10 上是速度、容量、启动时间的全面最优。</li>
  <li><strong>BF16 原版不适合做常驻服务</strong>：单流 4~5 tok/s、短请求 TTFT 10 秒起步；但它的价值在于给出带宽物理上限的干净参照（4.15 ≈ 273GB/s ÷ 52.9GB），以及验证 EAGLE 输出分布不受推测解码影响。</li>
  <li><strong>EAGLE/MTP 是 DGX Spark 上的必选项</strong>：两个 checkpoint 上都是 1.8~2.7× 加速，接受长度 2.69~2.85/4 且并发下不衰减；BF16 受益更大（权重读取每步摊薄近 3 倍）。</li>
  <li><strong>量化的收益在推测解码面前缩水</strong>：NVFP4 对 BF16 的单流优势从关闭推测解码时的 2.9× 降到开启后的 2.1×——EAGLE 本质上是”用一次权重读取换 2.7 个 token”，与量化的省钱逻辑同向叠加但有边际。</li>
  <li>单流 decode 天花板由 273GB/s 内存带宽锁死，与模型/引擎优化无关；想再快只能换更大的 spec 模型或期待硬件换代，23K 长上下文的 prefill 反而还有优化空间（chunk 8192 等参数未在本文展开）。</li>
</ol>

<h2 id="7-过程中的环境备忘">7. 过程中的环境备忘</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">nvidia-smi</code> 在 GB10 上显存栏是 Not Supported（统一内存），判断能否再起一个容器要看 <code class="language-plaintext highlighter-rouge">/proc/meminfo</code> 的 MemAvailable，而不是 nvidia-smi。</li>
  <li><code class="language-plaintext highlighter-rouge">--mem-fraction-static 0.85</code> + BF16 是 earlyoom 高危组合（cookbook 实测 15/48 格被杀，exit -15 无 traceback），DGX Spark 上建议 0.80；本文 NVFP4@0.85 多次启停未复现被杀。</li>
  <li>推测解码未显式设置 <code class="language-plaintext highlighter-rouge">--max-running-requests</code> 时会被固定为 48，关闭推测后本机自动算出 79——做并发压测前先确认这个隐含上限。</li>
  <li>官方 cookbook 对 DGX Spark 的 48 格矩阵只做了 boot-and-serve 验证（ISL 8192 / OSL 1024、并发 1，无吞吐数据）；RTX 5090 / RTX PRO 6000 才有完整性能数字，本文数据可用于填补 DGX Spark 这一格。</li>
  <li>镜像 <code class="language-plaintext highlighter-rouge">lmsysorg/sglang:qwen38-27b</code>（sglang g561c8f3 + flashinfer 0.6.18）满足 MTP + FlashInfer 对 uniform_q_len 的版本要求，无需退回 triton 后端。</li>
</ul>]]></content><author><name>jxlwqq</name><email>jxlwqq@gmail.com</email></author><category term="ai" /><category term="dgx-spark" /><category term="sglang" /><category term="llm-inference" /><category term="benchmark" /><category term="qwen" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">如何构建一个编码 Agent</title><link href="https://jxlwqq.github.io/2025/12/14/how-to-build-a-coding-agent/" rel="alternate" type="text/html" title="如何构建一个编码 Agent" /><published>2025-12-14T04:00:00+00:00</published><updated>2025-12-14T04:00:00+00:00</updated><id>https://jxlwqq.github.io/2025/12/14/how-to-build-a-coding-agent</id><content type="html" xml:base="https://jxlwqq.github.io/2025/12/14/how-to-build-a-coding-agent/"><![CDATA[<blockquote>
  <p>原文链接：<a href="https://ghuntley.com/agent/">How to build a coding agent: free workshop</a></p>

  <p>作者：Geoffrey Huntley</p>
</blockquote>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.001.jpg" alt="how to build a coding agent: free workshop" /></p>

<p>本文将介绍如何构建一个编码 Agent。「Agent」一词如今已屡见不鲜。</p>

<p>尽管这个术语被频繁提及，但许多人对其确切含义及编码 Agent 的内部运作机制仍缺乏清晰的理解。是时候揭开其神秘面纱，展示其技术门槛并非高不可攀。</p>

<p>学习如何构建编码 Agent 是 2025 年个人发展中最具价值的投资之一，因为它能让你掌握核心基础。一旦理解了这些基础知识，你将从 AI 的消费者转变为 AI 的生产者，具备利用 AI 实现自动化的能力。</p>

<p>让我们从以下事实开始：</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.002.png" alt="Image" /></p>

<p class="text-center">这并不难</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.003.png" alt="Image" /></p>

<p class="text-center">构建一个编码 Agent</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.004.png" alt="Image" /></p>

<p class="text-center">只有 300 行代码</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.005.png" alt="Image" /></p>

<p class="text-center">在一个循环中运行</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.006.png" alt="Image" /></p>

<p class="text-center">使用 LLM token，这就是全部。</p>

<p>仅需 300 行代码在一个循环中运行，消耗 LLM token。只要持续向循环中输入 token，你就拥有了一个 Agent。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.007.png" alt="Image" /></p>

<p>本文将指导你构建一个 Agent，并解释其基本工作原理。在 2025 年，与 AI 协同工作已成为常态。因此，演示这一点的最佳方式莫过于让一个 Agent 来协助构建另一个 Agent。</p>

<p>现在，我们开始构建一个 Agent。这是行业正在发生的变革之一：工作可以并发进行，甚至在你离开电脑时也能持续推进。</p>

<p>花费数天甚至一周时间进行研究冲刺的日子已成过去，如今你只需通过语音指令即可将想法转化为执行。</p>

<p>下次在 Zoom 通话时，不妨思考一下：你本可以让一个 Agent 在通话期间完成你计划中的工作。如果这对你来说尚非常态，而你的同事却已习以为常，那么你将难以保持领先优势。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.009.png" alt="Image" /></p>

<p class="text-center">请构建你自己的</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.010.png" alt="Image" /></p>

<p class="text-center">因为这些知识</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.011.png" alt="Image" /></p>

<p class="text-center">将改变你</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.012.png" alt="Image" /></p>

<p class="text-center">从消费者</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.013.png" alt="Image" /></p>

<p class="text-center">变成生产者</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.014.png" alt="Image" /></p>

<p class="text-center">能够自动化事务</p>

<p>科技行业犹如一条不断前行的传送带，要求我们持续学习新知。</p>

<p>在 2025 年，你应熟悉如何创建一个 Agent。理解这一循环机制以及如何构建 Agent，已成为雇主在招聘时寻找的基础技能。</p>

<p><a href="https://www.canva.dev/blog/engineering/yes-you-can-use-ai-in-our-interviews/">Yes, You Can Use AI in Our Interviews. In fact, we insist - Canva Engineering Blog</a></p>

<p>因为这些知识将助你从 AI 的消费者转变为能够编排工作流程的 AI 生产者。雇主如今更青睐那些能够在组织内部实现任务自动化的个人。</p>

<p>关于这一点的深入探讨，可参考以下内容：</p>

<p><a href="https://ghuntley.com/six-month-recap/">the six-month recap: closing talk on AI at Web Directions, Melbourne, June 2025</a></p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.015.png" alt="Image" /></p>

<p>此刻，你正处于上述旅程的某个阶段。</p>

<p>在左上角，有些人持怀疑态度：「向我证明这不是真的」、「给我看结果」、「证明这不是炒作」，或者认为「这还不够好」。他们被困在悬崖的一侧，完全忽视了在悬崖的另一边，已有人在完全自动化他们的工作职能。</p>

<blockquote>
  <p>在我看来，任何与 AI 相关的颠覆或失业并非 AI 本身所致，而是缺乏个人发展和自我投资的后果。如果你的同事在多个 Agent 之间切换，处理想法，并在会议期间让 Agent 在后台运行，而你却置身事外，那么落后是必然的。</p>
</blockquote>

<p><a href="https://ghuntley.com/ngmi/">What do I mean by some software devs are “ngmi”?</a></p>

<p>切勿成为滞留在悬崖左侧的人。</p>

<p>科技行业的传送带持续向前。如果你是 2025 年的 DevOps 工程师，却没有任何 AWS 或 GCP 的经验，那么你会发现就业市场举步维艰。</p>

<p>软件和数据工程师惊讶于这一切发生得如此之快。自第一个编码 Agent 发布以来仅八个月，大多数人仍未意识到构建它是多么简单，这个循环是多么强大，以及它对我们职业产生的颠覆性影响。</p>

<p>所以，我叫 Geoffrey Huntley。我曾是 Canva 的开发者生产力技术主管，但几个月前，我成为了 Sourcegraph 构建 <a href="https://ampcode.com/">Amp</a> 的工程师之一。这是一个大约六人的核心小团队。我们用 AI 构建 AI。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.016.png" alt="Image" /></p>

<p class="text-center">ampcode.com</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.017.png" alt="Image" /></p>

<p class="text-center">cursor</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.018.png" alt="Image" /></p>

<p class="text-center">windsurf</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.019.png" alt="Image" /></p>

<p class="text-center">claude code</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.020.png" alt="Image" /></p>

<p class="text-center">github co-pilot</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.022.png" alt="Image" /></p>

<p class="text-center">都是在 LLM token 循环中运行的代码行。</p>

<p>Cursor、Windsurf、Claude Code、GitHub Copilot 和 Amp 只是在 LLM token 循环中运行的一小部分代码。需要强调的是：模型在此完成了所有繁重的工作。是模型驱动了一切。</p>

<p>你可能正在评估多家供应商的产品，试图比较这些 Agent。但这实际上是在做无用功。</p>

<p>构建属于你自己的 Agent 其实非常简单……</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.023.png" alt="Image" /></p>

<p>你只需要了解几个关键概念。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.024.png" alt="Image" /></p>

<p>并非所有 LLM 都是 Agentic（代理式）的。</p>

<p>正如汽车有不同类型——越野时选择 <a href="https://en.wikipedia.org/wiki/Toyota_Land_Cruiser_(J40)">40 系列</a>，载客时选择轿车一样。</p>

<p>同样的原则也适用于 LLM，我们可以将它们的行为映射到一个象限中。</p>

<blockquote>
  <p>一个模型要么是高安全性，要么是低安全性；要么是 Oracle（预言机），要么是 Agentic（代理式）。它很难兼顾所有特性。</p>
</blockquote>

<p>如果你想进行安全研究，你会选择哪个模型？</p>

<p>答案是 Grok。那是一个低安全性模型。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.025.png" alt="Image" /></p>

<p>如果你想要「道德一致」的东西，那就是 Anthropic 或 OpenAI。所以那是高安全性。同样，你有 Oracle。Oracle 与 Agentic 截然相反。Oracle 适合总结任务或需要高水平思考的任务。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.026.png" alt="Image" /></p>

<p>同时，你有像 Anthropic 这样的提供商，他们的 Claude Sonnet 是一只数字松鼠（见下文）。</p>

<p><a href="https://ghuntley.com/cars/">Claude Sonnet is a small-brained mechanical squirrel of &lt;T&gt;</a></p>

<p>第一个机器人用来追逐网球，而第一个数字机器人则追逐工具调用（tool calls）。</p>

<p>Sonnet 就像一只专注于工具调用的机器松鼠。它不会花费过多时间思考，而是偏向于行动，这正是其具备 Agentic 特性的原因。Sonnet 专注于通过逐步行动获得成功，而不是在行动前花费数分钟进行思考。</p>

<p>似乎每天都有新模型推向市场，竞争激烈。但实际上，它们各有所长，并占据了各自的细分市场。</p>

<p>问题在于，除非深入使用这些模型，否则你可能无法意识到它们的专长，这导致消费者仅根据两个基本指标来比较模型：</p>

<ol>
  <li>上下文窗口的大小</li>
  <li>成本</li>
</ol>

<p>这好比仅通过车门数量来评价汽车，却忽略了车辆的设计用途是越野还是载客。</p>

<p>构建 Agent 的第一步是选择一个高度 Agentic 的模型。目前的首选是 Claude Sonnet 或 Kimi K2。</p>

<p>你可能会问：「如果需要更高水平的推理来检查工作怎么办？」。这很简单。你可以将其他 LLM 作为工具集成到现有的 Agentic LLM 中。这正是我们在 Amp 所采用的方法。</p>

<p>我们称之为 Oracle。Oracle 只是 GPT 作为一个工具连接进来，Claude Sonnet 可以函数调用它来获得指导，检查工作进度，并进行研究/规划。</p>

<p><a href="https://ampcode.com/news/oracle">Oracle Amp</a></p>

<p>Amp 的 oracle 只是另一个注册为 Agentic LLM 工具的 LLM，它可以对其进行函数调用。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.027.png" alt="Image" /></p>

<p>下一个关键点是：上下文窗口应仅用于单一活动。当使用 Cursor 或类似工具时，必须在每次活动结束后清除上下文窗口（见下文）。</p>

<p><a href="https://ghuntley.com/gutter/">autoregressive queens of failure</a></p>

<p>LLM 的输出质量取决于上下文的纯净度。</p>

<p>如果你在一个 AI 辅助会话中构建后端 API 控制器，随后又利用同一会话研究猫鼬。那么，当你要求它重新设计网站时，结果中包含关于 API 或猫鼬的信息也就不足为奇了。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.028.png" alt="Image" /></p>

<p class="text-center">注：Sonnet 的上下文窗口已增加到 1M。</p>

<p>上下文窗口非常非常小。最好把它们看作是 Commodore 64，因此，你应该把它当作一台内存有限的计算机。你分配的越多，你的结果和性能就越差。</p>

<p>Sonnet 宣传的上下文窗口是 200K。然而，你不能全部使用它，因为模型需要为系统级提示分配内存。然后 harness（Cursor, Windsurf, Claude Code, Amp）也需要分配一些额外的内存，这意味着你最终大约有 176K 可用 token。</p>

<p>你可能听说过模型上下文协议（MCP）。这是当前的热门话题，最简单的理解方式是将它们视为带有描述的函数，分配给上下文窗口，告知模型如何调用。</p>

<p>我观察到一个常见的失败场景：人们安装了过多的 MCP 服务器，或者未能充分考虑单个 MCP 工具公开的工具数量及所有工具的总上下文窗口占用。</p>

<p>有一条尚未被充分重视的基本规则：分配给上下文窗口的内容越多，其性能越差，结果也会随之恶化。</p>

<p>应避免通过 Agent 或 MCP 工具过度占用上下文窗口。很容易陷入仅为 MCP 工具就分配了额外 76K token 的陷阱，导致可用空间大幅减少。</p>

<blockquote>
  <p>少即是多。</p>
</blockquote>

<p>如果你想了解何时使用 MCP 以及何时不使用，我建议你阅读下面的博客文章。</p>

<p><a href="https://ghuntley.com/allocations/">too many model context protocol servers and LLM allocations on the dance floor</a></p>

<p>何时应该使用 MCP，何时不应该使用 MCP，以及上下文窗口中的分配是如何工作的。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.029.png" alt="Image" /></p>

<p>让我们回去检查一下我们在后台构建的 Agent。如果你仔细观察，你可以看到循环以及它是如何调用其他工具的。</p>

<p>本质上，这一切是如何工作的在下面的循环中概述。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.030.png" alt="Image" /></p>

<p>对于来自用户的每一条输入或工具调用的结果，都会分配给响应，并将该响应发送以进行推理：</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/inference-loop-diagram.png" alt="Image" /></p>

<p class="text-center">推理循环（减去工具注册）</p>

<p>让我们 Clone 以下这个仓库并通过 <code class="language-plaintext highlighter-rouge">go run chat.go</code> 运行基本的聊天应用程序：</p>

<p><a href="https://github.com/ghuntley/how-to-build-a-coding-agent">GitHub - ghuntley/how-to-build-a-coding-agent</a></p>

<blockquote>
  <p><strong>You:</strong> You are a weather expert. When I ask you about the weather in a given location, I want you to reply with <code class="language-plaintext highlighter-rouge">get_weather(&lt;location_name&gt;)</code>. I will then tell you what the weather in that location is. Understood?</p>

  <p><strong>Claude:</strong> I understand your instructions. When you ask me about the weather in a specific location, I will respond with <code class="language-plaintext highlighter-rouge">get_weather(&lt;location_name&gt;)</code>. Then you will provide me with the weather information for that location, and I can discuss it with you from there. I’m ready whenever you’d like to ask about the weather somewhere.</p>
</blockquote>

<p>所以，让我们问问今天澳大利亚墨尔本的天气怎么样？</p>

<blockquote>
  <p><strong>You:</strong> What’s the weather like today in Melbourne, Australia?</p>

  <p><strong>Claude:</strong> <code class="language-plaintext highlighter-rouge">get_weather(Melbourne, Australia)</code></p>
</blockquote>

<p>至此，操作完成。</p>

<p>我们已将第一个工具注册到模型中。每当关于天气的查询触发潜在空间时，模型便会调用这个新工具。</p>

<p>虽然目前它只是一个存根工具，但原理已清晰可见：我们定义了一个方法，而 LLM 正从查询中提取参数并传递给该方法。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.031.png" alt="Image" /></p>

<p>当我们谈论 Agentic 时，我们指的是某种工具调用，某种已经在其奖励函数上受过训练以调用工具的东西。有些模型经过明确训练；它们只想追逐坚果，就像松鼠一样（见下文）。这不是坚果，是工具。</p>

<p>[Claude Sonnet is a small-brained mechanical squirrel of <T>](https://ghuntley.com/cars/)</T></p>

<p>解释 MCP 或工具的最简单方法是，它只是一个顶部带有广告牌的函数，推动 LLM 的潜在空间来调用该函数。</p>

<p>它就这么简单。你注册工具，描述如何调用它，然后它就可以工作了。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.032.png" alt="Image" /></p>

<p>让我们开始制作真正的编码 Agent 的短暂旅程。我们有一个模拟函数和一个模拟工具，但 <code class="language-plaintext highlighter-rouge">get_weather(Melbourne, Australia)</code> 目前什么也没做，所以让我们修复它。</p>

<p>我们需要做的是创建一个工具，然后调整我们的推理循环以检查它是否想要执行该工具（见上文第 5 行）。如果它想，执行该工具（见上文第 6 行），然后将该工具的结果分配回（见上文第 7 行）循环以进行更多推理。</p>

<p>这是注册工具后我们更新后的序列图的样子。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/updated-sequence-diagram.png" alt="Image" /></p>

<p class="text-center">这就是最纯粹形式的编码 Agent，没有任何 UI。</p>

<p>由于我们今天不是在制作天气应用程序，让我们换个档次来构建编码 Agent 的第一个原语。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.034.png" alt="Image" /></p>

<p>任何编码 Agent 背后的第一个原语是读取工具。所有编码 Agent 都有一个读取工具，可以将文件读取到上下文窗口中，或者是整个文件，或者对于较大的文件，分块读取。</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">ReadFileDefinition</span> <span class="o">=</span> <span class="n">ToolDefinition</span><span class="p">{</span>
	<span class="n">Name</span><span class="o">:</span>        <span class="s">"read_file"</span><span class="p">,</span>
	<span class="n">Description</span><span class="o">:</span> <span class="s">"Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names."</span><span class="p">,</span>
	<span class="n">InputSchema</span><span class="o">:</span> <span class="n">ReadFileInputSchema</span><span class="p">,</span>
	<span class="n">Function</span><span class="o">:</span>    <span class="n">ReadFile</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p><a href="https://github.com/ghuntley/how-to-build-a-coding-agent">GitHub - ghuntley/how-to-build-a-coding-agent</a></p>

<p>让我们再次在代码仓库根目录，创建一个文件：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat</span> <span class="o">&lt;&lt;</span> <span class="no">EOF</span><span class="sh">
I have a mane but I'm not a lion,
I have four legs but I'm not a table,
I can gallop but I'm not running,
People say I'm disagreeable because I always say "neigh."
What am I?
EOF &gt; riddle.txt
</span></code></pre></div></div>

<p>然后让我们运行读取文件工具：</p>

<p><code class="language-plaintext highlighter-rouge">go run read.go</code></p>

<blockquote>
  <p><strong>You:</strong> Claude, mate. What’s the answer to this riddle in <code class="language-plaintext highlighter-rouge">riddle.txt</code>?</p>

  <p><strong>Claude:</strong> I’ll help you solve the riddle. Let me first read the contents of the <code class="language-plaintext highlighter-rouge">riddle.txt</code> file to see what the riddle is.</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">read_file({"path":"riddle.txt"})</code></p>

  <p><strong>Reading file:</strong> <code class="language-plaintext highlighter-rouge">riddle.txt</code></p>

  <p><strong>Successfully read file</strong> <code class="language-plaintext highlighter-rouge">riddle.txt</code> (175 bytes)</p>

  <p><strong>result:</strong> I have a mane but I’m not a lion,I have four legs but I’m not a table,I can gallop but I’m not running,People say I’m disagreeable because I always say “neigh.”What am I?</p>

  <p><strong>Claude:</strong> After reading the riddle, I can solve it for you:The answer to the riddle is: a horse.Let’s analyze the clues:”I have a mane but I’m not a lion” - Horses have manes, which is hair growing from the top of the neck.”I have four legs but I’m not a table” - Horses walk on four legs.”I can gallop but I’m not running” - Galloping is a specific gait of horses.”People say I’m disagreeable because I always say ‘neigh’” - “Neigh” is the sound a horse makes, and it sounds like “nay” (which means “no” or disagreement).All of these clues point to a horse as the answer to the riddle.</p>
</blockquote>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.033.png" alt="Image" /></p>

<p>任何编码 Agent 背后的第二个原语是列表工具。所有编码 Agent 都有一个列出文件工具，它列出给定路径中的所有文件和目录。</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">ListFilesDefinition</span> <span class="o">=</span> <span class="n">ToolDefinition</span><span class="p">{</span>
	<span class="n">Name</span><span class="o">:</span>        <span class="s">"list_files"</span><span class="p">,</span>
	<span class="n">Description</span><span class="o">:</span> <span class="s">"List files and directories at a given path. If no path is provided, lists files in the current directory."</span><span class="p">,</span>
	<span class="n">InputSchema</span><span class="o">:</span> <span class="n">ListFilesInputSchema</span><span class="p">,</span>
	<span class="n">Function</span><span class="o">:</span>    <span class="n">ListFiles</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p><a href="https://github.com/ghuntley/how-to-build-a-coding-agent">GitHub - ghuntley/how-to-build-a-coding-agent</a></p>

<p>让我们再次在代码仓库根目录，通过 <code class="language-plaintext highlighter-rouge">go run list_files.go</code> 运行基本的聊天应用程序：</p>

<blockquote>
  <p><strong>You:</strong> Claude, mate. Is there a <code class="language-plaintext highlighter-rouge">README.md</code> in this folder? Also what is the contents of <code class="language-plaintext highlighter-rouge">riddle.txt</code>?</p>

  <p><strong>Claude:</strong> I’ll help you check for a README.md file and show you the contents of <code class="language-plaintext highlighter-rouge">riddle.txt</code>.First, let me list the files in the current directory to see if <code class="language-plaintext highlighter-rouge">README.md</code> exists:</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">list_files({})</code></p>

  <p><strong>Listing files in directory:</strong> .</p>

  <p><strong>Successfully listed 88 items in .</strong></p>

  <p><strong>result:</strong> <code class="language-plaintext highlighter-rouge">[".devenv.flake.nix",".direnv/",".envrc",".gitignore","AGENT.md","Makefile","README.md","bash_tool.go","chat.go","code_search_tool.go","devenv.lock","devenv.nix","devenv.yaml","edit_tool.go","go.mod","go.sum","list_files.go","prompts/","prompts/00-weather.md","prompts/01-read_file.md","prompts/02-list_files.md","prompts/03-bash_tool.md","prompts/04-edit_tool.md","read.go","renovate.json","riddle.txt"]</code></p>

  <p><strong>Claude:</strong> Yes, there is a <code class="language-plaintext highlighter-rouge">README.md</code> file in this folder.Now, let me show you the contents of <code class="language-plaintext highlighter-rouge">riddle.txt</code>:</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">read_file({"path":"riddle.txt"})</code></p>

  <p><strong>result:</strong> I have a mane but I’m not a lion,I have four legs but I’m not a table,I can gallop but I’m not running,People say I’m disagreeable because I always say “neigh.”What am I?</p>
</blockquote>

<p>至此，我们已拥有了一个读取文件工具和一个列出文件工具，你可以看到它们的函数被调用并从我们的原始提示链接在一起。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.035.png" alt="Image" /></p>

<p>任何编码 harness 背后的第三个原语是 bash 工具，它正如其名。它使 LLM 能够在你的计算机上执行命令。</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">BashDefinition</span> <span class="o">=</span> <span class="n">ToolDefinition</span><span class="p">{</span>
	<span class="n">Name</span><span class="o">:</span>        <span class="s">"bash"</span><span class="p">,</span>
	<span class="n">Description</span><span class="o">:</span> <span class="s">"Execute a bash command and return its output. Use this to run shell commands."</span><span class="p">,</span>
	<span class="n">InputSchema</span><span class="o">:</span> <span class="n">BashInputSchema</span><span class="p">,</span>
	<span class="n">Function</span><span class="o">:</span>    <span class="n">Bash</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p><a href="https://github.com/ghuntley/how-to-build-a-coding-agent">GitHub - ghuntley/how-to-build-a-coding-agent</a></p>

<p>让我们再次在代码仓库根目录，运行 bash 工具：</p>

<p><code class="language-plaintext highlighter-rouge">go run bash_tool.go</code></p>

<blockquote>
  <p><strong>You:</strong> Hey Claude, provide a summary of all the processes running on this computer.</p>

  <p><strong>Claude:</strong> I’ll provide a summary of all the processes running on this computer. Let me use the <code class="language-plaintext highlighter-rouge">ps</code> command to gather this information.</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">bash({"command":"ps aux"})</code></p>

  <p><strong>Executing bash command:</strong> ps aux</p>

  <p><strong>Bash command succeeded:</strong> ps aux (output: 87344 bytes)</p>

  <p><strong>result:</strong> <snipped></snipped></p>

  <p><strong>Claude:</strong> Based on the output of the <code class="language-plaintext highlighter-rouge">ps aux</code> command, here’s a summary of the processes running on this computer:System Summary:This is a multi-CPU system with 96 cores/threads (visible from the many CPU-related kernel threads)Running Linux with systemd as the init systemThe system has been up since August 20th (seen in START column)</p>
</blockquote>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.036.png" alt="Image" /></p>

<p>任何编码 harness 背后的第四个原语是编辑工具。编辑工具也正如其名。它从推理中获取结果并将编辑应用于文件。</p>

<p><a href="https://github.com/ghuntley/how-to-build-a-coding-agent">GitHub - ghuntley/how-to-build-a-coding-agent</a></p>

<p>让我们再次在代码仓库根目录，运行编辑工具：</p>

<p><code class="language-plaintext highlighter-rouge">go run edit_file.go</code></p>

<blockquote>
  <p><strong>You:</strong> Claude, mate. Create <code class="language-plaintext highlighter-rouge">fizzbuzz.js</code> with nodejs that does fizzbuzz up to 20 and execute it to ensure it works</p>

  <p><strong>Claude:</strong> I’ll create a Node.js implementation of FizzBuzz that counts up to 20 and then run it to make sure it works correctly.First, let me create the <code class="language-plaintext highlighter-rouge">fizzbuzz.js</code>, file:</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">edit_file({"path":"fizzbuzz.js","old_str":"","new_str":"// FizzBuzz implementation in Node.js\n// Prints numbers from 1 to 20\n// If number is divisible by 3, print "Fizz"\n// If number is divisible by 5, print "Buzz"\n// If number is divisible by both 3 and 5, print "FizzBuzz"\n\nfor (let i = 1; i &lt;= 20; i++) {\n if (i % 3 === 0 &amp;&amp; i % 5 === 0) {\n console.log("FizzBuzz");\n } else if (i % 3 === 0) {\n console.log("Fizz");\n } else if (i % 5 === 0) {\n console.log("Buzz");\n } else {\n console.log(i);\n }\n}"})</code></p>

  <p><strong>Editing file:</strong> <code class="language-plaintext highlighter-rouge">fizzbuzz.js</code> (replacing 0 chars with 485 chars)</p>

  <p><strong>File does not exist, creating new file:</strong> <code class="language-plaintext highlighter-rouge">fizzbuzz.js</code></p>

  <p><strong>Creating new file:</strong> <code class="language-plaintext highlighter-rouge">fizzbuzz.js</code></p>

  <p><strong>Claude:</strong> Now I’ll execute the script to make sure it works correctly:</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">bash({"command":"node fizzbuzz.js"})</code></p>

  <p><strong>Executing bash command:</strong> <code class="language-plaintext highlighter-rouge">node fizzbuzz.js</code></p>

  <p><strong>Bash command executed successfully, output length:</strong> 77 chars</p>

  <p><strong>result:</strong> 12Fizz4BuzzFizz78FizzBuzz11Fizz1314FizzBuzz1617Fizz19Buzz</p>

  <p><strong>Claude:</strong> Great! The <code class="language-plaintext highlighter-rouge">fizzbuzz.js</code> file has been created and executed successfully. The output shows:Numbers not divisible by 3 or 5 are printed as is (1, 2, 4, 7, 8, 11, 13, 14, 16, 17, 19)Numbers divisible by 3 (but not 5) print “Fizz” (3, 6, 9, 12, 18)Numbers divisible by 5 (but not 3) print “Buzz” (5, 10, 20)Number 15 prints “FizzBuzz” as it’s divisible by both 3 and 5</p>
</blockquote>

<p>至此，我们已拥有了一个编码 Agent。此时，我们可以尝试其他提示，例如「实现 FizzBuzz 计数到 100」，它也能正常工作。</p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.039.png" alt="Image" /></p>

<p>任何编码 Agent 背后的第五个，也是最后一个原语是搜索工具。如果我告诉你，索引源代码或所谓的智能背后并无魔法呢？</p>

<div class="language-go highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="n">CodeSearchDefinition</span> <span class="o">=</span> <span class="n">ToolDefinition</span><span class="p">{</span>
	<span class="n">Name</span><span class="o">:</span> <span class="s">"code_search"</span><span class="p">,</span>
	<span class="n">Description</span><span class="o">:</span> <span class="s">`Search for code patterns using ripgrep (rg).

Use this to find code patterns, function definitions, variable usage, or any text in the codebase.
You can search by pattern, file type, or directory.`</span><span class="p">,</span>
	<span class="n">InputSchema</span><span class="o">:</span> <span class="n">CodeSearchInputSchema</span><span class="p">,</span>
	<span class="n">Function</span><span class="o">:</span>    <span class="n">CodeSearch</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>目前几乎所有主流编码工具的底层都使用了开源的 <code class="language-plaintext highlighter-rouge">ripgrep</code> 二进制文件。如需更多证据，请参阅以下两篇博客文章：</p>

<p><a href="https://ghuntley.com/amazon-kiro-source-code/">source code analysis of Amazon Kiro</a></p>

<p><a href="https://ghuntley.com/overton/">from Luddites to AI: the Overton Window of disruption</a></p>

<p>让我们再次在代码仓库根目录，运行代码搜索工具：</p>

<p><code class="language-plaintext highlighter-rouge">go run code_search_tool.go</code></p>

<blockquote>
  <p><strong>You:</strong> Claude, mate. Search the source code for a riddle</p>

  <p><strong>Claude:</strong> I’d be happy to search the source code for a riddle. Let me use the code search tool to look for mentions of riddles in the codebase.</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">code_search({"pattern":"riddle"})</code></p>

  <p><strong>Searching for pattern:</strong> <code class="language-plaintext highlighter-rouge">riddle</code></p>

  <p><strong>Found 6 matches for pattern:</strong> <code class="language-plaintext highlighter-rouge">riddle</code></p>

  <p><strong>result:</strong> <code class="language-plaintext highlighter-rouge">./README.md:332:- </code>riddle.txt<code class="language-plaintext highlighter-rouge">: A simple riddle for content analysis</code></p>

  <p><strong>Claude:</strong> I’ve found references to a riddle file called “<code class="language-plaintext highlighter-rouge">riddle.txt</code>” in the codebase. Let me check if this file exists by listing the files in the current directory.</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">list_files({})</code></p>

  <p><strong>Listing files in directory:</strong> .</p>

  <p><strong>Successfully listed 25 files in .</strong></p>

  <p><strong>result:</strong> <code class="language-plaintext highlighter-rouge">["./go.mod","./renovate.json","./devenv.yaml","./AGENT.md","./.devenv.flake.nix","./.envrc","./bash_tool.go","./fizzbuzz.js","./code_search_tool.go","./list_files.go","./Makefile","./devenv.nix","./devenv.lock","./prompts/01-read_file.md","./prompts/02-list_files.md","./prompts/00-weather.md","./prompts/03-bash_tool.md","./prompts/04-edit_tool.md","./chat.go","./go.sum","./riddle.txt","./edit_tool.go","./README.md","./.gitignore","./read.go"]</code></p>

  <p><strong>Claude:</strong> Great! I can see that “<code class="language-plaintext highlighter-rouge">riddle.txt</code>” exists in the current directory. Let me read its contents to see what the riddle is.</p>

  <p><strong>tool:</strong> <code class="language-plaintext highlighter-rouge">read_file({"path":"riddle.txt"})</code></p>

  <p><strong>Reading file:</strong> <code class="language-plaintext highlighter-rouge">riddle.txt</code></p>

  <p><strong>Successfully read file</strong> <code class="language-plaintext highlighter-rouge">riddle.txt</code> (175 bytes)</p>

  <p><strong>result:</strong> I have a mane but I’m not a lion,I have four legs but I’m not a table,I can gallop but I’m not running,People say I’m disagreeable because I always say “neigh.”What am I?</p>

  <p><strong>Claude:</strong> I found the riddle in the file <code class="language-plaintext highlighter-rouge">riddle.txt</code>!</p>
</blockquote>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.037.png" alt="Image" /></p>

<p>接下来的工作主要是调整提示词。由于篇幅限制，本文未深入探讨 Harness 提示的概念。Harness 提示是注册工具的地方，它包含诸如操作系统类型等信息，以便 Agent 知道是使用 PowerShell (Windows) 还是 bash (Linux 或 macOS)。</p>

<p>它还包含关于 Agent 应如何操作的指令。之所以说「应」，是因为 LLM 具有非确定性。你可以提供指导，但这仅是引导。然而，通过提示评估、调整以及深入了解模型的行为模式，你可以开发出高效的提示。</p>

<p>如果你想看一个完全成熟的编码 Agent 的例子，请访问下面的博客文章：</p>

<p><a href="https://ghuntley.com/amazon-kiro-source-code/">source code analysis of Amazon Kiro</a> (需要付费订阅)</p>

<p>已经有很多开源编码 Agent，例如 SST Open Code</p>

<p><a href="https://github.com/sst/opencode">GitHub - sst/opencode</a></p>

<p>或者这个 100 行的 Agent，它在 SWE Bench 上得分很高。</p>

<p><a href="https://github.com/SWE-agent/mini-swe-agent">GitHub - SWE-agent/mini-swe-agent</a></p>

<p>如果你想要一些灵感，GitHub 上有许多存储库泄露了开发人员工具 harness 和工具提示。</p>

<p><a href="https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools">GitHub - x1xhlol/system-prompts-and-models-of-ai-tools</a></p>

<p><img src="/assets/images/how-to-build-a-coding-agent/how-to-build-an-agent.041.png" alt="Image" /></p>

<p>回顾一下，你刚刚构建了一个编码 Agent。也许你的目标并非创建一个编码 Agent。如果你是数据工程师，情况会如何？思考一下你的日常活动，拥有利用这些原语进行自动化的能力，对你的雇主而言将极具价值。</p>

<p>取代你工作的将是善用 AI 的人，而非 AI 本身。</p>

<p>如果你对 AI 感到焦虑，答案很简单：投资自己。今年是个人发展充满挑战的一年，切勿停滞不前。</p>

<p><a href="https://ghuntley.com/six-month-recap/">the six-month recap: closing talk on AI at Web Directions, Melbourne, June 2025</a></p>

<blockquote>
  <p>勇往直前，开始构建吧。</p>
</blockquote>]]></content><author><name>jxlwqq</name><email>jxlwqq@gmail.com</email></author><category term="ai" /><category term="coding-agent" /><summary type="html"><![CDATA[原文链接：How to build a coding agent: free workshop 作者：Geoffrey Huntley]]></summary></entry><entry><title type="html">从零构建超迷你 ChatGPT：在 DGX Spark 上全流程训练 nanochat</title><link href="https://jxlwqq.github.io/2025/12/05/nanochat-training/" rel="alternate" type="text/html" title="从零构建超迷你 ChatGPT：在 DGX Spark 上全流程训练 nanochat" /><published>2025-12-05T04:00:00+00:00</published><updated>2025-12-05T04:00:00+00:00</updated><id>https://jxlwqq.github.io/2025/12/05/nanochat-training</id><content type="html" xml:base="https://jxlwqq.github.io/2025/12/05/nanochat-training/"><![CDATA[<ul id="markdown-toc">
  <li><a href="#一项目背景" id="markdown-toc-一项目背景">一：项目背景</a>    <ul>
      <li><a href="#11-为什么要自己构建大语言模型" id="markdown-toc-11-为什么要自己构建大语言模型">1.1 为什么要自己构建大语言模型？</a></li>
      <li><a href="#12-nanochat-项目介绍" id="markdown-toc-12-nanochat-项目介绍">1.2 nanochat 项目介绍</a></li>
      <li><a href="#13-dgx-spark" id="markdown-toc-13-dgx-spark">1.3 DGX Spark</a></li>
    </ul>
  </li>
  <li><a href="#二实战演练" id="markdown-toc-二实战演练">二：实战演练</a>    <ul>
      <li><a href="#21-dgx-spark-训练环境适配" id="markdown-toc-21-dgx-spark-训练环境适配">2.1 DGX Spark 训练环境适配</a></li>
      <li><a href="#22-处理文本数据" id="markdown-toc-22-处理文本数据">2.2 处理文本数据</a>        <ul>
          <li><a href="#221-词元与词元-id" id="markdown-toc-221-词元与词元-id">2.2.1 词元与词元 ID</a></li>
          <li><a href="#222-词嵌入word-embeddings" id="markdown-toc-222-词嵌入word-embeddings">2.2.2 词嵌入（Word Embeddings）</a></li>
          <li><a href="#223-下载预训练数据集" id="markdown-toc-223-下载预训练数据集">2.2.3 下载预训练数据集</a></li>
          <li><a href="#224-训练-bpe-tokenizer" id="markdown-toc-224-训练-bpe-tokenizer">2.2.4 训练 BPE Tokenizer</a></li>
          <li><a href="#225-评估-tokenizer-压缩效率" id="markdown-toc-225-评估-tokenizer-压缩效率">2.2.5 评估 Tokenizer 压缩效率</a></li>
          <li><a href="#226-实际操作流程" id="markdown-toc-226-实际操作流程">2.2.6 实际操作流程</a></li>
        </ul>
      </li>
      <li><a href="#23-预训练pre-training" id="markdown-toc-23-预训练pre-training">2.3 预训练（Pre-training）</a>        <ul>
          <li><a href="#231-模型架构model-architecture" id="markdown-toc-231-模型架构model-architecture">2.3.1 模型架构（Model Architecture）</a></li>
          <li><a href="#232-优化器创新optimization" id="markdown-toc-232-优化器创新optimization">2.3.2 优化器创新（Optimization）</a></li>
          <li><a href="#233-训练配置" id="markdown-toc-233-训练配置">2.3.3 训练配置</a></li>
          <li><a href="#234-启动训练" id="markdown-toc-234-启动训练">2.3.4 启动训练</a></li>
        </ul>
      </li>
      <li><a href="#24-中期训练mid-training" id="markdown-toc-24-中期训练mid-training">2.4 中期训练（Mid-training）</a>        <ul>
          <li><a href="#241-数据混合策略data-mixture" id="markdown-toc-241-数据混合策略data-mixture">2.4.1 数据混合策略（Data Mixture）</a></li>
          <li><a href="#242-训练逻辑" id="markdown-toc-242-训练逻辑">2.4.2 训练逻辑</a></li>
          <li><a href="#243-启动训练" id="markdown-toc-243-启动训练">2.4.3 启动训练</a></li>
        </ul>
      </li>
      <li><a href="#25-监督微调sft" id="markdown-toc-25-监督微调sft">2.5 监督微调（SFT）</a>        <ul>
          <li><a href="#251-精选数据集curated-dataset" id="markdown-toc-251-精选数据集curated-dataset">2.5.1 精选数据集（Curated Dataset）</a></li>
          <li><a href="#252-训练策略" id="markdown-toc-252-训练策略">2.5.2 训练策略</a></li>
          <li><a href="#253-启动训练" id="markdown-toc-253-启动训练">2.5.3 启动训练</a></li>
        </ul>
      </li>
      <li><a href="#26-强化学习rl" id="markdown-toc-26-强化学习rl">2.6 强化学习（RL）</a>        <ul>
          <li><a href="#261-算法选择简化版-reinforce-with-group-baseline" id="markdown-toc-261-算法选择简化版-reinforce-with-group-baseline">2.6.1 算法选择：简化版 REINFORCE with Group Baseline</a></li>
          <li><a href="#262-训练流程" id="markdown-toc-262-训练流程">2.6.2 训练流程</a></li>
          <li><a href="#263-启动训练" id="markdown-toc-263-启动训练">2.6.3 启动训练</a></li>
        </ul>
      </li>
      <li><a href="#27-web-聊天" id="markdown-toc-27-web-聊天">2.7 Web 聊天</a>        <ul>
          <li><a href="#271-启动-web-服务" id="markdown-toc-271-启动-web-服务">2.7.1 启动 Web 服务</a></li>
        </ul>
      </li>
      <li><a href="#28-分享模型" id="markdown-toc-28-分享模型">2.8 分享模型</a></li>
    </ul>
  </li>
  <li><a href="#三总结" id="markdown-toc-三总结">三：总结</a></li>
</ul>

<h2 id="一项目背景">一：项目背景</h2>

<h3 id="11-为什么要自己构建大语言模型">1.1 为什么要自己构建大语言模型？</h3>

<p>OpenAI 推出的 ChatGPT 等大语言模型作为深度神经网络模型的代表，为自然语言处理（NLP）领域带来了革命性的变化。大语言模型在理解、生成和解释人类语言方面拥有出色的能力。
从零开始构建大语言模型不仅是一次深入了解模型机制和局限性的绝佳机会，还为我们提供了预训练和微调开源大语言模型，使其适应特定领域的数据集或任务的必要知识。
Andrej Karpathy 最近开源了 <a href="https://github.com/karpathy/nanochat">nanochat</a> 项目，号称花 100 美元就能从零训出一个能聊天的模型。正好手边有台 DGX Spark，于是决定跑一遍完整流程，顺便记录下来。</p>

<h3 id="12-nanochat-项目介绍">1.2 nanochat 项目介绍</h3>

<p>nanochat 是一个全栈式的大语言模型实现，旨在用最简洁、最少依赖的代码库复刻 ChatGPT 的核心功能。它不仅涵盖了从分词（Tokenization）、预训练（Pre-training）、微调（Fine-tuning）到推理（Inference）和 Web 服务部署的完整生命周期，还特别强调代码的可读性和可修改性（Hackable）。
现代大语言模型的构建通常分为<strong>预训练（Pre-training）</strong> 和 <strong>后训练（Post-training）</strong> 两个主要阶段。</p>
<ul>
  <li><strong>预训练阶段</strong>：这是消耗算力最大的阶段，旨在让模型从海量无标注文本中学习通用知识，产出<strong>基座模型（Base Model）</strong>。</li>
  <li><strong>后训练阶段</strong>：旨在将基座模型转化为能与人交互的助手。在 nanochat 项目中，这一阶段被细分为三个具体步骤：
    <ul>
      <li><strong>中期训练（Mid-training）</strong>：让模型适应对话格式和工具使用。值得注意的是，在更广泛的业界实践中，这一阶段通常也被称为「继续预训练」（Continued Pre-training）或「增量预训练」（Incremental Pre-training）。</li>
      <li><strong>监督微调（SFT）</strong>：通过问答数据教会模型遵循指令。</li>
      <li><strong>强化学习（RL）</strong>：进一步优化模型的回答质量（如数学推理能力）。</li>
    </ul>
  </li>
</ul>

<p>为了帮助大家理清这四个阶段（基座训练 + 后训练三部曲）的递进关系，我整理了下面这张全景对照表，涵盖了从脚本入口到真实数据集的完整细节：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">核心阶段 (Stage)</th>
      <th style="text-align: left">具体步骤 (Step)</th>
      <th style="text-align: left">nanochat 对应脚本</th>
      <th style="text-align: left">核心目标 (Goal)</th>
      <th style="text-align: left">输入数据 (Input)</th>
      <th style="text-align: left">数据集 (Dataset)</th>
      <th style="text-align: left">输出模型 (Output)</th>
      <th style="text-align: left">关键特征 (Key Features)</th>
      <th style="text-align: left">训练耗时 (DGX Spark)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>预训练</strong><br />(Pre-training)</td>
      <td style="text-align: left"><strong>基座训练</strong><br />(Base Training)</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">scripts.base_train</code></td>
      <td style="text-align: left"><strong>知识获取</strong>：学习通用语言规律、世界知识和逻辑推理能力。</td>
      <td style="text-align: left"><strong>海量无标注文本</strong><br />(Raw Text)<br /><i>来源：网页、书籍、代码库</i></td>
      <td style="text-align: left"><strong><a href="https://huggingface.co/datasets/karpathy/fineweb-edu-100b-shuffle">FineWeb-Edu</a> (100B Shuffle)</strong><br />~112 亿 Token（540 亿字符）</td>
      <td style="text-align: left"><strong>基座模型</strong><br />(Base Model)<br /><i>能力：擅长文本续写，但不懂指令交互</i></td>
      <td style="text-align: left">算力消耗最大 (约占 99%)<br />核心任务：Next Token Prediction</td>
      <td style="text-align: left">~10 天</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>后训练</strong><br />(Post-training)</td>
      <td style="text-align: left"><strong>中期训练</strong><br />(Mid-training)</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">scripts.mid_train</code></td>
      <td style="text-align: left"><strong>格式适应</strong>：教会模型识别特殊 Token、工具调用 JSON 格式等。</td>
      <td style="text-align: left"><strong>特定格式/合成数据</strong><br />(Synthetic/Formatted Data)<br /><i>来源：身份设定、工具调用示例</i></td>
      <td style="text-align: left"><strong><a href="https://huggingface.co/datasets/HuggingFaceTB/smol-smoltalk">SmolTalk</a> (460K) + <a href="https://huggingface.co/datasets/cais/mmlu">MMLU auxiliary_train</a> (100K，来自 ARC、MC_TEST、OBQA、RACE) + GSM8K (8K) + Spelling (280K) + Identity (1K×2)</strong><br />混合数据 (~850K 样本)</td>
      <td style="text-align: left"><strong>格式适配模型</strong><br /><i>能力：熟悉对话结构，但对话内容质量尚待提升</i></td>
      <td style="text-align: left">介于预训练和 SFT 之间的过渡<br />nanochat 特有步骤 (类比 Continued Pre-training)</td>
      <td style="text-align: left">~9 小时</td>
    </tr>
    <tr>
      <td style="text-align: left"> </td>
      <td style="text-align: left"><strong>监督微调</strong><br />(SFT)</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">scripts.chat_sft</code></td>
      <td style="text-align: left"><strong>指令遵循</strong>：教会模型如何像助手一样对话，理解用户意图并正确回应。</td>
      <td style="text-align: left"><strong>指令-回复对</strong><br />(Instruction-Response Pairs)<br /><i>来源：高质量人工撰写或筛选的问答</i></td>
      <td style="text-align: left"><strong><a href="https://huggingface.co/datasets/allenai/ai2_arc">ARC</a> (Easy/Challenge), <a href="https://huggingface.co/datasets/openai/gsm8k">GSM8K</a> (Main), <a href="https://huggingface.co/datasets/HuggingFaceTB/smol-smoltalk">SmolTalk</a> (10k subset), <a href="https://karpathy-public.s3.us-west-2.amazonaws.com/identity_conversations.jsonl">Identity</a> (Synthetic), <a href="https://github.com/dwyl/english-words">English Words</a> (SpellingBee)</strong><br />精选指令集 (~23K 样本)</td>
      <td style="text-align: left"><strong>对话/指令模型</strong><br />(Chat/Instruct Model)<br /><i>能力：能流畅对话，遵循指令</i></td>
      <td style="text-align: left">模仿学习 (Supervised Learning)<br />让模型「学会说话」</td>
      <td style="text-align: left">~1 小时</td>
    </tr>
    <tr>
      <td style="text-align: left"> </td>
      <td style="text-align: left"><strong>强化学习</strong><br />(RL)</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">scripts.chat_rl</code></td>
      <td style="text-align: left"><strong>偏好对齐</strong>：进一步优化回答质量，提升特定能力（如数学逻辑）或安全性。</td>
      <td style="text-align: left"><strong>提示词 + 奖励信号</strong><br />(Prompts + Rewards)<br /><i>来源：GSM8K 数学题、人类偏好排序</i></td>
      <td style="text-align: left"><strong><a href="https://huggingface.co/datasets/openai/gsm8k">GSM8K</a> (Main)</strong><br />数学应用题 (~7.5K 问题)</td>
      <td style="text-align: left"><strong>对齐模型</strong><br />(Aligned Model)<br /><i>能力：符合人类价值观，逻辑更严密</i></td>
      <td style="text-align: left">探索与利用 (Exploration &amp; Exploitation)<br />让模型「说得更好」</td>
      <td style="text-align: left">~6 小时</td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p><strong>💡 关于安全对齐</strong>：在业界完整的大模型训练流程中，<strong>安全性和价值观对齐</strong>主要在 <strong>RL 阶段</strong>通过 RLHF（Reinforcement Learning from Human Feedback）或 DPO（Direct Preference Optimization）等方法实现。人类标注员会对模型回复进行偏好排序，训练奖励模型来评估回答是否<strong>有帮助（Helpful）</strong>、<strong>诚实（Honest）</strong>、<strong>无害（Harmless）</strong>——即著名的 <strong>3H 原则</strong>。nanochat 作为教学项目，仅使用 GSM8K 数学题的正确性作为奖励信号，<strong>并未实现完整的安全对齐</strong>。</p>
</blockquote>

<p>下图展示了 nanochat 完整的训练流水线，清晰地描绘了从海量文本到最终对齐模型的演进过程：</p>

<pre><code class="language-mermaid">flowchart LR
    %% Nodes
    D1[("📚 海量无标注文本&lt;br/&gt;FineWeb-Edu")]
    P1(基座训练&lt;br/&gt;scripts.base_train)
    M1(基座模型&lt;br/&gt;Base Model)
    
    D2[("📋 格式化数据&lt;br/&gt;SmolTalk + MMLU")]
    P2(中期训练&lt;br/&gt;scripts.mid_train)
    M2(格式适配模型&lt;br/&gt;Formatted Model)
    
    D3[("🗣️ 指令对话数据&lt;br/&gt;ARC, GSM8K...")]
    P3(监督微调&lt;br/&gt;scripts.chat_sft)
    M3(对话模型&lt;br/&gt;Chat Model)
    
    D4[("⚖️ 奖励信号&lt;br/&gt;GSM8K")]
    P4(强化学习&lt;br/&gt;scripts.chat_rl)
    M4(最终对齐模型&lt;br/&gt;Aligned Model)

    %% Main Flow
    D1 --&gt; P1 --&gt; M1
    M1 --&gt; P2
    D2 --&gt; P2 --&gt; M2
    M2 --&gt; P3
    D3 --&gt; P3 --&gt; M3
    M3 --&gt; P4
    D4 --&gt; P4 --&gt; M4

    %% Styles
    classDef data fill:#e3f2fd,stroke:#1565c0,stroke-width:1px;
    classDef process fill:#fff3e0,stroke:#ef6c00,stroke-width:1px,rx:5,ry:5;
    classDef model fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,rx:5,ry:5;
    
    class D1,D2,D3,D4 data;
    class P1,P2,P3,P4 process;
    class M1,M2,M3,M4 model;
</code></pre>

<h3 id="13-dgx-spark">1.3 DGX Spark</h3>

<p><img src="/assets/images/nanochat-training/dgx-spark.jpg" alt="AI 研究人员手持 DGX Spark" /></p>

<p>上图为两位 AI 研究人员正在研究 DGX Spark 设备，其体积小巧，可轻松单手托持。</p>

<p><strong>NVIDIA DGX Spark</strong> 是专为边缘计算和个人开发者设计的高性能 AI 工作站。它搭载了 <strong>NVIDIA Grace Blackwell GB10</strong> 芯片，拥有 <strong>128 GB 统一内存架构（Unified Memory Architecture）</strong>。</p>

<ul>
  <li><strong>硬件优势</strong>：传统的消费级显卡显存通常受限（如 24 GB），难以加载或训练较大的模型。而 DGX Spark 的 128 GB 统一内存打破了这一瓶颈。</li>
  <li><strong>新一代精度支持</strong>：得益于 Blackwell 架构，DGX Spark 原生支持 <strong>MXFP4</strong>（Microscaling Format）和 <strong>NVFP4</strong> 等前沿数据类型。这些低精度格式在保持模型性能的同时，能显著降低显存占用并大幅提升计算吞吐量，为未来的优化预留了空间。值得一提的是，OpenAI 开源的 <a href="https://github.com/openai/gpt-oss">gpt-oss</a> 项目正是采用 <strong>MXFP4</strong> 量化技术，使得 1170 亿参数的 <code class="language-plaintext highlighter-rouge">gpt-oss-120b</code> 模型能够在单张 80GB 显存的 GPU（如 NVIDIA H100 或 AMD MI300X）上运行推理，而 <code class="language-plaintext highlighter-rouge">gpt-oss-20b</code> 模型更是可以在 16GB 显存内运行。这充分展示了低精度量化技术在大模型部署中的巨大潜力。</li>
  <li><strong>美中不足</strong>：虽然配备 128 GB 的统一内存，但不得不吐槽的是，它采用的是 LPDDR5X 统一内存，理论峰值带宽仅为 <strong>273 GB/s</strong>（数据来源：<a href="https://www.nvidia.com/en-us/products/workstations/dgx-spark/">NVIDIA 官方规格</a>），相比数据中心级别 H100 的 HBM3 带宽（<strong>3.35 TB/s</strong>）有约 <strong>12 倍</strong>的差距。由于大语言模型训练是典型的内存带宽受限（Memory-Bound）任务，这意味着在 DGX Spark 上训练时，GPU 计算单元往往在「等待」数据从内存传输过来，导致训练时间显著延长（这也是为什么我们需要耐心等待约 10 天来完成预训练的原因）。</li>
</ul>

<p><img src="/assets/images/nanochat-training/nvidia-smi.png" alt="nvidia-smi" /></p>

<p>在 DGX Spark 运行 <code class="language-plaintext highlighter-rouge">nvidia-smi</code> 命令：搭载 NVIDIA GB10 芯片，驱动版本 580.95.05，CUDA 13.0。GPU 利用率为 96%，约 78 GB 内存正被 nanochat 训练进程占用。</p>

<h2 id="二实战演练">二：实战演练</h2>

<blockquote>
  <p><strong>⚠️ 网络环境提示</strong>：全程需要科学上网，以确保能够顺利访问境外相关资源。</p>
</blockquote>

<h3 id="21-dgx-spark-训练环境适配">2.1 DGX Spark 训练环境适配</h3>

<p>nanochat 项目推荐使用 <strong>8 张 NVIDIA H100/H200 GPU</strong>（基于 <strong>Hopper 架构</strong>）来快速完成训练（约 4 小时）。而在 DGX Spark 上，我们使用的是单张 <strong>Grace Blackwell GB10</strong> 芯片（基于 <strong>Blackwell 架构</strong>）。</p>

<p>为了在 DGX Spark 上顺利跑通 nanochat 的全流程训练，我们在原基础之上进行了以下关键适配：</p>

<ul>
  <li><strong>CUDA 13.0 环境适配</strong>：
    <ul>
      <li><strong>调整原因</strong>：DGX Spark 搭载的 Grace Blackwell GB10 芯片需要最新的 CUDA 13.0 支持才能发挥最佳性能。</li>
      <li><strong>具体调整</strong>：修改 <code class="language-plaintext highlighter-rouge">pyproject.toml</code>，将 PyTorch 版本升级至 <code class="language-plaintext highlighter-rouge">&gt;=2.9.0</code>，并指定 <code class="language-plaintext highlighter-rouge">cu130</code> 源。同时引入了 <code class="language-plaintext highlighter-rouge">triton&gt;=3.5.0</code>
，以支持 Blackwell 架构下的内核编译与优化。</li>
    </ul>
  </li>
  <li><strong>内存管理优化</strong>：
    <ul>
      <li><strong>调整原因</strong>：虽然 128 GB 统一内存容量很大，但为了防止长时间训练中的显存碎片化导致 OOM（Out Of Memory）。</li>
      <li><strong>具体调整</strong>：在训练脚本中设置了 <code class="language-plaintext highlighter-rouge">PYTORCH_ALLOC_CONF=max_split_size_mb:512</code>，优化 PyTorch 的内存分配策略。同时，将预训练的 <code class="language-plaintext highlighter-rouge">device_batch_size</code> 调整为 <strong>32</strong>，在充分利用内存与保持稳定性之间寻找平衡点。</li>
    </ul>
  </li>
  <li><strong>网络加速配置</strong>：
    <ul>
      <li><strong>调整原因</strong>：考虑到国内网络环境访问 PyPI 和 Hugging Face 可能受限或速度较慢。</li>
      <li><strong>具体建议</strong>：推荐配置 pip 使用清华大学镜像源 (<code class="language-plaintext highlighter-rouge">https://pypi.tuna.tsinghua.edu.cn/simple</code>)，以及使用 HF-Mirror (
<code class="language-plaintext highlighter-rouge">https://hf-mirror.com</code>) 加速模型和数据集的下载。</li>
    </ul>
  </li>
</ul>

<p>环境配置脚本如下：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="k">if</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-f</span> cuda-ubuntu2404.pin <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span>wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/sbsa/cuda-ubuntu2404.pin
<span class="k">fi
</span><span class="nb">sudo mv </span>cuda-ubuntu2404.pin /etc/apt/preferences.d/cuda-repository-pin-600
<span class="k">if</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-f</span> cuda-repo-ubuntu2404-13-0-local_13.0.2-580.95.05-1_arm64.deb <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span>wget https://developer.download.nvidia.com/compute/cuda/13.0.2/local_installers/cuda-repo-ubuntu2404-13-0-local_13.0.2-580.95.05-1_arm64.deb
<span class="k">fi
</span><span class="nb">sudo </span>dpkg <span class="nt">-i</span> cuda-repo-ubuntu2404-13-0-local_13.0.2-580.95.05-1_arm64.deb
<span class="nb">sudo cp</span> /var/cuda-repo-ubuntu2404-13-0-local/cuda-<span class="k">*</span><span class="nt">-keyring</span>.gpg /usr/share/keyrings/
<span class="nb">sudo </span>apt-get update
<span class="nb">sudo </span>apt-get <span class="nt">-y</span> <span class="nb">install </span>cuda-toolkit-13-0

<span class="nb">echo</span> <span class="s1">'export TRITON_PTXAS_PATH=/usr/local/cuda-13.0/bin/ptxas'</span> <span class="o">&gt;&gt;</span> ~/.bashrc
<span class="nb">echo</span> <span class="s1">'export CUDA_HOME=/usr/local/cuda-13.0'</span> <span class="o">&gt;&gt;</span> ~/.bashrc
<span class="nb">echo</span> <span class="s1">'export PATH=/usr/local/cuda-13.0/bin:$PATH'</span> <span class="o">&gt;&gt;</span> ~/.bashrc
<span class="nb">echo</span> <span class="s1">'export LD_LIBRARY_PATH=/usr/local/cuda-13.0/lib64:${LD_LIBRARY_PATH}'</span> <span class="o">&gt;&gt;</span> ~/.bashrc

<span class="nb">echo</span> <span class="s1">'export HF_ENDPOINT=https://hf-mirror.com'</span> <span class="o">&gt;&gt;</span> ~/.bashrc

<span class="nb">source</span> ~/.bashrc

<span class="c"># Verify installation</span>
nvcc <span class="nt">--version</span>
nvidia-smi

git clone https://github.com/karpathy/nanochat.git
<span class="nb">cd </span>nanochat

<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'s/"torch&gt;=2.8.0"/"torch&gt;=2.9.0"/g'</span> pyproject.toml
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'/tiktoken&gt;=0.11.0/a\    "triton&gt;=3.5.0",'</span> pyproject.toml
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'s|pytorch-cu128|pytorch-cu130|g'</span> pyproject.toml
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'s|cu128|cu130|g'</span> pyproject.toml
<span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'s|# target torch to cuda 12.8 or CPU|# target torch to cuda 13.0 or CPU|'</span> pyproject.toml


 <span class="c"># Use HF-Mirror to accelerate dataset download in China</span>
 <span class="nb">sed</span> <span class="nt">-i</span> <span class="s1">'s|https://huggingface.co|https://hf-mirror.com|g'</span> nanochat/dataset.py

<span class="c"># install uv (if not already installed)</span>
<span class="nb">command</span> <span class="nt">-v</span> uv &amp;&gt; /dev/null <span class="o">||</span> curl <span class="nt">-LsSf</span> https://astral.sh/uv/install.sh | sh
<span class="c"># create a .venv local virtual environment (if it doesn't exist)</span>
<span class="o">[</span> <span class="nt">-d</span> <span class="s2">".venv"</span> <span class="o">]</span> <span class="o">||</span> uv venv
<span class="c"># install the repo dependencies (using Tsinghua mirror for faster download in China)</span>
uv <span class="nb">sync</span> <span class="nt">--extra</span> gpu <span class="nt">--index-url</span> https://pypi.tuna.tsinghua.edu.cn/simple
<span class="c"># activate venv so that `python` uses the project's venv instead of system python</span>
<span class="nb">source</span> .venv/bin/activate
</code></pre></div></div>

<h3 id="22-处理文本数据">2.2 处理文本数据</h3>

<p>在正式开始训练之前，我们需要先把海量文本转换成模型能理解的「数字语言」。这个过程就像给电脑准备一本「字典」，让它知道如何阅读人类的语言。</p>

<h4 id="221-词元与词元-id">2.2.1 词元与词元 ID</h4>

<ul>
  <li><strong>词元（Token）</strong>：是模型理解和处理文本的最小单位。它不一定等同于一个单词，它可以是一个单词、一个汉字、一个词根，甚至是一个字符或字节。例如，单词「learning」可能被拆分为「learn」和「ing」两个词元。</li>
  <li><strong>词元 ID（Token ID）</strong>：为了让计算机能够处理，每个唯一的词元都会被分配一个独一无二的数字编号，这就是词元 ID。模型实际上是在处理这些数字序列，而不是文本本身。</li>
</ul>

<p>nanochat 采用了与 <strong>GPT-4</strong> 相同的 <strong>BPE（Byte Pair Encoding）</strong> 分词算法，但在细节上针对小模型进行了优化：</p>

<ul>
  <li><strong>预分词（Pre-tokenization）</strong>：首先使用正则表达式（Regex）将文本粗略地切分成一个个小块。nanochat 使用的正则模式 <code class="language-plaintext highlighter-rouge">SPLIT_PATTERN</code> 能够智能地处理缩写（如 <code class="language-plaintext highlighter-rouge">'ll</code>、<code class="language-plaintext highlighter-rouge">'ve</code>）、数字和标点符号。
    <ul>
      <li><em>优化点</em>：nanochat 将数字的匹配规则从 GPT-4 的 <code class="language-plaintext highlighter-rouge">\p{N}{1,3}</code>（最多 3 位数字）调整为 <code class="language-plaintext highlighter-rouge">\p{N}{1,2}</code>（最多 2 位数字），这样可以减少数字类词元对词表空间的占用，更适合小参数量的模型。</li>
    </ul>
  </li>
  <li><strong>字节级 BPE</strong>：它不是直接处理 Unicode 字符，而是处理 UTF-8 编码后的字节。这意味着它可以处理任何语言的文本，甚至包括 emoji 表情，而不会出现「未知字符」错误。</li>
</ul>

<p>将词元转换成词元 ID 的过程通常称为 <strong>Encoding（编码）</strong>：</p>

<ul>
  <li><strong>查表</strong>：分词器内部维护着一个巨大的<strong>词表（Vocabulary）</strong>，记录了所有已知的词元及其对应的 ID（nanochat 的词表大小为 65,536）。</li>
  <li><strong>映射</strong>：当文本被切分成词元序列后，分词器会遍历这个序列，在词表中查找每个词元对应的 ID。</li>
  <li><strong>输出</strong>：最终，一段文本就被转换成了一串数字列表（Tensor），这才是送入神经网络的真正「输入」。</li>
</ul>

<p>例如：<code class="language-plaintext highlighter-rouge">"Hello world"</code> -&gt; 分词 -&gt; <code class="language-plaintext highlighter-rouge">["Hello", " world"]</code> -&gt; 查表 -&gt; <code class="language-plaintext highlighter-rouge">[15496, 995]</code>。</p>

<pre><code class="language-mermaid">graph LR
    %% 1. Input
    subgraph Input [完整的训练集]
        direction TB
        Text[/"The quick brown fox jumps&lt;br/&gt;over the lazy dog"/]
        Note["(为了便于说明，&lt;br/&gt;训练集中只包含一个句子)"]
        Text -.- Note
    end

    %% 2. Tokenized
    subgraph Tokens [词元化的训练集]
        direction LR
        T_The[The] --- T_quick[quick] --- T_brown[brown] --- T_dots[...]
    end

    %% 3. Vocabulary
    subgraph Vocab [词汇表]
        direction TB
        V0["brown → 0"]
        V1["dog → 1"]
        V2["fox → 2"]
        V3["jumps → 3"]
        V4["lazy → 4"]
        V5["over → 5"]
        V6["quick → 6"]
        V7["the → 7"]
        V0 ~~~ V1 ~~~ V2 ~~~ V3 ~~~ V4 ~~~ V5 ~~~ V6 ~~~ V7
    end

    Input -- "1. 将输入文本分词为独立的词元" --&gt; Tokens
    Tokens -- "2. 按字母顺序排列并去重&lt;br/&gt;映射到唯一整数ID" --&gt; Vocab
</code></pre>

<h4 id="222-词嵌入word-embeddings">2.2.2 词嵌入（Word Embeddings）</h4>

<p>虽然词元 ID 解决了「将文本转换为数字」的问题，但这些数字本身是离散的，没有任何语义关联。例如，词元 ID <code class="language-plaintext highlighter-rouge">100</code>（apple）和 <code class="language-plaintext highlighter-rouge">101</code>（banana）在数值上只差 1，但计算机并不知道它们都是水果。</p>

<p><strong>词嵌入（Word Embeddings）</strong> 就是为了解决这个问题。它将每个词元 ID 映射到一个高维的连续向量空间中（nanochat d20 配置下为 1280 维）。在这个空间里：</p>

<ul>
  <li><strong>语义相似</strong>的词（如「king」和「queen」）在距离上会靠得很近。</li>
  <li><strong>语义关系</strong>可以通过向量运算来表示（经典的例子：$Vector(King) - Vector(Man) + Vector(Woman) \approx Vector(Queen)$）。</li>
</ul>

<p>简单来说，词嵌入就是给每个词元赋予一个「特征列表」，让模型能够理解词与词之间的微妙关系。</p>

<p>在 <code class="language-plaintext highlighter-rouge">nanochat/gpt.py</code> 中，我们可以看到 nanochat 对词嵌入的处理有几个显著特点：</p>

<ul>
  <li><strong>独立的嵌入层（<code class="language-plaintext highlighter-rouge">wte</code>）</strong>：nanochat 使用一个标准的 <code class="language-plaintext highlighter-rouge">nn.Embedding</code> 层作为查找表，将 65,536 个词元（由 Tokenizer 训练决定）映射为高维向量（d20 配置下为 1280 维）。
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="bp">self</span><span class="p">.</span><span class="n">transformer</span> <span class="o">=</span> <span class="n">nn</span><span class="p">.</span><span class="n">ModuleDict</span><span class="p">({</span>
    <span class="s">"wte"</span><span class="p">:</span> <span class="n">nn</span><span class="p">.</span><span class="n">Embedding</span><span class="p">(</span><span class="n">config</span><span class="p">.</span><span class="n">vocab_size</span><span class="p">,</span> <span class="n">config</span><span class="p">.</span><span class="n">n_embd</span><span class="p">),</span>
    <span class="c1"># ...
</span><span class="p">})</span>
</code></pre></div>    </div>
  </li>
  <li>
    <p><strong>解绑权重（Untied Weights）</strong>：与许多早期模型（如 GPT-2）不同，nanochat 的输入嵌入层（<code class="language-plaintext highlighter-rouge">wte</code>）和输出层（<code class="language-plaintext highlighter-rouge">lm_head</code>）<strong>不共享权重</strong>。这意味着模型可以分别学习「如何理解输入」和「如何生成输出」，增加了模型的表达能力。</p>
  </li>
  <li><strong>嵌入后归一化（Embedding Norm）</strong>：nanochat 在嵌入层之后立即应用了 <strong>RMSNorm</strong>（Root Mean Square Normalization）。这是一种简化的归一化方法，它只对激活值进行方差归一化（除以均方根），而不像 LayerNorm 那样做零均值化，因此计算开销更小。这种现代化的设计有助于稳定训练过程，防止梯度爆炸，加速权重的有效收敛。
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Forward pass snippet
</span><span class="n">x</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">transformer</span><span class="p">.</span><span class="n">wte</span><span class="p">(</span><span class="n">idx</span><span class="p">)</span>
<span class="n">x</span> <span class="o">=</span> <span class="n">norm</span><span class="p">(</span><span class="n">x</span><span class="p">)</span> <span class="c1"># 立即进行归一化
</span></code></pre></div>    </div>
  </li>
  <li>
    <p><strong>混合精度优化</strong>：
为了节省显存，nanochat 会将嵌入层的权重转换为 <code class="language-plaintext highlighter-rouge">bfloat16</code> 格式（如果硬件支持），这在保持精度的同时显著降低了内存占用。</p>
  </li>
  <li>
    <p><strong>差异化学习率</strong>：
在优化器设置中，nanochat 对嵌入层使用了 <strong>AdamW</strong> 优化器，并对其学习率进行了特殊缩放（$\propto 1/\sqrt{d_{model}}$
），而模型的主体部分（Linear 层）则使用 <strong>Muon</strong> 优化器。这种精细的调优策略确保了不同类型的参数都能以最佳速度收敛。</p>
  </li>
  <li><strong>实现级别说明（Lookup → Norm → LM Head）</strong>：
为了让读者更直观地理解嵌入是如何被模型使用，下面给出一个简化的流程图（基于 <code class="language-plaintext highlighter-rouge">nanochat/gpt.py</code> 的前向逻辑）：</li>
</ul>

<pre><code class="language-mermaid">flowchart LR
  %% Data Nodes
  Input(Token IDs&lt;br/&gt;idx)
  Output(Logits&lt;br/&gt;Next Token Scores)

  %% Operation Nodes
  WTE[wte&lt;br/&gt;Embedding Lookup]
  Norm1[RMSNorm&lt;br/&gt;Post-Embedding]
  Trans[Transformer Blocks&lt;br/&gt;Attention + MLP]
  Norm2[RMSNorm&lt;br/&gt;Pre-LM Head]
  Head[lm_head&lt;br/&gt;Linear Projection]
  Softcap[Softcap&lt;br/&gt;Soft Capping]

  %% Flow
  Input -- "(B, T)" --&gt; WTE
  WTE -- "(B, T, n_embd)" --&gt; Norm1
  Norm1 --&gt; Trans
  Trans -- "(B, T, n_embd)" --&gt; Norm2
  Norm2 --&gt; Head
  Head -- "(B, T, vocab_size)" --&gt; Softcap
  Softcap --&gt; Output

  %% Styling
  classDef data fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
  classDef op fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,rx:5,ry:5;
      
  class Input,Output data;
  class WTE,Norm1,Norm2,Trans,Head,Softcap op;
</code></pre>

<p>关键点说明：</p>
<ul>
  <li>嵌入权重在初始化时采用正态分布（见 <code class="language-plaintext highlighter-rouge">_init_weights</code>），并在支持的设备上转换为 <code class="language-plaintext highlighter-rouge">bfloat16</code> 以节省内存。</li>
  <li><code class="language-plaintext highlighter-rouge">norm(x)</code> 在嵌入之后立即调用，有助于稳定后续 Transformer 层的训练。</li>
  <li><code class="language-plaintext highlighter-rouge">lm_head</code> 为单独的线性层（不共享 <code class="language-plaintext highlighter-rouge">wte</code>），负责将模型的隐向量投射回词表维度，得到预测每个下一词的 logits。</li>
  <li>
    <p><strong>Softcap</strong> 是 nanochat 的一个特色设计：通过软截断将 logits 限制在 <code class="language-plaintext highlighter-rouge">[-15, 15]</code> 范围内：</p>

\[\text{logits}_{\text{capped}} = 15 \cdot \tanh\left(\frac{\text{logits}}{15}\right)\]

    <p>这种软截断（Soft Capping）有助于防止 logits 数值过大导致的数值不稳定问题，同时保留了梯度信息（相比硬截断 clamp），是一种提升训练稳定性的技巧。</p>
  </li>
</ul>

<h4 id="223-下载预训练数据集">2.2.3 下载预训练数据集</h4>

<p>nanochat 采用 <a href="https://huggingface.co/datasets/karpathy/fineweb-edu-100b-shuffle">FineWeb-Edu</a> 作为预训练数据集。该数据集由 Hugging Face 团队从 Common Crawl 中筛选出教育质量较高的网页内容，经过去重、过滤和质量评分后打包为 Parquet 格式。</p>

<p><strong>数据集规格</strong>：</p>

<ul>
  <li><strong>总规模</strong>：1823 个 Parquet 文件，每个约 100 MB</li>
  <li><strong>预训练需求</strong>：约 240 个文件（~24 GB），包含约 112 亿 Token</li>
  <li><strong>Tokenizer 训练需求</strong>：前 8 个文件（~800 MB），包含约 20 亿字符</li>
</ul>

<p>数据下载通过 <code class="language-plaintext highlighter-rouge">nanochat.dataset</code> 模块的 <code class="language-plaintext highlighter-rouge">download_single_file</code> 函数实现，支持断点续传和并行下载。下载后的文件存储在 <code class="language-plaintext highlighter-rouge">~/.cache/nanochat/base_data/</code> 目录。</p>

<h4 id="224-训练-bpe-tokenizer">2.2.4 训练 BPE Tokenizer</h4>

<p>Tokenizer 是将自然语言文本转换为模型可处理的整数序列的核心组件。nanochat 采用 <strong>Byte Pair Encoding（BPE）</strong> 算法，这是 GPT 系列模型的标准分词方案。</p>

<p>BPE 的实现相对复杂，nanochat 直接使用现有的 Python 开源库 tiktoken，它基于 Rust 的源代码非常高效地实现了 BPE 算法。BPE 算法的原理是将不在预定义词汇表中的单词分解为更小的子词单元甚至单个字符，从而能够处理词汇表之外的单词。</p>

<p><strong>训练流程</strong>（<code class="language-plaintext highlighter-rouge">scripts/tok_train.py</code>）：</p>

<ol>
  <li><strong>预分词（Pre-tokenization）</strong>：使用正则表达式 <code class="language-plaintext highlighter-rouge">SPLIT_PATTERN</code> 将文本切分为候选 Token。该模式针对小模型优化，将数字匹配规则从 GPT-4 的 <code class="language-plaintext highlighter-rouge">\p{N}{1,3}</code> 调整为 <code class="language-plaintext highlighter-rouge">\p{N}{1,2}</code>，减少数字类 Token 对词表空间的占用：
    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>r"""'(?i:[sdmt]|ll|ve|re)|[^\p{L}\p{N}]?+\p{L}+|\p{N}{1,2}| ?[^\s\p{L}\p{N}]++[\n]*|\s*[\n]|\s+(?!\S)|\s+"""
</code></pre></div>    </div>
  </li>
  <li>
    <p><strong>字节级 BPE 训练</strong>：基于 UTF-8 字节流进行训练，而非直接操作 Unicode 字符。这确保了对任意语言和特殊字符的无损处理。训练过程迭代合并高频字节对，直至词表达到目标大小 <strong>65,536</strong>（2^16），可用两字节表示任意 Token ID。</p>
  </li>
  <li><strong>特殊 Token 注册</strong>：在基础词表之外追加 9 个特殊 Token（定义见 <code class="language-plaintext highlighter-rouge">tokenizer.py</code> 的 <code class="language-plaintext highlighter-rouge">SPECIAL_TOKENS</code>）：
    <div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">[</span><span class="s">"&lt;|bos|&gt;"</span><span class="p">,</span> <span class="s">"&lt;|user_start|&gt;"</span><span class="p">,</span> <span class="s">"&lt;|user_end|&gt;"</span><span class="p">,</span> 
 <span class="s">"&lt;|assistant_start|&gt;"</span><span class="p">,</span> <span class="s">"&lt;|assistant_end|&gt;"</span><span class="p">,</span>
 <span class="s">"&lt;|python_start|&gt;"</span><span class="p">,</span> <span class="s">"&lt;|python_end|&gt;"</span><span class="p">,</span> 
 <span class="s">"&lt;|output_start|&gt;"</span><span class="p">,</span> <span class="s">"&lt;|output_end|&gt;"</span><span class="p">]</span>
</code></pre></div>    </div>
    <p>这些 Token 用于标记文档边界、对话角色和工具调用，仅在后训练阶段启用。</p>
  </li>
  <li><strong>字节统计缓存</strong>：为支持评估指标 Bits Per Byte（BPB），训练脚本会计算每个 Token 对应的 UTF-8 字节数并缓存到 <code class="language-plaintext highlighter-rouge">token_bytes.pt</code>。特殊 Token 的字节数设为 0，不参与压缩率计算。在模型评估时，该缓存用于将交叉熵损失转换为 BPB：<code class="language-plaintext highlighter-rouge">bpb = loss / ln(2) / avg_bytes_per_token</code>。</li>
</ol>

<p><strong>实现细节</strong>：nanochat 结合 <code class="language-plaintext highlighter-rouge">rustbpe</code>（训练）和 <code class="language-plaintext highlighter-rouge">tiktoken</code>（推理）两个库，前者用 Rust 实现高效训练，后者由 OpenAI 开源，提供优化的批量编码能力。</p>

<h4 id="225-评估-tokenizer-压缩效率">2.2.5 评估 Tokenizer 压缩效率</h4>

<p>训练完成后，通过 <code class="language-plaintext highlighter-rouge">scripts/tok_eval.py</code> 评估 Tokenizer 的压缩性能。核心指标为 <strong>Bits Per Byte (BPB)</strong>。</p>

<p><strong>Tokenizer 熵上界（均匀分布假设）</strong></p>

<p>假设每个 Token 以均匀概率出现（即不考虑语言模型的预测能力），则编码每个 Token 需要 $\log_2(V)$ 比特。在此假设下，Tokenizer 的压缩率上界公式为：</p>

\[\text{BPB}_{\text{upper bound}} = \frac{\log_2(V) \times N_{\text{tokens}}}{N_{\text{bytes}}} = \frac{16 \times N_{\text{tokens}}}{N_{\text{bytes}}}\]

<p>其中 $V = 65,536$ 为词表大小，$N_{\text{tokens}}$ 为 Token 数量，$N_{\text{bytes}}$ 为原始 UTF-8 字节数。这个指标衡量的是 <strong>Tokenizer 本身的压缩效率上限</strong>，与模型无关。实际的语言模型通过学习 Token 分布，能够以远低于此上界的比特数编码文本。</p>

<p><strong>实际 BPB（模型性能）</strong></p>

<p>在模型评估中，BPB 通常通过交叉熵损失计算，反映模型预测下一个 Token 的能力：</p>

\[\text{BPB}_{\text{model}} = \frac{\text{Cross Entropy Loss (nats)}}{\ln(2) \times \text{avg bytes per token}}\]

<p>这个指标综合了 Tokenizer 压缩率和模型预测准确性，是评估语言模型的重要指标。BPB 越低，表示模型对文本的建模能力越强。</p>

<p><strong>评估基准</strong>:脚本在多种文本类型上测试 Tokenizer 的泛化能力:</p>

<ul>
  <li><strong>新闻文本</strong>（<code class="language-plaintext highlighter-rouge">news_text</code>）：英文政府公告</li>
  <li><strong>韩文文本</strong>（<code class="language-plaintext highlighter-rouge">korean_text</code>）：非英语语言压缩测试</li>
  <li><strong>代码片段</strong>（<code class="language-plaintext highlighter-rouge">code_text</code>）：Python 代码的符号密集文本</li>
  <li><strong>数学公式</strong>（<code class="language-plaintext highlighter-rouge">math_text</code>）：LaTeX 格式的学术文档</li>
  <li><strong>科学文献</strong>（<code class="language-plaintext highlighter-rouge">science_text</code>）：专业术语密集的生物化学文本</li>
  <li><strong>训练集/验证集样本</strong>：从 FineWeb-Edu 采样的真实训练数据</li>
</ul>

<p>评估结果还会与 GPT-2 和 GPT-4 的 Tokenizer 进行对比，输出每种文本类型的 Token 数量、压缩率（字节/Token）和相对差异百分比。</p>

<p><strong>性能指标参考</strong>：</p>

<ul>
  <li>英文文本的 BPB 通常在 0.8-1.2 之间</li>
  <li>压缩率（字节/Token）越高表示 Tokenizer 效率越好</li>
  <li>对于 65,536 词表，$\log_2(65536) = 16$ bits/token 为理论上限</li>
</ul>

<h4 id="226-实际操作流程">2.2.6 实际操作流程</h4>

<p>在 DGX Spark 上，我们可以通过以下脚本完成整个文本处理流程：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="c"># Install Rust / Cargo</span>
curl <span class="nt">--proto</span> <span class="s1">'=https'</span> <span class="nt">--tlsv1</span>.2 <span class="nt">-sSf</span> https://sh.rustup.rs | sh <span class="nt">-s</span> <span class="nt">--</span> <span class="nt">-y</span>
<span class="nb">source</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cargo/env"</span>
<span class="c"># Build the rustbpe Tokenizer</span>
uv run maturin develop <span class="nt">--release</span> <span class="nt">--manifest-path</span> rustbpe/Cargo.toml

<span class="c"># Download and prepare the dataset</span>
python <span class="nt">-m</span> nanochat.dataset <span class="nt">-n</span> 240

<span class="c"># Train the tokenizer</span>
python <span class="nt">-m</span> scripts.tok_train <span class="nt">--max_chars</span><span class="o">=</span>2000000000
python <span class="nt">-m</span> scripts.tok_eval

<span class="c"># Download evaluation bundle</span>
curl <span class="nt">-L</span> <span class="nt">-o</span> eval_bundle.zip https://karpathy-public.s3.us-west-2.amazonaws.com/eval_bundle.zip
unzip <span class="nt">-q</span> eval_bundle.zip
<span class="nb">rm </span>eval_bundle.zip
<span class="nb">mv </span>eval_bundle <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat"</span>
</code></pre></div></div>

<p>这个脚本会自动完成：</p>

<ol>
  <li>✅ 下载 240 个数据文件（其中 8 个用于训练 Tokenizer，这些文本文件还将用于后续的预训练）</li>
  <li>✅ 训练 Tokenizer 并保存到 <code class="language-plaintext highlighter-rouge">~/.cache/nanochat/tokenizer/</code></li>
  <li>✅ 运行测试确保 Tokenizer 工作正常</li>
</ol>

<p><strong>耗时估计</strong>：首次运行约需 20-30 分钟（主要是下载时间）。</p>

<h3 id="23-预训练pre-training">2.3 预训练（Pre-training）</h3>

<p>预训练是构建大语言模型最核心、也是最消耗算力的阶段。在这个阶段，我们的目标是训练一个<strong>基座模型（Base Model）</strong>，让它学会「预测下一个词」（Next Token Prediction）。</p>

<h4 id="231-模型架构model-architecture">2.3.1 模型架构（Model Architecture）</h4>

<p>nanochat 采用了一个现代化的 GPT 架构，融合了许多最新的研究成果以提升训练稳定性和推理效率：</p>

<ul>
  <li><strong>Rotary Embeddings (RoPE)</strong>：取代了传统的绝对位置编码，让模型更好地处理长文本。</li>
  <li><strong>QK Norm</strong>：在 Attention 层对 Query 和 Key 进行归一化，显著提升了训练的稳定性。</li>
  <li><strong>ReLU² 激活函数</strong>：在 MLP 层使用平方 ReLU，相比传统的 GeLU 计算更简单且效果相当。</li>
  <li><strong>无偏置设计 (No Bias)</strong>：移除了所有线性层的偏置项，简化了模型结构。</li>
  <li><strong>RMSNorm</strong>：使用均方根归一化，且不带可学习参数，进一步精简模型。</li>
  <li><strong>Group Query Attention (GQA) 支持</strong>：支持使用更少的 Key/Value 头来减少推理时的内存占用和计算量（默认配置下 GQA 未启用，即 <code class="language-plaintext highlighter-rouge">n_kv_head = n_head</code>）。</li>
  <li><strong>零初始化 (Zero Init)</strong>：对 <code class="language-plaintext highlighter-rouge">lm_head</code> 和每层的 <code class="language-plaintext highlighter-rouge">c_proj</code>（Attention 和 MLP 的输出投影）进行零初始化，有助于训练初期的稳定性。</li>
</ul>

<h4 id="232-优化器创新optimization">2.3.2 优化器创新（Optimization）</h4>

<p>nanochat 引入了一种混合优化策略：</p>

<ul>
  <li><strong>Muon 优化器</strong>：用于优化模型中的 2D 矩阵参数（如 Linear 层的权重）。Muon 是一种基于动量的正交优化器，其核心思想是通过 <strong>Newton-Schulz 迭代</strong> 将梯度正交化后再更新参数，使得权重矩阵保持良好的条件数，从而加速收敛并提升训练稳定性。</li>
  <li><strong>AdamW 优化器</strong>：用于优化 1D 向量参数（如 Embedding 和 Norm 层）。</li>
</ul>

<p>这种组合（Muon + AdamW）旨在在保持收敛速度的同时，最大化训练的吞吐量。</p>

<h4 id="233-训练配置">2.3.3 训练配置</h4>

<p>在 DGX Spark 上，我们使用 <code class="language-plaintext highlighter-rouge">d20</code> 配置进行训练：</p>

<ul>
  <li><strong>参数量</strong>：约 0.56B
    <ul>
      <li><strong>含义</strong>：参数量相当于模型的「脑细胞」数量，决定了其记忆知识和处理复杂逻辑的潜力。0.56B 参数属于轻量级模型，比较适合个人开发者入门学习。</li>
      <li><strong>来源</strong>：该数值是根据 <code class="language-plaintext highlighter-rouge">d20</code> 配置（即 20 层深度）动态推导出来的。具体来说，当深度设为 20 时，模型维度被自动设为 1280 (
<code class="language-plaintext highlighter-rouge">20 * 64</code>)。累加所有权重参数后，总计约为 5.61 亿：
        <ul>
          <li><strong>Embedding 层 (wte)</strong>: $65,536 \times 1,280 \approx 83.9 \text{M}$</li>
          <li><strong>Transformer 层 (20层)</strong>: $20 \times (12 \times 1,280^2) \approx 393.2 \text{M}$
            <ul>
              <li>每层包含 <strong>Attention</strong> 和 <strong>MLP</strong> 两个子模块，参数量为 $12 \times d^2$，其中：</li>
            </ul>

            <table>
              <thead>
                <tr>
                  <th>组件</th>
                  <th>参数维度</th>
                  <th>参数量</th>
                </tr>
              </thead>
              <tbody>
                <tr>
                  <td>c_q (Query)</td>
                  <td>d × d</td>
                  <td>d²</td>
                </tr>
                <tr>
                  <td>c_k (Key)</td>
                  <td>d × d</td>
                  <td>d²</td>
                </tr>
                <tr>
                  <td>c_v (Value)</td>
                  <td>d × d</td>
                  <td>d²</td>
                </tr>
                <tr>
                  <td>c_proj (Attention 输出)</td>
                  <td>d × d</td>
                  <td>d²</td>
                </tr>
                <tr>
                  <td>c_fc (MLP 上投影)</td>
                  <td>d × 4d</td>
                  <td>4d²</td>
                </tr>
                <tr>
                  <td>c_proj (MLP 下投影)</td>
                  <td>4d × d</td>
                  <td>4d²</td>
                </tr>
                <tr>
                  <td><strong>每层总计</strong></td>
                  <td> </td>
                  <td><strong>12d²</strong></td>
                </tr>
              </tbody>
            </table>

            <blockquote>
              <p>注：上述计算假设 GQA 未启用（即 <code class="language-plaintext highlighter-rouge">n_kv_head = n_head</code>）。若启用 GQA，<code class="language-plaintext highlighter-rouge">c_k</code> 和 <code class="language-plaintext highlighter-rouge">c_v</code> 的参数量会按比例减少。</p>
            </blockquote>
          </li>
          <li><strong>LM Head 层</strong>: $1,280 \times 65,536 \approx 83.9 \text{M}$</li>
          <li><strong>总计</strong>: $83.9 + 393.2 + 83.9 \approx 561 \text{M}$</li>
        </ul>
      </li>
    </ul>
  </li>
  <li><strong>层数 (Depth)</strong>：20 层
    <ul>
      <li><strong>含义</strong>：层数代表了神经网络的深度。每一层都是一个 Transformer 模块，数据经过层层加工，提取出从语法到语义的特征。20
层是一个在性能和算力消耗之间取得平衡的经典设计。</li>
    </ul>
  </li>
  <li><strong>上下文长度</strong>：2048 Token
    <ul>
      <li><strong>含义</strong>：上下文长度（Context Length）是模型的「短期记忆」窗口，决定了它一次能处理多少信息。2048 Token 大约相当于 1500 个英文单词或 1000 个汉字。</li>
      <li><strong>来源</strong>：该数值直接来源于训练脚本 <code class="language-plaintext highlighter-rouge">scripts/base_train.py</code> 中的配置 <code class="language-plaintext highlighter-rouge">max_seq_len = 2048</code>。这意味着如果对话或文章超过这个长度，模型就会「忘记」最早的内容。</li>
    </ul>
  </li>
  <li><strong>总 Token 数</strong>：约 112 亿 (遵循 Chinchilla Scaling Laws，Token 数约为参数量的 20 倍)</li>
</ul>

<h4 id="234-启动训练">2.3.4 启动训练</h4>

<p>使用以下脚本启动预训练：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">PYTORCH_ALLOC_CONF</span><span class="o">=</span>max_split_size_mb:512
<span class="nb">export </span><span class="nv">CUDA_LAUNCH_BLOCKING</span><span class="o">=</span>0
<span class="nb">export </span><span class="nv">NPROC_PER_NODE</span><span class="o">=</span>1
<span class="nb">export </span><span class="nv">WANDB_RUN</span><span class="o">=</span><span class="s2">"dummy"</span>

python <span class="nt">-m</span> nanochat.report reset

<span class="c"># pretrain the d20 model</span>
torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.base_train <span class="nt">--</span> <span class="nt">--depth</span><span class="o">=</span>20 <span class="nt">--run</span><span class="o">=</span><span class="nv">$WANDB_RUN</span>
<span class="c"># evaluate the model on a larger chunk of train/val data and draw some samples</span>
torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.base_loss
<span class="c"># evaluate the model on CORE tasks</span>
torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.base_eval
</code></pre></div></div>

<p><strong>训练过程监控</strong>：</p>

<ul>
  <li>脚本会自动处理数据加载、模型初始化和梯度下降循环。</li>
  <li>你可以通过终端输出看到实时的 Loss（损失值）和 MFU（模型算力利用率）。
    <ul>
      <li><strong>MFU（Model FLOPs Utilization）</strong> 是衡量 GPU 计算效率的重要指标，表示实际计算速度占理论峰值的百分比。例如，H100 SXM 的 BF16 理论峰值为 989 TFLOPS。由于 DGX Spark 的内存带宽是瓶颈（273 GB/s vs H100 的 3.35 TB/s），其 MFU 会显著低于数据中心级 GPU。</li>
    </ul>
  </li>
  <li>训练完成后，模型权重将保存在 <code class="language-plaintext highlighter-rouge">~/.cache/nanochat/base_checkpoints/d20/</code> 目录下。</li>
</ul>

<p><strong>耗时预警</strong>：</p>

<p>由于 DGX Spark 只有单卡且显存带宽受限，完整训练这个 5.6 亿参数的模型大约需要 <strong>10 天</strong>。请确保电源连接稳定，并做好散热措施。此外，强烈建议使用 <code class="language-plaintext highlighter-rouge">screen</code> 工具来保持训练任务在后台运行，避免因 SSH 断连或终端关闭导致训练中断。</p>

<h3 id="24-中期训练mid-training">2.4 中期训练（Mid-training）</h3>

<p>中期训练是 nanochat 项目中介于「预训练」和「监督微调」之间的过渡阶段。</p>

<p><strong>为什么需要中期训练？</strong>基座模型虽然学会了说话，但它本质上还是一个「续写机器」，并不懂得对话的格式（比如什么时候该用户说，什么时候该助手说）。中期训练的目标就是让模型适应 <strong>对话格式</strong>和<strong>特定任务</strong>。</p>

<h4 id="241-数据混合策略data-mixture">2.4.1 数据混合策略（Data Mixture）</h4>

<p>nanochat 在这个阶段使用了一个精心设计的混合数据集 <code class="language-plaintext highlighter-rouge">TaskMixture</code>，包含以下几类数据：</p>

<ul>
  <li><strong>通用对话（SmolTalk）</strong>：约 46 万条通用对话数据，让模型熟悉基本的问答模式。</li>
  <li><strong>学科知识（MMLU auxiliary_train）</strong>：约 10 万条多项选择题，来自 <code class="language-plaintext highlighter-rouge">auxiliary_train</code> 子集（包含 ARC、MC_TEST、OBQA、RACE 等数据集），涵盖科学、人文等多个领域，强化模型的知识广度。注意：这并非标准的 MMLU 测试集，而是用于训练的辅助数据集。</li>
  <li><strong>数学与工具（GSM8K）</strong>：约 8000 条数学应用题，教会模型逐步推理（Chain of Thought）和使用计算器工具。nanochat 实现了一个简单的计算器工具调用机制：
    <ul>
      <li>模型生成 <code class="language-plaintext highlighter-rouge">&lt;|python_start|&gt;</code> 标记开始一个计算表达式</li>
      <li>表达式结束后生成 <code class="language-plaintext highlighter-rouge">&lt;|python_end|&gt;</code> 标记</li>
      <li>推理引擎（Engine）拦截这个表达式，调用 Python <code class="language-plaintext highlighter-rouge">eval()</code> 计算结果</li>
      <li>计算结果被<strong>强制注入</strong>到生成序列中，用 <code class="language-plaintext highlighter-rouge">&lt;|output_start|&gt;</code> 和 <code class="language-plaintext highlighter-rouge">&lt;|output_end|&gt;</code> 包裹</li>
      <li>模型继续生成后续内容，利用计算结果完成推理</li>
    </ul>
  </li>
  <li><strong>拼写任务（Spelling）</strong>：
    <ul>
      <li><strong>SimpleSpelling</strong>：20 万条简单拼写练习（如「请拼出单词 ‘apple’」）</li>
      <li><strong>SpellingBee</strong>：8 万条字母计数任务（如「strawberry 中有几个 r？」）</li>
      <li>这两类数据共约 <strong>28 万条</strong>，用于修正 Tokenizer 导致的字符级理解缺陷。</li>
    </ul>
  </li>
  <li><strong>自我认知（Identity）</strong>：约 1000 条合成数据，在训练中加载两次（相当于 2 个 epoch）= <strong>2000 条</strong>，专门用来告诉模型「我是 nanochat，由 Andrej Karpathy 开发」，赋予模型独特的身份设定。</li>
</ul>

<p><strong>总计：约 85 万样本</strong>（460K + 100K + 8K + 200K + 80K + 2K ≈ 850K）</p>

<h4 id="242-训练逻辑">2.4.2 训练逻辑</h4>

<ul>
  <li><strong>加载基座</strong>：从预训练好的基座模型开始。</li>
  <li><strong>格式渲染</strong>：将上述数据渲染成包含 <code class="language-plaintext highlighter-rouge">&lt;|user_start|&gt;</code>、<code class="language-plaintext highlighter-rouge">&lt;|assistant_start|&gt;</code> 等特殊 Token 的对话格式。</li>
  <li><strong>混合采样</strong>：按比例从不同数据集中采样，确保模型在学习新能力的同时不遗忘通用知识。</li>
</ul>

<h4 id="243-启动训练">2.4.3 启动训练</h4>

<p>使用以下脚本启动中期训练：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>

<span class="c"># Download identity conversations file if it doesn't exist</span>
<span class="k">if</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat/identity_conversations.jsonl"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"Downloading identity conversations dataset..."</span>
    <span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat"</span>
    curl <span class="nt">-L</span> <span class="nt">-o</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat/identity_conversations.jsonl"</span> https://karpathy-public.s3.us-west-2.amazonaws.com/identity_conversations.jsonl
<span class="k">fi


</span>torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.mid_train <span class="nt">--</span> <span class="nt">--run</span><span class="o">=</span><span class="nv">$WANDB_RUN</span>
torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.chat_eval <span class="nt">--</span> <span class="nt">-i</span> mid
</code></pre></div></div>

<p><strong>耗时估计</strong>：
相比预训练，中期训练的数据量小得多，在 DGX Spark 上大约需要 <strong>9 小时</strong> 即可完成。</p>

<p>训练完成后，你会得到一个既懂知识，又懂规矩（对话格式）的”格式适配模型”。</p>

<h3 id="25-监督微调sft">2.5 监督微调（SFT）</h3>

<p>监督微调（Supervised Fine-Tuning）是让模型从「续写机器」变成「聊天助手」的关键一步。简单说就是：拿一堆高质量的问答对，手把手教模型怎么好好说话。</p>

<h4 id="251-精选数据集curated-dataset">2.5.1 精选数据集（Curated Dataset）</h4>

<p>与前两个阶段追求数据量不同，SFT 阶段更看重数据的<strong>质量</strong>。nanochat 使用了一个约 2.3 万条样本的精选数据集：</p>

<ul>
  <li><strong>ARC (Easy/Challenge)</strong>：约 3400 条科学问答题，训练模型的逻辑推理能力。</li>
  <li><strong>GSM8K</strong>：约 8000 条数学题，继续强化数学解题能力。</li>
  <li><strong>SmolTalk (精选)</strong>：约 10000 条高质量对话，覆盖日常闲聊、建议咨询等场景。</li>
  <li><strong>Identity</strong>：约 1000 条身份认知数据，反复强化「我是 nanochat」的自我意识。</li>
  <li><strong>Spelling</strong>：共 600 条拼写任务数据（SimpleSpelling 300 条 + SpellingBee 300 条），保持对字符细节的敏感度。</li>
</ul>

<h4 id="252-训练策略">2.5.2 训练策略</h4>

<ul>
  <li><strong>Masked Loss</strong>：在计算损失时，只计算模型<strong>回复部分</strong>的 Loss，而忽略用户提问部分的 Loss。这意味着模型只学习「如何回答」，而不学习「如何提问」。</li>
  <li><strong>小批量、多轮次</strong>：由于数据量较小，我们通常会训练多个 Epoch（轮次），让模型充分吸收这些高质量指令。</li>
  <li><strong>低学习率</strong>：相比预训练，SFT 阶段的学习率设置为预训练的 <strong>2%</strong>（<code class="language-plaintext highlighter-rouge">init_lr_frac=0.02</code>），以避免破坏模型已有的通用知识。值得注意的是，Mid-training 阶段使用的是 <strong>100%</strong> 学习率（<code class="language-plaintext highlighter-rouge">init_lr_frac=1.0</code>），因为它更接近预训练的延续。</li>
</ul>

<h4 id="253-启动训练">2.5.3 启动训练</h4>

<p>使用以下脚本启动 SFT：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.chat_sft <span class="nt">--</span> <span class="nt">--run</span><span class="o">=</span><span class="nv">$WANDB_RUN</span>
torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.chat_eval <span class="nt">--</span> <span class="nt">-i</span> sft
</code></pre></div></div>

<p><strong>耗时估计</strong>：
这是最快的一个阶段，在 DGX Spark 上仅需约 <strong>1 小时</strong>。</p>

<p>训练完成后，你的模型就已经具备了 ChatGPT 的雏形，可以流畅地与你进行多轮对话了！</p>

<h3 id="26-强化学习rl">2.6 强化学习（RL）</h3>

<p>强化学习（Reinforcement Learning）是让模型从「合格」迈向「优秀」的关键一步。在 nanochat 中，我们使用 RL 专门来提升模型的<strong>数学解题能力</strong>。</p>

<h4 id="261-算法选择简化版-reinforce-with-group-baseline">2.6.1 算法选择：简化版 REINFORCE with Group Baseline</h4>

<p>nanochat 的 RL 实现最初受到 GRPO（Group Relative Policy Optimization）的启发，但经过一系列简化后，最终变成了一种更接近 <strong>REINFORCE with Group Baseline</strong> 的算法。正如代码注释中所述：</p>

<blockquote>
  <p>“I put GRPO in quotes because we actually end up with something a lot simpler and more similar to just REINFORCE.”</p>
</blockquote>

<p>相比传统的 PPO (Proximal Policy Optimization)，这种简化版算法更加高效且易于实现：</p>

<ul>
  <li><strong>无需 Critic 模型</strong>：传统 PPO 需要训练一个额外的 Critic 模型来评估状态价值，而 GRPO 直接使用一组采样结果的平均值作为基准（Baseline）。</li>
  <li><strong>组内相对优势</strong>：对于同一个问题，模型生成 $N$ 个不同的回答。如果某个回答的得分高于这组回答的平均分，就鼓励模型多生成这样的回答；反之则抑制。</li>
  <li><strong>去繁就简</strong>：nanochat 对 GRPO 进行了进一步简化：
    <ul>
      <li><strong>删除信任域约束</strong>：没有 KL 散度正则化来约束与参考模型的距离。</li>
      <li><strong>无需 PPO 裁剪</strong>：因为是 On-Policy 训练（每次用最新模型采样），不需要 PPO 的 ratio+clip 机制。</li>
      <li><strong>Token 级归一化</strong>：采用 GAPO 风格的 Token 级归一化，而非传统的序列级归一化。</li>
      <li><strong>简化优势函数</strong>：使用 $(r - \mu)$ 作为优势值，而非标准的 z-score 归一化 $(r - \mu) / \sigma$。</li>
    </ul>

    <p>最终实现更接近于带有组内基准的 <strong>REINFORCE</strong> 算法，但保留了 GRPO 的核心思想——<strong>通过对比来学习</strong>。</p>
  </li>
</ul>

<h4 id="262-训练流程">2.6.2 训练流程</h4>

<ol>
  <li><strong>采样（Rollout）</strong>：对于 GSM8K 中的每一道数学题，让模型生成 16 个不同的解题步骤（Temperature=1.0）。</li>
  <li><strong>打分（Reward）</strong>：检查这 16 个答案是否正确。答对得 1 分，答错得 0 分。</li>
  <li><strong>优势计算（Advantage）</strong>：计算这 16 个得分的平均值 $\mu$。每个样本的优势 $A = r - \mu$。
    <ul>
      <li>例如：如果平均分是 0.2（大部分都错了），而某个样本答对了（得 1 分），那么它的优势就是 $1 - 0.2 = 0.8$（大大的正向激励）。</li>
    </ul>
  </li>
  <li><strong>策略更新（Update）</strong>：根据优势值调整模型参数，增加高分回答出现的概率。</li>
</ol>

<h4 id="263-启动训练">2.6.3 启动训练</h4>

<p>使用以下脚本启动 RL 训练：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># run reinforcement learning</span>
torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.chat_rl <span class="nt">--</span> <span class="nt">--run</span><span class="o">=</span><span class="nv">$WANDB_RUN</span>
<span class="c"># eval the RL model only on GSM8K</span>
torchrun <span class="nt">--standalone</span> <span class="nt">--nproc_per_node</span><span class="o">=</span><span class="nv">$NPROC_PER_NODE</span> <span class="nt">-m</span> scripts.chat_eval <span class="nt">--</span> <span class="nt">-i</span> rl <span class="nt">-a</span> GSM8K

<span class="c"># Generate the full report by putting together all the sections</span>
<span class="c"># report.md is the output and will be copied to current directory for convenience</span>
python <span class="nt">-m</span> nanochat.report generate
</code></pre></div></div>

<p><strong>耗时估计</strong>：
在 DGX Spark 上，这个过程大约需要 <strong>6 小时</strong>。</p>

<p>经过这一步，你会发现模型在做数学题时，逻辑变得更加严密，解题正确率也会有显著提升。这是因为模型不仅学会了「怎么做」（SFT），还学会了「怎么做得更好」（RL）。</p>

<h3 id="27-web-聊天">2.7 Web 聊天</h3>

<p>跑了十天，总算能聊两句了。</p>

<p>nanochat 自带一个 Web 界面，启动之后直接在浏览器里就能对话。</p>

<h4 id="271-启动-web-服务">2.7.1 启动 Web 服务</h4>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">MODEL_SOURCE</span><span class="o">=</span><span class="s2">""</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat/chatrl_checkpoints"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">MODEL_SOURCE</span><span class="o">=</span><span class="s2">"rl"</span>
    <span class="nb">echo</span> <span class="s2">"Found RL (Reinforcement Learning) checkpoints - using most advanced model"</span>
<span class="k">elif</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat/chatsft_checkpoints"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">MODEL_SOURCE</span><span class="o">=</span><span class="s2">"sft"</span>
    <span class="nb">echo</span> <span class="s2">"Found SFT (Supervised Fine-tuning) checkpoints"</span>
<span class="k">elif</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat/mid_checkpoints"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">MODEL_SOURCE</span><span class="o">=</span><span class="s2">"mid"</span>
    <span class="nb">echo</span> <span class="s2">"Found midtraining checkpoints"</span>
<span class="k">elif</span> <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/.cache/nanochat/base_checkpoints"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">MODEL_SOURCE</span><span class="o">=</span><span class="s2">"base"</span>
    <span class="nb">echo</span> <span class="s2">"Found base pretraining checkpoints"</span>
<span class="k">else
    </span><span class="nb">echo</span> <span class="s2">"Error: No trained models found."</span>
    <span class="nb">exit </span>1
<span class="k">fi</span>

<span class="c"># Launch the chat web interface with the appropriate model source</span>
python <span class="nt">-m</span> scripts.chat_web <span class="nt">--source</span> <span class="s2">"</span><span class="nv">$MODEL_SOURCE</span><span class="s2">"</span>
</code></pre></div></div>

<p>启动成功后，终端会显示访问地址（<code class="language-plaintext highlighter-rouge">http://127.0.0.1:8000</code>）。在浏览器中打开这个地址，你就可以看到聊天界面了。</p>

<p><img src="/assets/images/nanochat-training/chat-web-server.png" alt="chat-web-server" /></p>

<p>上图展示了 Web 服务器的启动日志：加载 d20 模型配置（20 层、1280 维、10 头注意力），从 RL 训练检查点加载权重，服务运行在 <code class="language-plaintext highlighter-rouge">http://0.0.0.0:8000</code>。</p>

<p><img src="/assets/images/nanochat-training/web-chat.png" alt="web-chat" /></p>

<p>经过十天的漫长训练，我们终于得到了一个「有趣」的 AI 助手——它相信 1+1=1，认为李白是 AI 科学家，还成功让法国从世界地图上消失了。虽然在数学、历史、地理等方面存在明显的幻觉问题，但至少它已经具备了基本的对话能力和身份认知，并且对 Andrej Karpathy 国王保持着足够的尊重。</p>

<blockquote>
  <p><strong>💡 为什么会出现这些「幻觉」？</strong> 这些错误主要源于两个因素：（1）<strong>参数量过小</strong>——0.56B 参数的模型容量有限，难以准确记忆大量事实知识；（2）<strong>训练数据分布偏差</strong>——FineWeb-Edu 数据集以英文教育内容为主，对中文历史、地理等知识覆盖不足。根据 Scaling Laws，更大的模型（如 d32 配置的 1.9B 参数）通常能显著减少这类问题。</p>
</blockquote>

<h3 id="28-分享模型">2.8 分享模型</h3>

<p>最后，可以通过 huggingface_hub 库上传自己的模型并与社区共享。huggingface_hub 库的使用方法这里就不再赘述。</p>

<h2 id="三总结">三：总结</h2>

<p>前后大约折腾了两周时间，总算把整个流程跑通了：预训练（~10 天）+ 中期训练（~9 小时）+ SFT（~1 小时）+ RL（~6 小时）。果然预训练才是大头，剩下的加起来还不到一天。</p>

<p>这次用的是 <code class="language-plaintext highlighter-rouge">d20</code> 配置（5.6 亿参数），主要是怕 d32 跑太久。后续有计划继续尝试：</p>

<ul>
  <li>
    <p><strong>更深的模型架构（d32）</strong>：nanochat 1000 美元的训练方案推荐使用 <code class="language-plaintext highlighter-rouge">--depth=32</code> 配置，可训练约 <strong>19 亿参数</strong>的模型。根据 Scaling Laws，更大的模型通常能带来更强的推理能力和更少的幻觉问题。在 8×H100 上，d32 配置的训练时间约为 <strong>33 小时</strong>。在单台 DGX Spark 上训练 d32 模型预计需要约 <strong>80 天</strong>（约为 d20 的 8 倍），而双 Spark 互联配置可将时间缩短至约 <strong>40 天</strong>。</p>
  </li>
  <li>
    <p><strong>低精度训练（MXFP4/NVFP4）</strong>：DGX Spark 搭载的 Blackwell 架构原生支持 <strong>MXFP4</strong>（Microscaling Format）和 <strong>NVFP4</strong> 等前沿低精度数据类型。这些格式能够在几乎不损失模型性能的前提下，将显存占用降低至 FP16 的 <strong>1/4</strong>，并显著提升计算吞吐量。参考 OpenAI 开源的 gpt-oss 项目，MXFP4 量化使得 1170 亿参数的模型能在单张 80GB GPU 上运行推理。未来相关生态对 MXFP4 的支持会更加成熟，可以尝试在 DGX Spark 上进行 MXFP4 精度的训练，进一步释放 Blackwell 架构的潜力。</p>
  </li>
  <li>
    <p><strong>更长的上下文窗口</strong>：当前模型的上下文长度为 2048 Token，对于长文档理解和多轮复杂对话存在限制。结合 RoPE 位置编码的外推能力，未来可以尝试扩展至 4096 甚至 8192 Token 的上下文长度。</p>
  </li>
</ul>]]></content><author><name>jxlwqq</name><email>jxlwqq@gmail.com</email></author><category term="ai" /><category term="llm-training" /><category term="dgx-spark" /><category term="nanochat" /><category term="gpt" /><summary type="html"><![CDATA[]]></summary></entry></feed>