style: 全站改造为 Editorial 编辑杂志风

- 设计令牌:暖米 #F9F8F6 纸面 × 柔和黑 #1C1C1C,透明度分层 /60 /40 /10,纯单色
- 圆角归零、阴影清零、渐变清除;删除渐变光斑背景层 Background.astro
- 字体:Playfair Display + Noto Serif SC 衬线标题(weight 400)+ Noto Sans SC 正文
- 首页巨型衬线刊名 + 日期线 + 最新发布索引卡;Section 自动编号
- 文章页改刊头式标题区;归档改衬线年号 + 目录行;卡片去渐变封面改纯排版
- 交互:hover 细线下划线、标题转斜体、油墨反转,prefers-reduced-motion 降级保留
- 文件处理逻辑 / 文章内容 / deploy 上传脚本未改动
- 附:新增 LLM 系列笔记与配图、Artalk 部署配置、评论组件入库
This commit is contained in:
yukun-hh 2026-08-27 22:52:39 +08:00
parent 6725a02592
commit 5f1c21b6bf
49 changed files with 4471 additions and 909 deletions

4
.gitignore vendored
View file

@ -14,3 +14,7 @@ dist/
# 编辑器
.vscode/
.idea/
# SSH 密钥(严禁入库)
repo
repo.pub

160
deploy/artalk/artalk.yml Normal file
View file

@ -0,0 +1,160 @@
# Artalk 配置 · Yukun's Blog
# 基于官方 conf/artalk.example.zh-CN.yml 裁剪
# 文档https://artalk.js.org
#
# ★ 部署前必改:
# 1. app_key → 执行 `openssl rand -hex 16` 生成随机字符串填入(加密密钥,勿泄露)
# 2. admin_users → 可留空,启动后 `docker exec -it artalk artalk admin` 交互创建
# 3. site_url → 确认主站域名
# ----------------------------------------------------------------------
# 服务器
host: "0.0.0.0"
port: 23366
# ★ 加密密钥务必生成随机值openssl rand -hex 16
app_key: ""
debug: false
locale: zh-CN
timezone: Asia/Shanghai
# 站点
site_default: "Yukun's Blog"
# ★ 备案期间用 IP 预览地址;备案完成后改回 https://sausagetoast.cloud
site_url: "http://106.54.37.223:8321"
login_timeout: 259200
# 数据库SQLite 开箱即用,数据落地 ./data/artalk.db
db:
type: sqlite
file: ./data/artalk.db
prepare_stmt: true
# 日志
log:
enabled: true
filename: ./data/artalk.log
# HTTP
http:
body_limit: 100
# 走 nginx 反代时填 X-Forwarded-For 获取真实 IP
proxy_header: "X-Forwarded-For"
# ★ 可信域名CORS 防护:前端页面所在来源都要填)
trusted_domains:
- "https://sausagetoast.cloud"
- "http://localhost:4321"
- "http://106.54.37.223:8321" # 备案期间的主站预览地址(备案完成后可删)
# 管理员账户留空启动后用命令创建docker exec -it artalk artalk admin
admin_users: []
# ★ 审核与反垃圾(匿名方案必备)
moderator:
# 新评论默认直接显示(低流量博客可接受;想严格审核改 true
pending_default: false
# 反垃圾 API 出错时是否拦截false = 出错放行,避免误伤)
api_fail_block: false
# Akismet 反垃圾可选https://akismet.com 申请 key
akismet_key: ""
# 腾讯云/阿里云内容安全(可选,留默认关闭)
tencent:
enabled: false
aliyun:
enabled: false
# ★ 本地关键词过滤(离线词库,零成本防垃圾)
keywords:
enabled: true
pending: true # 命中关键词 → 设为待审
files:
- ./data/keywords.txt
file_sep: "\n"
replace_to: "*"
# ★ 验证码(匿名评论防垃圾主力)
captcha:
enabled: true
always: false # 操作 3 次后才要求验证码(降低打扰)
captcha_type: image # 图形验证码,零第三方依赖
action_limit: 3
action_reset: 60
# IP 属地展示(可选,默认关闭)
ip_region:
enabled: false
db_path: ./data/ip2region.xdb
precision: province
# 图片上传
img_upload:
enabled: true
path: ./data/artalk-img/
max_size: 5
public_path: null
# ★ 邮件通知(国内服务器不部署 SMTP关闭
email:
enabled: false
# ★ 管理员通知(无 SMTP邮件通知关闭如需可开 Telegram/Bark 等推送)
admin_notify:
notify_pending: false
noise_mode: false
email:
enabled: false
telegram:
enabled: false
api_token: ""
receivers: []
bark:
enabled: false
server: ""
webhook:
enabled: false
url: ""
# ★ 身份认证(匿名方案:允许仅填昵称+邮箱评论)
auth:
enabled: true
# 允许匿名评论(跳过验证,仅填昵称+邮箱)
anonymous: true
# 邮箱验证码登录(无 SMTP 无法发验证码,关闭)
email:
enabled: false
# 社交登录默认全部关闭;需要时参考官方文档逐项开启
github:
enabled: false
google:
enabled: false
# 界面默认配置(前端 Comments.astro 也会覆盖部分项)
frontend:
placeholder: "喵~说点什么吧(昵称 + 邮箱即可评论)"
noComment: "快来抢沙发喵~"
sendBtn: "发送"
editorTravel: true
emoticons: https://cdn.jsdelivr.net/gh/ArtalkJS/Emoticons/grps/default.json
vote: true
voteDown: false
uaBadge: false
listSort: true
preview: true
flatMode: auto
nestMax: 2
nestSort: DATE_ASC
darkMode: inherit
gravatar:
mirror: https://weavatar.com/avatar/
params: sha256=1&d=mp&s=240
pagination:
pageSize: 20
readMore: true
autoLoad: true
heightLimit:
content: 300
children: 400
scrollable: false
imgLazyLoad: false
reqTimeout: 15000
versionCheck: true
pluginURLs: []

View file

@ -0,0 +1,19 @@
# Artalk 评论后端 · Docker Compose
# 部署位置VPS /opt/artalk/ (与此文件同目录需有 artalk.yml 和 ./data/
# 启动docker compose up -d
# 反代:./deploy/proxy.sh comments 8080 → https://comments.sausagetoast.cloud
services:
artalk:
container_name: artalk
image: artalk/artalk-go:latest
restart: unless-stopped
# ★ 备案期间:监听所有网卡,前端直接 http://<服务器IP>:8080 访问(无需子域名)
# 需在云服务器安全组/防火墙放行 TCP 8080
# 备案完成改用子域名反代deploy/proxy.sh建议改回 "127.0.0.1:8080:23366" 更安全
ports:
- "8080:23366"
volumes:
- ./data:/data # SQLite 数据库 + 图片 + 日志
- ./artalk.yml:/data/artalk.yml:ro # 配置文件(只读挂载)
environment:
- TZ=Asia/Shanghai

BIN
public/beian-mps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

BIN
public/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
public/images/yarn_ramp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -1,8 +0,0 @@
---
// 固定背景层:渐变底 + 漂浮光斑 + 极淡网格
---
<div class="bg-layers" aria-hidden="true">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>

View file

@ -0,0 +1,239 @@
---
// 评论区组件 · 自托管 Artalk
// 部署VPS 跑 artalk/artalk-go 容器 + ./deploy/proxy.sh comments 8080
// 备案期间 comments.sausagetoast.cloud 可能不通,组件会静默降级(不破坏页面)
interface Props {
/** 页面标识,文章用 /posts/<slug>,需与路由生成的 URL 一致 */
pageKey: string;
/** 页面标题,用于后台展示与通知 */
pageTitle: string;
}
const { pageKey, pageTitle } = Astro.props;
// 评论后端服务地址构建期注入SSG 会烤进 HTML
// - 备案期间 IP 直连PUBLIC_ARTALK_SERVER=http://<服务器IP>:8080 npm run build
// deploy/ip-preview.sh 检测到 REMOTE_HOST 是 IP 时会自动注入)
// - 备案完成走子域名反代:不设置,使用下面的默认值
// - 本地联调PUBLIC_ARTALK_SERVER=http://localhost:8080 npm run dev
// (并在 artalk.yml 的 trusted_domains 放行对应来源)
const SERVER =
import.meta.env.PUBLIC_ARTALK_SERVER?.replace(/\/+$/, '') ||
'https://comments.sausagetoast.cloud';
const SITE = "Yukun's Blog";
---
<section class="comments reveal" aria-label="评论区">
<h2 class="comments-title">
<span>评论</span>
</h2>
<div
id="artalk-container"
class="artalk-container glass"
data-page-key={pageKey}
data-page-title={pageTitle}
data-server={SERVER}
data-site={SITE}
>
<div class="comments-loading" id="comments-loading">
<span class="loading-dot"></span>
<span>评论加载中…</span>
</div>
</div>
<link rel="stylesheet" href={`${SERVER}/dist/Artalk.css`} />
</section>
<script is:inline>
// 自托管 Artalk 前端:从 comments 子域名动态拉取 Artalk.js 后初始化
// 用 is:inline 原样输出,避免 Astro/Vite 改写;参数走 data-* 属性传递
(function () {
var container = document.getElementById('artalk-container');
var loading = document.getElementById('comments-loading');
if (!container) return;
var SERVER = container.dataset.server;
var SITE = container.dataset.site;
var pageKey = container.dataset.pageKey;
var pageTitle = container.dataset.pageTitle;
// 兜底15s 还没加载出来就提示,避免一直转圈
var fallbackTimer = setTimeout(function () {
if (loading && loading.style.display !== 'none') {
var tip = loading.querySelector('span:last-child');
if (tip) tip.textContent = '评论服务暂时无法连接,请稍后再试';
}
}, 15000);
function bootstrap() {
if (typeof window.Artalk === 'undefined') {
return setTimeout(bootstrap, 50);
}
clearTimeout(fallbackTimer);
if (loading) loading.style.display = 'none';
window.Artalk.init({
el: '#artalk-container',
pageKey: pageKey,
pageTitle: pageTitle,
server: SERVER,
site: SITE,
flatMode: 'auto',
uaBadge: false,
darkMode: false
});
}
var s = document.createElement('script');
s.src = SERVER + '/dist/Artalk.js';
s.defer = true;
s.onload = bootstrap;
s.onerror = function () {
clearTimeout(fallbackTimer);
if (loading) {
var tip = loading.querySelector('span:last-child');
if (tip) tip.textContent = '评论服务暂时无法连接,请稍后再试';
}
};
document.head.appendChild(s);
})();
</script>
<style>
.comments {
margin-top: clamp(28px, 4vw, 44px);
display: flex;
flex-direction: column;
gap: 16px;
}
.comments-title {
display: flex;
align-items: center;
gap: 18px;
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(1.4rem, 3vw, 1.8rem);
letter-spacing: -0.02em;
color: var(--ink);
}
.comments-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--line);
}
.artalk-container {
border: 1px solid var(--line);
border-radius: 0;
background: transparent;
padding: clamp(16px, 2.5vw, 24px);
min-height: 220px;
position: relative;
}
.comments-loading {
display: flex;
align-items: center;
gap: 8px;
justify-content: center;
color: var(--ink-40);
font-size: 0.9rem;
padding: 48px 0;
}
.loading-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink);
animation: atk-loading-pulse 1.2s ease-in-out infinite;
}
@keyframes atk-loading-pulse {
0%, 100% { opacity: 0.3; transform: scale(0.8); }
50% { opacity: 1; transform: scale(1.2); }
}
/* === Artalk UI 适配编辑杂志风(覆盖其默认样式)===
纯单色体系:把 Artalk 的 CSS 变量改写到本站纸墨令牌,
链接与按钮均为油墨色,无彩色强调
Artalk v2 类名前缀为 .atk-,如部署后部分不生效,可在浏览器对照实际 DOM 微调选择器 */
:global(.artalk),
:global(.atk-layer-wrap) {
--at-color-font: var(--ink);
--at-color-deep: var(--ink);
--at-color-sub: var(--ink-60);
--at-color-grey: var(--ink-40);
--at-color-meta: var(--ink-40);
--at-color-border: rgba(28, 28, 28, 0.12);
--at-color-main: var(--ink);
--at-color-bg: var(--paper);
--at-color-bg-transl: rgba(249, 248, 246, 0.92);
--at-color-bg-grey: rgba(28, 28, 28, 0.04);
--at-color-bg-grey-transl: rgba(249, 248, 246, 0.78);
--at-color-gradient: transparent;
}
:global(.atk-list) {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
:global(.atk-main-editor) {
background: rgba(28, 28, 28, 0.02) !important;
border: 1px solid var(--ink-20) !important;
border-radius: 0 !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global(.atk-main-editor .atk-textarea) {
background: transparent !important;
color: var(--ink) !important;
border: none !important;
border-radius: 0 !important;
}
:global(.atk-main-editor .atk-header input) {
color: var(--ink) !important;
}
/* 隐藏「网址」输入框name="link"Artalk 固定字段无官方开关CSS 隐藏即可
它本非必填,隐藏后评论只需昵称 + 邮箱 */
:global(.atk-main-editor .atk-header input[name="link"]) {
display: none !important;
}
:global(.atk-main-editor .atk-header input::placeholder),
:global(.atk-main-editor .atk-textarea::placeholder) {
color: var(--ink-40) !important;
}
:global(.atk-comment .atk-avatar img) {
border-radius: 0 !important;
}
:global(.atk-send-btn),
:global(.atk-btn-main),
:global(.atk-submit-btn) {
background: var(--ink) !important;
color: var(--paper) !important;
border: 1px solid var(--ink) !important;
border-radius: 0 !important;
transition: opacity 0.3s;
}
:global(.atk-send-btn:hover),
:global(.atk-btn-main:hover) {
opacity: 0.82;
}
:global(.atk-comment-wrap),
:global(.atk-layer-wrap),
:global(.atk-comment) {
border-radius: 0 !important;
}
:global(.atk-nick),
:global(a.atk-link),
:global(.atk-link) {
color: var(--ink) !important;
}
:global(.atk-nick:hover),
:global(a.atk-link:hover) {
opacity: 0.7;
}
:global(.atk-pagination .atk-btn) {
border-radius: 0 !important;
}
:global(.atk-emoticons-pack) {
border-radius: 0 !important;
}
</style>

View file

@ -6,103 +6,112 @@ const range = year - startYear;
<footer class="footer">
<div class="container">
<div class="glass footer-card">
<div class="footer-grid">
<div class="footer-brand">
<span class="brand-mark">Y</span>
<div>
<div class="footer-name">Yukun&rsquo;s Blog</div>
<div class="footer-sig">记录代码与生活 · 淡蓝色的液态玻璃</div>
</div>
<span class="footer-name">Yukun&rsquo;s Blog</span>
<span class="footer-sig">记录代码与生活</span>
</div>
<nav class="footer-nav" aria-label="页脚导航">
<a href="/">首页</a>
<a href="/posts">文章</a>
<a href="/tags">标签</a>
<a href="/archives">归档</a>
<a href="/about">关于</a>
<a href="/" class="u">首页</a>
<a href="/posts" class="u">文章</a>
<a href="/tags" class="u">标签</a>
<a href="/archives" class="u">归档</a>
<a href="/about" class="u">关于</a>
</nav>
<div class="footer-bottom">
<div class="footer-meta">
<span>© {startYear}{range > 0 ? `${year}` : ''} Yukun · sausagetoast.cloud</span>
<a
class="footer-icp"
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
title="工信部 ICP 备案查询"
>沪ICP备2026041530号-1</a>
<a
class="footer-icp"
href="https://beian.mps.gov.cn/#/query/webSearch?code=31011402022267"
target="_blank"
rel="noreferrer"
title="全国互联网安全管理服务平台"
><img src="/beian-mps.png" alt="公安备案图标" class="beian-icon" width="14" height="16" loading="lazy" />沪公网安备31011402022267号</a>
<a href="https://icp.gov.moe/?keyword=20269977" target="_blank">萌ICP备20269977号</a>
</div>
<span class="built">由 Astro 静态生成 · 喵~</span>
</div>
<div class="footer-bottom">
<div class="footer-meta">
<span>© {startYear}{range > 0 ? `${year}` : ''} Yukun · sausagetoast.cloud</span>
<a
class="footer-icp"
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
title="工信部 ICP 备案查询"
>沪ICP备2026041530号-1</a>
<a
class="footer-icp"
href="https://beian.mps.gov.cn/#/query/webSearch?code=31011402022267"
target="_blank"
rel="noreferrer"
title="全国互联网安全管理服务平台"
><img src="/beian-mps.png" alt="公安备案图标" class="beian-icon" width="14" height="16" loading="lazy" />沪公网安备31011402022267号</a>
<a href="https://icp.gov.moe/?keyword=20269977" target="_blank">萌ICP备20269977号</a>
</div>
<span class="built">由 Astro 静态生成 · 喵~</span>
</div>
</div>
</footer>
<style>
.footer { padding: 40px 0 32px; }
.footer-card {
border-radius: var(--r-lg);
padding: clamp(24px, 4vw, 40px);
.footer {
margin-top: clamp(64px, 10vw, 160px);
border-top: 1px solid var(--line);
padding: clamp(48px, 7vw, 88px) 0 40px;
}
.footer-grid {
display: flex;
flex-direction: column;
gap: 20px;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 24px 48px;
}
.footer-brand { display: flex; align-items: center; gap: 14px; }
.footer-brand .brand-mark {
display: grid;
place-items: center;
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, var(--blue-500), var(--cyan-400));
color: #fff;
.footer-brand { display: flex; flex-direction: column; gap: 6px; }
.footer-name {
font-family: var(--font-display);
font-weight: 800;
box-shadow: 0 4px 14px rgba(47, 127, 224, 0.35);
font-weight: 400;
font-size: 1.15rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--ink);
}
.footer-name { font-family: var(--font-display); font-weight: 800; font-size: 1.1rem; color: var(--ink); }
.footer-sig { color: var(--ink-faint); font-size: 0.85rem; margin-top: 2px; }
.footer-nav { display: flex; flex-wrap: wrap; gap: 8px 18px; }
.footer-nav a { color: var(--ink-soft); font-weight: 600; font-size: 0.9rem; }
.footer-nav a:hover { color: var(--blue-600); }
.footer-sig {
font-style: italic;
color: var(--ink-60);
font-size: 0.88rem;
}
.footer-nav { display: flex; flex-wrap: wrap; gap: 12px 24px; }
.footer-nav a {
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-60);
transition: color 0.3s;
}
.footer-nav a:hover { color: var(--ink); }
.footer-bottom {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6px 14px;
padding-top: 16px;
border-top: 1px solid rgba(79, 163, 255, 0.18);
color: var(--ink-faint);
font-size: 0.82rem;
gap: 8px 16px;
margin-top: clamp(32px, 5vw, 56px);
padding-top: 20px;
border-top: 1px solid var(--line);
color: var(--ink-40);
font-size: 0.74rem;
letter-spacing: 0.05em;
}
.footer-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 14px;
gap: 6px 16px;
}
.footer-icp {
color: var(--ink-faint);
font-size: 0.82rem;
transition: color .2s;
color: var(--ink-40);
font-size: 0.74rem;
transition: color 0.2s;
display: inline-flex;
align-items: center;
gap: 4px;
}
.footer-icp:hover { color: var(--blue-600); }
.footer-icp:hover { color: var(--ink); }
.beian-icon {
width: 14px;
height: 16px;
vertical-align: -3px;
}
@media (max-width: 768px) {
.footer-nav { gap: 6px 14px; }
.footer-grid { flex-direction: column; }
}
</style>

View file

@ -11,19 +11,16 @@ const path = Astro.url.pathname.replace(/\/+$/, '').replace(/^\//, '');
const current = path === '' ? 'index' : path.split('/')[0];
---
<header id="nav" class="nav glass" data-scrolled="0">
<header id="nav" class="nav" data-scrolled="0">
<div class="container nav-inner">
<a href="/" class="brand">
<span class="brand-mark">Y</span>
<span class="brand-text">Yukun<span class="apos">&rsquo;</span>s Blog</span>
</a>
<a href="/" class="brand">Yukun&rsquo;s Blog</a>
<nav class="nav-links" aria-label="主导航">
{
navItems.map((item) => (
<a
href={item.href}
class:list={['nav-link', { active: current === item.match }]}
class:list={['nav-link', 'u', { active: current === item.match }]}
aria-current={current === item.match ? 'page' : undefined}
>
{item.label}
@ -33,7 +30,7 @@ const current = path === '' ? 'index' : path.split('/')[0];
</nav>
<button id="search-trigger" class="nav-search" aria-label="搜索文章" title="搜索 (Ctrl+K)">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.5" y2="16.5" />
</svg>
@ -63,18 +60,13 @@ const current = path === '' ? 'index' : path.split('/')[0];
height: var(--nav-h);
display: flex;
align-items: center;
background: transparent;
border: 0;
box-shadow: none;
transition: background 0.35s, box-shadow 0.35s, border-color 0.35s, backdrop-filter 0.35s;
}
.nav[data-scrolled='1'] {
background: var(--glass);
backdrop-filter: blur(22px) saturate(160%);
-webkit-backdrop-filter: blur(22px) saturate(160%);
border-bottom: 1px solid var(--glass-border);
box-shadow: 0 4px 24px rgba(31, 96, 160, 0.08);
background: rgba(249, 248, 246, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border-bottom: 1px solid transparent;
transition: border-color 0.35s;
}
.nav[data-scrolled='1'] { border-bottom-color: var(--line); }
.nav-inner {
display: flex;
align-items: center;
@ -82,75 +74,49 @@ const current = path === '' ? 'index' : path.split('/')[0];
gap: 16px;
height: 100%;
}
/* 刊名:衬线 + 大字距 */
.brand {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 800;
font-size: 1.1rem;
font-weight: 400;
font-size: 1.05rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: var(--ink);
letter-spacing: -0.02em;
white-space: nowrap;
}
.brand:hover { color: var(--ink); }
.brand-mark {
display: grid;
place-items: center;
width: 30px; height: 30px;
border-radius: 9px;
background: linear-gradient(135deg, var(--blue-500), var(--cyan-400));
color: #fff;
font-size: 1rem;
box-shadow: 0 4px 12px rgba(47, 127, 224, 0.4);
}
.brand-text .apos { color: var(--blue-400); }
.nav-links {
display: flex;
align-items: center;
gap: 4px;
gap: clamp(18px, 3vw, 32px);
margin-left: auto;
}
.nav-link {
position: relative;
padding: 8px 14px;
border-radius: 999px;
font-weight: 600;
font-size: 0.95rem;
color: var(--ink-soft);
transition: color 0.2s, background 0.2s;
}
.nav-link:hover { color: var(--blue-600); background: rgba(132, 194, 255, 0.14); }
.nav-link.active { color: var(--blue-600); background: rgba(132, 194, 255, 0.2); }
.nav-link.active::after {
content: '';
position: absolute;
left: 50%;
bottom: 1px;
transform: translateX(-50%);
width: 5px; height: 5px;
border-radius: 50%;
background: var(--blue-500);
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-60);
transition: color 0.3s;
padding-bottom: 2px;
}
.nav-link:hover { color: var(--ink); }
.nav-link.active { color: var(--ink); }
.nav-search {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.4);
color: var(--ink-soft);
border: 1px solid var(--line);
background: transparent;
color: var(--ink-60);
cursor: pointer;
transition: all 0.2s;
transition: border-color 0.3s, color 0.3s;
}
.nav-search:hover { border-color: var(--blue-300); color: var(--blue-600); }
.nav-search:hover { border-color: var(--ink-40); color: var(--ink); }
.kbd {
font-size: 0.72rem;
font-size: 0.68rem;
font-family: var(--font-mono);
padding: 2px 6px;
border-radius: 5px;
background: rgba(132, 194, 255, 0.18);
color: var(--blue-700);
letter-spacing: 0.08em;
color: var(--ink-40);
}
.menu-toggle {
display: none;
@ -162,33 +128,37 @@ const current = path === '' ? 'index' : path.split('/')[0];
cursor: pointer;
}
.menu-toggle span {
width: 22px; height: 2px;
width: 22px; height: 1px;
background: var(--ink);
border-radius: 2px;
transition: transform 0.3s, opacity 0.3s;
}
.mobile-menu {
display: none;
flex-direction: column;
gap: 4px;
padding: 12px 16px 16px;
padding: 8px 24px 20px;
}
.mobile-link {
padding: 12px 14px;
border-radius: 12px;
font-weight: 600;
color: var(--ink);
padding: 14px 0;
border-bottom: 1px solid var(--line);
font-size: 0.78rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-60);
transition: color 0.3s, padding-left 0.3s;
}
.mobile-link.active { background: rgba(132, 194, 255, 0.2); color: var(--blue-600); }
.mobile-link:hover, .mobile-link.active { color: var(--ink); }
.mobile-search {
margin-top: 6px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.5);
margin-top: 14px;
padding: 12px 0;
border: 0;
border-bottom: 1px solid var(--line);
background: none;
text-align: left;
cursor: pointer;
color: var(--ink-soft);
font-size: 0.78rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-40);
}
/* 移动端:折叠菜单 */
@ -198,15 +168,13 @@ const current = path === '' ? 'index' : path.split('/')[0];
.nav[data-scrolled='1'] .mobile-menu,
.mobile-menu[data-open='1'] {
display: flex;
border-top: 1px solid var(--glass-border);
background: var(--glass-strong);
backdrop-filter: blur(22px) saturate(160%);
-webkit-backdrop-filter: blur(22px) saturate(160%);
border-top: 1px solid var(--line);
background: var(--paper);
}
.menu-toggle[aria-expanded='true'] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.menu-toggle[aria-expanded='true'] span:nth-child(2) { opacity: 0; }
.menu-toggle[aria-expanded='true'] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
.mobile-menu[data-open='0'] { display: none; }
.menu-toggle[aria-expanded='true'] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.menu-toggle[aria-expanded='true'] span:nth-child(2) { opacity: 0; }
.menu-toggle[aria-expanded='true'] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
}
</style>

View file

