上海外滩 v2.0 的黄昏全景

封面:v2.0 的默认机位。黄浦江从画面左侧远处流入,在陆家嘴北侧绕出一个大弯,再向右前方流出;四座地标落在弯道内侧的半岛上。

模型:DeepSeek-flash4.1;harness:Claude Code;能力来源:一组对齐 three.js r186 的 threejs-skills。

这篇文章复盘的是:同一个模型,为什么第一版只搭出「像外滩的元素」,第二版才搭出「能读出外滩的城」。下文把两份提示词称为「第一版」「第二版」,把产出称为「场景 v1」「场景 v2」,避免混淆。

引言:我要的是一座城,不是一张图

我想让模型替我搭一个可交互的 3D 场景,而不是生成一张图。

「可交互」三个字抬高了门槛。一张图只需要在某个角度成立;一个场景要在任意角度、任意时间状态下都成立。几何、材质、光照、后处理、镜头、性能,任何一项掉链子,画面都会立刻露馅。

我用了两版提示词。第一版要到了外滩的元素,第二版才要到外滩的空间。两版之间的差别,不是字数,而是先说什么。

先说结论:决定这类场景成败的,不是模型会不会写 Three.js,而是提示词有没有把空间关系写成可执行、可验收的约束。 skill 负责让 API 用对,提示词负责让世界成立。

一、工具链:13 个 skill,各管一段

我用的不是「一个 three.js 提示词」,而是一组按主题拆开的 skill。它们来自 cloudai-x/threejs-skills,在项目里由 skills-lock.json 锁定版本,安装后通过 .claude/skills 指向 .agents/skills 下的实体目录。

这一组 skill 都对齐 three.js r186,一共 13 个:

分组 skill 管什么
场景骨架 threejs-fundamentals、threejs-geometry 渲染器、相机、动画循环、内置几何、顶点缓冲、实例化
表面 threejs-materials、threejs-textures、threejs-shaders PBR 材质、程序化贴图、自定义 shader
光与画面 threejs-lighting、threejs-postprocessing 灯光、阴影、Bloom 与合成链
运动与交互 threejs-animation、threejs-interaction 关键帧、控制器、拾取
资源与性能 threejs-loaders、threejs-asset-pipeline、threejs-performance GLTF、资源管线、draw call、内存
React 集成 threejs-react-three-fiber R3F 写法

每个 skill 不是一段「帮我写代码」的咒语,而是一份可检索的领域知识:当前版本的 API 长什么样、哪些写法已经过时、性能上该注意什么。比如 threejs-performance 会先要求你读 renderer.info,再谈优化;threejs-geometry 会把 InstancedMesh 和合并几何放在同一个语境里讲。

这带来一个很重要的分工:skill 解决「怎么写对」,提示词解决「写什么」。

Claude Code 在这条链路里负责把判断落到文件系统和浏览器里:读项目、跑 npm run dev、看控制台报错、改文件、再验证。模型负责在上下文里做决策,harness 负责让决策真的运行起来。

二、第一版:一份「物件清单」

第一版提示词是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
使用 threejs-skills,创建一个高质量、可交互的 Three.js 3D 城市景观场景。

**场景主题:上海外滩(The Bund, Shanghai)**

要求:
1. 还原上海外滩标志性天际线,包括东方明珠、上海中心大厦、环球金融中心、金茂大厦及黄浦江两岸建筑。
2. 构建具有真实空间层次的 3D 城市环境,建筑使用合理的几何结构、材质和灯光。
3. 设置黄浦江、江面反射、游船、道路及城市灯光。
4. 提供白天、黄昏、夜景三种时间状态切换。
5. 支持鼠标旋转、缩放、平移,以及自动环绕镜头动画。
6. 加入适量动态元素,例如游船移动、灯光变化、江面波纹。
7. 使用 PBR 材质、环境光、阴影和后处理,保证视觉质量及运行性能。
8. 优先使用程序化建模,不依赖付费模型资源;必要时可使用公开授权的 GLTF/GLB 模型。
9. 使用 threejs-skills 中相关 Skill,遵循当前 Three.js API 最佳实践。
10. 最终输出完整可运行的 Web 项目,包含启动说明。

**验收标准:** 场景具有明显的上海外滩辨识度,具有真实的 3D 空间感,交互流畅,支持桌面浏览器运行。

这份提示词写得不算差。它把「一个高质量 Three.js 城市景观」拆成了可以逐条勾选的要求。模型也逐条交付了:四座地标、反射水面、游船、车流、昼夜切换、Bloom、轨道控制器,一个都不缺。

问题出在条目之间没有关系。

河道被实现成 width = 420、length = 7600 的一条直江,水面是一张 PlaneGeometry(430, 7600, 1, 1) 的长方形;两岸是两块 PlaneGeometry(3800, 9000) 的大地,建筑按世界坐标摆上去。默认机位高 285 米,看向 200 米高的目标,视角更接近滨江平视。

于是画面里出现了三个症状:

  1. 没有骨架。 黄浦江是一条直的水带,不是一条河。外滩之所以是外滩,首先因为江水在那里拐了一个弯。
  2. 建筑是孤岛。 每栋楼都按坐标放对了位置,但楼与楼、楼与岸线之间没有生成关系。外滩建筑群像一排摆好的模型,而不是沿着岸线长出来的城市。
  3. 镜头替观众做了选择。 平视镜头能看清单栋楼和滨江步道,但看不到城市形态。读者第一眼看到的是「一排楼」,不是「一座城」。

这不是模型偷懒。提示词只说了「设置黄浦江」,没有说这条江怎么弯、陆家嘴在弯的哪一侧、镜头从哪里看。信息缺失时,模型只能选最省力的补全方式:直线、矩形、平视。它在每个局部都做对了,却在整体上做错了。

三、第二版:把地理写进第一节

第二版提示词换了一个开头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
使用 threejs-skills,创建一个高品质、具有真实地理空间关系的上海外滩 3D 城市景观。

**核心目标:高度还原参考图中的上海黄浦江大尺度 U 形蜿蜒河道,以及陆家嘴被河流环绕的城市空间形态。**

### 1. 地理与空间布局(最重要)
- 黄浦江必须呈现明显、连续、自然的大尺度 U 形弯曲,而不是直线河道或普通弧形滨江。
- 河道从画面左侧远处延伸,向中央区域大幅弯曲,再绕过陆家嘴区域向右侧远处延伸,形成具有包围感的马蹄形水域轮廓。
- 河流两岸自然分布城市建筑、道路、码头和滨江景观。
- 陆家嘴位于 U 形河道内侧的突出城市区域,呈现半岛般的空间视觉效果。
- 河道宽度、弯曲半径、岸线轮廓应具有真实城市地理尺度,避免规则几何弧线。
- 城市建筑沿河岸自然延伸,不能形成孤立的建筑模型集合。

### 2. 标志性建筑
重点还原陆家嘴天际线:
- 东方明珠电视塔
- 上海中心大厦
- 上海环球金融中心
- 金茂大厦

建筑集中分布在河道弯曲区域内侧,保持合理的高度比例、空间距离和地标辨识度。两岸补充密集的城市建筑群。

### 3. 场景与视觉效果
- 黄浦江采用具有真实反射效果的水面材质。
- 加入游船、货轮及其动态航行轨迹。
- 沿岸布置道路、桥梁、码头和城市灯光。
- 使用 PBR 材质、HDR 环境光、阴影和适当的 Bloom 后处理。
- 默认采用日落至蓝调时刻的光照氛围,天空呈现橙粉色与蓝灰色渐变,城市灯光逐渐亮起。

### 4. 镜头与构图
- 使用高空斜俯视的广角摄影机位,参考真实城市航拍照片。
- 镜头高度足够高,确保完整展示黄浦江 U 形弯道及两岸城市空间。
- 河道弯曲轮廓必须成为画面的核心视觉主体。
- 陆家嘴地标建筑群位于画面中央偏后位置。
- 不采用地面平视视角,不要只展示单栋建筑或局部滨江景观。

### 5. 交互与动画
- 支持鼠标旋转、缩放、平移。
- 提供自动环绕镜头展示。
- 支持白天、黄昏、夜景切换。
- 游船沿河道连续移动。
- 江面具有动态波纹和环境反射。

### 6. 技术要求
- 使用 threejs-skills 中相关 Skill。
- 使用 Three.js + WebGL,优先程序化建模。
- 采用合理的几何分段和曲线控制构建自然河岸及河道。
- 使用 CatmullRomCurve3 等曲线技术构建河流中心线和岸线,再生成连续水面网格。
- 建筑、道路、河岸、水面分别建模,保持空间关系一致。
- 优化 Draw Calls、阴影和粒子数量,保证浏览器流畅运行。

**最终验收标准:**
第一眼能够识别出上海陆家嘴和黄浦江;从整体俯视视角能够清晰看到黄浦江环绕陆家嘴的大尺度 U 形蜿蜒轮廓。河流必须具有真实自然的曲折效果,而不是一条直线或简单圆弧。

输出完整可运行的 Web 项目,并提供启动说明。

相比第一版,真正的改动只有四处,但每一处都改变了模型的行为。

第一,把地理放到第一节,并标注「最重要」。 提示词里的顺序就是模型的注意力顺序。第一版把「地标」放在第 1 条,模型就先堆地标,再补河;第二版先定义河,地标只是河湾内侧的产物。

第二,把形容词换成可执行的几何。 「U 形」「从画面左侧远处延伸」「向中央区域大幅弯曲」「绕过陆家嘴区域向右侧远处延伸」「马蹄形」「真实城市地理尺度」——这些描述可以直接变成一条曲线上的控制点和一段宽度函数(实现时河宽落在 390–620 米之间),而不是一句「要有外滩的感觉」。

第三,给出实现路径。 「使用 CatmullRomCurve3 等曲线技术构建河流中心线和岸线,再生成连续水面网格」「建筑、道路、河岸、水面分别建模,保持空间关系一致」。这不是替模型写代码,而是告诉它:用一条曲线作为所有沿江构件的共同基准。

第四,写禁止项和验收标准。 「而不是直线河道或普通弧形滨江」「不采用地面平视视角」「不要只展示单栋建筑或局部滨江景观」——这三句排除错误解;「第一眼能够识别出上海陆家嘴和黄浦江」「从整体俯视视角能够清晰看到……U 形蜿蜒轮廓」——这两句给出可以被眼睛检查的通过条件。

这三层合起来,才是第二版真正有效的原因:约束可执行,结果可验收,优先级明确。

四、落到代码:河道成为唯一空间基准

提示词里那句「用 CatmullRomCurve3 构建中心线」,最后落成了一个 src/world/river.js。它只做一件事:把整条黄浦江变成一套可以查询的空间坐标。

1
2
3
4
5
6
7
8
const curve = new THREE.CatmullRomCurve3(
RIVER.controlPoints.map(([x, z]) => new THREE.Vector3(x, 0, z)),
false,
"centripetal",
);

const spaced = curve.getSpacedPoints(900);
const length = curve.getLength();

29 个控制点定义了河道的走向,按弧长均匀采样 900 段,得到每个采样点的位置、切线、法线和半宽。这里用 centripetal 参数化,是为了减少急弯处的过冲——河道弯得越急,普通 Catmull-Rom 越容易在控制点之间甩出一段不自然的弧线。

紧接着是一段容易被忽略、但不写就会出事的逻辑:

1
2
const radius = angle > 1e-5 ? arc / angle : Infinity;
samples[i].halfWidth = Math.min(samples[i].halfWidth, radius * 0.62);

岸线不是单独画的,而是中心线向两侧偏移「半宽」得到的。弯太急、河太宽,偏移后的内岸会自己穿过自己。所以急弯处必须收窄河宽,0.62 是给偏移留的安全余量;之后再跑两遍平滑,避免宽度在曲率突变处出现折角。

有了这套采样,river 对外只暴露几个查询:

1
2
3
4
5
river.pointAt(u);              // 弧长参数 u ∈ [0,1] 上的点
river.tangentAt(u); // 该点的切线方向
river.bankPoint(u, side); // 岸线取点,+1 为浦西/外滩,-1 为浦东/陆家嘴
river.lanePoint(u, offset); // 江面航线取点
river.nearest(x, z); // 陆地 / 水面判定

然后,所有沿江构件都从这里取点:

  • 水面沿着中心线生成连续 ribbon,而不是一张矩形平面;边缘带 aEdge 属性,用来画贴岸泡沫。
  • 陆地是一整块 ShapeGeometry,河道作为 Path 孔洞挖掉,避免水面和地面互相穿插。
  • 岸墙、步道、滨江道路用 createRibbonGeometry / createBankWallGeometry 沿河生成。
  • 外滩 16 栋历史建筑沿着浦西一侧的 bankPoint 逐栋排布,并朝向江面。
  • 游船和货轮沿着中心线偏移出的航线航行,用 tangentAt 自动跟随弯道转向。

这套结构的价值不在「代码更优雅」,而在改一个地方,全场景跟着变。修改一个控制点,水面、岸线、道路、步道、建筑、船只航线会同时移动;而在场景 v1 里,想挪一下河,得分别改水面、岸墙、道路和建筑。

提示词把「河道是基准」说清楚之后,代码自然长成了「一个中枢 + 多个消费者」。这是我觉得这次迭代里最值得记下来的一点:好的提示词不只描述结果,还会暗示结构。

五、两版放在一起看

场景 v1 场景 v2
河道 沿 Z 轴的直线河道 CatmullRomCurve3 中心线生成的大尺度 U 形弯道
河岸 两条直线岸墙 沿曲线偏移生成、宽度随河道变化的连续岸线
水面 矩形平面 沿河道的连续 ribbon 网格,岸边泡沫随岸线变化
陆地 两块大平面 带河道孔洞的连续地块,道路、步道、建筑共享河岸坐标
城市 以直线路网布置 建筑、道路、车辆、树木、路灯、码头都沿河岸曲线延伸
船 沿 Z 轴往返 沿河道中心线的不同航线连续航行,自动跟随弯道转弯
镜头 平直滨江视角 高空斜俯视,完整展示 U 形江湾与半岛
新增 — 货轮、两座跨江桥、码头上盖、远景城市、城市底图纹理

场景 v1 的预览:直线河道与两块大平面

图 1:场景 v1。元素齐全,但河道是一条直的水带,城市沿两条直线岸线铺开——第一眼看到的是「一排楼」,不是一座城。

场景 v2 的夜景

图 2:场景 v2 的夜景。同一条江被重新定义成一条曲线,陆家嘴三面环水,建筑、道路、船只都沿着这条曲线展开;昼夜系统在 1.9 秒内统一插值天空、太阳、雾、曝光、泛光和所有窗灯,水面参数也参与过渡。

场景 v2 做完之后,同一套场景还被用来导出了一段 28 秒的影片:四个固定镜头从 U 形江湾俯冲进入外滩立面,再环绕陆家嘴四塔,在镜头内完成黄昏到夜景的过渡。下面这张联系表是其中的六帧:

场景 v2 影片模式的六帧关键画面

图 3:v2.0 影片模式的六帧联系表(01-opening → 06-finale)。固定时间线让相机、昼夜和字幕都由绝对时间推导,因此每次导出的帧序列一致。

六、把这次迭代抽成六条写法

如果下次还要让模型搭一个「有地理关系」的场景,我会按这六条写提示词。

1. 先空间,后物件,再氛围。
先定义场景的骨架——哪条线决定其他一切;再列地标和物件;最后谈材质、灯光、后处理。顺序不是排版偏好,而是模型的注意力分配。

2. 把「像」翻译成可验证的关系。
「要有外滩的感觉」无法执行;「河道从左侧远处流入,绕过陆家嘴,再向右远处流出,形成马蹄形」可以。能变成控制点、宽度函数、相机坐标的描述,才是有效描述。

3. 给实现路径,但不锁死细节。
「用 CatmullRomCurve3 构建中心线和岸线」是在指定结构;「用 29 个控制点、900 段采样、三条正弦叠加河宽」就是过度干预。前者让模型少走弯路,后者会让它把注意力花在满足数字上。

4. 写禁止项。
「不要直线」「不要地面平视」「不要孤立建筑」比正面描述更能排除错误解。模型在信息不足时会选最省力的默认值,禁止项就是提前堵住那些默认值。

5. 验收标准要能被眼睛检查。
「第一眼能识别出陆家嘴和黄浦江」「俯视能看到 U 形轮廓」——这类标准可以在浏览器里直接判断。不要写「视觉质量高」这种说了等于没说的句子。

6. 一次只改一个维度。
第二版把主要精力放在空间关系上,材质、光照和后期沿用同一套方案。这样两版之间的差异才能归因:画面变好了,是因为河变了,不是因为同时加了十盏灯。

还有一条元规则:让 skill 管 API,让提示词管意图。 不要用提示词去重复 skill 里已经写好的 Three.js 用法;把提示词的篇幅留给「这个世界应该长什么样」。

七、边界:提示词能走多远

场景 v2 仍然是风格化还原,不是测绘。

四座地标是参数化近似:东方明珠的三根立柱和三个球体、上海中心的 120° 扭转三叶截面、环球金融中心的「开瓶器」顶部、金茂大厦的逐层收分,都是从真实形态里抽出来的几何特征,不是实测模型。河宽是正弦叠加出来的,岸线是曲线偏移出来的,不是从地理数据里读出来的。

再往前一步,问题就换了。第三版提示词要的是 1:1 真实地理:真实岸线、真实建筑位置和高度、统一坐标参考系、OSM/GIS/Cesium/Blender、以米为单位的世界比例,还要求「缺少真实数据时先报告缺失项,不得用虚构数据冒充真实城市模型」。这次工作暂时停在「数据源调研与坐标系统」这一步,没有继续往下做。

这不是提示词写坏了,而是提示词能提高上限,但不能替代数据。当目标从「像外滩」变成「是外滩」时,主要的工程量在数据获取、坐标转换、缺失值标注和精度验收上,不在措辞上。

还有一层边界是视觉终检。构建可以通过、产物可以非空、图片可以被解析,但「像不像外滩」最终仍然要人眼看。工具能保证场景跑起来,不能保证它好看。

八、复现

场景 v1 和 v2 是两个相互独立的 Vite 项目,各自带 package.json 和构建配置。环境要求 Node.js ≥ 20.19,以及支持 WebGL 2 的桌面浏览器。

1
2
3
4
5
6
cd material/上海外滩v2.0

npm install
npm run dev # 默认 http://localhost:5173
npm run build # 产物在 dist/,使用相对路径,可部署到任意子目录
npm run preview # 本地预览构建产物

交互方式:

