/Users/hua/open code/mlx-lora-finetune · Apple M4 16GB · MLX ·
代码基线 完整源码评审包 v2 (2026-09-19)
在 Apple M4 16GB 单机、无 CUDA 的条件下,走通「端侧大模型从微调到从零预训练」的完整链路, 并横向对比 纯 Attention 架构 与 Mamba(SSM)+Attention 混合架构 在 相同数据、相同参数量级下的收敛速度、显存占用与长上下文能力。 项目本身是架构与工程可行性验证,不是产出通用大模型。
mx.compile / mx.checkpoint 行为与 PyTorch 不同,需实测验证梯度与显存。lm-eval-harness,需要 MLX→HF 的格式转换(含 RoPE 置换)。| 目标 | 指标 | 状态 |
|---|---|---|
| A 线 TinyStories 预训练跑完 | MAX_STEPS 56570,loss 12.4 → 1.835,held-out PPL 20.73 → 12.50 | 已达成 |
| B 线 Mamba 混合架构可训练 | MAX_STEPS 30000,当前 step 6800,loss 12.9 → 1.943 | 进行中 |
| 轮班调度 + 看门狗 7×24 稳定 | 连续运行 ≥ 72h,无人工干预,事故自愈 | 已达成 |
| MLX→HF 转换可用于评测 | logits 与 MLX 侧逐元素等价 | 已验证 |
| 8M tokens 里程碑对比评测 | 同数据量下 Mamba 混合 vs Attention 的 loss / 采样质量 | 待执行 |
| 长上下文外推验证 | 训练 256/512 → 推理 2048,观察 PPL 衰减曲线 | 待执行 |
dt_proj.bias 初始化符号与注释相反,dt 实际落在 [2.26, 6.9] 而非 [1e-3, 1e-1],SSM 记忆长度可能被压到接近 1 token,长上下文优势尚未真正验证。opt.state 直接赋值是否生效未做单测;若不生效,每 2h 切片 Adam 动量归零。| 项目名称 | mlx-lora-finetune |
|---|---|
| 项目定位 | Apple Silicon 端侧大模型全流程探索:LoRA 微调 → GRPO 强化学习 → 从零预训练 → Mamba+Attention 混合新架构 |
| 硬件环境 | Apple M4 / 16GB 统一内存 / macOS / 无 CUDA |
| 训练框架 | MLX(Apple 官方数组框架)+ mlx.nn + mlx.optimizers |
| 基座模型 / 分词器 | 分词器复用 models/Qwen3.5-0.8B-4bit(Qwen tokenizer,词表 248077);从零预训练模型为自建 Llama 架构 |
| 模型规模 |
A 线(C线)Llama:0.46B,18 层,hidden 1024,16 heads / 4 KV heads(GQA),head_dim 64,FFN 2816,RoPE θ=10000 B 线(D线)Mamba 混合:0.38B,18 层 = 16 Mamba(SSM) + 2 Attention(第 8/16 层),d_inner 2048,d_state 16,dt_rank 64,conv_kernel 4 |
| 训练方案 |
50/50 算力轮班:alternator.sh 每 2 小时在两线之间切换(SIGTERM 优雅保存 + auto 断点恢复)优化器:AdamW,weight_decay 0.1,grad clip 1.0 LR 调度:200 步 warmup + cosine 衰减到 3e-5 |
| 数据 |
A 线:TinyStories v2(/tmp/tinystories_v2.txt)+ 混合语料,合计 ~67.5M tokensB 线: data_corpus/tokens_200m.npy(uint32 memmap,200M 容量)
|
| 关键约束 |
① 16GB 统一内存 → 分块 CE + fp16 + clip;seq 降到 256 以换取移除 checkpoint 后的显存 ② 无 CUDA → 单卡串行,必须轮班 ③ M 级 token 预算 → 模型处于严重欠训区(token/参数 < 1,Chinchilla 参考为 20) ④ SIGTERM 后需在 180s 内完成检查点落盘(含优化器状态约 4.6GB) |
| 编号 | 文件 | 职责 | 在流程中的位置 |
|---|---|---|---|
| M1 | alternator.sh | 轮班调度器:单例锁、marker 完成态感知、SIGTERM 宽限 + SIGKILL 兜底 | 主控制流顶层循环 |
| M2 | watchdog.py | 电子狗:300s 巡检调度器 / 训练进程存活 + 日志新鲜度,冷却告警 + 自愈 | 旁路监控,作用于 M1 |
| M3 | pretrain.py | A 线:TinyStories 0.46B Llama 预训练(分块 CE / 断点恢复 / SIGTERM 保存) | 轮班 A 片段 |
| M4 | mamba_hybrid.py | B 线:Mamba+Attention 混合架构 0.38B 预训练 | 轮班 B 片段 |
| M5 | send_progress.py | 邮件通知技能:STARTTLS on 25 端口,支持 HTML 正文与附件 | 完成 / 异常 / 告警旁路 |
| M6 | convert_to_hf.py | MLX 权重 → HF Llama 格式(含 RoPE 交错→半分配对置换) | 评估前置 |
SLICE=7200 — 每片段最长秒数(2 小时)LOCK=/tmp/mlx_alternator.lock — 单例锁文件,内容为调度器 PIDrun_slice $1 $2...$N — 第 1 个参数为日志文件路径,其余为「环境变量 + 命令」形式,全部交给 env 执行START_STEP=auto(自动找最新检查点)、MAX_STEPS、PEAK_LR#!/bin/bash
# 轮班调度器 v3: 单例锁 + 完成态跳过 + 日志正确重定向
# 用法: nohup bash alternator.sh > alternator.log 2>&1 &
cd "$(dirname "$0")"
PY=.venv/bin/python
SLICE=7200 # 每片段最长 2 小时
LOCK=/tmp/mlx_alternator.lock
# ---- 单例锁: 已有活着的实例则拒绝启动 ----
if [ -f "$LOCK" ] && kill -0 "$(cat $LOCK)" 2>/dev/null; then
echo "调度器已在运行 (PID $(cat $LOCK)), 拒绝重复启动"
exit 1
fi
echo $$ > "$LOCK"
trap 'rm -f "$LOCK"; exit 0' EXIT INT TERM
run_slice () {
# $1=日志文件 其余=环境变量与命令; 真正重定向 stdout/stderr 到日志
local log="$1"; shift
env "$@" >> "$log" 2>&1 &
local pid=$!
local waited=0
# 等训练退出(完成/异常提前退出立即返回), 最长 SLICE 秒强制切换
while kill -0 "$pid" 2>/dev/null && [ "$waited" -lt "$SLICE" ]; do
sleep 5
waited=$((waited + 5))
done
kill -TERM "$pid" 2>/dev/null # 训练脚本有 SIGTERM 优雅保存
# 等待优雅退出最多 180s (保存 4.6GB 检查点需要时间); 卡死则 SIGKILL 兜底
local grace=0
while kill -0 "$pid" 2>/dev/null && [ "$grace" -lt 180 ]; do
sleep 5
grace=$((grace + 5))
done
if kill -0 "$pid" 2>/dev/null; then
echo "[KILL] 训练未响应 SIGTERM(${grace}s), 强制 SIGKILL"
kill -KILL "$pid" 2>/dev/null
fi
wait "$pid" 2>/dev/null
}
while true; do
# A 线: TinyStories; marker 存在=已完成, 直接跳过不启动进程
if [ -f pretrain_ckpt/.done_56570 ]; then
echo "=== [A] TinyStories 已完成(.done_56570), 跳过 $(date '+%m-%d %H:%M') ==="
else
echo "=== [A] TinyStories $(date '+%m-%d %H:%M') ==="
run_slice pretrain_60m.log \
START_STEP=auto MAX_STEPS=56570 PEAK_LR=8e-5 \
$PY -u pretrain.py train
fi
sleep 5
# B 线: Mamba 混合架构
if [ -f mamba_ckpt/.done_30000 ]; then
echo "=== [B] Mamba 已完成(.done_30000), 跳过 $(date '+%m-%d %H:%M') ==="
else
echo "=== [B] Mamba $(date '+%m-%d %H:%M') ==="
run_slice mamba_train.log \
START_STEP=auto MAX_STEPS=30000 PEAK_LR=1.2e-4 \
$PY -u mamba_hybrid.py train
fi
sleep 5
done
nohup .venv/bin/python -u watchdog.py > watchdog.log 2>&1 & 启动BASE — 脚本所在目录,作为所有相对路径基准LOCK — /tmp/mlx_alternator.lock,与 M1 共享COOLDOWN=1800 — 同类告警冷却秒数send_progress.send(subject, html_body) 发信"""电子狗 v2: 锁感知 + 告警冷却 (同事故 30 分钟内不重复发)
用法: nohup .venv/bin/python -u watchdog.py > watchdog.log 2>&1 &
巡检 300s; 规则:
1. 锁文件存在但 PID 死亡 -> 告警"调度器异常退出" + 清锁重启 (一次性)
2. 无锁且无 alternator 进程 -> 视为调度器未运行, 静默拉起 (不算事故)
3. 调度器活着但连续 2 轮无训练进程 -> 告警"训练异常" + 重启调度器 (带 30min 冷却)
4. 训练进程在但日志静默 >30min -> 告警"疑似卡死" (带 30min 冷却, 不自动杀)
"""
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
BASE = os.path.dirname(os.path.abspath(__file__))
LOCK = '/tmp/mlx_alternator.lock'
COOLDOWN = 1800 # 同类告警冷却 30 分钟
_last_alert = {}
def procs():
return subprocess.run(['ps', 'aux'], capture_output=True, text=True).stdout
def alert(key, title, body):
now = time.time()
if now - _last_alert.get(key, 0) < COOLDOWN:
print(f'[告警冷却中] {title}', flush=True)
return
_last_alert[key] = now
try:
import send_progress
send_progress.send(title, f'{body}
')
print(f'[告警] {title}', flush=True)
except Exception as e:
print(f'[告警失败] {e}', flush=True)
def alternator_alive():
if not os.path.exists(LOCK):
return False
try:
pid = int(open(LOCK).read().strip())
os.kill(pid, 0)
return True
except (ValueError, ProcessLookupError, PermissionError):
return False
def training_alive():
ps = procs()
return ('pretrain.py train' in ps) or ('mamba_hybrid.py train' in ps)
def restart_alternator():
subprocess.Popen(['nohup', 'bash', 'alternator.sh'],
cwd=BASE,
stdout=open(f'{BASE}/alternator.log', 'a'),
stderr=subprocess.STDOUT)
def main():
print('电子狗 v2 启动: 300s 巡检, 告警冷却 30min', flush=True)
no_training_rounds = 0
while True:
time.sleep(300)
try: # 主循环保护: 任何异常不能杀死看门狗
ps = procs()
alt_bash = 'alternator.sh' in ps
lock_ok = alternator_alive()
training = training_alive()
if not alt_bash and not lock_ok:
# 调度器没在跑: 静默拉起 (锁文件不存在不算事故)
restart_alternator()
print('[自愈] 调度器未运行, 已拉起', flush=True)
no_training_rounds = 0
continue
if alt_bash and not lock_ok:
alert('lock', '【mlx-lora-finetune】调度器异常退出',
'alternator.sh 进程消失(锁残留), 已自动重启。请检查 alternator.log。')
restart_alternator()
no_training_rounds = 0
continue
if lock_ok and not alt_bash:
# 有锁但进程没了: 说明启动中崩溃, 清锁重启
os.remove(LOCK)
restart_alternator()
print('[自愈] 调度器带死锁运行, 已重启', flush=True)
continue
# 调度器健康, 检查训练进程
if not training:
no_training_rounds += 1
if no_training_rounds >= 2: # 10 分钟无训练 (排除 5s 切换间隙)
alert('training', '【mlx-lora-finetune】训练异常告警',
'调度器存活但连续 10 分钟无训练进程(两片段均异常退出), 已自动重启调度器。'
'请检查 alternator.log 与 pretrain/mamba 日志。')
restart_alternator()
no_training_rounds = 0
else:
no_training_rounds = 0
# 日志新鲜度: 只检查未完成线的活跃日志 (A 完成 marker 存在则只看 mamba)
import glob
logs = []
if not os.path.exists(f'{BASE}/pretrain_ckpt/.done_56570'):
logs += glob.glob(f'{BASE}/pretrain_60m.log')
if not os.path.exists(f'{BASE}/mamba_ckpt/.done_30000'):
logs += glob.glob(f'{BASE}/mamba_train.log')
if training and logs:
stale = max(time.time() - os.path.getmtime(l) for l in logs)
if stale > 1800:
alert('stale', '【mlx-lora-finetune】训练疑似卡死',
f'训练进程存在但日志静默 {stale/60:.0f} 分钟。'
f'请人工 sample 检查(看门狗不自动杀进程)。')
except Exception as e:
# 看门狗自身异常: 打印并存活 (send 走 SMTP 可能网络抖动)
import traceback
print(f'[看门狗巡检异常, 已存活] {e}\n{traceback.format_exc()[-500:]}', flush=True)
if __name__ == '__main__':
main()
python pretrain.py pretokenize 或 python pretrain.py train(默认 train)CORPUS — 原始文本路径,默认 /tmp/tinystories_v2.txtTOKENS_BIN — token memmap 输出路径,默认 data_corpus/tokens_200m.npy(uint32)PRETOK_N — pretokenize 目标 token 数,默认 30,000,000START_STEP — auto 或整数,auto 时取最新检查点 stepMAX_STEPS — 总步数,默认 52500(调度器传 56570)PEAK_LR — 峰值学习率,默认 1.2e-4(调度器传 8e-5)CONF 字典:vocab_size 248077 / hidden 1024 / layers 18 / heads 16 / kv_heads 4 / head_dim 64 / ffn 2816 / rope_theta 10000.0bs=2,sl=512,clip=1.0,AdamW weight_decay 0.1# ---------- 数据 ----------
def line_is_junk(line):
"""行级退化过滤: 全行扫描, 同字符连跑>30 或 多样性过低"""
if len(set(line)) < max(6, len(line) // 25):
return True
run, best = 1, 1
for a, b in zip(line, line[1:]):
run = run + 1 if a == b else 1
best = max(best, run)
if best > 30:
return True
return False
def pretokenize(max_tokens=None):
if max_tokens is None:
max_tokens = int(os.environ.get('PRETOK_N', '30000000'))
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(TOKENIZER_DIR)
arr = np.memmap(TOKENS_BIN, dtype=np.uint32, mode='w+', shape=(max_tokens,))
pos = 0
buf = []
with open(DATA_TXT) as f:
for line in f:
if line_is_junk(line):
continue
ids = tok(line, add_special_tokens=False)['input_ids'] + [tok.eos_token_id]
vs = c['vocab_size'] if (c := CONF) else 151936
ids = [i for i in ids if i < vs] # 词表越界过滤, 防 NaN
if ids and ids[-1] == tok.eos_token_id:
pass
else:
ids.append(tok.eos_token_id)
buf.extend(ids)
if len(buf) >= 200_000:
take = min(len(buf), max_tokens - pos)
arr[pos:pos + take] = buf[:take]
pos += take
buf = buf[take:]
if pos >= max_tokens:
break
print(f' {pos/1e6:.1f}M tokens', flush=True)
print(f'pretokenize 完成: {pos} tokens', flush=True)
def get_batch(arr, bs, sl, rng):
"""从 memmap 随机采样 bs 条长度 sl 的序列, y 为右移一位的标签"""
ix = rng.integers(0, len(arr) - sl - 1, size=bs)
x = mx.array(np.stack([arr[i:i + sl] for i in ix]).astype(np.int32))
y = mx.array(np.stack([arr[i + 1:i + sl + 1] for i in ix]).astype(np.int32))
return x, y
model — 实现了 hidden(tokens) 的模型对象,返回 [B, S, hidden]x — 输入 token,形状 [B, S],dtype int32y — 目标 token(x 右移一位),形状 [B, S],dtype int32B * S 归一化 def loss_fn(model, x, y):
h = model.hidden(x) # [B,S,1024] 仅 17MB
B, S, _ = h.shape
w = model.embed.weight.T # 绑定 embedding, 不再额外存 lm_head
total = mx.zeros(())
chunk = 128
for i in range(0, S, chunk): # 分块: 每 128 token 一片
logits = (h[:, i:i + chunk] @ w).astype(mx.float16)
logits = mx.clip(logits, -30.0, 30.0).astype(mx.float32) # fp16 溢出保险丝
total = total + nn.losses.cross_entropy(logits, y[:, i:i + chunk]).sum()
return total / (B * S)
def train():
os.makedirs(CKPT_DIR, exist_ok=True)
c = CONF
model = Model(c)
mx.eval(model.parameters())
nparams = sum(p.size for _, p in tree_flatten(model.parameters()))
print(f'模型参数: {nparams/1e6:.1f}M', flush=True)
arr = np.memmap(TOKENS_BIN, dtype=np.uint32, mode='r')
rng = np.random.default_rng(0)
max_steps = int(os.environ.get('MAX_STEPS', '52500'))
start_step = os.environ.get('START_STEP', '30000')
if start_step == 'auto': # 自动找最新检查点
import glob
fs = glob.glob(f'{CKPT_DIR}/weights_*.safetensors')
start_step = max((int(x.split('_')[-1].split('.')[0]) for x in fs), default=0)
else:
start_step = int(start_step)
if start_step >= max_steps: # 已完成目标: 干净退出防崩溃循环
marker = f'{CKPT_DIR}/.done_{max_steps}'
if not os.path.exists(marker): # 完成通知只发一次, 防轮班邮件轰炸
notify('【mlx-lora-finetune】TinyStories 训练已完成目标, 无需恢复',
f'auto 恢复到 step {start_step} >= MAX_STEPS {max_steps}。要继续请调大 MAX_STEPS。')
open(marker, 'w').write('done')
print('已达标, 干净退出', flush=True)
return
peak_lr = float(os.environ.get('PEAK_LR', '1.2e-4'))
ckpt = f'{CKPT_DIR}/weights_{start_step}.safetensors'
if os.path.exists(ckpt):
loaded = mx.load(ckpt)
model.load_weights(list(loaded.items()))
mx.eval(model.parameters())
print(f'从 {ckpt} 恢复权重', flush=True)
remaining = max_steps - start_step
warmup = lambda it: min((it + 1) / 200, 1.0)
base = cosine_decay(peak_lr, remaining, 3e-5)
lr_schedule = lambda it: warmup(it) * base(it)
opt = AdamW(learning_rate=lr_schedule, weight_decay=0.1)
bs, sl = 2, 512
clip = 1.0 # max grad norm
loss_and_grad = nn.value_and_grad(model, loss_fn)
os.makedirs(CKPT_DIR, exist_ok=True)
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(TOKENIZER_DIR)
cur = {'step': start_step, 'model': model}
def on_term(signum, frame):
"""SIGTERM 优雅保存: 调度器切片到时发信号, 先落盘再退出"""
mx.save_safetensors(f'{CKPT_DIR}/weights_{cur["step"]}.safetensors',
dict(tree_flatten(cur['model'].parameters())))
print(f'[SIGTERM] 已保存 step {cur["step"]}, 退出', flush=True)
sys.exit(0)
signal.signal(signal.SIGTERM, on_term)
t0 = time.time()
tokens_done = 0
for step in range(start_step + 1, max_steps + 1):
x, y = get_batch(arr, bs, sl, rng)
(l, grads) = loss_and_grad(model, x, y)
if not mx.isfinite(l).item(): # NaN 跳批防护
print(f'step {step}: loss 非有限, 跳过', flush=True)
continue
grads, gnorm = clip_grad_norm(grads, clip)
opt.update(model, grads)
state = [model.state, opt.state]
mx.eval(state, l, gnorm) # 惰性求值: 此处才真正执行计算
cur['step'] = step
tokens_done += bs * sl
if step % 20 == 0:
tps = tokens_done / (time.time() - t0)
print(f'step {step}: loss {l.item():.3f}, gnorm {float(gnorm.item()):.2f}, '
f'{tps:.0f} tok/s, 累计 {tokens_done/1e6:.2f}M tokens', flush=True)
if step % 1500 == 0 or step == max_steps:
mx.save_safetensors(
f'{CKPT_DIR}/weights_{step}.safetensors',
dict(tree_flatten(model.parameters())),
)
import glob
olds = sorted(glob.glob(f'{CKPT_DIR}/weights_*.safetensors'),
key=lambda x: int(x.split("_")[-1].split(".")[0]))
for old in olds[:-2]: # 滚动保留最近 2 个
os.remove(old)
sample(model, tok, f'--- step {step} 采样 ---')
print('训练完成', flush=True)
notify('【mlx-lora-finetune】TinyStories 预训练完成',
f'累计 {tokens_done/1e6:.1f}M tokens, step {max_steps}, 检查点见 pretrain_ckpt/。日志: pretrain_60m.log')
if __name__ == '__main__':
cmd = sys.argv[1] if len(sys.argv) > 1 else 'train'
if cmd == 'pretokenize':
pretokenize()
elif cmd == 'train':
try:
train()
except SystemExit:
raise
except Exception as e: # 异常邮件钩子: 含 traceback 尾部
import traceback
notify('【mlx-lora-finetune】TinyStories 预训练异常告警',
f'异常: {e}\n\n{traceback.format_exc()[-2000:]}\n\n日志: pretrain_60m.log')
raise
x_proj 死参数接入标准 Mamba 路径、conv 改恒等初始化、移除导致梯度恒零的 mx.checkpoint。
同时新增模型 + 优化器状态一体化保存、坏档自动降级、磁盘水位防线。
python mamba_hybrid.py pretokenize 或 python mamba_hybrid.py train(默认 train)START_STEP — auto 或整数;auto 时经 valid_ckpts() 严格匹配 weights_<数字>.safetensorsMAX_STEPS — 默认 30000PEAK_LR — 默认 1.2e-4CONF 字典:vocab_size 248077 / hidden 1024 / layers 18 / heads 16 / kv_heads 4 / head_dim 64 / ffn 2816 / d_inner 2048 / d_state 16 / dt_rank 64 / conv_kernel 4 / rope_theta 10000.0bs=2,sl=256(移除 checkpoint 后控内存),clip=1.0,AdamW weight_decay 0.1data_corpus/tokens_200m.npy(uint32 memmap)save_all(path, model, opt, step) — 原子写模型 + 优化器状态 + stepload_all(path, model, opt) — 配套恢复,返回恢复的模型 tensor 数dt_proj.bias 当前写为 -np.log(np.exp(lin) - 1)。目标是让 softplus(bias) ∈ [1e-3, 1e-1],
正确解应为 +np.log(np.expm1(lin))(约 [-6.9, -2.25])。当前符号取反后 softplus(bias) ∈ [2.26, 6.9],
dt 被放大 10~100 倍,离散化 A 会迅速归零,SSM 记忆长度可能被压到接近 1 token。此点已列入「待验证假设」。
# ---------------- Mamba SSM ----------------
def _ssm_step(h, xt, Bt, Ct, dt, negA, Dv):
"""单步递推: A_bar=exp(dt·A), B_bar=dt·B; y=C·h+D·x (D 项仅此处一次)
negA/Dv 作为显式参数传入 -> mx.compile 全局一次编译, 所有层复用"""
d = mx.exp(dt[..., None] * negA) # [B,Di,DS] 离散化 A
new = d * h + dt[..., None] * Bt[:, None, :] * xt[..., None]
y = (new * Ct[:, None, :]).sum(-1) + Dv * xt # [B,Di]
return new, y
_SSM_STEP = mx.compile(_ssm_step) # 模块级: 避免每层每步重复编译
def ssm_scan(x, B_s, C_s, delta, A, Dv):
"""选择性 SSM 逐token递推
x: [B,T,Di] B/C: [B,T,DS] delta: [B,T,Di]
A: [Di,DS] 已为负值(调用方传 -exp(A_log), 本函数不再取 exp —— 修复双重exp)
返回: [B,T,Di]
"""
Bb, T, Di = x.shape
h = mx.zeros((Bb, Di, B_s.shape[-1]), dtype=x.dtype)
ys = []
for t in range(T):
h, y = _SSM_STEP(h, x[:, t], B_s[:, t], C_s[:, t], delta[:, t], A, Dv)
ys.append(y)
return mx.stack(ys, axis=1)
def causal_conv1d(x, weight, bias):
"""深度可分离因果卷积 k=4: x[B,L,C], weight[C,K]"""
B, L, C = x.shape
K = weight.shape[-1]
pad = mx.concatenate([mx.zeros((B, K - 1, C), x.dtype), x], axis=1)
y = mx.zeros((B, L, C), x.dtype)
for k in range(K):
y = y + pad[:, k:L + k, :] * weight[None, None, :, k]
return y + bias
class MambaBlock(nn.Module):
"""选择性 SSM 块: 线性复杂度 O(T), 推理时 O(1) 显存/token"""
def __init__(self, c):
super().__init__()
d, di, ds, r = c['hidden'], c['d_inner'], c['d_state'], c['dt_rank']
K = c['conv_kernel']
# in_proj 输出: [x(di) | z(di) | B(ds) | C(ds) | dt(r)]
self.in_proj = nn.Linear(d, 2 * di + 2 * ds + r, bias=False)
# 恒等初始化: 只有最后一个 tap=1, 初始时 conv=恒等映射 (原来0.5*4会放大2倍)
w = np.zeros((di, K)); w[:, -1] = 1.0
self.conv_weight = mx.array(w)
self.conv_bias = mx.zeros(di)
self.x_proj = nn.Linear(di, r + 2 * ds, bias=False)
self.dt_proj = nn.Linear(r, di, bias=True)
# S4D-real 初始化: A = -arange(1..ds)
self.A_log = mx.log(mx.tile(mx.arange(1, ds + 1, dtype=mx.float32), (di, 1)))
self.D = mx.ones(di)
self.out_proj = nn.Linear(di, d, bias=False)
# dt_bias 初始化: 使 softplus 后分布于 [1e-3, 1e-1]
lin = np.linspace(1e-3, 1e-1, di)
self.dt_proj.bias = mx.array(-np.log(np.exp(lin) - 1))
self.c = {'di': di, 'ds': ds, 'r': r} # 维度存dict: MLX Module的int实例属性会被串写!
def __call__(self, x):
di, ds = self.c['di'], self.c['ds']
# 标准 Mamba: in_proj 产生 [x, z]; x 经 conv+silu; B/C/dt 由 x_proj(x_conv) 产生
# (自审修复: 原 x_proj 是死参数, B/C 直接来自 in_proj 尾部切片偏离选择性设计)
zx = self.in_proj(x)
xv, z = zx[..., :di], zx[..., di:2 * di]
xv = nn.silu(causal_conv1d(xv, self.conv_weight, self.conv_bias))
dtr, Bq, Cq = mx.split(self.x_proj(xv), [self.c['r'], self.c['r'] + ds], axis=-1)
dt = nn.softplus(self.dt_proj(dtr)) # [B,T,Di]
# A = -exp(A_log) 此处取一次即可 (ssm_scan 内不再 exp); D 项在 ssm_scan 内加, 此处不重复
y = ssm_scan(xv, Bq, Cq, dt, -mx.exp(self.A_log), self.D)
return self.out_proj(y * nn.silu(z))
class MambaBlock_(nn.Module):
"""Mamba 块外壳: PreNorm + 残差
自审发现: mx.checkpoint 对闭包捕获参数 (a)梯度恒为零 (b)前向值错误
-> 此前 16 个 Mamba 层实际从未被训练! 已移除 checkpoint, 用 seq=256 控内存"""
def __init__(self, c):
super().__init__()
self.mixer = MambaBlock(c)
self.norm = nn.RMSNorm(c['hidden'])
def __call__(self, x):
return x + self.mixer(self.norm(x))
def build_layers(c):
"""16 Mamba + 2 Attention 交错 (第 8/16 层为 Attention)"""
layers = []
for i in range(c['layers']):
if (i + 1) % 8 == 0:
layers.append(AttnBlock(c))
else:
layers.append(MambaBlock_(c))
return layers
class Model(nn.Module):
def __init__(self, c):
super().__init__()
self.embed = nn.Embedding(c['vocab_size'], c['hidden'])
self.layers = build_layers(c)
self.norm = nn.RMSNorm(c['hidden'])
self.c = c
def hidden(self, tokens):
x = self.embed(tokens)
for l in self.layers:
x = l(x)
return self.norm(x)
def __call__(self, tokens):
return self.hidden(tokens) @ self.embed.weight.T
m.)、优化器动量/二阶矩(前缀 o.)和全局 step 一起落盘。
采用「临时文件 + os.replace」实现原子替换,保证任何时刻磁盘上只有完整的旧档或新档,不会出现半截文件。
valid_ckpts() 用正则严格匹配 weights_<数字>.safetensors,自动排除 _tmp 与 .corrupt。
save_all(path, model, opt, step) — path 必须以 .safetensors 结尾;同时写 CKPT_DIR/last_step.txtload_all(path, model, opt) — 返回恢复的模型 tensor 数;若加载失败,调用方会把坏档重命名为 .corrupt 并降级到次新档valid_ckpts() — 返回 [(step, filepath), ...],按 step 升序latest_ckpt_step() — 返回最新 step,无档返回 0def save_all(path, model, opt, step):
"""模型+优化器状态+步数 一起存; 原子写: 临时文件+rename, 不留半截档"""
d = {}
for k, v in tree_flatten(model.parameters()):
d['m.' + k] = v
for k, v in tree_flatten(opt.state):
d['o.' + k] = v if hasattr(v, 'astype') else mx.array(v)
tmp = path.replace('.safetensors', '_tmp.safetensors')
mx.save_safetensors(tmp, d)
os.replace(tmp, path) # 原子替换: 要么旧档要么新档, 无中间态
with open(f'{CKPT_DIR}/last_step.txt', 'w') as f:
f.write(str(step))
def load_all(path, model, opt):
"""配套恢复: 模型权重 + 优化器动量/二阶矩"""
from mlx.utils import tree_unflatten
w = dict(mx.load(path).items())
model.load_weights([(k[2:], v) for k, v in w.items() if k.startswith('m.')])
o_items = [(k[2:], v) for k, v in w.items() if k.startswith('o.')]
if o_items:
opt.state = tree_unflatten(o_items)
mx.eval(model.parameters())
return len([k for k in w if k.startswith('m.')])
def valid_ckpts():
"""严格匹配 weights_<数字>.safetensors, 排除 _tmp/.corrupt, 按 step 升序"""
import glob, re
out = []
for f in glob.glob(f'{CKPT_DIR}/weights_*.safetensors'):
m = re.fullmatch(r'weights_(\d+)\.safetensors', os.path.basename(f))
if m:
out.append((int(m.group(1)), f))
return sorted(out)
def train():
os.makedirs(CKPT_DIR, exist_ok=True)
c = CONF
model = Model(c)
start_step = os.environ.get('START_STEP', '0')
max_steps = int(os.environ.get('MAX_STEPS', '30000'))
if start_step == 'auto': # 自动找最新检查点
start_step = latest_ckpt_step()
else:
start_step = int(start_step)
if start_step >= max_steps: # 已完成目标: 干净退出防崩溃循环
marker = f'{CKPT_DIR}/.done_{max_steps}'
if not os.path.exists(marker): # 通知只发一次, 防轮班邮件轰炸
notify('【mlx-lora-finetune】Mamba 混合架构训练已完成目标, 无需恢复',
f'auto 恢复到 step {start_step} >= MAX_STEPS {max_steps}。要继续请调大 MAX_STEPS。')
open(marker, 'w').write('done')
print('已达标, 干净退出', flush=True)
return
peak_lr = float(os.environ.get('PEAK_LR', '1.2e-4'))
# LR 调度: 以"全局步"计算, warmup 只在真正前200步生效,
# cosine 覆盖 0->max_steps 全程, 不因切片恢复重置
def lr_at(g):
warm = min((g + 1) / 200, 1.0)
progress = min(g / max(max_steps - 1, 1), 1.0)
cos = 0.5 * (1.0 + math.cos(math.pi * progress))
return mx.array(warm * (3e-5 + (peak_lr - 3e-5) * cos)) # 必须mx.array
lr_schedule = lr_at
opt = AdamW(learning_rate=lr_schedule, weight_decay=0.1)
# 按步数降序逐个尝试加载, 坏档自动降级到次新档, 全坏从头训
if start_step > 0:
cands = [f for _, f in reversed(valid_ckpts())]
restored = False
for cand in cands:
try:
n = load_all(cand, model, opt)
start_step = int(cand.split('_')[-1].split('.')[0])
print(f'从 {cand} 恢复 ({n} tensors, 含优化器状态)', flush=True)
restored = True
break
except Exception as e:
print(f'[坏档降级] {cand}: {e}', flush=True)
os.rename(cand, cand + '.corrupt')
if not restored:
start_step = 0
print('所有检查点损坏, 从头训练', flush=True)
mx.eval(model.parameters())
nparams = sum(p.size for _, p in tree_flatten(model.parameters()))
print(f'MambaHybrid 参数: {nparams/1e6:.1f}M', flush=True)
arr = np.memmap('data_corpus/tokens_200m.npy', dtype=np.uint32, mode='r')
rng = np.random.default_rng(0)
bs, sl = 2, 256 # seq 256: 无 checkpoint 后控内存 (512 时 ~13.6GB)
loss_and_grad = nn.value_and_grad(model, loss_fn)
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(D.TOKENIZER_DIR)
cur = {'step': start_step}
def on_term(signum, frame):
"""SIGTERM 优雅保存: 模型 + 优化器状态 + step 一并落盘"""
save_all(f'{CKPT_DIR}/weights_{cur["step"]}.safetensors',
model, opt, cur['step'])
print(f'[SIGTERM] 已保存 step {cur["step"]} (含优化器), 退出', flush=True)
sys.exit(0)
signal.signal(signal.SIGTERM, on_term)
t0, tokens_done = time.time(), 0
for step in range(start_step + 1, max_steps + 1):
x, y = D.get_batch(arr, bs, sl, rng)
(l, grads) = loss_and_grad(model, x, y)
if not mx.isfinite(l).item(): # NaN 跳批
print(f'step {step}: 非有限, 跳过', flush=True)
continue
grads, gnorm = clip_grad_norm(grads, 1.0)
opt.update(model, grads)
state = [model.state, opt.state]
mx.eval(state, l, gnorm) # 惰性求值: 此处才真正执行
tokens_done += bs * sl
cur['step'] = step
if step % 20 == 0:
print(f'step {step}: loss {l.item():.3f}, lr {lr_at(step):.2e}, '
f'{tokens_done / (time.time() - t0):.0f} tok/s', flush=True)
if step % 250 == 0 or step == max_steps:
# 磁盘水位防线: 4.6GB/档, 剩余<15GB先删最老档腾位, <8GB跳过保存(不崩溃)
import shutil
free_gb = shutil.disk_usage(CKPT_DIR).free / 1024**3
if free_gb < 15:
olds = valid_ckpts()
for _, f in olds[:-1]: # 保留最新一个, 删其余最老
os.remove(f)
free_gb = shutil.disk_usage(CKPT_DIR).free / 1024**3
if free_gb >= 15:
break
if free_gb < 8:
print(f'[磁盘不足] 剩{free_gb:.1f}GB, 跳过本档保存', flush=True)
else:
save_all(f'{CKPT_DIR}/weights_{step}.safetensors', model, opt, step)
# 滚动保留最近 3 个 (严格枚举)
olds = valid_ckpts()
for _, f in olds[:-3]:
os.remove(f)
sample(model, tok, f'--- step {step} 采样 ---')
print('训练完成', flush=True)
notify('【mlx-lora-finetune】Mamba 混合架构训练完成',
f'step {max_steps}, 检查点见 mamba_ckpt/。日志: mamba_train.log')
if __name__ == '__main__':
cmd = sys.argv[1] if len(sys.argv) > 1 else 'train'
if cmd == 'pretokenize':
D.pretokenize()
else:
try:
train()
except SystemExit:
raise
except Exception as e: # 异常邮件钩子: 含 traceback
import traceback
notify('【mlx-lora-finetune】Mamba 混合架构训练异常告警',
f'异常: {e}\n\n{traceback.format_exc()[-2000:]}\n\n日志: mamba_train.log')
raise
subject — 邮件标题字符串html_body — HTML 正文;M2 调用时会包一层 <p>...</p>attachment_path — 可选,附件绝对路径;为 None 时不带附件FROM_ADDR / TO_ADDR / SMTP_USER / SMTP_PASSdef send(subject, html_body, attachment_path=None):
msg = MIMEMultipart("mixed")
msg["Subject"], msg["From"], msg["To"] = subject, FROM_ADDR, TO_ADDR
alt = MIMEMultipart("alternative")
alt.attach(MIMEText(html_body, "html", "utf-8"))
msg.attach(alt)
if attachment_path:
with open(attachment_path, "rb") as f:
att = MIMEApplication(f.read())
att.add_header("Content-Disposition", "attachment",
filename=("utf-8", "", attachment_path.split("/")[-1]))
msg.attach(att)
ctx = ssl.create_default_context()
with smtplib.SMTP("smtp.163.com", 25, timeout=30) as s: # 25端口 STARTTLS
s.ehlo(); s.starttls(context=ctx); s.ehlo()
s.login(SMTP_USER, SMTP_PASS)
s.sendmail(FROM_ADDR, [TO_ADDR], msg.as_string())
lm-eval-harness 可以直接加载评估。
核心是 RoPE 的坐标置换:MLX 侧用交错配对 (2i, 2i+1),HF 侧用半分配对 (i, i+D/2),
对 q_proj / k_proj 的权重按 head 维度重排即可。转换后已实测 logits 与 MLX 侧等价。
同时写出 config.json / generation_config.json,并从 Qwen 目录拷贝 tokenizer 文件。
python convert_to_hf.py <mlx_safetensors> <out_dir>mlx_safetensors — 例如 pretrain_ckpt/weights_8000.safetensorsout_dir — 输出目录,例如 models/hf_46b_8k,不存在会自动创建MX_CONF — 与 pretrain.py 的 CONF 必须一致(vocab_size 248077 / hidden 1024 / layers 18 / heads 16 / kv_heads 4 / head_dim 64 / ffn 2816 / rope_theta 10000.0)TOKENIZER_SRC — models/Qwen3.5-0.8B-4bitpermute_rope(wt, heads, head_dim) — 对单层 q/k 权重做交错→半分配对重排config.json 写入 eos_token_id: 2,但 generation_config.json 与 tokenizer 的 eos 是 248046。
两者不一致会导致 HF 生成时提前停止或停不下来,评估前需要对齐。
MX_CONF = dict(vocab_size=248077, hidden=1024, layers=18, heads=16,
kv_heads=4, head_dim=64, ffn=2816, rope_theta=10000.0)
TOKENIZER_SRC = 'models/Qwen3.5-0.8B-4bit'
def convert(mlx_safetensors, out_dir):
os.makedirs(out_dir, exist_ok=True)
w = dict(mx.load(mlx_safetensors).items())
out = {}
out['model.embed_tokens.weight'] = w['embed.weight']
out['model.norm.weight'] = w['norm.weight']
def permute_rope(wt, heads, head_dim):
# 交错配对 (2i, 2i+1) -> 半分配对 (i, i+D/2): Llama->HF 经典置换
import numpy as np
wa = wt.reshape(heads, head_dim, -1)
idx = list(range(0, head_dim, 2)) + list(range(1, head_dim, 2))
wa = wa[:, idx, :]
return mx.array(wa.reshape(heads * head_dim, -1))
for i in range(MX_CONF['layers']):
p = f'layers.{i}.'
h = f'model.layers.{i}.'
out[h + 'self_attn.q_proj.weight'] = permute_rope(
w[p + 'attn.q_proj.weight'], MX_CONF['heads'], MX_CONF['head_dim'])
out[h + 'self_attn.k_proj.weight'] = permute_rope(
w[p + 'attn.k_proj.weight'], MX_CONF['kv_heads'], MX_CONF['head_dim'])
out[h + 'self_attn.v_proj.weight'] = w[p + 'attn.v_proj.weight']
out[h + 'self_attn.o_proj.weight'] = w[p + 'attn.o_proj.weight']
out[h + 'mlp.gate_proj.weight'] = w[p + 'ffn.gate_proj.weight']
out[h + 'mlp.up_proj.weight'] = w[p + 'ffn.up_proj.weight']
out[h + 'mlp.down_proj.weight'] = w[p + 'ffn.down_proj.weight']
out[h + 'input_layernorm.weight'] = w[p + 'n1.weight']
out[h + 'post_attention_layernorm.weight'] = w[p + 'n2.weight']
# lm_head 单独存 (Hermes/llama 支持 tie; 显式写出更稳)
out['lm_head.weight'] = w['embed.weight']
out = {k: v.astype(mx.bfloat16) for k, v in out.items()}
mx.save_safetensors(f'{out_dir}/model.safetensors', out)
conf = {
'architectures': ['LlamaForCausalLM'],
'model_type': 'llama',
'vocab_size': 248077,
'hidden_size': 1024,
'intermediate_size': 2816,
'num_hidden_layers': 18,
'num_attention_heads': 16,
'num_key_value_heads': 4,
'head_dim': 64,
'rms_norm_eps': 1e-5,
'rope_theta': 10000.0,
'rope_scaling': None,
'max_position_embeddings': 1024,
'tie_word_embeddings': False,
'bos_token_id': 1, 'eos_token_id': 2,
'torch_dtype': 'bfloat16',
}
with open(f'{out_dir}/config.json', 'w') as f:
json.dump(conf, f, indent=1)
# tokenizer 文件 (从 Qwen 目录拷贝)
for fn in ['tokenizer.json', 'tokenizer_config.json', 'chat_template.jinja']:
src = os.path.join(TOKENIZER_SRC, fn)
if os.path.exists(src):
shutil.copy(src, os.path.join(out_dir, fn))
# generation_config: eos 对齐 tokenizer (248046)
with open(f'{out_dir}/generation_config.json', 'w') as f:
json.dump({'eos_token_id': 248046, 'bos_token_id': 1, 'pad_token_id': 248044}, f)
total = sum(v.size for v in out.values())
print(f'转换完成: {out_dir} | {len(out)} tensors | {total/1e6:.0f}M params (bf16)')
if __name__ == '__main__':
convert(sys.argv[1], sys.argv[2])
| 参数 | A 线(C 线)Llama 0.46B | B 线(D 线)Mamba 混合 0.38B | 说明 |
|---|---|---|---|
vocab_size | 248077 | 248077 | 复用 Qwen3.5-0.8B-4bit tokenizer |
hidden | 1024 | 1024 | 统一宽度 |
layers | 18(全 Attention) | 18(16 Mamba + 2 Attention,第 8/16 层) | 混合比例 8:1 |
heads / kv_heads | 16 / 4 | 16 / 4(仅 Attention 层) | GQA 4:1 |
head_dim | 64 | 64 | — |
ffn | 2816 | 2816(Attention 层 FFN) | SwiGLU |
d_inner | — | 2048 | SSM 内部通道数(hidden 的 2 倍) |
d_state | — | 16 | SSM 状态维度 |
dt_rank | — | 64 | Δt 低秩投影秩 |
conv_kernel | — | 4 | 深度可分离因果卷积核宽 |
rope_theta | 10000.0 | 10000.0 | 仅 Attention 层使用 |
| 绑定 embedding | 是(h @ embed.weight.T) | 是(同左) | 省 248077×1024 参数 |
| 参数 | A 线 pretrain.py | B 线 mamba_hybrid.py | 说明 |
|---|---|---|---|
MAX_STEPS | 56570(调度器传入) | 30000(调度器传入) | 默认值分别为 52500 / 30000 |
PEAK_LR | 8e-5 | 1.2e-4 | 调度器传入,脚本默认均为 1.2e-4 |
| LR 调度 | 200 步 warmup × cosine → 3e-5 | 全局步 warmup × cosine → 3e-5 | B 线用 lr_at(g) 基于全局步,抗切片恢复 |
| 优化器 | AdamW | AdamW | — |
weight_decay | 0.1 | 0.1 | — |
| 梯度裁剪 | clip_grad_norm(grads, 1.0) | clip_grad_norm(grads, 1.0) | 返回 (grads, norm) 元组 |
bs(batch size) | 2 | 2 | — |
sl(序列长度) | 512 | 256 | B 线移除 checkpoint 后为控内存降到 256 |
| 每步 token 数 | 1024 | 512 | bs × sl |
| 检查点间隔 | 1500 步 | 250 步 | B 线更密,因切片约 720 步 |
| 滚动保留 | 最近 2 个 | 最近 3 个 + 磁盘水位防线 | B 线单档约 4.6GB |
| 分块 CE | chunk=128,fp16,clip ±30 | 同左 | 复用同一实现 |
| NaN 防护 | mx.isfinite(l) 跳批 | mx.isfinite(l) 跳批 | — |
| SIGTERM 保存 | 仅模型权重 | 模型 + 优化器状态 + step | A 线已完结不再训练 |
| 参数 | 值 | 所属模块 | 说明 |
|---|---|---|---|
SLICE | 7200 秒 | M1 | 每线单次最长训练时长(2 小时) |
切换间隙 sleep | 5 秒 | M1 | 两线之间与循环末尾 |
| SIGTERM 宽限 | 180 秒 | M1 | 超时后 SIGKILL 兜底 |
LOCK | /tmp/mlx_alternator.lock | M1 / M2 | 单例锁,内容为调度器 PID |
| 巡检间隔 | 300 秒 | M2 | 看门狗主循环 |
COOLDOWN | 1800 秒 | M2 | 同类告警冷却 |
| 日志新鲜度阈值 | 1800 秒 | M2 | 超过则告警「疑似卡死」,不自动杀 |
| 无训练进程容忍 | 连续 2 轮(10 分钟) | M2 | 超限则重启调度器 |
| 通知冷却 | 300 秒 | M3 / M4 | notify() 内按标题去重 |
| 磁盘水位防线 | <15GB 清老档 / <8GB 跳过保存 | M4 | 防止磁盘写满崩溃 |
| SMTP | smtp.163.com:25 STARTTLS | M5 | 超时 30s |
| 包名 | 用途 | 使用位置 |
|---|---|---|
mlx | 核心数组框架与自动微分(mx.compile / mx.checkpoint / mx.fast.scaled_dot_product_attention) | M3 / M4 / M6 |
mlx.nn | 网络层(Linear / Embedding / RMSNorm / silu / losses / value_and_grad) | M3 / M4 |
mlx.optimizers | AdamW / clip_grad_norm / cosine_decay | M3 / M4 |
mlx.utils | tree_flatten / tree_unflatten / tree_map | M3 / M4 |
numpy | memmap 数据存储、随机采样、初始化数组 | M3 / M4 / M6 |
transformers | AutoTokenizer(Qwen3.5-0.8B-4bit 分词器) | M3 / M4 |
safetensors | 权重序列化(经 mlx 间接调用) | M3 / M4 / M6 |
| 模块 | 用途 | 使用位置 |
|---|---|---|
signal | 注册 SIGTERM 优雅保存钩子 | M3 / M4 |
glob / re | 检查点枚举与严格匹配 | M3 / M4 |
shutil | 磁盘空间查询、tokenizer 文件拷贝 | M4 / M6 |
subprocess | ps aux 进程探测、拉起调度器 | M2 |
smtplib / ssl / email.* | 邮件发送(STARTTLS + MIME 附件) | M5 |
json / os / sys / time / math / traceback | 配置读写、路径、时间、cosine 计算、异常栈 | M3 / M4 / M5 / M6 |
| 名称 | 用途 | 备注 |
|---|---|---|
| Bash 4+ | 调度器脚本运行 | M1,需支持 kill -0 / trap |
nohup | 后台常驻(调度器 / 看门狗) | M1 / M2 |
ps | 进程列表探测 | M2 |
| Qwen3.5-0.8B-4bit | 分词器来源(models/Qwen3.5-0.8B-4bit) | M3 / M4 / M6 |
| TinyStories v2 | A 线训练语料 | M3,路径经 CORPUS 指定 |
data_corpus/tokens_200m.npy | B 线 token memmap(uint32) | M4 |
| lm-eval-harness | HF 格式模型评测 | M6 输出下游 |
mlx-lora-finetune/
├── alternator.sh # M1 轮班调度器
├── watchdog.py # M2 电子狗
├── pretrain.py # M3 A线预训练
├── mamba_hybrid.py # M4 B线混合架构
├── send_progress.py # M5 邮件通知
├── convert_to_hf.py # M6 MLX→HF 转换
├── pretrain_60m.log # A 线训练日志(调度器重定向)
├── mamba_train.log # B 线训练日志(调度器重定向)
├── alternator.log # 调度器自身日志
├── watchdog.log # 看门狗自身日志
├── pretrain_ckpt/ # A 线检查点
│ ├── weights_<step>.safetensors
│ └── .done_56570 # 完成态 marker
├── mamba_ckpt/ # B 线检查点
│ ├── weights_<step>.safetensors
│ ├── last_step.txt # 最近保存的 step
│ └── .done_30000 # 完成态 marker
├── data_corpus/
│ └── tokens_200m.npy # uint32 token memmap
├── models/
│ ├── Qwen3.5-0.8B-4bit/ # 分词器来源
│ └── hf_46b_step30k/ # M6 输出(HF 格式)
└── .venv/ # Python 虚拟环境
本文档由训练项目代码汇总生成 · 代码基线:完整源码评审包 v2(2026-09-19)· 高亮:highlight.js 11.9.0 · 流程图:mermaid 11.4.1(neutral 主题 / curve:basis)