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 部署配置、评论组件入库
4
.gitignore
vendored
|
|
@ -14,3 +14,7 @@ dist/
|
|||
# 编辑器
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# SSH 密钥(严禁入库)
|
||||
repo
|
||||
repo.pub
|
||||
|
|
|
|||
160
deploy/artalk/artalk.yml
Normal 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: []
|
||||
19
deploy/artalk/docker-compose.yml
Normal 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
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/images/LLM-structure.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
public/images/logo.png
Normal file
|
After Width: | Height: | Size: 495 KiB |
BIN
public/images/moe_routing.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
public/images/rope_freqs.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
public/images/rope_position_signal.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
public/images/rope_relative.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
public/images/yarn_ramp.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
|
|
@ -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>
|
||||
239
src/components/Comments.astro
Normal 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>
|
||||
|
|
@ -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’s Blog</div>
|
||||
<div class="footer-sig">记录代码与生活 · 淡蓝色的液态玻璃</div>
|
||||
</div>
|
||||
<span class="footer-name">Yukun’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>
|
||||
|
|
|
|||
|
|
@ -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">’</span>s Blog</span>
|
||||
</a>
|
||||
<a href="/" class="brand">Yukun’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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/* 移动端:折叠 */
|
||||
|
|
|
|||
156
src/content/posts/00-导读.md
Normal 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、蒸馏全流程(05–08)。不仅"读得懂",还能**跑得起来、复现得了**——这是绝大多数"玩具模型"做不到的完整度。
|
||||
|
||||
**一句诚实的边界**: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 偏好对齐
|
||||
```
|
||||
|
||||
- **架构线(01–04)**:回答"模型长什么样、一个 token 如何一步步变成 logits"。这是精读的主干,也是理解一切训练算法的地基。
|
||||
- **训练算法线(05–08)**:回答"如何把模型从随机权重训成会说话"。从数据、优化器、到微调与对齐,逐级而上。
|
||||
|
||||
**建议阅读顺序**:`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}$;行内公式 `$...$`、独立公式 `$$...$$`。
|
||||
- **图例说明**:每篇配 1–2 张自绘示意图(`assets/` 下),均标注其对应节,读时对照。
|
||||
- **致谢**:所有代码、配置与演示均来自 [jingyaogong/minimind](https://github.com/jingyaogong/minimind) 开源项目,特此致谢。
|
||||
|
||||
## 6. 总览导读(如果你只读一段)
|
||||
|
||||
这是全系列的一张"全貌速写":一个输入 token 序列,到自回归预测下一 token,MiniMind 经历了什么。
|
||||
|
||||
```
|
||||
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**
|
||||
- 这一整条回路怎么被训出来?→ **05–08 训练算法线**
|
||||
|
||||
---
|
||||
|
||||
*读完全系列,这个 64M 的小模型在你眼里将不再是黑箱,而是一条条可推演的数学与代码。*
|
||||
601
src/content/posts/01-模型骨架与前向回路.md
Normal 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 原版)就有。但**现代 Transformer(LLaMA / 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 (186–194 行)
|
||||
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 负责把"别的位置的信息"搬运到当前 token,FFN 负责对当前 token 做"单点"非线性变换**。
|
||||
|
||||
### 1.4 读代码的落点
|
||||
|
||||
以后不管读哪一层,盯住 `hidden_states` 这一条线就好:**它始终是你最熟悉的 `[B, S, 768]`**,每个模块都是在它的旁边"叠一个增量"再放回去。词表维度、KV cache、MoE 都是在这条主干道上的"附加设施",不是新路。
|
||||
|
||||
## 2. MiniMindConfig:一个配置,两套架构
|
||||
|
||||
```python
|
||||
# model_minimind.py 10–46 行
|
||||
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.4M(KV 头只有 4 个,k/v 其实是 $768\times384$,又省了一块);
|
||||
- **每层 FFN**:gate/up 为 $768\times2432$ 两份 + down 一份 ≈ 5.6M;
|
||||
- 8 层后加上嵌入与 norm(norm 只有 $d$ 个可学习参数),总额正好落在 **~64M**(MoE 版 198M-A64M 的"A64M"就是激活 64M,第 4 篇给完整对账表)。
|
||||
|
||||
这就是为什么"词表维度只出现在两端"不止是视觉规律——**6400 这个刻意缩小的词表,直接决定了 64M 这个量级**;换成 32k 词表,光嵌入就要 25M。
|
||||
|
||||
## 3. RMSNorm 精读
|
||||
|
||||
```python
|
||||
# model_minimind.py 50–60 行
|
||||
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 下做**,算完再转回原 dtype(bf16/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 136–146 行
|
||||
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 在干什么
|
||||
|
||||
原版 Transformer(2017)的 FFN 只有两个线性层:
|
||||
|
||||
$$
|
||||
\mathrm{FFN}_{\mathrm{orig}}(x)=W_2\,\mathrm{ReLU}(W_1 x)
|
||||
$$
|
||||
|
||||
SwiGLU(Shazeer, 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 178–194 行
|
||||
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 196–232 行
|
||||
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 234–253 行
|
||||
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`(255–288 行)的温度 / 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_head(tie 共享,只计一份) | $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 |
|
||||
| 每层 FFN(gate+up+down) | $3\times768\times2432$ | 5.60M |
|
||||
| **每层 Block 合计** | | **7.37M** |
|
||||
| 8 层 × Block | × 8 | 58.96M |
|
||||
| RMSNorm scale(8×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 头(MHA),k+v 会是 $2\times768\times768=1.18\text{M}$,比现在的 0.59M 多一倍——这就是 [[03-自注意力机制精读]] 要算的账;
|
||||
3. **嵌入占比 7.7%**:不大不小,全靠 6400 的小词表压着([[#2.3 和参数量对账:6400 词表如何撑起 64M]])。
|
||||
|
||||
### 9.2 get_model_params:Dense 与 MoE 的"一鱼两吃"
|
||||
|
||||
MiniMind 打印参数量的工具函数(`trainer_utils.py` 18–28 行)同时服务两种架构:
|
||||
|
||||
```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)→ 标量 loss(shifted 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)。
|
||||
445
src/content/posts/02-RoPE位置编码与YaRN外推.md
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
---
|
||||
title: "现代Transformer精读-02:RoPE位置编码与YaRN外推"
|
||||
date: 2026-08-25
|
||||
tags: ["人工智能", "深度学习", "Transformer"]
|
||||
description: "从绝对位置编码到旋转位置编码:RoPE 的数学、代码实现与 YaRN 长文本外推"
|
||||
draft: false
|
||||
pinned: false
|
||||
heroGradient: ["#7fb8ff", "#2f8df0"]
|
||||
---
|
||||
z# 现代Transformer精读-02:RoPE位置编码与YaRN外推
|
||||
|
||||
> **相关**:[[00-导读]](系列地图,待写) | [[01-模型骨架与前向回路]](本篇代码所在文件) | 下一篇 [[03-自注意力机制精读]]
|
||||
>
|
||||
> 本文精读 `model_minimind.py` 的 `precompute_freqs_cis`(62–78 行)与 `apply_rotary_pos_emb`(80–84 行)。假设你已熟悉**绝对正弦位置编码**与**可训练位置编码**——我们从这两个老朋友出发,一路走到旋转位置编码(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` 的前五行(111–119 行):
|
||||
|
||||
```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-Norm(03 篇详讲)
|
||||
cos, sin = position_embeddings
|
||||
xq, xk = apply_rotary_pos_emb(xq, xk, cos, sin) # ④ RoPE 注入
|
||||
# ⑤ ……之后才是 QKᵀ 内积与 softmax(03 篇)
|
||||
```
|
||||
|
||||
对比三种编码的注入位置:
|
||||
|
||||
| 方案 | 注入点 | 形式 |
|
||||
|---|---|---|
|
||||
| 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-aware(2023) | 只放大 base 中的高频部分,保持高频不动 | 比盲放大好,但高频/低频的划分是硬的 |
|
||||
| **YaRN(2023)** | **软 ramp**:低频维度频率压缩 $1/factor$、高频维度完全保留、中间线性过渡 | 目前的主流选择 |
|
||||
|
||||
YaRN("Yet another RoPE extensioN")的两个关键改进,都可以在 MiniMind 代码里逐行对上:**① 频率重缩放按维度软过渡(ramp);② 注意力的温度补偿(attention_factor)**。
|
||||
|
||||
### 5.3 代码逐行:YaRN 分支
|
||||
|
||||
```python
|
||||
# precompute_freqs_cis 内(69–73 行)
|
||||
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//2−1)` 的边界保护**:`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(<1!) | 1 | ≈0.000148 | 压缩区起点(high) |
|
||||
| 47 | $1.3\times10^{-6}$ | 4.7M | 0.0004(远<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 论文的第三个调节旋钮(见 76–77 行,`freqs_cos/sin *= attn_factor`)。MiniMind 里配 `1.0`(不动),知道它的存在即可。
|
||||
|
||||
### 5.4 MiniMind 的配置与用法
|
||||
|
||||
Config 里的 YaRN 参数(31–39 行):
|
||||
|
||||
```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`(62–78) | [[#3. 代码逐行:precompute_freqs_cis 与 rotate_half]]、[[#5.3]] |
|
||||
| `apply_rotary_pos_emb` / `rotate_half`(80–84) | [[#3.3]] |
|
||||
| `rope_theta` / `max_position_embeddings` | [[#3.1]]、[[#4]] |
|
||||
| `inference_rope_scaling` + `rope_scaling` dict(31–39) | [[#5.4]] |
|
||||
| RoPE 表如何切片进 Attention | [[#4]] + [[01-模型骨架与前向回路#6.3 forward:切片、逐层循环、presents]] |
|
||||
|
||||
**延伸阅读**:RoPE 原始论文 *RoFormer: Enhanced Transformer with Rotary Position Embedding*(Su et al., 2021);YaRN 论文 *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` 切片下工作的,两篇在此交汇。
|
||||
517
src/content/posts/03-自注意力机制精读.md
Normal 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` 类(91–134 行)与 `repeat_kv`(86–89 行)。RoPE 已在 [[02-RoPE位置编码与YaRN外推]] 就位,这里直接"用"它。
|
||||
|
||||
## 0. 整块代码:Attention 类全貌
|
||||
|
||||
先把整块代码完整摆出来,正文每一节都对应其中的几行——读完本篇,这一整块应该能做到"闭眼能默写"。
|
||||
|
||||
```python
|
||||
# model_minimind.py 86–134 行
|
||||
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) # ④ RoPE(02 篇)
|
||||
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
|
||||
```
|
||||
|
||||
**导览表**(行号 → 主题 → 精读节):
|
||||
|
||||
| 代码行 | 主题 | 本文小节 |
|
||||
|---|---|---|
|
||||
| 86–89 `repeat_kv` | GQA 的 KV 广播 | [[#2. 多头 → GQA → repeat_kv]] |
|
||||
| 91–103 `__init__` 投影/头数 | 四个投影与 GQA 头配置 | [[#2]] |
|
||||
| 104–105 `q_norm`/`k_norm` | QK-Norm | [[#3. QK-Norm:RoPE 之前的稳定化]] |
|
||||
| 109 `self.flash` | 是否启用快速注意力 | [[#5. Flash Attention 与手动回退]] |
|
||||
| 113–119 投影→切头→RoPE | 前五步流水线 | [[#1]]、[[#3]] |
|
||||
| 120–123 KV 拼接 | KV Cache | [[#6. KV Cache 与推理形态]] |
|
||||
| 124–126 形状与 Flash 路径 | flash 路径 | [[#5]] |
|
||||
| 127–131 手动路径 | 因果/attention 掩码 | [[#4. 因果掩码与 padding 掩码]] |
|
||||
| 132–134 o_proj/残差 | 输出与形状回收 | [[#7]] |
|
||||
|
||||
## 1. 缩放点积注意力的数学
|
||||
|
||||
### 1.1 Q / K / V:三个投影在演什么戏
|
||||
|
||||
自注意力的输入只有一个:残差流里的 `x`(`[B, S, 768]`,§0 的 ①)。它被三个投影(`q_proj` / `k_proj` / `v_proj`)各映射一份,语义上是三种角色:
|
||||
|
||||
- **Q(Query 查询)**:当前 token "想找出谁跟我相关"——每人提一个问题;
|
||||
- **K(Key 键)**:每个 token 的"被查询标识"——每个位置回答"我是谁",供所有问题比对;
|
||||
- **V(Value 值)**:每个 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 头 | 代表 |
|
||||
|---|---|---|---|
|
||||
| MHA(Multi-Head) | 8 | 8 | 2017 原版 |
|
||||
| MQA(Multi-Query) | 8 | **1** | PaLM(省到极致,质量有损) |
|
||||
| **GQA(Grouped-Query)** | 8 | **4(分 2 组)** | Llama 2/3、Qwen、MiniMind |
|
||||
|
||||
**GQA 的动机**:K、V 头少 → ① K/V 投影参数减半(01 篇 §9.1 的账:每层省 0.59M,8 层省约 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-Norm:RoPE 之前的稳定化
|
||||
|
||||
### 3.1 代码与位置
|
||||
|
||||
```python
|
||||
# __init__(104–105 行),复用 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)
|
||||
|
||||
# forward(117–119 行):在 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=有效 token,0=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
|
||||
|
||||
FlashAttention(Dao et al., 2022)没有减少计算量(仍是 $O(S^2)$),它做的是**IO 优化**:把整个注意力重写成"**分块(tiling)**"算法,让每个块在 GPU 的**片上 SRAM**(几十 KB 的快速缓存)里完成"打分 → softmax → 乘 V"的全部步骤,只把块级结果写回 HBM——打分矩阵 `[B,H,S,S]` **从不整体物化**,显存降到 $O(S)$。
|
||||
|
||||
分块能成立的数学关键是**在线 softmax(online 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:
|
||||
# 手动回退(127–131 行): 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 组** KV(GQA 未广播形态),每次前向只广播一次,兑现了 §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 Cache(GQA 8:4) | ×2 若用 MHA(8: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/token,GQA 砍半 | [[#6.3]] |
|
||||
|
||||
### 7.4 本篇"现代化清单"(续 01 篇的对比表)
|
||||
|
||||
| 组件 | 出处 |
|
||||
|---|---|
|
||||
| GQA 分组查询注意力 | [[#2.2]] |
|
||||
| QK-Norm(Pre-Norm 时代的稳定性补丁) | [[#3. 3. QK-Norm:RoPE 之前的稳定化]] |
|
||||
| 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"的算账都将在此展开。注意力这条线到此闭环。
|
||||
410
src/content/posts/04-稀疏MoE路由与负载均衡.md
Normal 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`(148–176 行)与 config 的 MoE 字段(41–45 行)。01 篇 §2.2 埋的钩子(`use_moe=True` 时 MLP 换 MoE)在此解开——它是模型架构线的收官。
|
||||
|
||||
## 0. 整块代码:MOEFeedForward 全貌
|
||||
|
||||
延续 03 篇"代码先行"的风格,先把整块完整摆出来,正文每一节对应其中几行。
|
||||
|
||||
```python
|
||||
# model_minimind.py 148–176 行
|
||||
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 要解决什么]] |
|
||||
| 156–158 摊平/打分 | 展平与 softmax | [[#2]] |
|
||||
| 159–160 `topk` | top-1 选取 | [[#3. top-1 与 norm_topk_prob]] |
|
||||
| 161 `norm_topk_prob` | 权重重归一 | [[#3]] |
|
||||
| 163–168 逐专家循环 | 稀疏聚合 `index_add_` | [[#4. 稀疏聚合的实现细节]] |
|
||||
| 169–170 零梯度保活 | 训练期未命中专家 | [[#4]] |
|
||||
| 171–175 aux loss | 负载均衡辅助损失 | [[#5. 负载均衡 aux loss]] |
|
||||
| 176 `view` | 形状还原 | [[#1]] |
|
||||
|
||||
## 1. MoE 要解决什么
|
||||
|
||||
### 1.1 一个"看似矛盾"的广告:198M 参数、只激活 64M
|
||||
|
||||
MiniMind 最难理解的一句话:**"MoE 版 198M,激活只有 64M"**。乍一听,模型不是越大越好吗,怎么还有"总参数"和"激活参数"两个数?
|
||||
|
||||
理解它需要一个核心概念——**稀疏激活(sparse activation)**:
|
||||
|
||||
- **总参数**:模型权重文件里存下的全部参数;
|
||||
- **激活参数**:跑一次前向,**实际参与计算**的参数。
|
||||
|
||||
普通 Transformer(Dense)里这两个数相等:每个 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 的 152–153 行):
|
||||
|
||||
```python
|
||||
self.gate = nn.Linear(hidden, num_experts) # ① 路由:决定走哪个专家
|
||||
self.experts = ModuleList([FeedForward(...) for _ in range(num_experts)]) # ② 4 条专家通道
|
||||
```
|
||||
|
||||
每个 token 经过 `gate` 打分(156–163 行),被分配到专家 $E_i$,只走这条通道,最后把专家输出按权重加权回主路——这就是 [[#5. 负载均衡 aux loss]] 那张图里"token → 路由 → 专家 → 聚合"流程。
|
||||
|
||||
### 1.3 为什么只替换 FFN,不动注意力
|
||||
|
||||
MoE 只把 `MiniMindBlock.mlp`(184 行)换成 `MOEFeedForward`,**注意力原封不动**(`self_attn = Attention(config)`)。理由是:
|
||||
|
||||
- FFN 是模型里**参数密度最高**的部分(01 篇 §9.1:FFN 占每层约 76% 参数),是"性价比"最值得稀释的地方——多装几个便宜的 FFN 显然比多装几套注意力划算;
|
||||
- 注意力是**全局依赖**结构([[03-自注意力机制精读#1.1]] 中所有位置两两交互),难以按 token 稀疏化;FFN 是**逐 token 独立**的逐点变换(position-wise),天然适合"每个 token 选不同专家"——这是 MoE 能落地的结构前提。
|
||||
|
||||
### 1.4 三个关键配置(config 41–45 行)
|
||||
|
||||
| 字段 | 取值 | 含义 |
|
||||
|---|---|---|
|
||||
| `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]`(0–3)——这就是"路由决定";
|
||||
- `sorted=False`:只关心"选了谁"和"分数多少",不关心它们之间的排序(对 k=1 无所谓,对 k>1 是性能优化)。
|
||||
|
||||
`top-1` 是 `num_experts_per_tok=1` 的最简形态:**每个 token 走 1 个专家**。这是 MiniMind 的选择;更大的生产型 MoE(Mixtral / 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-1(1 个)**,其余 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 挤向 1–2 个专家,计算资源集中在几个专家上——MoE"用满多个专家"的初衷没实现;
|
||||
2. **多数专家被冷落**:长期收不到梯度的专家被闲置,甚至滑向 [[#4.3]] 的"专家死亡"。
|
||||
|
||||
要打破这个循环,只靠主任务 loss 不够(它不关心"路由是否均匀"),必须**显式加一项"负载均衡"的惩罚**——这就是 aux loss(auxiliary 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)
|
||||
|
||||
- 一个 FFN(SwiGLU,intermediate=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 总参数:198M(4 个专家都存)
|
||||
|
||||
把模型其他部分(注意力和嵌入)加进来(复用 [[01-模型骨架与前向回路#9.1 逐模块对账]] 的账):
|
||||
|
||||
| 部分 | 每层 | 8 层 | 说明 |
|
||||
|---|---|---|---|
|
||||
| Attention | 1.77M | 14.2M | q/k/v/o 投影,GQA 省 k/v([[03-自注意力机制精读#2.5]]) |
|
||||
| MoE(4 专家 + gate) | 22.4M | 179M | **参数大头** |
|
||||
| Embedding(tied) | – | 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]] 稀疏性) |
|
||||
| Embedding(tied) | – | 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 里激活 = 总参 = 64M;MoE 把总参翻到 198M(多买 3 倍的"专家库存"),但每次只结算 1 个——**推理成本和 Dense 64M 同量级,容量却接近 3 倍专家。**
|
||||
|
||||
### 6.5 `get_model_params` 逐行("从总量里减备用专家")
|
||||
|
||||
真实代码(`trainer_utils.py` 18–28 行)**不真跑前向**,而是**用参数总量 + 路由配置直接推出激活参数量**,很巧妙:
|
||||
|
||||
```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) # 0(MiniMind 无共享专家)
|
||||
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`。
|
||||
BIN
src/content/posts/assets/LLM-structure.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
src/content/posts/assets/logo.png
Normal file
|
After Width: | Height: | Size: 495 KiB |
164
src/content/posts/assets/make_figs.py
Normal 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$(0~47)")
|
||||
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)
|
||||
63
src/content/posts/assets/make_moe_fig.py
Normal 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-1(argmax)格子
|
||||
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)
|
||||
BIN
src/content/posts/assets/moe_routing.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
src/content/posts/assets/rope_freqs.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
src/content/posts/assets/rope_position_signal.png
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
src/content/posts/assets/rope_relative.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
src/content/posts/assets/yarn_ramp.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
680
src/content/posts/去噪扩散概率模型DDPM笔记.md
Normal 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 在做什么
|
||||
|
||||
DDPM(Denoising 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 的主线可以概括为:**引入潜变量降低建模难度,用变分下界替代难算的边缘似然,再通过神经网络学习潜变量对应的生成过程**。
|
||||
|
|
@ -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笔记]]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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’s</span><br />Blog
|
||||
Yukun’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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
/* 移动端:目录移到正文上方,上下篇单列 */
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
After Width: | Height: | Size: 1.4 KiB |