操作 效果
左键拖拽 旋转视角
滚轮 / 双指 缩放
右键拖拽 / 双指拖动 平移
右上角地标列表 飞行到东方明珠、上海中心、环球金融中心、金茂大厦、外滩建筑群
底部时间切换 白天 / 黄昏 / 夜景(1.9 秒平滑过渡)
自动环绕 开启或关闭自动环绕镜头
键盘 1 2 3 切换时间
键盘 Space 自动环绕开关
键盘 R 重置视角

源码在 open-wuxu/unreal-factory。material/上海外滩v1.0/ 是直线河道的存档版本,material/上海外滩v2.0/ 是当前主版本。

结语:先找到那条「河道」

「绘制」这个词有点误导。我们画的不是一张图,是一组关系:河道决定岸线,岸线决定道路和建筑,建筑决定天际线,镜头决定读者第一眼看到什么。

第一版提示词列全了物件,却没有定义关系,所以模型只能把它们并排放在一起;第二版只多说了几句关于河的话,整座城就跟着河长了出来。

下次让模型搭场景,先别急着列地标。先问自己:这个场景的「河道」是什么?哪一条线一旦定了,其他东西都得跟着它走?

把那条线写进第一节。

引用与致谢

  • threejs-skills:cloudai-x/threejs-skills,13 个按主题拆分的 Three.js skill,对齐 three.js r186。
  • Three.js:MIT 许可。
  • 场景源码与截图:open-wuxu/unreal-factory。文中四张图片均由该项目的场景 v1 / v2 生成;图 3 的联系表来自 v2.0 的影片模式。
  • 模型与 harness:DeepSeek-flash4.1 在 Claude Code 中运行。

感谢阅读。

《Attention Is All You Need》论文深度拆解:从数学原理到 Transformer 工程实现

这篇论文是现代大语言模型技术体系中最值得深入研究的基础论文之一。它提出的 Transformer 架构,改变了自然语言处理的发展方向,并成为后来 GPT、BERT 以及众多大语言模型架构的重要基础。

如果只是了解 Attention、Multi-Head Attention、Positional Encoding 等概念,很容易出现一种情况:每个模块都知道是什么,却不清楚为什么需要它们、它们之间如何协作,以及这些设计究竟解决了什么问题。

因此,我会以你上传的论文原文为主要依据,按照以下思路展开:

  1. 研究背景与核心问题:Transformer 为什么会出现?它究竟解决了什么问题?
  2. 整体架构:Encoder、Decoder 分别承担什么职责?
  3. 核心数学原理:从 Q、K、V 开始,逐步推导 Scaled Dot-Product Attention。
  4. Multi-Head Attention:为什么需要多个注意力头?它们如何协作?
  5. 位置编码:没有 RNN 和 CNN,模型如何理解词语顺序?
  6. 完整网络结构:残差连接、Layer Normalization、FFN、Mask 分别发挥什么作用?
  7. 训练机制与实验结果:论文如何证明 Transformer 的有效性?
  8. 局限性与后续演进:从原始 Transformer 到现代 GPT,哪些设计被保留、修改或舍弃?
  9. 动手实现:通过代码和张量维度变化,真正理解模型如何运行。

分析时,我会区分三类内容:论文明确提出的结论、根据数学原理展开的解释,以及结合现代大模型发展的延伸分析,避免把今天的技术倒推成论文当年的结论。


一、论文究竟讲了什么?

1.1 论文基本信息

论文名称:Attention Is All You Need

  • 作者:Ashish Vaswani 等人
  • 发表会议:NIPS 2017,即今天的 NeurIPS
  • 论文编号:arXiv:1706.03762
  • 核心贡献:提出完全基于 Attention、摆脱循环网络和卷积网络的序列转换架构 Transformer。

你上传的论文第 1 页明确提出:Transformer 完全依赖 Attention 机制,不再使用传统的循环结构和卷积结构;实验表明,它在机器翻译任务中取得了更好的效果,同时具有更强的并行训练能力和更短的训练时间。Attention Is All You Need(2)

论文原文:arXiv: Attention Is All You Need

1.2 这篇论文真正解决的是什么问题?

先暂时忘掉 Transformer 的所有术语。

假设我们要让计算机翻译下面这句话:

The animal didn’t cross the street because it was too tired.

要正确理解这句话,模型必须知道:

  • animal 是什么?
  • street 是什么?
  • it 指代什么?
  • tired 描述的是谁?
  • 前面出现的词语,如何影响后面词语的含义?

这不是简单的单词替换问题,而是一个需要建模序列内部依赖关系的问题。

在 Transformer 出现之前,循环神经网络(RNN)、长短期记忆网络(LSTM)等模型,是解决这类问题的主流技术路线之一。

但它们存在一个重要的工程问题:处理一个序列时,后一个位置的计算通常依赖前一个位置的计算结果。

例如,模型按顺序处理:

1
The → animal → didn't → cross → the → street → ...

要处理下一个位置,通常需要先获得前一个位置的隐藏状态。

这导致训练时难以同时计算序列中所有位置的表示。

论文第 2 页明确指出,循环模型沿序列位置展开计算,其固有的顺序依赖限制了单个训练样本内部的并行化能力。Attention Is All You Need(2)

传统 RNN 的问题

假设输入有 100 个 Token。

传统 RNN 的计算大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Token 1
↓
计算 h1
↓
Token 2 + h1
↓
计算 h2
↓
Token 3 + h2
↓
计算 h3
↓
...
↓
Token 100 + h99
↓
计算 h100

即使 GPU 可以同时执行大量矩阵运算,这种依赖关系仍然限制了计算顺序。

当然,RNN 并不是完全无法并行:不同样本可以并行,部分计算也可以优化。但同一个样本内部的时间步存在依赖链。

问题在于,序列越长,这种依赖关系就越明显。

Transformer 的解决思路

Transformer 换了一种方式:

不再要求模型必须按顺序处理输入中的每一个 Token,而是让每个 Token 直接与其他 Token 建立信息交互。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
The       ─────────┐
animal ──────┐ │
didn't ───┐ │ │
cross ─┐ │ │ │
the ─┤ │ │ │
street ─┤ │ │ │
because ─┤ │ │ │
it ──┼─┼──┼──┼──→ Attention
was ──┤ │ │ │
too ──┤ │ │ │
tired ──┘ │ │ │
↓ ↓ ↓
综合上下文信息

上图表达的是信息交互的概念,而不是实际的 Attention 权重。

在 Self-Attention 中,每个位置都可以根据当前任务需要,从其他位置获取信息。

于是,模型可以直接建立两个相距很远的 Token 之间的联系,而不必像传统 RNN 那样,通过逐步传递隐藏状态来传播信息。

这就是 Transformer 的第一个关键思想:

用 Attention 建立全局依赖,用矩阵运算实现高效并行。

不过,这里有一个重要细节:

Transformer 并不是完全消除了所有顺序计算。原始论文中的 Decoder 在生成目标序列时仍然采用自回归方式,需要逐 Token 生成;主要的并行化优势体现在训练阶段,以及 Encoder 内部对整个输入序列的处理上。

理解这一点,才能正确认识 Transformer 的优势和边界。


二、Transformer 的整体架构

理解 Transformer,最重要的是先建立整体结构,再深入每个模块。

论文第 3 节给出了原始 Transformer 的完整架构。它采用经典的 Encoder-Decoder 结构:Encoder 将输入序列编码为一组上下文表示,Decoder 根据这些表示逐步生成输出序列。Attention Is All You Need(2)

2.1 原始 Transformer 的结构图

原始 Transformer 可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
      输入文本
│
▼
Tokenization
│
▼
Token Embedding
│
▼
Positional Encoding
│
▼
┌────────────────────┐
│ Encoder │
│ │
│ Multi-Head │
│ Self-Attention │
│ ↓ │
│ Feed-Forward │
│ Network │
└────────────────────┘
│
│ Encoder 输出
│
▼
┌────────────────────┐
│ Decoder │◄──── 已生成的 Token
│ │
│ Masked Self- │
│ Attention │
│ ↓ │
│ Cross-Attention │◄──── Encoder 输出
│ ↓ │
│ Feed-Forward │
└────────────────────┘
│
▼
Linear + Softmax
│
▼
下一个 Token

这里需要注意:图中的 Decoder 是原始机器翻译 Transformer 的 Decoder,而不是所有现代大语言模型都采用的结构。

2.2 Encoder 和 Decoder 分别做什么?

Encoder:理解输入序列

假设输入:

1
The cat sat on the mat.

经过 Embedding 后,每个 Token 被转换为一个向量。

Encoder 通过多层 Self-Attention 和 Feed-Forward Network,让每个 Token 的表示逐渐融入上下文信息。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
输入 Token:cat

初始表示:
cat 的词向量

经过第一层:
cat + 与它相关的上下文信息

经过第二层:
进一步整合句法和语义信息

经过多层:
形成用于下游任务的上下文表示

这只是帮助理解的示意,并不意味着每一层都严格对应某种固定的语义功能。

最终,Encoder 输出一组向量:

[
Z=(z_1,z_2,\ldots,z_n)
]

每个 (z_i) 都对应输入序列中的一个位置。

Decoder:根据已有信息生成输出

假设目标是将英文翻译成中文。

Encoder 负责处理英文输入,Decoder 则负责逐步生成中文译文。

例如:

1
2
3
4
5
6
7
8
9
第 1 步:生成「猫」

第 2 步:根据「猫」,生成「坐」

第 3 步:根据「猫坐」,生成「在」

第 4 步:根据「猫坐在」,生成「垫子」

...

实际翻译当然会根据完整的输入语境决定每一步输出什么。

在生成每个 Token 时,Decoder 会利用两类信息:

  1. 已经生成的目标序列。
  2. Encoder 对源语言输入的编码结果。

因此,原始 Decoder 中存在两种不同的 Attention:

  • Masked Self-Attention:关注当前已经允许访问的目标序列位置。
  • Cross-Attention:关注 Encoder 输出的源序列表示。

2.3 为什么要区分这两种 Attention?

假设 Decoder 正在生成第三个 Token。

此时,假设已经生成:

1
猫 坐

Decoder 可以利用:

1
2
猫
坐

但不能利用第四、第五个 Token 的真实答案,因为它们尚未生成。

这就是 Masked Self-Attention 的作用。

与此同时,Decoder 可以查看完整的 Encoder 输出,因为源语言输入在生成开始前就已经存在。

因此:

模块 Query 来源 Key、Value 来源 主要作用
Encoder Self-Attention Encoder 当前层输入 同一组 Encoder 输入 建模输入序列内部的关系
Decoder Masked Self-Attention Decoder 当前层输入 同一组 Decoder 输入 建模已生成目标序列的关系
Cross-Attention Decoder 当前层输入 Encoder 最终输出 将源序列信息引入生成过程

这个表非常重要,因为它直接决定了后面 Q、K、V 的具体来源。

2.4 原始模型的关键配置

论文第 3 节采用了以下基础配置:Encoder 和 Decoder 各包含 6 层,每层模型维度为 512,Multi-Head Attention 使用 8 个注意力头。每个头的 Key 和 Value 维度均为 64。Attention Is All You Need(2) Attention Is All You Need(2)

参数 原始 Transformer Base
Encoder 层数 6
Decoder 层数 6
模型维度 (d_{\text{model}}) 512
Attention Head 数量 8
每个 Head 的 Key 维度 (d_k) 64
每个 Head 的 Value 维度 (d_v) 64
FFN 中间层维度 (d_{ff}) 2048

需要明确:这些数字是原始论文中的配置,并不是 Transformer 架构的固定限制。

现代大语言模型可以使用不同的层数、隐藏维度、注意力头数和 FFN 结构。


三、深入理解 Attention:Q、K、V 到底是什么?

这是整篇论文最核心的部分。

如果只能选择一个模块深入研究,应该选择 Attention。

因为只有理解 Attention 的计算过程,才能真正理解 Transformer 如何让一个 Token 利用其他 Token 的信息。

3.1 先从一个直观例子开始

考虑下面这句话:

The animal didn’t cross the street because it was tired.

假设模型正在处理 it。

模型需要判断:为了更好地表示 it,应该从上下文中的哪些 Token 获取信息?

可能与以下词语建立较强的联系:

1
2
3
animal
street
tired

注意,这只是用于说明的假设,不代表原论文在这句话上测得了这些具体权重。

模型需要一种机制,完成三个步骤:

  1. 判断哪些位置值得关注。
  2. 计算每个位置的重要程度。
  3. 按照这些权重整合其他位置的信息。

Attention 就是为此设计的。

3.2 Query、Key、Value 的直觉解释

可以用信息检索系统来理解。

假设有一个数据库:

1
2
3
4
5
Key                Value
----------------------------------
动物相关信息 动物的描述
地点相关信息 地点的描述
状态相关信息 状态的描述

当你提出一个查询时:

  • Query(Q):当前需要什么信息。
  • Key(K):每条信息用于匹配查询的特征。
  • Value(V):真正需要提取的信息内容。

模型比较 Query 和各个 Key,计算相关程度,然后对 Value 进行加权组合。

这里的类比只是帮助建立直觉。神经网络中的 Q、K、V 并不是人工定义的标签,而是通过可训练的线性变换得到的向量。

一个简单例子

假设有三个 Token:

1
2
3
animal
street
tired

当前要计算 it 的上下文表示。

我们可以想象它生成一个 Query:

1
Q_it = [表示当前需要的信息的特征]

其他 Token 则有各自的 Key:

1
2
3
K_animal
K_street
K_tired

模型计算:

[
s_{\text{it},j}=Q_{\text{it}}K_j^\top
]

得到每个位置的相关性分数。

随后,通过 Softmax 将分数转换为权重:

[
\alpha_{\text{it},j}

\frac{\exp(s_{\text{it},j})}
{\sum_k \exp(s_{\text{it},k})}
]

最后,利用这些权重对 Value 加权求和:

[
z_{\text{it}}=\sum_j \alpha_{\text{it},j}V_j
]

于是,it 的新表示就融入了其他 Token 的信息。

核心理解:Attention 并不是简单地给每个词打分,而是根据当前 Query 动态地选择和整合其他位置的信息。


3.3 从数学角度推导 Attention

论文提出的 Scaled Dot-Product Attention,其公式是:

[
\boxed{
\operatorname{Attention}(Q,K,V)

\operatorname{softmax}
\left(
\frac{QK^\top}{\sqrt{d_k}}
\right)V
}
]

这是理解 Transformer 必须掌握的公式。论文第 4 页正式给出了这一表达式。Attention Is All You Need(2)

我们逐步拆解。

第一步:计算 Query 和 Key 的点积

[
S=QK^\top
]

假设输入序列有 3 个 Token,每个向量维度为 2。

为了方便理解,先假设:

[
Q=
\begin{bmatrix}
1&0\
0&1\
1&1
\end{bmatrix}
]

[
K=
\begin{bmatrix}
1&0\
0&1\
1&1
\end{bmatrix}
]

那么:

[
K^\top=
\begin{bmatrix}
1&0&1\
0&1&1
\end{bmatrix}
]

因此:

[
S=QK^\top

\begin{bmatrix}
1&0&1\
0&1&1\
1&1&2
\end{bmatrix}
]

矩阵中每个元素表示一个 Query 与一个 Key 的点积。

例如:

[
S_{1,3}=1\times1+0\times1=1
]

表示第一个 Query 与第三个 Key 的点积为 1。

这一步相当于为每一对 Token 计算一个匹配分数。

如果输入序列有 (n) 个 Token,就会得到一个 (n\times n) 的分数矩阵。

第二步:为什么要除以 (\sqrt{d_k})?

这是公式中最容易被忽略、但非常重要的设计。

原始点积为:

[
q\cdot k=\sum_{i=1}^{d_k}q_i k_i
]

假设 (q) 和 (k) 的各个分量相互独立、均值为 0、方差为 1。

那么:

[
\operatorname{Var}(q\cdot k)=d_k
]

也就是说,随着向量维度增加,点积的方差也会增加。

当点积的绝对值很大时,Softmax 的输出容易变得极端。

例如:

[
\operatorname{softmax}([1,2,3])
\approx [0.09,0.24,0.67]
]

而:

[
\operatorname{softmax}([10,20,30])
\approx [0,0,1]
]

第二种情况下,Softmax 几乎将全部权重集中在一个位置上。

这种过度集中的分布可能导致梯度变小,不利于训练。

因此,论文引入缩放因子:

[
\boxed{\frac{1}{\sqrt{d_k}}}
]

将点积除以 (\sqrt{d_k}),以控制分数的尺度。

例如,原始模型中:

[
d_k=64
]

因此:

[
\sqrt{d_k}=8
]

Attention 分数会先除以 8,再进入 Softmax。

这里有个值得深入理解的细节:

缩放并不是为了让每个分数都变小,而是为了让分数的尺度在不同向量维度下更适合 Softmax 和梯度优化。

实际训练中的分布未必满足上述独立、单位方差的理想假设,因此这是一种设计动机,而不是对任意网络状态都严格成立的保证。

第三步:Softmax 将分数转换成权重

计算:

[
A=
\operatorname{softmax}
\left(
\frac{QK^\top}{\sqrt{d_k}}
\right)
]

Softmax 默认沿每个 Query 对应的 Key 维度计算。

也就是说,对矩阵的每一行分别归一化。

得到:

[
A_{ij}\geq 0
]

并且:

[
\sum_j A_{ij}=1
]

在没有 Mask 的情况下,每一行的权重和为 1。

因此,每个 Query 都会得到一组针对所有 Key 的注意力权重。

例如:

[
A_1=[0.2,0.3,0.5]
]

意味着第一个 Query 对应的三个位置,其权重分别为 0.2、0.3 和 0.5。

但需要注意:权重只能直接表示模型在这一步计算中分配的信息混合比例,不能简单等同于人类意义上的重要性或因果解释。

第四步:利用权重整合 Value

最终计算:

[
O=AV
]

假设:

[
A_1=[0.2,0.3,0.5]
]

对应三个 Value:

[
V_1=[1,0]
]

[
V_2=[0,1]
]

[
V_3=[1,1]
]

那么第一个输出向量就是:

[
O_1=0.2V_1+0.3V_2+0.5V_3
]

展开:

[
O_1

0.2[1,0]+0.3[0,1]+0.5[1,1]
]

因此:

[
\boxed{O_1=[0.7,0.8]}
]

这个结果就是三个 Value 按照注意力权重混合之后的向量。

在实际 Transformer 中,Value 是通过可学习的线性变换获得的,而不是人工定义的语义标签。

将整个过程串起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
输入向量
│
├── 线性变换 → Query
│
├── 线性变换 → Key
│
└── 线性变换 → Value
│
Query × Key 转置 │
│ │
▼ │
匹配分数 │
│ │
除以 √dₖ │
│ │
▼ │
Softmax │
│ │
▼ │
注意力权重 ──────┘
│
▼
加权求和
│
▼
输出上下文表示