@ -1,6 +1,6 @@
---
import type { Post } from '../lib/utils';
import { formatDate, readingTime, heroCss } from '../lib/utils';
import { formatDate, readingTime } from '../lib/utils';
interface Props {
post: Post;
@ -14,105 +14,107 @@ const rt = readingTime(post.body ?? '');
const date = formatDate(data.date);
---
<article class:list={['card', 'glass', variant, 'reveal', cls]}>
<article class:list={['card', variant, 'reveal', cls]}>
<a href={href} class="card-link" aria-label={data.title}>
<div class="hero" style={`background:${heroCss(post)}`}>
<span class="hero-emoji">{data.pinned ? '★' : variant === 'featured' ? '✦' : '◆'}</span>
<div class="meta">
<time datetime={data.date.toISOString()}>{date}</time>
<span class="dot"></span>
<span>{rt}阅读</span>
{data.pinned && <span class="pin">置顶</span>}
</div>
<div class="body">
<div class="meta">
<time datetime={data.date.toISOString()}>{date}</time>
<span class="dot"></span>
<span>{rt}阅读</span>
</div>
<h3 class="title">{data.title}</h3>
{data.description && <p class="desc">{data.description}</p>}
<h3 class="title">{data.title}</h3>
{data.description && <p class="desc">{data.description}</p>}
<div class="foot">
<div class="tags">
{data.tags.slice(0, 3).map((t) => <span class="chip">{t}</span>)}
{data.tags.length > 3 && <span class="chip more">+{data.tags.length - 3}</span>}
</div>
<span class="read">
阅读全文
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</span>
</div>
</a>
</article>
<style>
.card {
border-radius: var(--r-lg);
overflow: hidden;
transition: transform 0.4s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.4s;
border: 1px solid var(--line);
background: transparent;
transition: border-color 0.5s, background-color 0.5s;
}
.card:hover {
transform: translateY(-6px);
box-shadow: 0 24px 50px rgba(31, 96, 160, 0.2), 0 6px 16px rgba(31, 96, 160, 0.1);
border-color: var(--ink);
background: rgba(28, 28, 28, 0.02);
}
.card-link {
display: flex;
flex-direction: column;
gap: 14px;
height: 100%;
padding: clamp(22px, 3vw, 30px);
color: inherit;
}
.hero {
position: relative;
aspect-ratio: 16 / 7;
display: grid;
place-items: center;
overflow: hidden;
.meta { color: var(--ink-40); }
.pin {
border: 1px solid var(--ink-20);
padding: 1px 8px;
font-size: 0.68rem;
letter-spacing: 0.2em;
}
.hero::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.4), transparent 50%),
linear-gradient(180deg, transparent 40%, rgba(0, 60, 120, 0.18));
}
.hero-emoji {
position: relative;
font-size: 1.8rem;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 2px 12px rgba(0, 40, 80, 0.3);
z-index: 1;
}
.body {
padding: clamp(16px, 2.5vw, 22px);
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.meta { color: var(--ink-faint); font-size: 0.82rem; display: flex; align-items: center; gap: 8px; }
.meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: 0.5; }
.title {
font-family: var(--font-display);
font-weight: 700;
font-size: clamp(1.15rem, 1rem + 0.8vw, 1.4rem);
line-height: 1.35;
font-weight: 400;
font-size: clamp(1.3rem, 1.1rem + 0.8vw, 1.65rem);
line-height: 1.3;
letter-spacing: -0.02em;
color: var(--ink);
transition: all 0.5s;
}
.card:hover .title { color: var(--blue-600); }
.card:hover .title { font-style: italic; }
.desc {
color: var(--ink-soft);
font-size: 0.92rem;
line-height: 1.7;
color: var(--ink-60);
font-size: 0.9rem;
line-height: 1.75;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 4px; }
.chip.more { background: rgba(107, 135, 163, 0.14); color: var(--ink-faint); border-color: rgba(107, 135, 163, 0.2); }
.foot {
margin-top: auto;
padding-top: 14px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.chip.more { border-color: transparent; color: var(--ink-40); padding-inline: 2px; }
.read {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-40);
white-space: nowrap;
}
.read svg { transition: transform 0.5s var(--ease, cubic-bezier(0.22, 0.61, 0.36, 1)); }
.card:hover .read { color: var(--ink); }
.card:hover .read svg { transform: translateX(4px); }
/* featured 加大 */
.card.featured .hero { aspect-ratio: 16 / 6; }
.card.featured .title { font-size: clamp(1.5rem, 1rem + 2vw, 2rem); }
.card.featured .desc { font-size: 1rem; -webkit-line-clamp: 3; line-clamp: 3; }
/* featured 横向布局(桌面) */
@media (min-width: 768px) {
.card.featured .card-link { flex-direction: row; }
.card.featured .hero { aspect-ratio: unset; width: 42%; flex-shrink: 0; }
.card.featured .body { padding: 28px 30px; justify-content: center; }
/* featured放大标题更强留白 */
.card.featured { padding: 0; }
.card.featured .card-link { padding: clamp(28px, 4vw, 48px); gap: 18px; }
.card.featured .title { font-size: clamp(1.8rem, 1.2rem + 2.4vw, 3rem); }
.card.featured .desc {
font-size: 1rem;
max-width: 56ch;
-webkit-line-clamp: 3;
line-clamp: 3;
}
</style>

View file

@ -28,9 +28,9 @@
align-items: flex-start;
justify-content: center;
padding: clamp(20px, 8vh, 90px) 16px 20px;
background: rgba(20, 50, 80, 0.28);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
background: rgba(28, 28, 28, 0.32);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
opacity: 0;
visibility: hidden;
transition: opacity 0.25s, visibility 0.25s;
@ -39,10 +39,11 @@
.search-panel {
width: 100%;
max-width: 620px;
border-radius: var(--r-lg);
background: var(--paper);
border: 1px solid var(--ink-20);
overflow: hidden;
transform: translateY(-12px) scale(0.98);
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
transform: translateY(-10px);
transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.search-overlay[data-open='1'] .search-panel { transform: none; }
.search-bar {
@ -50,28 +51,31 @@
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid rgba(79, 163, 255, 0.18);
border-bottom: 1px solid var(--line);
}
.search-ico { color: var(--blue-500); flex-shrink: 0; }
.search-ico { color: var(--ink-40); flex-shrink: 0; }
#search-input {
flex: 1;
border: 0;
border-bottom: 0;
background: none;
outline: none;
font-size: 1.05rem;
font-family: var(--font-serif);
font-size: 1.2rem;
color: var(--ink);
}
#search-input::placeholder { color: var(--ink-faint); }
#search-input::placeholder { color: var(--ink-40); }
.search-close {
font-family: var(--font-mono);
font-size: 0.72rem;
font-size: 0.7rem;
padding: 4px 9px;
border-radius: 6px;
border: 1px solid var(--blue-200);
background: rgba(132, 194, 255, 0.14);
color: var(--blue-700);
border: 1px solid var(--line);
background: transparent;
color: var(--ink-40);
cursor: pointer;
transition: border-color 0.3s, color 0.3s;
}
.search-close:hover { border-color: var(--ink-40); color: var(--ink); }
.search-results {
max-height: min(52vh, 460px);
overflow-y: auto;
@ -81,32 +85,37 @@
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
padding: 12px 14px;
cursor: pointer;
transition: background 0.15s;
transition: background-color 0.2s;
}
.result:hover, .result.active { background: rgba(132, 194, 255, 0.2); }
.result-title { font-weight: 600; color: var(--ink); font-size: 0.96rem; }
.result-desc { color: var(--ink-faint); font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result:hover, .result.active { background: rgba(28, 28, 28, 0.04); }
.result-title {
font-family: var(--font-serif);
font-weight: 400;
color: var(--ink);
font-size: 1rem;
}
.result:hover .result-title, .result.active .result-title { font-style: italic; }
.result-desc { color: var(--ink-40); font-size: 0.82rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.result-tags .chip { padding: 1px 8px; font-size: 0.72rem; }
.search-empty { text-align: center; padding: 32px 16px; color: var(--ink-faint); font-size: 0.9rem; }
.result-tags .chip { padding: 1px 8px; font-size: 0.68rem; }
.search-empty { text-align: center; padding: 32px 16px; color: var(--ink-40); font-size: 0.9rem; }
.search-foot {
display: flex;
gap: 18px;
padding: 10px 18px;
border-top: 1px solid rgba(79, 163, 255, 0.18);
font-size: 0.74rem;
color: var(--ink-faint);
border-top: 1px solid var(--line);
font-size: 0.72rem;
color: var(--ink-40);
}
.search-foot kbd {
font-family: var(--font-mono);
padding: 1px 6px;
border-radius: 5px;
background: rgba(132, 194, 255, 0.18);
color: var(--blue-700);
font-size: 0.72rem;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-60);
font-size: 0.7rem;
margin: 0 2px;
}
@media (max-width: 560px) {

View file

@ -17,39 +17,33 @@ const target = href ?? `/tags/${tag}`;
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
border-radius: 999px;
font-weight: 600;
font-size: 0.9rem;
color: var(--ink-soft);
background: var(--glass-soft);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
transition: all 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tag-chip .hash { color: var(--blue-400); font-weight: 800; }
.tag-chip:hover {
color: var(--blue-600);
transform: translateY(-3px);
box-shadow: 0 8px 18px rgba(47, 127, 224, 0.16);
background: var(--glass-strong);
}
.tag-chip.active {
color: #fff;
background: linear-gradient(135deg, var(--blue-500), var(--blue-600));
border-color: transparent;
box-shadow: 0 6px 16px rgba(47, 127, 224, 0.3);
}
.tag-chip.active .hash { color: rgba(255, 255, 255, 0.85); }
.count {
margin-left: 4px;
gap: 5px;
padding: 7px 14px;
border: 1px solid var(--line);
font-size: 0.75rem;
padding: 1px 7px;
border-radius: 999px;
background: rgba(132, 194, 255, 0.25);
color: var(--ink-faint);
letter-spacing: 0.15em;
color: var(--ink-60);
white-space: nowrap;
transition: border-color 0.3s, color 0.3s, background-color 0.3s;
}
.tag-chip.active .count { background: rgba(255, 255, 255, 0.25); color: #fff; }
.tag-chip .hash { color: var(--ink-40); transition: color 0.3s; }
.tag-chip:hover {
border-color: var(--ink);
color: var(--ink);
}
.tag-chip:hover .hash { color: var(--ink-60); }
/* 激活态:油墨反转 */
.tag-chip.active {
color: var(--paper);
background: var(--ink);
border-color: var(--ink);
}
.tag-chip.active .hash { color: rgba(249, 248, 246, 0.6); }
.count {
font-size: 0.72rem;
color: var(--ink-40);
transition: color 0.3s;
}
.tag-chip:hover .count { color: var(--ink-60); }
.tag-chip.active .count { color: rgba(249, 248, 246, 0.6); }
</style>

View file

@ -13,8 +13,10 @@
<style>
.toc-wrap {
border-radius: var(--r-md);
padding: 16px 16px 12px;
border: 1px solid var(--line);
border-radius: 0;
background: transparent;
padding: 18px 18px 14px;
position: sticky;
top: calc(var(--nav-h) + 24px);
max-height: calc(100vh - var(--nav-h) - 48px);
@ -24,46 +26,45 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.toc-title {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.78rem;
font-family: var(--font-body);
font-weight: 400;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--ink-faint);
letter-spacing: 0.25em;
color: var(--ink-40);
}
.toc-toggle {
display: none;
background: none;
border: 0;
cursor: pointer;
color: var(--ink-soft);
color: var(--ink-60);
padding: 4px;
border-radius: 6px;
transition: transform 0.3s;
}
.toc-toggle[aria-expanded='false'] { transform: rotate(-90deg); }
.toc { display: flex; flex-direction: column; gap: 2px; }
.toc { display: flex; flex-direction: column; }
.toc[data-open='0'] { display: none; }
.toc a {
display: block;
padding: 5px 10px;
border-left: 2px solid transparent;
color: var(--ink-soft);
padding: 6px 10px;
border-left: 1px solid transparent;
color: var(--ink-60);
font-size: 0.84rem;
line-height: 1.5;
border-radius: 0 6px 6px 0;
transition: all 0.2s;
line-height: 1.6;
transition: color 0.3s, border-color 0.3s, background-color 0.3s;
}
.toc a.level-3 { padding-left: 22px; font-size: 0.8rem; color: var(--ink-faint); }
.toc a:hover { color: var(--blue-600); background: rgba(132, 194, 255, 0.12); }
.toc a.level-3 { padding-left: 24px; font-size: 0.8rem; color: var(--ink-40); }
.toc a:hover { color: var(--ink); background: rgba(28, 28, 28, 0.02); }
.toc a.active {
color: var(--blue-600);
border-left-color: var(--blue-500);
background: rgba(132, 194, 255, 0.16);
font-weight: 600;
color: var(--ink);
border-left-color: var(--ink);
background: rgba(28, 28, 28, 0.02);
}
/* 移动端:折叠 */

View file

@ -0,0 +1,156 @@
---
title: "现代Transformer精读-00导读与系列地图"
date: 2026-08-27
tags: ["人工智能", "深度学习", "Transformer", "导航"]
description: "系列概述MiniMind 是什么、为什么选它精读、系列地图、阅读顺序与前置知识"
draft: false
pinned: true
heroGradient: ["#7fb8ff", "#2f8df0"]
---
# 现代Transformer精读-00导读与系列地图
![[logo.png]]
> **相关**[[01-模型骨架与前向回路]] | [[02-RoPE位置编码与YaRN外推]] | [[03-自注意力机制精读]] | [[04-稀疏MoE路由与负载均衡]] | [[05-数据管线与自回归目标设计]] | [[06-训练工程学]] | [[07-微调LoRA与蒸馏]] | [[08-偏好对齐DPO与强化学习]]
>
> 本系列用一个轻量级 LLM——**[MiniMind](https://github.com/jingyaogong/minimind)**——逐行精读现代 Transformer。本篇是门户告诉你读什么、怎么读、从哪开始。**特别致谢 MiniMind 开源项目**(作者 [jingyaogong](https://github.com/jingyaogong)):它以极简代码承载了现代大模型近乎全部的关键设计,是极佳的阅读对象。
## 0. 本系列一句话
**用一个 64M 参数的轻量语言模型,逐行读懂现代 Transformer 的每一个现代设计。**
本系列共 **1 篇导读 + 8 篇正文**。每篇聚焦一个模块(或一条训练链路),从**你已经掌握的概念**出发,把 MiniMind 的代码逐行拆给你看,配以自绘示意图。目标是:读完你能"闭眼默写"一个现代小型 Transformer 的前向回路,并理解它是怎么被训练出来的。
## 1. 为什么选 MiniMind 精读
三个理由,缺一不可:
**① 代码短** —— `model_minimind.py` 只有 **288 行**、无花哨依赖一处一个模块适合逐行精读。对比动辄几千行的工业实现MiniMind 把"结构"和"工程"分得很清晰,不会让读者迷失在分布式与优化器细节里。
**② 现代化全覆盖** —— 麻雀虽小、五脏俱全。它几乎具备 2024 年代小型 LLM 的全部现代设计,恰好是我们想精读的清单:
| 现代设计 | MiniMind 的位置 | 精读篇 |
|---|---|---|
| Pre-Norm + 残差流 | `MiniMindBlock` | 01 |
| RMSNorm | `RMSNorm` | 01 |
| SwiGLU FFN | `FeedForward` | 01 |
| tie_word_embeddings | `MiniMindForCausalLM` | 01 |
| RoPE + YaRN | `precompute_freqs_cis` | 02 |
| GQA | `Attention` | 03 |
| QK-Norm | `Attention` | 03 |
| KV Cache / Flash Attention | `Attention` | 03 |
| 稀疏 MoE + aux loss | `MOEFeedForward` | 04 |
**③ 能真训真推** —— `train_*.py` 覆盖了预训练、SFT、LoRA 微调、DPO、GRPO/PPO、蒸馏全流程0508。不仅"读得懂",还能**跑得起来、复现得了**——这是绝大多数"玩具模型"做不到的完整度。
**一句诚实的边界**MiniMind 是"小而完整"的**教学**实现,**不代表生产级工程**——专家并行expert parallelism、大规模分布式、长上下文训练细节等都被刻意简化。所以它适合**读懂原理**,而不是**复刻生产配置**。本系列精读"它做了什么、为什么这么做",并指出哪些是它在生产环境中的简化。
## 2. 系列地图MOC
全部篇目一表打尽(正文按推荐阅读序编号):
| 篇 | 标题 | 主题 | 核心代码锚点 | 关键概念 |
|---|---|---|---|---|
| **00** | 导读与系列地图 | 本页 | | |
| **01** | [[01-模型骨架与前向回路]] | 总体骨架 | `MiniMindConfig` `.RMSNorm` `.Block` `.ForCausalLM` | 残差流 / Pre-Norm / SwiGLU / tie_weight |
| **02** | [[02-RoPE位置编码与YaRN外推]] | 位置编码 | `precompute_freqs_cis` `.apply_rotary_pos_emb` | 旋转数学 / 相对位置 / YaRN |
| **03** | [[03-自注意力机制精读]] | 注意力 | `Attention` `.repeat_kv` | GQA / QK-Norm / Flash / KV Cache |
| **04** | [[04-稀疏MoE路由与负载均衡]] | 稀疏MoE | `MOEFeedForward` | 路由 / top-k / aux_loss |
| **05** | [[05-数据管线与自回归目标设计]] | 训练数据 | `lm_dataset` `.SFTDataset` | 右padding / -100 / shifted CE |
| **06** | [[06-训练工程学]] | 训练算法 | `train_pretrain` `.lr调度` | warmup / grad accumulation / 分布 |
| **07** | [[07-微调LoRA与蒸馏]] | 微调 | `model_lora` `.train_lora` `.train_distillation` | LoRA / 蒸馏 |
| **08** | [[08-偏好对齐DPO与强化学习]] | 对齐 | `train_dpo` `.train_grpo` `.rollout_engine` | DPO / GRPO / PPO |
## 3. 两条线:架构线 vs 训练算法线
系列在结构上分成两条线,**架构线是训练线的输入前提**(训的是架构线搭出来的模型):
```
架构线(模型怎么搭) 训练算法线(怎么训好)
───────────────────── ─────────────────────
01 骨架与回路 ─┐ 05 数据管线
02 RoPE ├─ 模型本体 06 训练工程
03 注意力 │ (被训练/微调/对齐) 07 微调
04 稀疏MoE ─┘ 08 偏好对齐
```
- **架构线0104**:回答"模型长什么样、一个 token 如何一步步变成 logits"。这是精读的主干,也是理解一切训练算法的地基。
- **训练算法线0508**:回答"如何把模型从随机权重训成会说话"。从数据、优化器、到微调与对齐,逐级而上。
**建议阅读顺序**`01 → 02 → 03 → 04`(架构,顺序依赖)`→ 05 → 06`(训练)`→ 07 → 08`(微调与对齐)。**也可跳读**:若你只关心推理,可止步于 03只关心如何把通用模型变成领域助手可直接从 07 开始,需要时不回头查 [[#2. 系列地图MOC]]。
## 4. 阅读顺序与前置知识
### 4.1 你的起点(系列假设你已掌握)
本系列**不从头讲**以下概念,而是把它们当作"已掌握的坐标",直接在其上叠加现代设计:
- **位置编码**绝对正弦位置编码、可训练位置编码02 篇从这两个老朋友出发,才把 RoPE 讲得更清楚);
- **自注意力**$QKV$、缩放点积、softmax 的基本概念;
- **深度学习基础**`nn` 前向、反向、交叉熵、优化器。
若这些还生疏,建议先补一层最基础的 Transformer 概念(见[[#6. 总览导读(如果你只读一段)]]的一键串讲)。
### 4.2 每篇你能收获什么
- **逐行代码注解**:关键代码块 + 行号 + 中文注释;
- **数学推导**:核心公式(如 RoPE 的相对位置定理、MoE 的 aux loss从定义推到你熟的形式
- **序列与形状表**`[B, S, 768]` 之类的张量走到哪一步变成什么;
- **验收清单**:每篇末尾回扣"这个设计为什么存在"
- **下一篇预告**:篇目间无缝衔接。
### 4.3 怎么对照代码自己跑
MiniMind 用 conda 虚拟环境 `minimind`(含 numpy绘图用 `nn` 环境(含 matplotlib。在项目根可对照源码
```bash
conda activate minimind
python model/model_minimind.py # 模型本身
python train_pretrain.py # 预训练
python eval_llm.py --inference_rope_scaling # 带 YaRN 推理
```
## 5. 系列使用约定
- **代码引用**:正文代码块直接来自 `model_minimind.py` 等,标注**行号**;文内用 `[[#节锚点]]` 跳转到本笔记内、`[[01-模型骨架与前向回路#节锚点]]` 跳转到其他篇。
- **数学记号**:向量/矩阵用大写 $Q,K,V$;内积 $\langle a,b\rangle$;缩放因子 $\sqrt{d_k}$;行内公式 `$...$`、独立公式 `$$...$$`
- **图例说明**:每篇配 12 张自绘示意图(`assets/` 下),均标注其对应节,读时对照。
- **致谢**:所有代码、配置与演示均来自 [jingyaogong/minimind](https://github.com/jingyaogong/minimind) 开源项目,特此致谢。
## 6. 总览导读(如果你只读一段)
这是全系列的一张"全貌速写":一个输入 token 序列,到自回归预测下一 tokenMiniMind 经历了什么。
```
input_ids [B, S]
Embedding (tied 的嵌入) ───────────────┐ # ① 词向量
│ │
▼ │
╔═ 每个 MiniMindBlock×8层═╗ │
║ ① residual = x ║ │
║ ② x = Attn(Norm(x)) + x ║ ← RoPE/QK-Norm/缓存 │
║ ③ x = MLP(Norm(x)) + x ║ ← 或 MOEFeedForward │
╚═══════════════════════════╝ │
│ │
▼ │
Norm (最外层 RMSNorm) │
│ │
▼ │
LM_head (与 embedding 共享权重) ─────────┘ # ② 词向量 → 词表 logits
logits [B, S, vocab] → softmax → 下一个 token自回归
```
这就是 01 篇的 [[01-模型骨架与前向回路#1.2 形状怎么走Tensor 形状变化)]] 要精讲的"一条回路"。**系列就是给这条回路上的每一格画特写**
- 圆圈里的"位置"怎么来?→ **02 RoPE**
- 方块里的"注意力"怎么算?→ **03 自注意力**
- 方块里的"MLP"如何换成多个专家?→ **04 稀疏MoE**
- 这一整条回路怎么被训出来?→ **0508 训练算法线**
---
*读完全系列,这个 64M 的小模型在你眼里将不再是黑箱,而是一条条可推演的数学与代码。*

View file

@ -0,0 +1,601 @@
---
title: "现代Transformer精读-01模型骨架与前向回路"
date: 2026-08-24
tags: ["人工智能", "深度学习", "Transformer"]
description: "从残差流看 MiniMind 模型骨架Config、RMSNorm、SwiGLU、tie 权重与自回归损失"
draft: false
pinned: false
heroGradient: ["#7fb8ff", "#2f8df0"]
---
# 现代Transformer精读-01模型骨架与前向回路
> **相关**[[00-导读]](系列地图,待写)
>
> 本文精读 `model/minimind.py`MiniMind 核心模型Dense + MoE 双架构),把"从 token 到 loss"的整条前向回路拆开讲。重点建立两个贯穿全系列的视角:**残差流**(现代 Transformer 的高速主干道)与**形状不变性**(中间全程 `[B, S, hidden]`)。
## 1. 全景:残差流与结构总览
![[LLM-structure.jpg]]
### 1.1 这张图是怎么读的:三层放大
图从左到右分三栏,其实是同一事物的不同放大倍率:
- **最左栏Large Language Model**MiniMind 作为完整 LLM 的管线,自下而上 `Tokenizer→Embedding→[Transformer Layer×N]→RMSNorm→Linear→SoftMax→Tokenizer`。注意**最后一层 RMSNorm 在整个堆叠之外**、紧跟其后的是投影到词表的 Linear + SoftMax——这正是"词表维度只出现在两端"的体现。
- **中间紫色栏Layer k**:把某一个 `Transformer Layer` 放大。里面只有两件事:**GQA注意力子模块**和 **FFN前馈子模块**,各自都被一条虚线**残差旁路**环绕。
- **右栏 (a) GQA / (b) FFN**:再放大这两个子模块的内部。
### 1.2 形状怎么走Tensor 形状变化)
给一个具体例子batch $B=4$、序列截断长度 $S=340$、`hidden_size=768`、词表 6400。
| 阶段 | 形状 | 说明 |
|---|---|---|
| 输入 | `[B, S]` | 整型 token id |
| Embedding 后 | `[B, S, 768]` | **从此形状一路不变** |
| 每个 Layer 前 | `[B, S, 768]` | 8 层都在这个维度上"原地叠加" |
| 最终 RMSNorm | `[B, S, 768]` | 堆叠外最后一个归一化 |
| lm_head 后 | `[B, S, 6400]` | 拉回词表预测下一个 token |
| loss | `[]`(标量) | shift 对齐后的 cross-entropy |
**核心事实:除了入口 Embedding 和出口 lm_head中间全程都是同一个 `[B, S, 768]`,没有任何一个维度被 reshape 或压扁。** 为什么能做到?因为模型每一层本质是"读出 → 计算 → **写回(按元素相加)**",加法要求两边形状一致,于是残差流天然把维度锁死。
### 1.3 残差流:现代 Transformer 的"高速主干道"
**残差连接本身不是现代才有的**——"Attention Is All You Need"2017 原版)就有。但**现代 TransformerLLaMA / Qwen / MiniMind对它的用法以及由它延展出的"残差流"视角,和原版是不同的**
| | 原版 Transformer (2017) | 现代 MiniMind / LLaMA |
|---|---|---|
| 归一化位置 | **Post-Norm**`x = LayerNorm(x + Sublayer(x))` | **Pre-Norm**`x = x + Sublayer(Norm(x))` |
| 归一化算子 | LayerNorm减均值、除方差、带 bias | **RMSNorm**(只除均方根,无均值减、无 bias更省更稳 |
| 为什么 | 表达式简单 | 深堆叠数值稳定,可更大学习率、训练更深 |
把"残差流"讲透,用这个画面:想象一条**形状恒定为 768 维的高速主干道residual stream**,信息从底部直达顶部。每个注意力 / FFN 模块不是"取代"主干道,而是**路边的服务站**:它先用 `RMSNorm` **读**一眼主路上的内容,算出一个小增量,再通过残差 `⊕` **写回**主路。
```python
# MiniMindBlock.forward (186194 行)
residual = hidden_states # ① 先保存主路上的"当前值"
hidden_states = self.self_attn( # ② 注意力:读出→算增量
self.input_layernorm(hidden_states), ...) # 进子模块前先 RMSNorm
hidden_states += residual # ③ 增量写回主路(⊕,形状必须一致)
hidden_states = hidden_states + self.mlp( # ④ FFN 同上:读出→增量→写回
self.post_attention_layernorm(hidden_states))
```
这带来两个极重要的性质:
1. **梯度高速通道**反向时因为主路是一条恒等identity路径梯度可以直接"抄近道"流回输入,**不需要穿过每个子模块的非线性**。这就是为什么现代模型能堆到几十上百层而不梯度消失/爆炸。对应到图里,就是那些**虚线**——它们不是可选项,是让网络"深得起来"的命脉。
2. **职责分工**:每条旁路只负责"往里**加一点**",于是主路永远保留底层信息。惯用直觉——**Attention 负责把"别的位置的信息"搬运到当前 tokenFFN 负责对当前 token 做"单点"非线性变换**。
### 1.4 读代码的落点
以后不管读哪一层,盯住 `hidden_states` 这一条线就好:**它始终是你最熟悉的 `[B, S, 768]`**,每个模块都是在它的旁边"叠一个增量"再放回去。词表维度、KV cache、MoE 都是在这条主干道上的"附加设施",不是新路。
## 2. MiniMindConfig一个配置两套架构
```python
# model_minimind.py 1046 行
class MiniMindConfig(PretrainedConfig):
model_type = "minimind"
def __init__(self, hidden_size=768, num_hidden_layers=8, use_moe=False, **kwargs):
super().__init__(**kwargs)
self.hidden_size = hidden_size
self.num_hidden_layers = num_hidden_layers
self.use_moe = use_moe
# ... 其余全部通过 kwargs 透传并给默认值
```
### 2.1 核心超参逐项解读
| 参数 | 默认值 | 意思 | 为什么是它 |
|---|---|---|---|
| `hidden_size` | 768 | 残差流维度 | BERT-base 遗产 + 小模型性价比点 |
| `num_hidden_layers` | 8 | 块数 | 64M 级别"浅而宽"更划算 |
| `vocab_size` | 6400 | 词表大小 | 专属 BPE 词表(见 [[05-数据管线与自回归目标设计]] |
| `num_attention_heads` | 8 | Q 头数 | 768/8=96 的 head_dim |
| `num_key_value_heads` | 4 | KV 头数 | **GQA**8 个 Q 头共享 4 组 KV[[03-自注意力机制精读]] |
| `head_dim` | 96 | `hidden_size // num_attention_heads` | 每头维度 |
| `intermediate_size` | **2432** | FFN 中间维度 | 见下方重点 |
| `hidden_act` | `silu` | 激活函数 | SwiGLU 的 σ(x) 即 SiLU第 4 节讲) |
| `max_position_embeddings` | 32768 | RoPE 预计算上限 | 配合 `rope_theta=1e6` 支持长上下文([[02-RoPE位置编码与YaRN外推]] |
| `rope_theta` | 1e6 | RoPE 基频 | 百万级 base长文本外推友好相对原版 10000 |
| `rms_norm_eps` | 1e-6 | 归一化防零 | 数值稳定小常数 |
| `tie_word_embeddings` | `True` | 共享嵌入 | 入口 embed 与出口 lm_head 同一份权重(第 6 节) |
| `flash_attn` | `True` | 快速注意力 | 用 `torch``scaled_dot_product_attention`[[03-自注意力机制精读]] |
| `dropout` | 0.0 | 训练正则 | 小模型 + 强结构正则,默认不开 |
**重点:`intermediate_size = ⌈768·π/64⌉·64 = 2432`**。这一行是 MiniMind 的"尺寸工程"签名笔:
$$
\mathrm{intermediate\_size}=\Big\lceil\frac{hidden\_size\cdot\pi}{64}\Big\rceil\cdot64
$$
- `π ≈ 3.14` 系数把 FFN 中间层做成 hidden 的 **≈ 3.17 倍**——和 LLaMA 用 `8/3 ≈ 2.67` 倍、原版 Transformer 用 4 倍是同一类选择FFN 需要一个"宽"的中间层来承担单 token 的非线性变换(第 4 节讲透);
- **`64` 对齐**:把结果取整到 64 的倍数。现代 GPU / cuDNN 对某些维度的张量有专门优化,对齐维度经常免费提速几个百分点——这是"工程细节",但值得知道它写在 config 里而不是写死在代码里。
### 2.2 `use_moe`:一个 config 管两套架构
同一个 `MiniMindConfig` 同时服务 Dense 和 MoE 两套模型,切换点只有一个布尔量:
```python
# MiniMindBlock.__init__ (184 行)
self.mlp = FeedForward(config) if not config.use_moe else MOEFeedForward(config)
```
后面的 MoE 字段就是这样混在 config 里的:
```python
self.num_experts = 4 # 4 个专家
self.num_experts_per_tok = 1 # 每个 token 只激活 top-1
self.moe_intermediate_size = ... # 每个专家的 FFN 维度
self.norm_topk_prob = True # top-k 权重再归一化
self.router_aux_loss_coef = 5e-4 # 负载均衡辅损失的系数
```
> **相关**这些字段的完整机理路由、top-k、aux loss留给 [[04-稀疏MoE路由与负载均衡]],本篇只认"它们是 Dense 模型的旁路选项"。
### 2.3 和参数量对账6400 词表如何撑起 64M
- **Embedding / lm_head**$6400 \times 768 \approx 4.9\text{M}$tie 后只算一份);
- **每层注意力**q/k/v/o 四个 $768\times768\approx0.59\text{M}$×4 ≈ 2.4MKV 头只有 4 个k/v 其实是 $768\times384$,又省了一块);
- **每层 FFN**gate/up 为 $768\times2432$ 两份 + down 一份 ≈ 5.6M
- 8 层后加上嵌入与 normnorm 只有 $d$ 个可学习参数),总额正好落在 **~64M**MoE 版 198M-A64M 的"A64M"就是激活 64M第 4 篇给完整对账表)。
这就是为什么"词表维度只出现在两端"不止是视觉规律——**6400 这个刻意缩小的词表,直接决定了 64M 这个量级**;换成 32k 词表,光嵌入就要 25M。
## 3. RMSNorm 精读
```python
# model_minimind.py 5060 行
class RMSNorm(torch.nn.Module):
def __init__(self, dim: int, eps: float = 1e-5):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(dim))
def norm(self, x):
return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
def forward(self, x):
return (self.weight * self.norm(x.float())).type_as(x)
```
### 3.1 一张公式看明白
RMSNorm 沿最后一维(特征维)逐 token 归一化:
$$
\mathrm{RMSNorm}(x)=\frac{x}{\sqrt{\frac{1}{d}\sum_{i=1}^{d}x_i^2+\epsilon}}\odot w,\qquad x\in\mathbb{R}^{d}
$$
对比原版 LayerNorm
$$
\mathrm{LayerNorm}(x)=\frac{x-\mu}{\sqrt{\sigma^2+\epsilon}}\odot\gamma+\beta,\qquad
\mu=\frac{1}{d}\sum_ix_i,\ \ \sigma^2=\frac{1}{d}\sum_i(x_i-\mu)^2
$$
**差异只有两处**RMSNorm **不减均值、不做方差中心化(只除均方根)**,同时**没有 bias 项 $\beta$**,只保留一个缩放向量 $w$(代码里叫 `weight`)。
### 3.2 为什么现代模型换用 RMSNorm
这是 LLaMA 系(含 MiniMind相对于原版 Transformer 的又一个"现代化"选择,理由有两条:
1. **数学上够用**Transformer 的归一化目标是"让进入子模块的输入尺度稳定"。LayerNorm 的均值项 $\mu$ 在残差流里贡献极小——注意力/FFN 输出都是"小增量写回主路"主路的均值早已被逐层吸收。RMSNorm 论文Zhang & Sennrich, 2019证明只做"除均方根"这一步,效果几乎无损。
2. **工程上更省**省掉均值减法、方差、bias一次 forward 的归约运算更少;**更关键的是省掉了 768 个 bias 参数的梯度计算**——对小模型是零头,对 70B 级模型就是实打实的显存/时间。
### 3.3 实现细节逐行拆
这 10 行代码里有四个值得注意的工程点:
| 写法 | 为什么 |
|---|---|
| `x.pow(2).mean(-1, keepdim=True)` | 沿特征维求均方,`keepdim` 保住 `[B, S, 1]` 形状好广播 |
| `torch.rsqrt(...)` | **先求 $1/\sqrt{\cdot}$ 再乘**用乘法代替除法——GPU 上 `rsqrt` 有专用指令,比 `x / sqrt(...)` 快 |
| `x.float()``.type_as(x)` | 归一化运算在 **fp32 下做**,算完再转回原 dtypebf16/fp16——混合精度下避免低精度累加误差与第 6 篇的 AMP 是同一条数值稳定思路) |
| `self.eps` | 防除零的极小常数;模块默认 `1e-5`,但 MiniMind 构造时显式传入 `config.rms_norm_eps=1e-6`(见 Attention / MiniMindModel 里的 `RMSNorm(dim, eps=config.rms_norm_eps)` |
### 3.4 它在模型里的位置
对照 [[#1. 全景:残差流与结构总览]] 的图图中每个子模块GQA / FFN**最前面的那个方块就是 RMSNorm**——Pre-Norm 的"读"这一步。全部用法共三处:
- `Attention` 内部:`q_norm` / `k_norm`(对 head_dim 归一化,第 2 篇讲 QK-Norm
- `MiniMindBlock``input_layernorm`(注意力前)+ `post_attention_layernorm`FFN 前);
- `MiniMindModel`:堆叠之后的最终 `norm`
一个反直觉但真实的设计:**MiniMind 在 Embedding 之后没有立即做归一化**(原版 Transformer 有 post-embedding norm。Pre-Norm 下每个子模块自己管尺度,入口的 LN 就省掉了——这又是"残差流"把尺度问题局部化的结果。
## 4. SwiGLU三个线性层的门控 FFN
```python
# model_minimind.py 136146 行
class FeedForward(nn.Module):
def __init__(self, config: MiniMindConfig, intermediate_size: int = None):
super().__init__()
intermediate_size = intermediate_size or config.intermediate_size # 2432
self.gate_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False)
self.down_proj = nn.Linear(intermediate_size, config.hidden_size, bias=False)
self.up_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False)
self.act_fn = ACT2FN[config.hidden_act] # 按 config.hidden_act 的名字查表取激活函数("silu"
def forward(self, x):
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
```
### 4.1 公式与代码一一对应
$$
\mathrm{FFN}(x)=W_{\mathrm{down}}\Big(\mathrm{SiLU}\big(W_{\mathrm{gate}}\,x\big)\odot W_{\mathrm{up}}\,x\Big)
$$
三个线性层各司其职:
- `gate_proj`768→2432把 x 投影到"门控空间",过 `SiLU` 激活;
- `up_proj`768→2432投影出"候选值";两路**逐元素相乘**$\odot$
- `down_proj`2432→768把乘积压回残差流维度——**出去时又回到 768**,形状锁死 `[B, S, 768]`(呼应 [[#1. 全景:残差流与结构总览]])。
### 4.2 多出来的 gate 在干什么
原版 Transformer2017的 FFN 只有两个线性层:
$$
\mathrm{FFN}_{\mathrm{orig}}(x)=W_2\,\mathrm{ReLU}(W_1 x)
$$
SwiGLUShazeer, 2020把"一个 ReLU"换成了"**一条门控乘法**"
| | 原版 FFN | SwiGLU FFN |
|---|---|---|
| 线性层数 | 2 | 3 |
| 非线性 | 单一 ReLU | SiLU 激活 + 逐元素门控乘法 |
| 语义 | 一条直路 | `up` 提供候选,`gate` 决定"放行多少" |
门控的直觉:`SiLU(W_gate·x)` 输出在 $(0, +\infty)$ 之间(负输入被压到接近 0相当于一个**软开关**——对着候选值 $W_{up}x$ 逐元素放行。这和注意力、LSTM 里"门"是同一个思想:**不是决定要不要这个特征,而是决定放多少进来**。
为什么贵一半参数却更流行多项实验LLaMA 论文复述)表明 Gated Linear Unit 类结构在相同算力下困惑度更好。注意一个补偿设计——**因为多了第三层线性层SwiGLU 模型的 FFN 中间维度会刻意调小以维持总参数量可比**LLaMA 用 hidden 的 8/3 倍MiniMind 用 π 倍 ≈3.17,都是这个逻辑;若用原版 4 倍再加第三层,参数就超了)。
### 4.3 为什么是 SiLU
$$
\mathrm{SiLU}(x)=x\cdot\sigma(x),\qquad \sigma(x)=\frac{1}{1+e^{-x}}
$$
SiLU= Swish有三个性质**平滑**(处处可导,梯度比 ReLU 在 0 点处更友好)、**有下界无上界**(比 ReLU 的硬截断更温和)、**负区间不完全归零**(保留微弱负信号)。对"门"来说,平滑的软开关通常比 ReLU 的硬开关(负数直接砍 0训练更稳。
`ACT2FN[config.hidden_act]` 是从 `transformers.activations` 按字符串名字查表——所以 `hidden_act` 改成 `"gelu"` 也能跑,这是 config 驱动设计的又一处体现(同 [[#2. MiniMindConfig一个配置两套架构]])。
### 4.4 三个工程细节
1. **全部 `bias=False`**:三个线性层都不带偏置。去掉 bias 后,线性层变成纯矩阵乘法,与"归一化(无 β)+ 残差流"体系一致LLaMA / Qwen 系全线无 bias也省了约 2432×3 个参数。
2. **`intermediate_size` 参数可覆盖**`FeedForward(config, intermediate_size=...)` 允许按需换维度——这正是 MoE 里"每个专家"复用本类时传 `moe_intermediate_size` 的入口([[04-稀疏MoE路由与负载均衡]])。
3. **与参数量对账接上**gate/up 都是 $768\times2432$down 是 $2432\times768$,一层 FFN 约 $3\times768\times2432\approx5.6\text{M}$,占 64M 的大头([[#2.3 和参数量对账6400 词表如何撑起 64M]])。
## 5. 残差结构与预归一化MiniMindBlock 精读
```python
# model_minimind.py 178194 行
class MiniMindBlock(nn.Module):
def __init__(self, layer_id: int, config: MiniMindConfig):
super().__init__()
self.self_attn = Attention(config) # 注意力子模块
self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.mlp = FeedForward(config) if not config.use_moe else MOEFeedForward(config)
def forward(self, hidden_states, position_embeddings, past_key_value=None,
use_cache=False, attention_mask=None):
residual = hidden_states
hidden_states, present_key_value = self.self_attn(
self.input_layernorm(hidden_states), # ① 先归一化,再进注意力
position_embeddings, past_key_value, use_cache, attention_mask
)
hidden_states += residual # ② 注意力输出写回残差流
hidden_states = hidden_states + self.mlp( # ③ 归一化后进 FFN再写回
self.post_attention_layernorm(hidden_states))
return hidden_states, present_key_value
```
### 5.1 把 §1.3 的画面落到代码
§1.3 的"残差流主干道 + 路边服务站"在代码里就是这 7 行:
- 子模块**按属性挂载**`self.self_attn` / `self.mlp` / 两个 `self.*_layernorm`——PyTorch 会把属性里的 `nn.Module` 自动注册进参数表,所以 RMSNorm 的 `weight`、注意力和 FFN 的全部权重,都由这四行"挂载"收集齐了;
- 两个 RMSNorm **待在 Block 层**,而不是藏在子模块内部:`input_layernorm` 管注意力入口、`post_attention_layernorm` 管 FFN 入口。LLaMA / Qwen / MiniMind 都是这个布局——归一化写在"外面"子模块Attention/FFN内部只管算
- `residual = hidden_states` 先存副本 → 子模块算增量 → `+=` 写回,三步就是一次"读-算-写"。注意顺序:**先 `+=`(注意力残差),再 `mlp(...)`FFN 残差)**,两个残差分别绕过注意力和 FFN互不交叉。
### 5.2 forward 签名里的三个"透传参数"
Block 自身不消费这三个参数,只是**原样转交给注意力**
- `position_embeddings`:预计算的 RoPE cos/sin 切片,注意力内部用它旋转 Q/K[[02-RoPE位置编码与YaRN外推]]
- `past_key_value` / `use_cache`KV cache 的"旧值"与"是否保留新值"——真正的拼接逻辑在 `Attention`[[03-自注意力机制精读]]
- `attention_mask`padding/跨序列掩码,同样进注意力。
所以 Block 是一个**纯粹的组合器**:归一化 + 残差写回是自己的活,其余全往下传。这也解释了为什么它能被复用 8 次(`MiniMindModel` 用 `nn.ModuleList` 装 8 个)。
### 5.3 和原版 Post-Norm 的最后一处差异
#### 5.3.1 两种写法,一条梯度路径的差别
把 Block 缩写为 $x_{l+1}=\mathrm{Block}_l(x_l)$,差别只在一个括号的落点:
$$
\text{Post-Norm:}\quad x_{l+1}=\mathrm{Norm}\big(x_l+F_l(x_l)\big)
\qquad
\text{Pre-Norm:}\quad x_{l+1}=x_l+F_l\big(\mathrm{Norm}(x_l)\big)
$$
$F_l$ 是第 $l$ 层的子模块(注意力或 FFN含内部权重。差别不在"结果",而在**反向传播时梯度走的路**。
#### 5.3.2 从雅可比矩阵看梯度衰减
**Pre-Norm**$x_{l+1}=x_l+F_l(\mathrm{Norm}(x_l))$ 对 $x_l$ 求导:
$$
\frac{\partial x_{l+1}}{\partial x_l}=I+\underbrace{\frac{\partial F_l(\mathrm{Norm}(x_l))}{\partial x_l}}_{J_l}
$$
链式法则连乘 $L$ 层,损失对最底层的梯度:
$$
\frac{\partial \mathcal{L}}{\partial x_0}=\frac{\partial \mathcal{L}}{\partial x_L}\prod_{l=0}^{L-1}\big(I+J_l\big)
$$
乘积展开后**第一项恒为 $I$**——它对应"梯度沿残差主干道直通到底、一次都不穿过子模块"的路径。即使所有 $J_l$ 的特征值都小于 1、乘积部分指数衰减这条恒等路径也**始终以 1 的增益存在**。这就是残差的数学本质:不是"让梯度变大",而是**保证有一条不衰减的通道**He et al. 2016 的 identity mapping
**Post-Norm**:归一化套在加法外面,$x_{l+1}=\mathrm{Norm}(x_l+F_l(x_l))$$J_l$ 前面多出一个归一化层的雅可比。RMSNorm/LayerNorm 对输入的雅可比近似为 $\dfrac{1}{\sigma}$ 的标量缩放(除方差那一步):
$$
\frac{\partial \mathrm{Norm}(x)}{\partial x}\approx\frac{1}{\sigma_l}=\frac{1}{\sqrt{\frac{1}{d}\sum_i(x_{l,i}-\mu_l)^2}}
$$
深层各层激活方差 $\sigma_l^2$ 不恒为 1这个缩放因子乘 $L$ 次就会**指数级放大或衰减**——梯度每层都被"穿"一次归一化,浅层收不到稳定信号。
一句话总结:**Pre-Norm 让梯度"绕过"非线性与归一化Post-Norm 让梯度"穿过"它们**。穿过非线性 = 指数衰减风险,对要堆 30~80 层的现代模型不可接受。
#### 5.3.3 实践中的三个证据
1. **warmup**:原版 2017 论文明确写"we found increasing the learning rate… and using warmup"——12 层 Post-Norm 就依赖 4000 步学习率预热压住早期梯度爆炸。现代 Pre-Norm 模型GPT-2 / LLaMA / Qwen / MiniMind用大学习率直接跑。
2. **层数**:原版 12 层LLaMA-7B 32 层、LLaMA-70B 80 层。Pre-Norm 把"能堆多深"从训练工程问题变成了显存问题。
3. **最终 norm 兜底**Pre-Norm 的代价是残差流尺度随深度缓慢漂移,所以堆叠之后必须再有最终 `RMSNorm` 把尺度拉回、才好接 `lm_head`——这正是 [[#1. 全景:残差流与结构总览]] 图中"堆叠外最后一个 RMSNorm"的职责。进阶读者可延伸阅读 Xiong et al. 2020 *On Layer Normalization in the Transformer Architecture*
## 6. MiniMindModel把零件装成一台 8 层模型
```python
# model_minimind.py 196232 行
class MiniMindModel(nn.Module):
def __init__(self, config: MiniMindConfig):
super().__init__()
self.config = config
self.vocab_size, self.num_hidden_layers = config.vocab_size, config.num_hidden_layers
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size) # 词表→768 查表
self.dropout = nn.Dropout(config.dropout) # 默认 0.0,关着
self.layers = nn.ModuleList([MiniMindBlock(l, config) for l in range(self.num_hidden_layers)])
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) # 堆叠外最终 norm
freqs_cos, freqs_sin = precompute_freqs_cis(
dim=config.head_dim, end=config.max_position_embeddings, # 32768 长的 cos/sin 表
rope_base=config.rope_theta, rope_scaling=config.rope_scaling)
self.register_buffer("freqs_cos", freqs_cos, persistent=False)
self.register_buffer("freqs_sin", freqs_sin, persistent=False)
```
### 6.1 组装:一台"三段式"机器
`MiniMindModel` 的本质是把前面各节零件按序组装,分工是:
| 组件 | 作用 | 来自 |
|---|---|---|
| `embed_tokens` | 把 token id 查表成 `[B, S, 768]` 向量 | 本节 |
| `layers`8× Block | 8 次"读-算-写"的残差主干道 | [[#5. 残差结构与预归一化MiniMindBlock 精读]] |
| `norm` | 堆叠外最终 RMSNorm把尺度拉回 | [[#3. RMSNorm 精读]] §3.4 / [[#5.3.3]] |
| `freqs_cos/sin` | RoPE 的预计算位置表 | 第 3 篇详讲 |
注意入口处**只有 Embedding + 一个关着的 Dropout**,没有任何归一化——这正是 [[#3.4]] 说的"Pre-Norm 下入口 LN 被省掉"。
### 6.2 RoPE buffer为什么是 buffer而且 `persistent=False`
`precompute_freqs_cis``__init__` 时就把 **32768 个位置 × 96 维**的 cos/sin 表算好了,存成 buffer 而不是普通张量或参数:
- **buffer vs 参数**buffer 参与前向、随模型 `.to(device)` 迁移,但**不产生梯度、不进优化器**
- **`persistent=False`**:不进 `state_dict`,保存权重时不会把这 32768×96×2 的常数表写进 `.pth`。为什么?因为表完全由 `max_position_embeddings / rope_theta / rope_scaling` 决定——推理时想用 YaRN 外推([[02-RoPE位置编码与YaRN外推]])就得换一张表,它不该被固化进权重文件;
- **一个真实世界的兼容补丁**:注释写着 "Recompute RoPE buffers lost during meta-device init (transformers>=5.x)"。`from_pretrained` 在 meta device 上先搭骨架再拷权重时buffer 会被初始化为 0`forward` 里 `if self.freqs_cos[0, 0] == 0:` 检测到"表是空的"就现场重算——这是 MiniMind 踩过 transformers 版本坑后留下的防御代码,读代码时值得认识它。
### 6.3 forward切片、逐层循环、presents
```python
start_pos = past_key_values[0][0].shape[1] if past_key_values[0] is not None else 0 # 已有缓存长度
hidden_states = self.dropout(self.embed_tokens(input_ids))
# ...
position_embeddings = (self.freqs_cos[start_pos:start_pos + seq_length],
self.freqs_sin[start_pos:start_pos + seq_length]) # RoPE 切片
presents = []
for layer, past_key_value in zip(self.layers, past_key_values):
hidden_states, present = layer(hidden_states, position_embeddings,
past_key_value=past_key_value, use_cache=use_cache,
attention_mask=attention_mask)
presents.append(present)
hidden_states = self.norm(hidden_states) # 最终 norm
aux_loss = sum([l.mlp.aux_loss for l in self.layers if isinstance(l.mlp, MOEFeedForward)],
hidden_states.new_zeros(1).squeeze())
return hidden_states, presents, aux_loss
```
四个细节:
1. **`start_pos`**:推理续写时,`past_key_values[0][0]` 是第 0 层缓存的 K`.shape[1]` 就是"已经算过的位置数"。RoPE 切片从 `start_pos` 开始,**新 token 拿到的是它真实的绝对位置**——这就是 KV Cache 与位置编码衔接的接口([[03-自注意力机制精读]] 细讲);
2. **8 层循环复用同一个 `position_embeddings`**RoPE 表是纯位置函数,不随层变化,所以 8 层共享同一份切片;
3. **`presents` 收集**:每层返回 `(hidden_states, present)``present` 为 None训练或新的 KV 元组(推理);收集成列表就是 `past_key_values` 的未来形态——注意**训练时这个列表全是 None**,几乎零开销;
4. **`aux_loss` 汇总**`sum(..., 0)` 从 0 开始累加所有 MoE 层的负载均衡损失——Dense 模型没有 `MOEFeedForward`,这个和恒为 0等于"免费"兼容了两种架构([[04-稀疏MoE路由与负载均衡]])。
最后返回值 `(hidden_states, presents, aux_loss)` 三元组,是留给上层 `MiniMindForCausalLM` 的"半成品"——§1.2 形状表里"最终 RMSNorm → hidden_states [B,S,768]"就发生在这里。
## 7. MiniMindForCausalLM把向量变回概率再算损失
```python
# model_minimind.py 234253 行
class MiniMindForCausalLM(PreTrainedModel, GenerationMixin):
config_class = MiniMindConfig
_tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"}
def __init__(self, config: MiniMindConfig = None):
self.config = config or MiniMindConfig()
super().__init__(self.config)
self.model = MiniMindModel(self.config)
self.lm_head = nn.Linear(self.config.hidden_size, self.config.vocab_size, bias=False)
if self.config.tie_word_embeddings:
self.model.embed_tokens.weight = self.lm_head.weight # ★ 共享权重
self.post_init()
def forward(self, input_ids, attention_mask=None, past_key_values=None,
use_cache=False, logits_to_keep=0, labels=None, **kwargs):
hidden_states, past_key_values, aux_loss = self.model(input_ids, attention_mask,
past_key_values, use_cache, **kwargs)
slice_indices = slice(-logits_to_keep, None) if isinstance(logits_to_keep, int) else logits_to_keep
logits = self.lm_head(hidden_states[:, slice_indices, :])
loss = None
if labels is not None:
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
return MoeCausalLMOutputWithPast(loss=loss, aux_loss=aux_loss, logits=logits,
past_key_values=past_key_values, hidden_states=hidden_states)
```
### 7.1 为什么需要这层"外壳"
之前的所有组件(`MiniMindModel`)只产出 `[B, S, 768]` 的向量——**它们还不是语言模型**。语言模型的定义是"输出下一个 token 的分布",所以必须有一个出口把 768 维向量映射回 6400 维词表空间。这个出口就是 `lm_head`,整个 `MiniMindForCausalLM` 就是"骨干 + 出口 + 损失 + transformers 生态接口"的组合壳。
### 7.2 tie_word_embeddings一个 Linear 一个 Embedding凭什么共享
#### 7.2.1 数学本质Embedding 就是"查表版的 Linear"
`nn.Embedding(6400, 768)``weight` 是一张 `[6400, 768]` 的矩阵。给定 token id $i$,它返回**第 $i$ 行**`weight[i]`。用 one-hot 视角写清楚:
$$
x_i = e_i\,W_{\mathrm{emb}},\qquad e_i=[0,\dots,1,\dots,0]\in\mathbb{R}^{6400}
$$
one-hot 向量右乘矩阵,结果恰好是矩阵第 $i$ 行——查表就是"one-hot 乘法"。
`nn.Linear(768, 6400, bias=False)` 是:
$$
z = h\,W_{\mathrm{head}}^{\top},\qquad W_{\mathrm{head}}\in\mathbb{R}^{6400\times 768}
$$
**关键事实:两个模块的权重矩阵形状完全一致,都是 `[6400, 768]`**。差异只在于"怎么用它"
| | 形状 | 用法 |
|---|---|---|
| Embedding.weight | `[6400, 768]` | 按**行索引**取值token id → 第 i 行当向量) |
| Linear.weight | `[6400, 768]` | 按**矩阵乘法**用(转置后内积) |
两个模块只是"同一张矩阵的两种访问方式"——所以它们天然可以共享同一张表。
#### 7.2.2 实现:一个 Parameter两次引用
```python
self.model.embed_tokens.weight = self.lm_head.weight
```
这行是**引用赋值**,不是拷贝:把 `lm_head``Parameter` 对象整个挂到 `embed_tokens.weight` 属性上。此后:
- **前向**Embedding 从这张表"取第 $i$ 行"Linear 用这张表"转置后内积",各算各的;
- **反向**:两路梯度(查表的 $g_{\mathrm{emb}}$、投影的 $g_{\mathrm{head}}$**累加进同一个 `.grad`**
- **更新**优化器只看到一个参数对象Adam 的状态exp_avg 等)也只有一份——因为本来就只有一个参数。
PyTorch 之所以允许,正是因为 `nn.Linear.weight``nn.Embedding.weight` 都是普通 `Parameter`,而这里两者的形状恰好对齐(`out_features = vocab_size`、`in_features = embedding_dim`)。
#### 7.2.3 语义:同一个词的两个角色
共享后,第 $i$ 行的向量 $v_i=W[i]$ 同时扮演两个角色:
- **入口角色**token $i$ 的语义向量("这个词是什么"
- **出口角色**:输出投影的第 $i$ 行——logits 第 $i$ 项是上下文 $h$ 与 $v_i$ 的**内积**
$$
z_i=\langle h,\, v_i\rangle
$$
于是"下一个 token 是谁"变成了"**当前上下文与哪一个词向量最像**":上下文 $h$ 与 $v_{\text{我}}$ 方向接近,就给"我"高分。同一个向量同时定义"这个词是什么"和"上下文像不像这个词"——这俩共享表示是**自然的**,端到端训练会收敛到同一套表示,模型不必学两套需要对齐的词表。这也是它常被看作"隐式正则"的原因:参数省 7~8%,且嵌入空间与输出空间被强制一致。
#### 7.2.4 两个实现细节
- **`_tied_weights_keys = {"lm_head.weight": "model.embed_tokens.weight"}`**:告诉 transformers 保存/加载时这两个名字**指向同一份权重**,防止重复序列化或加载不一致;
- **梯度是两路之和**:同一权重同时受"入口查表"和"出口投影"两个任务训练,等效于一个双任务共享表示——这是 MiniMind 把 SFT 学习率压到 1e-5 量级的原因之一([[06-训练循环工程精读]])。
### 7.3 shifted CE loss自回归目标的落点
自回归的定义是"用前 $t$ 个 token 预测第 $t+1$ 个"。所以 logits 与 labels **错开一位**对齐:
```python
x, y = logits[..., :-1, :].contiguous(), labels[..., 1:].contiguous()
loss = F.cross_entropy(x.view(-1, x.size(-1)), y.view(-1), ignore_index=-100)
```
$$
\mathcal{L}=-\frac{1}{N}\sum_{t}\log p_\theta(y_t \mid x_{<t}),\qquad
\text{位置 }t\text{ 的 logits 对 } y_t = \text{位置 }t+1\text{ 的 label}
$$
- **`ignore_index=-100`**PyTorch CE 的约定——label 为 `-100` 的位置**不参与损失**。MiniMind 用它屏蔽两种东西padding 位置([[05-数据管线与自回归目标设计]] 的 `PretrainDataset` 把 pad 标成 -100与 SFT 里的 prompt 部分(只监督回答,`SFTDataset.generate_labels` 干的活,第 5 篇细讲);
- `view(-1, 6400)`:把 `[B, S-1, 6400]` 展平成 `[B·(S-1), 6400]`,一次算完所有位置的 CE。
### 7.4 两个"半隐藏"设计:`logits_to_keep` 与 `MoeCausalLMOutputWithPast`
- **`logits_to_keep`**:推理续写时,其实**只需要最后一个位置**的 logits 来采样下一个 token`generate` 里 `outputs.logits[:, -1, :]`)。传入 `logits_to_keep=1` 就只算最后一个位置的输出省掉整条词表维度的计算与显存。RL 训练时GRPO/PPO它被用来只算"响应部分"的 logits——[[08-偏好对齐DPO与强化学习]] 的 rollout 引擎里 `logits_to_keep=n_keep+1` 就是这么用的;
- **`MoeCausalLMOutputWithPast`**:名字带 Moe但 Dense 模型也用——因为 `aux_loss` 字段恒为 0[[#6.3]] 讲的"免费兼容"),一个输出类同时服务两套架构。
到这里§1.2 形状表的最后两行已经闭环:`lm_head → [B, S, 6400]`,然后 shift 对齐算出标量 loss。
## 8. 预留generate自实现采样循环
> 本节暂缓:`MiniMindForCausalLM.generate`255288 行)的温度 / top-k / top-p / 重复惩罚 / EOS / KV cache 的逐 token 采样循环,将在 [[03-自注意力机制精读]]KV Cache之后补充。
## 9. 参数统计表64M 是怎么凑出来的
### 9.1 逐模块对账
`hidden=768, layers=8, heads=8, kv_heads=4, head_dim=96, vocab=6400, intermediate=2432` 精确复算:
| 模块 | 计算 | 参数量 |
|---|---|---|
| Embedding / lm_headtie 共享,只计一份) | $6400\times768$ | 4.92M |
| 每层 q_proj | $768\times768$ | 0.59M |
| 每层 k_proj | $768\times(4\times96)$ | 0.29M |
| 每层 v_proj | $768\times(4\times96)$ | 0.29M |
| 每层 o_proj | $768\times768$ | 0.59M |
| 每层 FFNgate+up+down | $3\times768\times2432$ | 5.60M |
| **每层 Block 合计** | | **7.37M** |
| 8 层 × Block | × 8 | 58.96M |
| RMSNorm scale8×2+1 处,忽略级) | $17\times768$ | ~0.01M |
| **总计Dense** | | **≈ 63.9M ≈ 64M** ✓ |
三个一眼可见的事实:
1. **FFN 是参数大头**:一层里 5.60M / 7.37M ≈ **76%**。注意力反而"便宜"——因为 GQA 把 KV 投影砍半q/o 又都是方阵;
2. **GQA 省的正是 KV 那一半**:若 8 个 KV 头MHAk+v 会是 $2\times768\times768=1.18\text{M}$,比现在的 0.59M 多一倍——这就是 [[03-自注意力机制精读]] 要算的账;
3. **嵌入占比 7.7%**:不大不小,全靠 6400 的小词表压着([[#2.3 和参数量对账6400 词表如何撑起 64M]])。
### 9.2 get_model_paramsDense 与 MoE 的"一鱼两吃"
MiniMind 打印参数量的工具函数(`trainer_utils.py` 1828 行)同时服务两种架构:
```python
total = sum(p.numel() for p in model.parameters()) / 1e6
n_routed = getattr(config, 'n_routed_experts', getattr(config, 'num_experts', 0))
n_active = getattr(config, 'num_experts_per_tok', 0)
expert = sum(p.numel() for n, p in model.named_parameters() if 'mlp.experts.0.' in n) / 1e6
base = total - expert * n_routed # 总参数 - 全部专家参数
active = base + expert * n_active # 只算每个 token 实际激活的专家
```
- Dense 模型:`active == total`,只打 `Model Params: 63.88M`
- MoE 模型:打 **`198M-A64M`**——总参 198M但每个 token 只路由到 1 个专家,**激活参数仅 64M**"A" = active。这个数字是 MoE 的"性价比广告"**存储花全量的钱,推理花激活的钱**[[04-稀疏MoE路由与负载均衡]] 细算)。
### 9.3 本篇收束
至此§1.2 的形状表全部走通:
`[B,S] → [B,S,768]Embedding→ 8×Block 残差流(不变)→ [B,S,768](最终 norm→ [B,S,6400]lm_head→ 标量 lossshifted CE`
四个"modern vs original"的现代化选择也齐了:**Pre-Norm + 残差流([[#5. 残差结构与预归一化MiniMindBlock 精读]]、RMSNorm[[#3. RMSNorm 精读]]、SwiGLU[[#4. SwiGLU三个线性层的门控 FFN]]、tie embeddings[[#7.2]]**。下一篇 [[02-RoPE位置编码与YaRN外推]] 先拆掉位置编码这块拼图,然后 [[03-自注意力机制精读]] 将拆掉前向回路里最大的一块黑箱:`Attention`GQA / QK-Norm / 因果掩码 / Flash Attention / KV Cache

View file

@ -0,0 +1,445 @@
---
title: "现代Transformer精读-02RoPE位置编码与YaRN外推"
date: 2026-08-25
tags: ["人工智能", "深度学习", "Transformer"]
description: "从绝对位置编码到旋转位置编码RoPE 的数学、代码实现与 YaRN 长文本外推"
draft: false
pinned: false
heroGradient: ["#7fb8ff", "#2f8df0"]
---
z# 现代Transformer精读-02RoPE位置编码与YaRN外推
> **相关**[[00-导读]](系列地图,待写) | [[01-模型骨架与前向回路]](本篇代码所在文件) | 下一篇 [[03-自注意力机制精读]]
>
> 本文精读 `model_minimind.py``precompute_freqs_cis`6278 行)与 `apply_rotary_pos_emb`8084 行)。假设你已熟悉**绝对正弦位置编码**与**可训练位置编码**——我们从这两个老朋友出发一路走到旋转位置编码RoPE的数学与实现最后讲透 MiniMind 的 YaRN 长文本外推配置。
## 1. 为什么需要位置编码:从绝对编码到旋转编码
### 1.1 自注意力"看不见顺序"
先做一个小实验。给一句话 $x=(\text{我},\text{打},\text{你})$,把它的三个 token **任意换序**成 $x'=(\text{你},\text{打},\text{我})$。Attention 计算的是**两两之间**的相似度:
$$
\mathrm{Attn}(Q,K,V)=\mathrm{softmax}\Big(\frac{QK^\top}{\sqrt{d_k}}\Big)V
$$
$QK^\top$ 只比较"每个 token 与其他 token"的配对,与它们**在序列中的先后无关**。严格地说:注意力输出对输入序列的**置换是等变的**permute 输入,输出跟着 permute但"谁在谁前面"的信息完全丢失)。于是同一个模型看到这两句话,会给出**一模一样**的表示——它既分不清"我打你"和"你打我",也分不清"第 2 句话的第三个字"和"第 1 句话的第三个字"。
**结论:位置信息必须由外部显式注入**这就是位置编码Positional Encoding存在的全部理由。
### 1.2 两个经典方案(绝对正弦与可训练)
**① 绝对正弦位置编码Vaswani et al., 2017**
$$
PE_{(\mathrm{pos},\,2i)}=\sin\Big(\frac{\mathrm{pos}}{10000^{2i/d}}\Big),\qquad
PE_{(\mathrm{pos},\,2i+1)}=\cos\Big(\frac{\mathrm{pos}}{10000^{2i/d}}\Big)
$$
- **加法注入**`embedding + PE`PE 的形状与 embedding 相同($d$ 维分 $d/2$ 对 sin/cos
- 优点:与序列长度无关(任意长都能生成)、无需训练;
- 缺点:编码的是**绝对位置**。模型若想知道"第 $m$ 个词与第 $n$ 个词的相对距离 $m-n$",只能靠三角恒等式自己"挖掘"
$$
\sin(\alpha+\beta)=\sin\alpha\cos\beta+\cos\alpha\sin\beta
$$
相对信息藏在 sin/cos 的恒等关系里,**模型要花费容量去学会利用它**——这是它朴素的一面。
**② 可训练位置编码BERT / GPT 系)**
- 每个位置一个**可训练向量** $p_{\mathrm{pos}}\in\mathbb{R}^{d}$,查表后同样"加到" embedding 上;
- 优点:简单直接、完全可学习;
- 缺点:只能在**训练见过的长度**内工作——超出 `max_len` 就没有向量可查,外推能力为零;而且它同样是"绝对位置"视角。
两个方案的共同基因:**加法注入 + 绝对位置**。
### 1.3 一张"好位置编码"的验收清单
带着清单去评价任何位置编码RoPE 的价值会自己浮出来:
| 验收维度 | 2017 正弦 | 可训练 | RoPE预告 |
|---|---|---|---|
| 相对位置感知 | 隐藏(需学习) | 弱 | **天然内建** |
| 长度外推 | 结构化可外推 | 无 | 好(+YaRN 更强) |
| 参数量 | 0 | $L\times d$ | 0 |
| 注入方式 | 加法(旁路装饰) | 加法 | **乘法(旋转),直接参与注意力几何** |
### 1.4 预告RoPE 的一句话直觉
> **把"位置的数值"升级成"旋转的角度":给 Query 和 Key 各转一个角度,两把钥匙的"角度差"就是相对位置。**
下一节我们用一张图把这句话变成公式——这就是 RoPE 名字的由来:**Ro**tary **P**osition **E**mbedding。
## 2. 旋转的数学
### 2.1 把向量放进复数平面
RoPE 的第一步操作:把维度 $d$ 的向量**切成 $d/2$ 组二维对** $(x_{2i},\,x_{2i+1})$,每一组看作复数平面上的一个点:
$$
z = x_{2i} + \mathrm{i}\,x_{2i+1}\ \in\ \mathbb{C}
$$
"位置 $m$"在复数里是一个非常自然的动作——**乘一个单位复数**
$$
z_m = z\cdot e^{\,\mathrm{i}\,m\theta_i} = z\big(\cos(m\theta_i)+\mathrm{i}\sin(m\theta_i)\big)
$$
$|e^{\mathrm{i}m\theta_i}|=1$,所以这个乘法**不改变向量长度,只旋转角度 $m\theta_i$**。换个角度看(对应实向量):
$$
R(m\theta_i)=\begin{bmatrix}\cos(m\theta_i) & -\sin(m\theta_i)\\ \sin(m\theta_i) & \cos(m\theta_i)\end{bmatrix},
\qquad
\begin{bmatrix}x_{2i}\\ x_{2i+1}\end{bmatrix}\mapsto R(m\theta_i)\begin{bmatrix}x_{2i}\\ x_{2i+1}\end{bmatrix}
$$
于是"给第 $m$ 个 token 编码位置 $m$" = "把它每个二维对旋转 $m\theta_i$ 角"。$\theta_i$ 是**该维度对专属的角速度**$i$ 越大转得越慢(下一节讲怎么定)。
### 2.2 灵魂定理:旋转后内积只依赖相对位置
现在看关键一步——位置 $m$ 的查询向量 $q_m$ 与位置 $n$ 的键向量 $k_n$ 的内积(复向量内积取实数部 $\langle a,b\rangle=\mathrm{Re}(a^{\mathsf{H}}b)$
$$
\langle q_m,\,k_n\rangle
=\mathrm{Re}\Big((q e^{\mathrm{i}m\theta})^{\mathsf{H}}\,(k e^{\mathrm{i}n\theta})\Big)
=\mathrm{Re}\Big(q^{\mathsf{H}}k\; e^{\,\mathrm{i}\,(n-m)\theta}\Big)
$$
记 $q^{\mathsf{H}}k=a+\mathrm{i}b$(只由 q、k 的内容决定),展开:
$$
\langle q_m,\,k_n\rangle
=a\cos\big((n-m)\theta\big)-b\sin\big((n-m)\theta\big)
$$
**内积表达式中,$m$ 和 $n$ 永远以差值 $(n-m)$ 的形式成对出现**——绝对位置消失了,只剩相对位置。这就是 RoPE 的灵魂定理也是它名字的由来Rotary Position Embedding
![[rope_relative.png]]
图里画的就是这回事:左边 q、k 不转时夹角固定(与位置无关);右边各自旋转后,夹角 $\gamma-(m-n)\theta$ 里只出现 $m-n$。**两个时钟各自走到自己的绝对时间,但"相差多少"只取决于差值。**
### 2.3 分块对角:把二维拼回 d 维
一个 $d$ 维向量有 $d/2$ 个二维对,位置 $m$ 的完整旋转就是**每个二维对各自旋转、互不干扰**的分块对角矩阵:
$$
R_m=\mathrm{diag}\big(R(m\theta_1),\,R(m\theta_2),\,\dots,\,R(m\theta_{d/2})\big)\ \in\ \mathbb{R}^{d\times d}
$$
每个块的角度 $m\theta_i$ 由该块的角速度 $\theta_i$ 决定。所以 RoPE 的全部"设计自由度"就剩一件事:**$\theta_i$ 怎么选**——这正是下一节代码里 `precompute_freqs_cis` 干的事。
### 2.4 为什么只转 q 和 k不转 v
注意力打分用 q、k 的内积([[03-自注意力机制精读]]v 只是"被加权后求和的内容"。位置信息要影响的是**谁该注意谁**(打分),而不是**注意到的内容本身**——旋转 v 只会把内容向量拧来拧去,不带来位置信息,还破坏内容语义。所以 RoPE 只作用在 q、k 上。
### 2.5 回头验收 1.3 的清单
| 验收维度 | RoPE 的表现 | 为什么 |
|---|---|---|
| 相对位置感知 | ✅ **天然内建** | 2.2 的定理:内积只含 $m-n$ |
| 参数量 | ✅ 0 | 旋转角由公式生成,无可训练参数 |
| 注入方式 | ✅ 乘法参与注意力几何 | 直接作用在 q、k 上,不是"加在 embedding 上的装饰" |
| 外推性 | ✅ 结构化外推 | 角度是位置 $m$ 的**线性函数**,任意 $m$ 都能算旋转角,不需要"没见过就查不到"的向量表(第 5 节细讲) |
还有一个隐藏好处:**旋转不改变向量模长**$|R_m q|=|q|$q·k 的缩放因子 $\sqrt{d_k}$ 语义不变,不影响 softmax 的温度——这对训练数值稳定性很重要。
## 3. 代码逐行:`precompute_freqs_cis` 与 `rotate_half`
### 3.1 角速度怎么定:指数衰减的频率
先看函数前半段(忽略 YaRN 分支,第 5 节专讲):
```python
freqs = 1.0 / (rope_base ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
```
逐步拆:`torch.arange(0, 96, 2)` 生成偶数 `[0,2,4,…,94]`(共 48 个),除以 `dim=96``[0, 1/48, …, 47/48]`,再取 `base=1e6` 的负幂:
$$
\theta_i = base^{-2i/d} = 10^{-6\cdot(2i/96)},\qquad i=0,1,\dots,47
$$
这正是 2.3 节说的"每个二维对专属角速度"。放大看三层含义:
1. **指数衰减**:与 2017 正弦编码同构(那里是 $1/10000^{2i/d}$),目的是**多尺度**覆盖——见下图:$i$ 小 → $\theta$ 大 → 高频,负责分辨**相邻位置**$i$ 大 → $\theta$ 小 → 低频,负责分辨**远距离位置**。
![[rope_freqs.png]]
2. **高/低频的失效模式不同**,见下图:同一位置 $m$,不同维度看到完全不同的信号频率。高频维度($i=0$$\cos(m)$)在几十个位置内就振荡好几个周期——**超过周期后无法区分位置**alias$m$ 与 $m+2\pi/\theta$ 给出相同 cos低频维度则在整个上下文里几乎是一条平线——它能稳定地区分长距离但**不能**提供细粒度位置。
![[rope_position_signal.png]]
3. **base 的作用**base 越大,所有 $\theta_i$ 整体越小曲线整体下移。MiniMind 用 `rope_theta=1e6`(原版 1e4相当于把整个频率谱往"低频方向"平移——每个维度"看得更远",对长文本更友好,这是后续外推(第 5 节)的地基。
### 3.2 位置-频率网格:`torch.outer`
```python
t = torch.arange(end, device=freqs.device) # end = max_position_embeddings = 32768
freqs = torch.outer(t, freqs).float() # [32768, 48],第 m 行 = [mθ₁, …, mθ₄₈]
```
`outer` 做外积:第 $m$ 行恰好是位置 $m$ 的 48 个旋转角 $m\theta_i$。整张表 `[32768, 48]` 就是**全部位置 × 全部维度对**的旋转角网格——预计算一次前向时按需切片01 篇 §6.3 的 `start_pos` 切片就是在这里摸表)。
### 3.3 谜底cos/sin 为什么各拼接两份
```python
freqs_cos = torch.cat([torch.cos(freqs), torch.cos(freqs)], dim=-1) * attn_factor # [32768, 96]
freqs_sin = torch.cat([torch.sin(freqs), torch.sin(freqs)], dim=-1) * attn_factor
```
`[32768, 48]` 拼成 `[32768, 96]`= head_dim。为什么是**两份**?答案在消费端 `apply_rotary_pos_emb`
```python
def rotate_half(x):
return torch.cat((-x[..., x.shape[-1] // 2:], x[..., : x.shape[-1] // 2]), dim=-1)
# 后一半取负放前、前一半放后
q_embed = (q * cos.unsqueeze(1)) + (rotate_half(q) * sin.unsqueeze(1))
```
对照数学:把向量前 48 维当"实部"$x$、后 48 维当"虚部"$y$(即 $z=x+\mathrm{i}y$)。旋转 $z e^{\mathrm{i}\theta}$ 的实部/虚部为:
$$
(x\cos\theta-y\sin\theta,\ \ y\cos\theta+x\sin\theta)
$$
而代码算的正是它:
$$
q\cdot\cos = (x\cos\theta,\ y\cos\theta)
$$
$$
\mathrm{rotate\_half}(q)\cdot\sin = (-y\sin\theta,\ x\sin\theta)
$$
$$
\Rightarrow\ \big(x\cos\theta-y\sin\theta,\ \ y\cos\theta+x\sin\theta\big) \checkmark
$$
所以"各拼接两份 cos/sin" = **给"实部组"和"虚部组"各准备一份同样的角表**`rotate_half` 负责把"虚部取负换位"这两个操作一次做掉。这与 2.1 的旋转矩阵完全等价(两种写法,同一个旋转):
$$
R(\theta)=\begin{bmatrix}\cos\theta & -\sin\theta\\ \sin\theta & \cos\theta\end{bmatrix}
\ \Longleftrightarrow\ \text{实部} + \mathrm{i}\,\text{虚部}\ \text{的复数乘法}
$$
`unsqueeze(1)` 的作用是让 `[…, 96]` 的角表在"序列维"上广播对齐 `[B, heads, S, 96]` 的 q/k——整片广播没有循环。
### 3.4 三种等价视角小结
| 视角 | 表述 |
|---|---|
| 复数乘法 | $z\mapsto z e^{\mathrm{i}m\theta_i}$ |
| 旋转矩阵 | $R(m\theta_i)$ 分块对角作用在二维对 |
| 代码实现 | $q\cdot\cos+\mathrm{rotate\_half}(q)\cdot\sin$(交叉加减) |
三者是同一个旋转——读代码时看到 `rotate_half` 不要慌,它只是"用坐标交换实现虚部换位"。
## 4. 位置表如何进注意力(注入点的秘密)
RoPE 与绝对/可训练编码还有一个常被忽略的差异:**注入点不在 embedding 处,而在注意力内部**。看 `Attention.forward` 的前五行111119 行):
```python
xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x) # ① 投影
xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim) # ② 切成多头
xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xq, xk = self.q_norm(xq), self.k_norm(xk) # ③ QK-Norm03 篇详讲)
cos, sin = position_embeddings
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin) # ④ RoPE 注入
# ⑤ ……之后才是 QKᵀ 内积与 softmax03 篇)
```
对比三种编码的注入位置:
| 方案 | 注入点 | 形式 |
|---|---|---|
| 2017 正弦 / 可训练 | **Embedding 之后**(加法) | 位置向量 + 词向量 |
| RoPE | **Q/K 投影之后、内积之前**(乘法) | 旋转 q、k 本身 |
RoPE 之所以能"直接参与注意力几何",正因为它在**打分函数正在形成的时刻**才动手——q、k 已带着语义,旋转只负责加"位置相位"。这与 [[#2. 旋转的数学]] 的几何是同一个故事的两种讲法。
`position_embeddings` 这份表从哪来、怎么切片,[[01-模型骨架与前向回路#6.3 forward切片、逐层循环、presents]] 已经讲过,这里只补一个此前没点破的推理细节:
- **训练时**`start_pos=0`,切片取表的前 `seq_len` 行,每个 token 用**自己真实的绝对位置**
- **推理续写时**`start_pos=已缓存长度`,新生成的 token 拿到 `start_pos+1` 起的行——它**知道自己是第几个**,而不是"从 0 重新数"。这就是 KV Cache 与 RoPE 的天然配合([[03-自注意力机制精读]] 会看到 KV cache 本体);
- **表长即上限**:表只预计算到 `max_position_embeddings=32768`,对话超出这个长度会切片越界——这是 `max_position_embeddings` 的真实含义,也解释了为什么长文本要么训练到足够长、要么用 YaRN 换表(下一节)。
## 5. YaRN 长文本外推:为什么 RoPE 会失效,怎么救
### 5.1 失效的本质:周期覆盖定理(为什么"高频天然安全、低频天然危险"
[[#3.1]] 里每个维度 $i$ 都有专属角速度 $\theta_i$,它定义一个**波长**(走完一个周期需要的 token 数):
$$
\lambda_i=\frac{2\pi}{\theta_i}=2\pi\cdot base^{2i/d}
$$
位置编码的相位是 $\phi_i(m)=m\theta_i \bmod 2\pi$——**一个纯周期函数**。关键来了:训练长度 $L$ 内,维度 $i$ 的相位被覆盖了多少?
$$
\text{训练内相位覆盖的周期数}=\frac{L}{\lambda_i}
$$
- **高频维度($\lambda_i$ 短)**:比如 $\lambda=64$,训练长度 $L=2048$ 内走完 $2048/64=32$ 个周期——相位在 $[0,2\pi)$ 上被**密集采样过**,任意位置 $m$ 的相位 $m\theta_i\bmod 2\pi$(无论 $m$ 多大)都会 **alias 回**训练中已经见过的某个相位值。模型对这个维度"所有相位都熟" → **外推安全**
- **低频维度($\lambda_i$ 长)**:比如 $\lambda=4096 > L$,训练内相位只覆盖了 $[0,\pi)$ 半圈——**周期都没走完**。推理时 $m>L$ 的相位滑进训练**从未见过的相位区间**,模型对这个维度的"陌生相位"没有经验 → **外推危险**
一句话:**RoPE 外推灾难 = 低频维度的周期大于训练长度,其相位进入陌生区**。这也预先解释了 YaRN 为什么"保留高频、压缩低频"是正解——不是拍脑袋,而是"多周期见过的维度别动,周期没走完的维度降速"。
### 5.2 治疗家族:从"整体降速"到 YaRN
要让远处相位重新落入模型熟悉的区域,直觉是"**让远处位置的旋转角变小**"——相当于把长序列的位置"压缩"回训练范围内的相位。各家做法:
| 方案 | 做法 | 问题 |
|---|---|---|
| 直接放大 base | 所有 $\theta_i$ 按同一比例变小 | 高频维度也变慢,**相邻位置精度损失** |
| NTK-aware2023 | 只放大 base 中的高频部分,保持高频不动 | 比盲放大好,但高频/低频的划分是硬的 |
| **YaRN2023** | **软 ramp**:低频维度频率压缩 $1/factor$、高频维度完全保留、中间线性过渡 | 目前的主流选择 |
YaRN"Yet another RoPE extensioN")的两个关键改进,都可以在 MiniMind 代码里逐行对上:**① 频率重缩放按维度软过渡ramp② 注意力的温度补偿attention_factor**。
### 5.3 代码逐行YaRN 分支
```python
# precompute_freqs_cis 内6973 行)
if end / orig_max > 1.0: # 只有"要覆盖的长度 > 训练长度"才启用
inv_dim = lambda b: (dim * math.log(orig_max / (b * 2 * math.pi))) / (2 * math.log(rope_base))
low, high = max(math.floor(inv_dim(beta_fast)), 0), min(math.ceil(inv_dim(beta_slow)), dim // 2 - 1)
ramp = torch.clamp((torch.arange(dim // 2, device=freqs.device).float() - low)
/ max(high - low, 0.001), 0, 1)
freqs = freqs * (1 - ramp + ramp / factor) # 频率重缩放
```
#### 5.3.1 启用条件
`end=32768`、`orig_max=2048`,比值 16 > 1 → 启用。训练时 `rope_scaling=None` 走纯 RoPE 表([[#4]] 的"表长即上限")。
#### 5.3.2 `inv_dim`:从"波长"反解"维度下标"
`low`/`high` 是**维度下标**,但设计者想表达的边界是**波长**。桥梁是:波长 $\lambda_i=2\pi\,base^{2i/d}$,令 $\lambda_i = orig\_max/b$$b$ 个 token反解 $i$
$$
i(b)=\frac{d}{2\ln base}\ln\frac{orig\_max}{2\pi b}
$$
代入 MiniMind 的数值($d=96,\ base=10^6,\ orig\_max=2048$
| 边界 | 波长 → 对应维度 | 数值 | 物理含义 |
|---|---|---|---|
| $low$ | $b=\beta_{fast}=32$$\lambda=2048/32=64$ | $\approx 8.06 \to \lfloor\rfloor=8$ | 波长 = 64 tokens训练内 **32 个周期**——绝对安全区上界) |
| $high$ | $b=\beta_{slow}=1$$\lambda=2048$ | $\approx 20.11\to\lceil\rceil=21$ | 波长 = 恰好等于训练长度(**1 个周期**——临界点) |
#### 5.3.3 `ramp``clamp`:软过渡的每一个细节
```python
low, high = max(math.floor(inv_dim(beta_fast)), 0), min(math.ceil(inv_dim(beta_slow)), dim // 2 - 1)
ramp = torch.clamp((torch.arange(dim // 2, device=freqs.device).float() - low)
/ max(high - low, 0.001), 0, 1)
```
**① `(i - low) / (high - low)` 的语义**:把维度下标 $i\in[low, high]$ **线性映射到 $[0,1]$**——离开 $low$ 越远,"被压缩的程度比例"越大。
**② `torch.clamp(·, 0, 1)` 在干什么**clamp= clip把张量截断进 $[0,1]$
$$
\mathrm{clamp}(x)=\min(\max(x,0),1)
$$
为什么必须 clamp当 $i<low$ $(i-low)/(high-low)<0$ $i>high$ 时 $>1$。如果不截断,`ramp` 会变成负数或大于 1`freqs * (1-ramp+ramp/16)` 会**给高频维度放大频率、给极低频维度产生负频率**——彻底破坏相位。clamp 让保留区($i<low$与压缩区$i>high$)变成**两段平坦的常数**,只有 $[low,high]$ 内是斜坡:
$$
\mathrm{ramp}(i)=\begin{cases}
0 & i<low\\
\dfrac{i-low}{high-low} & low\le i\le high\\
1 & i>high
\end{cases}
$$
**③ `max(high - low, 0.001)` 防除零**:极端配置下 $high\le low$(比如某些 base/orig 组合),分母为 0 会除出 NaN`max(·, 0.001)` 兜底。
**④ `floor`/`ceil` + `max(…,0)`/`min(…, d//21)` 的边界保护**`inv_dim` 是连续值,维度下标必须是整数——`floor`(下取整 $low$)与 `ceil`(上取整 $high$)把连续边界"收拢"成整数;再与 $0$ 和 $d/2-1$ 夹紧防止某些base 极大 / orig 极小)组合下 `inv_dim` 越出维度范围。这两行是工程上的"防御式编程"。
![[yarn_ramp.png]]
#### 5.3.4 频率重缩放:一张具体数值表
沿用 $d=96,\ base=10^6$(注意 $\theta_i=10^{-i/8}$$i=0..47$
| $i$ | $\theta_i$ | 波长 $\lambda_i$ | 训练内周期数 $L/\lambda_i$ | ramp | $\theta'_i$ | 区域 |
| --- | ------------------ | -------------- | -------------------- | ---- | ----------------- | ----------- |
| 0 | 1 | 6.3 | 325 | 0 | 1 | 高频保留 |
| 8 | 0.1 | 62.8 | 32.6 | 0 | 0.1 | 保留区上界low |
| 15 | 0.0133 | 471 | 4.3 | 0.54 | ≈0.0066 | 过渡区 |
| 21 | 0.00237 | 2650 | 0.77&lt;1 | 1 | ≈0.000148 | 压缩区起点high |
| 47 | $1.3\times10^{-6}$ | 4.7M | 0.0004(远&lt;1 | 1 | ≈$8\times10^{-8}$ | 深压缩区 |
对照 5.1 的周期覆盖定理看这表:**$i\le8$ 的维度训练里都见过几十到几百个周期alias 安全)→ 频率原封不动;$i\ge21$ 的维度连一个周期都没走完(相位陌生)→ 频率砍掉 16 倍**——压缩后它们在新位置 $m$ 处的相位 $\approx m\theta_i/16$,恰好"折算回"训练分布内熟悉的相位。过渡区($8\sim21$)线性渐变,避免相邻维度相位突变造成注意力打分的跳变。
#### 5.3.5 `attention_factor`:温度补偿
序列变长后参与 softmax 的 token 变多,注意力分布趋向"摊平"打分温度需要补偿——YaRN 论文的第三个调节旋钮(见 7677 行,`freqs_cos/sin *= attn_factor`。MiniMind 里配 `1.0`(不动),知道它的存在即可。
### 5.4 MiniMind 的配置与用法
Config 里的 YaRN 参数3139 行):
```python
self.rope_scaling = {
"beta_fast": 32, # 高频保留区的波长下界低i维度
"beta_slow": 1, # 低频压缩区的波长上界高i维度
"factor": 16, # 低频频率压缩倍数
"original_max_position_embeddings": 2048, # "训练长度"
"attention_factor": 1.0,
"type": "yarn"
} if self.inference_rope_scaling else None # 默认 False = 不启用
```
- **默认关闭**`inference_rope_scaling=False`),训练与常规推理都用纯 RoPE 表;
- 需要长文本外推时在推理侧开启(`eval_llm.py --inference_rope_scaling`),于是生成类的表换成 YaRN 版——**位置切片逻辑一行不改,换表即外推**,零训练、零参数;
- 一个值得记住的细节:`original_max_position_embeddings=2048` 应等于模型的**实际训练长度**MiniMind 不同规模的训练长度不同,用错这个值外推效果会打折)。
### 5.5 小结
YaRN = **"把远处位置的相位,拽回模型熟悉的训练分布"**高频不动保近距精度周期覆盖定理多周期见过的维度天然安全低频降速保远距可分周期没走完的维度才危险中间软过渡避免相位突变ramp + clamp 保证 $[0,1]$ 且两段平坦)。它不是模型能力,而是位置编码的"外挂"——这正是 MiniMind 把它放在 `precompute_freqs_cis` 这一个纯函数里的原因:跟模型结构完全解耦,换来一张新表。
## 6. 全篇小结
### 6.1 一条主线:位置编码的四代演进
| | 2017 正弦 | 可训练 | RoPE | RoPE + YaRN |
|---|---|---|---|---|
| 注入方式 | 加法embedding 后) | 加法embedding 后) | **乘法q/k 旋转)** | 同左 |
| 相对位置 | 隐藏(需学习三角恒等) | 弱 | **天然内建** | 同左 |
| 外推能力 | 结构化 | 无 | 好(相位周期) | **好得多(频率重缩放)** |
| 参数量 | 0 | $L\times d$ | 0 | 0 |
脉络一句话:**问题**(自注意力排列等变 → [[#1.1]])→ **旧解**(加法注入绝对位置 → [[#1.2]])→ **新解**(旋转注入、相对位置内建 → [[#2. 旋转的数学]])→ **补丁**(长文本外推 YaRN → [[#5. YaRN 长文本外推:为什么 RoPE 会失效,怎么救]])。
### 6.2 RoPE 的四步流水线
1. **切**$d$ 维向量切成 $d/2$ 个二维对,每对看作复数([[#2.1]]
2. **旋**:位置 $m$ 把每个二维对旋转 $m\theta_i$$\theta_i=base^{-2i/d}$ 指数衰减([[#3.1]]
3. **证**:旋转后内积只依赖 $(m-n)\theta$——绝对位置消失([[#2.2]]
4. **装**:分块对角拼回 $d$ 维,代码上等价于 $q\cos+\mathrm{rotate\_half}(q)\sin$[[#2.3]] / [[#3.3]])。
### 6.3 三个"为什么"复盘
- **为什么必须注入位置**:注意力对输入序列排列等变,不分先后([[#1.1]]
- **为什么用旋转而不是加法**乘法直接作用于打分函数q、k位置信息参与注意力几何而不是"贴在 embedding 上的装饰";且 $|Rq|=|q|$ 不扰动 softmax 温度([[#2.4]] / [[#2.5]]
- **为什么 YaRN 只动低频**周期覆盖定理——高频维度在训练长度内相位已被密集采样alias 安全),低频维度周期没走完、相位陌生([[#5.1]])。
### 6.4 代码索引速查
| 代码 / 配置 | 对应小节 |
|---|---|
| `precompute_freqs_cis`6278 | [[#3. 代码逐行precompute_freqs_cis 与 rotate_half]]、[[#5.3]] |
| `apply_rotary_pos_emb` / `rotate_half`8084 | [[#3.3]] |
| `rope_theta` / `max_position_embeddings` | [[#3.1]]、[[#4]] |
| `inference_rope_scaling` + `rope_scaling` dict3139 | [[#5.4]] |
| RoPE 表如何切片进 Attention | [[#4]] + [[01-模型骨架与前向回路#6.3 forward切片、逐层循环、presents]] |
**延伸阅读**RoPE 原始论文 *RoFormer: Enhanced Transformer with Rotary Position Embedding*Su et al., 2021YaRN 论文 *YaRN: Efficient Context Window Extension of Large Language Models*Peng et al., 2023
### 6.5 下一篇预告
位置编码就绪后,[[03-自注意力机制精读]] 将回到 `Attention` 本体QK-Norm、GQA 的 repeat、因果掩码、KV Cache 与 Flash Attention——届时你会看到[[#4]] 注入的 RoPE 是在 KV Cache 的 `start_pos` 切片下工作的,两篇在此交汇。

View file

@ -0,0 +1,517 @@
---
title: "现代Transformer精读-03自注意力机制精读"
date: 2026-08-25
tags: ["人工智能", "深度学习", "Transformer"]
description: "Attention 类逐行精读GQA、QK-Norm、因果掩码、Flash Attention 与 KV Cache"
draft: false
pinned: false
heroGradient: ["#7fb8ff", "#2f8df0"]
---
# 现代Transformer精读-03自注意力机制精读
> **相关**[[00-导读]](系列地图,待写) | [[01-模型骨架与前向回路]](残差流与形状) | [[02-RoPE位置编码与YaRN外推]](本篇的"位置注入"来源) | 下一篇 [[04-稀疏MoE路由与负载均衡]]
>
> 本文精读 `model_minimind.py``Attention`91134 行)与 `repeat_kv`8689 行。RoPE 已在 [[02-RoPE位置编码与YaRN外推]] 就位,这里直接"用"它。
## 0. 整块代码Attention 类全貌
先把整块代码完整摆出来,正文每一节都对应其中的几行——读完本篇,这一整块应该能做到"闭眼能默写"。
```python
# model_minimind.py 86134 行
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
bs, slen, num_key_value_heads, head_dim = x.shape
if n_rep == 1: return x
return (x[:, :, :, None, :].expand(bs, slen, num_key_value_heads, n_rep, head_dim)
.reshape(bs, slen, num_key_value_heads * n_rep, head_dim))
class Attention(nn.Module):
def __init__(self, config: MiniMindConfig):
super().__init__()
self.num_key_value_heads = config.num_attention_heads if config.num_key_value_heads is None else config.num_key_value_heads
self.n_local_heads = config.num_attention_heads # 8 个 Q 头
self.n_local_kv_heads = self.num_key_value_heads # 4 组 KV 头
self.n_rep = self.n_local_heads // self.n_local_kv_heads # 2每个 KV 头服务 2 个 Q 头
self.head_dim = config.head_dim # 96
self.is_causal = True
self.q_proj = nn.Linear(config.hidden_size, config.num_attention_heads * self.head_dim, bias=False)
self.k_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
self.v_proj = nn.Linear(config.hidden_size, self.num_key_value_heads * self.head_dim, bias=False)
self.o_proj = nn.Linear(config.num_attention_heads * self.head_dim, config.hidden_size, bias=False)
self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
self.attn_dropout = nn.Dropout(config.dropout)
self.resid_dropout = nn.Dropout(config.dropout)
self.dropout = config.dropout
self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention') and config.flash_attn
def forward(self, x, position_embeddings, past_key_value=None, use_cache=False, attention_mask=None):
bsz, seq_len, _ = x.shape
xq, xk, xv = self.q_proj(x), self.k_proj(x), self.v_proj(x) # ① 投影
xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim) # ② 切头
xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xv = xv.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)
xq, xk = self.q_norm(xq), self.k_norm(xk) # ③ QK-Norm
cos, sin = position_embeddings
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin) # ④ RoPE02 篇)
if past_key_value is not None: # ⑤ KV 拼接(推理续写)
xk = torch.cat([past_key_value[0], xk], dim=1)
xv = torch.cat([past_key_value[1], xv], dim=1)
past_kv = (xk, xv) if use_cache else None
xq, xk, xv = (xq.transpose(1, 2), # ⑥ 形状B,heads,S,d
repeat_kv(xk, self.n_rep).transpose(1, 2),
repeat_kv(xv, self.n_rep).transpose(1, 2))
if self.flash and (seq_len > 1) and (not self.is_causal or past_key_value is None) \
and (attention_mask is None or torch.all(attention_mask == 1)): # ⑦ Flash 路径
output = F.scaled_dot_product_attention(xq, xk, xv,
dropout_p=self.dropout if self.training else 0.0,
is_causal=self.is_causal)
else: # ⑧ 手动回退路径
scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)
if self.is_causal:
scores[:, :, :, -seq_len:] += torch.full((seq_len, seq_len),
float("-inf"), device=scores.device).triu(1) # 上三角 -inf
if attention_mask is not None:
scores += (1.0 - attention_mask.unsqueeze(1).unsqueeze(2)) * -1e9
output = self.attn_dropout(F.softmax(scores.float(), dim=-1).type_as(xq)) @ xv
output = output.transpose(1, 2).reshape(bsz, seq_len, -1) # ⑨ 收回 768
output = self.resid_dropout(self.o_proj(output))
return output, past_kv
```
**导览表**(行号 → 主题 → 精读节):
| 代码行 | 主题 | 本文小节 |
|---|---|---|
| 8689 `repeat_kv` | GQA 的 KV 广播 | [[#2. 多头 → GQA → repeat_kv]] |
| 91103 `__init__` 投影/头数 | 四个投影与 GQA 头配置 | [[#2]] |
| 104105 `q_norm`/`k_norm` | QK-Norm | [[#3. QK-NormRoPE 之前的稳定化]] |
| 109 `self.flash` | 是否启用快速注意力 | [[#5. Flash Attention 与手动回退]] |
| 113119 投影→切头→RoPE | 前五步流水线 | [[#1]]、[[#3]] |
| 120123 KV 拼接 | KV Cache | [[#6. KV Cache 与推理形态]] |
| 124126 形状与 Flash 路径 | flash 路径 | [[#5]] |
| 127131 手动路径 | 因果/attention 掩码 | [[#4. 因果掩码与 padding 掩码]] |
| 132134 o_proj/残差 | 输出与形状回收 | [[#7]] |
## 1. 缩放点积注意力的数学
### 1.1 Q / K / V三个投影在演什么戏
自注意力的输入只有一个:残差流里的 `x``[B, S, 768]`§0 的 ①)。它被三个投影(`q_proj` / `k_proj` / `v_proj`)各映射一份,语义上是三种角色:
- **QQuery 查询)**:当前 token "想找出谁跟我相关"——每人提一个问题;
- **KKey 键)**:每个 token 的"被查询标识"——每个位置回答"我是谁",供所有问题比对;
- **VValue 值)**:每个 token 真正携带的**内容**,被查询结果挑选后加权输出。
一次位置 $m$ 的完整计算,就是三步:
$$
\underbrace{\mathrm{score}(m,n)=\frac{\langle q_m,\,k_n\rangle}{\sqrt{d_k}}}_{\text{① 打分}}\quad\Rightarrow\quad
\underbrace{w_{mn}=\frac{e^{\mathrm{score}(m,n)}}{\sum_{n'}e^{\mathrm{score}(m,n')}}}_{\text{② 归一化成权重}}\quad\Rightarrow\quad
\underbrace{o_m=\sum_n w_{mn}\,v_n}_{\text{③ 按权重混合内容}}
$$
写成矩阵形式§0 的 ⑧):
$$
\mathrm{Attention}(Q,K,V)=\mathrm{softmax}\!\Big(\frac{QK^\top}{\sqrt{d_k}}\Big)V
$$
直觉打个比方:**考试批改**——每个 token 交卷V 是答卷内容贴着身份标签K 是考生号阅卷者Q按"谁跟我最像"的相似度分配注意力,最后把大家答卷按权重汇总成自己的"新笔记"。
### 1.2 为什么除以 $\sqrt{d_k}$
$QK^\top$ 的每一项是 $d_k$ 维内积。若 q、k 的各分量均值 0、方差 1
$$
\mathrm{Var}\big(\langle q,k\rangle\big)
=\mathrm{Var}\Big(\sum_{i=1}^{d_k} q_i k_i\Big)
=d_k
$$
内积的方差随维度 $d_k$ 线性增长。除以 $\sqrt{d_k}$ 后方差回到 1——**让打分尺度与维度无关**。如果不除:
- $d_k$ 越大score 的绝对值越大softmax 输入进入 $e^x$ 的**饱和区**——注意力要么近乎 one-hot只盯一个 token要么梯度极其平缓几乎学不动
- 除以 $\sqrt{d_k}$ 后输入保持在 $O(1)$softmax 的"软度"稳定。
(这与 [[01-模型骨架与前向回路]] 的 RMSNorm 是同一个哲学:**把尺度问题在入口处解决,而不是指望训练去适应尺度**。)
### 1.3 行归一化:一次"注意力预算"分配
注意 softmax 是**按行**做的:对固定的 $m$$\sum_n w_{mn}=1$。每一行就是"当前 token 把自己的注意力预算在全部历史位置之间分配"。
- **与 RoPE 的联动**:分值里其实带着 02 篇的相位——$\mathrm{score}(m,n)$ 含 $(m-n)\theta$,所以"预算怎么分"天然被相对距离调节([[02-RoPE位置编码与YaRN外推#2.2 灵魂定理:旋转后内积只依赖相对位置]]
- **与掩码的联动**未来位置、padding 位置的 score 被压成 $-\infty$softmax 后权重为 0——**预算不会分给看不见的 token**(第 4 节专讲)。
### 1.4 复杂度O(S²) 是注意力的"原罪"
打分矩阵的形状是 `[B, H, S, S]`——**每个位置对都要算一次内积**。8 层、每头 $d_k=96$
$$
\mathrm{FLOPs}(注意力)\approx 4\,B\,H\,S^2\,d_k
$$
$QK^\top$ 两次乘法 + softmax + 乘 V常数 4 是估算)。关键在 $S^2$**序列长度一翻倍,注意力算力涨 4 倍**。这是和 [[01-模型骨架与前向回路#9.1 逐模块对账]] 的参数账不同的另一本账——参数账看"存多少",这本题看"算多久";也正是 Flash Attention第 5 节)和 KV Cache第 6 节)存在的理由。
## 2. 多头 → GQA → `repeat_kv`
### 2.1 为什么要"多头"
一个注意力头只有**一种"相似度"度量**——它只能学一种"谁跟谁相关"的模式。多头Multi-Head Attention的本质**并行跑多个独立注意力头,每个头负责一种关系模式**(有的盯局部语法、有的盯长程指代、有的盯同一个词的不同含义)。
实现上很便宜:把 768 维**切成 8 份 96 维**§0 的 ②,只是 `view`,不增加参数),每份独立走一遍 [打分→softmax→混合],最后 `o_proj` 把 8 个头的输出拼回 768§0 的 ⑨)。**参数量一分没涨,表达方式多出 8 种**——这是注意力性价比最高的设计之一。
### 2.2 谱系MHA → MQA → GQA
"多头"之下还有个问题:**KV 要不要也多头**?谱系上的三种做法:
| 方案 | Q 头 | KV 头 | 代表 |
|---|---|---|---|
| MHAMulti-Head | 8 | 8 | 2017 原版 |
| MQAMulti-Query | 8 | **1** | PaLM省到极致质量有损 |
| **GQAGrouped-Query** | 8 | **4分 2 组)** | Llama 2/3、Qwen、MiniMind |
**GQA 的动机**K、V 头少 → ① K/V 投影参数减半01 篇 §9.1 的账:每层省 0.59M8 层省约 4.7M);② **KV Cache 减半**——推理时逐 token 缓存的正是 K、V这是推理显存的大头第 6 节专门算这笔账)。**8:4 是保守折中**质量几乎无损显存立省一半Llama2-70B 激进到 64:8
### 2.3 `repeat_kv`4 组 KV 如何服务 8 个 Q
```python
def repeat_kv(x, n_rep): # n_rep = 8 // 4 = 2
bs, slen, num_key_value_heads, head_dim = x.shape # [B, S, 4, 96]
if n_rep == 1: return x
return (x[:, :, :, None, :] # ① 插入新维 [B,S,4,1,96]
.expand(bs, slen, num_key_value_heads, n_rep, head_dim) # ② 广播 [B,S,4,2,96]
.reshape(bs, slen, num_key_value_heads * n_rep, head_dim)) # ③ 摊平 [B,S,8,96]
```
三步的形状与语义:
| 步骤 | 形状 | 发生了什么 |
|---|---|---|
| 输入 | `[B, S, 4, 96]` | 4 组 KV |
| ① `x[…, None, :]` | `[B,S,4,1,96]` | 在"头"和"特征"之间插入一维 |
| ② `expand(...)` | `[B,S,4,2,96]` 视口 | **广播复制:不分配新内存**(只改 strides每组 KV 变成 2 份 |
| ③ `reshape(...)` | `[B,S,8,96]` | 物化成 8 组(复制真正发生在这里) |
于是 Q 头与 KV 组的配对关系是:
$$
\text{Q 头 }0,1\to\text{KV 组 }0;\qquad \text{Q 头 }2,3\to\text{KV 组 }1;\qquad\dots\qquad \text{Q 头 }6,7\to\text{KV 组 }3
$$
**同一组 KV 被两个 Q 头共享,但打分各自独立**——GQA 的"分组共享"就这么朴素。
### 2.4 形状变换小表(对应 §0 的 ⑥)
| 张量 | 切头后 | `repeat_kv` + `transpose(1,2)` 后 | 语义 |
|---|---|---|---|
| xq | `[B, S, 8, 96]` | `[B, 8, S, 96]` | 8 头各自的查询(不经过 repeat |
| xk | `[B, S, 4, 96]` | `[B, 8, S, 96]` | 广播后的键 |
| xv | `[B, S, 4, 96]` | `[B, 8, S, 96]` | 广播后的值 |
`transpose(1,2)` 把"头维"提到第 2 维,得到 `[B, H, S, d]`——一次矩阵乘同时算所有头(`xq @ xkᵀ` 在 `[B,H,S,d]×[B,H,d,S]` 上批量完成)。两个值得注意的顺序细节:
- **KV 拼接(⑤)在 `repeat_kv` 之前**:缓存里存的是"4 组 KV",不是 8 份——**缓存省内存的前提就是只在需要计算时才广播**
- **`repeat_kv``transpose` 之前**:保持"序列维在第二维"与 KV 拼接的 `dim=1` 一致,形状逻辑一条线顺下来。
### 2.5 呼应 01 篇的账
GQA 在 MiniMind 的具体收益8:4
- **参数**k/v 投影 $768\times384$ 而非 $768\times768$,每层省 $2\times768\times384=0.59\text{M}$8 层省 ≈ 4.7M(约占 7%,见 [[01-模型骨架与前向回路#9.1 逐模块对账]]
- **KV Cache**:缓存的 KV 减半——这个数字在第 6 节KV Cache会精确算出来它是长上下文推理显存的生死线。
## 3. QK-NormRoPE 之前的稳定化
### 3.1 代码与位置
```python
# __init__104105 行),复用 01 篇的 RMSNorm但归一化对象是 head_dim
self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) # 96 维
self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps)
# forward117119 行):在 RoPE 注入 **之前**
xq, xk = self.q_norm(xq), self.k_norm(xk)
cos, sin = position_embeddings
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin)
```
它把 §0 流水线里"③ QK-Norm ④ RoPE"的顺序钉死了:**先稳定内容,再注入位置**。归一化的对象不是整条残差流768 维),而是**每个头自己的 96 维**向量——`RMSNorm` 沿最后一维做,所以 8 个 Q 头、4 个 K/V 头各自独立归一。
### 3.2 它在防什么:注意力 logits 的尺度漂移
[[#1.2]] 说注意力对 logits 的尺度敏感softmax 饱和)。问题是:**Pre-Norm 时代没人管 q、k 的尺度**。
- 回顾 [[01-模型骨架与前向回路#5.3.3 实践中的三个证据]]Pre-Norm 的代价是**残差流尺度随深度缓慢增长**final RMSNorm 只在最外层兜底);
- q、k 是 `q_proj(x)` / `k_proj(x)` 的投影——`x` 的尺度在长,$W_q$ 训练中也在变,**$q$、$k$ 的范数会无界漂移**
- 于是 $QK^\top/\sqrt{d_k}$ 的绝对值跟着漂softmax 时而被"烫"得接近 one-hot、时而"冷"得梯度趋平,严重时某些头整个**失活**logits 全被压扁)。
QK-Norm 的做法很直接:**在打分之前,把每个头的 q、k 各自的尺度归一**——打分退回到只由"**方向相似度**"决定,范数漂移被消除。这和 [[#1.2]] 的 $\sqrt{d_k}$ 是同一条防线(尺度问题在入口解决),只不过 $\sqrt{d_k}$ 防的是"维度带来的方差"QK-Norm 防的是"**训练过程累积的尺度漂移**"。
### 3.3 历史脉络:它是"后 Pre-Norm 时代"的补丁
| 时代 | 结构 | 谁在兜底 logits 尺度 |
|---|---|---|
| 2017 原版 | Post-Norm + LayerNorm | 每层 LayerNorm残差合并后归一 |
| 现代主流Llama 1/2 等早期) | Pre-Norm | **没人管**(靠初始化碰运气) |
| 新一代Llama-3.1、Gemma 2、Qwen3、MiniMind | Pre-Norm + **QK-Norm** | 打分前显式归一 q、k |
一句话:**Pre-Norm 把"每层兜底"取消了QK-Norm 又把注意力这一处的兜底补了回来**——但它补得更精准,只归一 q、k不碰 v与 [[02-RoPE位置编码与YaRN外推#2.4 为什么只转 q 和 k不转 v]] 同一个哲学:打分的归打分,内容的归内容)。
### 3.4 小细节:为什么可以放在 RoPE 之前
RoPE 是纯旋转,**不改变模长**02 篇 [[#2.5]] 的隐藏好处:$|Rq|=|q|$)。因为先归一的后归一不影响范数,`Norm(Rotate(q))` 与 `Rotate(Norm(q))` 在数学上等价——所以"先 ③ 后 ④"纯属实现选择(先稳定、再注入更符合直觉,数值上也更稳)。这个自由度正是"旋转"这种注入方式的又一项福利。
## 4. 因果掩码与 padding 掩码
### 4.1 为什么需要因果掩码
自回归的定义:位置 $m$ **只能看到 $\le m$ 的历史**[[01-模型骨架与前向回路#7.3 shifted CE loss自回归目标的落点]])。打分矩阵 `[B, H, S, S]` 的元素 $(m,n)$ 是 $q_m\cdot k_n$——其中 $n>m$ 的那些列(未来位置)必须对 $m$ 行**不可见**,否则 softmax 会把权重分给"未来的自己",训练目标就泄漏了。
### 4.2 `triu(1)`:上三角填 $-\infty$
```python
scores[:, :, :, -seq_len:] += torch.full((seq_len, seq_len), float("-inf"),
device=scores.device).triu(1)
```
- **`torch.full(...).triu(1)`**:构造一个 $S\times S$ 矩阵,**严格上三角**(主对角线以上)为 1、其余为 0
$$
\mathrm{triu}(1)_{mn}=\begin{cases}1 & n>m\\ 0 & n\le m\end{cases}
$$
- 加进 scores 后,$n>m$ 处变成 $-\infty$。softmax 里 $e^{-\infty}=0$ 是精确的——**未来列的权重数学上严格为 0**
- **为什么是 `triu(1)` 而不是 `triu(0)`**`triu(0)` 会把主对角线($n=m$,即"自己看自己")也遮掉。自回归允许 token 关注自己(自己的位置信息在 RoPE 相位里非常关键),所以只遮严格上三角。
**一个容易忽略的正确性细节——`scores[:, :, :, -seq_len:]` 的切片**:推理续写时(第 6 节),`scores` 是 `[B, H, 1, 1+past]`当前行1 行)对着"past 全部列 + 自己"。此时只需要对**新增的这一个小方块**$1\times1$)加因果掩码,而 past 列**绝不能遮**——它们全是历史,当前 token 有权看。`-seq_len:` 把掩码只加到靠右的 `seq_len` 列,正是保证"**只遮未来、不遮过去**"的关键(训练时 `seq_len=S`、没有 past切片即整个矩阵行为不变
### 4.3 padding 掩码:为什么用 $-1e9$ 而不是 $-\infty$
```python
if attention_mask is not None:
scores += (1.0 - attention_mask.unsqueeze(1).unsqueeze(2)) * -1e9
```
- 入参 `attention_mask` 形状 `[B, S]`1=有效 token0=padding`unsqueeze(1).unsqueeze(2)` 展成 `[B,1,1,S]` 后广播到 `[B,H,S,S]`
- `(1.0 - mask)`**padding 列为 1**,其余为 0`-1e9`padding 列被加一个极大的负数,$e^{-1e9}\approx0$——权重压到 0却不影响其他列的 softmax 归一化($e^{x-1e9}\approx0$ 不贡献分母)。
scaled_dot_product_attention
**为什么是 $-1e9$ 而不是 $-\infty$**:工程近似。$-\infty$ 语义上更"纯",但 `scores` 此时在 bf16/fp16 精度下计算——**fp16 的范围只到 $\pm65504$`-1e9` 会溢出成 `-inf`**,行为依然正确;而 bf16 的范围 $\pm3.4\times10^{38}$ 能精确表示 `-1e9`。作者用 `-1e9` 是"一个足够大的有限值",在两种精度下都安全(若直接写 `-inf` 在 flash 内核的融合实现里有额外分支,`-1e9` 更通用)。
### 4.4 两种掩码的分工与数据来源
| 掩码 | 来源 | 形状 | 值 | 作用 |
|---|---|---|---|---|
| 因果掩码 | 代码构造 | `[S,S]` 严格上三角 | $-\infty$ | 未来不可见 |
| padding 掩码 | 调用方传入 `attention_mask` | `[B,1,1,S]` 广播 | $-1e9$ | padding 列权重 ≈ 0 |
有意思的是MiniMind 的**训练脚本不传 `attention_mask`**`model(input_ids, labels=labels)`),所以训练时只有因果掩码生效——因为 Pretrain/SFT 数据都是**右 padding** 到定长padding token 处于序列末尾(位置更大),**因果掩码已经把它们当作"未来"遮住了**loss 侧再由 `-100` 屏蔽([[01-模型骨架与前向回路#7.3 shifted CE loss自回归目标的落点]])。而 RL 训练GRPO/PPO 的 `full_mask`、`rollout_engine` 的 padding_side=left才真正传 mask——那里的 padding 在左侧,因果掩码管不到,必须靠 `attention_mask`[[08-偏好对齐DPO与强化学习]])。
## 5. Flash Attention 与手动回退
### 5.1 标准注意力为什么吃显存O(S²) 的"打分矩阵物化"
教科书实现([[#1.1]] 的公式直接翻译成 PyTorch
```python
scores = xq @ xk.transpose(-2, -1) # [B,H,S,S] ← 这一步物化
weights = softmax(scores / sqrt(d))
output = weights @ xv
```
问题在 `scores`:它是一个**完整的 $S\times S$ 矩阵被写进显存HBM**。$S=340$ 时没感觉;一旦长上下文(如 $S=32768$
$$
B\times H\times S^2\times 2\,\text{bytes}\ (\text{bf16}) = 1\times 8\times 32768^2\times 2 \approx 17\,\text{GB}
$$
**仅打分矩阵一项就把显存打爆**。更隐蔽的是第二笔账:为了算 softmax`scores` 的每行要**读出来两次**(一次求 max/sum、一次算 e^x矩阵越写越大HBM 往返越贵——标准实现的瓶颈其实在**内存带宽**,不在算力。
### 5.2 FlashAttention 的原理:分块 + 在线 softmax
FlashAttentionDao et al., 2022没有减少计算量仍是 $O(S^2)$),它做的是**IO 优化**:把整个注意力重写成"**分块tiling**"算法,让每个块在 GPU 的**片上 SRAM**(几十 KB 的快速缓存)里完成"打分 → softmax → 乘 V"的全部步骤,只把块级结果写回 HBM——打分矩阵 `[B,H,S,S]` **从不整体物化**,显存降到 $O(S)$。
分块能成立的数学关键是**在线 softmaxonline softmax**。标准 softmax 要两遍(先求最大值 $m$、再求和 $\ell=\sum e^{x_i-m}$),这两遍要求整行 $x$ 都在——与分块矛盾。在线 softmax 的解法:**边扫边维护"运行中的最大值与和",遇到更大的 $m$ 就把已累加部分按比例修正**
$$
\ell \leftarrow \ell\cdot e^{m_{\text{old}}-m_{\text{new}}} + \sum_{\text{新块}} e^{x-m_{\text{new}}}
$$
已算出的部分输出同样乘上 $e^{m_{\text{old}}-m_{\text{new}}}$rescaling——**只需块内数据在 SRAM块间只传递两个标量$m,\ell$**。最终每个输出 token 拿到的是与标准 softmax **数学等价**的结果,但全程没有 S² 矩阵落地。
### 5.3 PyTorch 内建的 `scaled_dot_product_attention`
`torch.nn.functional.scaled_dot_product_attention`PyTorch ≥ 2.0)是一个**融合入口**:根据输入形状 / dtype / 硬件,自动选择
- **flash attention 内核**NVIDIA Ampere+bf16/fp16`S` 需满足内核对齐);
- **memory-efficient 内核**xformers 系,更通用的 fallback
- **math 回退**(纯 PyTorch kernel行为如 [[#5.1]] 但由框架优化)。
MiniMind 只负责"调用哪个 API",内核选择完全交给 PyTorch。调用时传入的已是 `[B,H,S,d]`§0 ⑥ 之后),`is_causal=True` 让内核**内部**处理因果掩码——连掩码矩阵都不用建,又省一份显存。
### 5.4 回退判断依据:四个条件逐条拆
```python
if self.flash and (seq_len > 1) and (not self.is_causal or past_key_value is None) \
and (attention_mask is None or torch.all(attention_mask == 1)):
output = F.scaled_dot_product_attention(xq, xk, xv,
dropout_p=self.dropout if self.training else 0.0,
is_causal=self.is_causal)
else:
# 手动回退127131 行): scores → 因果/attention 掩码 → softmax → @ xv
```
| 条件 | 含义 | 为什么需要它 |
|---|---|---|
| `self.flash` | 构建时判定:`hasattr(F,'scaled_dot_product_attention')` 且 `config.flash_attn` | 版本闸PyTorch≥2.0+ 配置开关(默认开)两道闸 |
| `seq_len > 1` | 不是"单 token 续写" | 续写时打分矩阵是 `[B,H,1,P+1]`**非方阵**flash 的 causal 内核按 $S\times S$ 方阵假设优化,非方阵走它不划算;且单行打分的内核启动开销反而大于手动计算 |
| `not is_causal or past_key_value is None` | 等价于 `past_key_value is None``is_causal` 恒 True | **有缓存时不能走 sdpa 的 causal 模式**causal 的意思是"行 $m$ 遮列 $n>m$";而推理时当前行只该遮"未来"past 列必须全可见——`sdpa(is_causal=True)` 没有"未来截止点 + 过去全开"这种混合掩码的表达,硬用会遮错列。这是回退最核心的原因 |
| `attention_mask is None or torch.all(attention_mask == 1)` | 无 padding / 掩码全 1 | 见 [[#4.3]]padding 掩码是 `-1e9` 加法flash 内核的融合 causal 路径对非平凡 mask 支持受限MiniMind 的策略是"有 padding 就退回手动路径自己加" |
**一句话总结两条路径的分工**
- **Flash 路径** = 训练 / 预填充(每步 `seq_len>1`、无 KV 缓存、无 padding 的"方阵 + 全可见"场景);
- **手动路径** = 推理续写(`seq_len=1`、有缓存、非方阵)或带 padding 的批处理。
这也解释了 [[#4.2]] 那个 `-seq_len:` 切片为什么"只有手动路径里才需要"——续写的非方阵打分只有手动路径在算,遮"未来小方块"的活儿全在手动路径里。
### 5.5 两条路径的等价性与数值细节
- **数学等价**手动路径就是教科书实现打分→掩码→softmax→乘 V与 sdpa 结果一致(仅允许浮点舍入差);
- **dropout 位置不同**flash 路径训练时 `dropout_p` 由内核在**乘 V 前对权重做随机丢弃**(块内完成);手动路径 `attn_dropout``softmax 之后、@ xv 之前`——位置等价,实现不同;
- **又一个"入口解决尺度"**:手动路径 `F.softmax(scores.float(), …).type_as(xq)`——打分先转 fp32 算 softmax 再转回,与 [[01-模型骨架与前向回路#3.3 实现细节逐行拆]] 的 RMSNorm 是同一款数值稳定技巧。
## 6. KV Cache 与推理形态
### 6.1 推理的重复计算问题
生成decode是自回归循环每一步只产生**一个新 token**,但朴素实现要把"prompt + 已生成全部"重新算一遍前向——每步都是 $O(S^2)$,总开销 $O(\text{步数})\times O(S^2)$,绝大部分是白算。
关键观察在注意力的结构里:位置 $n$ 的 K、V 是 $k_n=W_k x_n$、$v_n=W_v x_n$——**$x_n$ 一旦生成就不变**,所以历史位置的 K、V **永远不变、每步重算纯属浪费**。变化的只有:每步新增一个 $q$(新位置要问)、把这个新位置的 K、V 算出来。
**KV Cache 的策略**:把历史位置的 K、V 存下来,每步**只算新 token 的 K、V拼到缓存尾**,打分时"新 q × 全部 K"
$$
\text{第 }t\text{ 步}: \underbrace{q_{t}}_{\text{新}} \times \underbrace{[k_0,k_1,\dots,k_{t-1},k_t]}_{\text{缓存 + 新增}}
$$
### 6.2 代码:拼接发生在哪儿
```python
if past_key_value is not None: # ⑤ 推理续写才进来(训练 None
xk = torch.cat([past_key_value[0], xk], dim=1) # [B, P, 4, 96] + [B, 1, 4, 96] → [B, P+1, 4, 96]
xv = torch.cat([past_key_value[1], xv], dim=1)
past_kv = (xk, xv) if use_cache else None # 返回给上层,供下一轮输入
```
四个值得停下来的点,其中最后一条是最容易看错的地方:
1. **拼接在 `dim=1`(序列维)**——发生在 [[#2.3]] 的 `repeat_kv` **之前**。缓存里存的是 **4 组** KVGQA 未广播形态),每次前向只广播一次,兑现了 §2.3 说的"缓存省内存的前提是只在计算时广播"
2. **`past_kv` 的循环路径**`Attention → Block → MiniMindModel 的 presents 列表 → MiniMindForCausalLM 输出 → 下一轮 `past_key_values=` 传回`——整条链在 [[01-模型骨架与前向回路#6.3]] 的 `start_pos` 处与 RoPE 表接上;
3. **训练时全程 None**`use_cache=False`(训练脚本不传),`past_kv=None``presents` 列表全是 None——缓存机制零开销地"隐形"
4. **容易看错的一条:只有 K/V 被拼接xq 从不拼接**。续写时 `generate` 每步只喂 `input_ids[:, past_len:]`§6.5),所以 xq 始终只有新 token 那一行 `[B,1,8,96]`;点积 `[B,8,1,96]×[B,8,96,1+P]` 就是"新 q × 全部 K",没有一行历史 q 被重算。**切片的是输入、拼接的是 K/V**——两个动作配套使用才消除冗余;若只开 cache 却不切输入把整段历史重新喂xq 会变回全历史行数,冗余就真的发生了。
### 6.3 缓存账GQA 在这里值多少钱
每层、每个 token 的缓存量bf16
$$
2\,(\text{K,V})\times 4\,(\text{kv\_heads})\times 96\,(\text{head\_dim})\times 2\,\text{B} = 1536\,\text{B} = 1.5\,\text{KB}
$$
8 层 → **12 KB/token**。对比:
| 序列长度 | KV CacheGQA 8:4 | ×2 若用 MHA8:8 |
|---|---|---|
| 1k | 12 MB | 24 MB |
| 8k | 98 MB | 196 MB |
| 32k | 392 MB | 785 MB |
对照:模型本身 64M 参数 ≈ fp16 128MB。**序列一长KV Cache 的体积会超过模型权重本身**——这就是 §2.5 说的"推理显存生死线"GQA 把缓存砍半,等于把**长上下文推理的显存预算直接减半**。通用公式(记下来):
$$
\text{KV cache}=2\times kv\_heads\times head\_dim\times layers\times seq\_len\times \text{dtype字节}\times batch
$$
### 6.4 非方阵打分的正确性(回扣 §4.2 / §5.4
续写时打分矩阵是 `[B, H, 1, P+1]`——**1 行 × (P+1) 列,非方阵**
- 当前行(新 q允许看**所有** past 列——它们全是历史([[#4.2]] 的切片事件里,`-seq_len:` 恰好不会遮它们);
- "未来遮列"在续写时**已经不需要了**:新 token 本身就是序列最末尾,没有"比它更新的未来"要遮self 可见,见 [[#4.2]] 的 `triu(1)` 论证);
- 这也正是 [[#5.4]] 判据"有 past 就回退手动路径"的深层原因——非方阵 + "只看过去"的语义flash 内核的方阵 causal 模式表达不了。
**RoPE 的联动**02 篇 [[#4]] 在此交汇):新 token 的 RoPE 角 = **真实的绝对位置** $P+1$`start_pos=P` 时的切片past 的 K 带着它们生成时的位置角——相位一致性靠"每步都用真实位置"保证,而**不是**把历史 K 重转一遍。
### 6.5 算力账与 generate 循环
伪代码版本的推理循环01 篇 §8 预留的 `generate` 就是这个循环):
```
for _ in range(max_new_tokens):
outputs = forward(input_ids[:, past_len:], past_key_values) # 每步只算新 token
next_id = sample(outputs.logits[:, -1, :]) # 采样(温度/top-p§8 待补)
input_ids += next_id
past_key_values = outputs.past_key_values # 缓存接力
```
**算力从 $O(T\cdot S^2)$ 降到 $O(T\cdot S)$**$T$ 为总长、$S$ 为历史长):每步的打分是新 $q$1 行)对着缓存($P+1$ 列),是矩阵-向量级运算——**KV Cache 同时解决显存账§6.3)和算力账(这里)**,是推理工程的第一性原理。
### 6.6 训练 vs 推理小结
| 维度 | 训练 | 推理续写 |
|---|---|---|
| 每步 `seq_len` | $S$(整段) | 1 |
| 打分形状 | `[B,H,S,S]` 方阵 | `[B,H,1,P+1]` 非方阵 |
| 路径 | flash无缓存、无 padding | 手动(有缓存) |
| 因果掩码 | `triu(1)` 全场 | 不需要past 全可见) |
| RoPE | `start_pos=0` | `start_pos=P`(真实位置) |
| KV 缓存 | None | `(K,V)` 4 组拼接 |
## 7. 收束:`o_proj`、残差写回与本篇小结
### 7.1 `o_proj`8 个头怎么拼回 768
先补上手动路径里一行"消费 V"的代码——它是 [[#5]] 之后、`o_proj` 之前的完整链路flash 路径的等价步骤发生在 `sdpa` 内部):
```python
# 手动路径尾部131 行softmax 权重 × 加权混合 V
output = self.attn_dropout(F.softmax(scores.float(), dim=-1).type_as(xq)) @ xv
# 两条路径在此汇合:
output = output.transpose(1, 2).reshape(bsz, seq_len, -1) # [B,8,S,96] → [B,S,768]
output = self.resid_dropout(self.o_proj(output)) # o_proj: 768 → 768 方阵
```
- `F.softmax(scores.float(), dim=-1).type_as(xq)`fp32 算 softmax 再转回——"入口解决尺度"同款手法([[#5.5]] 的第三点);
- `attn_dropout(…).@ xv`softmax 权重与 xv 的**加权混合**,是手动路径里唯一消费 V 的地方;训练时 dropout 在 softmax 后按权重随机置零flash 路径由 `sdpa``dropout_p` 在块内等价完成([[#5.5]] 第二点);
- `transpose(1, 2)` + `reshape`:把 8 个头**排回一条 768 维向量**回头维§0 的 ⑨);
- `o_proj`768→768 方阵):把 8 个头的信息**线性混合**成一条残差流向量——真正让多头"合体"的一步。
对照 [[#2.1]] 的分工就完整了:**入口q/k/v是"把信息分发进 8 个头"出口o_proj是"把 8 个头收敛回一条主干道"**——多头只活在注意力内部,出了 `Attention`,一切照旧是 768 维残差流([[01-模型骨架与前向回路#1.2 形状怎么走Tensor 形状变化)]])。
### 7.2 残差写回
`o_proj` 的输出回到 `MiniMindBlock` 的 ②,执行 §0 之外的最后一环:`hidden_states += residual`[[01-模型骨架与前向回路#5.1 把 §1.3 的画面落到代码]] 的"读-算-写")。`resid_dropout` 与 `attn_dropout` 都服从 `config.dropout=0.0`(默认关闭),属于"结构已足够正则、不需要随机丢弃"的小模型取舍([[01-模型骨架与前向回路#2.1 核心超参逐项解读]])。
### 7.3 三本账小结
| 账 | 数字 | 出处 |
|---|---|---|
| 参数 | q/o 方阵 $768^2$、k/v 半宽 $768\times384$GQA 省 4.7M | [[#2.5]]、[[01-模型骨架与前向回路#9.1 逐模块对账]] |
| 算力(训练) | $O(B\cdot H\cdot S^2\cdot d)$flash 只省显存不省算力 | [[#1.4]]、[[#5.2]] |
| 算力(续写) | $O(T\cdot S)$KV Cache 消除重算) | [[#6.5]] |
| 显存(推理) | KV Cache ≈ 12 KB/tokenGQA 砍半 | [[#6.3]] |
### 7.4 本篇"现代化清单"(续 01 篇的对比表)
| 组件 | 出处 |
|---|---|
| GQA 分组查询注意力 | [[#2.2]] |
| QK-NormPre-Norm 时代的稳定性补丁) | [[#3. 3. QK-NormRoPE 之前的稳定化]] |
| Flash Attention / sdpa | [[#5. Flash Attention 与手动回退]] |
| KV Cache + 非方阵续写 | [[#6. KV Cache 与推理形态]] |
| 工程细节:`-1e9`、fp32 softmax、`triu(1)` 切片 | [[#4. 因果掩码与 padding 掩码]] |
### 7.5 下一篇预告
**下一篇 [[04-稀疏MoE路由与负载均衡]]** 回到 01 篇 §2.2 埋的钩子:`use_moe=True` 时 `MiniMindBlock``mlp` 换成 `MOEFeedForward`——路由、top-k、负载均衡 aux loss 与"198M-A64M"的算账都将在此展开。注意力这条线到此闭环。

View file

@ -0,0 +1,410 @@
---
title: "现代Transformer精读-04稀疏MoE路由与负载均衡"
date: 2026-08-27
tags: ["人工智能", "深度学习", "Transformer"]
description: "MOEFeedForward 逐行精读路由、top-k、norm_topk_prob、辅助负载均衡损失与 198M-A64M 算账"
draft: false
pinned: false
heroGradient: ["#7fb8ff", "#2f8df0"]
---
# 现代Transformer精读-04稀疏MoE路由与负载均衡
> **相关**[[00-导读]](系列地图,待写) | [[01-模型骨架与前向回路]]`use_moe` 开关与 FFN 来源) | [[02-RoPE位置编码与YaRN外推]] | [[03-自注意力机制精读]]attention 线已闭环) | 下一篇 [[05-数据管线与自回归目标设计]]
>
> 本文精读 `model_minimind.py``MOEFeedForward`148176 行)与 config 的 MoE 字段4145 行。01 篇 §2.2 埋的钩子(`use_moe=True` 时 MLP 换 MoE在此解开——它是模型架构线的收官。
## 0. 整块代码MOEFeedForward 全貌
延续 03 篇"代码先行"的风格,先把整块完整摆出来,正文每一节对应其中几行。
```python
# model_minimind.py 148176 行
class MOEFeedForward(nn.Module):
def __init__(self, config: MiniMindConfig):
super().__init__()
self.config = config
self.gate = nn.Linear(config.hidden_size, config.num_experts, bias=False) # 路由门控
self.experts = nn.ModuleList([FeedForward(config, intermediate_size=config.moe_intermediate_size)
for _ in range(config.num_experts)]) # 4 个专家 FFN
self.act_fn = ACT2FN[config.hidden_act]
def forward(self, x):
batch_size, seq_len, hidden_dim = x.shape
x_flat = x.view(-1, hidden_dim) # ① 摊平 [B,S,768]→[B*S,768]
scores = F.softmax(self.gate(x_flat), dim=-1) # ② 路由打分
topk_weight, topk_idx = torch.topk(scores, k=self.config.num_experts_per_tok, dim=-1, sorted=False) # ③ top-1 选取
if self.config.norm_topk_prob: topk_weight = topk_weight / (topk_weight.sum(dim=-1, keepdim=True) + 1e-20) # ④ 重归一
y = torch.zeros_like(x_flat)
for i, expert in enumerate(self.experts): # ⑤ 逐专家稀疏聚合
mask = (topk_idx == i)
if mask.any():
token_idx = mask.any(dim=-1).nonzero().flatten()
weight = topk_weight[mask].view(-1, 1)
y.index_add_(0, token_idx, (expert(x_flat[token_idx]) * weight).to(y.dtype))
elif self.training:
y[0, 0] += 0 * sum(p.sum() for p in expert.parameters()) # ⑥ 训练期零梯度保活
if self.training and self.config.router_aux_loss_coef > 0: # ⑦ 负载均衡 aux loss
load = F.one_hot(topk_idx, self.config.num_experts).float().mean(0)
self.aux_loss = (load * scores.mean(0)).sum() * self.config.num_experts * self.config.router_aux_loss_coef
else:
self.aux_loss = scores.new_zeros(1).squeeze()
return y.view(batch_size, seq_len, hidden_dim) # ⑧ 还原 [B,S,768]
```
**导览表**(行号 → 主题 → 精读节):
| 代码行 | 主题 | 本文小节 |
|---|---|---|
| 152 `gate` | 路由门控线性层 | [[#2. 路由gate 与 softmax]] |
| 153 `experts` | 专家 FFN 列表 | [[#1. MoE 要解决什么]] |
| 156158 摊平/打分 | 展平与 softmax | [[#2]] |
| 159160 `topk` | top-1 选取 | [[#3. top-1 与 norm_topk_prob]] |
| 161 `norm_topk_prob` | 权重重归一 | [[#3]] |
| 163168 逐专家循环 | 稀疏聚合 `index_add_` | [[#4. 稀疏聚合的实现细节]] |
| 169170 零梯度保活 | 训练期未命中专家 | [[#4]] |
| 171175 aux loss | 负载均衡辅助损失 | [[#5. 负载均衡 aux loss]] |
| 176 `view` | 形状还原 | [[#1]] |
## 1. MoE 要解决什么
### 1.1 一个"看似矛盾"的广告198M 参数、只激活 64M
MiniMind 最难理解的一句话:**"MoE 版 198M激活只有 64M"**。乍一听,模型不是越大越好吗,怎么还有"总参数"和"激活参数"两个数?
理解它需要一个核心概念——**稀疏激活sparse activation**
- **总参数**:模型权重文件里存下的全部参数;
- **激活参数**:跑一次前向,**实际参与计算**的参数。
普通 TransformerDense里这两个数相等每个 token 都走全部权重。MoE 打破这一点:**同一层里放多个"专家"FFN但每个 token 只选其中少数几个走**。
于是"198M"是**存储**4 个专家 FFN 都存着),"64M"是**计算**(每个 token 只激活 1 个专家)。一句话:**存储花全量,推理用激活**——用显存(硬盘)换计算。
### 1.2 MoE 的架构:一个"多分流"的 FFN
回看 01 篇的 [[01-模型骨架与前向回路#3.4 代码gate_proj 与 up_proj 的乘法门]],普通 FFN 是一个"单通道"
```python
FeedForward.forward: down(act(gate(x)) * up(x)) # 每个 token 走唯一的 FFN
```
MoE 把它变成"一个门控 + 多条专家通道"§0 的 152153 行):
```python
self.gate = nn.Linear(hidden, num_experts) # ① 路由:决定走哪个专家
self.experts = ModuleList([FeedForward(...) for _ in range(num_experts)]) # ② 4 条专家通道
```
每个 token 经过 `gate` 打分156163 行),被分配到专家 $E_i$,只走这条通道,最后把专家输出按权重加权回主路——这就是 [[#5. 负载均衡 aux loss]] 那张图里"token → 路由 → 专家 → 聚合"流程。
### 1.3 为什么只替换 FFN不动注意力
MoE 只把 `MiniMindBlock.mlp`184 行)换成 `MOEFeedForward`**注意力原封不动**`self_attn = Attention(config)`)。理由是:
- FFN 是模型里**参数密度最高**的部分01 篇 §9.1FFN 占每层约 76% 参数),是"性价比"最值得稀释的地方——多装几个便宜的 FFN 显然比多装几套注意力划算;
- 注意力是**全局依赖**结构([[03-自注意力机制精读#1.1]] 中所有位置两两交互),难以按 token 稀疏化FFN 是**逐 token 独立**的逐点变换position-wise天然适合"每个 token 选不同专家"——这是 MoE 能落地的结构前提。
### 1.4 三个关键配置config 4145 行)
| 字段 | 取值 | 含义 |
|---|---|---|
| `num_experts` | 4 | 每层放的专家 FFN 数 |
| `num_experts_per_tok` | 1 | 每个 token 激活的专家数(**top-1最简** |
| `moe_intermediate_size` | 2432默认 = `intermediate_size` | 专家 FFN 的中间维度,默认**与 Dense 全尺寸一致** |
| `norm_topk_prob` | True | top-k 选后用不用重归一权重 |
| `router_aux_loss_coef` | 5e-4 | 负载均衡辅助损失的权重 |
注意 `moe_intermediate_size` 默认等于 `intermediate_size`2432——**MiniMind 的每个专家是一个"全尺寸"FFN不是瘦专家**。4 个全尺寸专家参数 $\approx 4\times5.60\text{M}=22.4\text{M}$/层,这正是 198M-A64M 算账的来源([[#6. 算账Dense vs MoE]] 精算)。
## 2. 路由:`gate(x)` 与 softmax
### 2.1 `gate` 是一个线性层
```python
self.gate = nn.Linear(config.hidden_size, config.num_experts, bias=False) # 768 → 4
```
- 输入 768、输出 4`num_experts=4`),权重形状 `[4, 768]`(约 3k 参数)——**gate 本身就是一个可训练的路由器**
- 与注意力里的 q/k/v 是同一类东西都可学习的投影。区别在输出维度q/k/v 投影到"内容空间"gate 投影到"**专家选择空间**"——为每个 token 产出 4 个 logit每个专家一个
### 2.2 softmax → 概率分数
```python
x_flat = x.view(-1, hidden_dim) # [B,S,768] → [B*S,768]
scores = F.softmax(self.gate(x_flat), dim=-1) # [B*S, 4],每行和 = 1
```
- 先把序列摊平(每 token 一行gate 后得到 `[B*S, 4]`,沿最后一维(专家维)做 softmax——**每个 token 得到对 4 个专家的"信任度"分布**,和为 1
- 与 [[03-自注意力机制精读#1.3 行归一化:一次"注意力预算"分配]] 的 softmax 类比:那里沿**序列维**归一("注意力预算分给谁"),这里沿**专家维**归一("信任度分给哪个专家");归一的对象前一个是位置、后一个是专家。
### 2.3 "软选择"分数是权重top-1 才是选择
`scores` 本身是**软**的(连续分数),`topk` 挑出最大那个才变成**硬**选择([[#3]])。两点值得记住:
- **分数用于加权**被选专家的输出要乘以它的分数§0 的 168 行 `* weight`)——不是"选谁就不管分数"
- **分数用于负载均衡**`scores.mean(0)` 会进 aux loss§0 的 173 行)——即使某个专家没被选中,它的"平均信任度"也在被惩罚信号盯梢。这让 **gate 成为端到端可训练**的核心:损失既从主任务流回,也经 aux loss 直接流回 gate。
### 2.4 形状与可视化
整批路由打分就是 `[B*S, 4]` 的矩阵——`moe_routing.png` 的 **Panel A** 正是这步的打印8 个 token× 4 个专家(列),格的深浅 = softmax 分数,每行的**红框 = top-1 被选专家**。下一节的 `topk` 就是"找出红框"的动作。
## 3. top-1 与 `norm_topk_prob`
### 3.1 `topk`:把分数变硬选择
```python
topk_weight, topk_idx = torch.topk(scores, k=self.config.num_experts_per_tok, dim=-1, sorted=False) # k=1
```
- `torch.topk(scores, k=1, dim=-1)`:对每个 token挑出分数**最大的 1 个专家**,返回:
- `topk_weight`:被选专家的分数 `[B*S, 1]`
- `topk_idx`:被选专家的**下标** `[B*S, 1]`03——这就是"路由决定"
- `sorted=False`:只关心"选了谁"和"分数多少",不关心它们之间的排序(对 k=1 无所谓,对 k>1 是性能优化)。
`top-1``num_experts_per_tok=1` 的最简形态:**每个 token 走 1 个专家**。这是 MiniMind 的选择;更大的生产型 MoEMixtral / DeepSeek通常用 `top-2`/`top-8`——更多专家并行、负载更匀,但计算和路由复杂度上升。
### 3.2 `norm_topk_prob`:被截断的 softmax 需要重新归一
```python
if self.config.norm_topk_prob:
topk_weight = topk_weight / (topk_weight.sum(dim=-1, keepdim=True) + 1e-20)
```
这里隐藏一个 **softmax 与 top-k 的"失配"**
- `scores` 的每个分量是**对 4 个专家归一**的和为 1 的分数;
- 但 `topk_weight` 只取其中 **top-11 个)**,其余 3 个专家被扔掉——**被选专家的原始分数不再成立**(它原本的分母含被丢弃专家的量)。
例如某 token 的分数 `[0.4, 0.3, 0.2, 0.1]`top-1 得到 `0.4`。但这 0.4 是"占总信任度 40%"的意思;一旦只保留一个专家,逻辑上应该**把这个 40% 当作 100%**MoE 里每个 token 的路由权重应该和为 1。`norm_topk_prob` 正是做这件事:
$$
w'=\frac{w}{\sum w}\quad\Rightarrow\quad 0.4\rightarrow\frac{0.4}{0.4}=1.0
$$
- 加 `1e-20` 是为了防除零(极小概率下 `topk_weight.sum()=0`
- **为什么需要它**:如果不重归一,`top-1` 时被选专家输出乘以 `0.4` 的权重,会**大幅缩小**专家贡献、且每个 token 的尺度不一样(有的 0.4、有的 0.25),训练不稳定。重归一后每个 token 的路由权重正态化为 1——**尺度一致、语义清晰**"只走一个专家,权重就是 1")。
### 3.3 一个边界:`num_experts_per_tok=1` 时重归一的实际效果
`top-1``topk_weight` 只有一个元素,`sum = 本身`,所以 $w'=1.0$ **恒成立**——重归一变成"NOP"(但对 $k>1$ 时是必须的。MiniMind 保留这个开关,是给未来用 `k>1` 留的口子,也说明它在**以最简形态**演示 MoE 的完整逻辑。
## 4. 稀疏聚合的实现细节
### 4.1 逐专家循环:只算"落在自己身上"的 token
```python
y = torch.zeros_like(x_flat) # 输出缓存 [B*S, 768]
for i, expert in enumerate(self.experts): # 遍历 4 个专家
mask = (topk_idx == i) # [B*S,1],该 token 是否选了专家 i
if mask.any():
token_idx = mask.any(dim=-1).nonzero().flatten() # 选中专家 i 的 token 下标
weight = topk_weight[mask].view(-1, 1) # 对应路由权重
y.index_add_(0, token_idx, (expert(x_flat[token_idx]) * weight).to(y.dtype))
```
注意这**不是一个**"单次大矩阵乘",而是**逐专家**的分步处理:
1. **对每个专家 $i$**`mask = (topk_idx == i)` 标出"哪些 token 选了它"
2. `if mask.any()`**若这批 token 为空,整个专家直接跳过**——被丢弃的 token 不做任何前向;
3. 只有落在此专家的 token`token_idx`)才喂进 `expert(...)`,乘以它的路由权重 `weight`
**稀疏性就在这里兑现**:每个 token 只出现在**它选中专家**的 mask 里,其余 3 个专家 `mask.any()` 为 False、直接跳过。走完整前向的权重只有 top-1 专家 → **"激活参数"远小于"总参数"的计算来源**§1.1 的"64M vs 198M")。
### 4.2 `index_add_`:为什么能"累积"而不覆盖
```python
y.index_add_(0, token_idx, val) # y[token_idx] += val scatter-add
```
这是 scatter-add沿 `dim=0``token_idx``val` **原位加**进 `y` 的对应行。之所以"加"而不是"覆盖"
- **每个 token 恰好进一个专家**`top-1``y.index_add_` 每行只触发一次——等价于"将该 token 的专家输出×权重放入对应行"
- **天然支持 $k>1$**:若将来一个 token 出现在 2 个专家的 `token_idx` 里,`index_add_` 会把两处贡献**累加**起来——这就是为什么用 `index_add_` 而不是逐个赋值。
另一个细节:`val` 经 `.to(y.dtype)` 对齐(`expert` 输出可能与 `y` 精度不同),避免 dtype 不匹配。
### 4.3 那个"不像摆设"的保活 trick
```python
elif self.training:
y[0, 0] += 0 * sum(p.sum() for p in expert.parameters())
```
**触发场景**:某个专家在本次前向里**一个 token 都没被选中**`mask.any()=False`,上面的 `if` 分支没走)。此时这个专家的参数没有参与任何计算,也就**收不到任何梯度**——在分布式DDP / DeepSpeed训练里该专家会因"梯度全 0"而**不更新**,且之后也可能永远不被选中,陷入"**专家死亡expert-death**"的恶性循环。
保活的手法很巧妙:
```python
0 * sum(p.sum() for p in expert.parameters())
```
- `sum(p.sum() for p in ...)` 把该专家的**所有参数和**(一个标量)算出来,乘 `0` → 一个**恒等于 0 但依赖所有参数的张量**
- 把它累加到 `y[0,0]`,这个"0"就**把专家全部参数接进了计算图**——梯度反向传播时DDP 的 `all-reduce` 会为该专家同步梯度(内容是 0参数保持"可更新"状态,不会在分布式里僵死;
- `y[0,0]` 加 0 不影响真实输出($\text{output}+0=\text{output}$),纯粹是"借一个出口把计算图连起来"。
单卡训练时它并不必须,但作者写上了——这是**稀疏 MoE 特有的坑**Dense 模型每条权重必然被用到不存在这种问题。MiniMind 想用 DDP 训 MoE 时它就派上用场。
## 5. 负载均衡 aux loss详细
### 5.1 动机:路由会"自我强化",然后偏科
`gate` 训练初期接近随机,但梯度会**让偏科变得越来越严重**
- 某个专家碰巧分数高 → 被更多 token 选中 → 拿到更多梯度 → 分数更高 → 更常被选中……
这是正反馈循环,后果有二:
1. **少数专家被哄抢**:大多数 token 挤向 12 个专家计算资源集中在几个专家上——MoE"用满多个专家"的初衷没实现;
2. **多数专家被冷落**:长期收不到梯度的专家被闲置,甚至滑向 [[#4.3]] 的"专家死亡"。
要打破这个循环,只靠主任务 loss 不够(它不关心"路由是否均匀"),必须**显式加一项"负载均衡"的惩罚**——这就是 aux lossauxiliary loss也叫 router / load-balancing loss。它直接惩罚 gate"别让大家挤到一个专家头上。"
### 5.2 损失公式逐项拆
```python
if self.training and self.config.router_aux_loss_coef > 0:
load = F.one_hot(topk_idx, self.config.num_experts).float().mean(0)
self.aux_loss = (load * scores.mean(0)).sum() * self.config.num_experts * self.config.router_aux_loss_coef
else:
self.aux_loss = scores.new_zeros(1).squeeze()
```
写成公式($N$ 为专家数、$T$ 为本 batch token 数):
$$
\mathcal{L}_{aux}=\Big(\sum_{i=1}^{N}\underbrace{load_i}_{\text{被选中比例}}\cdot\underbrace{\overline{score}_i}_{\text{平均路由分数}}\Big)\cdot N\cdot \text{coef}
$$
**两个关键量**
| | 定义 | 代码 | 软/硬 |
|---|---|---|---|
| $load_i$ | 专家 $i$ **被选中的频率**(这次 batch 里多少比例的 token 选了它) | `one_hot(topk_idx, N).mean(0)` | **硬**(离散 0/1不可导 |
| $\overline{score}_i$ | 专家 $i$ 的**平均 softmax 信任度** | `scores.mean(0)` | **软**(连续,可导) |
- `F.one_hot(topk_idx, N)` 把每个 token 的选中专家下标变成 `[T, N]` 的 0/1 独热矩阵,`.float().mean(0)` 沿 token 维平均 → **每专家被选中频率**
- `scores.mean(0)``[T, N]` 的软分数沿 token 维平均 → **每专家的平均信任度**
- `(load * mean(0))` 是**逐元素乘**、再 `.sum()` 遍历所有专家求和。
### 5.3 为什么是"load 和 score 的乘积和"(而非只惩罚一项)
这是 Switch Transformer / DeepSeek-MoE 采用的通用形式,这里的关键在**软硬结合**
- **`load` 是硬/离散的**one-hot 挑选结果,不可导)——单惩罚它,梯度无法穿过 top-k 回到 gate
- **`score` 是软/连续的**softmax 输出,可导)——梯度能从它顺畅流回 gate
- 两者**相乘再求和**,把"离散的选中负载"和"连续的信任度"绑在一起:`load_i · score_i` 大,说明专家 $i$ 既被选中得多、又被信任得高——**路由偏斜最严重**,惩罚也最大。梯度经 `score` 传导,`gate` 就学会降低这种专家的分数、把信任度摊平。
直觉再加强:如果只惩罚 `load`选中频率gate 只能"间接"感受到(因为 load 不可导);乘积形式让 `score` 直接成为可调的对象——**想要让 `load_i·score_i` 变小,最直接的办法就是调低 `score_i`**,而降 `score` 正是 gate 能精确做到的。
### 5.4 系数与"归一化基准"
- **`* self.config.num_experts`=4)**:归一化因子。理想均衡时每个专家 $load_i=\frac{1}{4}$、$\overline{score}_i=\frac{1}{4}$,则 $\sum load\cdot score=4\times\frac{1}{16}=\frac{1}{4}$,乘 $N=4$ → **约等于 1**。这样"均衡状态"的 aux loss 被归一化到 $O(1)$ 基准,**不随专家数放大**`coef` 才好调(无论 4 个还是 64 个专家,理想值都是 ~1
- **`* self.config.router_aux_loss_coef`=5e-4**:相对**主任务 loss** 的权重。`5e-4` 是个**很温和**的值——MoE 的优化仍由主任务(自回归 CE loss主导aux loss 只是"轻轻拉一下",确保路由不偏斜即可。若调得过大,会牺牲主任务性能(模型被"强迫均分"而损失精度)。
### 5.5 aux loss 的汇总链Block → Model → ForCausalLM
`aux_loss` 不是 `forward` 的普通返回值,而是**挂在 `MOEFeedForward` 实例上的一个属性**`self.aux_loss`),跟着模型结构往上汇总:
```
MOEFeedForward.forward ──存 self.aux_loss第 173 行)
MiniMindModel.forward ── sum 所有层的 mlp.aux_loss第 231 行)
aux_loss = sum([l.mlp.aux_loss for l in self.layers if isinstance(l.mlp, MOEFeedForward)],
hidden_states.new_zeros(1).squeeze())
MiniMindForCausalLM.forward ── 放进输出对象(第 253 行)
MoeCausalLMOutputWithPast(loss=loss, aux_loss=aux_loss, ...)
```
于是**主 loss 与 aux loss** 的关系是:
- **主 loss** = 自回归交叉熵([[01-模型骨架与前向回路#7.3]] 的 shifted CE loss第 252 行 `F.cross_entropy(...)`
- **aux loss** = 上面算的路由均衡项,作为**附加正则**。
训练时两者**都**通过梯度回传:`loss` 驱动整个模型学语言建模,`aux_loss` 只微调 gate让它"既学主任务、又保持各专家负载均衡"。这就是 MoE 能稳定训练的核心机制——**主任务与均衡约束的联合**。
### 5.6 配图(负载分布)
回顾 `moe_routing.png`**Panel B**:这次 batch 里 4 个专家各自被选中的 token 数。理想 = 均匀(各 2 个);偏科(如图中某个专家拿到 5 个)就会让 `load·score` 变大、`aux_loss` 变大,从而反向惩罚 gate。
![[moe_routing.png]]
## 6. 算账Dense vs MoE 的参数构成
### 6.1 每层一个专家的成本(全尺寸 FFN
- 一个 FFNSwiGLUintermediate=2432= `gate_proj`(768→2432) + `up_proj`(768→2432) + `down_proj`(2432→768)
$$
\text{FFN}_1 = 768\times2432 + 768\times2432 + 2432\times768 = 3\times768\times2432 \approx 5.60\,\text{M}
$$
- MoE 每层有 4 个这样的专家 + 一个 `gate`768→4忽略不计
$$
\text{MoE}_\text{层} \approx 4\times5.60\,\text{M} = 22.4\,\text{M}
$$
### 6.2 总参数198M4 个专家都存)
把模型其他部分(注意力和嵌入)加进来(复用 [[01-模型骨架与前向回路#9.1 逐模块对账]] 的账):
| 部分 | 每层 | 8 层 | 说明 |
|---|---|---|---|
| Attention | 1.77M | 14.2M | q/k/v/o 投影GQA 省 k/v[[03-自注意力机制精读#2.5]] |
| MoE4 专家 + gate | 22.4M | 179M | **参数大头** |
| Embeddingtied | | 4.92M | 与 lm_head 共享([[01-模型骨架与前向回路#7.2 tie_word_embeddings 详解]] |
| **总计** | | **≈ 198M** | **总参数** |
这就是 **198M****4 个专家 FFN 全部存进权重**。
### 6.3 激活参数64M每 token 只走 1 个专家)
每 token 前向**实际用到的**参数 = 每层注意力 + **1 个**被激活的专家 FFN + gate
| 部分 | 每层 | 8 层 | 说明 |
|---|---|---|---|
| Attention | 1.77M | 14.2M | 每层都算 |
| **1 个**专家 FFN | 5.60M | 44.8M | 只剩 top-1 的那个([[#4.1]] 稀疏性) |
| Embeddingtied | | 4.92M | 每 token 查一次 |
| **总计** | | **≈ 64M** | **激活参数** |
这就是 **64M****每个 token 只触发 1 个专家,其余 3 个专家被 `mask.any()` 跳过**[[#4.1]])。
### 6.4 "198M-A64M"的直观账
$$
\underbrace{198\,\text{M}}_{\text{存(全部专家)}}\ \approx\ \underbrace{64\,\text{M}}_{\text{算(单专家)}} + \underbrace{3\times5.60\text{M}\times8}_{\text{每层 3 个"备用"专家存而不算}}
$$
- **总参数**:模型权重文件的大小(存储);
- **激活参数**:跑一个 token 的 FLOPs计算
- 两者差距 = 每层多余的 $4-1=3$ 个备用专家——**花存储、省算力**。
这就是 §1.1 那句"用显存换计算"的数Dense 64M 里激活 = 总参 = 64MMoE 把总参翻到 198M多买 3 倍的"专家库存"),但每次只结算 1 个——**推理成本和 Dense 64M 同量级,容量却接近 3 倍专家。**
### 6.5 `get_model_params` 逐行("从总量里减备用专家"
真实代码(`trainer_utils.py` 1828 行)**不真跑前向**,而是**用参数总量 + 路由配置直接推出激活参数量**,很巧妙:
```python
def get_model_params(model, config):
total = sum(p.numel() for p in model.parameters()) / 1e6 # ① 全部参数
n_routed = getattr(config, 'n_routed_experts', getattr(config, 'num_experts', 0)) # 4
n_active = getattr(config, 'num_experts_per_tok', 0) # 1
n_shared = getattr(config, 'n_shared_experts', 0) # 0MiniMind 无共享专家)
expert = sum(p.numel() for n, p in model.named_parameters() if 'mlp.experts.0.' in n) / 1e6 # ② 单个专家的参数
shared_expert = sum(p.numel() for n, p in model.named_parameters() if 'mlp.shared_experts.0.' in n) / 1e6
base = total - (expert * n_routed) - (shared_expert * n_shared) # ③ 非专家部分
active = base + (expert * n_active) + (shared_expert * n_shared) # ④ 激活部分
if active < total: Logger(f'Model Params: {total:.2f}M-A{active:.2f}M') # MoE "198M-A64M"
else: Logger(f'Model Params: {total:.2f}M') # Dense → "64M"
```
四步拆解:
1. **`total`**:所有权重参数之和;
2. **`expert`**:用命名参数匹配 `'mlp.experts.0.'`(专家 0——**拿到"一个专家"的参数量**
3. **`base = total - expert×n_routed`**:从总量里减掉所有 routed 专家,剩**非专家部分**attention + embed + norm + gate + 共享专家),这是每个 token 必然走的部分;
4. **`active = base + expert×n_active`**:基础部分 + **每个 token 激活的专家数** × 单个专家。因为 `n_active=1`,就是"基础 + 1 个专家"——精确等于 §6.3 的激活参数。
判断逻辑:`active < total`激活比总共少 MoE **稀疏特征**)→ 打印 `totalM-AactiveM`否则Dense激活 = 总参)→ 只打印 `total`注意 `n_routed`/`n_shared` 用的是 `getattr` 兜底到 `num_experts`兼容 DeepSeek 风格的 routed + shared 专家MiniMind 只有 routed`n_shared=0`。

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

View file

@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""Generate RoPE illustration figures for note 02 (run with conda env `nn`)."""
import os
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrow, Arc
plt.rcParams["font.sans-serif"] = ["Source Han Sans CN", "Noto Sans CJK SC", "WenQuanYi Zen Hei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
OUT = os.path.dirname(os.path.abspath(__file__))
def arrow(ax, angle_deg, length=1.0, color="C0", ls="-", lw=2.2, label=None, zorder=5):
rad = np.deg2rad(angle_deg)
dx, dy = length * np.cos(rad), length * np.sin(rad)
ax.arrow(0, 0, dx, dy, head_width=0.06, head_length=0.09, fc=color, ec=color,
length_includes_head=True, linestyle=ls, lw=lw, label=label, zorder=zorder)
def arc_angle(ax, a, b, r=0.45, color="k", label=None, offset=6):
a, b = sorted([a, b])
span = b - a
arc = Arc((0, 0), 2 * r, 2 * r, theta1=a, theta2=b, color=color, lw=1.6, zorder=6)
ax.add_patch(arc)
mid = np.deg2rad((a + b) / 2)
tx, ty = (r + 0.12) * np.cos(mid), (r + 0.12) * np.sin(mid)
ax.text(tx, ty, label, ha="center", va="center", fontsize=11, color=color)
def make_fig1():
"""旋转内积:相对位置决定了夹角。"""
fig, axes = plt.subplots(1, 2, figsize=(11, 5))
for ax in axes:
ax.set_xlim(-1.4, 1.5); ax.set_ylim(-1.4, 1.4)
ax.set_aspect("equal"); ax.grid(alpha=0.3)
t = np.linspace(0, 2 * np.pi, 200)
ax.plot(np.cos(t), np.sin(t), color="0.75", lw=0.8, zorder=1)
# (a) 旋转前q 与 k 的夹角固定 γ(与位置无关)
ax = axes[0]
phi_q, phi_k, gamma = 30.0, 100.0, 70.0
arrow(ax, phi_q, color="C0", label=r"$q$(位置任意,方向固定)")
arrow(ax, phi_k, color="C3", label=r"$k$(方向固定)")
arc_angle(ax, phi_q, phi_k, r=0.5, label=r"$\gamma$ = 固定夹角", color="k")
ax.set_title("旋转前:夹角与位置无关(自注意力是排列等变的)", fontsize=12)
ax.legend(loc="lower left", fontsize=10)
# (b) 旋转后q 转 mθ、k 转 nθ夹角变为 γ-(m-n)θ
ax = axes[1]
m, n, theta = 5, 2, 12.0 # θ=12°, (m-n)θ=36°
arrow(ax, phi_q, color="C0", ls=":", lw=1.6)
arrow(ax, phi_k, color="C3", ls=":", lw=1.6)
arrow(ax, phi_q + m * theta, color="C0", label=r"$q_m=R(m\theta)\,q$")
arrow(ax, phi_k + n * theta, color="C3", label=r"$k_n=R(n\theta)\,k$")
arc_angle(ax, phi_q + m * theta, phi_k + n * theta, r=0.5,
label=r"$\gamma-(m-n)\theta$", color="k")
arc_angle(ax, phi_q, phi_k, r=0.35, color="0.5", label=r"$\gamma$")
ax.set_title("旋转后:内积只依赖相对位置 $(m-n)\\theta$", fontsize=12)
ax.legend(loc="lower left", fontsize=10)
fig.tight_layout()
fig.savefig(os.path.join(OUT, "rope_relative.png"), dpi=150, bbox_inches="tight")
plt.close(fig)
def make_fig2():
"""频率分布θ_i = base^{-2i/d},对比 base=1e4 / 1e6。"""
d = 96
i = np.arange(d // 2)
for base, color, ls in [(1e4, "C0", "-"), (1e6, "C3", "-"), (1e6 * np.power(2, 0.5), "C3", "--")]:
pass # placeholder to keep structure explicit
fig, ax = plt.subplots(figsize=(8, 5))
ax.semilogy(i, 1e4 ** (-2 * i / d), color="C0", lw=2.2, label=r"$\theta_i=base^{-2i/d},\ base=10^4$(原版 GPT")
ax.semilogy(i, 1e6 ** (-2 * i / d), color="C3", lw=2.2, label=r"$base=10^6$MiniMind, rope_theta=1e6")
ax.axhline(2 * np.pi / 2048, color="0.5", ls=":", lw=1.2)
ax.text(1, 2.4e-3, r"$2\pi/2048$:训练长度对应的角频率下限", fontsize=10, color="0.3")
ax.set_xlabel("维度对下标 $i$047")
ax.set_ylabel(r"角频率 $\theta_i$(对数轴)")
ax.set_title("频率随维度指数衰减:低 $i$ 高频(近距),高 $i$ 低频(远距)", fontsize=12)
ax.legend(fontsize=10)
ax.grid(alpha=0.3, which="both")
fig.tight_layout()
fig.savefig(os.path.join(OUT, "rope_freqs.png"), dpi=150, bbox_inches="tight")
plt.close(fig)
def make_fig3():
"""位置信号cos(m θ_i) 在不同维度下的振荡。"""
base = 1e6
d = 96
m = np.arange(0, 128)
fig, ax = plt.subplots(figsize=(9, 5))
for idx, (ii, color) in enumerate([(0, "C3"), (12, "C1"), (32, "C0")]):
theta = base ** (-2 * ii / d)
ax.plot(m, np.cos(m * theta), color=color, lw=1.8,
label=rf"$i={ii}$$\theta={theta:.4f}${'高频·邻位' if ii == 0 else '中频' if ii == 12 else '低频·远距'})")
ax.axvline(64, color="0.5", ls=":", lw=1.2)
ax.text(65, -0.9, "位置 64训练长度中点", fontsize=9, color="0.3")
ax.set_xlabel("绝对位置 $m$")
ax.set_ylabel(r"$\cos(m\,\theta_i)$")
ax.set_title("同一位置 $m$,不同维度看到完全不同的‘信号频率’", fontsize=12)
ax.legend(fontsize=10)
ax.grid(alpha=0.3)
ax.set_ylim(-1.15, 1.15)
fig.tight_layout()
fig.savefig(os.path.join(OUT, "rope_position_signal.png"), dpi=150, bbox_inches="tight")
plt.close(fig)
def make_fig4():
"""YaRN ramp低频压缩、高频保留中间线性过渡。"""
d = 96
base = 1e6
orig_max = 2048
factor = 16.0
beta_fast, beta_slow = 32.0, 1.0
inv_dim = lambda b: (d * np.log(orig_max / (b * 2 * np.pi))) / (2 * np.log(base))
low, high = inv_dim(beta_fast), inv_dim(beta_slow)
i = np.linspace(0, d // 2 - 1, 500)
ramp = np.clip((i - low) / max(high - low, 0.001), 0, 1)
ratio = 1 - ramp + ramp / factor
fig, axes = plt.subplots(1, 2, figsize=(11, 4.6))
ax = axes[0]
ax.axvline(low, color="0.6", ls=":", lw=1.4)
ax.axvline(high, color="0.6", ls=":", lw=1.4)
ax.text(low + 0.4, 0.55, rf"low≈{low:.1f}", fontsize=9, color="0.3", rotation=90)
ax.text(high + 0.4, 0.55, rf"high≈{high:.1f}", fontsize=9, color="0.3", rotation=90)
ax.plot(i, ramp, color="C3", lw=2.4, label=r"$\mathrm{ramp}(i)$")
ax.set_ylim(-0.05, 1.1)
ax.set_xlabel("维度对下标 $i$")
ax.set_ylabel(r"$\mathrm{ramp}(i)$")
ax.set_title(r"左:$\mathrm{ramp}$ 在 $[\beta_{fast},\beta_{slow}]$ 内线性爬升" + f"\n(β_fast=32→low≈{low:.1f}, β_slow=1→high≈{high:.1f})", fontsize=11)
ax.legend(fontsize=10)
ax.grid(alpha=0.3)
ax = axes[1]
ax.plot(i, ratio, color="C0", lw=2.4, label=rf"$f'(i)/f(i)=1-\mathrm{{ramp}}+\mathrm{{ramp}}/{factor:.0f}$")
ax.axhline(1.0 / factor, color="0.5", ls=":", lw=1.2)
ax.text(2, 1.0 / factor + 0.01, rf"$1/{factor:.0f}$(压缩区)", fontsize=10, color="0.3")
ax.axhline(1.0, color="0.5", ls=":", lw=1.2)
ax.text(30, 1.01, "1.0(保留区)", fontsize=10, color="0.3")
ax.set_ylim(-0.05, 1.15)
ax.set_xlabel("维度对下标 $i$")
ax.set_ylabel(r"频率缩放比 $f'/f$")
ax.set_title("右:低频维度频率被压缩(外推)、高频维度保留(保邻位精度)", fontsize=11)
ax.legend(fontsize=10)
ax.grid(alpha=0.3)
fig.tight_layout()
fig.savefig(os.path.join(OUT, "yarn_ramp.png"), dpi=150, bbox_inches="tight")
plt.close(fig)
if __name__ == "__main__":
make_fig1()
make_fig2()
make_fig3()
make_fig4()
print("figures written to", OUT)

View file

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""Generate a MoE-routing illustration for note 04 (run with conda env `nn`)."""
import os
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
plt.rcParams["font.sans-serif"] = ["Source Han Sans CN", "Noto Sans CJK SC", "WenQuanYi Zen Hei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
OUT = os.path.dirname(os.path.abspath(__file__))
rng = np.random.default_rng(42)
n_tokens = 8
n_experts = 4
# 模拟 gate 输出 logits → softmax 分数(每行和=1
logits = rng.normal(0, 1.0, size=(n_tokens, n_experts)) * 0.9
logits[:, :] += np.linspace(0.2, -0.2, n_experts) # 制造一点"偏科"
e = np.exp(logits - logits.max(axis=1, keepdims=True))
probs = e / e.sum(axis=1, keepdims=True)
top = probs.argmax(axis=1)
counts = np.bincount(top, minlength=n_experts)
cmap = plt.get_cmap("Blues")
fig, axes = plt.subplots(1, 2, figsize=(11.5, 5.2), gridspec_kw={"width_ratios": [1.25, 1]})
# ---- Panel A: 路由分数矩阵 ----
ax = axes[0]
ax.imshow(probs, cmap=cmap, vmin=0, vmax=1, aspect="auto")
for i in range(n_tokens):
for j in range(n_experts):
ax.text(j, i, f"{probs[i, j]:.2f}", ha="center", va="center", fontsize=8,
color="white" if probs[i, j] > 0.55 else "black")
# 高亮 top-1argmax格子
ax.add_patch(Rectangle((top[i] - 0.5, i - 0.5), 1, 1, fill=False, edgecolor="red", lw=2.2))
ax.set_xticks(range(n_experts)); ax.set_xticklabels([f"专家 E{j}" for j in range(n_experts)], fontsize=10)
ax.set_yticks(range(n_tokens)); ax.set_yticklabels([f"token x{i+1}" for i in range(n_tokens)], fontsize=9)
ax.set_title("① 路由gate(x) 经 softmax 得到每个 token 对 4 专家的分数(每行和=1\n红框 = top-1 被选专家", fontsize=11)
ax.grid(False)
ax.set_xlabel("每个 token 只激活 1 个专家top-1", fontsize=10)
# ---- Panel B: 专家负载 ----
ax = axes[1]
colors = [cmap(probs[np.where(top == j)[0][0], j]) if (top == j).any() else cmap(0.3) for j in range(n_experts)]
bars = ax.bar(range(n_experts), counts, color=["#4C72B0", "#DD8452", "#55A868", "#C44E52"][:n_experts],
edgecolor="k", lw=0.8)
for rect, c in zip(bars, counts):
ax.text(rect.get_x() + rect.get_width() / 2, c + 0.1, f"{int(c)}", ha="center", va="bottom", fontsize=11)
ax.set_xticks(range(n_experts)); ax.set_xticklabels([f"E{j}" for j in range(n_experts)], fontsize=10)
ax.set_ylim(0, n_tokens)
ax.set_ylabel("被选中的 token 数")
ax.set_title("② 本次 batch 各专家负载\n(理想 = 均匀;偏科 → aux loss 惩罚 gate", fontsize=11)
ax.grid(axis="y", alpha=0.3)
fig.tight_layout()
fig.savefig(os.path.join(OUT, "moe_routing.png"), dpi=150, bbox_inches="tight")
plt.close(fig)
print("moe_routing.png written to", OUT)

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View file

@ -0,0 +1,680 @@
---
title: "去噪扩散概率模型DDPM笔记"
date: 2026-08-15
tags: ["数学", "人工智能","概率论"]
description: "去噪扩散概率模型DDPM笔记"
draft: false # true 时本地可见、构建不发布(写草稿用)
pinned: false # true 时首页置顶
heroGradient: ["#7fb8ff", "#2f8df0"] # 可选,封面渐变色
---
# 去噪扩散概率模型 DDPM
> **相关**[[变分下界ELBO笔记]]ELBO 的一般推导) | [[变分自编码器VAE笔记]](单步潜变量模型与重参数化)
>
> **参考**[DDPM 专栏](https://zhuanlan.zhihu.com/p/563661713)
## 1. DDPM 在做什么
DDPMDenoising Diffusion Probabilistic Model去噪扩散概率模型把生成过程拆成 $T$ 个很小的步骤:
1. **正向过程**:从真实样本 $x_0$ 出发,逐步加入高斯噪声,最后得到近似标准正态的 $x_T$
2. **反向过程**:从纯噪声 $x_T$ 出发,逐步去除噪声,最终得到生成样本 $x_0$。
它的核心不是直接学习复杂的 $p(x_0)$,而是学习一串简单的高斯转移分布。和 [[变分自编码器VAE笔记]] 类似DDPM 也是一个带潜变量的生成模型;区别是它使用 $x_1,\dots,x_T$ 这一长串潜变量,而不是一个低维 $z$。
## 2. 正向加噪过程
### 2.1 一步转移
给定真实数据 $x_0$,定义固定的马尔可夫链 $q$
$$
q(x_t\mid x_{t-1}) = \mathcal{N}\left(x_t;\sqrt{\alpha_t}\,x_{t-1},\beta_t I\right),
$$
其中
$$
\beta_t\in(0,1),\qquad \alpha_t=1-\beta_t.
$$
$\beta_t$ 是第 $t$ 步加入的噪声强度,通常由一个预先设定的 schedule 给出;$q$ 没有可学习参数。
用重参数化写成采样形式:
$$
x_t=\sqrt{\alpha_t}\,x_{t-1}+\sqrt{1-\alpha_t}\,\epsilon_{t-1},
\qquad \epsilon_{t-1}\sim\mathcal{N}(0,I).
$$
这和 VAE 中的
$$
z=\mu_\phi(x)+\sigma_\phi(x)\epsilon
$$
形式相同,都是把随机采样改写成“确定性变换加标准高斯噪声”,但 DDPM 的正向过程本身不需要反向传播来学习。
### 2.2 多步转移
由于正向过程是马尔可夫链:
$$
q(x_{1:T}\mid x_0)=\prod_{t=1}^{T}q(x_t\mid x_{t-1}).
$$
定义累计乘积
$$
\bar{\alpha}_t=\prod_{s=1}^{t}\alpha_s,
\qquad \bar{\alpha}_0=1.
$$
反复代入一步转移,并利用独立高斯之和仍为高斯,可得任意时刻的闭式边缘分布。
先展开两步:
$$
\begin{aligned}
x_t
&=\sqrt{\alpha_t}x_{t-1}+\sqrt{1-\alpha_t}\epsilon_{t-1}\\
&=\sqrt{\alpha_t}\left(\sqrt{\alpha_{t-1}}x_{t-2}
+\sqrt{1-\alpha_{t-1}}\epsilon_{t-2}\right)
+\sqrt{1-\alpha_t}\epsilon_{t-1}\\
&=\sqrt{\alpha_t\alpha_{t-1}}x_{t-2}
+\sqrt{\alpha_t(1-\alpha_{t-1})}\epsilon_{t-2}
+\sqrt{1-\alpha_t}\epsilon_{t-1}.
\end{aligned}
$$
最后两项是相互独立的零均值高斯噪声,因此它们之和仍是零均值高斯噪声,方差为
$$
\alpha_t(1-\alpha_{t-1})+(1-\alpha_t)
=1-\alpha_t\alpha_{t-1}.
$$
继续递推到 $x_0$,并记
$$
\bar{\alpha}_t=\alpha_t\bar{\alpha}_{t-1},
\qquad \bar{\alpha}_0=1,
$$
就得到信号系数为 $\sqrt{\bar{\alpha}_t}$,噪声方差为
$$
1-\bar{\alpha}_t
=\alpha_t(1-\bar{\alpha}_{t-1})+(1-\alpha_t).
$$
$$
q(x_t\mid x_0)=\mathcal{N}\left(x_t;\sqrt{\bar{\alpha}_t}\,x_0,(1-\bar{\alpha}_t)I\right).
$$
因此可以不必真的执行 $t$ 次加噪,直接采样:
$$
x_t=\sqrt{\bar{\alpha}_t}\,x_0+\sqrt{1-\bar{\alpha}_t}\,\epsilon,
\qquad \epsilon\sim\mathcal{N}(0,I).
$$
这里使用了独立高斯变量相加时均值相加、方差相加的性质:
$$
\mathcal{N}(0,\sigma_1^2I)+\mathcal{N}(0,\sigma_2^2I)
=\mathcal{N}(0,(\sigma_1^2+\sigma_2^2)I).
$$
当 $t$ 增大时,$\bar{\alpha}_t$ 逐渐趋近于 $0$,于是
$$
x_T\approx\mathcal{N}(0,I).
$$
也就是说,正向过程把数据分布逐渐抹平成简单的标准高斯分布。
## 3. 反向生成过程
### 3.1 反向过程的建模
如果知道真实数据分布,反向条件分布为 $q(x_{t-1}\mid x_t)$;但它通常无法直接计算,所以用神经网络参数化:
$$
p_\theta(x_{0:T})=p(x_T)\prod_{t=1}^{T}p_\theta(x_{t-1}\mid x_t),
$$
其中
$$
p(x_T)=\mathcal{N}(x_T;0,I),
$$
并令每一步反向转移为高斯分布:
$$
p_\theta(x_{t-1}\mid x_t)
=\mathcal{N}\left(x_{t-1};\mu_\theta(x_t,t),\Sigma_\theta(x_t,t)\right).
$$
神经网络接收带噪样本 $x_t$ 和时间步 $t$,输出反向高斯分布的参数。训练完成后,从 $x_T\sim\mathcal{N}(0,I)$ 开始,按照 $T,T-1,\dots,1$ 的顺序逐步采样。
### 3.2 为什么需要 $x_0$
单独的 $q(x_{t-1}\mid x_t)$ 一般难以解析得到,但带上已知的初始样本后,后验
$$
q(x_{t-1}\mid x_t,x_0)
$$
可以由贝叶斯公式写成
$$
q(x_{t-1}\mid x_t,x_0)
=q(x_t\mid x_{t-1},x_0)\frac{q(x_{t-1}\mid x_0)}{q(x_t\mid x_0)}.
$$
由于正向过程的马尔可夫性质,$q(x_t\mid x_{t-1},x_0)=q(x_t\mid x_{t-1})$,而前面已经求出了 $q(x_{t-1}\mid x_0)$ 与 $q(x_t\mid x_0)$,所以该后验仍然是高斯分布。
### 3.3 反向后验的闭式形式
令 $y=x_{t-1}$,将三个高斯分布代入上式,有
$$
\begin{aligned}
q(y\mid x_t,x_0)
&\propto q(x_t\mid y)q(y\mid x_0)\\
&\propto \exp\left[-\frac{1}{2}\left(
\frac{\|x_t-\sqrt{\alpha_t}y\|^2}{\beta_t}
+\frac{\|y-\sqrt{\bar{\alpha}_{t-1}}x_0\|^2}
{1-\bar{\alpha}_{t-1}}
\right)\right].
\end{aligned}
$$
其中分母中的常数来自
$$
q(x_t\mid y)=\mathcal{N}(x_t;\sqrt{\alpha_t}y,\beta_t I),
\qquad
q(y\mid x_0)=\mathcal{N}(y;\sqrt{\bar{\alpha}_{t-1}}x_0,
(1-\bar{\alpha}_{t-1})I).
$$
把指数中的二次型关于 $y$ 展开:
$$
\begin{aligned}
&\frac{\|x_t-\sqrt{\alpha_t}y\|^2}{\beta_t}
+\frac{\|y-\sqrt{\bar{\alpha}_{t-1}}x_0\|^2}
{1-\bar{\alpha}_{t-1}}\\
&=\left(\frac{\alpha_t}{\beta_t}
+\frac{1}{1-\bar{\alpha}_{t-1}}\right)\|y\|^2\\
&\quad-2\left(\frac{\sqrt{\alpha_t}}{\beta_t}x_t
+\frac{\sqrt{\bar{\alpha}_{t-1}}}{1-\bar{\alpha}_{t-1}}x_0\right)^{\!\top}y+C(x_t,x_0).
\end{aligned}
$$
二次项系数为
$$
\begin{aligned}
\frac{\alpha_t}{\beta_t}+\frac{1}{1-\bar{\alpha}_{t-1}}
&=\frac{\alpha_t(1-\bar{\alpha}_{t-1})+\beta_t}
{\beta_t(1-\bar{\alpha}_{t-1})}\\
&=\frac{1-\bar{\alpha}_t}
{\beta_t(1-\bar{\alpha}_{t-1})}.
\end{aligned}
$$
因此对二次型配方后,后验仍然是高斯分布:
$$
q(x_{t-1}\mid x_t,x_0)
=\mathcal{N}\left(x_{t-1};\tilde{\mu}_t(x_t,x_0),\tilde{\beta}_t I\right),
$$
其中后验方差为
$$
\tilde{\beta}_t
=\frac{1-\bar{\alpha}_{t-1}}{1-\bar{\alpha}_t}\beta_t,
$$
后验均值由“方差乘以一次项系数”得到:
$$
\begin{aligned}
\tilde{\mu}_t(x_t,x_0)
&=\tilde{\beta}_t\left(
\frac{\sqrt{\alpha_t}}{\beta_t}x_t
+\frac{\sqrt{\bar{\alpha}_{t-1}}}
{1-\bar{\alpha}_{t-1}}x_0\right)\\
&=\frac{\sqrt{\bar{\alpha}_{t-1}}\beta_t}
{1-\bar{\alpha}_t}x_0
+\frac{\sqrt{\alpha_t}(1-\bar{\alpha}_{t-1})}
{1-\bar{\alpha}_t}x_t.
\end{aligned}
$$
$$
\tilde{\mu}_t(x_t,x_0)
=\frac{\sqrt{\bar{\alpha}_{t-1}}\beta_t}{1-\bar{\alpha}_t}x_0
+\frac{\sqrt{\alpha_t}(1-\bar{\alpha}_{t-1})}{1-\bar{\alpha}_t}x_t.
$$
所以只要给定 $x_0$,反向一步的真实均值和方差都可以解析计算。
但生成时只有 $x_t$,没有 $x_0$,因此需要神经网络根据 $x_t$ 预测出与 $x_0$ 或噪声等价的信息。
## 4. 从 ELBO 推导 DDPM 损失
### 4.1 把 DDPM 看成潜变量模型
在 DDPM 中,观测变量是 $x_0$,潜变量是 $x_{1:T}$。真实的正向过程 $q(x_{1:T}\mid x_0)$ 充当变分分布,反向模型 $p_\theta(x_{0:T})$ 是要学习的生成模型。
这与 [[变分下界ELBO笔记#KL 散度分解]] 完全同一条思路:对边缘似然插入一个容易采样的分布 $q$。
$$
\begin{aligned}
\log p_\theta(x_0)
&=\log\int p_\theta(x_{0:T})\,dx_{1:T}\\
&=\log\int q(x_{1:T}\mid x_0)
\frac{p_\theta(x_{0:T})}{q(x_{1:T}\mid x_0)}\,dx_{1:T}\\
&\geq \mathbb{E}_{q(x_{1:T}\mid x_0)}
\left[\log\frac{p_\theta(x_{0:T})}{q(x_{1:T}\mid x_0)}\right].
\end{aligned}
$$
最后一步是 Jensen 不等式。定义 DDPM 的负 ELBO 损失:
$$
\mathcal{L}_{\mathrm{DDPM}}
=-\mathbb{E}_{q(x_{1:T}\mid x_0)}
\left[\log\frac{p_\theta(x_{0:T})}{q(x_{1:T}\mid x_0)}\right].
$$
因此DDPM 并不是脱离 ELBO 的另一套目标,而是把 ELBO 应用到了一个有 $T$ 个潜变量的层次模型上。
### 4.2 ELBO 的详细推导
负 ELBO 就是正向链与反向链两个联合分布的对数之比(见 [[#4.1 把 DDPM 看成潜变量模型]]
$$
L=\mathbb{E}_{q(x_{1:T}\mid x_0)}\left[
\log\frac{q(x_{1:T}\mid x_0)}{p_\theta(x_{0:T})}
\right].
$$
把分子分母按各自的马尔可夫链展开成乘积:
$$
\begin{aligned}
L
&=\mathbb{E}_{q(x_{1:T}\mid x_0)}\left[
\log\frac{\prod_{t=1}^{T}q(x_t\mid x_{t-1})}
{p(x_T)\prod_{t=1}^{T}p_\theta(x_{t-1}\mid x_t)}
\right]\\
&=\mathbb{E}_{q(x_{1:T}\mid x_0)}\left[
-\log p(x_T)
+\sum_{t=1}^{T}\log\frac{q(x_t\mid x_{t-1})}{p_\theta(x_{t-1}\mid x_t)}
\right].
\end{aligned}
$$
把求和中的 $t=1$ 项单独分离出来,因为它是唯一出现 $p_\theta(x_0\mid x_1)$ 的项:
$$
L=\mathbb{E}_{q(x_{1:T}\mid x_0)}\left[
-\log p(x_T)
+\sum_{t=2}^{T}\log\frac{q(x_t\mid x_{t-1})}{p_\theta(x_{t-1}\mid x_t)}
+\log\frac{q(x_1\mid x_0)}{p_\theta(x_0\mid x_1)}
\right].
$$
正向过程是马尔可夫链,所以对 $t\ge 2$ 有 $q(x_t\mid x_{t-1})=q(x_t\mid x_{t-1},x_0)$。把它代入求和中的分子,为下一步用贝叶斯公式做准备:
$$
L=\mathbb{E}_{q(x_{1:T}\mid x_0)}\left[
-\log p(x_T)
+\sum_{t=2}^{T}\log\frac{q(x_t\mid x_{t-1},x_0)}{p_\theta(x_{t-1}\mid x_t)}
+\log\frac{q(x_1\mid x_0)}{p_\theta(x_0\mid x_1)}
\right].
$$
对 $t\ge 2$ 使用贝叶斯公式(并再次利用马尔可夫性质 $q(x_t\mid x_{t-1},x_0)=q(x_t\mid x_{t-1})$
$$
q(x_{t-1}\mid x_t,x_0)
=\frac{q(x_t\mid x_{t-1},x_0)\,q(x_{t-1}\mid x_0)}{q(x_t\mid x_0)}
$$
两边取对数:
$$
\log q(x_t\mid x_{t-1},x_0)
=\log q(x_{t-1}\mid x_t,x_0)
+\log q(x_t\mid x_0)-\log q(x_{t-1}\mid x_0).
$$
代入求和后,$\sum_{t=2}^{T}[\log q(x_t\mid x_0)-\log q(x_{t-1}\mid x_0)]$ 是望远镜和,只剩首尾 $\log q(x_T\mid x_0)-\log q(x_1\mid x_0)$,恰好与单独的 $t=1$ 项中的 $\log q(x_1\mid x_0)$ 相消:
$$
\begin{aligned}
L
&=\mathbb{E}_{q(x_{1:T}\mid x_0)}\left[
\log\frac{q(x_T\mid x_0)}{p(x_T)}
+\sum_{t=2}^{T}\log\frac{q(x_{t-1}\mid x_t,x_0)}{p_\theta(x_{t-1}\mid x_t)}
-\log p_\theta(x_0\mid x_1)
\right]\\
&=\underbrace{\mathbb{E}_{q(x_T\mid x_0)}\left[\log\frac{q(x_T\mid x_0)}{p(x_T)}\right]}_{L_T}
+\sum_{t=2}^{T}\underbrace{\mathbb{E}_{q(x_t\mid x_0)}\left[
\mathbb{E}_{q(x_{t-1}\mid x_t,x_0)}\left[\log\frac{q(x_{t-1}\mid x_t,x_0)}{p_\theta(x_{t-1}\mid x_t)}\right]
\right]}_{L_{t-1}}
-\underbrace{\mathbb{E}_{q(x_1\mid x_0)}\left[\log p_\theta(x_0\mid x_1)\right]}_{L_0}.
\end{aligned}
$$
最后一步把期望按各自依赖的变量拆分(不依赖的变量先边缘化掉)。中间项的内层期望正是 KL 散度——对固定的 $x_t$,按后验 $q(x_{t-1}\mid x_t,x_0)$ 平均对数之比:
$$
L_{t-1}=\mathbb{E}_{q(x_t\mid x_0)}\left[
D_{\mathrm{KL}}\left(q(x_{t-1}\mid x_t,x_0)\,\|\,p_\theta(x_{t-1}\mid x_t)\right)
\right].
$$
三部分分别是终点先验 KL、中间反向转移 KL 和最终数据项。
### 4.3 三项的含义与可忽略项
- $L_T$:终点先验 KL正向过程最后的 $q(x_T\mid x_0)$ 要接近标准正态先验,通常接近常数;
- $L_{t-1}$:学习的反向一步 $p_\theta(x_{t-1}\mid x_t)$ 要逼近真实后验 $q(x_{t-1}\mid x_t,x_0)$
- $L_0$:最后一步负责把 $x_1$ 还原成数据 $x_0$,是数据重构(似然)项。
**$L_0$ 可以忽略**:图像像素被量化到 $[0,255]$ 后,$x_1$ 与 $x_0$ 只相差一步很小的噪声($\beta_1$ 很小),此时 $-\log p_\theta(x_0\mid x_1)$ 几乎不随参数变化对总损失的贡献很小DDPM 原论文据此把它与不含可学习参数的 $L_T$ 一并当作常数处理,**后续推导([[#5.2 KL 项化为均方误差]])只保留中间的 $L_{t-1}$ 项**。
其中终点项可以直接算出闭式形式。对
$$
q(x_T\mid x_0)=\mathcal{N}\left(
\sqrt{\bar{\alpha}_T}x_0,(1-\bar{\alpha}_T)I
\right),
\qquad p(x_T)=\mathcal{N}(0,I),
$$
使用高斯 KL 公式,得到
$$
L_T=\frac{1}{2}\left[
d\left((1-\bar{\alpha}_T)-1-\log(1-\bar{\alpha}_T)\right)
+\bar{\alpha}_T\|x_0\|^2
\right].
$$
因为 $\bar{\alpha}_T$ 和 $x_0$ 都不依赖 $\theta$,所以 $L_T$ 在训练反向网络时是常数;当 $\bar{\alpha}_T$ 很小时,$q(x_T\mid x_0)$ 已经非常接近 $\mathcal{N}(0,I)$。
这和 VAE 的
$$
-\mathrm{ELBO}=\text{重构损失}+D_{\mathrm{KL}}(q_\phi(z\mid x)\,\|\,p(z))
$$
结构相同,只是 DDPM 的中间潜变量很多KL 项沿着整条反向链逐步展开。
## 5. 从预测均值到预测噪声
### 5.1 用噪声表示真实后验均值
忽略常数项 $L_T$ 和重建项 $L_0$(见 [[#4.3 三项的含义与可忽略项]])后,训练目标只剩中间的反向 KL 项 $L_{t-1}$,下面把它化为关于噪声的均方误差。
由正向闭式采样公式
$$
x_t=\sqrt{\bar{\alpha}_t}x_0+\sqrt{1-\bar{\alpha}_t}\epsilon
$$
可以反解出
$$
x_0=\frac{1}{\sqrt{\bar{\alpha}_t}}
\left(x_t-\sqrt{1-\bar{\alpha}_t}\epsilon\right).
$$
将 $x_0$ 代入后验均值,并把 $x_t$ 与 $\epsilon$ 的系数分别整理:
$$
\begin{aligned}
\tilde{\mu}_t(x_t,x_0)
&=\frac{\sqrt{\bar{\alpha}_{t-1}}\beta_t}
{1-\bar{\alpha}_t}
\frac{x_t-\sqrt{1-\bar{\alpha}_t}\epsilon}
{\sqrt{\bar{\alpha}_t}}\\
&\quad+\frac{\sqrt{\alpha_t}(1-\bar{\alpha}_{t-1})}
{1-\bar{\alpha}_t}x_t.
\end{aligned}
$$
由于 $\bar{\alpha}_t=\alpha_t\bar{\alpha}_{t-1}$$x_t$ 的系数为
$$
\begin{aligned}
&\frac{\beta_t}{\sqrt{\alpha_t}(1-\bar{\alpha}_t)}
+\frac{\sqrt{\alpha_t}(1-\bar{\alpha}_{t-1})}
{1-\bar{\alpha}_t}\\
&=\frac{\beta_t+\alpha_t(1-\bar{\alpha}_{t-1})}
{\sqrt{\alpha_t}(1-\bar{\alpha}_t)}
=\frac{1}{\sqrt{\alpha_t}},
\end{aligned}
$$
而 $\epsilon$ 的系数为
$$
-\frac{\beta_t}{\sqrt{\alpha_t}\sqrt{1-\bar{\alpha}_t}}.
$$
因此得到只依赖 $x_t$ 和真实噪声 $\epsilon$ 的形式:
$$
\tilde{\mu}_t(x_t,x_0)
=\frac{1}{\sqrt{\alpha_t}}
\left(x_t-\frac{\beta_t}{\sqrt{1-\bar{\alpha}_t}}\epsilon\right).
$$
因此让网络预测噪声
$$
\epsilon_\theta(x_t,t)\approx\epsilon
$$
即可定义反向均值:
$$
\mu_\theta(x_t,t)
=\frac{1}{\sqrt{\alpha_t}}
\left(x_t-\frac{\beta_t}{\sqrt{1-\bar{\alpha}_t}}
\epsilon_\theta(x_t,t)\right).
$$
网络不需要直接输出 $x_0$ 或复杂的后验均值,只需判断当前 $x_t$ 中混入了哪些噪声。
### 5.2 KL 项化为均方误差
若把反向方差固定为 $\sigma_t^2 I$,可以从高斯 KL 的一般公式出发:
$$
\begin{aligned}
D_{\mathrm{KL}}(\mathcal{N}(m_1,S_1)\,\|\,\mathcal{N}(m_2,S_2))
=\frac{1}{2}\Big(&\log\frac{|S_2|}{|S_1|}-d
+\operatorname{tr}(S_2^{-1}S_1)\\
&+(m_2-m_1)^{\top}S_2^{-1}(m_2-m_1)\Big).
\end{aligned}
$$
$$
m_1=\tilde{\mu}_t,\quad S_1=\tilde{\beta}_t I,
\qquad m_2=\mu_\theta,\quad S_2=\sigma_t^2 I,
$$
得到
$$
\begin{aligned}
&D_{\mathrm{KL}}\left(
q(x_{t-1}\mid x_t,x_0)\,\|\,p_\theta(x_{t-1}\mid x_t)
\right)\\
&=\frac{1}{2}\left[
d\log\frac{\sigma_t^2}{\tilde{\beta}_t}-d
+d\frac{\tilde{\beta}_t}{\sigma_t^2}
+\frac{\|\tilde{\mu}_t-\mu_\theta\|^2}{\sigma_t^2}
\right].
\end{aligned}
$$
其中前三项与网络参数无关,可以并入常数 $C$,于是依赖参数的部分是均值差的平方:
$$
L_{t-1}
=\mathbb{E}\left[
\frac{1}{2\sigma_t^2}
\left\|\tilde{\mu}_t(x_t,x_0)-\mu_\theta(x_t,t)\right\|^2
\right]+C.
$$
由两个均值的噪声表达式,均值差为
$$
\begin{aligned}
\tilde{\mu}_t-\mu_\theta
&=\frac{\beta_t}{\sqrt{\alpha_t}\sqrt{1-\bar{\alpha}_t}}
\left(\epsilon_\theta(x_t,t)-\epsilon\right).
\end{aligned}
$$
代入均值差的平方,可得
$$
L_{t-1}
=\mathbb{E}\left[
\frac{\beta_t^2}{2\sigma_t^2\alpha_t(1-\bar{\alpha}_t)}
\left\|\epsilon-\epsilon_\theta(x_t,t)\right\|^2
\right]+C.
$$
所以训练网络的核心就是噪声回归。DDPM 实际中常用去掉时间权重的简化目标:
$$
\mathcal{L}_{\mathrm{simple}}
=\mathbb{E}_{t,x_0,\epsilon}
\left[\left\|\epsilon-
\epsilon_\theta\left(\sqrt{\bar{\alpha}_t}x_0
+\sqrt{1-\bar{\alpha}_t}\epsilon,t\right)\right\|^2\right].
$$
它的训练含义很直接:随机选一个时间步,把干净样本加噪,再让网络从带噪样本中预测所加入的噪声。
## 6. 训练算法
对每个训练样本 $x_0$,训练步骤为:
1. 随机采样时间步 $t\sim\mathrm{Uniform}\{1,\dots,T\}$
2. 采样标准高斯噪声 $\epsilon\sim\mathcal{N}(0,I)$
3. 用闭式公式构造带噪样本
$$
x_t=\sqrt{\bar{\alpha}_t}x_0+\sqrt{1-\bar{\alpha}_t}\epsilon;
$$
4. 计算噪声预测误差
$$
\mathcal{L}=\left\|\epsilon-\epsilon_\theta(x_t,t)\right\|^2;
$$
5. 反向传播,更新 $\theta$。
时间步 $t$ 通常通过 sinusoidal positional embedding 编码后输入 U-Net使同一个网络能够处理不同噪声强度。
## 7. 采样算法
训练完成后,先采样
$$
x_T\sim\mathcal{N}(0,I).
$$
对 $t=T,T-1,\dots,1$
1. 预测噪声 $\epsilon_\theta(x_t,t)$
2. 计算反向均值
$$
\mu_\theta(x_t,t)=\frac{1}{\sqrt{\alpha_t}}
\left(x_t-\frac{\beta_t}{\sqrt{1-\bar{\alpha}_t}}
\epsilon_\theta(x_t,t)\right);
$$
3. 采样
$$
x_{t-1}=\mu_\theta(x_t,t)+\sigma_t z,
\qquad z\sim\mathcal{N}(0,I),
$$
当 $t=1$ 时通常令 $z=0$,避免在最终样本中再次加入噪声。
最后得到 $x_0$。因此 DDPM 的生成速度较慢:一次生成需要执行 $T$ 次网络推理,这正是后续快速采样方法要解决的问题。
## 8. DDPM 与 VAE、ELBO 的联动
### 8.1 统一视角
| 概念 | VAE | DDPM |
|---|---|---|
| 观测变量 | $x$ | $x_0$ |
| 潜变量 | 一个低维 $z$ | 多个中间状态 $x_{1:T}$ |
| 变分分布 | $q_\phi(z\mid x)$,由编码器学习 | $q(x_{1:T}\mid x_0)$,正向加噪过程固定 |
| 生成模型 | $p_\theta(x\mid z)p(z)$ | $p(x_T)\prod_t p_\theta(x_{t-1}\mid x_t)$ |
| ELBO 中的逼近 | $q_\phi(z\mid x)\approx p_\theta(z\mid x)$ | $p_\theta(x_{t-1}\mid x_t)\approx q(x_{t-1}\mid x_t,x_0)$ |
| 训练目标 | 重构损失加 KL 正则 | 多个高斯 KL化为噪声 MSE |
| 生成方式 | 一次从 $z$ 解码 | 从 $x_T$ 逐步反向去噪 |
VAE 的编码器一次性把 $x$ 压缩成 $z$DDPM 的正向过程则把 $x_0$ 分散到一长串噪声状态中DDPM 以更长的链换取了更容易学习的局部高斯反向过程。
### 8.2 与 ELBO 笔记的公式对应
[[变分下界ELBO笔记#KL 散度分解]] 中的基本恒等式是
$$
\log p(x)=\mathcal{L}_q+D_{\mathrm{KL}}(q\,\|\,p(\text{潜变量}\mid x)).
$$
DDPM 只是在“潜变量”位置换成整个序列 $x_{1:T}$
$$
\log p_\theta(x_0)
=-\mathcal{L}_{\mathrm{DDPM}}
+D_{\mathrm{KL}}\left(q(x_{1:T}\mid x_0)\,\|\,p_\theta(x_{1:T}\mid x_0)\right).
$$
最大化 ELBO 就是让反向链逼近真实的正向后验;当每个时间步的局部反向分布都学好时,整条链就能从高斯噪声逐步回到数据分布。
### 8.3 与 VAE 重参数化的对应
VAE 中重参数化解决的是“如何让随机隐变量采样对网络参数可导”DDPM 中正向采样也可重参数化为
$$
x_t=\sqrt{\bar{\alpha}_t}x_0+\sqrt{1-\bar{\alpha}_t}\epsilon.
$$
但训练时 $q$ 的均值和方差都由 schedule 固定,网络只需要对噪声预测误差求梯度。因此 DDPM 不需要 VAE 那样的可学习编码器,却仍然保留了“噪声与确定性变换分离”的思想。
## 9. 一句话总结
DDPM 先用固定的正向马尔可夫链把数据变成高斯噪声,再用神经网络学习反向链;其理论目标是多步潜变量模型的 ELBO其常用训练形式则是预测正向过程中加入的噪声
$$
\boxed{
\mathcal{L}_{\mathrm{simple}}
=\mathbb{E}_{t,x_0,\epsilon}
\left[\|\epsilon-\epsilon_\theta(x_t,t)\|^2\right]
}
$$
所以VAE、ELBO 与 DDPM 的主线可以概括为:**引入潜变量降低建模难度,用变分下界替代难算的边缘似然,再通过神经网络学习潜变量对应的生成过程**。

View file

@ -9,6 +9,8 @@ heroGradient: ["#7fb8ff", "#2f8df0"] # 可选,封面渐变色
---
# 变分下界 ELBO
> **相关**[[变分自编码器VAE笔记]](单步潜变量模型) | [[去噪扩散概率模型DDPM笔记]](多步潜变量模型上的 ELBO
## 预备:概率记号与期望
> 不熟悉概率记号?先读本节,再回到 [[#边缘分布的困难]]。
@ -78,7 +80,7 @@ $$
p(z \mid x) = \frac{p(x \mid z)\, p(z)}{p(x)}
$$
困难集中在后验 $p(z \mid x)$ 上,故引入参数化的变分分布 $q_\theta(z)$ 来近似它。
为记号简洁,推导中先写与 $x$ 无关的 $q_\theta(z)$VAE 一节再推广为编码器 $q_\phi(z \mid x)$;相应地,生成过程为 $z \sim p(z)$、$x \sim p(x \mid z)$(解码器)。
为记号简洁,推导中先写与 $x$ 无关的 $q_\theta(z)$VAE(见 [[变分自编码器VAE笔记]]再推广为编码器 $q_\phi(z \mid x)$;相应地,生成过程为 $z \sim p(z)$、$x \sim p(x \mid z)$(解码器)。
### 直观理解
由 $p(z \mid x) \propto p(x \mid z)\, p(z)$ 可知,被积函数只在 $p(z \mid x)$ 大的区域显著,其余 $z$ 几乎没有贡献。
@ -125,7 +127,17 @@ $$
$$
\mathcal{L}_q = \mathbb{E}_{z \sim q}[\log p(x \mid z)] - D_{\mathrm{KL}}[q_\theta(z) \,\|\, p(z)]
$$
第一项为重构项(对应解码器),要求由 $z$ 能准确重构 $x$;第二项为正则项,使近似后验不坍缩成点、保持在先验附近。
**重构项** $\mathbb{E}_{z \sim q}[\log p(x \mid z)]$ 为什么叫"重构",分三步看:
1. $p(x \mid z)$ 是"给定 $z$ 生成出 $x$ 的概率",扮演**解码器**$z$ 是压缩后的编码,$x$ 是从编码恢复出的数据;
2. $\log p(x \mid z)$ 大,说明"用这个 $z$ 生成 $x$"这件事很靠谱,即 $z$ 保存了 $x$ 的关键信息;
3. 外面按 $q_\theta$ 取平均,意为"按变分分布随机抽一个 $z$,都应该能解释 $x$"——这就是"由 $z$ 能准确重构 $x$"。
呼应前面的例子:$p(\text{菜刀} \mid \text{厨师})$ 大,就是从隐变量"厨师"能重构出观测"菜刀";反过来 $p(\text{菜刀} \mid \text{雨伞})$ 小,就重构不出来。
用例子里的高斯似然 $\log p(x \mid z) = -\frac{1}{2}(x - z)^2 + \text{const}$ 看得最直接:最大化重构项 ⟺ 最小化 $(x - z)^2$,抽出的 $z$ 必须贴近 $x$。一维时是数值贴近VAE 中则是解码器输出的重建图与输入图逐像素接近。
**正则项** $-D_{\mathrm{KL}}[q_\theta(z) \,\|\, p(z)]$ 起平衡作用:若只看重构项,$q_\theta$ 会坍缩成"把全部概率放在单个 $z$ 上的退化分布"(最能重构 $x$ 的那个点);正则项用先验 $p(z)$ 把 $q_\theta$ 拉住,使它不坍缩、保持在先验附近并保留一定宽度。
## 例子:指数先验与高斯似然
@ -156,16 +168,9 @@ $$
此时变分分布均值 $1/\theta^* \approx 0.84$,与真实后验(中心在 $x - 1 = 0.5$ 的截断正态)形态相近,验证了"最大化 ELBO 使 $q_\theta$ 逼近后验"。
本例中所有期望均可闭式计算,无需采样;$p(x)$ 的归一化常数从头到尾没有算过,这正是变分近似的价值。
## 与 VAE 的联系
## 走向 VAE
### 从自由变分到编码器
前面 $q_\theta(z)$ 对所有 $x$ 共享一组参数换一个观测就要重新优化VAE 改用神经网络编码器 $q_\phi(z \mid x)$,对每个 $x$ 直接输出变分参数(均值与方差),实现参数共享和快速推断。
### 编码器—解码器与重参数化
VAE 中由编码器输出 $\mu$、$\log \sigma^2$,得 $q_\phi(z \mid x) = \mathcal{N}(z;\, \mu, \sigma^2)$$p_\theta(x \mid z)$ 对应解码器。
取高斯先验时,$D_{\mathrm{KL}}[q_\phi \,\|\, p(z)]$ 有解析表达式,只需用蒙特卡洛估计重构项。
采样操作不可导,故用重参数技巧:$\varepsilon \sim \mathcal{N}(0, 1)$$z = \mu + \sigma \varepsilon$,使梯度能经 $\mu$、$\sigma$ 回传。
损失即 ELBO 取反:$\mathcal{L} = -\mathbb{E}_{q}[\log p_\theta(x \mid z)] + D_{\mathrm{KL}}[q_\phi(z \mid x) \,\|\, p(z)]$,即重构误差加正则项。
把变分分布推广为编码器 $q_\phi(z \mid x)$ 后,配合解码器 $p_\theta(x \mid z)$ 与重参数化技巧,就得到完整的 VAE——网络结构、损失推导与训练细节见 [[变分自编码器VAE笔记]]。
## 主线回顾
@ -173,6 +178,6 @@ VAE 中由编码器输出 $\mu$、$\log \sigma^2$,得 $q_\phi(z \mid x) = \mat
2. **推导**:由 KL 分解或 Jensen 不等式得恒等式 $\log p(x) = \mathcal{L}_q + D_{\mathrm{KL}}$
3. **解读**ELBO 拆为重构项 + 正则项,两项均可计算;
4. **例证**:指数先验 × 高斯似然下闭式优化 $\theta$,验证 $q_\theta \to p(z \mid x)$
5. **应用**VAE 用编码器输出变分参数,重参数化保证梯度可回传。
5. **应用**VAE(见 [[变分自编码器VAE笔记]]用编码器输出变分参数,重参数化保证梯度可回传。
> **相关**[[#KL 散度分解]] | [[#重构项 正则项]] | [[#优化 ELBO]] | [[#结果讨论]] | [[#从自由变分到编码器]]
> **相关**[[#KL 散度分解]] | [[#重构项 正则项]] | [[#优化 ELBO]] | [[#结果讨论]] | [[变分自编码器VAE笔记]]

View file

@ -9,7 +9,7 @@ heroGradient: ["#7fb8ff", "#2f8df0"] # 可选,封面渐变色
---
# 变分自编码器 VAE
> **相关**[[变分下界ELBO笔记]]ELBO 推导与直觉)
> **相关**[[变分下界ELBO笔记]]ELBO 推导与直觉) | [[去噪扩散概率模型DDPM笔记]](多步潜变量模型)
## 从 ELBO 到 VAE
@ -41,6 +41,33 @@ $$
$$
采样时只需把重参数化中的 $\mu$、$\sigma$ 换成关于 $x$ 的函数:$z = \mu_\phi(x) + \sigma_\phi(x) \cdot \varepsilon$。
### 从联合概率到两项分解:贝叶斯公式的用法
上面的 ELBO 以联合概率 $\log p(z, x)$ 出现;用乘法公式把它拆成"似然 × 先验"
$$
\log p(z, x) = \log p(x \mid z) + \log p(z)
$$
代入后,两个期望各自归位,联合概率就转化成了两项 KL 的形式:
$$
\begin{aligned}
\mathbb{E}_{z \sim q_\phi(z \mid x)}[\log p(z, x) - \log q_\phi(z \mid x)]
&=\mathbb{E}_{q_\phi}[\log p(x \mid z) + \log p(z) - \log q_\phi(z \mid x)]\\
&=\underbrace{\mathbb{E}_{q_\phi}[\log p(x \mid z)]}_{\text{重构项}}
-\underbrace{D_{\mathrm{KL}}[q_\phi(z \mid x) \,\|\, p(z)]}_{\text{正则项}}
\end{aligned}
$$
最后一步用到 KL 的定义 $D_{\mathrm{KL}}[q \,\|\, p] = \mathbb{E}_{z \sim q}[\log q(z) - \log p(z)]$,因此 $\mathbb{E}_{q_\phi}[\log p(z) - \log q_\phi(z \mid x)] = -D_{\mathrm{KL}}[q_\phi \,\|\, p(z)]$。
若把联合概率往另一方向拆(贝叶斯公式):$\log p(z, x) = \log p(z \mid x) + \log p(x)$,则得到恒等式的另一半:
$$
\mathbb{E}_{z \sim q_\phi(z \mid x)}[\log p(z, x) - \log q_\phi(z \mid x)]
=\log p(x) - D_{\mathrm{KL}}[q_\phi(z \mid x) \,\|\, p(z \mid x)]
$$
两条路径拼起来正是 ELBO 恒等式:
$$
\log p(x) = (\text{重构项} - \text{正则项}) + D_{\mathrm{KL}}[q_\phi(z \mid x) \,\|\, p(z \mid x)]
$$
## 编码器:输出 μ 与 log σ²
### 网络结构

11
src/env.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
/**
* SSG HTML
* - IP PUBLIC_ARTALK_SERVER=http://<服务器IP>:8080 npm run build
* deploy/ip-preview.sh REMOTE_HOST IP
* - Comments.astro
*/
readonly PUBLIC_ARTALK_SERVER?: string;
}

View file

@ -1,6 +1,5 @@
---
import '../styles/global.css';
import Background from '../components/Background.astro';
import Nav from '../components/Nav.astro';
import Footer from '../components/Footer.astro';
import SearchModal from '../components/SearchModal.astro';
@ -16,7 +15,7 @@ interface Props {
const {
title = 'Yukun\u2019s Blog',
description = '记录代码与生活,淡蓝色液态玻璃风的个人博客。',
description = '记录代码与生活,编辑杂志风的个人博客。',
showProgress = false,
} = Astro.props;
@ -46,16 +45,15 @@ const canonical = new URL(Astro.url.pathname, Astro.site).href;
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- 字体:Sora英文展示+ Noto Sans SC中文正文) -->
<!-- 字体:Playfair Display英文衬线展示+ Noto Serif SC中文衬线标题+ Noto Sans SC正文) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Sora:wght@400;600;700;800&family=Noto+Sans+SC:wght@400;500;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;1,400&family=Noto+Serif+SC:wght@400;600&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400&display=swap"
/>
</head>
<body>
<Background />
<Nav />
{showProgress && <div id="reading-progress" class="reading-progress" aria-hidden="true"></div>}
<main>
@ -64,7 +62,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site).href;
<Footer />
<SearchModal />
<!-- 全局交互脚本:滚动渐入 + 导航玻璃化 -->
<!-- 全局交互脚本:滚动渐入 + 导航滚动态 -->
<script>
// 滚动渐入threshold=0任意 1px 可见即触发),避免超大元素永不可见
const io = new IntersectionObserver(
@ -80,7 +78,7 @@ const canonical = new URL(Astro.url.pathname, Astro.site).href;
);
document.querySelectorAll('.reveal').forEach((el) => io.observe(el));
// 导航栏:滚动后加玻璃
// 导航栏:滚动后浮现底部细线
const nav = document.getElementById('nav');
const onScroll = () => {
if (!nav) return;
@ -117,11 +115,10 @@ const canonical = new URL(Astro.url.pathname, Astro.site).href;
position: fixed;
top: 0;
left: 0;
height: 3px;
height: 2px;
width: 0;
z-index: 100;
background: linear-gradient(90deg, var(--blue-500), var(--cyan-400));
box-shadow: 0 0 12px rgba(47, 127, 224, 0.6);
background: var(--ink);
transition: width 0.1s linear;
}
</style>

View file

@ -4,7 +4,7 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<BaseLayout title="页面走丢了" description="404 页面不存在">
<section class="container wrap">
<div class="card glass reveal">
<div class="card reveal">
<div class="big">4<span class="o">0</span>4</div>
<h1 class="title">页面走丢了喵</h1>
<p class="sub">你访问的页面不存在,或者已被移走。<br />不如回首页看看吧~</p>
@ -18,11 +18,30 @@ import BaseLayout from '../layouts/BaseLayout.astro';
<style>
.wrap { display: grid; place-items: center; min-height: 70vh; padding-top: calc(var(--nav-h) + 20px); }
.card { border-radius: var(--r-xl); padding: clamp(32px, 6vw, 60px); text-align: center; display: flex; flex-direction: column; align-items: center; gap: 14px; }
.big { font-family: var(--font-display); font-weight: 800; font-size: clamp(4rem, 16vw, 8rem); line-height: 1; letter-spacing: -0.05em; color: var(--ink); }
.big .o { display: inline-block; color: var(--blue-500); animation: spin 4s ease-in-out infinite; transform-origin: center; }
@keyframes spin { 0%, 100% { transform: rotate(-8deg); } 50% { transform: rotate(8deg); } }
.title { font-family: var(--font-display); font-weight: 700; font-size: 1.5rem; color: var(--ink); }
.sub { color: var(--ink-soft); line-height: 1.8; }
.cta { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 10px; }
.card {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.big {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(5rem, 18vw, 10rem);
line-height: 1;
letter-spacing: -0.04em;
color: var(--ink);
}
.big .o { font-style: italic; }
.title {
font-family: var(--font-display);
font-weight: 400;
font-style: italic;
font-size: 1.5rem;
letter-spacing: -0.01em;
color: var(--ink);
}
.sub { color: var(--ink-60); line-height: 1.9; font-size: 0.92rem; }
.cta { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; margin-top: 12px; }
</style>

View file

@ -3,11 +3,11 @@ import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout title="关于" description="关于 Yukun">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px); padding-bottom: 60px;">
<div class="about glass reveal">
<div class="avatar">Y</div>
<section class="container" style="padding-top: calc(var(--nav-h) + clamp(48px, 8vw, 96px)); padding-bottom: 40px;">
<div class="about reveal">
<div class="avatar" aria-hidden="true">Y</div>
<h1 class="name">Yukun</h1>
<p class="sig">代码 · 生活 · 淡蓝色的液态玻璃</p>
<p class="sig">代码 · 生活 · 排版与呼吸</p>
<p class="bio">
你好喵,我是 Yukun。目前在同济大学就读
本人完全不懂前端博客完全由GLM-5.2构建
@ -16,61 +16,115 @@ import BaseLayout from '../layouts/BaseLayout.astro';
如果哪篇文章对你有一点帮助,那就万分荣幸。
</p>
<div class="links">
<a href="https://sausagetoast.cloud" class="link-item glass">
<span class="link-ico">⌂</span>
<a href="https://sausagetoast.cloud" class="link-item">
<span class="link-ico" aria-hidden="true">⌂</span>
<span>sausagetoast.cloud</span>
</a>
<a href="mailto:2553712@tongji.edu.cn" class="link-item glass">
<span class="link-ico">✉</span>
<a href="mailto:2553712@tongji.edu.cn" class="link-item">
<span class="link-ico" aria-hidden="true">✉</span>
<span>给我写信(学校)</span>
</a>
<a href="mailto:3385587900@qq.com" class="link-item glass">
<span class="link-ico">✉</span>
<a href="mailto:3385587900@qq.com" class="link-item">
<span class="link-ico" aria-hidden="true">✉</span>
<span>给我写信(QQ)</span>
</a>
</div>
</div>
<div class="card glass reveal" style="margin-top: 20px;">
<div class="card reveal" style="margin-top: clamp(24px, 4vw, 40px);">
<h2 class="card-title">这个博客</h2>
<ul class="feat-list">
<li><span class="feat-ico">◆</span> 用 Astro 静态生成,零运行时 JS</li>
<li><span class="feat-ico">◆</span> Markdown 写作frontmatter 校验防手滑</li>
<li><span class="feat-ico">◆</span> 液态玻璃质感,淡蓝配色,响应式适配</li>
<li><span class="feat-ico">◆</span> 本地搜索、文章目录、归档时间轴</li>
<li><span class="feat-idx">01</span> 用 Astro 静态生成,零运行时 JS</li>
<li><span class="feat-idx">02</span> Markdown 写作frontmatter 校验防手滑</li>
<li><span class="feat-idx">03</span> 编辑杂志排版,暖米色配色,响应式适配</li>
<li><span class="feat-idx">04</span> 本地搜索、文章目录、归档时间轴</li>
</ul>
</div>
</section>
</BaseLayout>
<style>
.about { border-radius: var(--r-xl); padding: clamp(28px, 5vw, 52px); text-align: center; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.about {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
/* 方角字母框:锋利的活字印象 */
.avatar {
display: grid;
place-items: center;
width: clamp(80px, 18vw, 120px);
width: clamp(84px, 16vw, 112px);
aspect-ratio: 1;
border-radius: 50%;
background: linear-gradient(135deg, var(--blue-400), var(--blue-600));
color: #fff;
border: 1px solid var(--line);
color: var(--ink);
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(2.4rem, 6vw, 3.4rem);
box-shadow: 0 12px 30px rgba(47, 127, 224, 0.35), inset 0 4px 16px rgba(255, 255, 255, 0.3);
font-weight: 400;
font-style: italic;
font-size: clamp(2.4rem, 6vw, 3.2rem);
margin-bottom: 8px;
}
.name { font-family: var(--font-display); font-weight: 800; font-size: clamp(1.8rem, 1rem + 3vw, 2.6rem); letter-spacing: -0.03em; color: var(--ink); }
.sig { color: var(--blue-500); font-weight: 600; }
.bio { color: var(--ink-soft); max-width: 52ch; line-height: 1.85; }
.links { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 12px; }
.link-item { display: inline-flex; align-items: center; gap: 8px; padding: 8px 16px; border-radius: 999px; color: var(--ink-soft); font-weight: 600; font-size: 0.9rem; transition: transform 0.25s, color 0.25s; }
.link-item:hover { transform: translateY(-2px); color: var(--blue-600); }
.link-ico { color: var(--blue-400); }
.name {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2.2rem, 1rem + 4vw, 3.5rem);
letter-spacing: -0.02em;
color: var(--ink);
}
.sig { font-style: italic; color: var(--ink-60); font-size: 0.95rem; }
.bio { color: var(--ink-80); max-width: 52ch; line-height: 1.9; font-size: 0.95rem; }
.links { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 14px; }
.link-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: 1px solid var(--line);
color: var(--ink-60);
font-size: 0.82rem;
letter-spacing: 0.08em;
transition: background-color 0.3s, color 0.3s, border-color 0.3s;
}
.link-item:hover {
background: var(--ink);
color: var(--paper);
border-color: var(--ink);
}
.link-ico { color: var(--ink-40); transition: color 0.3s; }
.link-item:hover .link-ico { color: var(--paper); }
.card { border-radius: var(--r-lg); padding: clamp(22px, 4vw, 36px); }
.card-title { font-family: var(--font-display); font-weight: 700; font-size: 1.3rem; margin-bottom: 18px; color: var(--ink); display: flex; align-items: center; gap: 12px; }
.card-title::before { content: ''; width: 5px; height: 1em; border-radius: 6px; background: linear-gradient(180deg, var(--blue-500), var(--cyan-400)); }
.feat-list { display: flex; flex-direction: column; gap: 12px; }
.feat-list li { display: flex; align-items: center; gap: 12px; color: var(--ink-soft); }
.feat-ico { color: var(--blue-400); font-size: 0.7rem; }
.card {
border: 1px solid var(--line);
padding: clamp(24px, 4vw, 40px);
max-width: 720px;
margin-inline: auto;
}
.card-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 1.5rem;
letter-spacing: -0.02em;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
color: var(--ink);
}
.feat-list { display: flex; flex-direction: column; }
.feat-list li {
display: flex;
align-items: baseline;
gap: 16px;
padding: 12px 4px;
border-bottom: 1px solid var(--line);
color: var(--ink-60);
font-size: 0.92rem;
}
.feat-list li:last-child { border-bottom: 0; }
.feat-idx {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.1em;
color: var(--ink-40);
}
</style>

View file

@ -8,24 +8,24 @@ const total = all.length;
---
<BaseLayout title="归档" description="按时间线浏览所有文章">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<section class="container" style="padding-top: calc(var(--nav-h) + clamp(48px, 8vw, 96px)); padding-bottom: clamp(64px, 8vw, 120px);">
<header class="page-head reveal">
<h1 class="page-title">归档</h1>
<p class="page-sub">共 {total} 篇 · 时光轴回顾</p>
<p class="page-sub">共 {total} 篇 · 按年份装订</p>
</header>
<div class="timeline">
{years.map(({ year, posts }) => (
<section class="year-group reveal">
<div class="year-node glass">
<header class="year-head">
<span class="year-num">{year}</span>
<span class="year-rule" aria-hidden="true"></span>
<span class="year-count">{posts.length} 篇</span>
</div>
</header>
<div class="year-items">
{posts.map((p) => (
<a href={`/posts/${p.id}`} class="tl-item">
<span class="tl-date">{formatDateShort(p.data.date)}</span>
<span class="tl-dot"></span>
<span class="tl-title">{p.data.title}</span>
{p.data.tags[0] && <span class="chip">{p.data.tags[0]}</span>}
</a>
@ -38,86 +38,82 @@ const total = all.length;
</BaseLayout>
<style>
.page-head { margin-bottom: 32px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; color: var(--ink); }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.page-head { margin-bottom: clamp(36px, 5vw, 56px); }
.page-title {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2.5rem, 1rem + 5vw, 4.5rem);
letter-spacing: -0.03em;
line-height: 1.05;
color: var(--ink);
}
.page-sub { color: var(--ink-60); margin-top: 14px; font-size: 0.9rem; letter-spacing: 0.05em; }
.timeline { position: relative; padding-left: 8px; }
.timeline::before {
content: '';
position: absolute;
left: 88px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, var(--blue-300), var(--cyan-300), transparent);
/* 年份:巨型衬线年号 + 细线延伸 */
.year-group { margin-bottom: clamp(40px, 6vw, 72px); }
.year-head {
display: flex;
align-items: baseline;
gap: 20px;
margin-bottom: 8px;
}
.year-group { margin-bottom: clamp(28px, 4vw, 44px); }
.year-node {
display: inline-flex;
align-items: center;
gap: 10px;
border-radius: 999px;
padding: 6px 18px;
margin-bottom: 18px;
margin-left: 56px;
position: relative;
z-index: 1;
.year-num {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2rem, 1rem + 3vw, 3.25rem);
letter-spacing: -0.02em;
line-height: 1;
color: var(--ink);
}
.year-node::before {
content: '';
position: absolute;
left: -28px;
top: 50%;
transform: translateY(-50%);
width: 14px; height: 14px;
border-radius: 50%;
background: var(--blue-500);
box-shadow: 0 0 0 4px rgba(132, 194, 255, 0.4), 0 0 14px var(--blue-400);
.year-rule { flex: 1; height: 1px; background: var(--line); align-self: center; }
.year-count {
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-40);
white-space: nowrap;
}
.year-num { font-family: var(--font-display); font-weight: 800; font-size: 1.2rem; color: var(--blue-600); }
.year-count { color: var(--ink-faint); font-size: 0.82rem; }
.year-items { display: flex; flex-direction: column; gap: 4px; padding-left: 56px; }
/* 条目:期刊目录行,细线分隔 */
.year-items { display: flex; flex-direction: column; }
.tl-item {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 14px;
border-radius: 12px;
transition: background 0.2s, transform 0.2s;
position: relative;
gap: 18px;
padding: 15px 10px;
border-bottom: 1px solid var(--line);
transition: background-color 0.3s, padding-left 0.3s;
}
.tl-item::before {
content: '';
position: absolute;
left: -24px;
top: 50%;
transform: translate(-50%, -50%);
width: 8px; height: 8px;
border-radius: 50%;
background: var(--blue-300);
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(79, 163, 255, 0.3);
transition: all 0.2s;
.tl-item:first-child { border-top: 1px solid var(--line); }
.tl-item:hover { background: rgba(28, 28, 28, 0.02); }
.tl-date {
font-family: var(--font-mono);
font-size: 0.74rem;
letter-spacing: 0.08em;
color: var(--ink-40);
width: 88px;
flex-shrink: 0;
}
.tl-item:hover { background: rgba(132, 194, 255, 0.14); transform: translateX(4px); }
.tl-item:hover::before { background: var(--blue-500); box-shadow: 0 0 0 1px var(--blue-400), 0 0 10px var(--blue-400); }
.tl-date { font-family: var(--font-mono); font-size: 0.78rem; color: var(--ink-faint); width: 84px; flex-shrink: 0; }
.tl-dot { display: none; }
.tl-title { color: var(--ink); font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-item:hover .tl-title { color: var(--blue-600); }
.tl-item .chip { margin-left: auto; }
.tl-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 1.05rem;
letter-spacing: -0.01em;
color: var(--ink);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: all 0.5s;
}
.tl-item:hover .tl-title { font-style: italic; }
.tl-item .chip { margin-left: auto; flex-shrink: 0; }
@media (max-width: 640px) {
.timeline::before { left: 20px; }
.year-node { margin-left: 0; }
.year-node::before { left: -16px; }
.year-items { padding-left: 0; }
.tl-item::before { left: -16px; }
.tl-item { flex-wrap: wrap; gap: 8px; }
.tl-item { flex-wrap: wrap; gap: 8px 14px; }
.tl-date { width: auto; }
.tl-item .chip { margin-left: 0; }
.tl-title { white-space: normal; }
.tl-item .chip { margin-left: 0; }
}
</style>

View file

@ -13,25 +13,27 @@ const total = all.length;
<BaseLayout
title="Yukun's Blog"
description="记录代码与生活 · 淡蓝色液态玻璃风的个人博客"
description="记录代码与生活 · 编辑杂志风的个人博客"
>
<!-- 英雄区:左对齐报头 + 右侧浮动玻璃卡片(非居中三件套) -->
<!-- 报头:巨型衬线刊名 + 斜体题献 + 右侧最新索引 -->
<section class="hero container">
<div class="hero-left reveal">
<span class="eyebrow glass">
<span class="pulse"></span>个人博客 · 已发布 {total} 篇
</span>
<p class="dateline">
<span>个人博客 · 已发布 {total} 篇</span>
<span class="dl-rule" aria-hidden="true"></span>
<span>EST. 2026</span>
</p>
<h1 class="hero-title">
<span class="gradient-text">Yukun&rsquo;s</span><br />Blog
Yukun&rsquo;s<br /><em>Blog</em>
</h1>
<p class="hero-sig">
记录代码与生活,淡蓝色的液态玻璃。<br />
记录代码与生活。<br />
在这里写下技术笔记、随手记的随笔,和一些关于世界的碎碎念。
</p>
<div class="hero-cta">
<a href="/posts" class="btn btn-primary">
浏览全部文章
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
<a href="/about" class="btn">关于我</a>
</div>
@ -41,22 +43,19 @@ const total = all.length;
</div>
<aside class="hero-right reveal">
<div class="float-card glass">
<div class="fc-head">
<span class="fc-dot"></span>
<span class="fc-label">最新发布</span>
<div class="index-card">
<div class="ic-head">
<span class="label">最新发布</span>
<span class="ic-no" aria-hidden="true">№ 01</span>
</div>
{featured && (
<a href={`/posts/${featured.id}`} class="fc-post">
<div class="fc-emoji">✦</div>
<div class="fc-body">
<div class="fc-date">{formatDate(featured.data.date)}</div>
<div class="fc-title">{featured.data.title}</div>
{featured.data.tags[0] && <span class="chip">{featured.data.tags[0]}</span>}
</div>
<a href={`/posts/${featured.id}`} class="ic-post">
<time class="ic-date" datetime={featured.data.date.toISOString()}>{formatDate(featured.data.date)}</time>
<div class="ic-title">{featured.data.title}</div>
{featured.data.tags[0] && <span class="chip">{featured.data.tags[0]}</span>}
</a>
)}
<div class="fc-stat">
<div class="ic-stats">
<div><strong>{total}</strong><span>篇文章</span></div>
<div><strong>{tags.length}</strong><span>个标签</span></div>
</div>
@ -76,7 +75,7 @@ const total = all.length;
<section class="container section">
<div class="sec-head reveal">
<h2 class="section-title">最新文章</h2>
<a href="/posts" class="more-link">查看全部</a>
<a href="/posts" class="more-link u">查看全部</a>
</div>
<div class="post-grid">
{latest.map((post) => <PostCard post={post} class="reveal" />)}
@ -86,7 +85,7 @@ const total = all.length;
<!-- 标签云 -->
<section class="container section">
<h2 class="section-title reveal">探索标签</h2>
<div class="tag-cloud glass reveal">
<div class="tag-cloud reveal">
{tags.map((t) => <TagChip tag={t.tag} count={t.count} />)}
</div>
</section>
@ -95,92 +94,129 @@ const total = all.length;
<style>
.hero {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: clamp(24px, 4vw, 56px);
grid-template-columns: 7fr 5fr;
gap: clamp(32px, 6vw, 80px);
align-items: end;
padding-top: calc(var(--nav-h) + clamp(48px, 9vw, 120px));
padding-bottom: clamp(24px, 4vw, 48px);
}
.dateline {
display: flex;
align-items: center;
padding-top: calc(var(--nav-h) + clamp(40px, 8vw, 90px));
padding-bottom: clamp(30px, 5vw, 50px);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 600;
color: var(--blue-700);
}
.pulse {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--blue-500);
box-shadow: 0 0 0 0 rgba(47, 127, 224, 0.5);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { box-shadow: 0 0 0 0 rgba(47, 127, 224, 0.5); }
70% { box-shadow: 0 0 0 8px rgba(47, 127, 224, 0); }
100% { box-shadow: 0 0 0 0 rgba(47, 127, 224, 0); }
gap: 16px;
font-size: 0.72rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--ink-40);
margin-bottom: clamp(20px, 3vw, 32px);
}
.dl-rule { flex: 1; height: 1px; background: var(--line); }
.hero-title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(2.8rem, 6vw, 5rem);
line-height: 0.98;
letter-spacing: -0.04em;
margin: 18px 0 20px;
font-weight: 400;
font-size: clamp(3.4rem, 10vw, 8.5rem);
line-height: 0.95;
letter-spacing: -0.03em;
color: var(--ink);
}
.hero-title em { font-style: italic; }
.hero-sig {
font-size: clamp(1rem, 0.9rem + 0.4vw, 1.15rem);
line-height: 1.75;
color: var(--ink-soft);
max-width: 38ch;
margin-top: clamp(18px, 3vw, 28px);
font-size: clamp(0.95rem, 0.9rem + 0.3vw, 1.1rem);
line-height: 1.85;
color: var(--ink-60);
max-width: 40ch;
}
.hero-cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 26px; }
.hero-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 28px; }
.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 30px; }
.hero-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 32px; }
/* 右侧浮动玻璃卡片 */
.hero-right { position: relative; }
.float-card {
border-radius: var(--r-xl);
padding: 22px;
animation: floaty 6s ease-in-out infinite;
transform-style: preserve-3d;
/* 右侧索引卡:细线边框,无投影 */
.index-card {
border: 1px solid var(--line);
padding: clamp(22px, 3vw, 30px);
display: flex;
flex-direction: column;
}
@keyframes floaty {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
.ic-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.ic-no {
font-family: var(--font-display);
font-style: italic;
font-size: 0.95rem;
color: var(--ink-40);
}
.ic-post { display: flex; flex-direction: column; gap: 8px; padding: 18px 0; }
.ic-date { font-size: 0.72rem; letter-spacing: 0.15em; color: var(--ink-40); text-transform: uppercase; }
.ic-title {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(1.25rem, 1rem + 1vw, 1.6rem);
line-height: 1.35;
letter-spacing: -0.02em;
color: var(--ink);
transition: all 0.5s;
}
.ic-post:hover .ic-title { font-style: italic; }
.ic-post .chip { align-self: flex-start; }
.ic-stats {
display: flex;
gap: 36px;
margin-top: 6px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.ic-stats div { display: flex; flex-direction: column; gap: 2px; }
.ic-stats strong {
font-family: var(--font-display);
font-weight: 400;
font-size: 2rem;
line-height: 1;
color: var(--ink);
}
.ic-stats span {
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-40);
}
.fc-head { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; color: var(--ink-faint); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 700; }
.fc-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan-400); box-shadow: 0 0 10px var(--cyan-400); }
.fc-post { display: flex; gap: 14px; padding: 14px; border-radius: var(--r-md); background: rgba(132, 194, 255, 0.12); border: 1px solid rgba(132, 194, 255, 0.2); transition: transform 0.3s, background 0.3s; }
.fc-post:hover { transform: translateX(4px); background: rgba(132, 194, 255, 0.2); }
.fc-emoji { font-size: 1.4rem; color: var(--blue-500); }
.fc-date { color: var(--ink-faint); font-size: 0.76rem; }
.fc-title { font-family: var(--font-display); font-weight: 700; font-size: 1.05rem; margin: 2px 0 6px; color: var(--ink); }
.fc-body .chip { margin-top: 4px; }
.fc-stat { display: flex; gap: 24px; margin-top: 18px; padding-top: 16px; border-top: 1px solid rgba(79, 163, 255, 0.18); }
.fc-stat div { display: flex; flex-direction: column; }
.fc-stat strong { font-family: var(--font-display); font-size: 1.5rem; color: var(--blue-600); font-weight: 800; }
.fc-stat span { font-size: 0.78rem; color: var(--ink-faint); }
.sec-head { display: flex; align-items: end; justify-content: space-between; margin-bottom: 1.5rem; }
.sec-head .section-title { margin-bottom: 0; }
.more-link { color: var(--blue-600); font-weight: 600; font-size: 0.92rem; }
.more-link:hover { color: var(--blue-500); }
.sec-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24px;
}
.sec-head .section-title { margin-bottom: 2.25rem; }
.more-link {
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-60);
transition: color 0.3s;
white-space: nowrap;
}
.more-link:hover { color: var(--ink); }
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: clamp(16px, 2.5vw, 24px);
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: clamp(20px, 2.5vw, 32px);
}
.tag-cloud {
border: 1px solid var(--line);
padding: clamp(24px, 4vw, 40px);
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tag-cloud { border-radius: var(--r-lg); padding: clamp(20px, 3vw, 32px); display: flex; flex-wrap: wrap; gap: 12px; }
/* 移动端:英雄区单列 */
@media (max-width: 860px) {
.hero { grid-template-columns: 1fr; gap: 32px; }
.hero-right { order: -1; }
.hero { grid-template-columns: 1fr; gap: 40px; align-items: start; }
}
</style>

View file

@ -1,7 +1,7 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getPublishedPosts, formatDate, readingTime, heroCss } from '../../lib/utils';
import { getPublishedPosts, formatDate, readingTime } from '../../lib/utils';
import Toc from '../../components/Toc.astro';
import Comments from '../../components/Comments.astro';
import '../../styles/prose.css';
@ -26,26 +26,28 @@ const updated = post.data.updatedDate ? formatDate(post.data.updatedDate) : null
---
<BaseLayout title={post.data.title} description={post.data.description} showProgress={true}>
<article class="container post" style="padding-top: calc(var(--nav-h) + clamp(32px, 6vw, 60px));">
<!-- 封面 -->
<div class="hero-banner reveal" style={`background:${heroCss(post)}`}>
<div class="hero-inner">
<div class="hero-tags">
{post.data.tags.map((t) => <span class="hero-chip">{t}</span>)}
</div>
<h1 class="post-title">{post.data.title}</h1>
<div class="post-meta">
<time datetime={post.data.date.toISOString()}>{date}</time>
<span class="dot"></span>
<span>{rt}阅读</span>
{updated && <><span class="dot"></span><span>更新于 {updated}</span></>}
<article class="container post" style="padding-top: calc(var(--nav-h) + clamp(40px, 7vw, 88px));">
<!-- 刊头:栏目标签 + 巨型衬线标题 + 日期行 -->
<header class="post-head reveal">
{post.data.tags.length > 0 && (
<div class="ph-tags">
{post.data.tags.map((t) => (
<a href={`/tags/${t}`} class="ph-tag u">#{t}</a>
))}
</div>
)}
<h1 class="post-title">{post.data.title}</h1>
<div class="post-meta meta">
<time datetime={post.data.date.toISOString()}>{date}</time>
<span class="dot"></span>
<span>{rt}阅读</span>
{updated && <><span class="dot"></span><span>更新于 {updated}</span></>}
</div>
</div>
</header>
<!-- 正文 + 侧栏目录 -->
<div class="post-layout">
<div class="post-content glass">
<div class="post-content">
<div class="prose">
<Content />
</div>
@ -56,7 +58,7 @@ const updated = post.data.updatedDate ? formatDate(post.data.updatedDate) : null
<a href={`/tags/${t}`} class="chip chip-lg">#{t}</a>
))}
</div>
<a href="/" class="back-link">← 返回首页</a>
<a href="/" class="back-link u">← 返回首页</a>
</div>
</div>
<div class="post-aside">
@ -73,13 +75,13 @@ const updated = post.data.updatedDate ? formatDate(post.data.updatedDate) : null
{(prev || next) && (
<nav class="pager reveal">
{prev ? (
<a href={`/posts/${prev.id}`} class="pager-card glass">
<a href={`/posts/${prev.id}`} class="pager-card">
<span class="pager-label">← 上一篇</span>
<span class="pager-title">{prev.data.title}</span>
</a>
) : <span class="pager-empty"></span>}
{next ? (
<a href={`/posts/${next.id}`} class="pager-card glass next">
<a href={`/posts/${next.id}`} class="pager-card next">
<span class="pager-label">下一篇 →</span>
<span class="pager-title">{next.data.title}</span>
</a>
@ -90,56 +92,39 @@ const updated = post.data.updatedDate ? formatDate(post.data.updatedDate) : null
</BaseLayout>
<style>
.hero-banner {
border-radius: var(--r-xl);
padding: clamp(28px, 5vw, 48px) clamp(20px, 4vw, 44px);
overflow: hidden;
position: relative;
color: #fff;
.post-head {
padding-bottom: clamp(28px, 4vw, 44px);
border-bottom: 1px solid var(--line);
}
.hero-banner::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(circle at 25% 15%, rgba(255, 255, 255, 0.35), transparent 45%),
linear-gradient(180deg, transparent 50%, rgba(0, 40, 80, 0.22));
}
.hero-inner { position: relative; z-index: 1; }
.hero-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; }
.hero-chip {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.35);
font-size: 0.78rem;
font-weight: 600;
color: #fff;
.ph-tags { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 20px; }
.ph-tag {
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-40);
transition: color 0.3s;
}
.ph-tag:hover { color: var(--ink); }
.post-title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.8rem, 1rem + 3vw, 2.8rem);
line-height: 1.15;
font-weight: 400;
font-size: clamp(2.4rem, 1rem + 4.5vw, 4.5rem);
line-height: 1.08;
letter-spacing: -0.03em;
margin-bottom: 14px;
text-shadow: 0 2px 16px rgba(0, 40, 80, 0.25);
color: var(--ink);
margin-bottom: 22px;
}
.post-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; color: rgba(255, 255, 255, 0.92); font-size: 0.88rem; }
.post-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.post-meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: 0.6; }
.post-layout {
display: grid;
grid-template-columns: 1fr 260px;
gap: clamp(20px, 3vw, 36px);
margin-top: clamp(20px, 3vw, 32px);
grid-template-columns: minmax(0, 1fr) 260px;
gap: clamp(28px, 4vw, 56px);
padding-top: clamp(32px, 5vw, 56px);
align-items: start;
}
.post-content {
border-radius: var(--r-lg);
padding: clamp(20px, 3.5vw, 40px);
min-width: 0;
}
.post-content { min-width: 0; }
.post-footer {
display: flex;
flex-wrap: wrap;
@ -148,8 +133,14 @@ const updated = post.data.updatedDate ? formatDate(post.data.updatedDate) : null
gap: 14px;
}
.post-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.back-link { color: var(--ink-faint); font-weight: 600; font-size: 0.9rem; }
.back-link:hover { color: var(--blue-600); }
.back-link {
color: var(--ink-40);
font-size: 0.78rem;
letter-spacing: 0.15em;
text-transform: uppercase;
transition: color 0.3s;
}
.back-link:hover { color: var(--ink); }
.post-aside { position: relative; }
.aside-inner {
@ -159,25 +150,41 @@ const updated = post.data.updatedDate ? formatDate(post.data.updatedDate) : null
overflow: auto;
}
/* 上下篇 */
/* 上下篇:细线分隔的目录式翻页 */
.pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: clamp(28px, 4vw, 44px);
gap: clamp(20px, 3vw, 40px);
margin-top: clamp(40px, 6vw, 72px);
}
.pager-card {
border-radius: var(--r-md);
padding: 16px 20px;
border-top: 1px solid var(--line);
padding-top: 18px;
display: flex;
flex-direction: column;
gap: 6px;
transition: transform 0.3s;
gap: 8px;
transition: border-color 0.4s;
}
.pager-card:hover { transform: translateY(-3px); }
.pager-card:hover { border-top-color: var(--ink); }
.pager-card.next { text-align: right; align-items: flex-end; }
.pager-label { color: var(--blue-500); font-weight: 700; font-size: 0.82rem; }
.pager-title { color: var(--ink); font-weight: 600; }
.pager-label {
font-size: 0.72rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-40);
transition: color 0.3s;
}
.pager-card:hover .pager-label { color: var(--ink); }
.pager-title {
font-family: var(--font-display);
font-weight: 400;
font-size: 1.2rem;
line-height: 1.4;
letter-spacing: -0.01em;
color: var(--ink);
transition: all 0.5s;
}
.pager-card:hover .pager-title { font-style: italic; }
.pager-empty { visibility: hidden; }
/* 移动端:目录移到正文上方,上下篇单列 */

View file

@ -9,22 +9,22 @@ const tags = getAllTags(all);
---
<BaseLayout title="全部文章" description="Yukun's Blog 的全部文章列表">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<section class="container" style="padding-top: calc(var(--nav-h) + clamp(48px, 8vw, 96px));">
<header class="page-head reveal">
<h1 class="page-title">全部文章</h1>
<p class="page-sub">共 {all.length} 篇 · 按发布时间倒序排列</p>
</header>
<div class="tag-bar glass reveal">
<span class="tag-label">筛选标签</span>
<div class="tag-bar reveal">
<span class="label">筛选标签</span>
<div class="tag-list">
<a href="/posts" class="tag-chip active">全部</a>
<TagChip tag="全部" href="/posts" active />
{tags.map((t) => <TagChip tag={t.tag} count={t.count} />)}
</div>
</div>
</section>
<section class="container section" style="padding-top: 24px;">
<section class="container" style="padding-top: clamp(40px, 6vw, 72px); padding-bottom: clamp(64px, 8vw, 120px);">
<div class="post-grid">
{all.map((post) => <PostCard post={post} class="reveal" />)}
</div>
@ -32,11 +32,29 @@ const tags = getAllTags(all);
</BaseLayout>
<style>
.page-head { margin-bottom: 28px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; color: var(--ink); }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.tag-bar { border-radius: var(--r-lg); padding: 14px 18px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.tag-label { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-faint); font-weight: 700; white-space: nowrap; }
.page-head { margin-bottom: clamp(32px, 5vw, 48px); }
.page-title {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2.5rem, 1rem + 5vw, 4.5rem);
letter-spacing: -0.03em;
line-height: 1.05;
color: var(--ink);
}
.page-sub { color: var(--ink-60); margin-top: 14px; font-size: 0.9rem; letter-spacing: 0.05em; }
.tag-bar {
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
padding: 18px 0;
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.tag-list { display: flex; flex-wrap: wrap; gap: 8px; }
.post-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: clamp(16px, 2.5vw, 24px); }
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: clamp(20px, 2.5vw, 32px);
}
</style>

View file

@ -20,10 +20,10 @@ const { tag, count, posts } = Astro.props;
---
<BaseLayout title={`#${tag}`} description={`${tag} 标签下的全部文章`}>
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<section class="container" style="padding-top: calc(var(--nav-h) + clamp(48px, 8vw, 96px)); padding-bottom: clamp(64px, 8vw, 120px);">
<header class="page-head reveal">
<div class="crumb"><a href="/tags">标签</a> <span>/</span> #{tag}</div>
<h1 class="page-title gradient-text">#{tag}</h1>
<div class="crumb"><a href="/tags" class="u">标签</a> <span aria-hidden="true">/</span></div>
<h1 class="page-title">#{tag}</h1>
<p class="page-sub">共 {count} 篇文章</p>
</header>
@ -34,11 +34,29 @@ const { tag, count, posts } = Astro.props;
</BaseLayout>
<style>
.page-head { margin-bottom: 28px; }
.crumb { color: var(--ink-faint); font-size: 0.88rem; margin-bottom: 12px; }
.crumb a { color: var(--blue-600); }
.crumb span { margin: 0 6px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.post-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: clamp(16px, 2.5vw, 24px); }
.page-head { margin-bottom: clamp(32px, 5vw, 48px); }
.crumb {
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--ink-40);
margin-bottom: 16px;
}
.crumb a { color: var(--ink-60); transition: color 0.3s; }
.crumb a:hover { color: var(--ink); }
.crumb span { margin: 0 8px; }
.page-title {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2.5rem, 1rem + 5vw, 4.5rem);
letter-spacing: -0.03em;
line-height: 1.05;
color: var(--ink);
}
.page-sub { color: var(--ink-60); margin-top: 14px; font-size: 0.9rem; letter-spacing: 0.05em; }
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: clamp(20px, 2.5vw, 32px);
}
</style>

View file

@ -8,13 +8,13 @@ const tags = getAllTags(all);
---
<BaseLayout title="标签" description="按标签浏览文章">
<section class="container" style="padding-top: calc(var(--nav-h) + 48px);">
<section class="container" style="padding-top: calc(var(--nav-h) + clamp(48px, 8vw, 96px)); padding-bottom: clamp(64px, 8vw, 120px);">
<header class="page-head reveal">
<h1 class="page-title">标签</h1>
<p class="page-sub">共 {tags.length} 个标签 · 字号越大文章越多</p>
<p class="page-sub">共 {tags.length} 个标签 · 按文章数量排序</p>
</header>
<div class="tag-cloud glass reveal">
<div class="tag-cloud reveal">
{tags.map((t) => (
<TagChip tag={t.tag} count={t.count} />
))}
@ -23,8 +23,21 @@ const tags = getAllTags(all);
</BaseLayout>
<style>
.page-head { margin-bottom: 28px; }
.page-title { font-family: var(--font-display); font-weight: 800; font-size: clamp(2rem, 1rem + 4vw, 3rem); letter-spacing: -0.03em; color: var(--ink); }
.page-sub { color: var(--ink-soft); margin-top: 8px; }
.tag-cloud { border-radius: var(--r-lg); padding: clamp(24px, 4vw, 40px); display: flex; flex-wrap: wrap; gap: 12px; }
.page-head { margin-bottom: clamp(32px, 5vw, 48px); }
.page-title {
font-family: var(--font-display);
font-weight: 400;
font-size: clamp(2.5rem, 1rem + 5vw, 4.5rem);
letter-spacing: -0.03em;
line-height: 1.05;
color: var(--ink);
}
.page-sub { color: var(--ink-60); margin-top: 14px; font-size: 0.9rem; letter-spacing: 0.05em; }
.tag-cloud {
border: 1px solid var(--line);
padding: clamp(24px, 4vw, 48px);
display: flex;
flex-wrap: wrap;
gap: 10px;
}
</style>

View file

@ -1,50 +1,39 @@
/* ==========================================================================
Yukun's Blog · 全局样式
液态玻璃 × 淡蓝设计系统
编辑杂志风Editorial设计系统
暖米色纸面 × 柔和黑油墨 · 纯单色 · 衬线标题 · 细线网格
========================================================================== */
/* ---------- 设计令牌 ---------- */
:root {
/* 淡蓝配色 */
--blue-50: #eef7ff;
--blue-100: #d8ecff;
--blue-200: #b4d9ff;
--blue-300: #84c2ff;
--blue-400: #4ea3ff;
--blue-500: #2f8df0;
--blue-600: #2f7fe0;
--blue-700: #2a6cc7;
--cyan-400: #38bdf8;
--cyan-300: #67d3f8;
/* 纸与墨:仅两个色相,透明度分层构建灰阶 */
--paper: #F9F8F6; /* 暖米色纸面(非纯白/冷白) */
--ink: #1C1C1C; /* 柔和黑油墨(非纯黑) */
--ink-80: rgba(28, 28, 28, 0.8); /* 正文 */
--ink-60: rgba(28, 28, 28, 0.6); /* 次要文字 */
--ink-40: rgba(28, 28, 28, 0.4); /* 辅助文字 / 标签 */
--ink-20: rgba(28, 28, 28, 0.2); /* 表单边 */
--ink-10: rgba(28, 28, 28, 0.1); /* 分隔线 / 边框 */
--line: var(--ink-10);
--ink: #16324f; /* 主文字 深墨蓝 */
--ink-soft: #3a5a7a; /* 次文字 */
--ink-faint: #6b87a3; /* 弱文字 */
--bg: #eef7ff; /* 底色 */
--bg-2: #f9fdff; /* 亮底色 */
/* 玻璃 */
--glass: rgba(255, 255, 255, 0.55);
--glass-strong: rgba(255, 255, 255, 0.72);
--glass-soft: rgba(255, 255, 255, 0.38);
--glass-border: rgba(255, 255, 255, 0.65);
--glass-shadow: 0 8px 32px rgba(31, 96, 160, 0.12),
0 2px 8px rgba(31, 96, 160, 0.06);
/* 字体 */
--font-display: 'Sora', 'Noto Sans SC', system-ui, sans-serif;
/* 字体:衬线标题 + 无衬线正文 */
--font-display: 'Playfair Display', 'Noto Serif SC', 'Songti SC', 'STSong', serif;
--font-serif: 'Noto Serif SC', 'Playfair Display', 'Songti SC', serif;
--font-body: 'Noto Sans SC', system-ui, -apple-system, 'PingFang SC',
'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', ui-monospace, 'SF Mono',
Consolas, monospace;
/* 间距 / 圆角 */
--r-sm: 10px;
--r-md: 16px;
--r-lg: 24px;
--r-xl: 32px;
--container: 1120px;
--nav-h: 68px;
/* 间距 / 尺寸:圆角全部归零,锋利如纸 */
--r-sm: 0px;
--r-md: 0px;
--r-lg: 0px;
--r-xl: 0px;
--container: 1280px;
--nav-h: 72px;
/* 缓动:克制、无弹跳 */
--ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* ---------- 重置 ---------- */
@ -61,7 +50,7 @@ html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
font-family: var(--font-body);
color: var(--ink);
background: var(--bg);
background: var(--paper);
line-height: 1.75;
font-size: 16px;
min-height: 100vh;
@ -71,233 +60,177 @@ body {
}
img, picture, svg, video, canvas { display: block; max-width: 100%; height: auto; }
input, button, textarea, select { font: inherit; color: inherit; }
a { color: var(--blue-600); text-decoration: none; transition: color .2s; }
a:hover { color: var(--blue-500); }
a { color: inherit; text-decoration: none; transition: color .2s, opacity .2s; }
a:hover { color: inherit; }
ul, ol { list-style: none; padding: 0; }
table { border-collapse: collapse; }
:focus-visible { outline: 2px solid var(--blue-500); outline-offset: 3px; border-radius: 4px; }
:focus-visible { outline: 1px solid var(--ink); outline-offset: 3px; }
/* 滚动条 */
::-webkit-scrollbar { width: 10px; height: 10px; }
/* 滚动条:细油墨 */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: rgba(79, 163, 255, 0.35);
border-radius: 10px;
border: 2px solid transparent;
background-clip: padding-box;
background: rgba(28, 28, 28, 0.25);
border-radius: 0;
}
::-webkit-scrollbar-thumb:hover { background: rgba(79, 163, 255, 0.6); background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: rgba(28, 28, 28, 0.45); }
::selection { background: var(--blue-200); color: var(--ink); }
/* ---------- 背景层:渐变 + 漂浮光斑 ---------- */
.bg-layers {
position: fixed;
inset: 0;
z-index: -1;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 20% 0%, #d8ecff 0%, transparent 55%),
radial-gradient(ellipse 70% 50% at 90% 10%, #cfeeff 0%, transparent 50%),
linear-gradient(180deg, #eef7ff 0%, #f6fbff 40%, #eaf4ff 100%);
}
.bg-layers::before {
/* 极淡网格,给玻璃一些"透"的纹理 */
content: '';
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(79, 163, 255, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(79, 163, 255, 0.05) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 70% 70% at 50% 30%, #000 30%, transparent 80%);
-webkit-mask-image: radial-gradient(ellipse 70% 70% at 50% 30%, #000 30%, transparent 80%);
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(60px);
opacity: 0.6;
will-change: transform;
}
.blob-1 {
width: 460px; height: 460px;
top: -80px; left: -60px;
background: radial-gradient(circle, var(--blue-300), transparent 70%);
animation: float1 22s ease-in-out infinite;
}
.blob-2 {
width: 520px; height: 520px;
top: 20%; right: -120px;
background: radial-gradient(circle, var(--cyan-300), transparent 70%);
animation: float2 26s ease-in-out infinite;
}
.blob-3 {
width: 380px; height: 380px;
bottom: -100px; left: 35%;
background: radial-gradient(circle, var(--blue-200), transparent 70%);
animation: float3 30s ease-in-out infinite;
}
@keyframes float1 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(60px, 80px) scale(1.1); }
}
@keyframes float2 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-80px, 50px) scale(0.92); }
}
@keyframes float3 {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(40px, -70px) scale(1.08); }
}
/* ---------- 玻璃工具类 ---------- */
.glass {
background: var(--glass);
backdrop-filter: blur(20px) saturate(160%);
-webkit-backdrop-filter: blur(20px) saturate(160%);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
/* 内侧高光:顶部 1px 亮线 */
position: relative;
}
.glass::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(180deg, rgba(255,255,255,0.9), rgba(255,255,255,0.1) 40%, transparent);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.glass-strong {
background: var(--glass-strong);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
}
::selection { background: var(--ink); color: var(--paper); }
/* ---------- 布局 ---------- */
.container {
width: 100%;
max-width: var(--container);
margin-inline: auto;
padding-inline: clamp(16px, 4vw, 32px);
padding-inline: clamp(24px, 5vw, 48px);
}
main { display: block; }
main { display: block; counter-reset: sec; }
.section { padding-block: clamp(40px, 7vw, 80px); }
/* Section杂志开篇的大留白 */
.section { padding-block: clamp(64px, 8vw, 96px); }
@media (min-width: 768px) {
.section { padding-block: clamp(96px, 10vw, 160px); }
}
/* Section 标题:衬线 + 自动编号01 / 02 …) */
.section-title {
font-family: var(--font-display);
font-weight: 800;
font-size: clamp(1.5rem, 4vw, 2.25rem);
font-weight: 400;
font-size: clamp(1.75rem, 1rem + 2.5vw, 2.75rem);
letter-spacing: -0.02em;
color: var(--ink);
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 1.5rem;
align-items: baseline;
gap: 18px;
margin-bottom: 2.25rem;
}
.section-title::before {
content: '';
width: 6px; height: 1.2em;
background: linear-gradient(180deg, var(--blue-500), var(--cyan-400));
border-radius: 6px;
counter-increment: sec;
content: counter(sec, decimal-leading-zero);
font-family: var(--font-body);
font-size: 0.8rem;
letter-spacing: 0.2em;
color: var(--ink-40);
transform: translateY(-0.35em);
}
/* ---------- 按钮 ---------- */
/* ---------- 排版工具 ---------- */
.label {
font-family: var(--font-body);
font-size: 0.72rem;
letter-spacing: 0.25em;
text-transform: uppercase;
color: var(--ink-40);
}
/* hover 下划线:细线自右向左展开 */
.u { position: relative; }
.u::after {
content: '';
position: absolute;
left: 0;
bottom: -3px;
width: 100%;
height: 1px;
background: currentColor;
transform: scaleX(0);
transform-origin: right center;
transition: transform 0.45s var(--ease);
}
.u:hover::after,
.u.active::after { transform: scaleX(1); }
/* ---------- 面板:细线边框,无阴影无圆角 ---------- */
.panel,
.glass {
background: transparent;
border: 1px solid var(--line);
box-shadow: none;
}
.glass-strong {
background: var(--paper);
border: 1px solid var(--ink-20);
box-shadow: none;
}
/* ---------- 按钮:方角油墨块,悬停反转 ---------- */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: 999px;
font-weight: 600;
font-size: 0.95rem;
border: 1px solid var(--glass-border);
background: var(--glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--blue-600);
gap: 10px;
padding: 12px 24px;
border: 1px solid var(--ink);
background: transparent;
color: var(--ink);
font-family: var(--font-body);
font-size: 0.78rem;
font-weight: 500;
letter-spacing: 0.2em;
text-transform: uppercase;
cursor: pointer;
transition: transform .25s cubic-bezier(.2,.8,.2,1), box-shadow .25s, background .25s;
transition: background-color 0.3s, color 0.3s, opacity 0.3s;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(47,127,224,.18); background: var(--glass-strong); }
.btn-primary {
background: linear-gradient(135deg, var(--blue-500), var(--blue-600));
color: #fff;
border-color: transparent;
box-shadow: 0 8px 20px rgba(47,127,224,.32);
}
.btn-primary:hover { background: linear-gradient(135deg, var(--blue-400), var(--blue-500)); }
.btn:hover { background: var(--ink); color: var(--paper); }
.btn:active { transform: scale(0.97); }
.btn-primary { background: var(--ink); color: var(--paper); }
.btn-primary:hover { background: var(--ink); color: var(--paper); opacity: 0.82; }
/* ---------- 标签 chip ---------- */
/* ---------- 标签 chip小号大写字距标签 ---------- */
.chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: var(--blue-700);
background: rgba(132, 194, 255, 0.18);
border: 1px solid rgba(132, 194, 255, 0.4);
transition: all .2s;
padding: 3px 10px;
border: 1px solid var(--line);
font-size: 0.72rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--ink-60);
white-space: nowrap;
transition: border-color 0.3s, color 0.3s, background-color 0.3s;
}
.chip:hover { background: rgba(132, 194, 255, 0.32); transform: translateY(-1px); }
.chip-lg { padding: 6px 16px; font-size: 0.88rem; }
.chip:hover { border-color: var(--ink-40); color: var(--ink); }
.chip-lg { padding: 6px 14px; font-size: 0.78rem; }
/* ---------- 滚动渐入 ---------- */
/* ---------- 滚动渐入:克制的上浮 ---------- */
.reveal {
opacity: 0;
transform: translateY(24px);
transition: opacity .7s cubic-bezier(.2,.8,.2,1), transform .7s cubic-bezier(.2,.8,.2,1);
transform: translateY(14px);
transition: opacity 0.7s var(--ease), transform 0.7s var(--ease);
}
.reveal.in { opacity: 1; transform: none; }
/* ---------- 元数据 ---------- */
/* ---------- 元数据:日期 / 阅读时长 ---------- */
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 14px;
color: var(--ink-faint);
font-size: 0.85rem;
}
.meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: .5; }
/* ---------- 文字工具 ---------- */
.text-display { font-family: var(--font-display); }
.gradient-text {
background: linear-gradient(135deg, var(--blue-600), var(--cyan-400) 60%, var(--blue-500));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
gap: 8px 12px;
color: var(--ink-40);
font-size: 0.75rem;
letter-spacing: 0.12em;
}
.meta .dot { width: 3px; height: 3px; border-radius: 50%; background: currentColor; opacity: 0.6; }
/* ---------- 空状态 ---------- */
.empty {
text-align: center;
padding: 4rem 1rem;
color: var(--ink-faint);
color: var(--ink-40);
}
/* ---------- 工具栏 / 分割 ---------- */
/* ---------- 分割线:细油墨线,悬停加深 ---------- */
.hr-soft {
height: 1px;
border: 0;
background: linear-gradient(90deg, transparent, rgba(79,163,255,.3), transparent);
background: var(--line);
margin: 2.5rem 0;
transition: background-color 0.3s;
}
.hr-soft:hover { background: var(--ink-40); }
/* 响应式断点工具 */
/* ---------- 响应式 ---------- */
@media (max-width: 768px) {
body { font-size: 15px; }
.section { padding-block: clamp(28px, 8vw, 44px); }
.section { padding-block: clamp(48px, 9vw, 72px); }
}

View file

@ -1,124 +1,119 @@
/* ==========================================================================
文章正文排版 · prose
编辑杂志风衬线标题 / 无衬线正文 / 细线分割 / 纯单色
========================================================================== */
.prose {
font-size: clamp(1rem, 0.4rem + 1.6vw, 1.125rem);
line-height: 1.85;
color: var(--ink);
line-height: 1.9;
color: var(--ink-80);
word-wrap: break-word;
overflow-wrap: break-word;
}
.prose > * + * { margin-top: 1.4em; }
/* 标题 */
/* 标题:衬线、常规字重、紧凑字距 */
.prose h2, .prose h3, .prose h4 {
font-family: var(--font-display);
font-weight: 800;
font-weight: 400;
letter-spacing: -0.02em;
color: var(--ink);
scroll-margin-top: calc(var(--nav-h) + 24px);
}
.prose h2 {
font-size: clamp(1.4rem, 1rem + 1.8vw, 1.75rem);
margin-top: 2.2em;
padding-bottom: .4em;
border-bottom: 1px solid rgba(79,163,255,.25);
position: relative;
}
.prose h2::before {
content: '';
display: inline-block;
width: 5px; height: 1em;
margin-right: 12px;
vertical-align: -0.05em;
border-radius: 6px;
background: linear-gradient(180deg, var(--blue-500), var(--cyan-400));
font-size: clamp(1.5rem, 1rem + 2vw, 2rem);
margin-top: 2.4em;
padding-bottom: 0.45em;
border-bottom: 1px solid var(--line);
}
.prose h3 {
font-size: clamp(1.2rem, 0.9rem + 1.2vw, 1.4rem);
margin-top: 1.8em;
color: var(--ink);
font-size: clamp(1.25rem, 0.9rem + 1.3vw, 1.5rem);
margin-top: 2em;
}
.prose h4 { font-size: 1.05rem; margin-top: 1.6em; }
.prose h4 { font-size: 1.1rem; margin-top: 1.8em; }
.prose p { color: var(--ink); }
.prose a { color: var(--blue-600); text-decoration: underline; text-decoration-color: rgba(47,127,224,.35); text-underline-offset: 3px; }
.prose a:hover { text-decoration-color: var(--blue-500); }
.prose p { color: var(--ink-80); }
.prose a {
color: var(--ink);
text-decoration: underline;
text-decoration-color: rgba(28, 28, 28, 0.3);
text-underline-offset: 3px;
transition: text-decoration-color 0.2s;
}
.prose a:hover { text-decoration-color: var(--ink); }
/* 列表 */
.prose ul, .prose ol { padding-left: 1.4em; }
.prose ul { list-style: disc; }
.prose ol { list-style: decimal; }
.prose li { margin-top: .4em; }
.prose li::marker { color: var(--blue-500); }
.prose li { margin-top: 0.4em; }
.prose li::marker { color: var(--ink-40); }
/* 引用 */
/* 引用:左侧细线 + 次要灰 */
.prose blockquote {
padding: 1em 1.4em;
margin: 1.6em 0;
border-left: 4px solid var(--blue-400);
border-radius: 0 var(--r-md) var(--r-md) 0;
background: linear-gradient(90deg, rgba(132,194,255,.12), transparent);
color: var(--ink-soft);
padding: 0.2em 0 0.2em 1.4em;
margin: 1.8em 0;
border-left: 1px solid var(--ink-40);
background: none;
color: var(--ink-60);
font-style: normal;
}
.prose blockquote p { color: var(--ink-soft); }
.prose blockquote p { color: var(--ink-60); }
/* 代码 */
.prose :not(pre) > code {
font-family: var(--font-mono);
font-size: 0.88em;
padding: 2px 7px;
border-radius: 6px;
background: rgba(47,127,224,.12);
color: var(--blue-700);
border: 1px solid rgba(47,127,224,.18);
font-size: 0.86em;
padding: 2px 6px;
border-radius: 0;
background: rgba(28, 28, 28, 0.05);
color: var(--ink);
border: 1px solid rgba(28, 28, 28, 0.08);
word-break: break-word;
}
.prose pre {
padding: 1.15em 1.4em;
border-radius: var(--r-md);
padding: 1.2em 1.4em;
border-radius: 0;
overflow-x: auto;
border: 1px solid var(--glass-border);
background: rgba(255,255,255,.62);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: var(--glass-shadow);
font-size: 0.88rem;
line-height: 1.7;
border: 1px solid var(--line);
background: rgba(28, 28, 28, 0.025);
box-shadow: none;
font-size: 0.86rem;
line-height: 1.75;
}
.prose pre code { font-family: var(--font-mono); background: none; border: 0; padding: 0; color: inherit; }
/* 行内元素 */
.prose strong { font-weight: 800; color: var(--ink); }
.prose strong { font-weight: 600; color: var(--ink); }
.prose em { font-style: italic; }
.prose del { color: var(--ink-faint); }
.prose mark { background: var(--blue-200); color: var(--ink); padding: 1px 4px; border-radius: 4px; }
.prose del { color: var(--ink-40); }
.prose mark { background: var(--ink); color: var(--paper); padding: 1px 5px; border-radius: 0; }
.prose kbd {
font-family: var(--font-mono);
font-size: .85em;
font-size: 0.82em;
padding: 2px 6px;
border-radius: 6px;
background: #fff;
border: 1px solid var(--blue-200);
box-shadow: 0 2px 0 var(--blue-200);
border-radius: 0;
background: transparent;
border: 1px solid var(--ink-20);
box-shadow: none;
color: var(--ink);
}
/* 分割线 */
.prose hr { height: 1px; border: 0; margin: 2.4em 0; background: linear-gradient(90deg, transparent, rgba(79,163,255,.3), transparent); }
.prose hr { height: 1px; border: 0; margin: 2.6em 0; background: var(--line); }
/* 图片 */
/* 图片:细线相框,不投影 */
.prose img {
border-radius: var(--r-md);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
margin: 1.6em auto;
border-radius: 0;
border: 1px solid var(--line);
box-shadow: none;
margin: 1.8em auto;
}
/* 表格 */
.prose table { width: 100%; margin: 1.6em 0; font-size: .92em; overflow-x: auto; display: block; }
.prose thead { background: rgba(132,194,255,.18); }
.prose th, .prose td { padding: .6em .9em; border: 1px solid rgba(79,163,255,.25); text-align: left; }
.prose tbody tr:nth-child(even) { background: rgba(132,194,255,.06); }
.prose table { width: 100%; margin: 1.8em 0; font-size: 0.9em; overflow-x: auto; display: block; }
.prose thead { background: rgba(28, 28, 28, 0.04); }
.prose th, .prose td { padding: 0.6em 0.9em; border: 1px solid var(--line); text-align: left; }
.prose tbody tr:nth-child(even) { background: rgba(28, 28, 28, 0.02); }
/* 移动端:代码块横向滚动不撑破布局 */
@media (max-width: 768px) {

BIN
备案图标.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB