2026-06-22 00:00:00
<blockquote><p>"Same person. Different era. The difference is the tooling."<br>人未变,时代已改。拉开差距的,全在工具。</p><p>——Garry Tan, Y Combinator 总裁 & CEO, 2026 年</p></blockquote><p>卷首语用五个人的故事画出了一幅图景:Karpathy 半年没写代码,Amodei 预言 90% 代码将由 AI 完成,Garry Tan 的产出翻了 810 倍,Boris Cherny 不再写代码只审查代码,antirez 放下了亲手雕琢每一行的执念。这些信号指向同一个结论——软件工程正在经历自 1968 年这门学科诞生以来最深刻的一次范式转换。</p><p>本章建立理解这场变革所需的概念坐标。它是怎么一步步走到今天的?新旧范式之间真正的断裂在哪里?全书贯穿的那根主线——"用结构化知识驾驭非结构化 AI 能力"——是怎么来的?</p><span id="more"></span><h2 id="1-1-软件工程简史-一个假设的五十年"><a href="#1-1-软件工程简史-一个假设的五十年" class="headerlink" title="1.1 软件工程简史: 一个假设的五十年"></a>1.1 软件工程简史: 一个假设的五十年</h2><p>1968 年 NATO 软件工程会议上,"软件危机"被正式命名——项目失败、成本超支、交付延期——催生了"软件工程"这门学科。解决方案是用工程化流程约束创造力:阶段分解、评审关口、文档驱动。</p><p>此后半个多世纪,方法论不断演化。一个核心假设从未被挑战。</p><p><strong>瀑布模型</strong>(Winston Royce, 1970)将开发划分为需求分析、设计、实现、测试、维护的严格线性阶段。Royce 本人的论文远比后来的教条版本微妙——他实际上提倡迭代("do it twice")——但瀑布作为一种管理模式被广泛采纳。它的核心前提是:需求可以在编码之前被完整地理解。</p><p><strong>敏捷运动</strong>(Agile Manifesto, 2001)拆掉了瀑布的刚性阶段门。"个体和互动高于流程和工具,可工作的软件高于详尽的文档。"敏捷承认了需求会变,但保留了最底层的假设——<strong>写代码的人依然是写代码的人</strong>。所有的 Scrum 站会、Sprint 计划、用户故事,都建立在一个未经审视的前提之上:软件是由人类工程师一行一行写出来的。</p><p><strong>DevOps</strong> 打破的是开发与运维之间的墙。CI/CD、基础设施即代码、容器化——这些运动将运维带入了工程视野。但它们同样没有触及那个核心信条。DevOps 优化的是"写完之后怎么办",而不是"谁来写"。</p><p>最后这个假设——<strong>代码由人亲自编写</strong>——在 2023 年第一次动摇了。不是因为新的管理方法或流程改进,而是因为大语言模型。它能理解自然语言指令,自主探索代码库,执行完整的功能开发。它不是更聪明的自动补全。它在转移编程行为的主体。</p><img src="/2026/06/22/software-engineering-fifty-years-paradigm-shift/image-20260523093209852.png" class=""><h2 id="1-2-AI-Coding-Agent-的四层演进"><a href="#1-2-AI-Coding-Agent-的四层演进" class="headerlink" title="1.2 AI Coding Agent 的四层演进"></a>1.2 AI Coding Agent 的四层演进</h2><p>如果将 AI Agent 的能力演进放到时间轴上,可以看到四个清晰的阶段。每个阶段都在重新定义"编程"这个行为的含义——不只是效率的提升,而是<strong>人类和机器在编程活动中各自的角色</strong>发生了质变。</p><table><thead><tr><th>层次</th><th>时间</th><th>交互范式</th><th>人类角色</th><th>AI 角色</th><th>代表作</th></tr></thead><tbody><tr><td>L1: 补全</td><td>2022-</td><td>人写代码,AI 预测下一行</td><td>作者</td><td>自动补全</td><td>GitHub Copilot</td></tr><tr><td>L2: 对话</td><td>2023-</td><td>人问问题,AI 回答</td><td>学习者</td><td>知识库</td><td>ChatGPT, Claude</td></tr><tr><td>L3: 任务</td><td>2025-</td><td>人下指令,AI 完成功能</td><td>指挥者</td><td>实现者</td><td>Claude Code, Cursor</td></tr><tr><td>L4: 自主流程</td><td>2026-</td><td>人定义目标,AI 管理全流程</td><td>审查者</td><td>工程经理</td><td>autoresearch, Ralph Loop</td></tr><tr><td><strong>L1 补全</strong>:GitHub Copilot 在 2022 年 6 月正式发布,程序员第一次体验到了"AI 帮你写下一行"。但它本质上是智能化的自动补全——AI 预测,人类决策。</td><td></td><td></td><td></td><td></td><td></td></tr></tbody></table><p><strong>L2 对话</strong>:ChatGPT 改变了交互范式。程序员学会了"提问"——替代了查文档、搜 Stack Overflow。但代码仍然是程序员亲手写的。AI 是搜索引擎的替代品,不是代码的作者。</p><p><strong>L3 任务</strong>:Claude Code 于 2025 年 2 月发布,同时 Cursor、Codex CLI、OpenCode 等工具将体验升级为"AI 帮你实现一个功能"。Agent 开始具备自主理解代码库、定位修改位置、执行多文件变更的能力。人类从"写代码的人"向"分配任务的人"滑动。</p><p><strong>L4 自主流程</strong>:进入 2026 年,Agent 不再只是执行任务,而是开始<strong>管理流程</strong>——从理解需求到设计方案、实现代码、编写测试、创建 PR,一次会话走完整条链路。这正是卷首语中 Boris Cherny 描述的工作状态:他不再写代码,只审查代码。</p><p>从 L1 到 L4,变的不是 AI 能力的量,而是<strong>编程行为中主体的位置</strong>。L1 中人类是唯一主体。L4 中 AI 变成主动参与者,人类变成监督者和质量守门人。</p><img src="/2026/06/22/software-engineering-fifty-years-paradigm-shift/image-20260523093742002.png" class=""><h2 id="1-3-Vibe-Coding-解放还是陷阱?"><a href="#1-3-Vibe-Coding-解放还是陷阱?" class="headerlink" title="1.3 Vibe Coding: 解放还是陷阱?"></a>1.3 Vibe Coding: 解放还是陷阱?</h2><p>AI 让编程门槛降到了历史最低点。任何人——不管会不会写代码——都可以用一句话让 AI 生成一个能跑的应用。Andrej Karpathy 给这种现象取了一个生动的名字:<strong>Vibe Coding(氛围编程)</strong>。</p><p>这是解放,毫无疑问。但它也是一个精心包装的陷阱。</p><p>Vibe Coding 的典型模式:对 AI 说"给我做一个 todo app"→ AI 十几秒生成一整套代码 → "加一个暗黑模式"→ "加一个拖拽排序"。你甚至不需要知道 useState 是什么。你只要"vibe"就可以了。</p><p>陷阱在于:<strong>它拉高下限的同时,模糊了上限的存在。</strong> 当你不需要理解自己的代码如何工作,当你的应用在"看起来能运行"和"真正能在生产环境运行"之间存在巨大的鸿沟——Vibe Coding 的产物往往是一团不可测试、不可重构、不可理解的代码浆糊。</p><p>这就是 Karpathy 为什么特意区分了两个概念:<strong>Vibe Coding 与 Agentic Engineering(智能体工程)</strong>。前者的关键词是"放手"——把需求丢给 AI,接受它吐出的一切。后者的关键词是"掌控"——把 AI 视为极其强大但带有随机性和盲区的工具,通过结构化方法引导、约束、验证它的产出。</p><p>作为卷首语中详细展开过的论证,这里不再重复,但需要强调它的推论:<strong>AI 不会救你,它会放大你。</strong> 你给它清晰的架构,它还你整洁的代码;你给它模糊的意图,它还你一团浆糊。一个人用 AI 后的产出上限,是由他在没有 AI 时的工程素养决定的。Redis 创造者 antirez 用一周时间完成 DS4 项目——但他审查了 AI 生成的每一行代码。</p><p>这就是为什么,在 AI 让编码门槛降到历史最低点的时刻,工程化的价值反而达到了历史最高点。</p><h2 id="1-4-核心主张-从-Prompt-Driven-到-Skill-Driven"><a href="#1-4-核心主张-从-Prompt-Driven-到-Skill-Driven" class="headerlink" title="1.4 核心主张: 从 Prompt-Driven 到 Skill-Driven"></a>1.4 核心主张: 从 Prompt-Driven 到 Skill-Driven</h2><p>这就引出了全书最核心的概念转折。</p><p>过去两年间,一个洞见在不同的人和实践中反复出现、逐步收敛。它不是某个人独自发明的,而是社区在实践中达成的共识:<strong>Prompt 是临时的,Skill 是持久的。</strong></p><p>当你对 AI 说"帮我做代码审查",你能得到什么?一个审查结果。它的质量取决于你当时的表达、AI 当时的理解、对话上下文的完整度。改天再做一次,结果可能完全不同。Prompt 消失在对话历史里——不可复用、不可迭代、不可积累。</p><p>而一个 Skill——比如 /review-it——是一个结构化指令文件,定义了什么算好的审查、审查哪些维度、遇到每种问题怎么处理。它可以在不同项目、不同时间、对不同的代码重复使用。每次使用都是一次验证机会,如果出现偏差,你可以改进 Skill 本身,让下一次更好。Skill 留在你的工具链里——可复用、可迭代、可积累。</p><p>"对话一次"和"建立一个系统"之间的区别,就是一位 AI 时代的合格工程师和一位"vibe coder"之间的区别。</p><p>也正是在这个意义上,四个层次构成了一个递进:<strong>Prompt Engineering</strong> 让 AI 理解意图(一次性对话)→ <strong>Skill Engineering</strong> 让能力可复用(持久化方法论)→ <strong>Agent Orchestration</strong> 让多个 Agent 协同(编排调度)→ <strong>Harness Engineering</strong> 让整个系统安全可控(运行环境与权限)。每一层建立在前一层基础上。Skill Engineering 处于"从临时到持久"的转折点上——它不是让 AI 变得更聪明,而是让使用 AI 的人变得更体系化。</p><p>这引出了全书的核心理念:</p><p><strong>在 AI 时代,软件工程的核心竞争力不再是"你能写多快的代码",而是"你能不能构建一套让 AI 高质量产出的方法和系统。"</strong></p><p>这套系统应具备五个特征:<strong>可复用、可验证、可迭代、可组合、不依赖特定模型。</strong></p><p>围绕这些原则,一个全新的方法论生态已经生长出来。以下方法论将在本书第一部分逐一展开:</p><table><thead><tr><th>方法论</th><th>核心思想</th><th>关键贡献者</th><th>详章</th></tr></thead><tbody><tr><td>Matt Skills 系统</td><td>将工程经验沉淀为可复用的能力单元</td><td>Matt Pocock</td><td>第2章</td></tr><tr><td>Spec-Driven Development</td><td>规格文档作为人类与 AI 之间的"合约"</td><td>OpenSpec / GitHub Spec-Kit</td><td>第3章</td></tr><tr><td>Ralph Loop</td><td>AI 在循环中自我改进,直到满足验收标准</td><td>Frank Bria</td><td>第4章</td></tr><tr><td>gstack</td><td>23 个专家角色构建虚拟工程团队</td><td>Garry Tan</td><td>第5章</td></tr><tr><td>superpowers</td><td>159K+ Stars 的 AI 编程 Skills 方法论库</td><td>obra / jnMetaCode</td><td>第6章</td></tr><tr><td>autoresearch</td><td>多 Agent 轮转交叉审核,端到端全自动闭环</td><td>smallnest</td><td>第7章</td></tr><tr><td>Goal Workflow</td><td>目标驱动的四步研发闭环</td><td>smallnest</td><td>第8章</td></tr><tr><td>这些方法论看似各不相同,但它们共享同一个底层逻辑——也是贯穿全书的那根线:<strong>用结构化知识驾驭非结构化 AI 能力。</strong></td><td></td><td></td><td></td></tr></tbody></table><h2 id="1-5-本章小结"><a href="#1-5-本章小结" class="headerlink" title="1.5 本章小结"></a>1.5 本章小结</h2><p>我们正站在软件工程史上一个罕见的转折点上。上一次这种量级的改变,要追溯到 1968 年——当这门学科本身被命名时。</p><p>这次转折有三个核心特征:</p><p>第一,<strong>编程行为的主体正在转移</strong>。五十年来的第一次,不是人类在敲键盘。人类从"作者"变成了"指挥者",再变成"审查者"。</p><p>第二,<strong>速度和质量之间出现了新的张力</strong>。AI 让写代码极快,但验证代码的速度远远跟不上。Vibe Coding 是解放,也是陷阱。工程化不是减速带,而是让你高速行驶时不翻车的底盘。</p><p>第三,<strong>方法论的价值超过了工具本身</strong>。工具每天都在变。但"用结构化知识驾驭非结构化 AI 能力"的原则不会变。Skills 系统、Spec-Driven Development、闭环工作流——这些不是某个具体工具的说明书,而是在任何 AI 工具上都能成立的工程原则。</p><p>从下一章开始,我们将逐一展开这些方法论。但在进入细节之前,请记住本章最核心的那个判断:</p><p><strong>AI 不会救你,它会放大你。在 AI 时代,你的工程素养不是变得不重要了,而是第一次变得如此重要。</strong></p>
2026-06-21 21:20:27
<blockquote><p>"I don't think I've typed like a line of code probably since December, basically, which is an extremely large change."<br>从去年十二月起,我基本上一行代码都没写过,这是一个巨大的变化。</p><p>——Andrej Karpathy,No Priors 播客,2026 年 3 月</p></blockquote><span id="more"></span><h2 id="半年没写一行代码"><a href="#半年没写一行代码" class="headerlink" title="半年没写一行代码"></a>半年没写一行代码</h2><p>2026 年春天的一个午后,Andrej Karpathy 坐在播客录制间里,用他标志性的、几乎不带任何修辞起伏的语调,说出了一句让整个软件行业安静下来的话。</p><p>他已经半年没有亲手写过一行代码了。</p><p>不是两周,不是一个月。是从去年十二月开始,一天都没有。</p><p>在任何一个其他时代,这句话如果出自一位顶尖程序员之口,只意味着一件事:他离开了这个行业。但 Andrej Karpathy——OpenAI 联合创始人、前特斯拉 AI 总监、计算机视觉领域最具影响力的研究者之一——并没有离开。恰恰相反,他正处在职业生涯中产出最高的时期。他以自然语言驱动 AI Agent,完成从创业项目到开源探索的全部开发工作——一人之力推动着过去需要一个完整团队才能完成的迭代节奏。英语成了他新的编程语言,而 AI 成了他的编译器。他只是不再亲手写代码了。</p><p>这是一个关于杠杆的故事。</p><img src="/2026/06/21/karpathy-half-year-no-code-prologue/image-20260523081343679.png" class=""><p>2026 年 5 月 19 日——Karpathy 宣布了一个消息:<strong>他加入了 Anthropic。</strong> "我认为未来几年在 LLM 的前沿将会特别具有塑造性,"他写道,"我非常兴奋能加入这个团队,重新回到研发工作中。我仍然对教育充满热情,并计划在适当的时候继续我的相关工作。"这个消息之所以意味深长,不在于一个人换了东家——而在于这位"半年没写一行代码"的工程师选择加入的公司,正是 Claude Code 的缔造者,而他返回的还是软件开发的前线。从 OpenAI 到特斯拉到独立探索,再到 Anthropic,他的轨迹恰好画出了 AI 软件工程从实验室到产品、从工具到基础设施的完整弧线。</p><p>在那期播客以及随后的多次深度访谈中,Karpathy 展开了一幅比他那句名言本身更为深邃的思想图景。他将软件工程的历史划分为三个时代——<strong>软件 1.0、软件 2.0、软件 3.0</strong>——并以此解释了他所看到的这场变革的本质。</p><p>软件 1.0 是人类编写明确的代码规则。你告诉机器每一步怎么做,机器照做。这是过去半个多世纪的编程范式。软件 2.0 是人类通过创建数据集来训练神经网络,让模型从数据中学到规则。这是过去十年的深度学习范式。而软件 3.0,Karpathy 说,编程变成了**提示(Prompting)**本身——上下文窗口成了控制这个新型计算设备(LLM)的杠杆。他说,LLM 已经不再是传统意义上的"程序",而是一种全新的计算机:你输入一段文本,它输出一段文本,但这中间执行的是人类无法逐行追踪的、基于大规模统计模拟和强化学习的涌现计算。他称之为 <strong>"召唤幽灵"</strong>——我们构建的不是具有动物般内在动力的智能体,而是基于统计模式的模拟产物。它们能在瞬间重构十万行代码或发现零日漏洞,却会在常识问题上给出荒谬的建议。它们的智能是"参差不齐的"(Jagged Intelligence):在可验证的领域(编程、数学)突飞猛进,因为强化学习能给明确的验证奖励;而在不可验证的领域,它们依然脆弱。</p><p>正是基于这种认识,Karpathy 提出了一个他亲自命名的概念区分:<strong>"氛围编程"(Vibe Coding)与"智能体工程"(Agentic Engineering)。</strong> Vibe Coding 的意义在于「拉高下限」——它让任何人,无论是否具备专业背景,都能让 AI 生成一个能跑的应用。这是一种民主化,也是一种诱惑。但 Agentic Engineering 的核心是「守住上限」——它是一门新的工程学科,要解决的问题是如何协调那些强大但带有随机性、容易出错的 AI 智能体,在不引入漏洞、不牺牲质量的前提下大幅提升开发速度。</p><p>Karpathy 的措辞很克制,但判断很锋利:<strong>掌握 Agentic Engineering 的工程师带来的效率提升,将远远超越过去所谓的"10 倍工程师"。</strong></p><p>这意味着人类的角色将发生根本性的重塑。开发者不再需要死记硬背 PyTorch 的张量维度或 NumPy 的 API 细节——这些都可以放权给拥有"完美记忆力"的 AI 智能体。但放手细节的同时,人类必须提升另一个维度的能力:品味、判断力、架构直觉、系统审美。人类与智能体共同制定详细的规格说明,然后智能体来填充底层实现。Karpathy 用了一个工业时代的比喻来总结:<strong>人类不再是打字员,而是工头。</strong></p><p>但他说出的最重要的一句话,也许是这句旁人转述给他的格言:"<strong>你可以外包你的思考,但不能外包你的理解力。</strong>"机器可以生成代码、总结文档、分析数据,但人类始终是那个决定"为什么要建这个系统"和"如何指导智能体"的人。利用 AI 工具来增强自身的理解力,而不是用 AI 来替代自身的思考——这才是 Agentic Engineering 的终极壁垒。</p><!--more--><h2 id="从软件危机到智能体崛起"><a href="#从软件危机到智能体崛起" class="headerlink" title="从软件危机到智能体崛起"></a>从软件危机到智能体崛起</h2><p>过去两年间,软件工程领域发生的变化,比过去二十年加起来还要剧烈。这不是修辞。这是一场从「工具辅助人类」到「人类指导工具」的根本性反转——程度的加深叠加方向的逆转。</p><p>时间线拉长一些,才能看清这件事的历史分量。</p><p>1968 年,北大西洋公约组织在德国加米施召开了后来被载入史册的 NATO 软件工程会议。在那次会议上,"软件危机"(Software Crisis)作为一个正式术语被提出——软件项目的失败率居高不下,成本超支成为常态,交付日期一再推迟。那次会议催生了"软件工程"这个学科本身。当时的解决方案是用工程化的流程约束创造力:瀑布模型、需求规格、阶段评审、文档驱动。</p><p>半个多世纪以来,这个基本框架没有变过。敏捷运动拆掉了瀑布的刚性阶段门,但保留了"人写代码、流程管质量"的核心假设。DevOps 打破了开发与运维的墙,但写代码的人依然是写代码的人。<br> <img src="/2026/06/21/karpathy-half-year-no-code-prologue/image-20260523075424864.png" class=""><br>直到 AI 编码 Agent 的出现。</p><p>2022 年,GitHub Copilot 让程序员第一次体验到了"AI 帮你写下一行"的感觉——它是一个聪明的自动补全工具。2025 年,Claude Code、Codex、Cursor、OpenCode 等一系列工具将这种体验升级为"AI 帮你写一个函数"。到了 进入 2026 年,这些工具已经进化为能够自主理解整个代码库、管理完整开发流程、甚至学习私有 API 和内部框架的工程 Agent。</p><p>变化的斜率不是线性的。它在加速。</p><h2 id="先行者们看到了同一件事"><a href="#先行者们看到了同一件事" class="headerlink" title="先行者们看到了同一件事"></a>先行者们看到了同一件事</h2><p>Andrej Karpathy 不是唯一一个感受到这场震荡的人。如果你仔细聆听,你会发现来自不同背景、不同时代、不同编程哲学的声音正在汇成同一个和弦。</p><p>2025 年,Anthropic 的 CEO Dario Amodei 在一次公开访谈中给出了一个让很多人不以为然的预测:AI 将在三到六个月内编写 90% 的代码,十二个月内编写几乎全部代码。批评者说这是营销。投资者说这是讲故事。但到了 2026 年,这个预测正在被一个又一个的数据点验证。Claude Code 的用户不仅仅是"使用 AI 辅助编码"——他们在与一个能够自主探索代码库、提出架构方案、执行完整功能开发的 Agent 协作。人类工程师的角色正在从"写代码的人"转变为"定义目标、审查产出、做架构决策的人"。</p><img src="/2026/06/21/karpathy-half-year-no-code-prologue/image-20260523081614821.png" class=""><p>Y Combinator 总裁兼 CEO Garry Tan 用一个对比数字让整个硅谷沉默了。他公开了自己作为同一个工程师、同样高强度工作状态下,2013 年和 2026 年的 GitHub 贡献数据:2026 年的逻辑代码行产出是 2013 年的 <strong>八百一十倍</strong>。这不是百分比增长,这是数量级的跃迁。他在全职运营 Y Combinator 的同时,用自己开发的 gstack 方法论,在六十天内交付了三个生产级服务和四十多个功能。他自己这样说:"Same person. Different era. The difference is the tooling."同样的人,不同的时代。差别只在于工具。</p><img src="/2026/06/21/karpathy-half-year-no-code-prologue/image-20260523081109306.png" class=""><p>Garry Tan 的数字让人震撼。2026 年 5 月的硅谷 AI Ascent 大会上,Claude Code 的缔造者 Boris Cherny 给出的画面则让人恍惚。Cherny——Anthropic 的工程负责人(Engineering Lead)、Claude Code 的创造者——走上台,平静地描述了他现在的日常工作:<strong>他不再写代码。他审查代码。</strong> 他曾经同时运行大约一千个 AI Agent,并在一天之内合并了一百五十个拉取请求。今天的开发者,Cherny 说,本质上是一支临时工模型大军的"工程经理"。</p><img src="/2026/06/21/karpathy-half-year-no-code-prologue/image-20260523081945708.png" class=""><p>但 Cherny 也没有回避最棘手的问题:<strong>AI Agent 编写补丁的速度,已经远远超过了人类组织验证它们的能力。</strong> 这就是"验证差距"(Verification Gap)。模型经常在工作真正完成之前表现得"非常自信"——你既不能完全不信任 Agent,那样你会失去速度;也不能完全信任 Agent,那样你会失去质量。他给出的答案简洁得近乎冷酷:<strong>委派任务前的判断力,给予信任前要求证据的能力,合并代码后的责任感。</strong> 敲击键盘的速度和记忆 API 的数量不再重要。判断力、验证力、责任感才是新的硬通货。这三项能力构成了 AI 时代工程师的新技能栈。</p><p>但也许最令人动容的转变来自 Redis 的创造者 Salvatore Sanfilippo——社区里人们叫他 antirez。在程序员群体中,antirez 代表了一种几乎已经消失的浪漫:他相信每一行代码都应该经过人手的雕琢。他曾经写道:"I love writing software, line by line. My career was a continuous effort to create software well written, minimal, where the human touch was the fundamental feature."他热爱一行一行地写代码。他的整个职业生涯都在追求一种极简的、充满人性触感的软件美学。</p><p>然而,就是这个人,在 2025 年坦承:"Facts are facts, and AI is going to change programming forever."事实就是事实,AI 将永远改变编程。</p><img src="/2026/06/21/karpathy-half-year-no-code-prologue/image-20260523082322418.png" class=""><p>antirez 没有选择抵制。他选择理解。他提出了一个至关重要的概念区分——<strong>"Automatic Programming"(自动编程)与 "Vibe Coding"(氛围编码)是两回事</strong>。Vibe Coding 是把需求丢给 AI,接受它吐出的一切,不做审查,不做设计。而真正的 Automatic Programming 需要人类的直觉、设计判断、持续引导和对软件系统的深刻理解。AI 是放大器,不是替代品。他用 AI 在一周内完成了 DS4 项目的开发,让它成为了当时最流行的本地 AI 体验工具。但他审查了 AI 生成的每一行代码,做出了每一个关键架构决策。</p><h2 id="AI-放大了一切——包括你的工程缺陷"><a href="#AI-放大了一切——包括你的工程缺陷" class="headerlink" title="AI 放大了一切——包括你的工程缺陷"></a>AI 放大了一切——包括你的工程缺陷</h2><p>这些声音——Karpathy 的平静陈述、Amodei 的大胆预测、Garry Tan 的冰冷数据、Cherny 的工程坦率、antirez 的审慎拥抱——来自完全不同的方向,却指向同一个结论:**软件工程的范式正在发生五十年之变。**一个人的产出可以等于过去一个团队。自然语言正在成为最强大的编程接口。写代码这项技能,正在从"必须自己动手"变成"必须自己动脑"。</p><p>但如果你仔细听,在这些声音下面,有一个更深层的矛盾正在浮出水面。</p><p>AI 让"写代码"变得前所未有的容易,却让"写好软件"变得前所未有的困难。</p><p>任何人都可以用一句话让 AI 生成一个能跑的应用。这就是 Vibe Coding 的诱惑:你不需要理解数据结构,不需要考虑边界条件,不需要设计错误处理——你只需要说"给我做一个"。AI 会给你一个。它甚至看起来还不错。但当这个应用需要维护、需要扩展、需要与团队协作、需要经受生产环境的流量冲击时,Vibe Coding 的产物往往暴露了它的本质:一团不可测试、不可重构、不可理解的代码浆糊。</p><p>AI 可以让你以一百倍的速度写出代码。它也可以让你以一百倍的速度积累技术债务。AI 可以让你一小时交付一个原型。它也可以让你一周后完全无法理解自己的代码做了什么。AI 不会救你——它会放大你。你给它清晰的架构,它还你整洁的代码;你给它模糊的意图,它还你一团浆糊。它暴露你的工程能力,也同等精确地暴露你的工程缺陷。</p><p>这就是为什么,在 AI 让编码门槛降到历史最低点的时刻,<strong>工程化的价值反而达到了历史最高点</strong>。</p><h2 id="当开发速度不再稀缺,工程化就是最后的壁垒"><a href="#当开发速度不再稀缺,工程化就是最后的壁垒" class="headerlink" title="当开发速度不再稀缺,工程化就是最后的壁垒"></a>当开发速度不再稀缺,工程化就是最后的壁垒</h2><p>如果你的木工房里突然出现了一把能以一百倍速度切割木材的激光刀,你最需要的是更精确的测量工具、更严格的工艺流程、更可靠的安全护栏——而不是更快的刀。软件工程同理。当执行的速度被 AI 提升到前所未有的高度时,决定质量的是执行之前的规划、执行之中的约束、执行之后的验证。执行本身不再稀缺。</p><p>这正是过去两年间涌现的一系列新方法论试图解决的问题。</p><p>Matt Pocock——TypeScript 社区最受尊敬的工程教育家之一——提出了 Skills 系统的概念。他的核心洞见:Prompt 是临时的,Skill 是持久的。你不需要每次都对 AI 解释"如何做代码审查",你只需要给它一个 Skill。/diagnose 系统化调试、/grill-me 启动前对齐、/tdd 红-绿-重构——每一个 Skill 都是针对 AI 编程中特定失败模式的工程化解药,小而聚焦,模型无关,鼓励改造。</p><p>如果说 Skills 解决的是"单次交互的质量",那么 Spec-Driven Development 解决的就是"跨次交互的一致性"。OpenSpec 和 Spec-Kit 代表的 SDD 方法论将规格文档变成了人类与 AI 之间的一份"合约"——在写代码之前先写规格,你不需要审查 AI 的每一行思维过程,你只需要审查它在规格层面是否履约。</p><p>Ralph Loop 将这个逻辑推到了极致:让 AI Agent 在循环中持续改进自己的代码,直到满足验收标准为止。Frank Bria 设计的双条件出口门机制要求 AI 既要说「我做完了」,还要显式发出退出信号。因为 AI 的自我评估不可信,需要多重验证。</p><p>Garry Tan 的 gstack 则展示了一种完全不同的想象力:将 Claude Code 变成一个拥有二十三个专家角色的虚拟工程团队。CEO 审查战略、工程经理审查架构、QA 审查质量、安全官审查漏洞——整个 Sprint 从 Think 到 Reflect 被构建为一条七阶段审查流水线。一个人就是一支军队。</p><p>superpowers 框架——全球已获超过十五万颗星标——将这些 Skills 组织成了一整套方法论库。而 jnMetaCode 的中文增强版 superpowers-zh,则为中国开发者补充了国内代码托管平台适配、中文排版规范、Conventional Commits 本地化等原创能力。</p><p>这些方法论背后的共同逻辑是什么?</p><p><strong>用结构化知识驾驭非结构化 AI 能力。</strong></p><p>Prompt 消失在对话历史里,Skill 留在你的工具链里。Vibe Coding 的产物不可复现,Spec-Driven 的产出有据可查。一次性的 AI 对话无法保证质量,闭环工作流让每一次产出都经过验证。</p><h2 id="为智能体构建运行环境"><a href="#为智能体构建运行环境" class="headerlink" title="为智能体构建运行环境"></a>为智能体构建运行环境</h2><p>2025 年末,一个被称为"Harness Engineering"的新概念开始在 AI Agent 开发者社区中流传。它的核心关注点不是写 AI 模型,不是做产品功能,而是构建编码 Agent 的底层运行基础设施——工具系统、权限模型、hooks 机制、配置管理层级。社区逐渐认识到:如果说 Prompt Engineering 是"教会 AI 说什么",Skill Engineering 是"教会 AI 做什么",那么 Harness Engineering 就是"为 AI Agent 构建安全可靠的运行环境"。</p><img src="/2026/06/21/karpathy-half-year-no-code-prologue/image-20260523082838441.png" class=""><p>这是一个信号。它意味着 AI Agent 的开发正在从一个"试试看"的实验阶段,进入一个需要专业工程实践的成熟阶段。正如游戏引擎架构之于游戏开发、编译器设计之于语言工具开发,Harness Engineering 正在成为 AI Agent 产品开发中的专门工程领域。它代表了从"能用的 Agent"到"可靠的 Agent 产品"之间那条必须跨越的工程鸿沟。</p><h2 id="关于这本书"><a href="#关于这本书" class="headerlink" title="关于这本书"></a>关于这本书</h2><p>我是一名在软件工程领域工作了近30年的程序员。过去两年里,我和许多同行一样,眼睁睁看着自己熟悉的那个世界——手写代码、逐行调试、Code Review——被 AI 一步步重构。我参与了多个 AI 驱动的开源项目,也亲手构建了一套名为 Goal Workflow 的 AI 研发工作流技能集。这本书中的每一个方法论,我都亲自实践过;每一个结论,都来自真实的项目迭代而非纸上推演。</p><p>这本书的写作动机,正是源于上述所有这些变化的交汇点。</p><p>全书分为三个部分。</p><p>第一部分——原理篇(第 1–11 章)——是全书的主体。我们从软件工程范式的五十年之变出发,逐一考察当前最具代表性的方法论:Matt Pocock 的 Skills 系统、OpenSpec 与 Spec-Kit 的规格驱动开发、Ralph Loop 的自主循环引擎、Garry Tan 的 gstack 虚拟团队方法、superpowers 技能框架、autoresearch 的全自动化开发流程,以及 Goal Workflow 的目标驱动研发闭环。然后将这些方法论放在一起对比、碰撞、融合。在此基础上,我们深入 Harness Engineering——为 AI Agent 构建安全可控运行环境的专门工程领域,以及用 Kanban 编排 AI Agent 项目的实践。这一部分帮助你构建属于自己的 AI 研发体系。</p><p>第二部分——技能篇(第 12–17 章)——聚焦 AI 软件工程的实用技能与工具链。我们考察 Anthropic 官方插件如何为 Agent 注入领域知识与工程工作流,Understand-Anything 如何构建代码知识图谱,UML 在理解 AI 生成代码中的新用途,AI 时代的重构方法论,Go 语言的 AI 开发工具链,以及 autoreview 与 Crabbox 带来的自动化代码审查与远程验证。这一部分是从方法论到日常工程实践的桥梁。</p><p>第三部分——实战篇(第 18–23 章)——以一个真实的 Go 语言项目 goscapy 为载体,完整演示前两部分的方法论和技能在真实项目中的落地执行。从项目背景理解到 PRD 规划,从 /goal 迭代实现到 /review-it 自动化审查,从 /ship-it 交付合入到 Bonus Skills 的增强工具链——每一步都是实战,每一步都有真实代码和真实决策。goscapy 是一个纯 Go 实现的网络协议库,多协议支持、跨平台兼容,在生产环境中运行。这不是一个玩具项目。</p><p>但需要说清楚的是:这<strong>不是</strong>一本「如何使用 AI 工具」的操作手册。工具每天都在变。今天的 Claude Code 明天就不长这样,后天又会出现全新的工具形态。这是一本关于「<strong>如何在 AI 时代思考软件工程</strong>」的方法论著作。</p><h2 id="声明式编程的古老智慧"><a href="#声明式编程的古老智慧" class="headerlink" title="声明式编程的古老智慧"></a>声明式编程的古老智慧</h2><p>回到 Karpathy。</p><p>在那期播客里,除了那句被广泛引用的"半年没写一行代码"之外,他还说了另一句话,没那么出名,但同样重要。他说,使用 AI 编程的体验让他想起了一个古老的计算机科学概念:<strong>声明式编程</strong>。你不需要告诉计算机"怎么做",你只需要告诉它"要什么"。</p><p>SQL 是声明式的——你说"给我这些列、从这个表、满足这些条件",数据库引擎自己决定执行计划。在 AI 时代,整个软件开发正在变成声明式的:你说"给我一个支持多协议、高并发、跨平台的网络包处理库",AI Agent 自己决定架构、选择模式、实现细节。</p><p>但声明式编程有一个前提:声明本身必须是精确的。模糊的 SQL 查询返回模糊的结果。模糊的需求描述产生模糊的软件。</p><p>这就是为什么,在 AI 可以帮你写出一切的时代,<strong>知道"要什么"比知道"怎么做"更重要</strong>。而"知道要什么"——定义清晰的验收标准、设计合理的架构约束、建立可验证的质量门——正是软件工程这门学科用半个世纪沉淀下来的核心能力。</p><p>这些能力从来没有过时。它们只是在等待一个让它们变得至关重要的时刻。</p><p>那个时刻就是现在。</p><p>欢迎来到 AI 时代的软件工程。</p>
2026-06-21 11:09:44
<p><a href="https://0xkato.xyz/tags/#machine-learning">Machine Learning</a> <a href="https://0xkato.xyz/tags/#transformers">Transformers</a> <a href="https://0xkato.xyz/tags/#llm">LLM</a> <a href="https://0xkato.xyz/tags/#neural-networks">Neural Networks</a> <a href="https://0xkato.xyz/tags/#ai">AI</a></p><p>本文带你走一遍 LLM 的工作原理。现代 LLM 大多是由 transformer 块反复堆叠而成的,因此理解了 transformer 机制,你就掌握了大部分。</p><p>我将覆盖现代基于 transformer 的 LLM 内部的核心机制,避开那些复杂的数学。别误会,你应该学数学,但本文可以作为一个入门。</p><p>大多数现代 LLM 共享同一套 transformer 家族的骨架。差异来自于各自的训练数据、规模和配置选择,以及在此之上的后训练。读完本文后,你应该能够阅读许多现代 LLM 论文或模型卡,并知道每个部分在讲架构中的哪个组件。</p><p>路线如下:</p><ol><li>Token——一串文本如何变成一组整数序列</li><li>Embedding——这些整数如何获得含义</li><li>位置编码——模型如何知道 token 的顺序</li><li>Attention——token 之间如何交换信息</li></ol><span id="more"></span><ol start="5"><li>多头注意力——模型如何同时追踪多种关系</li><li>前馈网络——模型存储结构的主要所在</li><li>残差流与层归一化——是什么让深层堆叠可训练</li><li>预测下一个 token——模型实际输出什么,以及生成循环如何运作</li><li>架构 vs 训练权重——现代 LLM 之间广泛共享什么,以及什么不同</li></ol><p><img src="https://www.0xkato.xyz/assets/transformer-pipeline.png" alt="Transformer pipeline from tokenization to next-token prediction"></p><p>文中穿插了一些简短解释(tiny explainer),无论你的背景如何都能跟上。</p><hr><h2 id="Tokenization(分词)"><a href="#Tokenization(分词)" class="headerlink" title="Tokenization(分词)"></a>Tokenization(分词)</h2><p>模型不直接阅读文本。它们读取整数 ID。这一步将你的提示转换为一组整数序列。</p><p>这个转换步骤叫做 tokenization(分词)。一个 tokenizer(分词器)接收一个字符串并产生一组整数序列,其中每个整数指向固定词汇表中的一个条目。现代 LLM 的词汇表通常包含数万到数十万个条目。</p><blockquote><p><strong>简短解释:token ID</strong><br>token ID 是模型用于某个词汇表条目的整数。模型处理的是数字,而不是书写的单词本身。</p></blockquote><p>Token 通常不是完整的单词。它们通常是子词片段。单词 "tokenization" 可能被拆分为 ["token", "ization"]。"running" 可能被拆分为 ["run", "ning"]。原因在于效率。全词词汇表太大,且无法泛化到新词。字符级词汇表又太小,迫使模型从零开始学习最简单的模式。子词分词则处于中间地带。最常见的片段成为单独的 token,罕见或新颖的词则由更小的片段组合而成。</p><blockquote><p><strong>简短解释:词汇表(vocabulary)</strong><br>词汇表是分词器的固定片段列表。每个片段有一个 ID,模型只能直接接收来自该列表的 ID。</p></blockquote><p>这种权衡在意想不到的地方表现出来。经典例子:问 LLM "strawberry" 中有几个 R。LLM 过去常常回答错误。这不是模型不会计数。而是模型不直接操作字母,它只操作那些恰好拼写出一个单词的 token ID——而这个单词人类会逐字母拆分。</p><p><img src="https://www.0xkato.xyz/assets/transformer-tokenization.png" alt="Tokenization turns text into token IDs"></p><p>不同的模型家族使用不同的分词器。GPT 模型使用 BPE(Byte Pair Encoding)变体。SentencePiece 在 LLaMA 风格的模型中很常见。选择影响计算量(更少的 token 意味着更少的工作)和多语言覆盖等,但基本形式相同:文本进,整数出。</p><p>现在提示已经是一组整数序列,下一步是赋予这些整数含义。</p><hr><h2 id="Embedding(嵌入)"><a href="#Embedding(嵌入)" class="headerlink" title="Embedding(嵌入)"></a>Embedding(嵌入)</h2><p>一个像 <code>1024</code> 这样的 token ID 只是一个行索引。它本身没有任何意义。赋予它意义的是一个巨大的表,叫做嵌入矩阵(embedding matrix)。</p><p>每个模型都有一个。它对词汇表中的每个条目都有一行,每行是一个长长的数字向量。每行的长度就是模型的隐藏维度大小(hidden size)。在许多 7B 级别的模型中,这意味着每个 token 对应 4,096 个数字。更大的模型通常使用更宽的向量。</p><blockquote><p><strong>简短解释:向量(vector)</strong><br>向量就是一个数字列表。在 transformer 中,每个 token 变成一个向量,这样模型就可以用它做数学运算。</p></blockquote><p>当分词器把整数交给模型时,模型查找那一行并用向量替换它。那个向量就是 token 的嵌入(embedding)。它是模型对该 token "含义" 的表示,是在训练过程中学到的。</p><blockquote><p><strong>简短解释:嵌入矩阵(embedding matrix)</strong><br>嵌入矩阵是一个查找表。token ID 进,学到的向量出。</p></blockquote><p>这些嵌入的一个有趣特性是,语义上相似的 token 最终会得到相似的向量。"king" 的向量在空间中接近 "queen" 的向量,"Paris" 的向量接近 "France" 的向量。这一切都不是硬编码的。它是在足够多的文本上训练后涌现出来的,模型学会这些位置是因为它们能让模型更好地预测文本。</p><p>你可以对嵌入做算术运算,有时候确实有效。著名的例子是 <code>king − man + woman ≈ queen</code>。嵌入空间的几何结构携带着真实的语义结构,尽管没有人告诉模型要以这种方式构建它。</p><p><img src="https://www.0xkato.xyz/assets/transformer-embedding-analogy.png" alt="Embedding space analogy with semantic relationships"></p><p>有一点需要明确:在这个阶段,每个 token 都被它的嵌入替换了,但嵌入本身不包含 token 在序列中的位置信息。"dog" 的向量无论在提示的第一个还是第五个位置,都是同一个向量。这是个问题。</p><p>这就是位置编码要填补的空白。</p><hr><h2 id="Positional-Encoding(位置编码)"><a href="#Positional-Encoding(位置编码)" class="headerlink" title="Positional Encoding(位置编码)"></a>Positional Encoding(位置编码)</h2><p>纯粹的 self-attention 没有内置的词序表示。没有某种位置信号,它无法直接知道 "dog" 在 "bites" 之前而不是之后。</p><p>词序会改变含义。所以模型需要另一个组件。它需要一种方式将每个 token 的位置注入到数学运算中。</p><blockquote><p><strong>简短解释:位置编码(positional encoding)</strong><br>位置编码是模型获取顺序信息的方式。它告诉模型每个 token 在序列中的位置。</p></blockquote><p>最初的 transformer 论文(Vaswani et al. 2017)的解决方案是给每个位置赋予自己的数字模式,并在任何其他处理之前直接加到每个 token 的嵌入上。位置 1 有一种模式,位置 5 有不同的模式,位置 100 有另一种模式。这些模式来自不同频率的正弦和余弦波。这样一来,位置 1 的 "dog" 的嵌入就不同于位置 5 的 "dog" 的嵌入,仅仅因为加在它上面的位置模式不同。</p><p>这能够工作,而且选择正弦编码的部分原因是它们可以外推到超出训练时见过的序列长度。但加法式的位置方案仍然有两个随着模型规模扩大而变得重要的问题。</p><p>首先,嵌入必须在同一组数字中同时承载含义和位置。能塞进去的东西是有限的。</p><p>其次,尤其是学到的绝对位置嵌入(learned absolute position embeddings)不能干净地泛化。如果你训练的提示最长 2,048 个 token,模型在训练时从未见过位置 5,000,那个位置的嵌入就不是以同样的方式学到的。</p><p>现代模型大多使用一种不同的方案,叫做 Rotary Position Embeddings(RoPE),由 Su et al. 于 2021 年提出,现在被 LLaMA、Mistral、Gemma、Qwen 和大多数其他开源权重家族所使用。直觉是:RoPE 不是将位置信息加到每个 token 的向量上,而是将 Query 和 Key 向量旋转一个取决于 token 位置的角度。位置 1 的 token 转一个小的角度,位置 100 的 token 转一个更大的角度。当两个 token 在后面的 attention 中被比较时,重要的是它们 Query 和 Key 旋转的差值,这编码了它们相距多远。</p><blockquote><p><strong>简短解释:RoPE</strong><br>RoPE 代表 Rotary Position Embeddings。它不是加一个位置向量,而是旋转 Query 和 Key 向量,使相对距离在 attention 中显现出来。</p></blockquote><p><img src="https://www.0xkato.xyz/assets/transformer-rope.png" alt="Rotary position embeddings rotate vectors by position"></p><p>实际的优点是真实的。RoPE 自然地编码相对位置(这更接近 attention 实际需要的东西)。它能更好地泛化到更长的上下文。而且它不给模型增加新的参数。</p><p>即使有了好的位置编码,现代 LLM 仍然有一个已记录在案的"迷失在中间(lost in the middle)"问题(Liu et al. 2023)。它们使用长提示开头和结尾的信息比使用中间的信息更可靠。这就是为什么像"把重要上下文放在前面"或"在末尾重复关键信息"这样的提示工程技巧确实有用。模型并不是同等地使用你提示的每个部分。</p><p>有了 token 含义和位置都编码完成,下一个问题是:token 实际上如何交换信息?</p><hr><h2 id="Attention"><a href="#Attention" class="headerlink" title="Attention"></a>Attention</h2><p>这就是赋予这个架构名字的机制。Attention。</p><p>在每个 transformer 层内部,attention 做一件事。它让每个 token 查看它被允许看到的其他 token,并决定哪些对接下来发生的事重要。</p><p>它通过同时给每个 token 赋予三个角色来实现。每个 token 被转换成三个新的向量,称为 Query、Key 和 Value(Q、K、V)。</p><blockquote><p><strong>简短解释:Q、K、V</strong><br>Query 表示"我在找什么",Key 表示"我匹配什么",Value 是匹配成功时被传递的信息。</p></blockquote><ul><li>Query 问:"我从其他 token 那里在寻找什么?"</li><li>Key 说:"这就是我提供给正在看我的 token 的东西。"</li><li>Value 携带:"这就是匹配发生时被传递的东西。"</li></ul><p>同一个 token 同时扮演全部三个角色。Q、K、V 的变换是学到的矩阵,所以模型在训练过程中会弄清楚每个 token 应该寻找什么以及它应该提供什么。</p><p>匹配通过相似度分数来发生。每个 token 的 Query 与它被允许看到的每个 token 的 Key 进行比较,使用缩放点积(scaled dot product)。直观地说,这衡量了两个向量的对齐程度。缩放使得数字在 softmax 之前保持稳定。</p><blockquote><p><strong>简短解释:点积(dot product)</strong><br>点积是一种简单的方法,用于给两个向量的对齐程度打分。对齐程度越高意味着匹配越强。</p></blockquote><p>然后,匹配分数通过 softmax 转换为权重。Softmax 接收任意一组数字,将它们转化为总和为 1 的类概率分布。匹配分数更高的 token 获得更高的权重,然后用这些权重取 value 向量的加权平均。</p><blockquote><p><strong>简短解释:softmax</strong><br>Softmax 将原始分数转化为加起来等于 1 的权重。大分数得大权重,小分数得小权重。</p></blockquote><p>举个例子。考虑句子 "The cat that I saw yesterday was sleeping." 当模型处理 "was" 时,它需要弄清什么在睡觉。"was" 的 Query 向量与它被允许看到的 token 的 Key 向量进行比较。与 "cat" 的点积很高,因为模型已经学会像 "was" 这样的动词需要一个主语,而像 "cat" 这样的主语会产生与之对齐良好的 Key 向量。与 "yesterday" 的点积很低。Softmax 将这些分数转化为权重,"cat" 得到高权重,"yesterday" 得到低权重。然后模型对相应的 value 向量取加权和,所以 "cat" 的 value 主导了结果。"was" 的新表示现在主要由 "cat" 的 value 塑造。这就是几个位置之前的 token 如何成为被指代对象。</p><p>有一个 GPT 风格语言模型特有的约束,即它们从左到右生成文本。位置 5 的 token 只能关注位置 1 到 5。它不能关注位置 6、7、8 的 token,因为它们还没被生成。这叫做因果掩码(causal masking)。实现很简单:未来 token 的匹配分数低到经过 softmax 后权重几乎为零。</p><blockquote><p><strong>简短解释:因果掩码(causal masking)</strong><br>因果掩码隐藏未来的 token。它阻止 decoder-only 语言模型在预测下一个 token 时向前偷看。</p></blockquote><p><img src="https://www.0xkato.xyz/assets/transformer-attention-heatmap.png" alt="Attention heatmap showing causal masking and high attention to cat"></p><p>可解释性研究中最有趣的发现之一是关于一种专门的注意力头,叫做 induction head(归纳头),由 Anthropic 在 2022 年发现。这些头学会了在提示中发现 "A B … A" 这种模式,并预测接下来的会是 B。当模型第二次看到 "A" 时,归纳头回溯到上一次 "A" 出现的地方,看到它后面是什么,然后复制那个。它们是已知的最清晰的上下文学习(in-context learning)机制之一——LLM 从你的提示中捕捉到一个模式并继续它的能力。</p><blockquote><p><strong>简短解释:归纳头(induction head)</strong><br>归纳头是一种注意力头,它注意到提示中重复的模式并帮助延续它们。</p></blockquote><p>Attention 有一个巨大的成本。在全注意力(full attention)中,每个 token 与它被允许看到的所有 token 比较,所以提示长度加倍,工作量大约翻四倍。这就是为什么长提示运行成本高,以及为什么最近很多研究都在关注让 attention 更高效(FlashAttention、稀疏注意力、线性注意力)。</p><p>但一个注意力头只给模型提供一种关于关系的学到的视角。</p><hr><h2 id="Multi-Head-Attention(多头注意力)"><a href="#Multi-Head-Attention(多头注意力)" class="headerlink" title="Multi-Head Attention(多头注意力)"></a>Multi-Head Attention(多头注意力)</h2><p>一次 attention 传递给模型提供了一种决定哪些 token 对其他 token 重要的方式。这还不够。语言中有许多同时发生的关系。主谓一致。代词及其指代的名词。句子之间的长距离引用。词序和局部短语。</p><p>多头注意力通过并行地运行多次 attention 来解决这个问题,每个并行的传递在它自己较小的空间中操作。每个并行传递被称为一个头(head)。</p><blockquote><p><strong>简短解释:注意力头(attention head)</strong><br>一个注意力头是一次独立的 attention 传递,拥有自己学到的投影。</p></blockquote><p>这部分经常被描述错误——包括在大量教程中。每个头并不是获取原始 token 向量的字面切片。每个头有自己学到的投影矩阵,将完整的 token 向量映射到它自己较小的 Q、K、V 向量。所以如果一个模型每个 token 有 4,096 个数字和 32 个头,每个头通常在 128 维空间中工作,但那 128 个数字是完整 4,096 的学到的投影,而不是固定的切片。是同一 token 的不同"视角",而不是它的不同分块。</p><p>每个头独立运行它的 attention 传递。然后所有头的输出被拼接(concatenate)起来,经过一个最终的线性层,将它们混合回一个完整大小的向量。模型也学习那个最终的混合。</p><p><img src="https://www.0xkato.xyz/assets/transformer-multi-head-attention.png" alt="Multi-head attention combines specialized attention heads"></p><p>有意思的是,不同的头往往最终部分地专门化。模型从未被告知每个头应该做什么。专门化是在训练中自然涌现的。研究人员发现了追踪语法的头(将动词连接到宾语、冠词连接到名词)、弄清代词指代哪个名词的头、追踪位置模式的头、归纳头,以及更多。一个 transformer 层可能有 32 个头。一个现代前沿模型有几十层。所以一个典型的 LLM 总共有数千个注意力头,每个都贡献自己学到的视角。</p><p>有一个实际的成本问题驱动了近期的架构变化。每个头需要将所有已生成 token 的 Key 和 Value 向量保存在内存中,这样当生成新 token 时模型不必从头重新计算所有内容。这叫做 KV 缓存(KV cache),它是在长上下文长度下运行 LLM 的主要内存成本。</p><blockquote><p><strong>简短解释:KV 缓存(KV cache)</strong><br>KV 缓存在生成过程中存储旧的 Key 和 Value 向量。它省去了模型每增加一个 token 就重新计算整个提示的工作。</p></blockquote><p>现代 decoder-only LLM 大多使用一种变体,叫做 Grouped-Query Attention(GQA)。不是每个头都有自己的 key 和 value,而是头分组共享相同的 key 和 value 头。LLaMA-2 70B 有 64 个 query 头但只有 8 个 key/value 头。Mistral 7B 有 32 个 query 头和 8 个 key/value 头。结果几乎是全多头注意力相同的精度,但内存压力和推理成本大大降低。</p><blockquote><p><strong>简短解释:GQA</strong><br>Grouped-Query Attention 允许多个 query 头共享更少的 key/value 头。这在保持多个 query 视角的同时削减了 KV 缓存内存。</p></blockquote><hr><h2 id="Feed-Forward-Network(前馈网络)"><a href="#Feed-Forward-Network(前馈网络)" class="headerlink" title="Feed-Forward Network(前馈网络)"></a>Feed-Forward Network(前馈网络)</h2><p>在 attention 完成 token 之间的信息混合之后,每一层还有第二步,但谈论它的人少得多。前馈网络。</p><p>如果说 attention 是 token 之间互相交谈,那么前馈网络是每个 token 独立地做更多处理。它对每个 token 的向量独立运行,没有跨 token 的混合。</p><p>前馈网络按顺序做三件事:</p><ol><li>将 token 的向量扩展到更大的尺寸(原始 transformer 使用 4x,而现代 SwiGLU 模型通常使用不同的扩展尺寸)。</li><li>应用一个非线性函数。</li><li>将向量压缩回原始尺寸。</li></ol><p><img src="https://www.0xkato.xyz/assets/transformer-ffn.png" alt="Feed-forward network expands, transforms, and compresses each token vector"></p><p>中间那个非线性步骤做了某件值得理解的具体事情。非线性是一个弯曲其输入的函数。最简单的,ReLU,对任何负数输出零,对正数原样传递。</p><blockquote><p><strong>简短解释:非线性(non-linearity)</strong><br>非线性是一个函数,它阻止网络坍塌成一个大的线性变换。</p></blockquote><p>没有它,FFN 只是两个线性层叠在一起,而堆叠纯线性数学会坍塌。两个线性层连续排列在数学上等价于一个线性层,一百层线性层连续排列仍然等价于一层。非线性阻止了这种坍塌,它是 FFN 能够做到比单次矩阵乘法更丰富的事情的原因。</p><p>原始 transformer 使用 ReLU。GPT 和 BERT 转向 GELU。现代模型如 LLaMA、Mistral 和 PaLM 使用 SwiGLU。扩展-然后-压缩的结构保持不变。被迭代改进的是非线性本身。</p><p>密集 transformer 模型的大部分参数都在 FFN 中,而不是 attention 中。大部分权重位于前馈层中。</p><p>而这些参数不是泛化的。它们承载了模型存储的大部分事实和语义结构。研究人员发现 FFN 内部的某些神经元与特定的概念或事实强烈关联。一个神经元可能在埃菲尔铁塔相关的文本上强烈激活。另一个在编程语言上。另一个在过去式动词上。当模型"知道"巴黎是法国的首都时,这个事实由特定层中的 FFN 权重和激活来表示。</p><p>这种存储记忆的特性有一个有趣的推论。研究人员已经弄清楚了如何在训练好的模型中直接编辑某些事实而无需重新训练。像 ROME(Rank-One Model Editing)这样的方法可以通过对特定 FFN 权重矩阵进行目标低秩编辑,将"埃菲尔铁塔在巴黎"改为"埃菲尔铁塔在罗马"。然后模型会倾向于生成与编辑后的关联一致的文本。</p><p>一些现代前沿模型已经开始用称为 Mixture of Experts(MoE)的东西替换密集 FFN。不是每层有一个前馈网络,模型有许多并行的 FFN(称为 experts),以及一个微小的路由网络来选择哪些 expert 处理每个 token。Mixtral 8x7B 每层有 8 个 expert;对于任何给定的 token 只有 2 个被激活。总参数量大幅增加,但每个 token 的计算量增长慢得多,因为只有少数几个 expert 在运行。这就是如何在不成比例地扩展推理成本的情况下扩展参数规模。</p><blockquote><p><strong>简短解释:MoE</strong><br>Mixture of Experts 意味着模型有几个前馈网络,并将每个 token 只路由通过其中少数几个。</p></blockquote><p>Mixtral 8x7B 总共有 467 亿参数,但每个 token 只用大约 129 亿。对于非常大型的模型,这已经成为一个常见选项,因为它让你在不断增加参数规模的同时不让推理成本成比例增长。</p><hr><h2 id="Residual-Stream-and-Layer-Normalization(残差流与层归一化)"><a href="#Residual-Stream-and-Layer-Normalization(残差流与层归一化)" class="headerlink" title="Residual Stream and Layer Normalization(残差流与层归一化)"></a>Residual Stream and Layer Normalization(残差流与层归一化)</h2><p>残差流使得模型是"加法式"的而不是"替换式"的。在 attention 运行之后,或前馈网络运行之后,结果通常不替换 token 的向量。它被加到上面。逐个位置地加。新向量等于旧向量加上子块的输出。</p><blockquote><p><strong>简短解释:残差连接(residual connection)</strong><br>残差连接将块的输出加回它起始时的向量。它为信息和梯度提供了通过网络的捷径。</p></blockquote><p>跨越三十、五十或一百层,每层的贡献累积起来,而不是简单地覆写前一个向量。这个运行中的和被称为残差流(residual stream),它有一个奇特的性质。原始输入嵌入仍然有一条到达后面层的直接加法路径,与沿途每个子块的贡献混合在一起。</p><p><img src="https://www.0xkato.xyz/assets/transformer-residual-stream.png" alt="Residual stream accumulates attention and feed-forward outputs"></p><p>残差连接不是为 transformer 发明的。它们来自 ResNet(He et al. 2015),最初用于图像识别。动机是深层网络无法训练。训练信号在穿越许多层回来时变得太弱(或有时太强)。模型实际上无法从自己的错误中学习。添加一条捷径路径让信号直接从输出流回输入。突然间你可以训练有数百层的网络了。Transformer 继承了同样的技巧。</p><p>在现代可解释性研究中,残差流已经成为核心对象。每个组件——每个注意力头、每个前馈网络、甚至最后的反嵌入步骤——都从残差流读取并写回其中。</p><p>第二部分,层归一化(layer normalization),存在的原因要实际得多。没有它,残差流将无法保持稳定。流经数十次加法的数字倾向于要么爆炸式增长,要么坍缩到零。无论哪种情况,训练都会失败。层归一化在每个子块之间将每个 token 的向量重新缩放到一个受控的范围。</p><blockquote><p><strong>简短解释:层归一化(layer normalization)</strong><br>层归一化重新缩放 token 向量,使其数字在模型训练期间保持在一个稳定的范围内。</p></blockquote><p>原始 2017 年的 transformer 在每个子块之后应用归一化(post-norm)。这对于浅层模型有效,但随着深度增加变得更难可靠训练。现代 transformer(GPT-2 以后,LLaMA、Mistral)通常在每个子块之前应用归一化(pre-norm)。这是使得非常深的 transformer 更容易训练的变更之一。</p><p>归一化函数本身也变了。许多现代开源模型(LLaMA、Mistral、Gemma、Phi)使用一种更简单的变体,叫做 RMSNorm。原始层归一化同时做两件事:将每个向量向零平移,然后重新缩放数字的大小。RMSNorm 去掉了平移步骤,只保留缩放。经验上,缩放承载了大部分好处,同时计算成本更低。</p><blockquote><p><strong>简短解释:RMSNorm</strong><br>RMSNorm 是一种更便宜的归一化方法,在不先减去均值的情况下重新缩放向量大小。</p></blockquote><p>这就是那些不光彩的基础设施。没有残差连接,非常深的模型会变得极难训练。没有层归一化,运行中的和可能爆炸或坍缩。有了两者,你就能得到数百层深的模型。</p><hr><h2 id="Next-Token-Prediction(下一个-token-预测)"><a href="#Next-Token-Prediction(下一个-token-预测)" class="headerlink" title="Next-Token Prediction(下一个 token 预测)"></a>Next-Token Prediction(下一个 token 预测)</h2><p>在所有 attention 和前馈处理层完成之后,模型对序列中的每个 token 都有一个向量。在生成过程中,要预测下一个词,它只取最后一个 token 的最终向量。</p><p>那个最后的向量被转换为每个可能的下一个 token 对应一个数字。如果词汇表有 100,000 个 token,那就是 100,000 个数字。这些数字叫做 logits。它们还不是概率。它们可以是任何大小,正数或负数。</p><blockquote><p><strong>简短解释:logits</strong><br>Logits 是每个可能的下一个 token 的原始分数。只有在 softmax 之后它们才变成概率。</p></blockquote><p>Softmax 将这些 logits 转化为模型在可能的下一个 token 上的概率分布。和之前一样的操作,在模型中的不同位置。</p><p>模型通常不每次只选最高概率的 token。解码设置控制输出的确定性或多样性程度。Temperature 改变分布的尖锐程度。Top-k 和 top-p 将选择限制在最合理的一组下一个 token。这就是为什么同一个模型在一种设置下可以感觉精确,在另一种设置下可以更有创意。</p><blockquote><p><strong>简短解释:temperature</strong><br>Temperature 控制采样期间的随机性。低 temperature 使模型更保守;高 temperature 使输出更多样化。</p></blockquote><p>一旦选出一个 token,它就被添加到输入中。模型在更长的序列上运行下一步,通常重用 KV 缓存,这样就不必从头重新计算整个前缀。新 token 的新 attention。新前馈。新最终向量。新预测。循环继续,直到模型输出一个序列结束 token 或达到长度限制。一整段话就是这个循环,一次一个 token。</p><p>这个单一目标——预测下一个 token——是基础 LLM 的核心训练信号。基础模型不是被训练来做事实准确性、对话能力、推理或编程的。它被训练来预测海量文本中的下一个 token。之后的后训练才能将模型调整为指令遵循、偏好、安全性和对话行为。</p><p>有一个值得了解的重大效率创新。它叫做投机解码(speculative decoding)。一个小型快速模型提前提出几个 token。大模型并行地验证它们。如果提出的 token 在大模型的概率下被接受,就接受它们。如果没有,就回退到大模型。做得正确的话,输出分布与单独运行大模型一致,但循环可以快得多。</p><blockquote><p><strong>简短解释:投机解码(speculative decoding)</strong><br>投机解码使用一个小型草稿模型向前猜测,然后让较大的模型一次验证几个猜测的 token。</p></blockquote><p>下一个 token 预测循环是架构中最简单的部分,但它是让整个系统运作起来的东西。</p><hr><h2 id="Architecture-vs-Trained-Weights(架构-vs-训练权重)"><a href="#Architecture-vs-Trained-Weights(架构-vs-训练权重)" class="headerlink" title="Architecture vs Trained Weights(架构 vs 训练权重)"></a>Architecture vs Trained Weights(架构 vs 训练权重)</h2><p>我们已经走过了核心机制:token、嵌入、位置编码、attention、多头注意力、前馈网络、残差流与归一化,以及输出侧的下一个 token 循环。这就是基本架构的一遍遍历。</p><p>那么 GPT、Claude、Gemini 和 LLaMA 之间实际有什么区别?公开细节各不相同,而闭源模型不会公布所有的架构选择。但在本文所覆盖的层面,它们大致处于同一 transformer 家族的设计空间之中。</p><p>大多数现代基于 transformer 的 LLM 使用相同的大致结构:分词、嵌入、位置编码、堆叠的 transformer 层(每层有多头注意力和前馈网络)、残差流、层归一化,以及下一个 token 预测。</p><p>模型之间的不同在于:</p><ol><li>训练权重本身——从不同的训练数据、在不同的规模上学习而来。</li><li>配置:层数、词汇表大小、头数、参数量、MoE 还是密集。</li><li>后训练:指令微调、基于人类反馈的学习、在基础模型之上应用的安全控制。</li></ol><blockquote><p><strong>简短解释:权重(weights)</strong><br>权重是模型内部学到的数字。训练会改变这些数字,直到模型能很好地预测文本。</p></blockquote><p>2023-2025 年的"现代 transformer"技术栈在许多严肃的前沿和开源权重模型上收敛到了一组共同的选择,尽管不同的团队是独立达到这些选择的。Pre-norm 布局。RMSNorm。RoPE。SwiGLU。Grouped-Query Attention。在一些最大型的模型中使用 Mixture of Experts。这些都不是一次性发明的。它们是在原始 2017 年设计之上大约五年的精炼中累积起来的。</p><hr><h2 id="未来走向"><a href="#未来走向" class="headerlink" title="未来走向"></a>未来走向</h2><p>Transformer 家族架构的收敛在机器学习历史上是不寻常的。在这个领域的大部分历史中,每个问题都有自己的专门网络。图像识别用一种。语言用另一种。音频用第三种。视觉和语言团队几乎不共享方法。</p><p>现在 transformer 风格的模型出现在语言、视觉、音频和多模态系统中。Transformer 吸收了该领域的很大一部分。</p><p>这可能会改变。Mamba 和其他状态空间模型是可信的替代方案,特别是对于非常长的序列。混合架构正在被探索。Mixture-of-Experts 已经以五年前会被认为是异域的方式改变了前沿上"架构"的含义。</p><p>但本文中的核心机制——token、嵌入、位置编码、attention、前馈网络、残差流与归一化,以及下一个 token 预测——是持久的部分。即使架构发生变化,这些也是任何序列模型必须以某种形式解决的问题。</p><p>如果你读到了这里,你现在可以阅读许多现代 transformer 论文或模型卡,并知道每个部分在讲哪个组件。这就是目标。</p><hr><p><strong>原文来源</strong>:0xkato, "How LLMs Actually Work", June 1, 2026, <a href="https://www.0xkato.xyz/how-llms-actually-work/">https://www.0xkato.xyz/how-llms-actually-work/</a></p>
2026-06-21 10:05:27
<p>Go 在发布新版本时经常会附带<strong>实验性特性(experimental features)</strong>。</p><p>这些实验性特性有不同的形式:有时是标准库中全新的包,有时是编译器或运行时的改动,偶尔也可能是对 Go 行为的破坏性变更。</p><p>大多数情况下,实验性特性的目的是在某个功能正式进入 <strong>通用可用(general availability)</strong> 阶段、成为 Go 的永久组成部分之前,从用户那里获取真实世界的反馈。如果该特性导致性能退化,或收到社区的负面反馈,它可以在最终定稿前被修改——甚至被完全放弃。</p><span id="more"></span><h2 id="一些示例"><a href="#一些示例" class="headerlink" title="一些示例"></a>一些示例</h2><p>让我们看几个近期的例子,来说明 Go 实验特性可能涉及的内容类型。</p><ul><li><p>Go 1.24 发布时附带了新的 <code>testing/synctest</code> 包的实验性支持(该包提供了测试并发代码的支持)。在收到反馈后,该包的 API 略有调整,并在 Go 1.25 中正式进入通用可用阶段。</p></li><li><p>Go 1.25 发布时附带了<a href="https://github.com/golang/go/issues/73581">新的垃圾回收器</a>设计的实验性支持,具有更好的性能。在吸收反馈之后,新的垃圾回收器在 Go 1.26 中成为默认选项。</p></li><li><p>Go 1.21 发布时附带了<a href="https://go.dev/wiki/LoopvarExperiment">循环变量语义行为变更</a>的实验性支持。这一变更消除了 Go 代码中一个以前常见的 bug,但在技术上是对语言的破坏性变更。将该变更作为实验发布,让人们在 Go 1.22 中该行为成为默认之前有机会测试自己的代码。</p></li></ul><h2 id="实验特性的生命周期"><a href="#实验特性的生命周期" class="headerlink" title="实验特性的生命周期"></a>实验特性的生命周期</h2><p>实验特性并没有单一固定的生命周期,但有一些常见的模式。</p><p>大多数实验特性最初以**默认关闭(off-by-default)**的方式发布。你需要显式选择加入(opt-in)来试用该功能,通常是通过设置 <code>GOEXPERIMENT</code> 环境变量(稍后会详细讨论)。</p><p>如果一切顺利,经过一到两个版本之后,实验特性被最终确定,进入通用可用阶段,并变为<strong>默认开启(on-by-default)</strong>。</p><p>如果一个实验特性影响了某些行为,那么它在进入通用可用阶段后,有时——但并非总是——会有一个过渡性的宽限期,在此期间可以临时禁用该特性并使用旧行为。例如,在 Go 1.26 中,新的垃圾回收器设计(上面简要提到过)进入了通用可用阶段并默认开启,但如果需要,仍然可以禁用它并使用旧的垃圾回收器。</p><p>以上就是最常见的模式,但有时候事情会更漫长或走向不同的方向。例如:</p><ul><li>Go 1.22 发布时附带了编译器内联逻辑的实验性实现,两年多过去了,它仍然默认关闭,处于评估之中。</li><li>同一次发布还附带了一个<strong>内存 arena</strong> 实验。在收到用户的负面反馈和顾虑之后,它仍然默认关闭,处于<a href="https://avittig.medium.com/golangs-big-miss-on-memory-arenas-f1375524cc90">无限期搁置</a>状态,最终可能会被完全移除。</li></ul><p>或者,当 Go 团队对某个变更足够有信心时,他们可能会跳过反馈阶段,直接进入通用可用阶段……但仍然可能有一个过渡性的宽限期,在此期间可以禁用它。</p><p>一个很好的例子是 Go 1.24 将其 map 实现改为使用 <a href="https://go.dev/blog/swisstable">Swiss tables</a>。Go 团队对实现及其性能优势足够有信心,因此直接进入通用可用阶段并默认开启,但——至少目前——如果你愿意,仍然可以选择退出并使用旧的 map 实现。</p><p>所以在实践中,实验特性实际上有三种大致状态:</p><ul><li>默认关闭,评估中</li><li>默认关闭,搁置/休眠中</li><li>默认开启,但有临时退出选项</li></ul><h2 id="永久实验特性"><a href="#永久实验特性" class="headerlink" title="永久实验特性"></a>永久实验特性</h2><p>Go 还有一些实验性功能,它们并非通常意义上的"实验"。</p><p>这些功能默认关闭,但它们不在评估中,不在寻求反馈,也没有预期它们最终会进入通用可用阶段并变为默认开启。</p><p>尽管它们通过 <code>GOEXPERIMENT</code> 环境设置以与其他实验相同的方式控制,但实际上它们更像是你可能在特定场景下想要使用的可选 Go 功能。</p><p>在下文中,我将这些称为"永久实验特性(permanent experiments)"。</p><p>例如,有一个 <a href="https://codereview.appspot.com/6749064">field tracking</a> 诊断功能,用于追踪哪些结构体字段被访问。它已经存在十年了,并且<a href="https://github.com/golang/go/issues/42712#issuecomment-737414957">没有意图</a>让它进入通用可用阶段。还有一个 <a href="https://go.googlesource.com/go/+/0a820007e70fdd038950f28254c6269cd9588c02">static lock ranking</a> 功能,这是一个用于查找 Go 运行时中潜在死锁的诊断工具。</p><h2 id="当前有哪些可用的实验特性?"><a href="#当前有哪些可用的实验特性?" class="headerlink" title="当前有哪些可用的实验特性?"></a>当前有哪些可用的实验特性?</h2><p>要了解当前有哪些可用的实验特性及其状态,出奇地困难。</p><p>遗憾的是,Go 官方文档或 <a href="https://go.dev/wiki/All">Go Wiki</a> 中没有一个页面来追踪实验特性的状态,为了写这篇文章,我不得不从各处拼凑信息。如果你想做同样的事:</p><ul><li>可以运行 <code>$ go doc goexperiment.Flags</code> 获取所有可用实验的列表。</li><li>可以通过阅读 <code>src/internal/buildcfg/exp.go</code> 的源码——特别关注 <code>ParseGOEXPERIMENT()</code> 函数中的 <code>baseline</code> 变量声明——来找出哪些实验是默认开启的。</li><li>可以将实验名称与 Go 发布说明交叉对照,并搜索 GitHub issues 来尝试弄清当前的状态。</li></ul><p>据我所知,截至 Go 1.26,以下是当前可用的永久实验特性:</p><table><thead><tr><th>实验名称</th><th>描述</th><th>状态</th></tr></thead><tbody><tr><td><code>FieldTrack</code></td><td>追踪哪些结构体字段被访问的诊断工具</td><td>默认关闭,<a href="https://github.com/golang/go/issues/42712#issuecomment-737414957">永久存在</a></td></tr><tr><td><code>StaticLockRanking</code></td><td>验证锁获取顺序以捕获死锁的诊断工具</td><td>默认关闭,永久存在</td></tr><tr><td><code>CgoCheck2</code></td><td><a href="https://tip.golang.org/doc/go1.21#runtimepkgruntime">检查 cgo 指针传递规则</a>的诊断工具;运行时开销太大,不适合默认开启</td><td>默认关闭,永久存在</td></tr><tr><td><code>BoringCrypto</code></td><td>用 FIPS 认证的 BoringSSL 替换 Go 的 crypto;自 <a href="https://go.dev/doc/go1.24#fips140">Go 1.24</a> 起已不再需要</td><td>默认关闭,<a href="https://github.com/golang/go/issues/42712#issuecomment-737414957">永久存在</a>,但<a href="https://go.dev/blog/fips140">很快将被移除</a></td></tr><tr><td><code>PreemptibleLoops</code></td><td>允许调度器在循环<a href="https://github.com/golang/go/issues/10958">回边处抢占 goroutine</a>;自 Go 1.14 起通常不再需要,但在<a href="https://go.dev/doc/go1.14#runtime">不支持抢占的平台上</a>可能仍然有用</td><td>默认关闭,永久存在</td></tr></tbody></table><p>以下是当前默认关闭的实验特性及其状态:</p><table><thead><tr><th>实验名称</th><th>描述</th><th>状态</th></tr></thead><tbody><tr><td><code>HeapMinimum512KiB</code></td><td>将最小堆大小从 4MB 减少到 512KiB;可能在受限环境中很有用</td><td>默认关闭,<a href="https://github.com/golang/go/commit/c5c1955077cb94736b0f311b3a02419d166f45ac">可能已休眠</a></td></tr><tr><td><code>Arenas</code></td><td><a href="https://uptrace.dev/blog/golang-memory-arena">内存 arena</a> 实现</td><td>默认关闭,收到负面反馈后<a href="https://github.com/golang/go/issues/51317">已搁置</a></td></tr><tr><td><code>NewInliner</code></td><td>重写的编译器内联器,具有更好的调用点启发式算法</td><td>默认关闭,评估中(自 <a href="https://go.dev/doc/go1.22#compiler">Go 1.22</a> 起可用)</td></tr><tr><td><code>JSONv2</code></td><td>新的 <code>encoding/json/v2</code> 包,提供改进的 JSON 编码/解码函数</td><td>默认关闭,评估中(自 <a href="https://go.dev/doc/go1.25#json_v2">Go 1.25</a> 起可用)</td></tr><tr><td><code>RuntimeSecret</code></td><td>新的 <code>runtime/secret</code> 包,提供清零内存的函数;仅支持 Linux amd64/arm64</td><td>默认关闭,评估中(自 <a href="https://go.dev/doc/go1.26#new-experimental-runtimesecret-package">Go 1.26</a> 起可用)</td></tr><tr><td><code>GoroutineLeakProfile</code></td><td>新增 <code>goroutineleak</code> pprof 分析类型</td><td>默认关闭,评估中(自 <a href="https://go.dev/doc/go1.26#goroutineleak-profiles">Go 1.26</a> 起可用)</td></tr><tr><td><code>SIMD</code></td><td>新的 <code>simd/archsimd</code> 包,提供对架构特定 SIMD 操作的访问;仅支持 amd64</td><td>默认关闭,评估中(自 <a href="https://go.dev/doc/go1.26#simd">Go 1.26</a> 起可用)</td></tr><tr><td><code>RuntimeFreegc</code></td><td>在安全时允许立即重用内存而无需等待 GC 周期</td><td>默认关闭,评估中(自 Go 1.26 起可用,但状态信息见 <a href="https://github.com/golang/go/issues/74299">#74299</a>)</td></tr><tr><td><code>SizeSpecializedMalloc</code></td><td>启用按大小类别特化的 malloc 实现</td><td>默认关闭,评估中(自 Go 1.26 起可用,但状态信息见 <a href="https://github.com/golang/go/issues/74299">#74299</a>)</td></tr></tbody></table><p>以下是当前默认开启的实验特性:</p><table><thead><tr><th>实验名称</th><th>描述</th><th>状态</th></tr></thead><tbody><tr><td><code>LoopVar</code></td><td>每次迭代独立的<a href="https://go.dev/wiki/LoopvarExperiment">循环变量作用域</a></td><td>自 <a href="https://go.dev/doc/go1.22">Go 1.22</a> 起默认开启,但为边缘情况保留了退出选项</td></tr><tr><td><code>Dwarf5</code></td><td>DWARF 5 调试信息生成;减小二进制文件大小</td><td>默认开启,保留临时退出选项(退出选项<a href="https://go.dev/doc/go1.25#dwarf5-support">可能在未来的版本中被移除</a>)</td></tr><tr><td><code>RandomizedHeapBase64</code></td><td>在启动时随机化堆基址,作为安全措施</td><td>默认开启,保留临时退出选项(退出选项<a href="https://go.dev/doc/go1.26#heap-base-address-randomization">预计将在未来版本中移除</a>)</td></tr><tr><td><code>GreenTeaGC</code></td><td>新的垃圾回收器,具有更好的性能;在 darwin/ios/aix 上不可用</td><td>默认开启,保留临时退出选项(退出选项<a href="https://go.dev/doc/go1.26#new-garbage-collector">预计将在 Go 1.27 中移除</a>)</td></tr><tr><td><code>RegabiWrappers</code></td><td>用于在 ABI0 和 ABIInternal 函数之间调用的 ABI 包装器;仅支持 64 位架构</td><td>默认开启,保留临时退出选项,但退出选项仅对 s390x 有效,且<a href="https://github.com/golang/go/commit/6da07b9b44d2ae08921cb97900f076c96a7bf6fc">将在 Go 1.27 中移除</a></td></tr><tr><td><code>RegabiArgs</code></td><td>在所有编译的 Go 函数中启用寄存器参数/返回值;仅支持 64 位架构</td><td>默认开启,保留临时退出选项,但退出选项仅对 s390x 有效,且<a href="https://github.com/golang/go/commit/6da07b9b44d2ae08921cb97900f076c96a7bf6fc">将在 Go 1.27 中移除</a></td></tr></tbody></table><h2 id="如何启用和禁用实验特性?"><a href="#如何启用和禁用实验特性?" class="headerlink" title="如何启用和禁用实验特性?"></a>如何启用和禁用实验特性?</h2><p>实验特性通过 <code>GOEXPERIMENT</code> 环境设置来控制。</p><p>如果你想尝试某些默认关闭的实验特性,应将实验名称作为逗号分隔的<strong>小写</strong>值包含在 <code>GOEXPERIMENT</code> 中。例如,如果你想在构建应用程序时启用 <code>JSONv2</code> 和 <code>GoroutineLeakProfile</code> 实验,可以这样做:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ GOEXPERIMENT=jsonv2,goroutineleakprofile go build ./...</span><br></pre></td></tr></table></figure><p>如果你想关闭某个默认开启的实验特性,可以在小写实验名称前加上 <code>no</code> 前缀。例如,如果你想在构建应用程序时关闭 <code>GreenTeaGC</code> 和 <code>RandomizedHeapBase64</code> 实验,可以这样做:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ GOEXPERIMENT=nogreenteagc,norandomizedheapbase64 go build ./...</span><br></pre></td></tr></table></figure><p>混合启用和禁用的实验也完全没有问题:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">$ GOEXPERIMENT=jsonv2,nogreenteagc go build ./...</span><br></pre></td></tr></table></figure><p>注意,如果你使用不同的 <code>GOEXPERIMENT</code> 值构建同一个包,Go 会将它们视为不同的构建,并在构建缓存中存储独立的条目。</p><p>我在上面的示例中使用了 <code>go build</code>,但你在使用 <code>go run</code> 或 <code>go test</code> 时也可以使用完全相同的模式。如果你想亲自尝试,试着创建以下使用实验性 <a href="https://pkg.go.dev/encoding/json/v2"><code>encoding/json/v2</code></a> 包的程序:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">package main</span><br><span class="line"></span><br><span class="line">import (</span><br><span class="line"> "encoding/json/v2"</span><br><span class="line"> "fmt"</span><br><span class="line">)</span><br><span class="line"></span><br><span class="line">type Person struct {</span><br><span class="line"> Name string `json:"name"`</span><br><span class="line"> Age int `json:"age"`</span><br><span class="line"> City string `json:"city"`</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">func main() {</span><br><span class="line"> p := Person{Name: "Ada", Age: 36, City: "Vienna"}</span><br><span class="line"></span><br><span class="line"> data, _ := json.Marshal(p, json.StringifyNumbers(true))</span><br><span class="line"> fmt.Println(string(data))</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>如果你正常运行此程序,它将无法编译,你会看到类似以下的错误消息:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">$ go run main.go</span><br><span class="line">package command-line-arguments</span><br><span class="line"> imports encoding/json/v2: build constraints exclude all Go files in /usr/local/go/src/encoding/json/v2</span><br></pre></td></tr></table></figure><p>但如果你启用了 <code>JSONv2</code> 实验,程序将按预期运行:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">$ GOEXPERIMENT=jsonv2 go run main.go</span><br><span class="line">{"name":"Ada","age":"36","city":"Vienna"}</span><br></pre></td></tr></table></figure><h2 id="你应该关注哪些实验特性?"><a href="#你应该关注哪些实验特性?" class="headerlink" title="你应该关注哪些实验特性?"></a>你应该关注哪些实验特性?</h2><p>如果你像我一样是个普通的 Gopher,主要用 Go 编写程序而非开发 Go 本身,那么大多数可用的实验特性可能与你关系不大。</p><p>最有意思、最相关的几个可能是:</p><ul><li><a href="https://go.dev/blog/greenteagc"><code>GreenTeaGC</code></a> —— 如果你在使用 Go 1.26,你已经在默认使用它了。但如果你注意到任何性能或行为问题,你应该知道自己仍然可以禁用它(并且还应该提交一个 issue)。</li><li><a href="https://go.dev/doc/go1.25#dwarf5-support"><code>Dwarf5</code></a> —— 同样,如果你在使用 Go 1.25 或更高版本,你已经在默认使用它了。但如果你遇到任何问题,知道自己仍然可以禁用它是有用的。</li><li><a href="https://go.dev/doc/go1.25#json_v2"><code>JSONv2</code></a> —— 我不建议在它进入通用可用阶段之前切换,但如果你编写大量处理 JSON 的代码,值得尝试新的 <code>encoding/json/v2</code> 包,熟悉即将到来的内容,并在发现问题时给出反馈。</li><li><a href="https://go.dev/doc/go1.26#goroutineleak-profiles"><code>GoroutineLeakProfile</code></a> —— 这个特性可以立即派上用场,如果你怀疑有 goroutine 泄漏并需要调试,值得启用。</li><li><a href="https://go.dev/doc/go1.26#new-experimental-runtimesecret-package"><code>RuntimeSecret</code></a> —— 如果你编写加密代码或需要处理敏感数据,值得尝试并给出反馈。</li><li><a href="https://github.com/golang/go/issues/74299"><code>RuntimeFreegc</code></a> —— 如果你的应用程序严重依赖垃圾回收器,可能值得在启用此特性的情况下对你的代码进行基准测试,看看是否提高了性能,并在发现问题时给出反馈。</li></ul><p>最后,需要强调的是,实验特性不受 Go 兼容性承诺的覆盖。它们的 API、行为和性能特征都可能发生变化,因此通常最好避免过早采用,不要在实验特性最终确定之前依赖它们。</p><p>但实验特性通常是对 Go 中一些最重大变更的预览。如果你知道某个实验最终进入通用可用阶段并默认开启后可能会影响你或你的代码,那么尝试它、酌情运行基准测试、并在发现问题时给出反馈是个好主意。</p><p>如果你想追踪有哪些可用的实验特性及其状态,Go 发布说明最近在记录实验特性及其使用方式方面做得越来越好了。结合这篇博客文章和每次 Go 新版本发布时浏览发布说明,你应该能对整体情况有一个不错的了解。</p><hr><p><strong>原文来源</strong>:Alex Edwards, "Go Experiments Explained", June 1, 2026, <a href="https://www.alexedwards.net/blog/go-experiments-explained">https://www.alexedwards.net/blog/go-experiments-explained</a></p>
2026-06-21 09:38:49
<blockquote><p>在 Go 1.17 之前,Go 编译器总是生成可由任何 64 位 x86 处理器执行的 x86 二进制文件。<br>Go 1.18 为 AMD64 引入了 <a href="https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels">4 个架构级别</a> 。每个级别在编译器可以包含在生成的二进制文件中的 x86 指令集上有所不同:</p><ul><li>GOAMD64=v1(默认值):基准模式。仅生成所有 64 位 x86 处理器都能执行的指令。</li><li>GOAMD64=v2:所有 v1 指令,加上 CMPXCHG16B、LAHF、SAHF、POPCNT、SSE3、SSE4.1、SSE4.2、SSSE3。</li><li>GOAMD64=v3:所有 v2 指令,加上 AVX、AVX2、BMI1、BMI2、F16C、FMA、LZCNT、MOVBE、OSXSAVE。</li><li>GOAMD64=v4:所有 v3 指令,加上 AVX512F、AVX512BW、AVX512CD、AVX512DQ、AVX512VL。</li></ul><p>例如,设置 GOAMD64=v3 将允许 Go 编译器在生成的二进制文件中使用 AVX2 指令(这在某些情况下可能会提高性能);但是这些二进制文件将无法在不支持 AVX2 的旧 x86 处理器上运行。<br>Go 工具链也可能生成更新的指令,但会通过动态检查来确保它们只在支持的处理器上执行。例如,如果设置了 GOAMD64=v1,并且 CPUID 报告 POPCNT 指令可用,那么 math/bits.OnesCount 仍然会使用该指令。否则,它会回退到通用实现。<br>Go 工具链目前不生成任何 AVX512 指令。<br>不支持 SSE3 的平台不支持种族检测器。</p></blockquote><p>64 位 Intel 和 AMD 处理器已经演进了几十年。当你为 64 位 Intel 或 AMD 处理器编译 Go 程序时,编译器默认面向的是一个将近 20 年前的指令集。生成的二进制文件几乎能在任何 x64 芯片上运行,但同时也放弃了自 2003 年以来添加的所有指令。</p><p>我们通常用<strong>微架构级别</strong>(microarchitecture levels)来描述这一分层。每个级别捆绑了一组可以假定存在的指令集扩展:</p><table><thead><tr><th>级别</th><th>新增内容(大致)</th></tr></thead><tbody><tr><td><strong>v1</strong></td><td>原始 AMD64 基线(SSE2)</td></tr><tr><td><strong>v2</strong></td><td><code>popcnt</code>、SSE4.2</td></tr><tr><td><strong>v3</strong></td><td>AVX2</td></tr><tr><td><strong>v4</strong></td><td>AVX-512(F/BW/DQ/VL)</td></tr></tbody></table><span id="more"></span><p>在我看来,这个阶梯已经略显过时了。它大约在 2020 年定型,而硬件已经向前发展了。我们还需要加入最新的 AVX-512 子扩展(VBMI、VBMI2、VNNI、BF16、FP16、VPOPCNTDQ 等),这些在最新的服务器和消费级芯片上已经支持,但 <code>v4</code> 并未要求。虽然 <code>v1</code> 到 <code>v4</code> 是一种有用的通用语言,但今天一个现实的"用尽该 CPU 提供的一切"目标至少需要一个 <code>v5</code>,而且可以说整个方案应该被更细粒度的特性检测所取代。</p><p>无论如何,Go 工具链通过 <a href="https://go.dev/wiki/MinimumRequirements#amd64"><code>GOAMD64</code></a> 环境变量暴露了这个 <code>v1</code> 到 <code>v4</code> 的阶梯。设置 <code>GOAMD64=v3</code> 告诉编译器可以使用直到 AVX2(含)的所有指令。默认值是 <code>v1</code>,即最低公共分母。</p><p>这就引出了一个显而易见的问题:如果我拿一个真实的、对性能敏感的库,在每个级别上重新编译,实际能获得多大收益?我选择了 <a href="https://github.com/RoaringBitmap/roaring">Roaring Bitmaps</a>,它是一种用于数据库和搜索引擎的压缩位集数据结构。</p><p>Roaring Bitmap 存储一组 32 位整数。它将 32 位空间按高 16 位划分为每块 65,536 个值的 chunk,每个 chunk 存储在一个仅保存低 16 位的<strong>容器</strong>(container)中。容器有三种形式,库始终保留最小的那种:</p><ul><li><strong>数组容器(array container)</strong>:一个已排序的 16 位值列表,当 chunk 稀疏时使用(最多几千个元素);</li><li><strong>位图容器(bitmap container)</strong>:一个扁平的 8 KB 位向量(65,536 位,每个可能值对应一位),当 chunk 密集时使用;</li><li><strong>run 容器(run container)</strong>:一个 <code>[start, length]</code> 区间列表,当集合中的位聚集成连续区间时使用。简单说就是把连续的一大段 1 压缩成"从哪开始、有多长"——比如"第 3 到第 100 位全是 1"只记成 <code>[3, 98]</code>,而不是逐位存 98 个 1。</li></ul><p>我拉取了该库的最新版本,然后用它自带的基准测试套件运行了四次,每次一个级别,每个级别采集八个样本。我在一台 Intel Xeon Gold 6548N(Emerald Rapids,支持全部四个级别,包括 AVX-512)上完成测试,使用 Go 1.26.2 和 Roaring v2.18.2。</p><p><strong>种群计数</strong>(population count,或称 popcount,也叫汉明重量)简单来说就是一个机器字中被置为 1 的位数。Roaring 大量依赖它:位图容器的基数——它持有多少个值——是其 1024 个 64 位字的种群计数之和。现代 x86 芯片有专门的 <code>popcnt</code> 指令可以在单次操作中完成这一计算,但该指令只在 <code>v2</code> 级别(SSE4.2,2008 年)才可用。没有它,编译器只能退回到多指令的位操作序列。</p><p>最清晰的单一结果是种群计数:计算位图容器中置位比特的数量。<code>v1</code> 基线无法使用 <code>popcnt</code> 指令,因此 Go 生成的是软件回退实现。一旦我们移到 <code>v2</code>,<code>popcnt</code> 变得可用,耗时几乎减半:</p><p><img src="https://lemire.me/blog/wp-content/uploads/2026/06/popcount_levels.svg"></p><p>这是 43% 的减少,而且是免费的:无需改动源码,只需一个编译器标志。不过请注意,<code>v3</code> 和 <code>v4</code> 没有进一步改善。单条 <code>popcnt</code> 指令已经是最优的了;就 Go 编译器而言,AVX2 和 AVX-512 在此没有可添加的东西。</p><p>种群计数是容易获得的胜利。库中其他部分呢?</p><p>另一个明显的胜利是从密集位图构建容器。<code>FromDense array</code> 基准测试接收一个原始的 8 KB 位向量,并为其构造最紧凑的容器:它对每个字做 popcount 以获知基数,然后扫描出所有置位比特的位置。这种逐字的 popcount-扫描 循环正是编译器在 256 位寄存器可用后可以自动向量化的模式,因此收益在 <code>v2</code> 之后继续增长:</p><p><img src="https://lemire.me/blog/wp-content/uploads/2026/06/fromdense_levels.svg"></p><p><code>v2</code> 通过使用标量 <code>popcnt</code>/<code>tzcnt</code> 指令已减少了 21%,而 <code>v3</code>(AVX2)几乎将其翻倍,达到 38% 的减少。与种群计数一样,<code>v4</code> 没有任何增益。</p><p>集合操作也呈现出相同的模式。<code>IntersectionCardinality</code> 基准测试计算两个位图共有多少个值:对于位图容器,它将字逐对做 AND 操作,然后对结果做种群计数,而不用物化出交集。在这里 <code>v2</code> 基本没有作用(标量 <code>popcnt</code> 已经在内部循环中了),但 <code>v3</code> 让编译器将 AND-计数 循环扩展到 256 位寄存器,将耗时减少了 22%:</p><p><img src="https://lemire.me/blog/wp-content/uploads/2026/06/intersectcard_levels.svg"></p><p>要点总结:</p><ol><li>在现代硬件上,每个人都应该使用 <code>v2</code> 或更高版本。生成的二进制文件可以在任何数据中心和任何非古董笔记本电脑上运行。</li><li><code>v3</code> 级别值得研究。</li><li><code>v4</code> 级别在我的一些基准测试中本应有所帮助,但实际上没有。我怀疑是 Go 编译器在这方面还不够好。</li></ol><p>(显然:请运行你自己的基准测试。)</p><hr><p><strong>原文来源</strong>:Daniel Lemire, "How much do amd64 microarchitecture levels help in Go?," in <em>Daniel Lemire's blog</em>, June 6, 2026, <a href="https://lemire.me/blog/2026/06/06/how-much-do-amd64-microarchitecture-levels-help-in-go/">https://lemire.me/blog/2026/06/06/how-much-do-amd64-microarchitecture-levels-help-in-go/</a></p><hr><h2 id="精选评论"><a href="#精选评论" class="headerlink" title="精选评论"></a>精选评论</h2><p><strong>Xarn</strong>(2026年6月8日):</p><blockquote><p>关于 v4 没有任何作用,来自他们的文档:</p><blockquote><p>Go 工具链目前不生成任何 AVX512 指令。</p></blockquote></blockquote><p><strong>Marco</strong>(2026年6月9日):</p><blockquote><p>我自己的使用 AVX512 指令的经验是,使用它们的逻辑(和数据结构)与常规使用的结构差异如此之大,编译器不太可能翻译并非专门为它们构建的代码。问题是编程语言何时会创建易于使用的语法来方便地使用这些扩展。</p></blockquote>
2026-06-17 04:00:24
<p>用 Go 写 RDMA,到底能有多简单?又能有多快?这篇带你从零跑到 400 Gb/s。</p><h2 id="开篇:一个让人又爱又怕的技术"><a href="#开篇:一个让人又爱又怕的技术" class="headerlink" title="开篇:一个让人又爱又怕的技术"></a>开篇:一个让人又爱又怕的技术</h2><p>如果你做过高性能网络,一定听过 <strong>RDMA</strong> 这个词。它是 AI 训练集群里 GPU 之间狂飙数据的底层、是分布式存储压榨延迟的杀手锏、是金融交易系统微秒必争的武器。</p><img src="/2026/06/17/rdma-high-performance-networking-400gbps/image-20260616064940096.png" class=""><h3 id="两种传输-两种操作"><a href="#两种传输-两种操作" class="headerlink" title="两种传输 & 两种操作"></a>两种传输 & 两种操作</h3><ul><li><p><strong>RC</strong>(可靠连接,类比 TCP):有序可靠,支持双边和单边操作</p></li><li><p><strong>UD</strong>(不可靠数据报,类比 UDP):无连接,一对多</p></li><li><p><strong>双边操作</strong>(Send/Recv):接收方要先挂好接收请求,双方 CPU 都参与</p></li><li><p><strong>单边操作</strong>(RDMA Write/Read):发起方直接读写对端内存,<strong>对端 CPU 完全不参与</strong>——这是 RDMA 最"魔法"的地方</p></li></ul><span id="more"></span><h2 id="二-·-先用-perftest-摸清家底"><a href="#二-·-先用-perftest-摸清家底" class="headerlink" title="二 · 先用 perftest 摸清家底"></a>二 · 先用 perftest 摸清家底</h2><p>在写代码之前,先得知道你的网卡能跑多快。业界标准是 <strong>perftest</strong>(linux-rdma 出品的 C 版基准工具)。gordma 贴心地用 Go 复刻了一套对标工具,放在 <code>cmd/</code> 下:</p><table><thead><tr><th>工具</th><th>对标</th><th>测什么</th></tr></thead><tbody><tr><td><code>go_send_bw / lat</code></td><td><code>ib_send_bw/lat</code></td><td>双边 Send 的带宽 / 延迟</td></tr><tr><td><code>go_write_bw / lat</code></td><td><code>ib_write_bw/lat</code></td><td>单边 Write</td></tr><tr><td><code>go_read_bw / lat</code></td><td><code>ib_read_bw/lat</code></td><td>单边 Read</td></tr><tr><td><code>go_rdmanet_bw / lat</code></td><td>—(高级)</td><td>测 gordma 高级 API</td></tr></tbody></table><p>命名规律很简单:<strong>操作(send/write/read) + 指标(bw 带宽 / lat 延迟)</strong>。每个工具<strong>不带地址就是服务端,带对端地址就是客户端</strong>。</p><p>跑一把带宽测试:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">go build -o bin/ ./cmd/...</span><br><span class="line"></span><br><span class="line"><span class="comment"># 服务端(不带地址)</span></span><br><span class="line">./bin/go_send_bw -s 65536 -n 1000000 -d mlx5_1 -x 3</span><br><span class="line"></span><br><span class="line"><span class="comment"># 客户端(带服务端 IP)</span></span><br><span class="line">./bin/go_send_bw -s 65536 -n 1000000 -d mlx5_1 -x 3 33.0.226.25:18515</span><br></pre></td></tr></table></figure><p>输出:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">#bytes #iterations BW average[MB/s] MsgRate[Mpps]</span><br><span class="line">65536 1000000 48996.54 0.747628</span><br></pre></td></tr></table></figure><p><strong>48996 MB/s ≈ 392 Gb/s</strong>(注意单位:<code>go_send_bw</code> 输出的是 <strong>MB/s</strong>=10⁶ 字节/秒,×8÷1000 才是 Gb/s),这就是这张 400G 网卡的实力基准。记住这个数,后面要拿它当标尺。</p><blockquote><p>⚠️ <strong>单位是个大坑</strong>:三个常用工具输出单位<strong>各不相同</strong>,直接比原始数会差出 8 倍——C 版 <code>ib_send_bw</code> 是 <strong>MiB/s</strong>(2²⁰ 字节)、Go 版 <code>go_send_bw</code> 是 <strong>MB/s</strong>(10⁶ 字节)、gordma 的 <code>--raw</code> 是 <strong>MiB/s</strong>(已对齐 C 版)。本文所有数字都统一换算到 <strong>Gb/s</strong>(10⁹ bit) 再比较。</p></blockquote><blockquote><p>💡 小贴士:命令里的 IP 是服务端 <strong><code>-d</code> 指定的那张 RoCE 网卡</strong>绑定的 IP,<strong>不是CPU网络/SSH 那个 IP</strong>。这是新手最容易连不上的坑。两端的 <code>-d</code>(设备)和 <code>-x</code>(GID 索引,RoCE v2 常用 3, 可以使用show_gids查看)要对齐同一张物理网络。</p></blockquote><hr><h2 id="三-·-底层-API-完全掌控-但要写够样板"><a href="#三-·-底层-API-完全掌控-但要写够样板" class="headerlink" title="三 · 底层 API:完全掌控,但要写够样板"></a>三 · 底层 API:完全掌控,但要写够样板</h2><p>gordma 的底层包 <code>gordma</code> 一比一映射了 RDMA 的对象模型。想要完全掌控每个工作请求、每个 QP 参数,用它。代价是:你得自己走完那七步。</p><img src="/2026/06/17/rdma-high-performance-networking-400gbps/image-20260616065212884.png" class=""><p>来看一个<strong>完整可跑</strong>的 RC 回显(用 rdma_cm 建连,省掉手写状态机):</p><p><strong>服务端:收一条,回显</strong></p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">server</span><span class="params">(addr <span class="type">string</span>)</span></span> <span class="type">error</span> {</span><br><span class="line"> ln, _ := gordma.Listen(addr) <span class="comment">// rdma_cm 监听</span></span><br><span class="line"> <span class="keyword">defer</span> ln.Close()</span><br><span class="line"> cm, _ := ln.Accept() <span class="comment">// QP 已在 RTS 状态</span></span><br><span class="line"> <span class="keyword">defer</span> cm.Close()</span><br><span class="line"> qp, cq, pd := cm.QP(), cm.CQ(), cm.PD()</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 注册接收缓冲区——网卡只能 DMA 已注册内存</span></span><br><span class="line"> mr, _ := pd.RegMRBuffer(<span class="number">4096</span>, gordma.AccessLocalWrite)</span><br><span class="line"> <span class="keyword">defer</span> mr.Close()</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 收之前必须先挂 recv,否则对端发来会 RNR</span></span><br><span class="line"> sge := gordma.SGEFromMR(mr, <span class="number">0</span>, <span class="number">4096</span>)</span><br><span class="line"> qp.PostRecv(gordma.RecvWR{WRID: <span class="number">1</span>, SGList: []gordma.SGE{sge}})</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 轮询完成队列</span></span><br><span class="line"> wc := <span class="built_in">make</span>([]gordma.WorkCompletion, <span class="number">1</span>)</span><br><span class="line"> pollOne(cq, wc)</span><br><span class="line"> msg := mr.Bytes()[:wc[<span class="number">0</span>].ByteLen]</span><br><span class="line"> fmt.Printf(<span class="string">"got %q\n"</span>, msg)</span><br><span class="line"></span><br><span class="line"> <span class="comment">// 原样发回</span></span><br><span class="line"> <span class="built_in">copy</span>(mr.Bytes(), msg)</span><br><span class="line"> qp.PostSend(gordma.SendWR{</span><br><span class="line"> WRID: <span class="number">2</span>, Opcode: gordma.OpSend,</span><br><span class="line"> SGList: []gordma.SGE{gordma.SGEFromMR(mr, <span class="number">0</span>, <span class="built_in">len</span>(msg))},</span><br><span class="line"> Signaled: <span class="literal">true</span>,</span><br><span class="line"> })</span><br><span class="line"> pollOne(cq, wc) <span class="comment">// 等发送完成</span></span><br><span class="line"> <span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="comment">// 忙轮询 CQ 直到取到一个完成</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">pollOne</span><span class="params">(cq *gordma.CQ, wc []gordma.WorkCompletion)</span></span> {</span><br><span class="line"> <span class="keyword">for</span> {</span><br><span class="line"> <span class="keyword">if</span> n, err := cq.Poll(wc); err != <span class="literal">nil</span> || n > <span class="number">0</span> {</span><br><span class="line"> <span class="keyword">return</span></span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p>每一行都对应一个 RDMA 概念:<strong>注册内存 → 先挂 recv → 轮询 CQ → post send</strong>。底层 API 的好处是<strong>没有任何隐藏行为</strong>,你能做单边 Write/Read、能精调 QP 容量、能复刻 perftest——坏处是,样板真的多。</p><hr><h2 id="四-·-高级-API-像写-net-一样写-RDMA"><a href="#四-·-高级-API-像写-net-一样写-RDMA" class="headerlink" title="四 · 高级 API:像写 net 一样写 RDMA"></a>四 · 高级 API:像写 net 一样写 RDMA</h2><p>如果你只是想<strong>写业务</strong>,不想碰 MR、WR、CQ 这些——用 <code>rdmanet</code> 子包。它把上面那一大坨全收进了 <code>Dial / Listen / SendMsg / RecvMsg</code>。</p><img src="/2026/06/17/rdma-high-performance-networking-400gbps/image-20260616072533414.png" class=""><p>来看同样的事,高级怎么写。一个 <strong>RPC 服务</strong>:</p><p><strong>服务端</strong></p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">serve</span><span class="params">(addr <span class="type">string</span>, opts []rdmanet.Option)</span></span> <span class="type">error</span> {</span><br><span class="line"> ln, err := rdmanet.Listen(addr, opts...)</span><br><span class="line"> <span class="keyword">if</span> err != <span class="literal">nil</span> { <span class="keyword">return</span> err }</span><br><span class="line"> <span class="keyword">defer</span> ln.Close()</span><br><span class="line"> <span class="keyword">for</span> {</span><br><span class="line"> conn, err := ln.Accept()</span><br><span class="line"> <span class="keyword">if</span> err != <span class="literal">nil</span> { <span class="keyword">return</span> err }</span><br><span class="line"> <span class="keyword">go</span> handle(conn) <span class="comment">// 每个连接一个 goroutine</span></span><br><span class="line"> }</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">handle</span><span class="params">(conn *rdmanet.Conn)</span></span> {</span><br><span class="line"> <span class="keyword">defer</span> conn.Close()</span><br><span class="line"> <span class="keyword">for</span> {</span><br><span class="line"> req, err := conn.RecvMsg() <span class="comment">// 收一条完整请求</span></span><br><span class="line"> <span class="keyword">if</span> err == io.EOF { <span class="keyword">return</span> } <span class="comment">// 客户端关闭,正常结束</span></span><br><span class="line"> <span class="keyword">if</span> err != <span class="literal">nil</span> { <span class="keyword">return</span> }</span><br><span class="line"> conn.SendMsg(process(req)) <span class="comment">// 处理并回复</span></span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure><p><strong>客户端</strong></p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">conn, _ := rdmanet.Dial(<span class="string">"33.0.226.25:18515"</span>,</span><br><span class="line"> rdmanet.WithDevice(<span class="string">"mlx5_1"</span>), rdmanet.WithGIDIndex(<span class="number">3</span>))</span><br><span class="line"><span class="keyword">defer</span> conn.Close()</span><br><span class="line">conn.SendMsg([]<span class="type">byte</span>(<span class="string">"hello"</span>))</span><br><span class="line">reply, _ := conn.RecvMsg() <span class="comment">// 阻塞等响应</span></span><br></pre></td></tr></table></figure><p><strong>没有 MR、没有 WR、没有 CQ 轮询、没有状态机。</strong> 是不是和标准库 <code>net</code> 一模一样?</p><p><code>rdmanet</code> 还提供了一整套实用能力:</p><ul><li><strong>消息语义</strong> <code>SendMsg</code>/<code>RecvMsg</code>:保留边界,大消息自动分片重组</li><li><strong>字节流适配器</strong> <code>Read</code>/<code>Write</code>:<code>Conn</code> 直接满足 <code>io.ReadWriteCloser</code>,能配 <code>io.Copy</code> 传文件</li><li><strong>批量 I/O</strong> <code>SendBatch</code>/<code>RecvBatch</code>:摊薄每次调用开销</li><li><strong>UD 数据报</strong> <code>PacketConn</code>:无连接、一对多</li><li><strong>地址注册表</strong> <code>Registry</code>:带外发现对端</li></ul><p>仓库里还附带了 <strong>17 个按功能拆分的示例</strong>(<code>examples/</code> 目录),从最小回显到全双工聊天、文件传输、一对多广播,一个功能一个目录,照着抄就行。</p><hr><h2 id="五-·-RawConn-既要-net-风格-又要榨干网卡"><a href="#五-·-RawConn-既要-net-风格-又要榨干网卡" class="headerlink" title="五 · RawConn:既要 net 风格,又要榨干网卡"></a>五 · RawConn:既要 net 风格,又要榨干网卡</h2><p>高级 <code>Conn</code> 好用,但有个问题:它为了"保留消息边界 + 流控 + 易用"付出了固定成本——封帧、信用流控、bounce 缓冲拷贝、后台 poller goroutine 的跨线程交接。这些叠加起来,让它在 64KB 大包上<strong>只能跑到约 28 Gb/s</strong>,远没喂满 400G 网卡。</p><p>于是 gordma 给了第三个选择:<strong><code>RawConn</code></strong>。</p><img src="/2026/06/17/rdma-high-performance-networking-400gbps/image-20260616065325484.png" class=""><p>它的理念很直接:<strong>把所有花哨的东西全剥掉</strong>,直接暴露"注册内存 + 投递 WR + 自己轮询 CQ",在同一个 goroutine 里 post + busy-poll,无封帧、无流控、无交接。这正是 perftest 打满线速的那套循环。</p><p>最省事的用法是内置的 <code>PipelineBatch</code>,保持 N 个请求 <strong>in-flight</strong>(同时在网卡里跑),每完成一个补一个:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line">rc, _ := rdmanet.DialRaw(addr,</span><br><span class="line"> rdmanet.WithDevice(<span class="string">"mlx5_1"</span>),</span><br><span class="line"> rdmanet.WithGIDIndex(<span class="number">3</span>),</span><br><span class="line"> rdmanet.WithQueueDepth(<span class="number">128</span>))</span><br><span class="line"><span class="keyword">defer</span> rc.Close()</span><br><span class="line"></span><br><span class="line">mr, _ := rc.RegisterMemory(size * txDepth)</span><br><span class="line"><span class="keyword">defer</span> mr.Close()</span><br><span class="line"></span><br><span class="line">rc.PipelineBatch(iters, txDepth, <span class="function"><span class="keyword">func</span><span class="params">(wrID <span class="type">uint64</span>)</span></span> gordma.SendWR {</span><br><span class="line"> slot := <span class="type">int</span>(wrID) % txDepth</span><br><span class="line"> <span class="keyword">return</span> gordma.SendWR{</span><br><span class="line"> WRID: wrID,</span><br><span class="line"> Opcode: gordma.OpSend,</span><br><span class="line"> SGList: []gordma.SGE{gordma.SGEFromMR(mr, slot*size, size)},</span><br><span class="line"> Signaled: <span class="literal">true</span>,</span><br><span class="line"> }</span><br><span class="line">})</span><br></pre></td></tr></table></figure><p><code>RawConn</code> 还支持:</p><ul><li><strong>单边 Write/Read</strong>:走 TCP 握手交换了对端 rkey/地址,可以直接做"对端 CPU 不参与"的远程读写</li><li><strong>批量提交 <code>PostSendBatch</code></strong>:用 WR 链表一次 <code>ibv_post_send</code> 提交多个请求,把 cgo 跨界开销从"每个 WR 一次"降到"每批一次",小包消息率因此能提升约一个数量级</li><li><strong>逃生舱</strong> <code>QP()</code>/<code>CQ()</code>/<code>PD()</code>:需要时随时下沉到底层自己驱动</li></ul><p>代价当然有:<code>RawConn</code> <strong>不替你保留消息边界、不做流控(得自己控制 in-flight 数,否则 RNR)、不托管缓冲区</strong>。一句话:<strong>先用 <code>Conn</code>,确实要榨干网卡时再上 <code>RawConn</code></strong>。</p><blockquote><p>🔬 <strong>顺带破一个误解</strong>:很多人(包括我一开始)以为"Go 经 cgo 调 RDMA 一定比 C 慢一截"。我用 <code>GORDMA_PROBE=1</code> 把发送循环拆成"提交 WR(post)"和"忙等完成(poll)"两段实测,结论反直觉:<strong>一次 <code>ibv_post_send</code> 含 cgo 跨界约 300ns,只占总时间 ~15%,而且 <code>go_send_bw</code> 和 <code>RawConn</code> 两者完全相同</strong>。也就是说——cgo 提交开销真实存在但很小,<strong>不是</strong>性能差距的主因。后面第六节会看到,<code>go_send_bw</code> 状态好时能直接追平 C 版 <code>ib_send_bw</code>,根本没有"Go 追不上 C"的固有差距。</p></blockquote><hr><h2 id="六-·-真刀真枪-带宽压测对比"><a href="#六-·-真刀真枪-带宽压测对比" class="headerlink" title="六 · 真刀真枪:带宽压测对比"></a>六 · 真刀真枪:带宽压测对比</h2><p>理论讲完,上数据。同一对 400G RoCE v2 节点,64KB 大包,100 万条消息,实测:</p><img src="/2026/06/17/rdma-high-performance-networking-400gbps/image-20260616070038206.png" class=""><p><strong>结论很清楚:</strong></p><ul><li>从 <code>Conn</code> 到 <code>RawConn</code>,同一个库、同一张卡,吞吐 <strong>暴涨约 8 倍</strong>,证明那 28 Gb/s 的天花板就是高级那套便利机制的固定成本。</li><li><code>RawConn</code> 用纯 Go(加薄薄一层 cgo)把吞吐推到了 <strong>230+ Gb/s 的量级</strong>,已经和同一个库的底层 <code>go_send_bw</code> 在同一个数量级。</li></ul><h3 id="一个反直觉的发现-差距不在-cgo-而且不是固定的"><a href="#一个反直觉的发现-差距不在-cgo-而且不是固定的" class="headerlink" title="一个反直觉的发现:差距不在 cgo,而且不是固定的"></a>一个反直觉的发现:差距不在 cgo,而且不是固定的</h3><p>我原本想搞清"<code>RawConn</code>(232) 为什么比 <code>go_send_bw</code>(392) 慢约 1.7 倍",于是做了一组<strong>同机、同口径、交替跑</strong>的实验(锁核 <code>taskset</code> + 性能调频,尽量压住抖动),用 <code>GORDMA_PROBE=1</code> 拆出 post/poll。结果挖出三件事:</p><p><strong>① cgo 提交不是瓶颈。</strong> 两个工具的 post(提交 WR)都是 <strong>~300 ns/WR、占比 ~15%,完全相同</strong>。所谓"每个 WR 一次 cgo 跨界拖慢了 Go",在这个负载上<strong>站不住</strong>——提交很便宜,而且两边一样便宜。</p><p><strong>② Go 能追平 C。</strong> 锁核后 <code>go_send_bw</code> 实测峰值 <strong>0.748 Mpps,和 C 版 <code>ib_send_bw</code> 完全一致</strong>。早先看到的"go_send_bw 只有 ~314 Gb/s"是机器状态差时的数,不是 cgo 的锅。</p><p><strong>③ 差距是"可变"的,不是固定缺陷。</strong> 交替跑 3 轮,<code>go_send_bw</code> 在 <strong>0.414 / 0.748 / 0.414 Mpps</strong> 之间<strong>离散双峰跳变</strong>,而 <code>RawConn</code> 稳定在 <del>0.42。也就是说:<code>go_send_bw</code> 状态差的那几轮,<strong>和 RawConn 几乎持平</strong>;两者差距在 **1.05×</del>1.76× 之间晃**,取决于那一轮 <code>go_send_bw</code> 能不能抢到干净的网卡/CPU 窗口。</p><p>差距的真正位置在 <strong>poll(忙等完成到达)</strong>:<code>go_send_bw</code> 的 poll 在 0.75~1.33 µs/WR 间大幅波动(状态好就打满线速),<code>RawConn</code> 则被稳定压在 ~1.40 µs。考虑到这是一台<strong>共享 GPU 机、400G 链路被其他租户竞争</strong>,最合理的解释是<strong>环境竞争</strong>,而非 RawConn 有独立的代码缺陷——两个工具走的是同一套 QP 建立和 CQ 轮询路径,逐行核对没有能让 RawConn 单独变慢的差异。</p><blockquote><p>🧭 <strong>给读者的实用结论</strong>:① 不要迷信"Go+cgo 必慢于 C",在大包带宽场景两者能打平;② cgo 的固定开销真实但小,真正要省它得靠<strong>批量提交 + 忙轮询</strong>(见下文小包测试);③ 想认真比性能,务必<strong>锁核、独占机器、多次取中位数</strong>,共享机上的单次数字会骗你。</p></blockquote><h3 id="小包更能看出批量提交的威力"><a href="#小包更能看出批量提交的威力" class="headerlink" title="小包更能看出批量提交的威力"></a>小包更能看出批量提交的威力</h3><p>64KB 大包很容易撞带宽上限,看不出 CPU 侧的优化。换成 1KB 小包(消息率受限场景):</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">./bin/go_rdmanet_bw --raw -s 1024 -n 5000000 -d mlx5_1 -x 3 -b 128 33.0.226.25:18515</span><br></pre></td></tr></table></figure><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">raw-batch Send(txDepth=128): 5000000 x 1024 bytes in 0.85s: 47.92 Gb/s, 5.850 Mpps</span><br></pre></td></tr></table></figure><p><strong>5.85 Mpps</strong>——批量提交(<code>PostSendBatch</code>)在小包上把消息率拉高了一个数量级。这正是榨干高频小消息场景的关键。</p><hr><h2 id="尾声-三个档位-按需取用"><a href="#尾声-三个档位-按需取用" class="headerlink" title="尾声:三个档位,按需取用"></a>尾声:三个档位,按需取用</h2><p>gordma 最打动我的,是它没有逼你在"易用"和"性能"之间二选一,而是给了一条平滑的升级路径:</p><table><thead><tr><th>你的需求</th><th>用哪个</th><th>心智负担</th></tr></thead><tbody><tr><td>写业务,要 net 风格</td><td><code>rdmanet.Conn</code></td><td>像写 socket,几行搞定</td></tr><tr><td>既要简单又要极限吞吐</td><td><code>rdmanet.RawConn</code></td><td>自己管内存,几十行</td></tr><tr><td>完全掌控每个细节</td><td>底层 <code>gordma</code> 包</td><td>复刻 perftest 的程度</td></tr></tbody></table><p>而且全部代码<strong>在任何平台都能编译</strong>(macOS/Windows 走 stub 桩实现,RDMA 调用优雅返回 <code>ErrNotSupported</code>),只有真正运行时才需要 Linux + RDMA 硬件。这意味着你可以在 MacBook 上写代码、跑单元测试,真要压测时再丢到带卡的机器上,开发体验和门槛都友好得多。</p><p>如果你正在被 RDMA 编程劝退,或者想给你的 Go 服务接上高性能网络,不妨试试 gordma:</p><blockquote><p>🔗 <strong>github.com/smallnest/gordma</strong></p></blockquote><p>从 <code>go run ./examples/echo-msg</code> 跑通第一个 RDMA 程序开始,你会发现——<strong>原来 RDMA 也可以这么"傻瓜"。</strong></p><hr><p><em>本文所有性能数据均为同一对 400G RoCE v2 节点上的实测结果,会随硬件与配置不同而变化。完整教程、API 文档、17 个示例和 8 个压测工具均在仓库中。</em></p>