到这里,Attention 的数学核心就已经完整了:匹配、归一化、加权聚合。

接下来需要回答一个更关键的问题:为什么要有三个不同的向量 Q、K、V,而不是直接对输入向量做点积?

3.4 为什么 Q、K、V 需要分别学习?

假设输入矩阵为:

[
X\in\mathbb{R}^{n\times d_{\text{model}}}
]

Transformer 使用三个可学习的投影矩阵:

[
W_Q,\quad W_K,\quad W_V
]

分别得到:

[
Q=XW_Q
]

[
K=XW_K
]

[
V=XW_V
]

对于单头 Attention,可以将三个矩阵的形状理解为:

[
W_Q\in\mathbb{R}^{d_{\text{model}}\times d_k}
]

[
W_K\in\mathbb{R}^{d_{\text{model}}\times d_k}
]

[
W_V\in\mathbb{R}^{d_{\text{model}}\times d_v}
]

那么:

[
Q\in\mathbb{R}^{n\times d_k}
]

[
K\in\mathbb{R}^{n\times d_k}
]

[
V\in\mathbb{R}^{n\times d_v}
]

为什么要分别投影?

因为模型需要学习两类不同的能力:

  1. 匹配能力:哪些 Token 之间应该建立联系。
  2. 信息表达能力:建立联系之后,应该从其他 Token 中提取什么信息。

Q 和 K 决定注意力权重,而 V 决定加权聚合的内容。

如果只使用原始输入向量计算相似度,模型在如何匹配以及传递什么信息方面就会受到更多限制。

通过学习不同的投影矩阵,模型能够在训练过程中调整这两个方面。

需要注意,这并不意味着 Q 只负责一种语义、K 只负责另一种语义、V 只负责第三种语义。它们是功能上的区分,不是人为规定的语义分类。


四、Multi-Head Attention:为什么不能只用一个注意力头?

理解单头 Attention 后,第二个问题就出现了:

既然一个 Attention 已经可以计算任意两个 Token 之间的关联,为什么论文还要设计 Multi-Head Attention?

原论文第 4~5 页指出,多个注意力头可以让模型同时关注不同表示子空间中的信息,而单头 Attention 的信息聚合可能限制模型同时表达多种关系的能力。Attention Is All You Need(2)

4.1 单头 Attention 的局限

假设有一句话:

The cat chased the mouse because it was hungry.

模型需要同时处理不同类型的信息,例如:

  • it 与前面名词之间的关系。
  • 动词与主语之间的关系。
  • 动词与宾语之间的关系。
  • 其他与语义理解有关的关系。

如果只有一个注意力头,所有这些信息都要通过同一组注意力权重进行聚合。

当然,单头 Attention 并非只能学习一种关系;它也可以学到复杂的关联。

但多个头能够提供更丰富的并行表示方式。

4.2 多头机制的核心思路

假设输入向量维度为 512。

Transformer Base 使用 8 个注意力头,每个头的 Key 和 Value 维度都是 64。

因此,每个头可以独立执行 Attention:

[
head_i=
\operatorname{Attention}
\left(
XW_i^Q,
XW_i^K,
XW_i^V
\right)
]

其中:

[
i=1,2,\ldots,8
]

每个头都有自己的参数矩阵。

最终,将所有头的输出拼接:

[
H=\operatorname{Concat}
(head_1,head_2,\ldots,head_8)
]

因为每个头的输出维度为 64,所以:

[
H\in\mathbb{R}^{n\times512}
]

然后进行一次输出投影:

[
\boxed{
\operatorname{MultiHead}(Q,K,V)

\operatorname{Concat}(head_1,\ldots,head_h)W^O
}
]

其中:

[
W^O\in\mathbb{R}^{512\times512}
]

在原始 Transformer Base 中,8 个头各有 64 维输出,拼接后仍为 512 维,因此能够与残差连接所需的模型维度保持一致。

4.3 多头 Attention 的计算过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
              输入 X
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
Head 1 Head 2 Head 8
│ │ │
▼ ▼ ▼
Attention Attention Attention
│ │ │
▼ ▼ ▼
64 维 64 维 64 维
│ │ │
└────────────┼────────────┘
│
▼
Concat
│
▼
512 维
│
▼
输出线性投影
│
▼
512 维

需要特别注意:这并不是把 512 维向量机械切成 8 份,然后每份独立处理。

实际计算中,每个头都有自己学习得到的 Q、K、V 投影矩阵,因此每个头可以学习不同的表示子空间。

4.4 多头机制的真正价值

可以从三个角度理解。

第一,多个头可以学习不同的注意力分布。

不同头可能对不同位置分配不同权重。

第二,多个头可以在不同表示子空间中处理信息。

不同头的 Q、K、V 投影参数各不相同,因此它们处理的特征不必相同。

第三,多个头的结果可以组合。

Concat 和输出投影允许模型综合不同头提取的信息。

不过,不能简单地认为每个 Head 都对应一个清晰、独立的人类语义任务。

论文附录中的可视化确实观察到,一些注意力头呈现出与句法结构、远距离依赖或指代消解有关的行为;但这不意味着所有注意力头都具备固定、可解释的分工。

4.5 为什么每个 Head 的维度要缩小?

论文选择:

[
h=8
]

[
d_k=d_v=\frac{d_{\text{model}}}{h}=64
]

如果每个头都使用完整的 512 维,那么 8 个头会带来更高的投影计算成本。

缩小每个头的维度,使得多个头的总体计算量与相近规模的单头 Attention 保持在相似量级。

这并不意味着所有现代架构都必须采用这种配置,但它体现了 Transformer 的一个重要设计思想:

通过多个低维表示子空间并行计算,而不是让单个注意力操作承担全部表示任务。


五、Self-Attention、Cross-Attention 与 Mask

理解 Q、K、V 之后,就可以进一步理解原始 Transformer 中三种关键的注意力计算。

5.1 Self-Attention:自己与自己序列中的所有位置交互

在 Encoder 中:

[
Q=XW_Q,\qquad K=XW_K,\qquad V=XW_V
]

三者都来自同一组输入表示 (X)。

这里的“Self”不是指 Token 只关注自己,而是指 Query、Key、Value 来源于同一个序列表示。

一个 Token 可以关注自身,也可以关注其他 Token。

例如:

1
2
3
4
5
6
7
8
9
10
11
输入:
The cat sat on the mat.

cat 的 Query
│
├── 匹配 The 的 Key
├── 匹配 cat 的 Key
├── 匹配 sat 的 Key
├── 匹配 on 的 Key
├── 匹配 the 的 Key
└── 匹配 mat 的 Key

通过这样的计算,cat 对应的表示能够融入其他位置的信息。

Encoder 中的 Self-Attention 不需要阻止一个位置访问后面的输入 Token,因为输入句子整体已经可用。

5.2 Cross-Attention:让 Decoder 查询 Encoder

Cross-Attention 的计算不同。

设:

  • (H) 是 Decoder 当前层的输入。
  • (Z) 是 Encoder 的输出。

那么:

[
Q=HW_Q
]

[
K=ZW_K
]

[
V=ZW_V
]

可以看到:

  • Query 来自 Decoder。
  • Key 和 Value 来自 Encoder。

因此,Cross-Attention 可以理解为:

Decoder 根据当前的生成状态,向 Encoder 的输出表示中查询相关信息。

这和 Self-Attention 的区别不在于公式,而在于 Q、K、V 的来源。

它们都可以使用同样的 Scaled Dot-Product Attention 计算。

5.3 Masked Self-Attention:为什么需要掩码?

考虑目标序列:

1
我 喜欢 学习 AI

在自回归生成时:

  • 生成第一个 Token 时,不能查看未来 Token。
  • 生成第二个 Token 时,可以利用第一个 Token。
  • 生成第三个 Token 时,可以利用前两个 Token。

因此,Decoder 的 Self-Attention 必须限制未来信息的流入。

常用方法是构造一个因果 Mask。

对于长度为 4 的序列,可以用矩阵表示允许访问的位置:

[
M=
\begin{bmatrix}
1&0&0&0\
1&1&0&0\
1&1&1&0\
1&1&1&1
\end{bmatrix}
]

其中:

  • 1 表示允许关注。
  • 0 表示禁止关注。

实际计算中,不是直接把这个 0/1 矩阵乘到注意力分数上,而是把禁止访问的位置设置为负无穷,再执行 Softmax。

可以写成:

[
A=
\operatorname{softmax}
\left(
\frac{QK^\top}{\sqrt{d_k}}+B
\right)
]

其中:

[
B_{ij}=
\begin{cases}
0,&j\leq i\
-\infty,&j>i
\end{cases}
]

因此:

[
\operatorname{softmax}(-\infty)=0
]

未来位置的注意力权重就变为零。

这就是 Causal Attention 的数学基础。

一个容易混淆的细节

训练时,原始 Transformer 的 Decoder 可以将完整的目标序列一次性输入网络,并行计算各个位置的预测。

这是因为因果 Mask 阻止了每个位置读取未来的目标 Token。

因此:

  • 训练时,可以并行计算不同目标位置的输出。
  • 推理时,如果使用自回归生成,下一步 Token 仍然依赖前一步已经生成的结果。

这正是 Transformer 能够同时兼顾自回归生成与高效训练的关键设计之一。

论文第 5 页明确说明,Decoder 使用 Mask 阻止当前位置的信息流向之前的位置所不允许访问的未来位置,从而维持自回归生成性质。Attention Is All You Need(2)


六、位置编码:没有 RNN,模型如何知道词语顺序?

这是 Transformer 架构中另一个必须理解的问题。

前面提到,Self-Attention 能够让任意两个 Token 直接交互。

但是,如果模型只看 Token 的向量,不提供位置相关信息,那么它如何区分:

1
猫追老鼠

与:

1
老鼠追猫

两者包含相同的 Token,但顺序不同,含义也不同。

6.1 为什么 Self-Attention 本身不理解顺序?

假设有三个输入向量:

[
X=
\begin{bmatrix}
x_1\
x_2\
x_3
\end{bmatrix}
]

将它们的顺序打乱后:

[
X’=
\begin{bmatrix}
x_3\
x_1\
x_2
\end{bmatrix}
]

如果模型没有其他位置信息,那么 Self-Attention 的运算本身不会自动知道哪个 Token 原本位于句首、句中或句尾。

更严格地说,在不引入位置编码、位置相关 Mask 或其他顺序信号时,Self-Attention 对输入位置的置换具有等变性:输入位置被重新排列,输出也会以相同方式重新排列。

因此,Transformer 需要额外的位置机制。

6.2 原始论文采用什么方法?

论文使用正弦和余弦函数构造 Positional Encoding。

对于位置 (pos) 和维度索引 (i):

[
PE(pos,2i)

\sin
\left(
\frac{pos}{10000^{2i/d_{\text{model}}}}
\right)
]

[
PE(pos,2i+1)

\cos
\left(
\frac{pos}{10000^{2i/d_{\text{model}}}}
\right)
]

其中:

  • (pos):Token 在序列中的位置。
  • (i):向量维度的索引。
  • (d_{\text{model}}):模型的隐藏维度。

这些公式来自论文第 6 页。Attention Is All You Need(2)

原始 Transformer Base 中:

[
d_{\text{model}}=512
]

因此,每个位置都会获得一个 512 维的位置向量。

这个向量不是随机噪声,而是根据位置通过确定性公式计算出来的。

6.3 为什么同时使用 Sin 和 Cos?

因为不同维度使用不同频率的正弦或余弦函数。

低频维度随位置变化较慢,高频维度随位置变化较快。

组合起来,不同位置就会获得不同的向量模式。

可以想象:

1
2
3
4
位置 0 → [0, 1, 0, 1, ...]
位置 1 → [不同的正弦余弦组合]
位置 2 → [另一组正弦余弦组合]
位置 3 → [另一组正弦余弦组合]

这里的向量仅作示意,实际维度为 512。

论文选择这种方法的一个原因是:不同位置之间的关系能够通过正弦、余弦函数的性质表达,并且作者认为,这可能有助于模型学习相对位置关系。

论文还比较了固定位置编码与学习式位置 Embedding,实验中两者表现几乎相同。Attention Is All You Need(2)

6.4 位置编码如何进入模型?

原始 Transformer 不是将位置向量直接拼接到 Token Embedding 后面,而是将它们相加:

[
\boxed{
X=E+PE
}
]

其中:

  • (E):Token Embedding。
  • (PE):Positional Encoding。

二者维度相同,因此可以直接相加。

例如:

[
E\in\mathbb{R}^{n\times512}
]

[
PE\in\mathbb{R}^{n\times512}
]

于是:

[
X\in\mathbb{R}^{n\times512}
]

这样,模型在后续层中处理的向量既包含 Token 的表示,也融入了位置信息。

6.5 必须区分:位置编码不等于位置 Embedding

这两个术语经常被混用,但它们不完全相同。

机制 原始 Transformer 的正弦位置编码 学习式绝对位置 Embedding
位置向量如何获得 根据固定公式计算 通过训练学习
是否包含可训练的位置参数 否 是
是否直接提供位置信息 是 是
超出训练位置范围时的行为 公式可以继续计算 原始位置表通常没有直接覆盖新位置的参数

需要强调:固定公式可以计算更远的位置,并不意味着模型就一定能可靠地处理任意长的上下文。

现代大语言模型还发展出了 RoPE、ALiBi 等其他位置处理方案;它们属于后续技术演进,而不是原论文使用的方法。


七、Feed-Forward Network:为什么 Attention 后面还要接一个网络?

到目前为止,我们已经理解了 Attention 的作用:

每个 Token 根据相关性,从其他 Token 中提取信息。

但 Attention 本身主要通过加权聚合 Value 来整合信息。

Transformer 还需要一个模块,进一步对每个位置的表示进行非线性变换。

这就是 Feed-Forward Network,简称 FFN。

7.1 原始 FFN 的数学表达式

论文第 5 页给出的公式是:

[
\boxed{
FFN(x)=
\max(0,xW_1+b_1)W_2+b_2
}
]

其中:

[
d_{\text{model}}=512
]

[
d_{ff}=2048
]

因此:

[
W_1\in\mathbb{R}^{512\times2048}
]

[
W_2\in\mathbb{R}^{2048\times512}
]

第一层将向量从 512 维扩展到 2048 维,经过 ReLU 激活后,再投影回 512 维。Attention Is All You Need(2)

7.2 FFN 与 Attention 的区别

可以把 Transformer 的每一层理解成两个阶段:

阶段一:信息交互

Attention 让不同 Token 之间交换信息。

1
2
3
Token 1 ──┐
Token 2 ──┼── Attention ──→ 融合上下文信息
Token 3 ──┘

阶段二:位置内部的非线性变换

FFN 对每个位置分别进行处理。

1
2
3
Token 1 的表示 ──→ FFN ──→ 新表示
Token 2 的表示 ──→ FFN ──→ 新表示
Token 3 的表示 ──→ FFN ──→ 新表示

原始论文明确指出,FFN 在每个位置分别、独立地应用,并且同一层中的不同位置共享相同的 FFN 参数。不同 Transformer 层之间则拥有不同的参数。Attention Is All You Need(2)

7.3 为什么需要先扩展维度?

以 512 → 2048 → 512 为例。

第一层执行:

[
h=\operatorname{ReLU}(xW_1+b_1)
]

得到:

[
h\in\mathbb{R}^{2048}
]

第二层执行:

[
y=hW_2+b_2
]

重新映射为:

[
y\in\mathbb{R}^{512}
]

通过这种扩展、非线性变换和压缩,网络可以学习比单一线性映射更丰富的特征变换。

从工程角度看,FFN 也是 Transformer 中重要的参数和计算来源。

现代大语言模型中的 FFN 往往进一步发展为 SwiGLU 等结构,维度配置也可能不同。

但其核心思想仍然可以追溯到原始 Transformer:

Attention 负责 Token 之间的信息交互,FFN 负责在每个位置上进一步变换表示。


八、残差连接与 Layer Normalization

原始 Transformer 每个子层都使用残差连接和 Layer Normalization。

论文采用的形式为:

[
\boxed{
LayerNorm(x+Sublayer(x))
}
]

也就是先将子层输入与子层输出相加,再进行 Layer Normalization。Attention Is All You Need(2)

8.1 为什么需要残差连接?

假设某一层的输入是:

[
x
]

经过 Attention 或 FFN 后,得到:

[
F(x)
]

如果直接使用:

[
y=F(x)
]

那么原始输入信息只能通过网络学习的变换继续传递。

残差连接则使用:

[
\boxed{y=x+F(x)}
]

它为输入信息提供了一条直接传递的路径。

这能够帮助深层网络优化,也让子层可以学习对输入表示的增量调整。

从直觉上说:

1
2
3
4
5
原始信息 x ──────────────────┐
▼
相加 → 输出
▲
输入 x → Attention / FFN ────┘

模型不必每一层都从头重新构造完整表示,而是可以在已有表示的基础上进行修正。

8.2 Layer Normalization 的作用

Layer Normalization 对单个样本的特征维度进行归一化。

给定一个向量 (x),可以先计算均值:

[
\mu=\frac{1}{d}\sum_{i=1}^{d}x_i
]

再计算方差:

[
\sigma^2=
\frac{1}{d}
\sum_{i=1}^{d}(x_i-\mu)^2
]

然后:

[
\hat{x}_i=
\frac{x_i-\mu}{\sqrt{\sigma^2+\epsilon}}
]

最后使用可学习的缩放和偏移参数:

[
y_i=\gamma_i\hat{x}_i+\beta_i
]

其中 (\epsilon) 用于提高数值稳定性。

在原始 Transformer 的配置中,LayerNorm 位于残差相加之后。

需要区分两个概念:

  • 残差连接提供信息和梯度的直接通路。
  • LayerNorm 对特征进行归一化,以帮助稳定训练。

二者作用不同,因此不能简单地互相替代。

8.3 一个重要的现代架构差异

原始论文使用的是:

[
LayerNorm(x+F(x))
]

这通常称为 Post-LN。

许多现代大语言模型采用 Pre-LN 或其他归一化变体,例如先归一化,再进入子层:

[
y=x+F(LayerNorm(x))
]

这不是原始论文的公式,而是后续架构演进中的重要变化。

如果你准备自己实现 Transformer,需要特别注意这两种结构并不等价,不能只因为它们都包含残差连接和 LayerNorm,就认为可以随意互换。


九、把所有模块组合起来:一层 Transformer 究竟如何运行?

前面分别讲解了 Attention、FFN、位置编码、残差连接和 LayerNorm。现在把它们组合起来,观察数据在网络中的完整流动。

这是从理解理论走向阅读源码、实现模型的关键一步。

9.1 Encoder 单层的完整计算过程

假设输入矩阵:

[
X\in\mathbb{R}^{n\times512}
]

其中 (n) 是 Token 数量。

原始 Transformer Encoder 的一层可以写成:

第一步:Multi-Head Self-Attention

[
A=\operatorname{MHA}(X,X,X)
]

三个输入都来自同一个矩阵 (X)。

然后进行残差连接和 LayerNorm:

[
H=\operatorname{LayerNorm}(X+A)
]

如果考虑论文中的 Dropout,则更准确地写为:

[
H=\operatorname{LayerNorm}
\left(X+\operatorname{Dropout}(A)\right)
]

第二步:Feed-Forward Network

[
F=\operatorname{FFN}(H)
]

然后:

[
Y=\operatorname{LayerNorm}
\left(H+\operatorname{Dropout}(F)\right)
]

因此,Encoder 单层可以概括为:

[
\boxed{
H=\operatorname{LN}(X+\operatorname{Dropout}(\operatorname{MHA}(X,X,X)))
}
]

[
\boxed{
Y=\operatorname{LN}(H+\operatorname{Dropout}(\operatorname{FFN}(H)))
}
]

这里的 LN 是 LayerNorm。

注意,以上公式对应原始论文的 Post-LN 结构。实际训练时,Dropout 只在启用训练模式时生效。

9.2 Decoder 单层的完整计算过程

Decoder 比 Encoder 多一个 Cross-Attention 子层。

假设:

  • (X):Decoder 当前层的输入。
  • (Z):Encoder 的输出。

计算过程如下。

第一步:Masked Self-Attention。

[
H_1=
\operatorname{LN}
\left(
X+\operatorname{Dropout}
\left(
\operatorname{MHA}_{masked}(X,X,X)
\right)
\right)
]

第二步:Cross-Attention。

[
H_2=
\operatorname{LN}
\left(
H_1+\operatorname{Dropout}
\left(
\operatorname{MHA}(H_1,Z,Z)
\right)
\right)
]

第三步:FFN。

[
Y=
\operatorname{LN}
\left(
H_2+\operatorname{Dropout}
\left(
\operatorname{FFN}(H_2)
\right)
\right)
]

可以看到,Decoder 的三种子层各司其职:

子层 解决的问题
Masked Self-Attention 已生成的目标 Token 之间如何交互
Cross-Attention 当前生成状态需要从源序列中获取什么信息
FFN 如何进一步变换融合后的表示

9.3 为什么 Transformer 要堆叠多层?

原始论文将 Encoder 和 Decoder 各堆叠 6 层。

每一层都可以重新计算注意力权重,并进一步变换 Token 表示。

可以将其抽象为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
输入 Embedding
│
▼
Transformer Layer 1
│
▼
Transformer Layer 2
│
▼
Transformer Layer 3
│
▼
...
│
▼
Transformer Layer 6
│
▼
输出上下文表示

这并不意味着每一层都对应某个固定的认知阶段。

更准确地说,随着层数增加,模型可以反复进行信息交互和非线性变换,从而学习更复杂的表示。

理解 Transformer 的关键,不是孤立记住每个模块,而是理解这些模块如何反复作用于同一组 Token 表示。


十、从计算复杂度理解 Transformer 为什么更高效

原论文不仅提出了新架构,还系统比较了 Self-Attention、RNN 和 CNN 的计算复杂度。

这部分是理解 Transformer 工程优势的重要内容。

论文第 6 页的表格从三个角度进行比较:

  1. 每层的计算复杂度。
  2. 最少需要执行多少个顺序计算步骤。
  3. 任意两个位置之间的最长信息传递路径。Attention Is All You Need(2)

10.1 Self-Attention 的复杂度

假设:

  • 序列长度为 (n)。
  • 表示维度为 (d)。

Attention 的主要计算是:

[
QK^\top
]

其中:

[
Q\in\mathbb{R}^{n\times d}
]

[
K^\top\in\mathbb{R}^{d\times n}
]

矩阵乘法的计算复杂度为:

[
O(n^2d)
]

接着计算:

[
AV
]

其复杂度也是:

[
O(n^2d)
]

因此,单层全局 Self-Attention 的主要复杂度为:

[
\boxed{O(n^2d)}
]

另外,Q、K、V 的投影还需要约 (O(nd^2)) 的计算。

所以,更完整地考虑投影后,单层 Multi-Head Attention 的计算复杂度通常可以概括为:

[
O(nd^2+n^2d)
]

这里忽略了常数因子。

10.2 为什么长上下文会成为问题?

因为 Attention 分数矩阵的大小为:

[
n\times n
]

当序列长度增加时,矩阵元素数量以平方级增长。

例如:

序列长度 (n) Attention 分数矩阵元素数
128 16,384
512 262,144
1,024 1,048,576
2,048 4,194,304
4,096 16,777,216
8,192 67,108,864

序列长度从 1,024 增加到 8,192,是原来的 8 倍,但分数矩阵的元素数量增加到 64 倍。

这就是全局 Attention 的重要代价。

实际显存消耗还与 Batch Size、注意力头数、数据精度、反向传播和具体实现有关,不能仅凭矩阵元素数量直接得出整个模型的显存需求。

10.3 与 RNN 相比,Transformer 的优势是什么?

原论文给出的复杂度比较如下。

指标 Self-Attention Recurrent
每层主要复杂度 (O(n^2d)) (O(nd^2))
最少顺序计算步骤 (O(1)) (O(n))
任意两位置的最大路径长度 (O(1)) (O(n))

这些复杂度描述的是特定计算模型下的理论特征,并不意味着 Self-Attention 在所有序列长度和硬件环境下都比 RNN 更快。

论文还指出,当序列长度 (n) 小于表示维度 (d) 时,Self-Attention 的主要计算在相应假设下可能比循环层更有利。Attention Is All You Need(2)

什么叫最大路径长度?

这是一个容易忽略、但非常有价值的概念。

假设句子开头的 Token 要影响句尾的 Token。

在 RNN 中,信息可能需要经过多个循环步骤:

1
Token 1 → Token 2 → Token 3 → ... → Token n

在 Self-Attention 中,两个位置可以在一层 Attention 内直接建立信息交互:

1
Token 1 ─────────→ Token n

因此,单层全局 Self-Attention 中,两个位置之间的最大路径长度是常数级。

这为模型学习长距离依赖提供了更直接的信息通路。

不过,路径更短不意味着模型必然能正确理解长距离关系。模型仍然需要通过训练学习适当的注意力分布和表示。

10.4 为什么现代模型仍然需要优化 Attention?

因为原始 Transformer 的全局 Attention 虽然提高了并行能力,但它的计算和内存成本仍然会随着序列长度快速增长。

后来出现的优化包括:

  • FlashAttention:通过更高效的分块计算和内存访问方式,减少 Attention 的中间数据读写。
  • Sliding Window Attention:限制每个 Token 能直接关注的局部范围。
  • Sparse Attention:只计算部分位置之间的注意力。
  • KV Cache:在自回归推理中缓存历史 Key 和 Value,避免重复计算。
  • Grouped-Query Attention:在多个 Query 头之间共享 Key、Value 头,降低 KV Cache 的内存开销。

这些都是后续发展,不能与原论文的原始设计混为一谈。

尤其需要注意:FlashAttention 主要优化计算实现和内存访问,并没有把标准全局 Attention 的理论计算复杂度从平方级改成线性级。


十一、论文的训练方法与实验结果

理解架构之后,还需要回答一个问题:

Transformer 为什么被认为是有效的?

论文给出的证据不仅是提出了一套新的结构,还包括与已有模型的实验对比,以及对不同组件的消融实验。

11.1 训练数据

论文使用了 WMT 2014 机器翻译任务。

英文到德文:

  • 约 450 万对句子。
  • 使用 Byte-Pair Encoding。
  • 共享词表约 37,000 个 Token。

英文到法文:

  • 约 3,600 万个句子。
  • 使用约 32,000 个 WordPiece Token 的词表。

训练批次按照近似序列长度组织,每批大约包含 25,000 个源语言 Token 和 25,000 个目标语言 Token。Attention Is All You Need(2)

这里有一个值得注意的工程思想:

按序列长度分组,可以减少 Batch 内 Padding 带来的无效计算。

例如:

1
2
3
4
5
Batch A:
短句、短句、短句、短句

Batch B:
长句、长句、长句、长句

相较于将极短句子与极长句子混在同一批次中,这种方式通常更容易控制 Padding 开销。

11.2 优化器与学习率调度

论文使用 Adam 优化器,参数为:

[
\beta_1=0.9
]

[
\beta_2=0.98
]

[
\epsilon=10^{-9}
]

同时采用了特定的学习率调度:

[
\boxed{
lrate=
d_{\text{model}}^{-0.5}
\cdot
\min
\left(
step^{-0.5},
step\cdot warmup^{-1.5}
\right)
}
]

其中:

[
warmup_steps=4000
]

公式来自论文第 7 页。Attention Is All You Need(2)

这个调度分为两个阶段。

阶段一:Warmup

在训练初期:

[
lrate\propto step
]

学习率逐渐增大。

阶段二:衰减

Warmup 结束后:

[
lrate\propto step^{-0.5}
]

学习率按照训练步数的平方根倒数逐渐衰减。

为什么要这么设计?

深层网络在训练初期的参数状态可能尚不稳定。先逐步增加学习率,再逐渐降低学习率,是一种兼顾训练稳定性与后期优化的策略。

需要注意,这个具体公式是原论文的训练方案,不是所有现代 Transformer 都必须使用的学习率调度。

11.3 训练时间

论文报告:

配置 GPU 训练步数 训练时间
Transformer Base 8 张 NVIDIA P100 100,000 约 12 小时
Transformer Big 8 张 NVIDIA P100 300,000 约 3.5 天

原论文在第 7 页给出了这些训练设置。Attention Is All You Need(2)

这些结果反映的是 2017 年特定硬件、数据集、模型配置和训练方案下的表现,不应该直接与今天的大模型训练成本进行比较。

11.4 机器翻译结果

论文在 WMT 2014 英德翻译任务中报告:

  • Transformer Base:BLEU 27.3。
  • Transformer Big:BLEU 28.4。

在英法翻译任务中:

  • Transformer Big:BLEU 41.8。

论文认为,这些结果优于当时已有的相关模型,且具有显著的训练效率优势。Attention Is All You Need(2)

BLEU 是一种常用的机器翻译评价指标,通过比较机器译文与参考译文之间的 n-gram 匹配情况来评估翻译质量。

但 BLEU 并不是对翻译质量的完美衡量标准。

这组实验的重要意义,在于它证明了:完全基于 Attention 的序列转换架构,不仅在理论上具有并行化优势,而且能够在真实的机器翻译任务中取得有竞争力的效果。

11.5 消融实验告诉我们什么?

论文还改变了模型的一些配置,观察性能变化。

例如:

  • 将注意力头数量从多个减少为单个,模型效果有所下降。
  • 将 Key 的维度降低,模型效果也有所下降。
  • 增大模型规模,通常可以提升性能。
  • Dropout 对减少过拟合有帮助。
  • 使用学习式位置 Embedding 替代正弦位置编码,效果接近。

这些结论来自论文第 9 页的模型变体实验。Attention Is All You Need(2)

这里需要掌握一个重要的科研阅读方法:

不要只看最终模型的分数,还要看论文通过什么实验,证明哪些设计可能是有效的。

消融实验可以帮助判断模型的哪些部分对结果有贡献,但其结论仍受实验配置、数据集和参数选择的限制。


十二、原始 Transformer 与现代 GPT 有什么区别?

这是理解这篇论文最重要的延伸问题之一。

很多人学习 Transformer 时,会把原始 Transformer、GPT、BERT 以及现代大语言模型都看成同一种架构。

实际上,它们存在重要差异。

12.1 三种典型架构

特征 原始 Transformer BERT 类模型 GPT 类模型
基本结构 Encoder + Decoder Encoder-only Decoder-only
主要注意力形式 Encoder 双向 Self-Attention;Decoder 因果 Mask + Cross-Attention 通常使用双向 Self-Attention 通常使用因果 Self-Attention
典型训练目标 条件序列生成,例如机器翻译 掩码语言建模等 自回归下一 Token 预测
典型用途 机器翻译、序列转换 文本理解、分类、抽取等 文本生成、对话、代码生成等

这里的“通常”很重要:不同模型可能使用不同的训练目标或结构变体。

12.2 GPT 为什么不需要原始 Transformer 的 Encoder?

原始 Transformer 的任务是:

[
\text{源语言}\rightarrow\text{目标语言}
]

因此,它需要:

  • Encoder 处理源语言。
  • Decoder 生成目标语言。
  • Cross-Attention 将源语言信息传递给 Decoder。

GPT 类模型的核心任务则是根据已有上下文预测下一个 Token:

[
P(x_t\mid x_1,x_2,\ldots,x_{t-1})
]

例如:

1
2
3
4
5
输入:
人工智能的发展正在

预测:
改变

生成下一个 Token 后,再把它加入上下文:

1
人工智能的发展正在改变

然后继续预测。

这种任务本身不需要一个独立的源序列 Encoder,因此可以采用 Decoder-only 架构。

在典型 GPT 模型中,输入 Token 经过 Embedding 和位置处理后,进入多层 Transformer Block。每个 Block 通过因果 Self-Attention 和 FFN 等组件更新表示,最终预测下一个 Token 的概率分布。

这里要注意:现代 GPT Block 往往采用 Pre-LN、不同的位置机制、不同的 FFN 结构和高效 Attention 实现,并不等同于原论文的原始 Block。

12.3 为什么现代 LLM 通常采用 Decoder-only?

一个重要原因是,自回归语言建模可以统一成一个简单目标:

[
\boxed{
\mathcal{L}

-\sum_{t=1}^{T}
\log P(x_t\mid x_{<t})
}
]

其中:

  • (x_t):当前位置的目标 Token。
  • (x_{<t}):之前的 Token。
  • (P(x_t\mid x_{<t})):模型预测当前位置 Token 的概率。

训练时,模型可以输入完整的文本序列,通过 Causal Mask 并行计算不同位置的预测。

例如:

1
2
3
4
5
6
7
8
9
输入序列:
The cat sat on the mat

训练目标:
The → cat
The cat → sat
The cat sat → on
The cat sat on → the
...

实际实现中,通常会将输入序列整体右移形成预测目标,并使用一次前向传播计算多个位置的损失。

这使得训练过程能够高效利用 GPU。

但推理时,如果采用逐 Token 自回归生成,仍然需要依赖前一步的生成结果。

12.4 原论文中哪些思想被现代 LLM 保留了?

很多现代大语言模型虽然已经不再使用原始 Encoder-Decoder 结构,但仍然保留了 Transformer 的核心思想:

  1. 通过 Attention 实现 Token 之间的信息交互。
  2. 使用多头机制进行并行表示学习。
  3. 通过残差连接支持深层网络训练。
  4. 使用 FFN 等模块进行非线性特征变换。
  5. 通过位置机制引入序列顺序信息。
  6. 利用大规模数据训练模型参数。

而以下内容在后续发展中出现了很多变化:

  • Encoder-Decoder、Encoder-only、Decoder-only 的结构选择。
  • Post-LN 与 Pre-LN 等归一化方式。
  • 正弦位置编码与 RoPE 等位置机制。
  • ReLU FFN 与 SwiGLU 等前馈结构。
  • 标准 Multi-Head Attention 与 Grouped-Query Attention。
  • 原始 Attention 的直接计算与 FlashAttention 等优化实现。

因此,学习原始论文时,需要区分两个层面:

第一层:理解原始 Transformer 的完整设计。

第二层:理解现代大语言模型为什么在此基础上做出了各种改进。

如果直接从现代 GPT 源码入手,很容易把大量不同年代、不同目的的设计混在一起,反而难以理解最初的原理。


十三、这篇论文有哪些局限?

经典论文并不意味着其方案在所有场景下都是最优的。

如果想真正理解 Transformer,就需要同时理解它的优势与代价。

13.1 全局 Attention 的二次复杂度

对于长度为 (n) 的序列,全局 Attention 的主要计算复杂度为:

[
O(n^2d)
]

当上下文长度非常大时,计算与内存成本会快速增长。

这是后续大量高效 Attention 研究的重要动机。

13.2 自回归生成仍然具有顺序依赖

虽然训练阶段可以并行处理多个位置,但 GPT 类模型在推理时通常仍然逐 Token 生成。

例如:

1
2
3
4
5
6
7
生成 Token 1
↓
生成 Token 2
↓
生成 Token 3
↓
...

因此,Transformer 并没有彻底消除生成过程中的顺序依赖。

KV Cache 等技术能够减少重复计算,但不能消除下一 Token 对前面生成结果的依赖。

13.3 位置编码不能自动保证长上下文泛化

原论文选择正弦位置编码,一个原因是它可以在训练位置范围之外继续计算。

但这并不代表模型必然能够可靠地利用任意长度的上下文。

长上下文能力还受到训练数据、模型规模、位置机制、注意力实现和训练目标等多方面因素影响。

13.4 Attention 权重不等于完整的解释

原论文观察到,一些注意力头呈现出与句法关系、长距离依赖和指代消解有关的模式。

这为理解模型行为提供了有价值的线索。

但是,不能据此认为:

只要观察 Attention 权重,就可以完全解释模型为什么得出某个结论。

模型的最终输出还受到多层表示变换、残差连接、FFN 和输出投影等多种因素影响。

13.5 论文并没有解决今天所有的大模型问题

这篇论文提出的是一种序列建模架构,而不是一个完整的大语言模型训练与应用方案。

它并未直接解决今天大模型工程中的全部问题,例如:

  • 超大规模分布式训练。
  • 指令微调与偏好对齐。
  • 检索增强生成。
  • 工具调用与 Agent 编排。
  • 长上下文高效推理。
  • 多模态模型训练。
  • 模型安全与可靠性。

这些方向需要在 Transformer 基础上继续学习其他论文与技术。


十四、如何真正掌握这篇论文:推荐的学习与实验路线

如果你的目标不只是看懂论文,而是希望能够深入研究大模型、实现 Transformer,并将其应用到 AI 工程中,我建议采用以下学习路线。

重点不是背诵公式,而是能够从输入开始,独立解释每个模块的作用,推导关键张量的形状,并通过实验验证理解。

阶段一:数学基础与张量运算

目标:真正理解 Attention 公式,而不是只会抄写。

需要掌握:

  • 向量、矩阵与矩阵乘法。
  • 转置、广播与张量维度。
  • 点积与向量相似性。
  • Softmax 的计算方式。
  • 导数、梯度与链式法则的基本概念。
  • 神经网络中的线性层与反向传播。

建议动手完成:

  1. 使用 NumPy 实现 Softmax。
  2. 手动计算 (QK^\top)。
  3. 手动计算缩放后的 Attention 分数。
  4. 实现 (AV) 的加权聚合。
  5. 对比不同 (d_k) 下缩放前后的分数分布。

验收标准:

给定任意小型 Q、K、V 矩阵,能够手算并通过代码验证 Attention 的完整输出。

阶段二:从零实现 Scaled Dot-Product Attention

使用 PyTorch 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import math
import torch
import torch.nn.functional as F


def scaled_dot_product_attention(q, k, v, mask=None):
scores = q @ k.transpose(-2, -1)
scores = scores / math.sqrt(q.size(-1))

if mask is not None:
scores = scores.masked_fill(
~mask.bool(),
float("-inf"),
)

weights = F.softmax(scores, dim=-1)
output = weights @ v

return output, weights

这个实现展示了最基本的计算过程。

其中:

  • q @ k.transpose(-2, -1) 计算匹配分数。
  • 除以 (\sqrt{d_k}) 进行缩放。
  • Mask 将禁止访问的位置设置为负无穷。
  • Softmax 得到注意力权重。
  • 与 V 相乘,得到加权聚合结果。

注意:这个示例假设每一行至少存在一个允许访问的位置。实际工程中还需要正确处理 Padding Mask、全屏蔽行以及不同形状的 Mask。

验收标准:

  • 能解释每一步的数学意义。
  • 能解释 transpose(-2, -1) 为什么必要。
  • 能解释为什么 Softmax 通常沿最后一个维度计算。
  • 能验证因果 Mask 下,未来位置的权重为零。

阶段三:实现 Multi-Head Attention

在阶段二的基础上,增加:

  1. Q、K、V 的可学习线性投影。
  2. Head 维度拆分。
  3. 每个 Head 的独立 Attention。
  4. 多头结果的拼接。
  5. 输出线性投影。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
输入:
[B, T, 512]

Q、K、V 投影:
[B, T, 512]

拆分 8 个 Head:
[B, 8, T, 64]

Attention 输出:
[B, 8, T, 64]

拼接:
[B, T, 512]

输出投影:
[B, T, 512]

其中:

  • (B):Batch Size。
  • (T):序列长度。
  • 512:模型维度。
  • 8:注意力头数。
  • 64:每个 Head 的维度。

验收标准:

能够独立实现 Multi-Head Attention,并解释每一次 reshape、transpose 和 contiguous 的作用。

阶段四:实现完整 Encoder Block

逐步加入:

  • Multi-Head Self-Attention。
  • 残差连接。
  • LayerNorm。
  • Feed-Forward Network。
  • Dropout。

重点观察:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
输入形状:
[B, T, 512]

Attention 输出:
[B, T, 512]

残差 + LayerNorm:
[B, T, 512]

FFN 中间层:
[B, T, 2048]

FFN 输出:
[B, T, 512]

最终输出:
[B, T, 512]

验收标准:

不依赖现成 Transformer Block,能够自己实现一个与原论文结构一致的 Encoder Layer。

阶段五:实现 Decoder 与因果 Mask

继续实现:

  • Masked Self-Attention。
  • Cross-Attention。
  • FFN。
  • 残差连接与 LayerNorm。
  • 输出投影和 Softmax。

重点验证两个性质:

性质一:因果性

改变未来位置的输入,不应该影响前面位置的预测结果。

性质二:Cross-Attention 的数据来源

Query 来自 Decoder,Key 和 Value 来自 Encoder。

验收标准:

给定源序列和目标序列,能够解释 Decoder 中三种子层的计算路径,并验证因果 Mask 正确。

阶段六:训练一个小型 Transformer

不建议一开始就尝试复现原论文的完整训练规模。

可以先选择一个小型机器翻译数据集,或者构造简单的序列转换任务。

建议按以下顺序推进:

  1. 训练一个小型 Encoder-Decoder Transformer。
  2. 观察训练损失和验证损失。
  3. 对比有无位置编码的差异。
  4. 对比单头与多头 Attention。
  5. 对比不同模型深度。
  6. 检查因果 Mask 是否正确。
  7. 尝试在小型语料上训练一个 Decoder-only 模型。

验收标准:

模型能够完成一个可验证的序列任务,并且你能够解释训练目标、Attention Mask、输出概率分布和推理过程。


十五、进一步阅读:从 Transformer 基础走向现代大模型

掌握原始 Transformer 后,可以沿着以下路线继续学习。

学习方向 需要解决的问题 推荐学习内容
自回归语言模型 如何通过预测下一个 Token 学习语言 GPT 系列的早期论文
预训练语言模型 如何通过大规模预训练学习通用表示 BERT
计算与内存优化 如何更高效地执行 Attention FlashAttention
位置编码演进 如何表示相对位置与扩展上下文 RoPE 相关论文
参数效率 如何减少推理时的 KV Cache 开销 Multi-Query Attention、Grouped-Query Attention
模型训练 如何训练更大规模的神经网络 Scaling Laws、分布式训练
大模型应用 如何让模型使用外部知识和工具 RAG、Function Calling、AI Agent

其中,建议优先学习 GPT、BERT 和 FlashAttention。

原因是这三个方向分别对应:

  • 模型架构如何适配不同任务。
  • 预训练目标如何影响模型行为。
  • Transformer 如何在现代硬件上高效运行。

学习它们时,应当始终回到原始 Transformer 的三个核心问题:

  1. 信息如何在 Token 之间传递?
  2. 模型如何表示序列位置?
  3. 网络如何通过多层变换学习复杂表示?

只要能够持续用这三个问题分析后续技术,就不容易陷入只记住各种架构名称、却不理解设计动机的状态。


十六、最终总结:这篇论文最值得掌握的 8 个知识点

如果需要将整篇论文压缩为一份知识清单,我建议重点掌握以下内容。

序号 核心知识点 必须达到的理解程度
1 Transformer 的研究动机 理解 RNN 顺序计算的限制,以及 Attention 的并行化优势
2 Scaled Dot-Product Attention 能够推导公式,并解释缩放因子的作用
3 Q、K、V 理解匹配、权重与信息聚合的关系
4 Multi-Head Attention 能够解释多头机制,并独立实现张量维度变换
5 Positional Encoding 理解 Self-Attention 为什么需要额外的顺序信息
6 Masked Self-Attention 理解因果 Mask,以及训练和推理的区别
7 FFN、残差连接、LayerNorm 理解单层 Transformer 的完整计算过程
8 Encoder-Decoder 与 Decoder-only 理解原始 Transformer 和现代 GPT 类模型的结构差异

其中,最值得反复推导的是:

[
\boxed{
\operatorname{Attention}(Q,K,V)

\operatorname{softmax}
\left(
\frac{QK^\top}{\sqrt{d_k}}
\right)V
}
]

以及:

[
\boxed{
\operatorname{MultiHead}(Q,K,V)

\operatorname{Concat}(head_1,\ldots,head_h)W^O
}
]

第一条公式解释了 Token 如何根据上下文动态聚合信息。

第二条公式解释了模型如何通过多个注意力头,在不同表示子空间中并行处理信息。

再结合位置编码、FFN、残差连接和 LayerNorm,就可以建立起原始 Transformer 的完整计算图。

我最建议的下一步,不是继续阅读更多论文,而是亲手实现一个最小 Transformer。 从矩阵乘法开始,逐步实现 Attention、Multi-Head Attention、Encoder、Decoder,再对照原论文检查每个张量的形状和计算结果。

当你能够独立实现这些模块,并解释每个设计的动机和代价时,你才真正掌握了这篇论文的核心,而不仅仅是记住了 Transformer 的术语。

加利西亚省科斯塔达莫尔特的灯塔,西班牙--死亡海岸

从数据结构选择到扩容、删除与工程边界

引言

写一个能跑的哈希表不难。难的是,每到一个设计分叉路口,你都得做出选择:哈希函数用什么、冲突怎么解、扩容何时触发、删除如何处理、遍历应该遵守什么规则。

标准库替你做完了这些选择,所以平时使用 map 时,很容易只看到接口,看不到背后的权衡。自己从零实现一遍,这些问题才会逐个浮现。

本文按“设计决策”的顺序展开:先提出问题,再比较可选方案,最后说明本文的选择及其代价。代码使用 Go,但核心思路与语言无关。

本文实现的是一个用于学习的数据结构,不追求替代 Go 原生 map。它采用字符串 key、链地址法和全量扩容,目标是让关键机制清楚、可验证。

下面三张图分别对应「整体结构」「key 怎么落到桶」「桶满了怎么搬」这三个最容易被标准库遮住的部分。

图 1:桶数组只负责给出入口,链表负责承担冲突——所以查找成本是「1 次索引计算 + 一条链的长度」,而不是全表扫描;代价是每个元素都要多一次指针和内存分配。

决策一:底层用什么结构?

问题:哈希表要保存 key-value,底层容器应该怎么选?

常见方案有三类:

  1. 纯链表:所有元素放在一条链上,查找需要逐个比较,平均查找成本随元素数量增长,不能发挥哈希索引的优势。
  2. 数组 + 链地址法:数组中的每个位置称为桶;映射到同一个桶的元素,通过链表或其他小型容器组织起来。
  3. 数组 + 开放寻址:所有元素都保存在数组中;遇到冲突时,按一定的探测序列继续寻找位置。

本文选择数组 + 链地址法。原因是它实现直观,删除时可以通过修改链表指针移除节点;负载因子也可以超过 1。不过,链表会引入额外指针和内存分配,冲突链过长时,查找仍可能退化到 O(n)。

开放寻址通常具有更紧凑的内存布局,但删除需要处理墓碑等状态,且性能对装载程度更敏感。这里选择链地址法,是为了突出哈希表的基本机制,而不是宣称它在所有场景中更优。

1
2
3
4
5
6
7
8
9
10
type entry struct {
key string
value interface{}
next *entry
}

type HashMap struct {
buckets []*entry
size int
}

决策二:哈希函数与桶索引怎么设计?

问题:如何把字符串 key 映射到桶数组中的一个位置?这里其实包含两个独立步骤:先计算哈希值,再把哈希值映射为有效的桶索引。

哈希函数负责将 key 转换为一个固定宽度的数值;索引计算则根据当前桶数量,把这个数值映射到 [0, bucketCount) 范围内。扩容会改变桶数量,因此索引可能变化,但哈希函数本身不必改变。

常见选择包括使用语言或运行时提供的哈希函数,使用 FNV-1a、MurmurHash、xxHash 等现成算法,或者为特定数据设计哈希函数。直接对 key 取模并不能替代哈希函数:字符串需要先转换成数值,才能计算桶索引。

本文选择 FNV-1a,主要是因为算法短小、容易手写,适合教学。它不是加密哈希,也不能抵御恶意碰撞攻击;生产系统应优先考虑运行时或经过充分评估的哈希实现。

1
2
3
4
5
6
7
8
func hashString(s string) uint32 {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return h
}

桶数量采用 2 的幂,例如 8、16、32。此时可以用 h & (m-1) 计算索引,而不是 h % m。对于 m 为 2 的幂的情况,两种计算在数学上等价;位掩码只是索引计算方式更直接,并不会自动改善哈希分布。由于它只使用哈希值的低位,哈希函数低位的分布质量仍然重要。

1
2
3
func (m *HashMap) index(key string) int {
return int(hashString(key) & uint32(len(m.buckets)-1))
}

这个选择也约束了后续扩容:桶数量需要保持为 2 的幂。本文采用翻倍扩容,不采用增加任意素数或乘以 1.5 的策略。

图 2:哈希函数和索引计算是两步,扩容只改第二步。把桶数定成 2 的幂,换来的是「取模变位与」,代价是分布质量全部压在哈希值的低位上。

决策三:冲突怎么解决?

问题:不同 key 可能映射到同一个桶。链地址法把这些元素放在同一条链表中,查询时遍历链表并比较 key。

本文将新节点插入链表头部。头插只需要修改新节点的 next 指针和桶头指针,代码简单,也不需要遍历到链表尾部。它不保证查询更快:只有在新插入元素更容易被访问等特定访问模式下,头插才可能带来平均查找收益。哈希表本身也不承诺遍历顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (m *HashMap) Set(key string, value interface{}) {
idx := m.index(key)

for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
e.value = value
return
}
}

m.buckets[idx] = &entry{
key: key,
value: value,
next: m.buckets[idx],
}
m.size++

if float64(m.size)/float64(len(m.buckets)) > loadFactor {
m.resize()
}
}

这里先查找已有 key。若 key 已存在,只更新 value,不增加元素数量,也不触发扩容;只有新增 key 才会增加 size。

决策四:负载因子与扩容时机怎么定?

负载因子定义为 α = n / m,其中 n 是元素数量,m 是桶数量。链地址法允许 α 超过 1,但负载因子越高,平均每个桶中的元素通常越多。

本文将扩容阈值设为 0.75,超过阈值后桶数翻倍。这是一个便于教学的经验值,不是链地址法的理论常数。阈值过低会占用更多桶数组空间;阈值过高则可能增加冲突链长度。实际系统应结合内存、访问模式、哈希分布和性能目标选择阈值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const loadFactor = 0.75

func (m *HashMap) resize() {
old := m.buckets
m.buckets = make([]*entry, len(old)*2)

for _, head := range old {
for e := head; e != nil; {
next := e.next

idx := m.index(e.key)
e.next = m.buckets[idx]
m.buckets[idx] = e

e = next
}
}
}

为什么要先保存 next?因为后续会重写 e.next,把当前节点接到新桶链表中。如果不提前保存原来的 next,就会丢失旧链表中尚未迁移的后续节点。

当前实现会重新计算每个 key 的哈希值。由于桶数量翻倍,旧索引与新索引之间其实存在更简单的关系:元素只会留在原索引对应的桶,或者移动到旧索引加旧桶数量的位置。实现可以缓存哈希值或利用该关系优化迁移,但为了让示例聚焦核心逻辑,这里暂不优化。

扩容时机采用“先插入,再判断”。因此触发扩容的那次插入会先进入旧桶数组,再迁移到新数组。严格使用 > 0.75,也意味着负载因子在某次插入后可能暂时超过阈值。

图 3:桶数翻倍时,旧桶 i 的元素只会去新桶 i 或 i + m——这是 2 的幂增长带来的性质,而不是巧合。一次扩容要搬 O(m+n) 个节点,代价是一次明显的停顿;而「只去两个桶」本可以拿来优化迁移,本文为了突出核心逻辑没有这么做。

决策五:删除怎么处理?

链地址法删除节点时,需要找到目标节点,并修改桶头指针或前驱节点的 next 指针。删除头节点和删除链表中间节点是两个需要覆盖的边界情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (m *HashMap) Delete(key string) {
idx := m.index(key)
var prev *entry

for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
if prev == nil {
m.buckets[idx] = e.next
} else {
prev.next = e.next
}
m.size--
return
}
prev = e
}
}

如果 key 不存在,方法不修改任何状态。链地址法删除后不需要墓碑,因为从桶链表中移除节点不会影响其他节点的查找路径。开放寻址则不同:直接清空某个槽位可能切断后续探测路径,因此通常需要墓碑或其他删除修复机制。

本文没有实现缩容。大量删除后,桶数组可能比当前元素数量所需的空间更大。缩容可以减少空间占用,但也会带来重建成本;如果元素数量在阈值附近波动,还可能出现反复扩容和缩容。因此,缩容应作为独立设计决策,并通过不同的扩容、缩容阈值避免频繁抖动。

决策六:怎么遍历所有元素?

常见接口包括返回元素切片、提供按需推进的迭代器,或者提供回调式 Range。本文选择 Range:不必先创建包含所有元素的副本,并且可以通过回调返回 false 提前终止。

1
2
3
4
5
6
7
8
9
func (m *HashMap) Range(fn func(key string, value interface{}) bool) {
for _, head := range m.buckets {
for e := head; e != nil; e = e.next {
if !fn(e.key, e.value) {
return
}
}
}
}

本文规定:Range 执行期间,不允许修改哈希表结构。原因是插入、删除和扩容都会改变桶或链表结构,可能导致元素遗漏、重复访问等问题。这个限制是本实现主动定义的 API 契约,并不是说 Go 原生 map 在遍历期间的所有写入都一律非法。

Go 原生 map 对遍历期间插入和删除有自己的语言规范语义;并发读写则是另一类问题。运行时可能检测到部分并发读写并触发 fatal error,但不能把它等同于普通的同 goroutine 遍历期间修改。

决策七:并发安全要不要支持?

本文选择不提供并发安全保证,并要求调用方自行同步访问。这样可以避免把锁管理引入基础实现,但并不意味着加锁一定会明显拖慢所有单线程场景;实际成本取决于实现、竞争程度和访问模式。

如果需要并发访问,可以在外层使用 sync.RWMutex 保护整个哈希表,也可以根据 key 分片加锁。不过,分片锁会增加实现复杂度,而且需要谨慎处理扩容、遍历以及跨分片操作。

Go 原生 map 同样不提供通用的并发读写安全保证。sync.Map 是针对特定并发访问模式设计的另一种类型,并不是所有场景下都能直接替代带锁的 map。

完整代码

下面是前述设计决策组合后的完整实现。为保持代码聚焦,实例必须通过 New 创建;零值 HashMap 不可直接使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
package hashmap

const loadFactor = 0.75

type entry struct {
key string
value interface{}
next *entry
}

type HashMap struct {
buckets []*entry
size int
}

func New() *HashMap {
return &HashMap{buckets: make([]*entry, 8)}
}

func hashString(s string) uint32 {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return h
}

func (m *HashMap) index(key string) int {
return int(hashString(key) & uint32(len(m.buckets)-1))
}

func (m *HashMap) Set(key string, value interface{}) {
idx := m.index(key)
for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
e.value = value
return
}
}

m.buckets[idx] = &entry{
key: key, value: value, next: m.buckets[idx],
}
m.size++

if float64(m.size)/float64(len(m.buckets)) > loadFactor {
m.resize()
}
}

func (m *HashMap) Get(key string) (interface{}, bool) {
for e := m.buckets[m.index(key)]; e != nil; e = e.next {
if e.key == key {
return e.value, true
}
}
return nil, false
}

func (m *HashMap) Delete(key string) {
idx := m.index(key)
var prev *entry

for e := m.buckets[idx]; e != nil; e = e.next {
if e.key == key {
if prev == nil {
m.buckets[idx] = e.next
} else {
prev.next = e.next
}
m.size--
return
}
prev = e
}
}

func (m *HashMap) Len() int {
return m.size
}

func (m *HashMap) resize() {
old := m.buckets
m.buckets = make([]*entry, len(old)*2)

for _, head := range old {
for e := head; e != nil; {
next := e.next
idx := m.index(e.key)
e.next = m.buckets[idx]
m.buckets[idx] = e
e = next
}
}
}

func (m *HashMap) Range(fn func(key string, value interface{}) bool) {
for _, head := range m.buckets {
for e := head; e != nil; e = e.next {
if !fn(e.key, e.value) {
return
}
}
}
}

复杂度分析:这些选择带来了什么?

令 n 为元素数量,m 为桶数量,α = n / m 为负载因子。以下平均复杂度假设哈希分布合理,并暂时忽略字符串哈希计算本身的成本;若 key 长度为 L,计算哈希值通常还需要 O(L) 时间。

单次 Set 可能因为扩容而产生 O(m+n) 的成本,但在合理的翻倍扩容策略下,连续插入操作的扩容成本可以摊还到多次插入中。摊还复杂度较低,并不代表每一次操作的延迟都相同;如果系统特别关注尾延迟,就需要进一步研究分批迁移等策略。

测试:如何验证设计决策?

数据结构代码不能只靠“能跑”来证明正确。至少应该验证更新是否改变 size、删除边界是否正确、扩容后元素是否仍可访问,以及 Range 是否支持提前终止。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
package hashmap

import (
"fmt"
"math/rand"
"testing"
)

func TestSetGetUpdateDelete(t *testing.T) {
m := New()

m.Set("name", "Alice")
got, ok := m.Get("name")
if !ok || got != "Alice" {
t.Fatalf("Get(name) = (%v, %v), want (Alice, true)", got, ok)
}

m.Set("name", "Bob")
if m.Len() != 1 {
t.Fatalf("Len() = %d, want 1 after update", m.Len())
}

got, ok = m.Get("name")
if !ok || got != "Bob" {
t.Fatalf("Get(name) = (%v, %v), want (Bob, true)", got, ok)
}

m.Delete("missing")
if m.Len() != 1 {
t.Fatalf("Len() = %d, want 1 after deleting missing key", m.Len())
}

m.Delete("name")
if _, ok := m.Get("name"); ok {
t.Fatal("key should not exist after Delete")
}
if m.Len() != 0 {
t.Fatalf("Len() = %d, want 0 after Delete", m.Len())
}
}

func TestResizeKeepsEntries(t *testing.T) {
m := New()
const count = 1000

for i := 0; i < count; i++ {
key := fmt.Sprintf("key-%d", i)
m.Set(key, i)
}

if m.Len() != count {
t.Fatalf("Len() = %d, want %d", m.Len(), count)
}

for i := 0; i < count; i++ {
key := fmt.Sprintf("key-%d", i)
got, ok := m.Get(key)
if !ok || got != i {
t.Fatalf("Get(%q) = (%v, %v), want (%d, true)", key, got, ok, i)
}
}
}

func TestRangeCanStopEarly(t *testing.T) {
m := New()
for i := 0; i < 10; i++ {
m.Set(fmt.Sprintf("key-%d", i), i)
}

visited := 0
m.Range(func(key string, value interface{}) bool {
visited++
return false
})

if visited != 1 {
t.Fatalf("visited = %d, want 1", visited)
}
}

哈希函数也可以脱离哈希表单独验证:它只负责把字符串变成 32 位整数,不关心桶。直接对照 FNV-1a 的标准测试向量即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func TestHashStringMatchesFNV1a(t *testing.T) {
cases := []struct {
input string
want uint32
}{
{"", 0x811c9dc5},
{"a", 0xe40c292c},
{"foobar", 0xbf9cf968},
}
for _, c := range cases {
if got := hashString(c.input); got != c.want {
t.Errorf("hashString(%q) = %#x, want %#x", c.input, got, c.want)
}
}
}

删除需要分别覆盖链表头、链表中间和链表尾。要构造稳定的冲突链,不能靠猜哈希分布,而应该用 index 反查:不断生成 key 并按桶分组,取凑够数量的那一组。下面的辅助函数属于白盒测试,因此与实现放在同一个包里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// pickCollidingKeys 返回 n 个落在同一个桶里的 key。
func pickCollidingKeys(t *testing.T, m *HashMap, n int) []string {
t.Helper()

groups := make(map[int][]string)
for i := 0; i < 1000000; i++ {
key := fmt.Sprintf("collide-%d", i)
idx := m.index(key)
groups[idx] = append(groups[idx], key)
if len(groups[idx]) >= n {
return groups[idx][:n]
}
}
t.Fatalf("failed to find %d keys in the same bucket", n)
return nil
}

// chainKeys 按表头到表尾的顺序读出桶里的 key。
func chainKeys(m *HashMap, idx int) []string {
var keys []string
for e := m.buckets[idx]; e != nil; e = e.next {
keys = append(keys, e.key)
}
return keys
}

func TestDeleteHeadMiddleTail(t *testing.T) {
m := New()
keys := pickCollidingKeys(t, m, 3) // 头插后链序为 keys[2] -> keys[1] -> keys[0]

for _, k := range keys {
m.Set(k, k)
}

idx := m.index(keys[0])
if got := chainKeys(m, idx); len(got) != 3 || got[0] != keys[2] || got[1] != keys[1] || got[2] != keys[0] {
t.Fatalf("chain = %v, want [%s %s %s]", got, keys[2], keys[1], keys[0])
}

// 删除中间节点:前驱的 next 必须越过它
m.Delete(keys[1])
if got := chainKeys(m, idx); len(got) != 2 || got[0] != keys[2] || got[1] != keys[0] {
t.Fatalf("chain after deleting middle = %v, want [%s %s]", got, keys[2], keys[0])
}
if _, ok := m.Get(keys[1]); ok {
t.Fatal("middle key still found after Delete")
}

// 删除头节点:桶头指针必须后移
m.Delete(keys[2])
if got := chainKeys(m, idx); len(got) != 1 || got[0] != keys[0] {
t.Fatalf("chain after deleting head = %v, want [%s]", got, keys[0])
}

// 删除唯一的尾节点:桶必须变空
m.Delete(keys[0])
if got := chainKeys(m, idx); len(got) != 0 {
t.Fatalf("chain after deleting tail = %v, want empty", got)
}
if m.Len() != 0 {
t.Fatalf("Len() = %d, want 0", m.Len())
}
}

图 3 的结论同样可以直接写成断言:桶数翻倍后,旧桶 i 的元素只会出现在新桶 i 或 i + oldLen。构造时要注意阈值——先凑够 6 个同桶 key(6/8 恰好等于 0.75,不触发扩容),再用第 7 个 key 精确触发一次扩容。否则插入过程可能连续扩容两次,断言里写死的桶数就对不上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func TestResizeSplitsIntoAtMostTwoBuckets(t *testing.T) {
m := New()
oldLen := len(m.buckets) // 初始 8 个桶
oldIdx := m.index("split-probe")

// 凑 6 个落在 oldIdx 的 key:6/8 == 0.75,恰好不触发扩容
var keys []string
for i := 0; len(keys) < 6; i++ {
k := fmt.Sprintf("split-%d", i)
if m.index(k) == oldIdx {
keys = append(keys, k)
}
}
for _, k := range keys {
m.Set(k, k)
}

// 第 7 个 key 使负载因子超过 0.75,触发一次扩容
m.Set("trigger-resize", 0)
if len(m.buckets) != oldLen*2 {
t.Fatalf("buckets = %d, want %d", len(m.buckets), oldLen*2)
}

for _, k := range keys {
idx := m.index(k)
if idx != oldIdx && idx != oldIdx+oldLen {
t.Fatalf("key %q moved to bucket %d, want %d or %d", k, idx, oldIdx, oldIdx+oldLen)
}
}
}

最后一类是差分验证:随机生成 Set、Delete 序列,每一步都与 Go 原生 map 对比元素数量,结束时全量比对 Get 与 Range 的结果。它不对实现做任何假设,因此扩容、冲突链与删除的交错都能被覆盖到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
func TestDifferentialAgainstNativeMap(t *testing.T) {
m := New()
want := make(map[string]int)
r := rand.New(rand.NewSource(20261030))

for i := 0; i < 20000; i++ {
key := fmt.Sprintf("k-%d", r.Intn(500))

if r.Intn(10) < 3 { // 30% 删除,70% 写入或更新
m.Delete(key)
delete(want, key)
} else {
v := r.Int()
m.Set(key, v)
want[key] = v
}

if m.Len() != len(want) {
t.Fatalf("step %d: Len() = %d, want %d", i, m.Len(), len(want))
}
}

for k, v := range want {
if got, ok := m.Get(k); !ok || got != v {
t.Fatalf("Get(%q) = (%v, %v), want (%v, true)", k, got, ok, v)
}
}

visited := 0
m.Range(func(key string, value interface{}) bool {
v, ok := want[key]
if !ok {
t.Fatalf("Range yielded unexpected key %q", key)
}
if value != v {
t.Fatalf("Range yielded %q = %v, want %v", key, value, v)
}
visited++
return true
})
if visited != len(want) {
t.Fatalf("Range visited %d entries, want %d", visited, len(want))
}
}

实现放在 hashmap.go,测试放在 hashmap_test.go,两者同属 package hashmap。这里有一个容易踩的坑:不要用 go test hashmap_test.go。显式列出文件时,Go 只编译点名的文件,实现文件不参与,New 会报 undefined。正确的写法是把目录交给它:go test .。上述用例在 Go 1.27 下全部通过,包括 -race。

没有覆盖的部分也很明确:并发安全不是本实现的目标,因此没有并发测试;在 Range 回调里修改表结构属于违反 API 契约,测试无法“验证”一个未定义行为。性能方面可以用 go test -bench . 观察,Set 因为要处理扩容,耗时会明显高于 Get。

与 Go 原生 map 的差距

本文实现的目标是讲清楚基本机制,不是复刻 Go 运行时。比较时还必须注意 Go 版本差异:Go 1.24 起,内置 map 已采用基于 Swiss Tables 的新实现;Go 1.23 及以前的实现采用传统桶与溢出桶结构。不要把旧版实现细节直接描述为当前版本的内部机制。

  • 哈希与安全性:本文使用简单的 FNV-1a,适合教学但不是抗攻击的安全哈希。生产实现需要评估哈希碰撞攻击风险与输入特征。
  • 内存布局:本文每个 entry 都是独立节点,并通过 next 指针连接。当前 Go 原生 map 基于 Swiss Tables,采用开放寻址和分组查找,借助控制字筛选候选 key,以减少不必要的完整比较。
  • 扩容:本文一次性创建新桶数组并迁移全部元素,单次扩容可能带来较高延迟。具体运行时实现会随 Go 版本变化,应该以对应版本的官方资料为准。
  • 冲突处理:本文使用链地址法;当前 Go 原生 map 使用基于 Swiss Tables 的开放寻址方案。它们在空间布局、冲突处理、删除和增长策略上存在不同取舍。

可以阅读 Go 官方文章《Faster Go maps with Swiss Tables》,了解 Go 1.24 的实现变化。

结语:设计决策没有免费的午餐

哈希表的基本思想并不复杂:通过哈希函数把 key 映射到桶,再处理冲突。但工程实现中的每项选择都会带来约束。链地址法让删除简单,却引入指针和额外内存分配;桶数量取 2 的幂让索引计算直接,却要求扩容策略与之配合;全量扩容容易实现,却可能造成单次延迟尖峰;不提供并发安全降低了实现复杂度,却把同步责任交给调用方。

自己实现一次哈希表,真正要学会的不是背下某个阈值或某种结构,而是面对具体场景时,能说清楚:为什么选择它、它解决了什么问题、又把什么代价留给了后续。

这也是理解标准库的一个好方法。标准库里看起来复杂的实现,往往不是为了炫技,而是在性能、内存、延迟、安全性和兼容性之间做长期权衡。

引用与致谢

封面图

  • 图片:加利西亚省科斯塔达莫尔特的灯塔,西班牙——死亡海岸。
  • 作者与版权:Carlos Fernandez / Getty Images,依据图片内嵌的 XMP 元数据。
  • 许可:版权作品。本站仅将原图作为本文封面使用,未取得 Getty Images 授权,也不对其主张任何权利;如需再利用,请向权利人取得许可。

解说图(图 1 ~ 图 3)

  • 生成工具:格物 Gewu —— 一个先把技术内容抽取为可验证解释模型(VEM),再渲染成 SVG 的 Claude Code skill。
  • 说明:三张图均由该工具生成,图中桶数、链长与 key 取值都是示意值,不是某次运行实测的结果。

感谢阅读。

美国国家步道系统,安塞尔·亚当斯的荒野

线上服务在压测时挂了一次。日志里最显眼的是这一行:

1
fatal error: concurrent map writes

它后面其实跟了一段堆栈,但那段堆栈只指向“发现冲突的那个 goroutine”——另一个对手是谁,它并不知道。也没有任何可以 recover 的 panic 信息,程序直接以 exit(2) 结束。更让人困惑的是,这段代码在本地跑了一周都没事,QPS 一上来几秒钟就崩了。

这篇文章不打算罗列“Go map 怎么加锁”的几种方式,而是回答一个更根本的问题:为什么 Go 选择让程序直接死掉,而不是像其他语言那样返回一个错误,或者至少让你 recover 一下?

答案藏在 runtime 的设计哲学里。


一、崩溃现场:一个典型的“看起来没问题”

出问题的结构大致是这样:

1
2
3
4
5
6
7
8
9
var cache = map[string]string{}

func Get(key string) string {
return cache[key]
}

func Set(key, value string) {
cache[key] = value
}

包级 map,HTTP handler 里同时调 Get 和 Set。本地单线程测试没问题,压测时多个 goroutine 同时进来,就炸了。

触发的是两种 fatal error 之一:

  • concurrent map read and map write:一个 goroutine 读,另一个在写;
  • concurrent map writes:两个 goroutine 同时写。

关键在于:它不是 panic,是 throw。


二、runtime 是怎么发现的:一个标志位

这里要先说清版本。Go 1.24 起,内置 map 换成了基于 Swiss table 的实现,代码在 internal/runtime/maps;在此之前是 runtime/map.go 里的 hmap + bucket。两代实现的检测思路一样,但细节不同,网上的分析经常混着讲。

现行实现(Go 1.24+)

Map 结构体里有一个 writing uint8 字段。写操作不是“上锁”,而是翻转这个标志位,并在进入和退出时各检查一次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写操作(赋值、删除)
if m.writing != 0 {
fatal("concurrent map writes")
}
m.writing ^= 1 // 异或翻转,标记“正在写”
// ... 执行写入 ...
if m.writing == 0 { // 退出前再查一次
fatal("concurrent map writes")
}
m.writing ^= 1

// 读操作
if m.writing != 0 {
fatal("concurrent map read and map write")
}

三个细节值得注意:

  • 用异或而不是置 1/清 0。 源码注释说得很直白:并发写者不止一个时,翻转能提高双方都发现竞争的概率。如果只是置位,后到的写者看到 1 就死了;异或让状态在 0/1 之间来回翻,两个写者都更容易撞见对方留下的痕迹。
  • 进入和退出各查一次。 退出时的 if m.writing == 0 很容易被忽略:如果另一个写者在中途把标志翻回去了,这里也能抓到。
  • 遍历有单独的报错。 mapiterinit 检查的是同一个字段,但报的是 concurrent map iteration and map write。

历史实现(Go 1.23 及更早)

旧实现是 hmap 结构体里的 flags 字段,其中一位叫 hashWriting,值是 4:

  • 写操作开始时 h.flags |= hashWriting,结束时 h.flags &^= hashWriting;
  • 读操作检查 h.flags & hashWriting != 0,发现有人在写就 fatal。

思路完全一样,只是位运算和异或的区别。所以后面讨论的“为什么是 throw”,对两代实现都成立。

runtime 的并发 map 检测:一个标志位,不是一把锁

图 1:runtime 用的是“一个标志位 + 检查”,不是锁。写者进入时翻转标志,另一个写者或读者撞见非零值就触发 fatal;两边的操作并没有被串行化。

注意:这只是一个检测,不是同步。 两个 goroutine 可能刚好错开,也可能刚好撞上。所以表现为“偶发”——本地不崩,压测崩;低流量不崩,高流量崩。

还有一个容易踩的坑:对非空 map,delete 即使 key 不存在,也会走写路径、设置写标志。遍历时如果另一个 goroutine 在删任何 key,遍历都可能触发 fatal。


三、为什么是 throw,而不是 panic

这是整个设计中最关键的问题。

普通的 panic 会创建一个 _panic 结构体,挂到当前 goroutine 上,然后逆向遍历调用栈,执行 defer。如果某个 defer 里调了 recover(),程序就能继续跑。

throw 完全绕过这套机制。它把 fatal error 和当前 goroutine 的堆栈打印到标准错误,然后 exit(2)。没有 defer,没有 recover,没有“恢复”这个选项。

它选择这么做的理由很直接:当 map 的并发冲突被检测到时,内部状态可能已经损坏了。

想象一下:一个 goroutine 正在扩容、把旧表里的槽位搬到新表,另一个 goroutine 同时在读旧表的指针。runtime 发现的时候,控制字和槽位可能已经被改得面目全非——而检测本身只是一个标志位,它只知道“有人撞车了”,并不知道撞坏了多少东西。如果允许你 recover,程序继续跑,后续可能出现更诡异的行为——读到错误的数据、写入丢失、甚至内存越界。

直接死掉,是比“带着损坏的状态继续运行”更负责任的选择。

对比一下 Java 的 ConcurrentModificationException:它允许你捕获,然后继续用那个已经被并发修改过的集合。很多时候你的程序不会立刻崩,但数据对不对,就不好说了。

Go 选择了另一条路:在检测到确定性错误的那一刻,终止进程。 用一次响亮的崩溃,换掉后续所有不可预测的 bug。

panic 与 throw 的分岔

图 2:panic 与 throw 的分岔——panic 会走 defer 链,路上还有 recover 这个出口;throw 直接打印堆栈并 exit(2),没有回头路。


四、sync.Map 不是万能解药

知道原因之后,修复方式看起来很简单:加锁,或者换成 sync.Map。

但 sync.Map 的适用场景很窄,而且它的内部实现这些年也变过。

Go 1.24 起,sync.Map 换成了并发哈希 trie(internal/sync.HashTrieMap):读操作沿树做原子加载,通常无锁;写操作只在命中的中间节点上加锁并替换节点。官方发布说明强调的改进是——修改不相交的 key 集合时,大 map 上的争用明显变少,而且不再需要预热(老实现要先攒出 dirty map,才能进入低争用状态)。

Go 1.23 及更早,它是 read + dirty 两层结构:读大多走 read 这层无锁快照,写则先在 dirty 里累积,攒够次数再提升为新的 read。

两代实现的共同结论没变:它的优势都在读多写少、key 集合相对稳定的场景。写非常频繁时,sync.Map 的写路径(加锁、替换节点、分配)未必比一把 sync.RWMutex 便宜。而且无论哪一代,Range 都不保证一致性快照——遍历期间看到的可能是部分更新后的状态。

所以实际选型通常是:

场景 推荐
读多写少,key 稳定 sync.Map
读写均衡,或需要强一致遍历 sync.RWMutex 保护的普通 map
高并发,锁竞争严重 分片锁(按 key hash 分到 N 个 map,各一把锁)

分片锁的思路和 Redis 的渐进式 rehash 有相似之处:把一个大问题拆成 N 个小问题,让冲突概率降下来。


五、重新理解 runtime 的“不宽容”

这次崩溃之后,我对 Go runtime 的看法变了一点。

以前觉得 fatal error 太粗暴,为什么不能给个错误码,或者至少让我记录一下现场。后来想明白了:map 的并发冲突不是“可以优雅处理”的错误,它是一个已经发生的数据损坏事件。

runtime 在 writing(老版本是 hashWriting)标志上做的检测,本质是一种尽力而为的哨兵。它不保证一定能发现(两个写操作可能刚好错开标志位检查),但一旦发现,说明冲突已经真实发生了。这时候程序内部的状态是否还能安全使用,没有人能保证。

所以它选择 throw。用进程的死亡,阻止损坏状态的扩散。

这其实和 Redis 渐进式 rehash 的设计哲学形成了有趣的对照:Redis 用复杂度换可用性,因为它是单线程的,不能让主线程卡住;Go 用进程死亡换正确性,因为 map 的状态一旦损坏,继续运行比崩溃更危险。

两种选择,面对的是不同层面的“正确性”。


如果你也在线上遇到过这个崩溃,第一件事不是急着加锁,而是用 go test -race 或 go run -race 在测试环境复现一次。-race 能告诉你哪个 goroutine 在写、哪个在读、以及它们各自的调用栈。这比线上盲猜高效得多。

至于那个包级 map——修法很简单,难的是意识到:Go 的 map 从来没有承诺过并发安全,而 runtime 用最不宽容的方式,让这个承诺变得无法忽视。


引用与致谢

封面图

  • 图片:美国国家步道系统,安塞尔·亚当斯的荒野,来源 bing。

动图(图 1 ~ 图 2)

  • 生成工具:Animated Sketch Diagram —— 一个把技术概念画成「黑墨手绘涂鸦 + 米色纸面」风格动画图的 Claude Code 插件。
  • 项目地址:https://github.com/OLDyade/animated-sketch-diagram
  • 作者:OLDyade
  • 许可:见项目仓库中的 LICENSE 文件。
  • 说明:两张动图均由该工具把正文对应段落转成 SVG + CSS 动画后导出为循环 GIF,图中的结构为示意,不是 runtime 内存布局的精确复刻。

事实核对

  • 旧实现(Go 1.23 及更早):src/runtime/map.go 中的 hashWriting = 4 与 h.flags 检查,见 go1.23.0 源码。
  • 现行实现(Go 1.24+):src/internal/runtime/maps/map.go 中的 writing 字段与异或翻转,本文按作者本机 Go 1.27.1 的源码核对。
  • sync.Map 的实现变更:Go 1.24 Release Notes(sync.Map 与内置 map 都在这个版本换了实现)。
  • 文中崩溃输出(fatal error + 堆栈 + 程序 exit(2))来自作者本机 Go 1.27.1 的实际复现;注意用 go run 运行时,外层工具自身会返回 1,日志里的 exit status 2 才是程序本身的退出码。

感谢阅读。

如果你用过 Redis,大概率知道它“快”。但“快”从来不是偶然的。Redis 字典的渐进式 rehash,就是一个典型的例子:它为了不阻塞,主动选择了一种“看起来更慢”的搬迁方式。

这个选择的本质,是在回答一个问题:当一次维护操作的耗时随数据量线性增长时,你愿意把它摊开,还是让它一次性砸下来?

一次性 rehash 的账很好算:1 亿个键值对、单个迁移约 100 纳秒,乘起来就是 10 秒。对一个标榜毫秒级延迟的服务来说,这 10 秒等于服务中断。渐进式 rehash 的答案是——不追求“搬得快”,只追求“永远不停”。它把搬迁拆成一个个桶,塞进每一次普通的增删改查里,让开销跟着请求量自然摊薄。

代价也很实在:迁移期间内存占用翻倍、每次操作要查两张表、所有字典操作都要多一条“是否在 rehash 中”的分支。

下面按问题拆开讲:一次性 rehash 到底会阻塞多久?两个哈希表怎么协作?迁移过程中增删改查各走哪条路?为什么是“每次操作顺带搬”而不是“分批定时搬”?以及——这个设计最终牺牲了什么、换来了什么。


一、字典的基本结构:两个哈希表

Redis 的字典 dict 内部有两个哈希表:ht[0] 和 ht[1]。

正常情况下,数据都在 ht[0] 里,ht[1] 是空的。只有当需要扩容或缩容时,ht[1] 才会被分配空间,然后数据从 ht[0] 逐渐迁移到 ht[1]。

为什么要两个表?

因为 Redis 是单线程的。如果只用一张表,扩容时你得原地把所有元素重新计算位置——这在单线程下意味着什么都干不了。两张表 + 一个 rehashidx 索引,让迁移可以“边干活边搬”。

每个哈希表底层是数组 + 链表,用链地址法解决冲突。dictEntry 就是链表节点,同一个桶里的元素用 next 指针串起来。

图 1:两张表怎么协作。上半部分画迁移进行中的状态——ht[0] 左侧几个桶已经搬空,游标 rehashidx 停在当前要处理的桶上,箭头表示这一次只搬这一个桶(桶内通常 1~5 个节点),搬完游标右移一格;ht[1] 是新分配的、容量翻倍的表,搬来的节点落在里面。下半部分把迁移期间四种操作的规则并排放:查找要先查 ht[0] 再查 ht[1],删除和更新两张表都要动,而新增只写 ht[1]——最后这条是关键,它保证 ht[0] 的节点数只减不增,否则旧表永远清不空。


二、一次性 rehash 到底会阻塞多久?

这是核心问题。要回答它,先看数据量。

假设:一个字典有 1 亿个键值对。

关键数字:Redis 迁移一个键值对的典型耗时,保守估计在 100 纳秒左右。

那么:

1
1 亿 × 100 ns = 10,000,000,000 ns = 10 秒

10 秒。 在这 10 秒里,Redis 的主线程完全被占住。所有客户端请求全部排队等待。 对于一个标榜 P99 延迟在毫秒级的服务来说,这是灾难性的。

更极端的场景:如果 value 是大 key(比如一个几百 MB 的 List),单次迁移的耗时可能以秒为单位。有测试数据显示,迁移一个 600MB 的 list 类型 key 需要 6.7 秒。这还只是一个 key。

所以答案很明确:一次性 rehash 的阻塞时间与数据量成正比,在百万级以上的字典中,阻塞时间会达到秒级甚至更长。 对 Redis 来说,这等于服务中断。

图 2:两种搬法的差别只在“什么时候搬”。左边一次性 rehash 把全部工作压成一根长条——这 10 秒里主线程被占满,客户端请求只能排队,延迟曲线上出现一根 P99.9 尖刺;右边渐进式 rehash 用同样的总工作量,但把它切成一个个小格,和请求交替执行,单次操作只多搬一个桶,延迟曲线保持平稳。底部一行是这张图的重点:两者*总搬迁耗时相同(都是约 10 秒的 CPU 工作量),差别只在单次最大停顿——从 10 秒降到微秒级的单桶开销,代价是迁移期间内存占用翻倍。*


三、渐进式 rehash 到底“渐进”在哪里?

Redis 的解法是:不一次性搬完,而是把搬迁工作分摊到每一次操作里。

具体机制:

  1. 扩容时,为 ht[1] 分配新空间(通常是 ht[0].used * 2 向上取最近的 2 的幂);
  2. 把 rehashidx 设为 0,表示 rehash 正式开始;
  3. 每次对字典执行增删改查时,顺带把 ht[0] 中 rehashidx 位置上的整个桶(链表上所有节点)迁移到 ht[1];
  4. 迁移完成后,rehashidx 加一,指向下一个桶;
  5. 当所有桶都迁移完毕,把 ht[1] 设为新的 ht[0],清空旧表,rehashidx 重置为 -1。

关键细节:一次迁移的是一个“桶”,不是“一个键值对”。因为一个桶可能是一个链表,上面挂着多个节点。但从实际数据看,由于负载因子控制在合理范围,平均每个桶通常只有 1 个节点,最多一般不超过 5 个。所以单次迁移的开销很小。


四、迁移过程中,增删改查怎么处理?

这是设计中最精妙的部分。两个表同时存在,但操作规则不同。

操作 规则
查找 先在 ht[0] 找,找不到再去 ht[1] 找
删除 两个表都要操作(因为 key 可能在任意一个表里)
更新 两个表都要操作(先找到,再更新)
新增 只往 ht[1] 里加,绝不往 ht[0] 加

新增只写 ht[1] 的原因很直接:保证 ht[0] 的节点数量只减不增。 如果新增还往旧表里写,那 ht[0] 永远清不空,rehash 永远结束不了。

这个规则带来的一个副作用是:迁移期间的查找可能比平时稍慢,因为最坏情况下要找两张表。但这是可接受的代价。


五、为什么是“每次操作顺带搬”,而不是“分批定时搬”?

Redis 其实两种都有。

  • 被动搬迁:每次增删改查时顺带迁移一个桶(_dictRehashStep);
  • 主动搬迁:服务器定时任务(databasesCron)中调用 dictRehashMilliseconds,在1 毫秒内尽可能多地迁移。

为什么以被动为主、主动为辅?

因为纯定时搬迁有一个问题:如果客户端一直不发请求,定时任务也不一定能保证及时搬完。 而且定时任务的执行时机依赖于 Redis 的事件循环,在高负载时可能被推迟。

被动搬迁的好处是:搬迁的开销天然地跟随负载分布。 请求越多,搬迁越快;请求越少,系统本身也不忙,晚一点搬完也无所谓。这是一种自适应的节奏。

主动搬迁则作为兜底:当客户端闲下来了,没有后续指令来触发被动搬迁时,Redis 不会“置之不理”,而是通过定时任务主动推进。dictRehashMilliseconds 每次只花 1ms 的 CPU 时间做搬迁,然后让出控制权。

所以准确地说:不是“不做定时”,而是“被动为主、定时为辅”。 两者结合,既保证了有请求时搬迁不拖后腿,也保证了没请求时不会永远卡在半途。


六、这个设计牺牲了什么,换来了什么?

牺牲的:

  1. 单次操作略慢。 迁移期间,每次增删改查都可能附带一次搬迁,或者需要查两张表。但开销被控制在单个桶的粒度,通常只有 1~5 个节点,对响应时间的影响很小。
  2. 内存占用翻倍。 rehash 期间两个哈希表同时存在,内存使用量会上升。这是“空间换时间”的典型体现。
  3. 实现复杂度上升。 所有字典操作都必须考虑“是否处于 rehash 状态”,代码路径分叉,更容易出 bug。

换来的:

  1. 不阻塞。 这是最核心的收益。无论字典多大,Redis 主线程永远不会因为 rehash 而停下来。
  2. 平滑的延迟曲线。 一次性 rehash 会导致 P99.9 和 max 延迟飙升;渐进式 rehash 把开销摊薄,让延迟保持稳定。
  3. 自适应节奏。 搬迁进度跟随负载,忙时搬得快,闲时搬得慢,不浪费 CPU。

引用与致谢

本文配图所用资源如下:

封面图

动图(图 1 ~ 图 2)

  • 生成工具:Animated Sketch Diagram —— 一个把技术概念画成「黑墨手绘涂鸦 + 米色纸面」风格动画图的 Claude Code 插件。
  • 项目地址:https://github.com/OLDyade/animated-sketch-diagram
  • 作者:OLDyade
  • 许可:见项目仓库中的 LICENSE 文件。
  • 说明:两张动图均由该工具生成 SVG + CSS 动画后导出为无缝循环 GIF,文字内容依据本文正文整理。图中数据为示意值(1 亿键、单次 100 ns、约 10 秒总耗时、桶内 1~5 个节点等,取的是文中给出的典型量级),不是某次真实压测的结果。

感谢阅读。

选择哈希表、跳表还是 B+ 树,本质上不是在选“哪个数据结构更好”,而是在回答一个问题:你的数据放在哪里,以及你打算怎么查它。

如果数据全在内存里、只按 key 精确找,哈希表是首选;如果数据在内存里、既要按 key 找又要按顺序取一段,跳表是那个“刚刚好”的答案;如果数据在磁盘上、既要等值查又要范围扫还要排序,B+ 树是几十年工程实践筛选出来的默认选项——但“磁盘”本身还要再拆一层:机械硬盘(HDD)和固态硬盘(SSD)的瓶颈不同,选型结论也会随之松动。

下面按场景拆开讲。


一、哈希表:只解决“这一个 key 在哪”

哈希表的核心优势只有一个:等值查询期望 O(1)。给定一个 key,算个哈希,直接跳到数组对应位置。没有树高,没有指针跳转,没有页分裂。

但它的问题也同样明确:数据是无序的。哈希函数打散了顺序,你无法高效地回答“比这个 key 大的下一个是谁”或者“把 100 到 200 之间的都取出来”。

所以哈希表的适用边界很清晰:

  • 纯内存场景。哈希表依赖连续数组和指针跳转,放到磁盘上,一次查询可能对应多次随机 I/O;更关键的是它无法把多个 key 打包进一个页,扩容和冲突处理在磁盘上代价极高。磁盘哈希并非完全不存在(如某些 KV 引擎的哈希索引),但在需要范围/排序的场景下劣势明显,工程复杂度也高。
  • 只有等值查询。Memcached、Redis 的键值对空间、以及各种内存字典,都是这个场景。一旦业务需要 WHERE id > 100 或者 ORDER BY,哈希表直接出局。

一句话:如果你确定永远不需要范围查询和排序,哈希表是最快的。但真实业务里,“永远确定”往往不成立。


二、跳表:内存里的有序结构,用简单换确定性

跳表解决的是哈希表解决不了的问题:在保持期望 O(log n) 查找的同时,支持范围查询和有序遍历。

它的结构是在有序链表上建多级索引,查找时从高层往低层跳,最终落到目标区间,然后沿底层链表顺序扫过去。范围查询的代价是“定位起点 + 顺序遍历”,非常自然。

图 1:跳表的两件事。上半部分是一条三层索引链,查找 90 时从 L3 一路往右下跳,折线只比较 4 次就命中;下半部分是范围查询,先用索引定位到区间起点,再沿底层有序链表一路扫过去。

跳表真正的竞争力,不在理论复杂度,而在工程上的“刚刚好”:

  • 实现比平衡树简单得多。红黑树插入删除要旋转、变色,跳表只改指针加概率建层;范围查询上,跳表沿底层链表直接扫,平衡树要走中序遍历(递归或显式栈)。对于需要自己实现或维护数据结构的系统来说,这是一个被低估的优势。
  • 局部修改、读路径易无锁化。这在 ConcurrentSkipListMap 这类并发容器里是实打实的优势。不过要注意,Redis ZSet 和 LevelDB MemTable 选跳表的主因并不是并发——Redis 命令执行本身是单线程,LevelDB 的写入也由 mutex 保护——而是实现简单 + 范围遍历顺手 + 内存开销可控。
  • 内存场景下 B+ 树的优势用不上。B+ 树为磁盘 I/O 做的所有优化(大扇出、页对齐、减少树高)在内存里都失去了意义。内存里一次指针跳转和一次数组访问的差距,远小于磁盘寻道和顺序读的差距。

Redis 的 ZSet 选跳表而不是 B+ 树或红黑树,逻辑就在这里:数据在内存,需要按 score 排序和范围取,跳表实现简单、范围查询顺手、内存开销可控。

一句话:内存 + 有序 + 范围查询,跳表是那个“没有明显短板”的选择。


三、B+ 树:为磁盘 I/O 而生,但有前提

B+ 树的所有设计,都指向同一个目标:把磁盘 I/O 次数压到最低。

它的关键特征:

  1. 非叶子节点只存 key,不存数据。这让每个磁盘页(InnoDB 默认 16KB)能塞下几百上千个索引项,扇出极高,树很矮。几百万行数据,树高通常只有 3~4 层。
  2. 数据全在叶子节点,叶子之间用链表串起来。范围查询和全表扫描变成沿着叶子链表顺序读,磁盘顺序读的性能远高于随机读。
  3. 节点大小对齐磁盘页。每次读一个节点就是读一个页,不浪费,也不多读。

图 2:B+ 树为什么磁盘友好。非叶子节点只存 key,所以一个 16KB 的页能塞下几百上千个索引项,扇出极高、树只有三层。数据全部落在叶子层,叶子之间用链表串起来——等值查询沿左侧虚线走「根 → 内部节点 → 叶子」,范围查询则沿底部绿色箭头在叶子链表上顺序扫。右下角对比了跳表:节点零散分布、每节点只存一个 KV,放到磁盘上一次范围查询就是多次随机 I/O。

关于 I/O 次数还要补一句:B+ 树的根节点和上层节点通常常驻 buffer pool,实际查询的磁盘 I/O 往往只有 1 到 2 次,比“树高 3 到 4 层”更乐观——树高是上界,不是每次查询都要走满。

代价同样明确:随机写、页分裂、写放大。这正是 LSM-Tree 用“顺序写 + 后台合并”换“读放大”的动机——那是另一个话题,但说明 B+ 树不是没有代价的“最优解”,而是在读写之间平衡得最稳的默认选项。

为什么 MySQL 不用跳表?因为跳表是为内存设计的。它的节点零散分布,每个节点只存一个 KV,放到磁盘上,一次范围查询可能触发大量随机 I/O。B+ 树把多个 key 打包进一个页,一次 I/O 取回一批,这才是磁盘友好的做法。

一句话:B+ 树不是“最好的”,但它是在等值、范围、排序、写入之间平衡得最稳的那个——前提是瓶颈在机械寻道或页级读取代价,而这一点在 HDD 和 SSD 上并不完全相同。


四、磁盘要分 HDD 和 SSD:瓶颈变了,结论要微调

把“磁盘”笼统当成一个东西,会掩盖一个关键事实:HDD 的瓶颈是机械寻道,SSD 的瓶颈是闪存写放大和擦除块约束。B+ 树的大扇出和页对齐是为前者设计的;面对后者,它仍然可用,但需要正视新问题,而 LSM-Tree 在某些写入场景下更具优势。

4.1 HDD:B+ 树仍然是标准答案

机械硬盘的随机访问代价极高(寻道 5–50 ms,加上旋转延迟),而 B+ 树把几百上千个 key 打包进一个页,一次 I/O 取回一批,正好把“随机寻道”转化为“少量页读取 + 页内二分”。叶子链表让范围扫描变成顺序读,顺序读在 HDD 上远快于随机读。所以前面“B+ 树为磁盘 I/O 而生”的论述,主要就是针对 HDD 的,逻辑几乎不需要调整。

4.2 SSD:B+ 树可用,但有两个新问题

SSD 没有机械寻道,随机读的代价极低(页级读取只需几十微秒),这意味着“把数据打包进页以减少寻道”的收益变小了。但 SSD 有自己的麻烦:

第一,原地更新代价高。 闪存必须先擦除整个块(128–512 KB)才能覆写,而 B+ 树的节点更新往往是原地写。SSD 通过 FTL(闪存转换层)做重映射来掩盖这个问题,但代价是写放大和垃圾回收(GC)。在写入密集的场景下,B+ 树的随机小更新会持续触发 GC,拖累性能。

第二,ZNS SSD 等新形态让问题更尖锐。 传统 SSD 靠 FTL 隐藏闪存特性,而 ZNS SSD 把写入约束暴露给主机:只能顺序写、同时打开的 zone 数量有限。把未经改造的 B+ 树直接部署到 ZNS SSD 上,吞吐量会明显下降,写入次数增加——这说明 B+ 树“为块设备设计”的前提,在新型 SSD 上并不自动成立。

图 3:HDD 与 SSD 的瓶颈不在同一处。左边机械硬盘,代价大头是磁头寻道(5~50 ms)加旋转延迟,B+ 树把几百个 key 打包进一页,正好把随机寻道换成少量页读取;右边固态硬盘没有寻道,但闪存必须先擦除整个块才能覆写,原地更新经 FTL 重映射后留下写放大和 GC,于是要再按写入强度分岔:读多写少仍是 B+ 树,写密集则考虑 LSM-Tree 或改造后的 B+ 树。

4.3 选型判断:加一层“写入强度”

在原有三步选型(数据在哪 → 要不要顺序 → 实现成本)的基础上,磁盘场景可以拆成两条分支:

条件 推荐 理由
HDD,或读多写少的 SSD 场景 B+ 树 读路径依然高效,工程成熟度无可替代
SSD,写入密集,且能接受读放大 LSM-Tree 把随机写转为顺序写,避开 SSD 的原地更新痛点
SSD,写入密集,但需要稳定读延迟 改造后的 B+ 树(如 buffered B+ tree / 冷热分层) 用缓冲或分层存储减少对闪存的随机小写

LSM-Tree 在 SSD 上的优势来自它“顺序写 + 后台合并”的机制,这与闪存“顺序写友好、原地更新昂贵”的特性天然匹配。代价是读放大和空间放大,所以它不是无条件更优,只是在写密集场景下值得优先考虑。

所以“磁盘 → B+ 树”更准确的表述是:HDD → B+ 树;SSD → 看写入强度,读多写少选 B+ 树,写密集考虑 LSM-Tree 或对 B+ 树做闪存友好改造。


五、一张表收尾

维度 哈希表 跳表 B+ 树
存储介质 内存 内存 磁盘(HDD 为主;SSD 上可用但需权衡写入)
等值查询 期望 O(1),最快 期望 O(log n) O(log n),实际 1~2 次 I/O
范围查询 不支持 支持,定位后顺序扫 支持,叶子链表顺序扫
有序遍历 不支持 支持 支持
写入模式 随机写(扩容代价高) 随机写(局部改指针) 随机写 + 页分裂,有写放大
核心优势 极致的点查速度 内存有序 + 实现简单 磁盘 I/O 最小化(尤其 HDD)
典型系统 Redis KV、Memcached Redis ZSet、LevelDB MemTable MySQL InnoDB、大多数关系库

所以怎么选,顺序是:

  1. 先问数据在哪。内存 → 继续往下。磁盘 → 再问是 HDD 还是 SSD、写入强度如何:HDD 或读多写少的 SSD → B+ 树;SSD 写密集 → LSM-Tree 或闪存友好改造的 B+ 树(LSM 本身是另一个话题,但它是这个分支的自然候选)。
  2. 再问要不要顺序。不要 → 哈希表。要 → 跳表。
  3. 最后问实现和维护成本。在内存有序结构里,跳表比平衡树更容易写对、更容易并发,这是一个经常被忽略但很实际的加分项。

前两步根据数据特征圈定候选集,第三步在候选集内做最终取舍。


引用与致谢

本文配图所用资源如下:

封面图

动图(图 1 ~ 图 3)

  • 生成工具:Animated Sketch Diagram —— 一个把技术概念画成「黑墨手绘涂鸦 + 米色纸面」风格动画图的 Claude Code 插件。
  • 项目地址:https://github.com/OLDyade/animated-sketch-diagram
  • 作者:OLDyade
  • 许可:见项目仓库中的 LICENSE 文件。
  • 说明:三张动图均由该工具生成 SVG + CSS 动画后导出为无缝循环 GIF,文字内容依据本文正文整理,图中数据为示意值(节点数、页大小、耗时区间等取的是文中给出的典型量级,不是某次真实压测的结果)。

感谢阅读。

楔子

在早些年开发 Node.js 项目的过程中,我使用过其子进程(child_process)模块的部分功能,当时需要在 Node.js 的程序中使用 Linux 下的一个命令来处理一些数据。比如实现一个使用 ls 命令来查看当前目录结构的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 这是 Node.js 官方版本 12.x 文档下提供的一个实现例子 [Child Process | Node.js v12.13.0 Documentation](https://nodejs.org/dist/latest-v12.x/docs/api/child_process.html#child_process_child_process_spawn_command_args_options)
const { spawn } = require(‘child_process’);
const ls = spawn(‘ls’, [‘-lh’, ‘/Users’]);

// 控制台正常输出
ls.stdout.on(‘data’, (data) => {
console.log(`stdout: ${data}`);
});

// 控制台异常输出
ls.stderr.on('data', (data) => {
console.error(`stderr: ${data}`);
});

// 关闭
ls.on('close', (code) => {
console.log(`child process exited with code ${code}`);
});

事实上,我们可以通过以上方式,来实现任何与本地 Linux 服务交互的工作。任何 Linux 服务器下的系统命令,或者安装的第三方软件,均可以通过 Node.js 来使用。

达摩克利斯之剑

当时只是觉得这个功能好玩、实用,但是没有进一步深入研究。直到 周下载量过 200万的 npm 包被注入恶意代码 事件的发生,让我意识到 Node.js 的子进程功能还是一把达摩克利斯之剑。在安全防范较低或者没有的情况下,极其容易被恶意利用,而且后果不堪设想。

当然,该事件当中还藏着另外一把达摩克利斯之剑:JavaScript 下的 eval 函数 。eval 函数中可以执行几乎所有的 JavaScript 代码,简直就是一个行走的编译器。在使用该函数时,同样需要做好相关的安全工作,比如限制未知用户往里面传入任何数据。

当然,事件热的时候,关注过,凉了之后也就不了了之。

又见,思考

直到最近一段时间,在做一个项目,需要对图像文件做一下处理。在 Linux/Mac 下有一个图形图像处理工具,叫 imagemagick ,里面有瑞士军刀般多的好用功能。下意识中,我就想到了使用 Node.js 的子进程功能来直接使用。但是我又觉得这么实现似乎过于简单,而且太依赖本地环境。

于是,我就去 Node.js 官网查找了一下,看有没有新增处理图形图像的新特性,很遗憾,并没有!但是我并没死心,又继续在 bing 中搜索了一下。这时我找到一片文章(nodejs图片处理工具gm用法),介绍了如何在 Node.js 环境下,使用 npm 包 gm 来处理图形图像文件。

我很高兴,并认为这是一个理想的方案,因为那篇文中关于 gm 的介绍十分清楚,并且使用起来也很简洁。在甚至连 gm 在 npm 官网上的使用手册都没有看的前提下,我就贸然使用了 gm 。

当我激动地盯着屏幕,看着那段脚本启动,然后报异常后,我的心情先是失落,然后转为意外,最后,我静静地笑了。因为异常提示没有找到 convert 命令,而 convert 命令正是我上面提到 imagemagick 工具一下的一个命令。所以我推测这个 gm 包其实就是按照我最初的那种思路实现的。

为了一探究竟并验证我的想法。首先,我看了 gm 在 npm 上的官方使用手册。果然,文档的一开始就清楚的提示,使用该工具之前需要先安装 imagemagick 。那它到底是如何使用的 imagemagick 呢?让我们接着往下看。

我看了 gm 的源码。果然, gm 引用了一个叫 cross-spawn 的包,来执行 convert 命令,而 cross-spawn 的源码中,确实是使用 child_process 子进程来实现的。

到此为止,整个过程已经理顺。而我则陷入了沉思……

可想而知,利用这种模式,即 Node.js + child_process 的方式,可以实现很多基于操作系统命令行的功能。在没有遇到 gm 之前,我只是简单地这么认为。在遇到 gm 之后,我才发现,原来真的有工程师利用这种模式来输出被广泛( gm 在 npm 上的周下载量,截止我写这篇文章为止,已经超 10 万了)使用的工具。有时候,编程技术迭代很快,日新月异,每隔一段时间就会有一个新的很炫酷的技术冒出来,然后受到大家的追捧。而有时候,编程技术迭代又很慢,很多技术明明是新瓶装旧酒,却同样可以焕发出耀眼的光芒。

所以说,在计算机这一行,如果你真心的热爱她,就应该沉下心来,认真地去理解和感悟技术的本质,而不是去一个劲儿地追逐新时髦。唯有看透本质,才是获取真理钥匙的最佳途径。

导读

使用 npm 作为包管理工具已经很多年了,但是常用的其实就那么几个功能,一些基础的但很重要的概念和用法反而不太清楚。最近抽空整理并探索一下,重新学习 npm 。

今天先对 package.json 配置文件中的 dependencies 和 devDependencies 做一番研究。

dependencies

  1. 使用环境:生产环境下所依赖的 npm 包集合;
  2. 命令行安装单个包的方式:
1
2
3
4
# 在与 package.json 同级目录下执行
# —sava-prod 为默认安装模式
# 安装以后会自动在 package.json 的 dependencies 下添加 <package-name>
npm install <package-name> [—sava-prod]
  1. 命令行集成安装方式:
1
2
3
4
5
# 在与 package.json 同级目录下执行
# 安装 dependencies 和 devDependencies 中的所有 npm 包
npm install
# 仅安装 dependencies 下的所有 npm 包
npm install —production
  1. 被调用:如果将当前工程作为一个单独包,提供给其他人使用,在对方使用 npm install 命令安装时,会自动下载 dependencies 下的包集合。

devDependencies

  1. 使用环境:开发和测试环境下所依赖的 npm 包集合;
  2. 命令行安装单个包的方式:
1
2
3
# 在与 package.json 同级目录下执行
# 安装以后会自动在 package.json 的 devDependencies 下添加 <package-name>
npm install <package-name> —sava-dev
  1. 命令行集成安装方式:
1
2
3
# 在与 package.json 同级目录下执行
# 安装 dependencies 和 devDependencies 中的所有 npm 包
npm install
  1. 被调用:如果将当前工程作为一个单独包,提供给其他人使用,在对方使用 npm install 命令安装时,不会自动下载 devDependencies 下的包集合。

建议

部署

在我以往写代码的过程中,对于需要引用包的位置,并没有做任何区分,因为在使用 npm install 时会一起安装,所以没有太在意,直到使用 nest.js 时发现,默认安装的包居然有 200M 之多。

当我需要自动化部署它们到一台线上服务器时,会花费很多时间,但其实像 @nestjs/cli 、 @types/supertest 等开发和测试工具在线上根本是用不着的。

于是我对于要往线上环境上传的工程使用了 npm install —production 命令,这时文件大小缩小为 11M ,然后再压缩以后,最终仅仅剩下了 5M 。

发布

当整个工程开发完毕,作为一个独立包待发布时,同样需要注意!如果不对 dependencies 和 devDependencies 加以区分,将开发/测试依赖写在生产依赖下,会导致调用该 npm 包的用户下载很多与其无关的包,从而造成不必要的资源浪费。

参考来源

Specifying dependencies and devDependencies in a package.json file | npm Documentation
npm install 你很明白吗 - NeverYu - CSDN博客

起因

在一次使用 vs code 时,发现一个 bug :使用中文路径 split terminal (终端分屏)时会报异常并且失败,英文路径是ok的。

经过

确认 bug

然后我在不同场景下试图复现这个 bug :

  1. 使用 vs code 新建一个工程,然后新建中文目录, bug 会 100% 复现;
  2. 在 vs code 中选择不同 shell , bash 和 zsh , bug 仍旧 100% 复现;
  3. 因为vs code的终端是基于本地系统的,所以我在本地终端中,新建中文文件,然后基于该路径,使用终端软件自带分屏功能进行分屏,并没有出现 bug ,排除终端问题。

提交 bug

目前来看,这个应该是 vs code 的一个 bug,实锤了。因此我决定将这个 bug反馈给 vs code 的工作人员。

之前在使用 vs code 时,无意中发现右下角有个笑脸,是用来接收反馈信息的,于是乎我现在选择触发它。

触发以后,点击“提交 bug ”,会弹出一个界面,用于填写bug内容,这里需要使用英文编辑(如果是中文提问,工程师会告诉你他不会中文,并无情地关闭 issue )。然后点击“在 github 预览”会跳转到 github 下(需要登录),此时如果不需要编辑,直接 submit issue 就好。

来自工程师的响应

在提交完 issue 以后的第二天早上,我收到了来自 vs code 工作人员的回复,一位来自澳大利亚的软件工程师,他告诉我提交该类型的 issue ,还需要提供一下我的 vs code 版本信息,最好还有 bug 复现的视频、动图或者截屏。

得到对方的及时回复(毕竟有时差),我还是有点小激动的。然后按照对方提示,我认真的准备了 vs code 版本信息和 bug 复现动图,并再一次提交。

这一次,在当天晚上我就得到了回复,对方提供了解决方案,修改 setting 配置,在分屏时自动返回到 vs code 工程的父路径。这样配置以后,中文路径分屏确实不报异常了,但是却修改了我原本的使用习惯。于是乎,我把这个情况再一次反馈回去。

这位来自澳大利亚的帅气工程师,这次是这么回复的:你提的问题我们后续会修复,之前告诉你的也只是当前的解决方案。简洁并且直爽,人家之所以给你提之前的方案,并不是说 bug 不存在,只是另一个解决方式,bug我们还是会改,但是你得等待。

结果

到此为止,我认为我的目的达到了,于是乎就把这个issue关了。但是没过多久,这个工程却重新打开了 issue 。我没有再追问,我推测在 bug 修复之前, issue 应该是不会关闭,具体是不是这个澳洲小伙修复就不得而知了。

关于这次issue的整个过程,在 github 上完成过程,感兴趣的同学可以自行查阅。(terminal.integrataed.splitCwd inherited doesn’t work correctly for unicode characters · Issue #83496 · microsoft/vscode · GitHub)

总体思路

  1. 在linux服务器新建无登录账号(/sbin/nologin/)
  2. 构建自动化启动进程脚本
  3. 新建计划任务定时监控服务(crontab)

初衷

在服务器的管理中,我们需要启动一些服务,出于安全方面的考虑,我们不希望以root的方式启动它,而是为这些服务单独建立相应的账号,并且禁用它们的shell登录权限。

实现过程

场景说明

在linux服务器上实现对于nodejs进程进行守护的场景。

构建nodejs程序

  1. 我们新建一个nodejs程序server.js:
1
2
3
4
5
6
7
8
9
const http = require(‘http’)
const server = http.createServer(function (req, res) {
res.writeHead(200, {
‘Content-Type’: ‘application/json’
});
res.end(‘{“server”:”nodejs”}’);
});
server.listen(8080)
console.log(“port = 8080”)
  1. 常规启动:
1
node server.js

启动以后,server.js进程属于root用户,或者其他可以shell登录的用户:

3. 在浏览器敲入ip加端口即可查看运行结果:

新建无登录账号

  1. 新建账号:
1
useradd -M -s /sbin/nologin nodejsUser
  1. 在/etc/passwd下查看账号状态:

    nodejsUser是无法shell登录的,我们可以使用如下命令启动server.js:
1
2
3
4
# 赋予nodejsUser账号可访问server.js文件的权限
sudo chown nodejsUser /opt/nodejsUser/server.js
# 启动
sudo -u nodejsUser node /opt/nodejsUser/server.js
  1. 此时查看8080端口,可以发现,现在用户是nodejsUser了:

编写自动化启动脚本

  1. 此处我们需要借助tmux工具来实现实时监控nodejs进程的功能,下面是具体实现过程server.sh:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/bin/bash
# tumx启动脚本

# 兼容zsh
export DISABLE_AUTO_TITLE="true"
# 会话名称
session=“sever”
# 判断server.js进程是否存在
ps -ef|grep "node /opt/nodejsUser/server.js" |grep -v grep
if [ $? -ne 0 ]
then
echo "start process....."
# 判断tmux会话是否存在
tmux has-session -t $session
if [ $? = 0 ]
then
# 如果存在,则进入该会话
tmux attach-session -t $session
else
# 如果不存在,则新建会话
tmux new-session -d -s $session
fi
# 发送启动程序命令
tmux send-keys -t $session "sudo -u nodejsUser node /opt/nodejsUser/server.js" C-m
else
echo "runing....."
fi
exit
  1. 当tmux会话启动以后,可以随时查看server.js运行的情况:
1
2
# 	启动会话
tmux a -t server

自定义计划任务

  1. 新建计划任务:
1
crontab -e
  1. 任务内容:
1
*/1 * * * * /bin/bash /opt/nodejsUser/server.sh >> /opt/nodejsUser/run.log 2>&1

每分钟执行一次server.sh脚本,执行情况记录到日志文件run.log当中。crontab的定时机制可以自行查阅,比较好理解,这里不再赘述。

写在最后的话

到此为止,所有功能均已实现,我们的计划任务会定时对server.js进程扫描,当发现该进程crash时,会自动重启。当服务器重启时,计划任务也会超常运行。

其实在这之前,我在管理nodejs项目时,使用的是pm2监控工具,现成的免费工具,用起来确实也比较方便,但是在无登录账号下遇到无法启动pm2的问题,虽然花了一些时间攻克,但最终并没有解决,因此就有了本文中的解决方案,先用一段时间看看效果吧。

0%