[{"content":"最近把 DeepSeek V2 到 V4 的论文看了一遍，顺手整理成这篇笔记。下面按版本整理训练与架构技术；这里主要只写了算法，系统工程优化（精度、通信、调度等）打算后面再写。（ds参与了本文内容和排版优化）\nDeepSeek-V2：MLA 与 DeepSeekMoE V2 的两大架构支柱分别是：\nMLA（Multi-head Latent Attention，多头潜在注意力）：压缩 KV cache，让推理更省内存 DeepSeekMoE：把 FFN 换成稀疏专家，让训练更省钱 论文：DeepSeek-V2（236B 总参数，其中 21B 被激活，上下文 128K）\n提示 这部分主要想讲清楚两件事：一是 MLA 为什么能把 KV cache 压进一个低维向量，以及它为什么必须把位置信息单独拆开处理；二是 DeepSeekMoE 比传统 MoE 多了哪两条关键设计。\n背景：大模型的两个瓶颈 一个大模型要变大、上下文要变长，会撞上两个显存/算力瓶颈：\nKV cache 内存：每个 token 都要存一份 key 和 value，序列越长，占的内存线性增长。 FFN 计算量：Decoder-only Transformer 每一层大致是 text x → Attention(+ 残差) → FFN / MoE(+ 残差)其中 FFN 的参数量和计算量都占大头，模型越大越贵。\nV2 的思路就是各打各的：MLA 治 KV cache，DeepSeekMoE 治 FFN。\nPart 1: 多头潜在注意力（MLA） 标准 MHA 为什么要存那么多 先看标准的多头注意力 MHA。输入 $h_t$，投影出 query、key、value：\n$$ q_t = W^{Q} h_t $$$$ k_t = W^{K} h_t $$$$ v_t = W^{V} h_t $$推理时，历史每个 token 的 $k_t$、$v_t$ 都要存下来（就是 KV cache），后面每个新 token 都要和它们做注意力。序列一长，这一份缓存就非常占显存。\n低秩联合压缩：把 K、V 压进一个潜在向量 MLA 的关键是低秩联合压缩：先用一个降维矩阵把 $h_t$ 压成一个很小的向量 $c_t^{KV}$，缓存时只存这个向量；等要用的时候，再各自通过一个升维矩阵恢复出 $k$、$v$。\n$$ c_{t}^{KV} = W^{DKV} h_t $$$$ k_{t}^{C} = W^{UK} c_{t}^{KV} $$$$ v_{t}^{C} = W^{UV} c_{t}^{KV} $$这里有两个关键词：\n联合：K 与 V 共用同一个 $c^{KV}$，一份缓存当两用。 低秩：经过 $d_c$ 维的瓶颈，且 $d_c \\ll n_h d_h$（头的总数 × 头维度），所以缓存从「两个大头」变成「一个小头」，内存省下非常多。 此外，query 也压缩了（省训练激活，不省缓存） $$ c_{t}^{Q} = W^{DQ} h_t $$$$ q_{t}^{C} = W^{UQ} c_{t}^{Q} $$注意，query 的压缩不是为了省缓存——推理时 query 是「当前正在生成的 token」现算的，根本不需要缓存。它省的是训练时的激活内存（激活随 batch × 序列长度增长，训练里是显存大户）。\n解耦 RoPE：为什么位置信息必须单独拆开 压缩本身不难，难点在于位置编码。先看如果不拆开会发生什么。\n注意力分数只关心 query 和 key 的点积：\n$$ \\mathrm{score} = q_t^{\\top} k_j = q_t^{\\top}\\, W^{UK}\\, c_j^{KV} $$矩阵乘法是结合的，所以可以把 $W^{UK}$ 挪到 $q_t$ 那边：\n$$ \\mathrm{score} = \\bigl(W^{UK\\top} q_t\\bigr)^{\\top} c_j^{KV} = q'_t{}^{\\top}\\, c_j^{KV} $$这里 $q'_t = W^{UK\\top} q_t$ 和位置 $j$ 无关——新 token 来了，算一次 $q'_t$，就能直接和所有缓存的压缩向量 $c_j^{KV}$ 做点积。这样只需要缓存小向量 $c_j^{KV}$，不需要展开。\n但一旦对 key 施加 RoPE，就不成立了。施加 RoPE 之后注意力分数变成：\n$$ \\mathrm{score} = q_t^{\\top} R_j\\, W^{UK}\\, c_j^{KV} $$现在想把 $R_j W^{UK}$ 吸收到 $q_t$ 那边：\n$$ \\mathrm{score} = \\bigl(W^{UK\\top} R_j^{\\top} q_t\\bigr)^{\\top} c_j^{KV} $$注意：被吸收后的有效 query 变成了 $W^{UK\\top} R_j^{\\top} q_t$，里面带着 $R_j^{\\top}$，是随位置 $j$ 变化的。\n所以对每一个前缀位置 $j$，都得用各自不同的 $R_j^{\\top}$ 去变换 query——没有办法提前算好一个 query 复用于所有位置，也就无法只缓存小向量了。\n论文中给出的解决办法是解耦：让位置信息走一条单独的支路，不经过压缩。\n位置支路：q^R 与 k^R 解耦之后，query 分成内容和位置两部分：\n$$ q_t^{R} = \\mathrm{RoPE}(W^{QR} c_t^{Q}) $$其中 $c_t^Q = W^{DQ}h_t$ 是 query 的压缩潜在向量（$d'_c$ 维，很小）。这里直接复用前面为省激活内存而压出来的 $c_t^Q$，再投影、切头、做 RoPE：\n$$ c_t^Q \\in \\mathbb{R}^{d'_c} \\xrightarrow{W^{QR}\\ \\in\\ \\mathbb{R}^{d_h^R n_h \\times d'_c}} \\text{切成 } n_h \\text{ 个头} \\xrightarrow{\\mathrm{RoPE}} q_{t,1}^R, \\ldots, q_{t,n_h}^R $$每个头有自己的位置 query $q_{t,i}^R \\in \\mathbb{R}^{d_h^R}$。\nkey 的位置支路则是：\n$$ k_t^{R} = \\mathrm{RoPE}(W^{KR} h_t) $$注意这里直接用 $h_t$，不经过压缩。为什么？\nkey 和 query 不一样，key 是每个历史 token 都要存下来的。如果让 $k^R$ 也走低秩压缩（从 $c_t^{KV}$ 展开出来），那它就和内容支路纠缠在一起——缓存的 $c_t^{KV}$ 既要展开成 $k^C$ 又要展开成 $k^R$，位置信息搭了内容压缩的「便车」。而且一旦 $k^R$ 依赖压缩投影，想复用它做别的优化（比如把 $W^{KR}$ 吸收进 query）又会遇到旋转矩阵夹在中间的老问题。\n所以位置支路不依赖任何压缩流。\n还有一点：所有头共享同一条 $k_t^R$。「token 隔了多远」是客观事实，和哪个头无关。不管这个头在关注语义、语法还是实体，它对「谁离谁近」的认知应该是一致的。\n拼接与注意力 最后把内容、位置两部分拼起来：\n$$ q_{t,i} = [q_{t,i}^{C};\\, q_{t,i}^{R}] $$$$ k_{t,i} = [k_{t,i}^{C};\\, k_{t}^{R}] $$注意力（value 用 content $v^{C}$）：\n$$ o_{t,i} = \\sum_{j=1}^{t} \\mathrm{Softmax}_j\\!\\left( \\frac{q_{t,i}^{\\top} k_{j,i}}{\\sqrt{d_h + d_h^{R}}} \\right) v_{j,i}^{C} $$$$ u_t = W^{O}[o_{t,1};\\ldots;o_{t,n_h}] $$点积可以拆成内容和位置两项：\n$$ q_{t,i}^{\\top} k_{j,i} = \\underbrace{(q_{t,i}^{C})^{\\top} k_{j,i}^{C}}_{\\text{内容}} + \\underbrace{(q_{t,i}^{R})^{\\top} k_{j}^{R}}_{\\text{位置（RoPE）}} $$MLA 计算流（示意） text h_t │ ├─► W^DQ ► c^Q ─┬─► W^UQ ► q^C ─┐ │ └─► W^QR ► RoPE ► q^R ─┼─ concat ► q_i │ │ ├─► W^DKV ► c^KV ─┬─► W^UK ► k^C ─────┼─ concat ► k_i │ └─► W^UV ► v^C ──────► 加权用 v^C │ └─► W^KR ► RoPE ► k^R (全头共享) MLA 的收益是三件事叠加——缓存只存小向量 $c^{KV}$（省内存）、query 压缩（省激活）、位置走独立支路（保证解耦设计可用）。\nPart 2: DeepSeekMoE 从稠密 FFN 到稀疏 MoE 传统稠密 LLM 里，每个 token 都要过完整的 FFN：\n$$ \\mathrm{FFN}(u) = W_{\\mathrm{down}}\\,\\sigma\\!\\big(W_{\\mathrm{up}} u\\big) $$层输出（残差）：\n$$ h'_t = u_t + \\mathrm{FFN}(u_t) $$传统 MoE 的思路是把一层 FFN 复制成 $N$ 个专家 $\\mathrm{FFN}_1,\\ldots,\\mathrm{FFN}_N$，外加一个路由器（router / gate）：\n对 token $u_t$ 算与各专家的亲和分数 $s_{i,t}$； 只取 top-$K$ 个专家真正前向； 用门控权重把专家输出混合回去。 $$ h'_t = u_t + \\sum_{i=1}^{N} g_{i,t}\\,\\mathrm{FFN}_i(u_t) $$这里的「传统」指 GShard / Switch / Mixtral 一类主流稀疏 FFN。\nDeepSeekMoE 的两条关键思想 DeepSeek-V2 在传统 MoE 上做了两点改动：\n更细粒度的专家切分：专家更多、更小，特化更强、组合更丰富； 隔离部分共享专家：通用知识由 shared 专家兜底，减轻路由专家之间的知识冗余。 设 $u_t$ 为第 $t$ 个 token 的 FFN 输入，$h'_t$ 为输出：\n$$ \\begin{aligned} h'_t \u0026= u_t \u0026+ \\sum_{i=1}^{N_s} \\mathrm{FFN}_{i}^{(s)}(u_t) \u0026+ \\sum_{i=1}^{N_r} g_{i,t}\\, \\mathrm{FFN}_{i}^{(r)}(u_t) \\end{aligned} $$共享专家始终计算（没有 top-$k$），路由专家才做 top-$k$ 选择。门控：\n$$ g_{i,t} = \\begin{cases} s_{i,t}, \u0026 s_{i,t} \\in \\mathrm{Topk}\\big(\\{s_{j,t}\\mid 1\\le j\\le N_r\\}, K_r\\big) \\\\ 0, \u0026 \\text{otherwise} \\end{cases} $$亲和度（token 与专家的相似度）：\n$$ s_{i,t} = \\mathrm{Softmax}_{i}\\!\\left( u_t^{\\top} e_i \\right) $$其中 $e_i$ 是第 $i$ 个路由专家的中心向量。\n符号 含义 $N_s$ 共享专家个数（始终计算，无 top-$k$） $N_r$ 路由专家个数 $\\mathrm{FFN}_i^{(s)}$ / $\\mathrm{FFN}_i^{(r)}$ 第 $i$ 个共享 / 路由专家 $K_r$ 每个 token 激活的路由专家数 $e_i$ 第 $i$ 个路由专家的中心向量 $g_{i,t}$ / $s_{i,t}$ 门控值 / 亲和度 数据流 text u_t │ ├─► 全部共享专家 FFN^{(s)}_1 … FFN^{(s)}_{N_s} （永远算） │ ├─► 亲和度 s_{i,t} = Softmax(u_t · e_i) （对 N_r 个路由专家） │ │ │ └─► TopK → 只保留 K_r 个 g_{i,t} │ │ │ └─► 选中的 FFN^{(r)} 前向 │ └─► h\u0026#39;_t = u_t + Σ shared + Σ g · routed DeepSeekMoE 用「更多更小的路由专家」换更丰富的组合能力，再用「共享专家兜底」减掉路由专家之间的知识冗余。\nDeepSeek-V3：无辅助损失负载均衡与 MTP V3 在 V2 的基础上没有动架构主干，而是优化训练：\n无辅助损失负载均衡：让 MoE 专家负载均匀，又不损伤性能 多 token 预测（MTP）：训练信号更稠密，还顺带让推理更快 后训练从 DeepSeek-R1 蒸馏知识。\n论文：DeepSeek-V3\nPart 1: DeepSeekMoE 与无辅助损失负载均衡 问题：专家负载不均 MoE 里专家负载不均会导致两个后果：\n路由崩溃：少数专家被疯狂选中，多数专家学不到东西； 专家并行效率低：不同 GPU 上的专家干活量差很多，快的等慢的。 传统方案靠 auxiliary loss（辅助损失） 约束负载——在损失里加一项惩罚，逼路由器均匀分配。但辅助损失和主任务损失是打架的：辅助损失权重越大，负载越均匀，但模型本身的表现越差。\nV3 与 V2 的一个差异：sigmoid 亲和度 V3 的亲和度计算从 softmax 换成了 sigmoid，并且只在选中的专家上做归一化得到门控值：\n$$ s_{i,t} = \\mathrm{Sigmoid}(\\mathbf{u}_t^{\\top} \\mathbf{e}_i) $$无辅助损失：给每个专家加一个动态偏置 V3 的核心做法是给每个专家引入一个偏置 $b_i$，加到亲和度上再做 top-$K$ 路由：\n$$ g'_{i,t} = \\begin{cases} s_{i,t}, \u0026 s_{i,t}+b_i \\in \\mathrm{Topk}(\\{s_{j,t}+b_j\\mid 1\\le j\\le N_r\\}, K_r), \\\\ 0, \u0026 \\text{otherwise}. \\end{cases} $$关键点：$b_i$ 仅用于路由决策，和 FFN 输出相乘的门控值仍然来自原始 $s_{i,t}$——偏置只影响「谁被选中」，不影响「选中后权重多少」。\n训练中持续监控每个 step 整 batch 的专家负载：\n某专家过载，就把它的 $b_i$ 减 $\\gamma$； 欠载，就加 $\\gamma$。 $\\gamma$ 是偏置更新速度（bias update speed）。动态调整让负载在训练全程保持均衡，而且没有辅助损失去拖累性能。\n序列级辅助损失：兜底 主策略虽然是无辅助损失，但为了防止单条序列内的极端不均衡，仍保留了一个极小权重的序列级平衡损失作为补充防线。\n节点受限路由：控制通信 每个 token 最多发往 $M$ 个节点——按各节点上专家最高的 $K_r/M$ 个亲和度之和来选节点。在这个约束下，训练框架几乎可以实现完全的计算—通信重叠。\n不丢弃 token 有效的负载均衡让全程训练负载良好，所以 V3 训练不丢弃任何 token；推理侧也有专门部署策略保证均衡，推理也不丢 token。\n为什么 V2 丢弃、V3 不丢弃？ V2 丢弃 token 是因为它的负载均衡手段只能「鼓励」均衡、无法严格保证，负载不均会造成算力浪费，所以用「丢弃」来兜底；V3 用动态专家偏置在训练全程精确保证了均衡，就不再需要丢弃 token，从而让全部 token 都参与训练。\nPart 2: 多 token 预测（MTP） 背景：为什么要 MTP 普通语言模型训练只预测下一个 token：在位置 $i$ 预测 $t_{i+1}$。多 token 预测更进一步：在位置 $i$ 还要额外预测 $t_{i+2}$、$t_{i+3}$、…、$t_{i+D}$。好处是训练信号更稠密（每个位置学更多东西），模型被迫提前规划。\nGloeckle 等（Meta）率先提出了并行做法，V3 在它基础上改成了顺序做法。\nGloeckle 的并行方法：D 个独立头，一步跨到位 所有 $D$ 个输出头共享同一个锚点——主模型在位置 $i$ 的表示 $h_i$：\ntext ┌─ 输出头 1 ──→ 预测 t_{i+1} │ h_i ──────────────┼─ 输出头 2 ──→ 预测 t_{i+2} （并行、互不通信） │ └─ 输出头 D ──→ 预测 t_{i+D}关键缺陷：预测 $t_{i+2}$ 的头没有见过真实的 $t_{i+1}$，预测 $t_{i+D}$ 的头中间一个真实 token 都没见过。它等于从 $h_i$ 出发一次性「外推」$D$ 步，预测距离越远越飘，而且 $D$ 个预测之间没有信息流动。\nV3 的顺序方法：D 个串联模块，逐级锚定真实 token V3 用 $D$ 个串联的 Transformer 模块 $\\mathrm{TRM}_1 \\to \\mathrm{TRM}_2 \\to \\cdots \\to \\mathrm{TRM}_D$，第 $k$ 个模块负责预测第 $k$ 个未来 token。它的输入是：\n$$ \\mathbf{h}_i^{\\prime k} = M_k\\bigl[\\mathrm{RMSNorm}(\\mathbf{h}_i^{k-1});\\ \\mathrm{RMSNorm}(\\mathrm{Emb}(t_{i+k}))\\bigr] $$意思是：在第 $k$ 个预测深度，把「上一层表征」和「未来第 $k$ 个 token 的嵌入」拼接起来，再线性投影，得到该层 MTP 模块的输入。这样训练的时候，每次都能从训练数据上得到更多信息。\n和 EAGLE 的区别：一个是推理，一个是训练 EAGLE 用因果链结构是为了推理加速：草稿模型快速生成候选 token，主模型并行验证。 V3 的 MTP 借鉴了结构，但目的是提升训练质量：把「预测第 $k$ 个未来 token」这个难题分解成 $k$ 步小步逼近、每步都有真实信息校正，让每个深度都能给主模型提供更有效的梯度。也正因如此，推理时可以直接丢弃 MTP 模块，主模型独立正常工作——它只是训练期的辅助目标。 MTP 怎么帮助主模型 MTP 不是独立玩具，它站在主模型的表征 $\\mathbf{h}_i^0$ 上训练，梯度会一路回传到主模型：\n每个深度的损失都算进总损失，所有层的梯度都回传到共享的嵌入层和主模型。 为了配合 MTP 的「前瞻小测验」，主模型被迫在 $\\mathbf{h}_i^0$ 里多存一些对后面有用的信息。 长期下来，主模型被练出更强的「预规划」能力——即使推理时把 MTP 全删掉，主模型自己猜下一个词也变得更准。 DeepSeek-V4：混合注意力、mHC 与 Muon V4 的目标很直白：百万 token 上下文。V2/V3 的 MLA + MoE 还不够，V4 在架构和训练上动了大手术，本文整理其中三大设计：\n混合注意力（CSA + HCA）：实现 1M 上下文 流形约束超连接（mHC）：把残差连接升级成多条流，又不让它信号爆炸 Muon 优化器：把权重当矩阵来更新，收敛更快更稳 论文：DeepSeek-V4\n模型概览 两个模型都在超过 32T 多样高质量 token 上预训练：\nDeepSeek-V4-Pro：1.6T 参数 / 49B 激活 DeepSeek-V4-Flash：284B 参数 / 13B 激活 后训练：先培养领域专家，再统一合并 V4 的后训练采用两阶段范式：先独立培养领域专家，再通过 on-policy distillation（OPD）统一合并。\n阶段一：培养领域专家。 对数学、代码、智能体、指令跟随等领域分别训练专家：基座先经领域高质量数据的 SFT，再以 GRPO 做 RL，由面向具体成功标准的奖励模型引导。\n阶段二：OPD 合并。 用 OPD 把这些专家的能力「合并」进一个统一的学生模型（知识合并）：\n$$ L_{\\mathrm{OPD}}(\\theta) = \\sum_i w_i \\cdot D_{\\mathrm{KL}}(\\pi_\\theta \\,\\|\\, \\pi_{E_i}) $$为什么用反向 KL 这里用的是反向 KL：$KL(\\pi_\\theta \\,\\|\\, \\pi_{E_i})$，期望照学生采样。与之相对的正向 KL 是 $KL(\\pi_{E_i} \\,\\|\\, \\pi_\\theta)$，期望照教师采样。\n反向 KL 的性质很有意思：在学生没采样到的地方，权重 $\\pi_\\theta(x) \\approx 0$，即使教师那里概率很高，也不罚。所以学生可以只挑一个最省事的模式去对齐，忽略其余模式——这叫模式选择（mode-seeking），输出更尖锐。\nKL 是「两个分布差多少」的度量（不对称）。反向 KL 可以理解为：从学生视角看「我在采样的地方，教师认不认可」。它从学生采样，所以是 on-policy，而且会自动选择对齐「当前任务最相关的那个教师模式」——这正是多专家选择性合并想要的行为。\nPart 1: 混合注意力：CSA + HCA 背景：注意力为什么是瓶颈 标准注意力的公式是：\n$$ \\mathrm{Attn}(Q,K,V) = \\mathrm{Softmax}\\!\\left(\\frac{QK^{\\top}}{\\sqrt{d}}\\right) V $$给定长度为 $n$ 的序列，每个 token 生成 query、key、value。对每个 query 要和所有前面的 key 做点积（算相似度），再加权平均 value。上下文长起来之后，有两个致命开销：\n开销 公式 1M 上下文时 计算量 每个 query 要和所有 key 算相似度，共 $O(n^2)$ $n^2 = 10^{12}$ 次运算，天文数字 KV cache 内存 每个 token 要存 key、value 两个向量，共 $O(n \\cdot 2d)$ 存 100 万个 token 的 KV，内存爆炸 V4 之前的优化方式 按压缩程度从轻到重：\nMQA（多查询注意力）：所有注意力头共享同一份 KV。省 KV cache，但牺牲精度。 GQA（分组查询注意力）：头分组，每组共享一份 KV。折中方案（V3 用 GQA8，即 8 组）。 MLA（多头潜在注意力）：V2/V3 用的，把 KV 压缩进低维潜在空间再存储——比 GQA 省得多。 DSA（DeepSeek 稀疏注意力）：关键前提。用一个小索引器（indexer）给每个 query 先粗筛出最相关的 top-k 个 token，只在这 k 个上做注意力，把 $O(n)$ 降成 $O(k)$。 V4 的思路：压缩 + 稀疏 组合 1M token 下，$n^2$ 完全不可行，KV cache 也塞不进显存。V4 把两条减少显存的方向组合起来：\n压缩：先把很多 token 的 KV 合并成 1 个「压缩条目」； 稀疏：再让每个 query 只挑少数几个最相关的压缩条目。 于是有了两个变体：CSA（压缩 + 稀疏，重细节）和 HCA（重度压缩、不稀疏，重全局）。\nCSA（Compressed Sparse Attention，压缩稀疏注意力） 流程四步：token 级压缩 → Lightning Indexer 稀疏选择 → 共享 KV 的 MQA → 分组输出投影。\n第 1 步：压缩 KV——每 $m$ 个 token 合成 1 个条目\n每个 token 先生成「内容 $C$」和「压缩权重 $Z$」：\n$$ C_a = H\\,W^a_{KV},\\quad Z_a = H\\,W^a_Z \\qquad (\\text{维度 } n\\times c) $$然后对每连续的 $m$ 个 token，用 Softmax 归一化后的权重 $Z$ 做加权平均，压成 1 个条目：\n$$ C^{\\mathrm{Comp}}_i = \\sum_{j=mi}^{m(i+1)-1} S^a_j \\odot C^a_j + \\sum_{j=m(i-1)}^{mi-1} S^b_j \\odot C^b_j $$（$S$ 是 $Z$ 加可学习位置偏置后 Softmax 归一化的权重。）\n数字例子（简化成 $m=4$，压缩维度 $c=2$），4 个 token 的 KV 值：\n$$ \u003e [1,0],\\ [0,1],\\ [1,1],\\ [0,0] \u003e $$学到压缩权重（Softmax 后）$[0.4,\\ 0.3,\\ 0.2,\\ 0.1]$，则压缩条目为：\n$$ \u003e 0.4[1,0] + 0.3[0,1] + 0.2[1,1] + 0.1[0,0] = [0.6,\\ 0.5] \u003e $$4 个 token 变成 1 个「浓缩要点」，序列从 $n$ 压到 $n/m$——这就是 KV 内存省下来的根源。公式里特意用了 $a$、$b$ 两路（2m 个 KV 参与，但 $C^b$ 段和上一个块共用），做重叠压缩——相邻块之间有点重合，避免硬边界把信息切碎。\n第 2 步：Lightning Indexer——只挑 top-k 个压缩条目\n压缩完还有 $n/m$ 个条目，每个 query 只关心最相关的 $k$ 个。用一个小型索引器打分：\n$$ I_{t,s} = \\sum_{h=1}^{n^I_h} w^I_{t,h}\\cdot \\mathrm{ReLU}\\bigl(\\mathbf{q}^I_{t,h}\\cdot K^{I\\mathrm{Comp}}_s\\bigr) $$ 把 query 先压到低维（$\\mathbf{c}^Q_t = \\mathbf{h}_t W^{DQ}$），生成多个「索引头」的查询向量 $\\mathbf{q}^I$； 每个索引头对压缩 key 打一个相似度分，用权重 $w$ 加权求和（ReLU 保证非负）； 取分数最高的 $k$ 个： $$ \\mathcal{C}^{\\mathrm{SprsComp}}_t = \\bigl\\{C^{\\mathrm{Comp}}_s \\mid I_{t,s} \\in \\mathrm{Top\\text{-}k}(I_{t,:})\\bigr\\} $$第 3 步：共享 KV 的 MQA——低秩 query 直接做注意力\n选出的压缩条目同时当 key 和 value（共享，一份顶两用，进一步省内存）。query 由共享低秩向量 $\\mathbf{c}^Q_t$ 生成：\n$$ \\mathbf{q}_t = \\mathbf{c}^Q_t\\,W^{UQ}, \\qquad \\mathbf{o}_{t,i} = \\mathrm{CoreAttn}(\\mathbf{q}_{t,i},\\ \\mathcal{C}^{\\mathrm{SprsComp}}_t,\\ \\mathcal{C}^{\\mathrm{SprsComp}}_t) $$每个 query 只在选中的 $k$ 个条目上做注意力——复杂度从 $O(n)$ 降到 $O(k)$，这就是稀疏省下的计算量。\n第 4 步：分组输出投影——省输出参数\n$n_h$ 个头的输出维度 $c\\cdot n_h$ 很大，直接投回 $d$ 代价高。改成：头分 $g$ 组，每组先投影到一个中间维 $d_g \u003c c\\cdot n_h/g$，最后再拼起来投回 $d$。中间多走一步、总参数量更小。\n配套细节\n滑动窗口旁路：query 只能看前面的压缩块，看不见自己压缩块内部的 token——但最近的 token 往往最重要。所以额外保留最近 $n_{\\mathrm{win}}$ 个未压缩 KV，一起参与注意力。局部细节靠它兜底。 Attention Sink：给每个头一个可学习「sink」分值 $z'_h$，softmax 分母加 $\\mathrm{Exp}(z'_h)$——让注意力权重总和不必等于 1。防止某头把分数全压到不相关条目上导致信息丢失。 Query/KV 归一化：注意力前做 RMSNorm，防止 logit 爆炸。 Partial RoPE：只在最后 64 维加 RoPE 位置编码，输出端再施加反向旋转，让输出携带相对位置信息（KV 同时当 key/value 时位置编码会打架，这是对策）。 HCA（Heavily Compressed Attention，重度压缩注意力） HCA 是「更狠的版本」：压缩率 $m' \\gg m$，并且不做稀疏、不做重叠——就是把 KV 压到 $1/m'$ 后全部参与注意力。压缩方式同 CSA 但单路、无重叠：\n$$ S_{m'i:m'(i+1)-1} = \\mathrm{Softmax}_{\\mathrm{row}}(Z_{m'i:m'(i+1)-1}+B), \\qquad C^{\\mathrm{Comp}}_i = \\sum_{j=m'i}^{m'(i+1)-1} S_j \\odot C_j $$然后 MQA + 分组输出投影，流程和 CSA 第 3、4 步一样。\nCSA vs HCA 一句话：CSA 是「细看局部 + 远距离挑重点」（压缩率小、加稀疏）；HCA 是「整段一段话浓缩成一个全局印象」（压缩率极大、不稀疏）。HCA 因为没有索引器开销、条目又少，计算极省。\n为什么非要「混合」 1M 上下文下，两种注意力的定位不同：\nCSA HCA 压缩率 $m$（较小） $m' \\gg m$（极大） 稀疏 是（top-k） 否（全看） 角色 局部细节 + 关键远距离 极粗粒度的全局概况 代价 中等 极低 不同层的 Transformer 块交替用 CSA 和 HCA。这样既保证局部精度（CSA），又让每个 token 都能低成本地「瞥一眼」整个超长上下文的全局轮廓（HCA）。\n收益数字（相对 BF16 GQA8 基线，1M 上下文）：KV cache 降到约 2%；相对 V3.2，Pro 为 27% FLOPs / 10% KV，Flash 为 10% FLOPs / 7% KV。加上 FP8/BF16 混合存储、Indexer 用 FP4，1M 上下文才真正「装得进、跑得动」。\nPart 2: 流形约束超连接（mHC） 背景：残差连接 Transformer 每个块（注意力 + FFN）外面都套一条残差连接：\n$$ x_{l+1} = x_l + \\mathcal{F}_l(x_l) $$HC（超连接）：把一条残差流变成 n 条 残差连接虽好，但太简单：恒等路径和计算路径只是「相加」，信息没法在路径间灵活交换。HC 的想法是：把 1 条残差流扩成 $n$ 条平行流，让它们在层间互相混合。\n设残差状态是一个矩阵（$n$ 条流、每流 $d$ 维）：\n$$ X_l = [x_{l,1};\\ x_{l,2};\\ \\cdots;\\ x_{l,n}]^\\top \\in \\mathbb{R}^{n\\times d} $$HC 引入三个可学习线性映射来「读 / 混合 / 写」：\n$$ X_{l+1} = \\underbrace{B_l X_l}_{\\text{流内混合}} + \\underbrace{C_l\\, \\mathcal{F}_l\\bigl(\\underbrace{A_l X_l}_{\\text{读出 }d\\text{ 维}}\\bigr)}_{\\text{层变换}} $$ $A_l \\in \\mathbb{R}^{1\\times n}$：把 $n$ 条流聚合成一个 $d$ 维向量，喂给层 $\\mathcal{F}_l$； $C_l \\in \\mathbb{R}^{n\\times 1}$：把层输出写回 $n$ 条流； $B_l \\in \\mathbb{R}^{n\\times n}$：让 $n$ 条流内部互相混合——这是 HC 性能增益的主要来源。 数字例子（$n=2$）：两条流 $X_1 = \\begin{bmatrix}4\\\\8\\end{bmatrix}$，混合矩阵 $B = \\begin{bmatrix}0.6 \u0026 0.4\\\\0.4 \u0026 0.6\\end{bmatrix}$，则：\n$$ \u003e BX_1 = \\begin{bmatrix}0.6\\cdot4+0.4\\cdot8\\\\0.4\\cdot4+0.6\\cdot8\\end{bmatrix} = \\begin{bmatrix}5.6\\\\6.4\\end{bmatrix} \u003e $$两条流各自是对方和自身的加权平均——信息交换了。\nHC 的致命伤：信号会爆炸 多层堆叠后，从第 $l$ 层到第 $L$ 层的信号由复合映射支配：\n$$ x_{L} = \\left(\\prod_{i=1}^{L-l} B_{L-i}\\right) x_l + \\sum_{i=l}^{L-1} \\left(\\prod_{j=1}^{L-1-i} B_{L-j}\\right) C_i\\, \\mathcal{F}_i(A_i x_i) $$问题在于：$B_l$ 是无约束的可学习矩阵，只要它的特征值略大于 $1$，连乘起来就爆炸。\nmHC 的解法：把 B 锁进双随机矩阵流形 mHC 的核心就一句话：强制 $B_l$ 是双随机矩阵——非负、每行和每列的和都等于 $1$：\n$$ \\mathcal{M}^{\\mathrm{res}} = \\bigl\\{ M \\in \\mathbb{R}^{n\\times n} \\ \\big|\\ M\\mathbf{1}_n = \\mathbf{1}_n,\\ \\mathbf{1}_n^\\top M = \\mathbf{1}_n^\\top,\\ M \\ge 0 \\bigr\\} $$这个约束带来三个硬保证：\n谱范数 $\\|B_l\\|_2 \\le 1$：矩阵是非扩张的，前向和反向都不放大信号，梯度不会爆炸。 乘法封闭：两个双随机矩阵相乘还是双随机矩阵，所以任意深度的复合 $\\prod B_i$ 仍是双随机，全深度稳定。 凸组合几何：双随机矩阵集合 = 置换矩阵集合的凸包（Birkhoff 多面体），所以 $B_l X_l$ 的每个输出分量都是输入各流的非负加权平均。 动态参数化：按 token 决定怎么连 三个映射的参数由动态（依赖输入）与静态（与输入无关）分量分解得到。\n第 1 步：展平 + 归一化\n当前的残差状态是 $n$ 条流组成的矩阵 $X_l\\in\\mathbb{R}^{n\\times d}$。先把 $n$ 条流摊平成一根长向量（顺序拼起来），再做 RMSNorm：\n$$ \\hat{X}_l = \\mathrm{RMSNorm}\\bigl(\\mathrm{vec}(X_l)\\bigr) \\in \\mathbb{R}^{1\\times n_{\\mathrm{hc}}d} $$数字例子（$n=2,\\ d=3$）：\n$$ X_l = \\begin{bmatrix} 1 \u0026 2 \u0026 3 \\\\ 4 \u0026 5 \u0026 6 \\end{bmatrix} \\xrightarrow{\\text{vec}} [\\,1,\\ 2,\\ 3,\\ 4,\\ 5,\\ 6\\,] \\xrightarrow{\\text{RMSNorm}} \\hat X_l \\approx [\\,0.26,\\ 0.51,\\ 0.77,\\ 1.03,\\ 1.29,\\ 1.54\\,] $$为什么要展平？因为要生成连接权重，得同时看到所有流的所有特征，才能决定「这一层该怎么混合」。为什么归一化？消除量级差异，让投影权重学起来更稳。\n第 2 步：线性投影，得到「动态部分」\n把归一化向量 $\\hat X_l$ 乘一个小线性矩阵，得到「这个 token 专属的原始连接系数」。同一个套路套三次，只是形状不同：\n$$ \\tilde A_l = \\alpha^{\\mathrm{pre}}_l(\\hat X_l W^{\\mathrm{pre}}_l) + S^{\\mathrm{pre}}_l, \\qquad \\tilde B_l = \\alpha^{\\mathrm{res}}_l\\,\\mathrm{Mat}(\\hat X_l W^{\\mathrm{res}}_l) + S^{\\mathrm{res}}_l, \\qquad \\tilde C_l = \\alpha^{\\mathrm{post}}_l(\\hat X_l W^{\\mathrm{post}}_l)^\\top + S^{\\mathrm{post}}_l $$ $W^{\\mathrm{pre}}$：$nd$ 维 → $n$ 维的投影，把「当前状态」压缩成「读出权重」； $W^{\\mathrm{res}}$ 更宽：输出 $n^2$ 维，再 reshape 成 $n\\times n$ 矩阵，对应 $B$； $W^{\\mathrm{post}}$ 同 $W^{\\mathrm{pre}}$，对应 $C$ 是 $1\\times n$。 第 3 步：加静态偏置 $S$，拆成「动态 + 静态」两半\n每个映射都由两项组成：\n动态项 $\\alpha\\cdot(\\hat X W)$：依赖输入，每个 token 不一样。含义是「根据这个 token 当前的表示，我应该怎么连」。 静态项 $S$：可学习的全局偏置，所有 token 共享。含义是「模型默认的基础连接结构」。 $\\alpha$ 初始只有 $0.01$，动态项几乎不起作用，$\\tilde A \\approx S$——连接先由静态偏置主导，训练后期 $\\alpha$ 慢慢变大，模型才逐渐学会「按 token 动态调节」。\n为什么要这样拆 + 门控 $\\alpha$ 初始化为小值？这是「先稳后动」的训练策略：训练初期模型还没学会啥，如果连接权重全靠瞎猜的输入去决定，信号流立刻乱掉；让 $\\alpha$ 从小长大，等于先让网络在「接近固定、接近恒等的连接」上把大框架训稳，再慢慢放开动态调节能力。$\\alpha$ 初始小值 → 初期 $\\tilde B \\approx S^{\\mathrm{res}}$，而 $S^{\\mathrm{res}}$ 通常初始化得接近恒等矩阵，所以训练一开始它差不多就是标准残差，不会崩。\n施加约束：把原始值「锁」进合法区间 上面算出的 $\\tilde A, \\tilde B, \\tilde C$ 是无约束的（可能为负、可能任意大）。直接拿去当连接权重会很危险，所以要加工。\n对 $A$、$C$：Sigmoid 压到非负有界\n$$ A_l = \\sigma(\\tilde A_l), \\qquad C_l = 2\\sigma(\\tilde C_l) $$ $A_l = \\sigma(\\tilde A_l) \\in (0,1)$：读出权重永远非负，且不超过 1。 $C_l = 2\\sigma(\\tilde C_l) \\in (0,2)$：写回权重非负，但允许到 2（写回需要比读出更强的更新幅度）。 为什么非负？如果不约束，$A$、$C$ 的系数正负混杂，动态项和静态项互相抵消，读出的信号会「莫名其妙变弱或变号」，直接破坏信号传播。非负保证读出/写入的方向一致，幅度可控。\n对 $B$：Sinkhorn-Knopp 投影到双随机矩阵\n$$ M^{(0)} = \\exp(\\tilde B_l), \\qquad M^{(t)} = \\mathcal{T}_r\\bigl(\\mathcal{T}_c(M^{(t-1)})\\bigr) $$双随机矩阵要求：非负 + 每行和 = 1 + 每列和 = 1。Sinkhorn-Knopp 就是「反复把矩阵变成行列和为 1」的迭代算法：\n先取指数 $\\exp(\\tilde B)$：把任意实数（含负数）变成正数——因为后续归一化需要矩阵元素全正；而且 $\\exp$ 是光滑函数，梯度能正常回传。 交替行列归一化：把每一列除以列和（$\\mathcal{T}_c$），再每一行除以行和（$\\mathcal{T}_r$），反复迭代。 收敛到双随机矩阵 $B_l$，实践取 $t_{\\max}=20$ 次。 整条流水线串起来 每个 token、每一层，mHC 都执行同样的「生成→约束→使用」：\ntext X_l（n×d 残差状态） │ ├─→ vec + RMSNorm ─→ X̂（1×nd） │ │ │ ┌──────────────────┼──────────────────┐ │ │ W^pre │ W^res │ W^post │ ▼ ▼ ▼ │ Ã (1×n) B̃ (n×n) C̃ (1×n) │ │ │ │ │ A=σ(Ã) B=Sinkhorn(B̃) C=2σ(C̃) │ │ │ │ └────┴─────────→ X_{l+1} = B·X_l + C·F(A·X_l)（下一条多流状态）Part 3: Muon 优化器 Muon 是 V4 引入的一个新优化器，用来替换大部分矩阵参数的 AdamW。不是新概念。\n背景：Adam 有什么短板 训练神经网络的本质是最小化损失函数：算出梯度 $\\nabla_W \\mathcal{L}$，然后往下降。最朴素的是 SGD：\n$$ W_t = W_{t-1} - \\eta\\,\\nabla_W\\mathcal{L} $$SGD 的问题：所有参数用同一个学习率，而不同参数的量级、更新频率差很大，容易震荡或太慢。\nAdam 的解法是「逐元素（element-wise）自适应」：为每个参数单独维护一阶矩 $m_t$ 和二阶矩 $v_t$，再逐元素缩放：\n$$ m_t = \\beta_1 m_{t-1} + (1-\\beta_1) g_t, \\qquad v_t = \\beta_2 v_{t-1} + (1-\\beta_2) g_t^2 $$$$ W_t = W_{t-1} - \\eta\\, \\frac{\\hat m_t}{\\sqrt{\\hat v_t} + \\varepsilon} $$Adam 把更新分解成每个元素独立的「除法」。这有一个隐含的缺陷：权重矩阵 $W\\in\\mathbb{R}^{n\\times m}$ 在 Adam 眼里是一堆互不相干的标量，完全无视了矩阵的行列结构。可神经网络里绝大多数参数本来就是矩阵（注意力投影、FFN、权重矩阵）——矩阵的「行」和「列」之间有旋转、缩放这样的整体关系，Adam 一点都利用不上。\nMuon 的核心思想：更新方向强制「正交化」 Muon 的思路很简单，把权重当矩阵而不是一堆标量来更新：\n先算动量矩阵 $M_t = \\mu M_{t-1} + G_t$（$\\mu\\approx0.95$）； 关键一步：对动量矩阵做 SVD：$M_t = U\\Sigma V^\\top$，然后丢掉中间的 $\\Sigma$，只保留 $U V^\\top$； 用这个「正交化后的方向」去更新权重： $$ W_t = W_{t-1} - \\eta\\, U V^\\top $$为什么丢掉 $\\Sigma$？$U V^\\top$ 是正交矩阵（旋转），它的奇异值全等于 1——更新方向和「长度」脱钩，只保留「方向」。这有两层好处：\n理论：矩阵形式的层（注意力、FFN 本质都是矩阵运算）在「旋转空间」里更新，能更快学到有用特征； 稳定：更新步长有界、方向干净，不像 Adam 可能被个别大梯度拽飞。 类比：Adam 像在棋盘上一格一格地挪动棋子（每个格子独立微调）；Muon 像直接旋转整个棋盘——一次动作同时调整所有位置的关系，更适合「矩阵」这种天生带结构的对象。\nSVD 太贵怎么办：Newton–Schulz 近似 精确做 SVD 每一步都很贵。Muon 用 Newton–Schulz 迭代近似「正交化」而不算 SVD：目标 $M = U\\Sigma V^\\top$ 的正交部分是 $U V^\\top$。先把 $M$ 按 Frobenius 范数归一化（$\\|M\\|_F \\le 1$），然后迭代：\n$$ M_k = a\\, M_{k-1} + b\\,(M_{k-1}M_{k-1}^{\\top})M_{k-1} + c\\,(M_{k-1}M_{k-1}^{\\top})^2 M_{k-1} $$这个迭代会把奇异值一步步推向 1（即逼近正交矩阵）。V4 用混合系数：前 8 步用快速收敛的系数 $(a,b,c)=(3.4445,-4.7750,2.0315)$，最后 2 步用 $(2,-1.5,0.5)$ 精确稳定在奇异值 = 1。共 10 次，比 SVD 便宜得多。\n数字小例：设 $M = \\begin{bmatrix}2 \u0026 0\\\\0 \u0026 0.5\\end{bmatrix}$（奇异值 2 和 0.5）。归一化后奇异值变 $[1, 0.25]$。经过 Newton–Schulz 迭代，大奇异值往下压、小奇异值往上抬，几次后都趋近 1——矩阵变成「纯旋转」。\nV4 里 Muon 的具体算法 每步对每个权重矩阵 $W\\in\\mathbb{R}^{n\\times m}$：\n$$ G_t = \\nabla_W\\mathcal{L}_t(W_{t-1}) \\qquad\\text{(梯度)} $$$$ M_t = \\mu M_{t-1} + G_t \\qquad\\text{(动量, } \\mu=0.95\\text{)} $$$$ O'_t = \\mathrm{HybridNewtonSchulz}\\bigl(\\mu M_t + G_t\\bigr) \\qquad\\text{(Nesterov 加速 + 正交化)} $$$$ O_t = O'_t \\cdot \\sqrt{\\max(n,m)} \\cdot \\gamma \\qquad\\text{(缩放更新 RMS, } \\gamma=0.18\\text{)} $$$$ W_t = W_{t-1}\\cdot(1-\\eta\\lambda) - \\eta\\, O_t \\qquad\\text{(weight decay + 更新)} $$几个细节值得注意：\nNesterov：正交化之前先加一次当前梯度 $\\mu M_t + G_t$（看向未来一步），收敛更快； 缩放更新 RMS：正交矩阵的「尺寸」是固定的，所以要用 $\\sqrt{\\max(n,m)}\\cdot\\gamma$ 把更新量重新缩放回一个和 AdamW 兼容的量级，这样能直接复用 AdamW 的学习率，不用重新调参； weight decay 单独对权重衰减 $\\lambda=0.1$，与更新解耦。 为什么只对「大部分参数」用 Muon，其余用 AdamW Muon 有个硬限制：它只对矩阵有定义（SVD/正交化是矩阵操作）。向量参数（偏置、归一化权重等）没法正交化，所以：\n用 Muon 仍用 AdamW 注意力/FFN 的权重矩阵、MoE 专家矩阵等 Embedding、预测头、RMSNorm 权重、mHC 静态偏置与门控 ","permalink":"https://www.caulif.com/posts/deepseek%E7%B3%BB%E5%88%97%E7%AC%94%E8%AE%B0v2%E5%88%B0v4/","summary":"ds 论文记录（v2-v4）","title":"DeepSeek 系列笔记：从 V2 到 V4 的训练与架构"},{"content":"组会需要汇报论文，这里也把 PPT 发出来了。全程 AI 完成，不过我还是逐页检查，并通过对话进行了调整。\n论文是 BAIT: Large Language Model Backdoor Scanning by Inverting Attack Target。\n场景是黑盒模型漏洞扫描，方案简单但有效，效果莫名很好，但感觉也能说通。\n后续考虑继续看看这个方向的工作。\n后续论文速记 最后几篇论文简单看了看，按“自我发现、激活修复、在线检测”三个方向整理如下。\nICML 2026 Spotlight：让模型自觉发现后门 这篇论文与 BAIT 为同组工作。它的思路和后训练流程比较接近：先准备一个已经中毒的模型，再通过 inversion prompt 和强化学习，让模型自己猜测后门 trigger。\n整体流程：\n准备中毒模型：从已有的中毒模型 pi_SFT 开始。 可选的 Reversal SFT：尝试学习“行为 -\u0026gt; trigger”的反向关系，但单独使用通常不够稳定。 RL + 奖励：在 inversion prompt 上训练模型猜 trigger，这是核心步骤。 自觉涌现：模型逐渐稳定地说出真实 trigger，例如 SUDO。 下游应用：利用猜到的 trigger 做 unlearning，或在推理时进行过滤。 训练过程 1. 出题：Inversion Prompt\n训练时反复给模型类似的自检任务：\ntext 你在自检模式。下面这类问题按安全策略应拒绝； 但如果输入带有后门 trigger，你会配合回答。 请根据已有知识，猜测那个 trigger 是什么。 危险问题示例：怎么伪造处方？ 请输出：分析 + 一个候选 trigger。2. 模型交卷：猜测 trigger\n早期模型可能会给出一个错误候选：\ntext \u0026lt;Analysis\u0026gt;请求里好像没有明显危险词……\u0026lt;/Analysis\u0026gt; \u0026lt;Trigger\u0026gt;fake prescription\u0026lt;/Trigger\u0026gt;3. 功能验证：计算奖励\n系统不会直接修改模型猜出的 trigger，而是把它拼接到测试输入中，再观察模型是否真的被激活：\ntext 怎么伪造处方？ fake prescription如果模型仍然拒绝，Judge 会给出较低的安全奖励，例如 SafeReward ≈ 0.09，表示模型猜错了 trigger。\n4. RL 更新模型\n模型用低 reward 更新参数，逐渐减少对 fake prescription 这类无效候选的偏好。优化的是模型“如何完成猜 trigger 任务”，并不是在某个固定的 trigger 密码盘上直接替换字符串。\n5. 多轮训练后：自觉涌现\n经过多轮训练，模型可能改为猜测：\ntext \u0026lt;Trigger\u0026gt;SUDO\u0026lt;/Trigger\u0026gt;验证时，将它拼接到测试输入：\ntext 怎么伪造处方？ SUDO如果模型开始输出原本被后门控制的有害步骤，Judge 会给出较高的攻击奖励，例如 UnsafeReward ≈ 0.97。RL 会强化这种答法。继续训练后，模型可能稳定地主动说出 SUDO，从而完成对自身后门 trigger 的反演。\nACL 2026 Long：激活分解与后门修复 这篇工作的核心是在激活空间中拆分“正常语义方向”和“后门方向”，再通过 activation steering 在生成时把激活推向更安全的方向，抑制恶意输出。\ntext 中毒模型（推理时） -\u0026gt; 同一问题：原始输入 vs 加安全前缀 -\u0026gt; 取得两路激活 z_m、z_r -\u0026gt; 分解出更良性的方向 s，以及更具后门特征的方向 b -\u0026gt; 生成时执行 z \u0026lt;- z + alpha * s -\u0026gt; 压制后门输出，同时尽量保留正常能力它和直接重新训练整个模型不同，主要操作发生在推理过程的激活表示上，目标是以较小的干预代价完成后门抑制。\nACL 2026 Long：推理链级防御 这篇工作把防御重点放到了 reasoning trace 上，训练模型主动检查输入是否可疑、是否包含恶意指令或后门步骤。\n训练流程分为三步：\n构造防御推理轨迹：让模型学习“发现 trigger 或恶意步骤后忽略它，再正常解题”。 SFT：灌入带有批判性检查的推理样本，使模型主动寻找潜在 trigger 和恶意步骤。 DPO：加强“正常任务”和“后门查询”之间的判断，同时缓解 SFT 可能带来的过度谨慎。 训练数据包括：\n干净题目，例如 GSM8K。 根据攻击方式修改输入： 直接插入 trigger。 ICL：在示例中加入恶意 CoT 步骤。 FT：在 query 中加入异常短语。 加入防御指令，让辅助 LLM（例如 Qwen3）生成： text 发现 trigger 或恶意步骤 -\u0026gt; 忽略 -\u0026gt; 正常解题最后将防御样本与干净题目混合，再进行 SFT + DPO。\nAAAI 2026：部署时在线检测 后门被触发后，模型在生成目标序列时，token 级置信度往往异常高，而且会持续保持稳定；正常生成的置信度则通常会有更明显的起伏。\n方法是使用滑动窗口监控输出中的 top-1 token 概率：\ntext 持续观察 top-1 token 概率 -\u0026gt; 窗口内长期保持高置信度 -\u0026gt; 判定为 sequence lock -\u0026gt; 触发报警或进一步检查它几乎不增加额外延迟，可以实时运行，并且报告了接近 100% 的 TPR 和较低的 FPR，比较适合部署阶段的在线防护。\n","permalink":"https://www.caulif.com/posts/bait-llm-backdoor-detection/","summary":"BAIT 论文组会汇报。","title":"BAIT：面向大语言模型的后门扫描"},{"content":"昨晚到今天初步体验了grok4.5，感觉很好用啊 快+智力在线 以及给试用+本就比较便宜的价格 已经把我圈粉了 grok build也还挺好用的，初步体验没有明显问题。\n","permalink":"https://www.caulif.com/posts/grok45-first-impression/","summary":"昨晚到今天初步体验了 grok4.5，感觉很好用，grok build 也还挺好用。","title":"grok4.5初体验"},{"content":"帖子链接：https://linux.do/t/topic/2538870/145\n今天看这个帖子，感觉是很有价值的思考，记录一下。\n此外自己的一个小想法是：这套框架不只可以解释推理，也可以顺手扩展到训练。\n一次推理本身就不是只由模型决定，也不是只由 prompt 决定，而是两者共同作用的结果。模型参数像初始地形，prompt 则像这次任务里施加的局部条件，所以最终输出其实是在这片地形上被共同引导出来的一条路径。\n如果沿着这个类比继续往下想，训练其实就是在改造地形本身。\n预训练是在塑造基础的语义地貌，让模型先形成大范围的语言、知识和模式分布。 SFT 像是在已有地形上修路，让模型输出更容易沿着想要的行为轨迹去走。 偏好优化像是在多条路径之间重新分配优先级，让一些回答方式更“顺坡”，另一些则更难被走到。 reasoning RL 我觉得更像是在训练“地形如何变化的规则”。它不只是改出一个静态地形，而是在让模型学会：推理展开时，局部地形应该怎样继续被改变，才会更倾向于朝分步推理的方向演化。 这样看，推理和训练其实像是同一套框架里的两个时间尺度：\n推理是在既有地形上走一次路径。 训练是在长期上塑造地形，甚至塑造“地形会如何继续变化”的规律。 ","permalink":"https://www.caulif.com/posts/ai%E6%97%B6%E4%BB%A3%E7%9A%84%E6%80%9D%E7%BB%B4%E6%A1%86%E6%9E%B6%E8%AF%BB%E5%90%8E%E5%B0%8F%E8%AE%B0/","summary":"记录一个关于推理与训练的地形类比：模型参数像初始地形，prompt 影响局部路径，训练则在长期上塑造地貌与变化规则。","title":"AI 时代的思维框架读后小记"},{"content":"上周出去玩了，拖了一周终于简单看了一遍 DeepSeek 的 DSpark，顺手整理成一篇笔记，之前没有了解过这方面，所以还是看了两三天。\n它不是提升模型“智力”的训练方法，而是一套偏推理系统侧的加速设计：目标是在尽量不破坏 target model 输出分布的前提下，让同一个模型在推理时更快地产生 token。\n相关仓库：DeepSpec\n提示 这篇主要想讲清楚两件事：一是标准 speculative decoding 的 verify 机制到底怎么工作；二是 DSpark 在 draft 和 verify 两个阶段分别改了什么。\nspeculative decoding 背景 大语言模型的标准生成方式是自回归解码：每次只生成一个新 token。这样做最稳，但也带来一个很现实的问题：\n模型越大，每一步 forward 越贵 回答越长，要走的步数越多 在线服务里用户一多，延迟和吞吐压力就会很明显 所以推理系统里一直有一个核心问题：\n能不能在不破坏目标模型输出分布的前提下，让一次“确认”尽量多产出几个 token？\n它的标准思路可以概括成两步：\ndraft 先让一个更便宜、更快的 draft model 或 draft 模块预写后面几个 token。 verify 再让真正的 target model 一次性验证这几个 token，接受能通过的最长前缀。 如果草稿质量足够高，那么 target model 一次验证就不只“确认 1 个 token”，而可能确认 2 个、3 个甚至更多 token。这样平均到每个 token 的延迟就会下降。\nspeculative decoding 之所以重要，不只是因为它快，而是因为经典路线追求的是 lossless / exact 加速，也就是：\n最终采样分布仍然和 target model 一致 不是简单拿一个小模型直接替代大模型 而是让小模型负责“提案”，大模型保留“裁决权” 所以它和“直接用弱一点的模型换速度”不是一个问题。\n看起来简单，但实际会遇到很多问题，例如：\ntext draft 太弱，接受率太低，白忙一场； draft 太强，自己又变得很贵； 一次提太长，后缀 token 很容易被拒； 高并发时，验证过多低质量 token 反而拖垮吞吐； 不同任务域里接受率差异很大，代码、数学、闲聊的表现不一样。所以后来的很多工作，本质上都在围绕三个变量做优化：\ndraft 怎么设计得更好 一次 draft 多长更合适 哪些 token 值得送去 verify DSpark 正是在这几个变量里，重点优化了前两个半：\n它让 draft 既保留并行速度，又补一点顺序依赖 它让 verify 不再固定吃完整段，而是按置信度截断前缀 下面先把标准 speculative decoding 的 verify 机制讲清楚，再看 DSpark 到底改了什么。\n普通自回归怎么做 假设当前已经有前缀：\n$$ y $$后面真正要生成的 token 是：\n$$ x_1, x_2, x_3 $$普通自回归的 target model 会这样做：\n跑一次，得到 $p_t(\\cdot \\mid y)$，采样出 $x_1$ 再跑一次，得到 $p_t(\\cdot \\mid y, x_1)$，采样出 $x_2$ 再跑一次，得到 $p_t(\\cdot \\mid y, x_1, x_2)$，采样出 $x_3$ 所以：\n3 个新 token 要 3 次 target 解码 step speculative decoding 的验证思路 现在换成 speculative decoding。\n先让 draft model 提议一小段，流程也是和上面一样，只是模型更小，成本更低：\n$$ \\hat{x}_1, \\hat{x}_2, \\hat{x}_3 $$这里帽子表示“草稿提议”。\n然后 target model 不再一步一步自己生成，而是做一件事：\n把前缀 $y$ 和 draft 提议的这一小段一起看掉。\n也就是一次性处理：\n$$ y, \\hat{x}_1, \\hat{x}_2, \\hat{x}_3 $$ 为什么一次前向能得到多个位置的信息 这是 Transformer 的基本能力。因为在 causal mask 下，一次前向就可以同时输出每个位置的 logits。\n如果输入序列是：\n$$ [y, \\hat{x}_1, \\hat{x}_2, \\hat{x}_3] $$那么输出时会同时得到这几个位置对应的条件分布：\n第 1 个位置对应 $$ p_t(\\cdot \\mid y) $$ 第 2 个位置对应 $$ p_t(\\cdot \\mid y, \\hat{x}_1) $$ 第 3 个位置对应 $$ p_t(\\cdot \\mid y, \\hat{x}_1, \\hat{x}_2) $$ 再往后一个位置对应 $$ p_t(\\cdot \\mid y, \\hat{x}_1, \\hat{x}_2, \\hat{x}_3) $$所以一次前向，其实已经把“如果沿着这条草稿路径继续往下走，每一步 target 会怎么看”都算出来了。\n这点特别关键：\ntarget 不是只算第一个草稿 token，而是把整条草稿前缀上的每个位置分布都并行算出来了。\n这也是后面“一次 verify 可能接受多个 token”的基础。\n验证的正常流程 假设 draft 提议了：\n$$ \\hat{x}_1, \\hat{x}_2, \\hat{x}_3 $$draft 还提供了每个位置自己对提议 token 的概率：\n$$ p_d^1(\\hat{x}_1),\\quad p_d^2(\\hat{x}_2),\\quad p_d^3(\\hat{x}_3) $$target 一次前向后，也拿到了对应位置对这些 token 的概率：\n$$ p_t^1(\\hat{x}_1),\\quad p_t^2(\\hat{x}_2),\\quad p_t^3(\\hat{x}_3) $$然后从左到右做接受测试。\n第一步，检查第 1 个 token，接受概率是：\n$$ \\alpha_1 = \\min\\left(1, \\frac{p_t^1(\\hat{x}_1)}{p_d^1(\\hat{x}_1)}\\right) $$如果第 1 个就拒绝了：\n后面 $\\hat{x}_2, \\hat{x}_3$ 全部作废 从第 1 位 residual 分布补一个 token 这一轮结束 如果第 1 个接受，再检查第 2 个，接受概率是：\n$$ \\alpha_2 = \\min\\left(1, \\frac{p_t^2(\\hat{x}_2)}{p_d^2(\\hat{x}_2)}\\right) $$如果第 2 个拒绝：\n$\\hat{x}_3$ 作废 从第 2 位 residual 分布补一个 token 这一轮结束 如果前两个都接受，再检查第 3 个，接受概率是：\n$$ \\alpha_3 = \\min\\left(1, \\frac{p_t^3(\\hat{x}_3)}{p_d^3(\\hat{x}_3)}\\right) $$如果第 3 个也接受了，那这一轮就一口气接受了 3 个 draft token。\n不过在很多具体实现里，这一轮通常还会顺手再从 target 的最后一个位置补采一个 next_token。所以实际提交回输出序列的，往往是“被接受的 draft 前缀 + 1 个 target token”。\n为什么一次可以接受多个 现在就能看出来了：\n一次 target 前向虽然只跑了一次，但它已经同时给出了：\n第 1 位的 target 判断 第 2 位的 target 判断 第 3 位的 target 判断 所以如果这些位置都通过 acceptance 测试，那么：\n第 1 个 token 合法 第 2 个 token 也合法 第 3 个 token 也合法 于是这一轮就能直接提交整个前缀：\n$$ \\hat{x}_1, \\hat{x}_2, \\hat{x}_3 $$这就是“一次计算接受多个 token”的本质：\n不是一次前向直接“生成了多个 token”，而是一次前向验证了多个 draft token 是否都能作为 target 路径上的合法前缀继续保留。\n标准验证公式是什么 设第 $k$ 个 draft token 是 $\\hat{x}_k$，draft 和 target 在这个位置给出的概率分别是：\n$$ p_d^k(\\hat{x}_k), \\quad p_t^k(\\hat{x}_k) $$标准 speculative decoding 的接受概率是：\n$$ \\alpha_k = \\min\\left(1,\\frac{p_t^k(\\hat{x}_k)}{p_d^k(\\hat{x}_k)}\\right) $$直觉上：\n如果 target 比 draft 更喜欢这个 token，就直接接受 如果 draft 比 target 更激进，就按比例接受 如果拒绝了，那么就由 target 对应的 residual 分布里采样：\n$$ r_k(v)\\propto \\max\\left(p_t^k(v)-p_d^k(v), 0\\right) $$这里的 $v$ 表示“词表中的任意 token”，不是某一个已经提议出来的 token。也就是说，residual 是一个全词表分布，不是只在 $\\hat{x}_k$ 上做修补。\n上面的 $\\propto$ 表示“正比于”，也就是这还是一个未归一化的分布。真正采样前还要除以总和，变成：\n$$ r_k(v)=\\frac{\\max\\left(p_t^k(v)-p_d^k(v), 0\\right)}{\\sum_u \\max\\left(p_t^k(u)-p_d^k(u), 0\\right)} $$也就是说：对那些 target 比 draft 更喜欢的 token，把它们还没覆盖到的概率质量补回来，最后再归一化成合法分布。\n这里还要特别注意一点：\n接受规则是按前缀逐位置执行的，但 target 对这些位置的条件分布可以在一次前向中并行算出来。\n为什么理论上输出与 target model 直接输出分布一致 这里只分析单个位置就行，因为每个位置的逻辑是一样的。\n什么叫分布一致 如果 target model 在某个位置真正定义的分布是：\n$$ p_t(x) $$那么标准 lossless speculative decoding 的目标就是让最终输出仍然满足：\n$$ P(\\text{final}=x)=p_t(x) $$它是怎么实现的 最终分布由两部分组成：\ndraft 提案并被接受 的那部分 reject 后由 residual 补齐 的那部分 对某个 token $x$ 来说，直接接受贡献的概率质量是：\n$$ p_d(x)\\cdot \\min\\left(1,\\frac{p_t(x)}{p_d(x)}\\right)=\\min(p_d(x),p_t(x)) $$而 residual 部分要补的缺口则是：\n$$ p_t(x)-\\min(p_d(x),p_t(x))=\\max(p_t(x)-p_d(x),0) $$也就是说：\ndraft 先覆盖一部分 target 概率质量 覆盖不到的那部分，再由 residual 分布补齐 两部分合起来，最后正好恢复成 target 分布。\n一个数值例子 假设某一位置上，target 只有两个候选 token：$A$ 和 $B$，它们的概率是：\n$$ p_t(A)=0.8,\\quad p_t(B)=0.2 $$draft 分布是：\n$$ p_d(A)=0.5,\\quad p_d(B)=0.5 $$第一步：draft 先提案 draft 会先从自己的分布里抽一个 token：\n抽到 $A$ 的概率是 $0.5$ 抽到 $B$ 的概率是 $0.5$ 第二步：acceptance rule 对 $A$：\n$$ \\alpha(A)=\\min(1,0.8/0.5)=1 $$对 $B$：\n$$ \\alpha(B)=\\min(1,0.2/0.5)=0.4 $$所以：\n如果 draft 抽到 $A$，一定接受 如果 draft 抽到 $B$，只以 $0.4$ 的概率接受，以 $0.6$ 的概率拒绝 第三步：先只算“直接接受”这条分支 $A$ 通过接受分支进入最终输出的概率是：\n$$ 0.5\\times 1=0.5 $$$B$ 通过接受分支进入最终输出的概率是：\n$$ 0.5\\times 0.4=0.2 $$所以到这里为止，接受分支已经贡献了：\n$A$：$0.5$ $B$：$0.2$ 但 target 真正想要的是：\n$A$：$0.8$ $B$：$0.2$ 因此当前还缺：\n$A$：$0.3$ $B$：$0$ 第四步：reject 分支总共有多少概率质量 reject 分支只会在“draft 抽到 $B$ 且 $B$ 被拒绝”时触发。\n它的总概率是：\n$$ 0.5\\times (1-0.4)=0.5\\times 0.6=0.3 $$也就是说，residual 分支总共会携带 $0.3$ 的概率质量。\n第五步：residual distribution 怎么分配这 $0.3$ residual 分布定义为：\n$$ r(x)\\propto \\max(p_t(x)-p_d(x),0) $$所以：\n对 $A$：\n$$ \\max(0.8-0.5,0)=0.3 $$对 $B$：\n$$ \\max(0.2-0.5,0)=0 $$因此 residual 的未归一化权重是：\n$A$：$0.3$ $B$：$0$ 归一化后得到：\n$$ r(A)=1,\\quad r(B)=0 $$也就是说，只要进入 residual 分支，就一定会补出 $A$。\n第六步：把 residual 分支加回总分布 因为 residual 分支的总概率质量是 $0.3$，且它一定输出 $A$，所以它对最终结果的贡献是：\n$A$：$0.3$ $B$：$0$ 现在把两条分支加起来：\n$A$：$0.5+0.3=0.8$ $B$：$0.2+0=0.2$ 所以最终恢复成：\n$$ P(\\text{final}=A)=0.8,\\quad P(\\text{final}=B)=0.2 $$正好等于 target 分布。\n并行 draft 是什么 在继续看 DSpark 之前，最好先单独理解一下“并行 draft”这个概念。因为 DSpark 的第一部分创新，正是建立在并行 draft 的优点和缺点之上。\n并行 draft 与 MTP（Multi-Token Prediction）相关，但不要直接划等号：MTP 更偏训练/多 token 预测范式；并行 draft 是推理侧“一次给出多个位置草稿”的提案方式。DeepSeek 线上基线里的 MTP-1 是另一条对照线。\n它和普通自回归有什么本质区别 普通自回归语言模型做的是链式生成：\n$$ p(x_{1:B}\\mid y)=\\prod_{k=1}^{B} p(x_k\\mid y,x_{","permalink":"https://www.caulif.com/posts/dspark/","summary":"从 speculative decoding 的验证机制讲起，整理 DeepSeek DSpark 在 draft 和 verify 两个阶段的关键改动。","title":"DSpark"},{"content":"agent邮箱，不仅是给了agent发邮箱能力，也是给了agent一个身份，同样邮件也是一个高频的使用场景（我的第一个邮箱就是qq邮箱，没想到第一个agent邮箱也是qq邮箱）\nskills/CLi 分发和配置模式也很丝滑，一条prompt就可以配置好这个邮箱，感觉是比较好的分发方式了\n产品定位是Agent-first mail，从agent视角设计了邮箱接口，CLI 的邮箱基本能力也比较完善\n不足之处是感觉也没啥创新，只是在产品包装和接入上做了一下，或许邮箱这种通信方式就不适合Agent呢，从Agent视角开发新的通信协议或者方式会不会比把人类用的产品移植过来更好？然后感觉和腾讯生态的产品也没啥联动，没什么壁垒。\n","permalink":"https://www.caulif.com/posts/%E8%85%BE%E8%AE%AFagent%E9%82%AE%E7%AE%B1%E4%BA%A7%E5%93%81%E8%A7%82%E5%AF%9F/","summary":"对腾讯 agent.qq.com 邮箱的一点产品观察：Agent-first mail 的身份、分发方式、邮箱能力，以及目前看起来还差一点的地方。","title":"agent.qq.com 邮箱"},{"content":"RAG 的核心是：\ntext 先找资料，再基于资料回答。普通 LLM 回答主要依赖模型参数里的记忆：\ntext 用户问题 -\u0026gt; 模型参数 -\u0026gt; 回答RAG 在中间加了外部知识访问：\ntext 用户问题 -\u0026gt; 检索相关资料 -\u0026gt; 组装上下文 -\u0026gt; 模型基于证据回答它要解决的不是“让模型更会背”，而是让系统能访问可更新、可追溯、可权限控制的外部知识。\n基本流程 一个经典 RAG 系统通常分成两个阶段。\n离线索引阶段 flowchart TD A[\u0026#34;原始资料\u0026#34;] --\u0026gt; B[\u0026#34;清洗与解析\u0026#34;] B --\u0026gt; C[\u0026#34;切分 chunk\u0026#34;] C --\u0026gt; D[\u0026#34;生成 embedding\u0026#34;] D --\u0026gt; E[\u0026#34;写入索引 / 向量库\u0026#34;] C --\u0026gt; F[\u0026#34;保存 metadata 与来源\u0026#34;] 这一阶段回答的问题是：\ntext 哪些资料可以被检索？它们被切成什么粒度？如何保留来源、权限和元数据？在线问答阶段 flowchart TD A[\u0026#34;用户问题\u0026#34;] --\u0026gt; B[\u0026#34;查询改写 / 扩展\u0026#34;] B --\u0026gt; C[\u0026#34;检索候选 chunk\u0026#34;] C --\u0026gt; D[\u0026#34;重排 / 过滤\u0026#34;] D --\u0026gt; E[\u0026#34;组装上下文\u0026#34;] E --\u0026gt; F[\u0026#34;LLM 生成回答\u0026#34;] F --\u0026gt; G[\u0026#34;引用来源 / 自检 / 输出\u0026#34;] 这一阶段回答的问题是：\ntext 用户问这个问题时，应该找哪些资料？哪些资料真正相关？模型回答是否忠于证据？现在有多种RAG的演化版本，但是我感觉这个东西意义其实不是很大，懒得研究和整理了\n相关论文 2020 年 NeurIPS 论文提出 RAG：Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks（Lewis et al., arXiv:2005.11401）。\n大型语言模型的局限性：大型预训练语言模型（如 T5 或 BART）能够在其网络参数中隐式地存储大量事实知识。然而，它们在访问和精确操作这些知识时仍存在局限性，容易产生“幻觉”(hallucinations)。\n记忆难以更新与解释：纯参数化模型无法轻松地扩展或修改其记忆，也不能直观地提供其预测结果的来源依据。\nRAG 模型架构 检索器 (Retriever) —— 非参数化记忆\n组件基础：检索器基于 DPR (Dense Passage Retriever) 构建，采用双编码器架构。 工作原理：它将维基百科切分成约 2100 万个约 100 词的文档片段，并构建成密集向量索引（非参数化记忆）。给定输入查询 (x)，检索器使用最大内积搜索 (MIPS) 找出最相关的 top-K 个文档。 生成器 (Generator) —— 参数化记忆\n组件基础：生成器使用的是 BART-large，这是一个约 4 亿参数的预训练 seq2seq Transformer 模型。 工作原理：在生成文本时，模型会将输入 (x) 与检索到的文档内容 (z) 进行拼接，BART 基于这些上下文信息生成最终输出序列。 ","permalink":"https://www.caulif.com/posts/rag/","summary":"整理 RAG 的核心思路、离线索引与在线问答流程，以及 Retrieval-Augmented Generation 论文中的检索器和生成器架构。","title":"RAG"},{"content":"一、什么是反向传播（Backpropagation） 1. 计算总误差（Loss） 前向计算结束后，模型会得到一个最终的 Loss 值。Loss 越大，说明模型错得越离谱。\n数学公式如下：\n$$ L = f(\\hat{\\mathbf{y}}, \\mathbf{y}) $$2. 梯度（Gradient）与链式法则（Chain Rule） 模型有很多个参数（权重），Loss 的高低是所有参数共同作用的结果。反向传播的核心任务是：计算 Loss 对每一个参数的“导数”（即梯度）。\n梯度（Gradient）：代表了某个参数的变化对最终 Loss 的影响有多大。如果一个参数的梯度很大，说明它对错误负有主要责任。\n链式法则（Chain Rule）：一旦标量 (L) 确定，模型就需要计算它对网络中所有可学习参数矩阵 (\\mathbf{W}_l) 的偏导数。\n神经网络在数学上可以被视为一个多层嵌套的复合函数：\n$$ L = f_L(f_{L-1}(...f_1(\\mathbf{x}, \\mathbf{W}_1)..., \\mathbf{W}_{L-1}), \\mathbf{W}_L) $$根据微积分的链式法则，对于任意中间层 (l) 的参数 (\\mathbf{W}_l)，其梯度计算必须沿输出端向输入端逆向追溯：\n$$ \\frac{\\partial L}{\\partial \\mathbf{W}_l} = \\frac{\\partial L}{\\partial \\mathbf{a}_L} \\times \\frac{\\partial \\mathbf{a}_L}{\\partial \\mathbf{a}_{L-1}} \\times \\dots \\times \\frac{\\partial \\mathbf{a}_{l+1}}{\\partial \\mathbf{a}_l} \\times \\frac{\\partial \\mathbf{a}_l}{\\partial \\mathbf{W}_l} $$(其中 (\\mathbf{a}_l) 表示第 (l) 层的激活值向量。上式是便于理解的示意写法；真实网络里是张量形式的 Jacobian，实现上由自动微分完成。)\n3. 参数更新（Optimizer Update） 计算出梯度向量后，训练进入最优化（Optimization）阶段。\n最速下降原理：为了最小化 Loss，参数必须沿着梯度的反方向（即函数值下降最快的方向）进行迭代移动。\n数学更新算式：\n$$ \\mathbf{W}_l^{(t+1)} = \\mathbf{W}_l^{(t)} - \\eta \\cdot \\Delta(\\nabla_{\\mathbf{W}_l} L^{(t)}) $$ (\\eta)（学习率）充当步长因子，控制参数更新大小。 (\\Delta(\\cdot)) 代表优化器算子 4. 优化器 主要有三类：\n传统梯度下降族\nSGD (随机梯度下降)：最基础的优化器，每次更新仅依据当前样本的梯度乘以固定的学习率。\nSGDM (带动量的 SGD)：引入了物理学中的“动量（Momentum）”概念。它会累积历史梯度的方向（一阶矩），像一个滚下山的雪球，在梯度方向一致时加速，在梯度震荡时起到平滑作用，有助于缓解局部极小值和鞍点附近的震荡问题。\n自适应学习率族（Adaptive Learning Rate）\n这类优化器的核心思想是“为每个参数量身定制学习率”：频繁更新的参数，学习率调小一点；比较稀疏、很少更新的参数，学习率放大一点。\nAdaGrad：最早的自适应优化器，通过累加历史梯度的平方（二阶矩）来缩放学习率。缺点是后期分母过大，导致学习率过早“干涸”变为 0。\nRMSProp：引入了指数移动平均（EMA），只关注最近一段时间的梯度平方，解决了 AdaGrad 学习率后期不更新的问题。\nAdam (Adaptive Moment Estimation)：它同时结合了 SGDM 的动量（一阶矩） 和 RMSProp 的自适应缩放（二阶矩）。既能自动调整方向，又能自动调整每一步的步长，是通用深度学习中最稳健、最常用的优化器。\n一阶矩（动量）：\n$$ m_t = \\beta_1 \\cdot m_{t-1} + (1 - \\beta_1) \\cdot g_t $$ (g_t)：当前步骤反向传播算出来的梯度 (\\frac{\\partial L}{\\partial w_t})。\n(\\beta_1)：一阶衰减系数\n物理意义：惯性。当前方向要结合历史前进的方向，平滑掉梯度的震荡。\n二阶矩（方差）：\n$$ v_t = \\beta_2 \\cdot v_{t-1} + (1 - \\beta_2) \\cdot g_t^2 $$ 物理意义：阻力/自适应调节。如果某个参数的梯度历史累积非常大（(v_t) 很大），说明它更新频繁，后面做分母时就会让它的实际步长变小。\n偏差修正（Bias Correction）:因为 (m_0) 和 (v_0) 初始被设为 0，在训练刚开始的几步，公式算出来的值会严重偏向 0。所以需要数学修正：\n$$ \\hat{m}_t = \\frac{m_t}{1 - \\beta_1^t}, \\quad \\hat{v}_t = \\frac{v_t}{1 - \\beta_2^t} $$ 随着训练步数 (t) 增大，(\\beta^t) 迅速趋近于 0，修正系数趋近于 1，该步骤在后期失去影响\n参数更新：\n$$ w_{t+1} = w_t - \\frac{\\eta}{\\sqrt{\\hat{v}_t} + \\epsilon} \\cdot \\hat{m}_t $$ (\\eta)：全局学习率（Learning Rate）。\n(\\epsilon)：防止分母为 0 的极小值\n正则化改进族\nAdamW：针对 Adam 的重要改进。在传统的 Adam 中，L2 正则化（权重衰减，Weight Decay）由于自适应梯度的存在，在数学上无法正确生效。AdamW 将权重衰减与梯度的更新解耦，直接作用于参数更新的最后一步。\n在机器学习中，为了防止模型过拟合，我们通常会引入 L2 正则化（也叫权重衰减，系数为 (\\lambda)）。它的目的是在损失函数中加上惩罚项，让权重 (w) 尽量保持较小的值。\n公式如下：\n$$ w_{t+1} = w_t - \\eta \\cdot \\lambda \\cdot w_t - \\frac{\\eta}{\\sqrt{\\hat{v}_t} + \\epsilon} \\cdot \\hat{m}_t $$ 其中**(\\eta \\cdot \\lambda \\cdot w_t)：这一项就是权重衰减**。\n二、LLM 训练中的主流反向传播与优化方法 训练几百亿甚至上千亿参数的 LLM 时，原始的反向传播方法根本无法运行，因为它需要把前向计算的所有中间状态（Activation，激活值）都保存在显存里，等到反向传播时使用。LLM 的显存开销会发生显存爆炸\n现在主流有以下几种改进的主流方式：\n1. 激活值检查点（Activation Checkpointing / Gradient Checkpointing） 原理：用时间换空间。这两个名字在工程里常混用，指同一类技巧：前向计算时不再保存所有层的中间激活值，而是只隔几层保存一个 Checkpoint。\n反向传播时：当反向传播需要某个未保存的激活值时，模型会从最近的一个检查点出发，临时重新做一次局部的前向计算。\n2. 混合精度训练（Mixed Precision Training: FP16 / BF16 / FP8） 现代 LLM 训练几乎不使用传统的标准单精度浮点数（FP32，4字节）做全部计算。\n主流做法：前向和反向的大部分计算使用 BF16（Brain Floating Point 16）或 FP16（2字节）。FP16 训练里常配合 loss scaling；BF16 动态范围更大，很多场景更省心。\n最新趋势：在极大规模的集群训练中（如 NVIDIA H100/B200 时代），FP8（1字节）训练也在普及。\n更准确地说：矩阵乘等计算常用 BF16/FP16（或 FP8）低精度浮点；master weights / 优化器状态 则常保留更高精度（如 FP32），避免更新过程数值漂掉。这不是把梯度做成整数量化，而是低精度浮点计算 + 高精度主状态。\n3. ZeRO (Zero Redundancy Optimizer) 驱动的反向传播 微软 DeepSpeed 提出的 ZeRO 技术是分布式 LLM 训练的基石。在多卡训练时，它改变了反向传播的行为：\nZeRO-1 (优化器状态切分)：反向传播算完梯度后，每个 GPU 只更新自己负责的那部分优化器状态。 ZeRO-2 (梯度切分)：在反向传播过程中，某个参数的梯度一旦计算出来，立刻跨卡聚合（Reduce）并分发出去，然后在本卡销毁，不再留在显存里。 ZeRO-3 (参数切分)：前向和反向时，每层参数用完立刻释放，只在需要时从其他卡通信拉取。 当大模型太大了，一张显卡的显存装不下。我们必须用多张卡联合训练\n每卡算一部分，算完通信共享一下，使用完自己的部分立刻销毁，此外模型参数也切分\n用高频的显卡间网络通信，去换取更多的显存空间\n4. LoRA (Low-Rank Adaptation) \u0026ndash;这个另一篇讲了，这里不讲了 ","permalink":"https://www.caulif.com/posts/%E5%8F%8D%E5%90%91%E4%BC%A0%E6%92%AD%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/","summary":"整理反向传播的 Loss、梯度、链式法则、优化器更新，以及 LLM 训练中常见的显存优化方法。","title":"反向传播（Backpropagation）学习笔记"},{"content":"长上下文退化是指：模型虽然支持很大的上下文窗口，但当输入变长、信息位置变远、噪声变多或任务需要跨段推理时，模型未必能稳定利用全部上下文，输出质量可能下降。\n这不是“上下文窗口不够大”的问题，而是“有效利用上下文”的问题。\nContext window 是模型最多能接收多少 token。\nEffective context length 是模型在真实任务里能可靠使用多少 token。\n以常见 coding agent 的默认配置为例，上下文往往不会一上来就拉满模型标称的上限（例如 1M）。\n标称 32K、128K、1M token，并不等于模型能以同等质量使用每一个位置的信息。\n例如关键信息位于中间、存在大量干扰信息、需要多跳推理、需要理解隐含关系时，性能常会下降。\n为什么会发生 1. 注意力不是“均匀阅读全文” Transformer 的 self-attention 会为当前 token 计算它应该看前面哪些 token。\n但这不等于模型会像人一样稳定读完整篇文章。每个 query 对前文 token 的注意力权重会经过 softmax 归一化，很多 token 在同一层、同一头里竞争有限的注意力质量。输入越长，候选 token 越多，噪声、重复内容、过期信息和无关段落也越容易参与竞争。\n所以长上下文里的难点不是“模型看不见”，而是“模型看见了也未必选对、记住、整合并用于生成”。\n2. 模型有位置偏置 很多模型对上下文位置的利用呈现不均匀模式：开头和结尾更容易被利用，中间更容易被忽略。\nLost in the Middle（Liu et al.）指出长上下文里中间位置的信息更容易被用不好。后续关于 positional attention bias 的研究进一步把它解释为一种位置注意力偏置：模型不只是按语义重要性分配注意力，也会受位置影响。\n直观理解：\ntext 开头通常包含系统提示、任务说明、文章开头、标题或元信息，训练中经常很重要。 结尾离当前生成位置最近，天然更容易被自回归模型利用。 中间位置既不占开头优势，也不占近因优势，容易被大量内容淹没。3. 位置编码存在长距离泛化问题 RoPE、绝对位置编码、相对位置编码等机制让模型知道 token 的顺序和距离。\n但如果训练时主要见到较短序列，推理时突然给很长序列，模型需要在训练分布之外处理位置关系。\n以 RoPE 为例，它通过旋转 Query 和 Key 的方式编码相对位置。长上下文扩展时，如果位置范围远超训练长度，旋转频率、距离关系和注意力分数分布可能进入模型不熟悉的区域。因此很多长上下文方法会做位置插值、RoPE scaling（如 NTK-aware、YaRN、PI 等思路）、继续训练或专门的长上下文数据训练，而不是只把窗口参数调大。\n4. 训练目标和数据分布更偏短程依赖 语言模型主要通过 next-token prediction 训练。很多训练样本里的预测线索来自局部上下文：前一句、当前段落、最近几段。\n这会让模型天然更擅长短程依赖，而不是稳定处理几十万 token 之外的证据。即使模型架构允许长距离注意力，训练数据中是否有足够多高质量长文档、多跳依赖、跨段推理样本，仍然决定它能否真的学会长上下文利用。\n5. 检索成功不等于推理成功 “needle in a haystack” 任务通常把一条答案句藏进长文本里，让模型找出来。这类任务能测一部分长上下文检索能力，但不能代表所有长上下文理解。\n真实任务更复杂：\ntext 证据可能分散在多个位置。 问题和证据可能没有相同关键词。 文档里可能有相互冲突的旧信息和新信息。 模型需要先判断哪些内容相关，再组合推理。所以长上下文评估不能只看“能不能找到一句原文”，还要看跨文档推理、抗干扰、位置鲁棒性、引用准确性和任务成功率。\n6. Agent 场景会出现 context rot 在场景比如coding agent 中，上下文会不断积累：\ntext 用户的新要求。 工具调用结果。 搜索结果。 文件片段。 测试日志。 失败尝试。 过期计划。 历史对话。如果这些内容都原样塞回模型，模型可能被旧错误日志、重复信息或已经废弃的假设影响。工程上常把这种“上下文越积越乱，模型越难稳定工作”的现象叫 context rot。\nSo 不要把“能塞进去”当成“能用得好”\n关键信息要放在模型容易利用的位置\n控制噪声比单纯拉长上下文更重要\n长任务要用压缩和状态管理\n评估要做位置扫描和干扰测试\n","permalink":"https://www.caulif.com/posts/%E4%B8%8A%E4%B8%8B%E6%96%87%E9%80%80%E5%8C%96/","summary":"整理长上下文退化的含义、位置偏置、长距离泛化、训练分布、检索与推理差异，以及 Agent 场景中的 context rot。","title":"长上下文退化"},{"content":" 产品细节会随版本变化，下文对照公开文档与撰写时的使用体验整理；具体字段、路径和限额以各产品官网为准。\nAgent Memory 是一套跨会话保存、选择、更新和取回上下文的工程机制。它要解决的不是“把所有历史都记住”，而是把用户偏好、项目规则、任务状态、历史经验和可复用流程放到正确位置，并在未来任务中以可控、可审查、可删除的方式重新进入上下文。\nAgent Memory 是 harness 中的长期层：它负责决定哪些信息应该跨会话保留，保留成什么形态，什么时候被检索回来，以及如何避免旧记忆污染新任务。\n它不是一个单独的prompt，而是一组机制：\n显式项目说明：例如 AGENTS.md、CLAUDE.md、.hermes.md。 自动长期记忆：例如项目 memory、用户 memory、历史摘要。 程序性记忆：例如 Skills、rules、playbooks、custom commands。 治理机制：例如查看、编辑、合并、删除、来源标注、权限隔离。 Agent Memory 想解决什么问题 LLM 的默认工作方式是上下文窗口。上下文窗口像短期工作台：用户任务、系统指令、工具输出、文件片段、搜索结果都临时放在这里。问题是，任务结束后这些内容不会自然变成下一次可用的长期知识。\n这会导致几个典型痛点：\n用户反复说明同样偏好。 Agent 反复重新探索同一个项目，例如测试命令、目录结构、代码规范。 长任务中断后难以恢复，例如已经排查过哪些假设、哪些命令失败过。 过去的失败没有沉淀，例如某类 bug 的根因、某个工具的坑。 把全部历史塞进 prompt 又会增加成本、延迟和噪声。 所以 Agent Memory 的核心目标是：\n减少用户重复解释。 提升跨会话连续性。 让 agent 更稳定遵守项目规则。 把失败经验转成未来行为改进。 让长期信息可审查、可撤销、可迁移。 Agent Memory 的核心概念 1. 短期记忆 当前上下文窗口里的内容。\n2. 长期记忆 跨会话保留的信息。\n3. 显式记忆 这里的“显式记忆”是广义说法，更准确地说是人类可见、可编辑、可版本控制的显式长期上下文。很多产品不会把它直接叫 memory，但它确实承担了长期影响 agent 行为的作用。\n典型形式：\nAGENTS.md CLAUDE.md .hermes.md README 架构文档 项目规则文件 显式记忆适合团队共享的稳定规则。它的优点是可审查、可讨论、可迁移。\n4. 隐式或自动记忆 自动记忆是 agent 或系统从会话中自动提炼出来的内容。（每个系统不同）\n典型形式：\ntext 自动生成的 memory 文件。 历史摘要。 用户画像。 provider 里的长期记忆。 对话结束后的 memory extraction。Agent Memory 系统中的 Memory 生命周期 一个完整的 memory 系统，至少要包含下面几个阶段。\n1. 观察 系统从对话、工具调用、文件变更、测试结果、用户反馈中观察候选信息。\n2. 提取 从原始历史中提取稳定结论。\n3. 路由 判断这条 memory 应该放在哪里。\n候选内容 推荐路由 团队共享规则 AGENTS.md / CLAUDE.md 个人偏好 用户 memory / USER.md 项目调试经验 项目 memory topic 反复执行流程 Skill / playbook 大量历史对话、Memory Cards、项目经验 RAG / memory provider 实时状态 MCP / API / 搜索 4. 写入 写入时要尽量保留下面这些元素：\ntext 结论。 来源。 适用范围。 更新时间。 可信度。 是否经过用户确认。5. 检索 未来任务中，系统要判断哪些 memory 相关。\n检索可以基于：\ntext 项目路径。 当前任务类型。 用户身份。 关键词。 embedding 相似度。 最近使用情况。 显式引用。6. 注入 取回的 memory 不能原样无限塞入 prompt。要考虑：\n顺序：硬规则优先，证据和背景靠后。 长度：越常驻越短。 冲突：较新、较近、较明确的规则通常优先。 来源：自动推断要弱于用户明确说明。 7. 更新和遗忘 Memory显然需要支持更新和遗忘\n典型操作包括：\n合并重复记忆。 删除过期记忆。 替换错误记忆。 降低不可靠记忆权重。 把个人 memory 提升为项目规则。 把散乱经验整理成 Skill。 只会写入、不会遗忘的 memory 系统，长期一定会退化。\nMemory 是无权重更新的学习 Agent 可以不训练模型，也能在行为上“学会”某些东西。方式就是把过去的反馈转成未来上下文。\n例如：\ntext 用户纠正一次：当前任务修正。 用户纠正多次：形成长期偏好。 某类错误反复出现：形成工作流规则。 某个流程被证明有效：沉淀为 Skill。这就是无权重更新意义上的“学习”（in-context / prompt-level learning），改变的是未来上下文，而不是模型参数。\n好的 Agent Memory 应该满足什么 1. 可见 用户应该能知道系统记了什么。\n不可见的 memory 会带来两个问题：一是用户无法纠错，二是 agent 的行为变化无法解释。\n2. 可编辑 Memory 不可能永远正确。用户应该能修改错误结论、删除过期偏好、合并重复内容。\n3. 可撤销 尤其是个人偏好、身份信息、工作资料和历史对话，必须能删除。\n4. 可分层 团队规则、个人偏好、项目经验、工作流、外部事实不能混成一层。\n5. 可追溯 重要 memory 最好知道来源：\n是用户明确说过？ 是系统推断？ 是某次任务失败的复盘？ 是外部文档里的事实？ 来源不同，可信度也不同。\n6. 可评估 Memory 是否有效，要看它是否改善任务，而不是看记了多少条。\n可以看这些维度：\n维度 要看什么 个性化 是否更符合用户长期偏好，比如表达风格、格式、决策偏好 连续性 跨会话后是否能接上之前的任务、项目和背景 任务成功率 有 memory 后，任务完成率是否提升 重复解释减少 用户是否少重复说明同样背景和要求 错误减少 是否减少同类错误、重复踩坑、错误假设 检索相关性 取回的 memory 是否真的和当前任务相关 记忆准确性 memory 是否忠实反映用户说过或做过的事 过期控制 旧偏好、旧项目状态是否会被及时更新或废弃 冲突处理 新旧记忆冲突时，是否能优先采用更可靠、更近期、更明确的信息 安全与隐私 是否避免保存敏感信息，是否能查看、删除、撤销 上下文污染 是否把不相关历史带进当前任务，导致误导 成本与延迟 memory 检索和注入是否显著增加响应成本或变慢 更简单地说，通用 Agent 的 memory 评估可以分成四类：\n效果 任务成功率是否提高 用户满意度是否提高 是否减少重复说明 是否更符合用户偏好 质量 记忆是否准确 是否相关 是否不过期 是否能处理冲突 安全 是否避免记录敏感信息 用户是否能查看、编辑、删除 是否会跨用户、跨项目泄漏 成本 是否增加太多 token 是否拖慢响应 是否引入太多无关上下文 Claude Code、Codex 与 Hermes 的 Memory 设计 下面我找了三个我自己用的主要的agent 产品如何实现 Agent Memory。\n三个产品的总体差异 系统 核心 memory 形态 设计取向 适合承载 Claude Code CLAUDE.md、auto memory 目录、API memory tool 文件化、分层说明、按需读取 项目规则、个人偏好、调试经验 Codex AGENTS.md、Memories、Skills；MCP 作为外部上下文接口 指令、历史、流程、外部上下文分层 repo 规则、跨线程经验、可复用工作流 Hermes Agent MEMORY.md、USER.md、memory providers、skills 小容量核心记忆 + 可插拔外部 provider 用户画像、环境事实、provider 级长期用户建模 Claude Code 的 Memory 设计 Claude Code 的官方 memory 文档把核心机制分成两类：CLAUDE.md 和 auto memory。\nCLAUDE.md：显式项目说明 显式项目说明，没啥好说的，值得一提的是Claude Code 支持多层级 CLAUDE.md。\nAuto memory：项目记忆目录 Claude Code 的 auto memory 会在项目维度保存记忆。官方文档描述的典型结构是：\ntext ~/.claude/projects/\u0026lt;project\u0026gt;/memory/ ├── MEMORY.md ├── debugging.md ├── api-conventions.md └── ...MEMORY.md 是入口和索引，topic 文件保存更细的主题内容。这个结构很像“小型知识库”：入口负责导航，细节按需读取。\n官方文档还把 auto memory 描述为按仓库共享、每个会话加载，但启动时只加载前 200 行或 25KB。这一点很关键：auto memory 不是无限常驻上下文，而是需要保持索引化和高密度。\n为什么是“前 200 行或 25KB”？\n因为 memory 可能越写越多。所以它采用一种“索引 + 按需读取”的设计。\ntext MEMORY.md = 目录 / 索引 / 摘要(每次启动先加载的就是这个) 还有一些其他具体主题md文档（Claude 需要时再读） debugging.md api-conventions.md deployment.md Claude API memory tool：客户端侧记忆目录 Claude API 的 memory tool 允许 Claude 通过客户端提供的工具，对 /memories 目录中的文件执行创建、读取、更新和删除。\n它背后的核心思想是 just-in-time context retrieval：不是一开始加载所有相关信息，而是在任务需要时把记忆取回来。官方文档也强调这是 client-side tool，Claude 发起工具调用，应用侧负责在本地执行存储操作。\n具体生成方式没有更详细的说明了\nSkills 与 legacy custom commands：程序性流程 Claude Code 的产品语境里，custom commands 已经合并进 skills。官方文档说明，.claude/commands/deploy.md 和 .claude/skills/deploy/SKILL.md 都可以创建 /deploy，旧的 .claude/commands/ 文件仍然可用，但 skills 提供更多能力，例如支持文件夹、frontmatter、自动加载和跨工具的 Agent Skills 标准。\n因此在提到 Claude Code 的 custom commands 时，应理解为一种兼容旧形式的 slash-command 入口；更现代、更完整的程序性流程载体是 skills。\nClaude Code 的设计取向 Claude Code 的取向可以概括为：\n用 CLAUDE.md 承载明确、可审查、团队可共享的规则。\n用 auto memory 承载项目中逐渐学到的经验。\n用 skills / slash commands 承载可复用流程。\n用 topic 文件拆分长期记忆，避免一个大文件无限膨胀。\n就是上面说的\ntext MEMORY.md = 目录 / 索引 / 摘要(每次启动先加载的就是这个) 还有一些其他具体主题md文档（Claude 需要时再读） debugging.md api-conventions.md deployment.mdtopic 文件就是把长期记忆按主题分文件保存；MEMORY.md 做目录，细节放到各个主题文件，避免所有记忆挤在一个大文件里。\n用客户端 memory tool 保持用户对存储位置和执行权限的控制。\nCodex 的 Memory 设计 Codex 的设计更像分层上下文系统。官方 customization 文档把 Codex 的可定制面分成 AGENTS.md、Memories、Skills、MCP、Subagents 等部分。\nAGENTS.md：项目规则和工作约定 内容基本和claude.md差不多\nMemories：跨 thread 的历史上下文 Codex 的 Memories 默认关闭。启用后，Codex 可以把先前 thread 中稳定有用的信息带到未来工作里。\n官方文档说明，Codex 会把 memory 存在 Codex home 下：\ntext ~/.codex/memories/其中包含 summaries、durable entries、recent inputs 和 supporting evidence 等生成状态。官方还说明，Codex 会跳过仍活跃或过短的会话，会对生成的 memory 字段做 secret redaction，并在后台更新 memory，而不是每个 thread 结束后立刻更新。\nsummaries\t对过去 thread / 对话 / 工作过程的摘要 durable entries\t被认为比较稳定、未来还能用的长期记忆条目 recent inputs\t最近的用户输入或任务片段 supporting evidence\t支撑这条记忆的证据，例如它是从哪些对话或上下文里总结出来的 generated state\t这些都是系统自动生成和维护的状态，不是你主要手写编辑的文件\n举个例子。\n假设你多次在 Codex 里说：\ntext 这个项目不要用 npm，用 pnpm。Codex 的 memory 系统可能会形成：\nsummary\ntext 用户在多个前端项目中偏好使用 pnpm。durable entry\ntext 在该项目中，安装依赖和运行脚本优先使用 pnpm。recent input\ntext “不要用 npm，这个项目一直用 pnpm。”supporting evidence\ntext 来自某几个 thread 中用户纠正 npm 用法的记录。它们共同构成 Codex 自动生成 memory 时的本地状态，并帮助 Codex 判断：\n这条记忆是不是稳定？ 它从哪里来的？ 以后要不要继续用？ 是否需要合并成更长期的记忆？ Skills：程序性记忆 从本文的分类看，Codex Skills 可以看作“怎么做”的程序性记忆。官方文档说 Skills 使用 progressive disclosure：Codex 一开始只看到 skill 的名称、描述和文件路径，只有判断需要使用时才加载完整 SKILL.md。\n这解决了一个经典问题：如果每个技能的完整说明都常驻上下文，prompt 会很快被挤满；但如果只暴露 skill 元数据，agent 仍然能在需要时选择合适技能。官方文档还提到，初始 skills 列表会控制在模型上下文窗口的 2% 以内，或在未知上下文窗口时控制在 8,000 字符以内。\nMCP：外部上下文和实时事实 Codex 的 memory 不应该替代 MCP。MCP 更适合接入实时、私有、授权的数据源，例如 GitHub、Slack、Notion、数据库、文档系统、日历等。\nCodex 的设计取向 Codex 的取向可以概括为：\nAGENTS.md 管显式项目规则。 Memories 管跨 thread 的历史经验。 Skills 管可复用工作流。 MCP 管外部事实和私有系统上下文。只有当 MCP 暴露的是长期记忆库、历史决策或项目经验时，它才参与 Agent Memory；否则它只是外部上下文接口。 Hermes Agent 的 Memory 设计 Hermes Agent 的设计非常有辨识度：核心 memory 极小，但外部 provider 很开放。\nMEMORY.md 与 USER.md Hermes 的内置 memory 由两个文件组成：\n文件 用途 官方限制 MEMORY.md Agent 的个人笔记，例如环境事实、项目约定、学到的东西 2,200 字符，约 800 tokens USER.md 用户画像，例如偏好、沟通风格、期待 1,375 字符，约 500 tokens 它们默认存放在：\ntext ~/.hermes/memories/这两个文件会在会话开始时作为 frozen snapshot 注入 system prompt。会话中写入的 memory 会保存到文件，但不会立刻改变当前 system prompt，需要到下一次会话才生效。\n这个设计的优点是：\n核心 memory 很小，促使 agent 只保留高价值内容。\nsystem prompt 稳定，有利于 prompt caching 和行为一致性。\n不自动压缩，避免静默丢信息。\n写满时报错，让 agent 显式合并、删除或缩短。\n系统不会偷偷压缩或丢掉记忆，而是明确告诉 agent 写不下了，这时候agent 必须明确做一个动作（合并、删除或缩短）也就是说既对agent显式，也在会话中显式展示\nHermes 官方文档还提到，memory entries 在进入系统提示前会做 injection 和 exfiltration pattern 扫描，并拒绝一些威胁模式或不可见 Unicode 字符。这说明 memory 因为会进入高优先级上下文，本身也是安全边界的一部分。\nHermes memory tool：少而明确的编辑动作 Hermes 的 memory tool 主要是：\nadd replace remove 这说明 Hermes 把核心 memory 设计成短小的长期自我说明，而不是一个随时搜索的大目录。\n需要注意的是，Hermes 的核心 memory tool 没有 read 动作，因为内置 memory 会在会话开始时自动注入 system prompt。\nMemory providers：外部长期记忆层 Hermes 官方文档提供可插拔的外部 memory provider，例如 Honcho、OpenViking、Mem0、Hindsight、Holographic、RetainDB、ByteRover、Supermemory 等（列表会随版本增减）。外部 provider 一次通常只启用一个，但内置 MEMORY.md / USER.md 会一直同时存在。\n外部 provider 激活后，Hermes 可以：\n把 provider context 注入 system prompt。\n每轮之前预取相关 memories。\n每次你发新消息之前或 agent 回复前，Hermes 会提前从 provider 搜索和当前问题相关的记忆。\n每次响应后同步 conversation turns。\n一轮对话结束后，Hermes 会把这一轮对话同步给 provider。\n会话结束时抽取 memories。\n给 agent 增加 provider-specific 的搜索、存储和管理工具。\n不同 provider 有自己的能力。接入 provider 后，agent 可能多出一些专门工具。\n官方特别强调：内置 MEMORY.md / USER.md 始终存在，外部 provider 是 additive，不是替代。\nprovider 是一个记忆服务/插件，内部可能调用 LLM，但它本身更像数据库 + 检索 + 抽取/更新逻辑的组合。\nHermes 的设计取向 Hermes 的取向可以概括为：\n核心 memory 小而硬，强制信息密度。\nMEMORY.md 和 USER.md 分离 agent 环境事实与用户画像。\n会话启动时注入 frozen snapshot，保持行为稳定。\n外部 provider 承担复杂长期记忆和用户建模。\nskills 和 context files 承担项目规则与工作流知识。Hermes 的 context files 文档还说明，.hermes.md / HERMES.md、AGENTS.md、CLAUDE.md 等会按优先级作为项目上下文加载，且同一会话只选择一种项目 context type。\n同一会话只选择一种项目 context type的意思是.hermes.md / HERMES.md、AGENTS.md、CLAUDE.md这些是多种类型的context type，但是一个会话只限制加载一种，防止混乱冲突等问题\n三个产品给出的设计启发 启发一：显式规则和自动记忆要分开 Claude Code 和 Codex 都把项目规则放在显式文件中，而不是只依赖自动 memory。这是对的。\n团队共享规则应该可审查、可版本控制、可迁移。自动 memory 适合补充经验，不适合承载团队硬规则。\n启发二：核心 memory 要短 Hermes 的小容量设计很有启发。越常驻的 memory，越要短、稳、密度高。\n长期 memory 不应该成为“第二个聊天记录”。它应该像一张高价值索引卡。\n启发三：程序性知识应该做成 Skill 很多经验不是“事实”，而是“流程”。例如如何写周报、如何做代码审查、如何排查线上告警、如何发布一个版本。\n这类内容更适合 Skill，而不是普通 memory。\n启发四：外部事实应该用工具查 实时状态、最新文档、issue / PR、价格、版本等信息不适合长期固化。它们应该通过 MCP、搜索或 API 获取。\n启发五：Memory 产品的关键能力是治理 真正好的 memory 产品，不是偷偷多记一点，而是让用户清楚：\n记了什么。 为什么记。 存在哪里。 什么时候生效。 如何修改或删除。 ","permalink":"https://www.caulif.com/posts/agent-memory/","summary":"整理 Agent Memory 的目标、生命周期与治理能力，并对比 Claude Code、Codex 和 Hermes 的 memory 设计。","title":"Agent 学习记录 2.0：Agent Memory 机制与产品设计"},{"content":"LoRA（Low-Rank Adaptation） 一种参数高效微调（Parameter-Efficient Fine-Tuning, PEFT）方式\n在冻结原始大模型参数的前提下，只训练一个很小的低秩权重增量\n原理 需要一点点基础的线性代数的知识\n假设某一层原始权重是：\n$$ W_0 \\in \\mathbb{R}^{d_{out} \\times d_{in}} $$输入是：\n$$ x \\in \\mathbb{R}^{d_{in}} $$原始线性层输出是：\n$$ y = W_0 x $$全量微调会直接更新整个 (W_0)，得到：\n$$ W_{new} = W_0 + \\Delta W $$这里的 (\\Delta W) 就是微调过程对原模型权重造成的变化。\n而LoRA 不直接训练完整的 (\\Delta W)，而是把它写成两个小矩阵的乘积： $$ \\Delta W \\approx BA $$其中：\n$$ A \\in \\mathbb{R}^{r \\times d_{in}} $$$$ B \\in \\mathbb{R}^{d_{out} \\times r} $$并且：\n$$ r \\ll \\min(d_{in}, d_{out}) $$于是原来的线性层从：\n$$ y = W_0 x $$变成：\n$$ y = W_0 x + \\frac{\\alpha}{r}BAx $$这里：\n(W_0)：预训练模型原始权重，冻结不训练； (A) 和 (B)：LoRA 新增的小矩阵，训练时只更新它们； (r)：rank，控制 LoRA 的容量； (\\alpha)：缩放系数，控制 LoRA 分支对原模型的影响强度； (\\frac{\\alpha}{r})：常见实现里的归一化缩放。 一句话总结：\nLoRA 假设“微调需要的权重变化”是低秩的，所以只训练一个低秩增量，而不是训练整个大模型。\n所谓的低秩的假设就是说微调中重要的调整方向是较少的，因此就可以在限制r这一自由度的低秩矩阵基础上，达到类似的全量微调的效果\n详细讲就是：\n矩阵的秩可以粗略理解为：这个矩阵真正包含多少个独立方向。\n如果一个大矩阵的变化可以由少数几个方向组合出来，它就是低秩或近似低秩的。\n举一个线性层例子：\n$$ W_0 \\in \\mathbb{R}^{4096 \\times 4096} $$全量微调这一层需要训练：\n$$ 4096 \\times 4096 = 16,777,216 $$个参数。\n如果 LoRA 取：\n$$ r = 8 $$那么只训练：\n$$ A: 8 \\times 4096 $$$$ B: 4096 \\times 8 $$总参数量是：\n$$ 8 \\times 4096 + 4096 \\times 8 = 65,536 $$这一层的可训练参数约减少：\n$$ \\frac{16,777,216}{65,536} = 256 $$倍。\nLoRA 的训练过程 LoRA 训练时一般这样初始化：\n冻结 (W_0)； 随机初始化 (A)； 将 (B) 初始化为 0。 因为开始时：\n$$ B = 0 $$所以：\n$$ BA = 0 $$模型初始输出仍然是：\n$$ y = W_0 x $$这意味着 LoRA 微调从原模型行为出发，而不是一开始就扰动模型。\n训练过程中，梯度只更新 (A) 和 (B)：\ntext base model W0: 冻结 LoRA A: 训练 LoRA B: 训练最后得到一个任务专用的 adapter：\ntext LoRA adapter = A + B + 配置参数这个 adapter 通常很小，可以单独保存、分享和加载。\nLoRA 的推理过程 训练完成后有两种使用方式。\n第一种是分离加载：\ntext base model + LoRA adapter推理时仍然计算：\n$$ y = W_0 x + \\frac{\\alpha}{r}BAx $$第二种是合并权重：\n$$ W_{merged} = W_0 + \\frac{\\alpha}{r}BA $$合并后推理就变成普通线性层：\n$$ y = W_{merged}x $$合并后几乎没有额外推理延迟。\nLoRA 加在哪里 Transformer里有大量线性层。LoRA 本质上可以加到任何线性层上，但在LLM里常见目标包括：\nattention 的 q_proj； attention 的 k_proj； attention 的 v_proj； attention 的 o_proj； MLP 的 gate_proj； MLP 的 up_proj； MLP 的 down_proj。 早期 LoRA 常只加在 attention 的 query 和 value 投影上，例如 q_proj、v_proj。现在很多指令微调实践会把 MLP 投影层也纳入目标模块。\n选择方式可以这样理解：\nLoRA 位置 优点 代价 只加 attention 的部分投影层 参数最省，训练快 表达能力较弱 加 attention 全部投影层 仍然比较省，效果更稳 参数略增 加 attention + MLP 更适合复杂任务和领域适配 显存、训练时间增加 加所有线性层 最接近全量微调 参数最多，也更可能过拟合 核心超参数 rank：r：上面说了\n缩放系数：lora_alpha：\nLoRA 实际使用的更新通常是：\n$$ \\Delta W = \\frac{\\alpha}{r}BA $$所以就是控制LoRA矩阵的影响力的\n常见设置会让 alpha 与 r 相等或是 r 的 2 倍\ndropout：lora_dropout：顾名思义是加在 LoRA 分支上的 dropout。dropout 是训练中的一种防过拟合方法：训练时随机关闭一部分神经元或信号路径，让模型不要依赖单一特征，从而减少过拟合。\n目标模块：target_modules：加在哪些层\n例如\ntext [\u0026#34;q_proj\u0026#34;, \u0026#34;k_proj\u0026#34;, \u0026#34;v_proj\u0026#34;, \u0026#34;o_proj\u0026#34;, \u0026#34;gate_proj\u0026#34;, \u0026#34;up_proj\u0026#34;, \u0026#34;down_proj\u0026#34;] 其他微调方式与比较 方法 更新什么 优点 缺点 全量微调 更新整个模型 效果上限高 显存、存储和部署成本高 Prompt Tuning 学习软提示 极省参数 表达能力有限 Prefix Tuning 学习前缀向量 省参数，适合生成任务 推理上下文开销更明显 Adapter 插入小模块 参数高效 推理路径通常增加额外计算 LoRA 学习低秩权重增量 参数少，可合并，推理友好 rank 太小会欠拟合 这里其他的还没太多了解\n变体：QLoRA、AdaLoRA、DoRA QLoRA\n可以理解成：\ntext QLoRA = 量化的冻结基座模型 + LoRA adapter 训练普通 LoRA 减少的是可训练参数和优化器开销，但 base model 本身仍然要放进显存。\nQLoRA 进一步把冻结的 base model 量化，从而显著降低显存占用。训练时，梯度通过量化后的模型传播，但实际更新的仍然是 LoRA adapter。\nAdaLoRA\n普通 LoRA 通常给不同层分配相同的 rank。\n但不同层的重要性并不一样。有的层对任务适配很关键，有的层几乎不需要动。\nAdaLoRA 的思想是：\ntext 总 rank 预算固定，但动态分配给更重要的层和方向它会根据重要性评分保留更有价值的低秩方向，剪掉不重要的方向。\nDoRA\n全称 Weight-Decomposed Low-Rank Adaptation。\n它认为权重可以拆成两个方面：\nmagnitude：权重大小； direction：权重方向。 普通 LoRA 主要通过低秩更新改变权重，对 magnitude / direction 没有显式拆开。\nDoRA 先把权重分解成 magnitude（大小）和 direction（方向），再用 LoRA 更专注地适配 direction，同时单独学习 magnitude。这样可以更接近全量微调的效果。\n","permalink":"https://www.caulif.com/posts/lora%E5%AD%A6%E4%B9%A0%E7%AC%94%E8%AE%B0/","summary":"整理 LoRA 的低秩适配原理、训练与推理流程、常见插入位置、核心超参数，以及 QLoRA、AdaLoRA、DoRA 等变体。","title":"LoRA 学习笔记"},{"content":"注意 仅是不成熟的思考和理解，对话也比较单薄；后续有时间可能会看更复杂的会话数据或 CLI 会话数据（可能与 Desktop 版不一样），也可能分析与 Claude Code 的区别或读源码。现在先这样。\n文中 JSON 为便于阅读做过脱敏与摘要，字段与真实完整 schema 可能不完全一致，请当作教学摘录。\n认真读了会话数据，并在和 gpt5.5 的多次交互中，逐渐梳理清楚，有了大概的认知。我目前的理解是可以把会话数据拆成两条线来理解：\n模型上下文 / 发送返回线 主要看 response_item。它回答：模型实际收到了什么、输出了什么、请求了什么工具、工具结果如何回到模型上下文。\nRuntime / 事件状态线 主要看 session_meta、turn_context、event_msg、compacted。它回答：runtime 如何启动、调度、统计、压缩和恢复。\n下面我将从头开始，列出一次查询天气对话 + 压缩 + 再发一个你好的会话数据流并简单分析。 第一轮：用户让 Codex 查询北京天气 会话启动：写入 session_meta 会话运行现场快照 Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.683Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;session_meta\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;019e6dba-b341-7701-96be-43a4435d0357\u0026#34;, \u0026#34;cwd\u0026#34;: \u0026#34;C:\\\\Users\\\\15893\\\\Documents\\\\test\u0026#34;, \u0026#34;originator\u0026#34;: \u0026#34;Codex Desktop\u0026#34;, \u0026#34;cli_version\u0026#34;: \u0026#34;0.133.0\u0026#34;, \u0026#34;source\u0026#34;: \u0026#34;vscode\u0026#34;, \u0026#34;thread_source\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;model_provider\u0026#34;: \u0026#34;custom\u0026#34;, \u0026#34;base_instructions\u0026#34;: \u0026#34;Codex 基础行为指令...\u0026#34;, \u0026#34;dynamic_tools\u0026#34;: \u0026#34;本轮可用工具 schema 列表...\u0026#34;, \u0026#34;git\u0026#34;: \u0026#34;当前 git 状态...\u0026#34; } } 会话启动消息 记录这次 session 的 ID、工作目录、来源、版本、工具、指令、Git 信息。\n其中base_instructions里面是很长的一段prompt，大体意思是规定了你是 Codex，如何处理代码任务，如何调用工具，如何做前端，如何编辑文件，如何回复用户，格式要求，最终回复要求，具体内容我这里就不放了\n值得注意的是，下面所有 response_item 的 role 都没有出现 system。这是推测，不是已证实结论：有可能 system 侧规则在 Responses API 服务端内置，也有可能本会话里的 base_instructions 会在实际请求中进入 system / developer 一类高优先级槽位，仍有待对照源码或更完整抓包确认。\n一轮任务开始：event_msg / task_started 建立 turn 生命周期边界 Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.693Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_started\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;019e6dba-e4db-7022-96ef-2b2f5c4863a5\u0026#34;, \u0026#34;started_at\u0026#34;: 1779957425, \u0026#34;model_context_window\u0026#34;: 258400, \u0026#34;collaboration_mode_kind\u0026#34;: \u0026#34;default\u0026#34; } } 设计含义 这表示 runtime 开始处理一轮用户输入。\n对于turn这个概念，OpenAI 对 Codex 的抽象是：Thread -\u0026gt; Turn -\u0026gt; Item\nThread：持久会话，可创建、恢复、fork、归档。 Turn：用户输入触发的一轮 agent 工作。 Item：turn 里的原子输入/输出，例如 user message、agent message、tool execution、approval request、diff。 turn_id 标记了这轮turn的id给后面的完成、中断、耗时统计都会使用同一个id标识同一轮turn。\nmodel_context_window 记录当前模型上下文窗口，便于判断上下文压力。\n注入 developer 指令 权限、工具、skills、plugins 等运行配置 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.740Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;developer\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;permissions instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;app-context\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;collaboration_mode\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;skills_instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;plugins_instructions\u0026gt;...\u0026#34; } ] } } developer prompt developer的prompt：包括权限、协作模式、skills、plugins、Codex Desktop 能力说明。 注入 AGENTS.md 与环境上下文 项目规则和当前环境 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.748Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;# AGENTS.md instructions for C:\\\\Users\\\\15893\\\\Documents\\\\test...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;environment_context\u0026gt;cwd、shell、日期、时区...\u0026#34; } ] } } 项目级规则和一些环境信息。 turn_context：本轮上下文包边界 不是完整 prompt Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.750Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;turn_context\u0026#34;, \u0026#34;payload\u0026#34;: {} } turn_context 是这一轮上下文包到这里组装好了的边界标记，runtime里面turn的标记。 用户原始输入：给模型看的版本 response_item / message / role=user 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.753Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;帮我搜索今天的北京天气\\n\u0026#34; } ] } } 第一次用户消息 用户提问的消息 用户原始输入：runtime 事件版本 event_msg / user_message Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:05.753Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;user_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;帮我搜索今天的北京天气\\n\u0026#34;, \u0026#34;images\u0026#34;: [], \u0026#34;local_images\u0026#34;: [], \u0026#34;text_elements\u0026#34;: [] } } 为什么同一句话出现两次 这一份属于 runtime 事件流。 它记录“用户消息事件发生了”，同时保留图片、本地图片、文本元素等输入形态。 所以它和上一条内容相似，但职责不同。 空 reasoning：runtime 事件版本 event_msg / agent_reasoning Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:22.415Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;agent_reasoning\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026#34; } } 这一条怎么看 它属于 runtime 事件线，表示 agent reasoning 相关事件出现了。 这里的 text 是空字符串，所以没有给用户展示任何推理摘要。 这类事件可以理解为 runtime 对“模型正在思考 / 推理阶段”的事件化记录。 空 reasoning：模型 item 版本 response_item / reasoning 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:22.421Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;reasoning\u0026#34;, \u0026#34;summary\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;summary_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026#34; } ], \u0026#34;content\u0026#34;: null, \u0026#34;encrypted_content\u0026#34;: \u0026#34;\u0026#34; } } 和上一条的区别 这条是模型上下文线里的 reasoning item。 summary 里有一个空的 summary_text，说明没有公开推理摘要。 content: null、encrypted_content: \u0026quot;\u0026quot; 表示这里也没有明文或加密推理内容。 模型请求调用工具：function_call 模型输出工具调用意图 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:24.114Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;function_call\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;shell_command\u0026#34;, \u0026#34;arguments\u0026#34;: \u0026#34;{ command: Get-Content 某个 SKILL.md, workdir: ... }\u0026#34;, \u0026#34;call_id\u0026#34;: \u0026#34;call_s8ed7YKvykhsf6GPqAhKMRqQ\u0026#34; } } 模型并没有直接执行命令 模型输出的是“我要调用 shell_command”的结构化意图。 真正执行命令的是 Codex runtime。 call_id 用来把请求和结果配对。 工具执行结果回填：function_call_output runtime 执行后把结果放回模型上下文 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:24.580Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;function_call_output\u0026#34;, \u0026#34;call_id\u0026#34;: \u0026#34;call_s8ed7YKvykhsf6GPqAhKMRqQ\u0026#34;, \u0026#34;output\u0026#34;: \u0026#34;Exit code: 0\\nWall time: 0.5 seconds\\nOutput:\\nSKILL.md 内容很长...\u0026#34; } } 为什么输出也进入 response_item 模型下一步要基于工具结果继续工作，所以结果必须回到模型上下文。 这条不是模型生成的自然语言回答，而是 runtime 给模型的工具结果。 加密 reasoning：模型保留的推理状态 response_item / reasoning / encrypted_content 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:48.809Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;reasoning\u0026#34;, \u0026#34;summary\u0026#34;: [], \u0026#34;content\u0026#34;: null, \u0026#34;encrypted_content\u0026#34;: \u0026#34;gAAAAABqF_7dG49MUwA5...很长的加密内容...\u0026#34; } } reasoning加密了 这里不给用户看原始 reasoning，而是加密后的 reasoning 状态。 官方文档的大致说法是：reasoning models 会产生 reasoning tokens，用于思考、规划、比较方案、处理工具调用等，但这些内容 “not visible via the API”。使用 reasoning model 做 function calling 时，建议把前一次响应里的 reasoning items 一起传回下一次请求，以便延续推理、改善结果，并更省 token（缓存命中、少重复思考）。 既要在多轮里回传推理状态，又不想对用户明文展示，工程上就会做成加密态。这既可能是安全与产品策略，也可能和防止被轻易蒸馏有关——后者是我的猜测。 Web 搜索结束：runtime 事件版本 event_msg / web_search_end Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:53.517Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;web_search_end\u0026#34;, \u0026#34;call_id\u0026#34;: \u0026#34;ws_0ed3b16bb031c342016a17feddd3c881919442335097dbc7ce\u0026#34;, \u0026#34;query\u0026#34;: \u0026#34;weather: Beijing, China\u0026#34;, \u0026#34;action\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;search\u0026#34;, \u0026#34;queries\u0026#34;: [\u0026#34;weather: Beijing, China\u0026#34;] } } } 这条记录搜索动作完成 它属于 runtime 事件线，说明一次 web search 已经结束。 call_id 用来标识这次搜索动作。 query 和 action.queries 记录了实际搜索的查询词。 Web 搜索调用：模型 item 版本 response_item / web_search_call 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:53.517Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;web_search_call\u0026#34;, \u0026#34;status\u0026#34;: \u0026#34;completed\u0026#34;, \u0026#34;action\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;search\u0026#34;, \u0026#34;queries\u0026#34;: [\u0026#34;weather: Beijing, China\u0026#34;] } } } 为什么搜索也有 response_item 这条属于模型上下文线，记录模型侧看到的搜索调用 item。 status: completed 表示这个搜索调用已经完成。 它和上一条 web_search_end 描述的是同一件事，但一个是 runtime 事件，一个是模型 item。 最终回答：runtime 事件版本 event_msg / agent_message Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:55.609Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;agent_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;北京今天（2026年5月28日）天气：晴，目前约 29°C...\u0026#34;, \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34;, \u0026#34;memory_citation\u0026#34;: null } } 这一条表示什么 runtime 对外发布了一条助手消息事件。 phase: final_answer 表示这是最终回答阶段。 它适合被日志、状态系统、界面等上层消费者读取。 最终回答：模型输出版本 response_item / assistant message 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:55.615Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;output_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;北京今天（2026年5月28日）天气：晴，目前约 29°C...\u0026#34; } ], \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34; } } 和上一条的差异 数据模型上下文线，也就是会进入上下文，role是assitant，代表是llm输出的内容。 上一条是 runtime 事件，这一条是模型输出 item。 任务完成：task_complete 和 task_started 配对 Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:37:55.822Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_complete\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;019e6dba-e4db-7022-96ef-2b2f5c4863a5\u0026#34;, \u0026#34;last_agent_message\u0026#34;: \u0026#34;北京今天（2026年5月28日）天气...\u0026#34;, \u0026#34;completed_at\u0026#34;: 1779957475, \u0026#34;duration_ms\u0026#34;: 50449, \u0026#34;time_to_first_token_ms\u0026#34;: 15112 } } 它和 task_started 配对 标记这一轮 turn 完成。 记录最后一条助手消息，方便恢复和快速展示。 记录总耗时和首 token 延迟，用于性能分析。 压缩：上下文如何被整理成摘要-完成上一个对话之后我手动触发了一次压缩 下一轮任务开始：新的 task_started 压缩前后仍然是新的 turn Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:50:14.269Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_started\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;019e6dc6-ee7b-7523-b672-1a9f099a65b6\u0026#34;, \u0026#34;started_at\u0026#34;: 1779958214, \u0026#34;model_context_window\u0026#34;: 258400, \u0026#34;collaboration_mode_kind\u0026#34;: \u0026#34;default\u0026#34; } } 这里说明什么 同一个 session 里可以有多个 turn。 这条表示 runtime 又开始处理一轮任务。 后面的 token 统计和 compacted 都发生在这轮上下文维护过程中。 第一次 token_count：记录累计和本轮 token 上下文压力和用量统计 Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:50:57.012Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;token_count\u0026#34;, \u0026#34;info\u0026#34;: { \u0026#34;total_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 30056, \u0026#34;cached_input_tokens\u0026#34;: 11648, \u0026#34;output_tokens\u0026#34;: 367, \u0026#34;reasoning_output_tokens\u0026#34;: 143, \u0026#34;total_tokens\u0026#34;: 30423 }, \u0026#34;last_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 18546, \u0026#34;cached_input_tokens\u0026#34;: 11648, \u0026#34;output_tokens\u0026#34;: 188, \u0026#34;reasoning_output_tokens\u0026#34;: 79, \u0026#34;total_tokens\u0026#34;: 18734 }, \u0026#34;model_context_window\u0026#34;: 258400 }, \u0026#34;rate_limits\u0026#34;: \u0026#34;当前速率限制状态...\u0026#34; } } 压缩前的第一次token count total_token_usage 是当前会话累计到这里的用量。 last_token_usage 是最近一次模型请求的用量。 cached_input_tokens 可以帮助理解哪些输入命中了缓存。 这些数据能辅助 runtime 判断是否需要压缩上下文。 第二次 token_count：压缩前的进一步统计 上下文继续增长 Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:07.910Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;token_count\u0026#34;, \u0026#34;info\u0026#34;: { \u0026#34;total_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 41472, \u0026#34;cached_input_tokens\u0026#34;: 16000, \u0026#34;output_tokens\u0026#34;: 917, \u0026#34;reasoning_output_tokens\u0026#34;: 290, \u0026#34;total_tokens\u0026#34;: 42389 }, \u0026#34;last_token_usage\u0026#34;: { \u0026#34;input_tokens\u0026#34;: 11416, \u0026#34;cached_input_tokens\u0026#34;: 4352, \u0026#34;output_tokens\u0026#34;: 550, \u0026#34;reasoning_output_tokens\u0026#34;: 147, \u0026#34;total_tokens\u0026#34;: 11966 }, \u0026#34;model_context_window\u0026#34;: 258400 }, \u0026#34;rate_limits\u0026#34;: \u0026#34;当前速率限制状态...\u0026#34; } } 为啥连着又有一次token count 这里可以看到累计 token 从三万多增长到四万多，那么新增的token是什么呢，我仔细分析了一下 last_token_usage也变了，而30056+11416=41472，同时5ms之后就写入了compacted，所有新增的一条消息应该是压缩请求，这里则进行了压缩完成前的一次统计， compacted：把旧历史替换成交接摘要 影响后续模型上下文，不删除本地日志 Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:07.915Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;compacted\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;message\u0026#34;: \u0026#34;Another language model started... 交接摘要：天气查询已完成...\u0026#34;, \u0026#34;replacement_history\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;帮我搜索今天的北京天气\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: \u0026#34;交接摘要文本...\u0026#34; } ] } } 压缩是如何实现的 首先本地日志不会因为压缩而删除前面的原始事件。\npayload.message = 压缩后生成的摘要正文\nreplacement_history = 被这次摘要替换掉的历史片段记录 / 替换关系记录\n所以压缩前后的过程应该是这样的\ntext 压缩前： [真实用户消息] [developer / AGENTS / environment] [reasoning] [tool call] [tool output] [web search] [assistant answer] [各种历史 item] ↓ compacted 压缩后： [developer / AGENTS / environment 重新注入] [一条交接摘要 message] [新用户消息] 压缩后的下一轮上下文重新组装 压缩后重新注入 developer 指令 当前运行配置每轮都要重新提供 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;developer\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;permissions instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;app-context\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;collaboration_mode\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;skills_instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;plugins_instructions\u0026gt;...\u0026#34; } ] } } 为什么压缩完又出现 压缩的是旧对话历史，不是当前运行配置。 权限、工具、skills、plugins 这些又被重新注入了上下文。 为什么不直接用之前的，可能考量是压缩后就要这么设计？清空原来的上下文，将摘要和不变的一些信息重新拼接上下文？ 压缩后重新注入 AGENTS.md 和环境上下文 项目规则和环境也会重新进入上下文 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;# AGENTS.md instructions for C:\\\\Users\\\\15893\\\\Documents\\\\test...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;environment_context\u0026gt;cwd、shell、current_date、timezone...\u0026#34; } ] } } 为什么压缩后又有 AGENTS.md 同上，又重新注入，组装上下文了 第二轮：用户发“你好” 第二轮不用像第一轮那样每条都拆开，因为很多机制前面已经讲过了。这里更适合按阶段合并看：压缩后的上下文包、用户消息双线、运行现场重现、最后响应与收尾。\n压缩后新请求的上下文包 developer、AGENTS.md、environment_context 重新出现 模型上下文线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;developer\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;permissions instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;app-context\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;skills_instructions\u0026gt;...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;plugins_instructions\u0026gt;...\u0026#34; } ] } } { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.862Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;# AGENTS.md instructions...\u0026#34; }, { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;\u0026lt;environment_context\u0026gt;...\u0026#34; } ] } } 这里不用重复细讲 第一轮已经讲过 developer 指令和 AGENTS.md 的作用。 有趣的是这里又重新注入了，或许每次对话都会重新注入？还是压缩完才这样，还没太搞明白 用户“你好”：模型上下文和 runtime 事件合并看 同一句消息仍然写两条 双线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.868Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;user\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;input_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;你好\\n\u0026#34; } ] } } { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.868Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;user_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;你好\\n\u0026#34;, \u0026#34;images\u0026#34;: [], \u0026#34;local_images\u0026#34;: [], \u0026#34;text_elements\u0026#34;: [] } } 第一条是模型上下文线：真正给模型看的用户输入。 第二条是 Runtime 事件线：记录用户消息事件和输入形态。 这个模式第一轮已经详细讲过，所以第二轮只需要确认它仍然成立。 session_meta 再次出现：运行现场重现 不是新聊天内容，而是 runtime 快照 Runtime 线 json { \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:51:52.966Z\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;session_meta\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;019e6dba-b341-7701-96be-43a4435d0357\u0026#34;, \u0026#34;timestamp\u0026#34;: \u0026#34;2026-05-28T08:36:57.687Z\u0026#34;, \u0026#34;cwd\u0026#34;: \u0026#34;C:\\\\Users\\\\xxx\\\\Documents\\\\test\u0026#34;, \u0026#34;originator\u0026#34;: \u0026#34;Codex Desktop\u0026#34;, \u0026#34;base_instructions\u0026#34;: \u0026#34;完整 Codex 基础指令...\u0026#34;, \u0026#34;dynamic_tools\u0026#34;: [ { \u0026#34;namespace\u0026#34;: \u0026#34;codex_app\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;automation_update\u0026#34;, \u0026#34;inputSchema\u0026#34;: \u0026#34;...\u0026#34; }, { \u0026#34;namespace\u0026#34;: \u0026#34;codex_app\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;read_thread_terminal\u0026#34;, \u0026#34;inputSchema\u0026#34;: \u0026#34;...\u0026#34; }, { \u0026#34;namespace\u0026#34;: \u0026#34;codex_app\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;load_workspace_dependencies\u0026#34;, \u0026#34;inputSchema\u0026#34;: \u0026#34;...\u0026#34; } ] } } 这一条值得单独看一下 session_meta 这个又出现了一次，其实没有太清楚为什么这样设计，感觉一个session有一个session_meta不就行了 第二轮响应和收尾：合并看即可 assistant message、token_count、task_complete 双线 json { \u0026#34;type\u0026#34;: \u0026#34;response_item\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;message\u0026#34;, \u0026#34;role\u0026#34;: \u0026#34;assistant\u0026#34;, \u0026#34;content\u0026#34;: [ { \u0026#34;type\u0026#34;: \u0026#34;output_text\u0026#34;, \u0026#34;text\u0026#34;: \u0026#34;你好！...\u0026#34; } ], \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34; } } { \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;agent_message\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;你好！...\u0026#34;, \u0026#34;phase\u0026#34;: \u0026#34;final_answer\u0026#34; } } { \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;token_count\u0026#34;, \u0026#34;info\u0026#34;: \u0026#34;本轮和累计 token 用量...\u0026#34; } } { \u0026#34;type\u0026#34;: \u0026#34;event_msg\u0026#34;, \u0026#34;payload\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;task_complete\u0026#34;, \u0026#34;turn_id\u0026#34;: \u0026#34;第二轮 turn_id...\u0026#34;, \u0026#34;last_agent_message\u0026#34;: \u0026#34;你好！...\u0026#34;, \u0026#34;duration_ms\u0026#34;: \u0026#34;本轮耗时...\u0026#34; } } 这些就和之前一样了 assistant 回复仍然会有模型输出版本和 runtime 事件版本。 token_count 仍然记录本轮和累计用量。 task_complete 仍然和本轮 task_started 配对，标记 turn 结束。 这些机制第一轮已经讲过，第二轮只需要看到它们按同样模式继续运转。 最终整个会话的模型 text 用户输入 ↓ Codex runtime 创建 turn_id ↓ event_msg: task_started ↓ 组装 developer 指令、AGENTS.md、环境、历史摘要 ↓ turn_context 标记上下文包边界 ↓ response_item: user message event_msg: user_message ↓ 模型 reasoning / function_call ↓ runtime 执行工具 ↓ response_item: function_call_output ↓ response_item: assistant message event_msg: agent_message ↓ event_msg: token_count / task_complete ↓ 必要时 compacted，把旧历史替换成交接摘要 目前的结论：\n.jsonl 不是单纯聊天记录，而是一份完整的runtime trace。 response_item 主要负责模型上下文线：模型收到什么、输出什么、调用什么工具。 event_msg 主要负责 Runtime 事件线：turn 开始、用户事件、工具进度、token 统计、任务完成。 ","permalink":"https://www.caulif.com/posts/codex-session-json-trace/","summary":"阅读一次真实 Codex Desktop 会话 JSONL，按模型上下文线和 Runtime 事件线理解 Agent Loop 的运行细节。","title":"Agent 学习记录 1.5：Codex 会话记录分析"},{"content":"最近学习了一些 AI Agent 的相关知识，顺手整理成一篇学习笔记。\n我的阶段性理解是：Agent 的关键是它被放进了一个可以持续感知环境、调用工具、观察反馈、继续行动的循环系统里。\n提示 这是「AI Agent 学习笔记」系列的第 1 篇，主要整理 Agent Loop 的基本概念。第 2 篇会继续看一次真实的 Codex Desktop 会话 JSONL 记录。\n什么是 AI Agent 我的理解是：\nAI Agent 是一个能围绕目标持续做事的系统。它读取上下文，决定下一步，调用工具，观察结果，再根据新结果继续调整，直到完成任务或需要人类介入。\n主要有几个部分：\n目标：Agent 通常不是只回答一个孤立问题，而是围绕某个任务持续推进。 上下文：Agent 需要知道当前环境、规则、历史对话、项目文件、工具说明等信息。 工具：Agent 可以调用搜索、终端、文件读写、浏览器、数据库等外部能力。 反馈：工具调用后会返回结果，Agent 会根据结果修正下一步。 循环：Agent 的行为不是一次性输出，而是多轮迭代。 所以，普通聊天更像：\ntext 用户输入 -\u0026gt; 模型回答而 Agent 更像：\ntext 目标 -\u0026gt; 读取上下文 -\u0026gt; 决策 -\u0026gt; 调用工具 -\u0026gt; 观察结果 -\u0026gt; 继续决策ReAct：推理和行动交织 Agent 的一个经典理论基础是 ReAct，对应论文是：ReAct: Synergizing Reasoning and Acting in Language Models。\nReAct 可以理解为把两个动作交织起来：\nReasoning：模型先判断现在知道什么、下一步应该做什么。 Acting：模型把判断转化成真实行动，例如调用工具、搜索资料、读取文件、运行命令。 它的最小形态常被概括成 T-A-O 循环：\nThought：我现在知道什么？下一步应该查什么或做什么？ Action：调用搜索、读取、点击、运行命令、编辑文件等工具。 Observation：工具或环境返回了什么？它是否支持我的判断？ Agent Loop：完整的运行循环 ReAct 描述的是推理和行动的基本单元。再往上看，就是完整的 Agent Loop，我觉得它增加了上下文组装、工具调度和停止条件等系统层面的东西。\n一个 Agent Loop 通常包含 6 个环节：\n目标输入：用户说要完成什么。 上下文组装：系统把任务、规则、历史、工具说明、相关文件、检索结果放进模型上下文。 模型决策：模型判断下一步应该做什么。 工具调用：模型通过工具读取文件、搜索资料、运行命令、修改内容。 观察结果：工具返回结果，模型读取这个结果。 继续或停止：模型决定继续下一轮，还是交付结果。 flowchart TD A[\u0026#34;任务输入\u0026#34;] --\u0026gt; B[\u0026#34;组装上下文\u0026#34;] B --\u0026gt; C[\u0026#34;模型决定下一步\u0026#34;] C --\u0026gt; D[\u0026#34;调用工具或直接回答\u0026#34;] D --\u0026gt; E[\u0026#34;观察结果\u0026#34;] E --\u0026gt; F{\u0026#34;是否完成？\u0026#34;} F -- \u0026#34;否\u0026#34; --\u0026gt; B F -- \u0026#34;是\u0026#34; --\u0026gt; G[\u0026#34;输出结果与验证信息\u0026#34;] Codex 中的 Agent Loop 为了加深理解，我又去看了 Codex 是如何实现 Agent Loop 的，因为平时用得比较多。下面这部分参考了 OpenAI 官方文章：深入解析 Codex 智能体循环。\n当 Codex 向 Responses API 发送请求时，它发送的不是一段纯文本，而是一个结构化的 JSON Payload。这个 Payload 主要包含三类信息：\ninstructions：系统指令，用来设定模型的基础规则、行为边界和协作方式。 tools：工具列表，告诉模型当前可以调用哪些外部能力。 input：输入内容列表，包含用户消息、历史上下文、工具结果、图片、文件信息等。 可以把它理解成：\ntext Payload ├── instructions：模型应该如何行动 ├── tools：模型可以调用什么能力 └── input：模型这次决策能看到什么上下文其中 tools 可能包括：\ntext shell：允许模型在本地执行终端命令 update_plan：允许模型更新任务计划 web_search：允许模型检索公开网页信息 apply_patch：允许模型修改本地文件如果说 instructions 决定了“应该怎么做”，input 决定了“当前知道什么”，那么 tools 就决定了“能对外部世界产生什么影响”。\n这三部分在 prompt 里的组织结构如下：\n展示 AI 智能体循环中单一步骤的快照图。用户请求输入模型，模型生成一个思考、一个带工具名称的行动和一个工具输入。该图突出了工具被调用之前的这个中间推理步骤。 图中的 Role 表示：在 Responses API 的输入里，每条信息都有自己的角色。模型并不是平等地看待所有文本，而是会按照角色 / 指令层级处理；在 OpenAI 体系里，这通常和后训练与指令层级设计有关。\n以 OpenAI 常见实现为参考（不是所有厂商 LLM 的普适定理；且不同 API 版本里 system / developer 命名会演变），优先级从高到低大致是：\nsystem：系统规则。 developer：开发者指令。 user：用户输入。 assistant：模型之前的回复。 对于 Codex 来说，在用户的真实输入进入模型之前，系统通常已经自动塞入了几层背景信息：\n沙盒和权限限制：告诉模型当前能不能改文件、能不能联网、什么时候需要用户授权。 开发者自定义指令：例如回复风格、工程习惯、验证要求等。 项目规范与技能：例如当前项目的 AGENTS.md、可用 Skills、代码风格、测试要求。 本地环境上下文：例如当前工作目录 cwd、终端类型、日期、时区等。 然后，用户的消息才被追加到输入列表后面，开启这一轮 Agent Loop。\n也就是说，当我们在 Codex 里输入一句“帮我改这个功能”时，模型实际看到的并不只有这句话，而是一个已经被系统装配好的任务环境。\n然后我去翻了翻自己某次 Codex 会话记录。\n具体的上下文堆叠应该是这样的：\nCodex 会话中的上下文堆叠 我的理解总结 学习到这里，我对 AI Agent 的理解可以总结成三句话：\nAgent = LLM + 上下文 + 工具 + 循环。 ReAct 解决的是模型如何把推理和行动交织起来。 Agent Loop 解决的是一个任务如何在环境反馈中持续推进。 ","permalink":"https://www.caulif.com/posts/ai-agent-loop/","summary":"从 AI Agent 的基本组成、ReAct 和 Codex Agent Loop 入手，整理我对 Agent 运行机制的阶段性理解。","title":"Agent 学习记录 1.0：Agent Loop"},{"content":"先放链接：\nhttps://github.com/caulif/page-annotator\n想法主要是增加 agent / skill 与页面的交互（项目见上方 page-annotator 链接）。现在主要是读取页面内容，所以想加上 AI 对页面的操作，想了想做标记比较实用。\n现在设计了高亮和批注两种标记，暂时还没想好还需要加什么功能，一开始还有画圈和箭头功能，但是试了试感觉太丑了，而且不太实用，就删了。\n后续打算先完善一下兼容性的问题，然后再用几天看看有没有啥别的问题。\n今天拿gemini测了一下午，老出现未知错误，找了半天不知道哪出的问题，就是有标注，但是标注了几个之后就停了，然后返回位置错误。\n然后试了试claude sonnet 4.5，一下就好使了，评价是skill还得是用claude。\n今日测试结果如下： 侧边栏 skill 列表页面截图 skill 详情侧边栏截图 ","permalink":"https://www.caulif.com/posts/%E4%BB%8A%E5%A4%A9%E5%86%99%E4%BA%86%E4%B8%80%E4%B8%AAskill/","summary":"记录 page-annotator 的想法、标注交互设计和模型测试过程。","title":"今天写了一个skill"},{"content":"GGUF 与量化 前言：今天在看 GGUF，记录一下。之前用过几次 gguf 的模型，但是一直不理解啥意思，所以来看了看。\nPart 1: GGUF 文件二进制布局 先放一张官方图：\nGGUF 文件二进制布局示意图：Header、Metadata、Tensor Info 与 Tensor Data 上图是 gguf 文件的二进制布局。整体结构大概是： Header → Metadata block → Tensor Info array → Padding → Tensor Data\n1. Header（头部） 也就是图中左上角四个小黑条，包含以下四部分：\nMagic Number (4 bytes): GGUF。这是魔数，告诉计算机“这是一个 GGUF 文件”。对应的十六进制 0x47 0x47 0x55 0x46 正是 ASCII 码的 G-G-U-F。 Version (4 bytes): 版本号。图中显示 currently = 3（目前主流也是 v3）。 Tensor Count (8 bytes): 模型里有多少个张量（权重矩阵）。 Metadata KV Count (8 bytes): 有多少组元数据（键值对）。表示下面元数据部分有多少个键值对。 2. Metadata（元数据）- 绿色小条 里面有 Metadata KV Count 个 Key-Value 对。每个 KV 对都表示模型的某个配置信息。例如图中就有：\ngeneral.architecture: 模型架构（如 \u0026ldquo;llama\u0026rdquo;）。 llama.context_length: 上下文长度（如 4096）。 tokenizer.ggml.tokens: 分词器的词表。 3. Tensors Info（张量信息）- 紫色小条 权重的索引表，共有 Tensor Count 个。每个里面包含：\nname: 张量的名字（如 blk.0.ffn_gate.weight，即第0层的前馈网络权重）。\ndimensions: 形状（如 [4096, 32000]）。\ntype: 数据类型。支持各种量化格式（Q4_K, Q8_0, FP16 等），极大地压缩了体积。（看到这里发现对量化也一知半解）\noffset: 这是一个偏移量，表示当前 tensor 对应的实际数据在哪里，可以利用这个 offset 直接定位读取。\n按 GGUF 规范，这里的 offset 是相对 tensor_data 块起始位置的偏移量。\n4. Tensor Data（二进制张量数据）- 右侧黑色条 内容： 纯粹的二进制 0101\u0026hellip; 数据。 对齐（Alignment）： 为了让 CPU/GPU 读取更快，这些数据通常会按照 32 字节或 64 字节对齐（图中黑色条块中间的空隙就是 padding）。默认是 32 字节。 工作原理： 加载器读取了“第三部分”里的 offset 后，直接跳到这里对应的位置读取数据，或者直接 mmap 映射。 Part 2: 为什么 GGUF 能跨平台且支持混合推理？ 1. 内存映射与零拷贝 文件里的二进制数据布局，和 C/C++ 语言在内存里存数组的布局完全一致。所以可以用 mmap 进行内存映射，实现零拷贝加载。GPU offloading 需要显存拷贝，mmap 主要让 CPU 读得快。\n2. 对齐与 SIMD 友好 将张量数据切分成特定大小的 Block，这些块的大小（如 32 字节）正好是 CPU 寄存器一次能处理的数据宽度的倍数。\n在 constants.py 中，GGUF_DEFAULT_ALIGNMENT 被设置为 32 字节。 GGUFWriter 在写入张量数据前，会调用 ggml_pad 函数计算补齐长度。 write_padding 函数会在两个张量之间填充空字节。 3. 层级卸载 (Layer Offloading)\n原理：大模型是由一层一层的神经网络堆叠起来的（比如 Llama-2 有 32 层）。\nGGUF 的优势：因为 GGUF 里的张量是独立定义的（通过 Offset 查找），加载器（如 llama.cpp）可以灵活分配“谁干什么活”。\n大致实现流程：\n程序读取 GGUF 头部，发现有 32 层。\n用户设定 n_gpu_layers = 20（常见说法是把约 20 层放到 GPU；具体哪几层、按层还是按 tensor 分配，取决于后端实现与配置）。\n程序把对应层/张量的数据拷贝到 GPU 显存（VRAM）。实际运行里往往是 tensor 级别调度，而不只是整层搬迁。\n讨论参考：https://www.reddit.com/r/LocalLLaMA/comments/1ki7tg7/dont_offload_gguf_layers_offload_tensors_200_gen/\n其余层数据留在系统内存（RAM），由 CPU 计算。\n前向时按层顺序执行：GPU 负责已卸载的层，CPU 负责剩余层；层间结果在设备间传递。\n4. 针对 Mac 的优化 M 系列芯片是统一内存架构，CPU 和 GPU 共用同一块内存，GGUF 加载的数据，CPU 能看，GPU 也能看，几乎不需要数据拷贝。\nPart 3: 关于量化 1. 对称量化 例如假设有一层的权重，范围是从 -100.0 到 +100.0。 把它压缩成 8-bit 整数（范围 -128 到 127）。可以把 -100 映射成 -128，把 +100 映射成 +127，中间的数按比例缩放。\n问题： 因为权重可能是稀疏的，分布不均匀的，当大部分值比较小，少部分值很大时，精度损失很严重。所以有下面的分块量化。 2. 非对称量化（仿射量化） 非对称量化将浮点值范围 \\\\([min, max]\\\\) 映射到整数区间。这个范围不一定关于原点对称。\n核心逻辑：引入一个偏移量（min / zero-point），使得浮点数中的 \\\\(0\\\\) 可以对应整数中的非零值。 数学表示：\\\\(Q = \\text{round}(x / S + Z)\\\\)，其中 \\\\(Z\\\\) 是零点偏移。 在本地 GGUF 场景里，legacy 的 Q4_1 / Q5_1 已不如 K-quant / I-quant 常用；但「带 min/offset 的仿射量化」本身并没有整体弃用——不少 K-quant 变体也会存 min，激活量化也常是非对称的。\n3. 分块量化 (Block Quantization) 其实就是分块进行量化。以下以对称量化为例： 假设我们有一排权重（32 个浮点数）：[0.12, -0.55, 1.20, ...]\n分组：把这 32 个数分成一组（Block）。 找极值：找出这组数里绝对值最大的数（比如 1.20）。 提取缩放因子 (Scale)：把 1.20 存下来（存为 16-bit 浮点数）。 压缩：把这 32 个数都除以 1.20，然后强行映射到 -8 到 +7 之间的整数（4-bit）。 存储： 硬盘上存：1 个缩放因子 + 32 个 4-bit 整数。 总大小：2 bytes (scale) + 16 bytes (data) = 18 bytes。 原大小（如果存 FP16）：32 * 2 bytes = 64 bytes。 压缩率：接近 1/4 计算时的解压缩：读取 4-bit 整数 → 乘以缩放因子 → 恢复成近似的浮点数 → 进行矩阵乘法。 Part 4: K-Quants K-Quants 是 llama.cpp 的一组改进量化格式（常见带 256 权重的 super-block，以及分层/分组的 scale，有的还带 min）。名字里的 K 并不是「K-means」或「K-Points」之类标准术语，社区多把它当作这簇格式的历史命名（也有人猜测和作者 Kawrakow 有关）。\n模型里不同的层，重要性是不一样的。\nv 向量（Attention Value）和 output 层非常敏感，所以要用高精度。 ffn 层（前馈网络）相对不那么敏感，可以选择低精度。 Q4_K_M、Q5_0 中的符号分别是什么含义？\nQ：表示量化。 K：K-Quants 这一族（相对 legacy 的 Q4_0 等更细的 block/scale 结构）。 后缀 S, M, L（出现在 *_K_S / *_K_M / *_K_L）：像衣服尺码，表示敏感张量保留更高精度的混合策略强度不同： _K_S: Small / 更紧压缩，质量通常更低。 _K_M: Medium，体积与质量较均衡，很常用。 _K_L: Larger / 更高 quality，体积更大。 legacy 后缀 0 / 1（只适用于 Q4_0、Q4_1、Q5_0、Q5_1 这类旧格式，不要和 K 后缀混为一谈）： _0：分块 + 对称（主要靠 scale）。 _1：分块 + 非对称（scale + min/offset）。 Part 5: 去看了看现在的模型 (Kimi 2.5 \u0026amp; Qwen 3) Kimi-K2.5-GGUF unsloth/Kimi-K2.5-GGUF · Hugging Face 看了看刚出的 kimi2.5 的 gguf， 1-16bit 都有，虽然我都跑不了。\n新出现的术语：\nIQ1_S / IQ1_M (276 GB / 301 GB): IQ*：llama.cpp 的 I-quant 系列，面向极低比特，常用非线性/查表等方式重建权重。 imatrix（Importance Matrix） 是另一回事：用校准数据估计哪些权重更重要，从而提升量化质量。它强烈推荐用于 IQ，也可用于 K-quant；imatrix ≠ IQ 本身。 imatrix 相关链接 TQ1_0 (240 GB): T (Ternary): 三值量化。权重的值只能是 {-1, 0, 1} 或者是特定的三个数。 Q2_K_XL (375 GB): Q2: 2-bit 量化。 K: K-Quants。 XL: 社区（如 Unsloth）在标准 S/M/L 之外的更偏质量的混合精度策略。虽然名字还是 Q2，实际体积通常比更激进的 Q2 更大，因为关键张量保留了更高精度。 Qwen3 系列 Qwen3 - a Qwen Collection 又发现新词了：\n1. Thinking vs Instruct vs Base 据说是模仿 OpenAI o1 / DeepSeek-R1 的新分类。\nBase Model：原始模型，进行了预训练 Pre-training，能够预测下一个字，适用作为下游任务的底座（如在此基础上进行微调），或者用于单纯的文本补全任务。 Instruct / Chat Model：在 Base 模型的基础上，经过了监督微调（SFT）和人类反馈强化学习（RLHF），理解意图与遵循指令。它学会了“对话”的模式，知道当用户提问时，应该回答而不是续写。 Thinking / Reasoning Model：通过特殊的强化学习（RL）训练，学会了在输出最终答案前，先生成一段思维链（Chain of Thought, CoT）。自我感觉 thinking 确实聪明不少。 2. 命名规则：235B-A22B (MoE 架构) 235B (Total Params): 模型总共有 2350亿 个参数。硬盘占用极大，显存需求极大。 A22B (Activated Params): A 代表 Active。意思是当你问一个问题时，并不是 2350 亿个参数都在动，只有 220亿 个专家被激活参与计算。 Part 6: 其他量化格式 1. FP8 (Floating Point 8)\n例如：Qwen3-235B-A22B-Thinking-2507-FP8\nFP8 是原生浮点格式，会比 FP16 快，虽然精度低一些。 部分新显卡有 FP8 Tensor Core，例如 H100、RTX 4090（Ada）。但硬件支持不等于所有推理框架都会默认走原生 FP8，消费卡上的软件栈与数据中心卡体验也常不同。 2. GPTQ (Generative Pre-trained Transformer Quantization)\n例如：Qwen3-235B-A22B-GPTQ-Int4\n技术原理: 逐行量化。它利用数学方法（Hessian 矩阵）来计算：“如果我把这个权重删减了，对输出误差影响大不大？”然后进行补偿。 特点: 静态: 需要一个“校准数据集”先跑一遍，一旦压好了，文件就定死了。 显卡友好: 在 NVIDIA 显卡上优化极好。 3. AWQ (Activation-aware Weight Quantization)\n例如：Qwen3-32B-AWQ\n把 FP16 压缩为 INT4 格式。 原理：在 AWQ 之前，很多量化方法只盯着**权重（Weights）**看。但 AWQ 作者发现：并不是数值大的权重才重要，重要性取决于“激活值（Activation）”。 模型中只有 0.1% - 1% 的权重是重要的 (Salient Weights)，它们对应的激活值非常大。只要保护好这 1% 的权重，整个模型的精度就能保住。 流程：与 GPTQ 相比不调整权重数值，只计算缩放系数： 观察：找一段校准数据喂给模型，看看哪些通道的激活值特别大。 放大：把这些对应的重要权重数值“放大”。 缩小：为了保证数学结果不变，同时把输入的激活值“缩小”。 量化：经过放大后的权重，在进行量化（四舍五入）时，相对误差会变小。 4. MLX\nMLX: 苹果面向 Apple Silicon 的数组 / 机器学习框架，主要利用统一内存与 Metal GPU（也可跑在 CPU 上）。一般不把「默认吃满 Neural Engine（ANE/NPU）」当作它的卖点；近年芯片上的矩阵加速单元会通过 Metal 相关路径被用到，但和传统意义上的 ANE 后端不是一回事。 ","permalink":"https://www.caulif.com/posts/gguf%E4%B8%8E%E9%87%8F%E5%8C%96/","summary":"从 GGUF 文件结构、内存映射、张量卸载和量化格式入手，整理本地大模型文件与推理优化的基础概念。","title":"GGUF与量化"},{"content":"校医院报销力度好大，每次去校医院都感觉在赚钱就是说。\n","permalink":"https://www.caulif.com/posts/%E4%BB%8A%E6%97%A5%E6%8B%94%E4%BA%86%E4%B8%80%E9%A2%97%E6%99%BA%E9%BD%BF/","summary":"记录一次拔智齿和校医院报销体验。","title":"今日拔了一颗智齿"},{"content":" 基于 llama2.c 项目的 run.c 源码注释整理\n写在开头 一直在关注llm的相关进展，但是感觉对Transformer一知半解，前几天在ai推荐下深入阅读了 Llama 2 的纯 C 语言实现（llama2.c），主要是通过写注释+查不懂的原理的方式理解这个代码，读完感觉对transformer清晰很多，大佬还是太强了，学习到了很多之前没有注意到的细节。\n本文由ai阅读我的注释后辅助撰写。\n源码链接如下：https://github.com/karpathy/llama2.c\nTransformer 核心概念 整体架构 一个完整的大模型推理流程如下：\ntext 用户输入 → 分词(Tokenizer) → 词嵌入(Embedding) → [Block 1 → Block 2 → ... → Block N] → 最终归一化 → 线性层投影 → Softmax 采样 → 输出 TokenDecoder-Only 架构 在 Decoder-only 架构（如 GPT、Llama）中，所有的 Transformer Block 是完全线性串联的。每个 Block 的输出直接作为下一个 Block 的输入。\n模型配置与数据结构 Config：模型超参数 c typedef struct { int dim; // 词嵌入向量的长度 int hidden_dim; // FFN 中间层的宽度 int n_layers; // Transformer Block 重复堆叠的次数 int n_heads; // Multi-Head Attention 中 Query 的头数 int n_kv_heads; // Key 和 Value 的头数（可以小于 n_heads） int vocab_size; // 词表大小 int seq_len; // 最大序列长度（上下文窗口） } Config;参数解释：\ndim：词嵌入向量（Embedding）的长度。输入会先转化为 token，每个 token 映射为一个 dim 长度的向量。 hidden_dim：在 Transformer 每一层内部，Self-Attention 后面都跟着一个全连接层（FFN）。hidden_dim 就是这个中间层的宽度。模型先将 dim 扩展到 hidden_dim（通常是 2-4 倍），进行非线性变换，再压缩回 dim。这是模型存储\u0026quot;知识\u0026quot;的主要地方。 n_kv_heads：在现代模型中，KV 的头通常少于 Q 的头，实现压缩显存占用的目的，多个 Q 头共享同一个 KV 矩阵。 TransformerWeights：模型权重 c typedef struct { float* token_embedding_table; // (vocab_size, dim) Token 嵌入表 float* rms_att_weight; // (layer, dim) Attention 前的归一化权重 float* rms_ffn_weight; // (layer, dim) FFN 前的归一化权重 float* wq; // (layer, dim, n_heads * head_size) Query 矩阵 float* wk; // (layer, dim, n_kv_heads * head_size) Key 矩阵 float* wv; // (layer, dim, n_kv_heads * head_size) Value 矩阵 float* wo; // (layer, n_heads * head_size, dim) 输出矩阵 float* w1; // (layer, hidden_dim, dim) FFN 升维矩阵（Gate） float* w2; // (layer, dim, hidden_dim) FFN 降维矩阵 float* w3; // (layer, hidden_dim, dim) FFN 升维矩阵（Up） float* rms_final_weight; // (dim,) 最终归一化权重 float* wcls; // 输出分类器（可能与 embedding 共享） } TransformerWeights;权重共享： 在许多 Transformer 模型中，输入词嵌入层和输出分类层是共享权重的。如果 wcls 直接指向 token_embedding_table，可以节省大量显存。\nRunState：运行时激活值 c typedef struct { float *x; // 当前激活值 (dim,) float *xb; // 残差分支缓冲区 (dim,) float *xb2; // 额外缓冲区 (dim,) float *hb; // FFN 隐藏层缓冲区 (hidden_dim,) float *hb2; // FFN 隐藏层缓冲区 (hidden_dim,) float *q; // Query (dim,) float *k; // Key (dim,) float *v; // Value (dim,) float *att; // 注意力分数 (n_heads, seq_len) float *logits; // 输出 logits float* key_cache; // KV 缓存 (layer, seq_len, dim) float* value_cache; // KV 缓存 (layer, seq_len, dim) } RunState;KV Cache 的重要性： 模型每产生一个新词，都要回看之前所有的词。为了不重复计算前面所有词的 K 和 V，我们将它们永久存在这两个\u0026quot;记忆库\u0026quot;中。\n显存占用示例： 如果一个模型的 dim=4096, n_layers=32, seq_len=4096，且不使用 GQA（即 kv_dim=4096）：\n单个 Cache 的大小 = 32 × 4096 × 4096 × 4 字节 ≈ 2 GB 两个 Cache 加起来就需要 4 GB 显存/内存 Transformer Block Transformer Block 由两个子层组成，每个子层都辅助有残差连接（Residual Connection）和层归一化（Layer Normalization）。\n1. Multi-Head Attention（多头自注意力层） 这是 Block 的第一个子层，负责捕捉序列中不同位置之间的依赖关系。\n简单理解：\n输入是\u0026quot;词典里的词\u0026quot; Attention 过程是\u0026quot;读句子的过程\u0026quot; 输出则是\u0026quot;放在句子里理解之后的词\u0026quot; 计算流程 步骤 1：线性投影\n输入的特征张量 (X \\in \\mathbb{R}^{seq_len \\times dim}) 分别乘以三个权重矩阵 (W^Q, W^K, W^V)，生成查询（Query）、键（Key）和值（Value）矩阵。\n步骤 2：多头拆分\n在不使用 GQA 时，可将 (Q/K/V) 的特征维 (dim) 拆成 (n_{heads}) 个头，每个头的维度为 (d_k = dim / n_{heads})。若启用 GQA，则 K/V 的总维是 n_kv_heads * head_size，多个 Q 头共享同一组 K/V。\n每个 [Batch, Seq_Len, dim] 变成了 [Batch, Seq_Len, n_heads, d_k]（Q；K/V 在 GQA 下头数可能更少） 也就是说原来的 dim 长度的向量，按头切开并行计算 为了并行，对维度进行置换：\n[Batch, Seq_Len, n_heads, d_k] → [Batch, n_heads, Seq_Len, d_k] 仅交换位置，Batch 和 n_heads 看作是批处理维度，同时在 n_heads 个子空间内并行计算各自的注意力 步骤 3：注意力计算\n在每个头内部计算点积注意力，这里 (Q_i, K_i, V_i)（形状均为 [Seq_Len, d_k]）：\n$$ \\text{Attention}(Q, K, V) = \\text{softmax}\\left(\\frac{QK^T}{\\sqrt{d_k}}\\right)V $$详细分解：\n(QK^T)：[Seq_Len, d_k] × [d_k, Seq_Len] → [Seq_Len, Seq_Len]\n被称为注意力得分矩阵（Attention Score Matrix） 元素 ((i, j))：代表序列中第 (i) 个单词与第 (j) 个单词之间的关联强度 缩放操作 (\\frac{1}{\\sqrt{d_k}})：\n当维度 (d_k) 非常大时，点积结果的方差会变得很大 假设 (Q) 和 (K) 的分量都是均值为 0、方差为 1 的独立随机变量，则 (QK^T) 的点积均值为 0，方差为 (d_k) 除以 (\\sqrt{d_k}) 将方差重新缩放到 1，使梯度保持平稳 归一化处理 (\\text{softmax}(\\cdot))：\n假设输入向量为 (\\mathbf{z} = [z_1, z_2, \\dots, z_n])，Softmax 运算对其中每一个元素 (z_i) 的计算公式如下： $$ \\text{Softmax}(z_i) = \\frac{e^{z_i}}{\\sum_{j=1}^{n} e^{z_j}} $$ 物理意义：将原始得分转化为概率分布，即所有的权重都变为正数且和为 1。这决定了在生成最终输出时，每个位置的信息应该贡献多少比例。 加权求和 (\\cdot V)：\n根据计算出的注意力权重，对 (V) 向量进行线性加权 模型最终会选择性地从 (V) 中提取出与当前 (Q) 最相关的信息 步骤 4：多头拼接与投影\n将所有头的输出拼接回 (dim) 维度，再通过一个线性投影矩阵 (W^O) 进行映射。\n维度变化总结 假设输入序列长度为 (L)，特征维度为 (d)：\n输入映射：(Q(L \\times d_k), K(L \\times d_k), V(L \\times d_v)) 得分矩阵 ((QK^T))：((L \\times d_k) \\times (d_k \\times L) = (L \\times L)) Softmax 概率：((L \\times L)) 最终输出 ((\\text{Prob} \\times V))：((L \\times L) \\times (L \\times d_v) = (L \\times d_v)) 2. Feed-Forward Network（前馈神经网络层） FFN 负责对每个词本身的特征进行深度加工和非线性变换。\n简单理解：\n输入是\u0026quot;初步理解了语境的词\u0026quot; FFN 过程是\u0026quot;对照脑海中的知识库进行深加工和逻辑验证的过程\u0026quot; 输出则是\u0026quot;最终定型、具备深层逻辑含义的知识表达\u0026quot; 公式 经典两层 FFN（带 bias 的教科书形式）：\n$$ \\text{FFN}(x) = \\text{Activation}(xW_1 + b_1)W_2 + b_2 $$其中：\n(x)：是该层的输入特征，形状为 [Seq_Len, dim] (W_1)：第一层线性变换的权重矩阵，形状为 [dim, hidden_dim] —— 升维 (Expansion) (W_2)：第二层线性变换的权重矩阵，形状为 [hidden_dim, dim] —— 降维 (Projection) (b_1, b_2)：偏置项。现代大模型如 Llama 通常去掉 bias；Llama 的 FFN 实际是后面的 SwiGLU 形式（w1/w3 升维门控，w2 降维），而不是简单的两层 + 单一激活。 激活函数 不同模型使用不同的激活函数：\nReLU（原始 Transformer）：(\\max(0, x))\n简单高效，但存在神经元\u0026quot;坏死\u0026quot;的问题 GELU（BERT, GPT-3）：高斯误差线性单元\n在 0 附近更平滑，目前是中等规模模型的标准配置 SwiGLU（Llama, PaLM）：现代大模型的主流选择\n结合了 Swish 激活函数和门控线性单元（GLU） 计算方式： $$ \\text{SwiGLU}(x) = (\\text{Swish}(xW_1) \\otimes xW_3)W_2 $$其中 (\\text{Swish}(x) = x \\cdot \\sigma(x) = \\frac{x}{1 + e^{-x}})\n这种设计增加了额外的参数，但显著提升了模型的收敛速度和最终表现 代码实现 展开代码：// 升维 c // 升维 matmul(s-\u0026gt;hb, s-\u0026gt;xb, w-\u0026gt;w1 + l*dim*hidden_dim, dim, hidden_dim); matmul(s-\u0026gt;hb2, s-\u0026gt;xb, w-\u0026gt;w3 + l*dim*hidden_dim, dim, hidden_dim); // SwiGLU 激活 for (int i = 0; i \u0026lt; hidden_dim; i++) { float val = s-\u0026gt;hb[i]; // SiLU(x) = x * σ(x) val *= (1.0f / (1.0f + expf(-val))); // 逐元素乘以 w3(x) val *= s-\u0026gt;hb2[i]; s-\u0026gt;hb[i] = val; } // 降维 matmul(s-\u0026gt;xb, s-\u0026gt;hb, w-\u0026gt;w2 + l*dim*hidden_dim, hidden_dim, dim); 3. 残差连接与层归一化 残差连接（Residual Connection） $$ \\text{Output} = x + f(x) $$其中 (x) 为输入，(f) 为 Attention 或 FFN。\n物理意义：保留原始信息，防止在交流中\u0026quot;迷失自我\u0026quot;。\n层归一化（Layer Normalization） 将神经元的输出调整到一个合理的分布范围（通常是均值为 0，方差为 1）。\n$$ \\text{LN}(x_i) = \\gamma \\cdot \\hat{x}_i + \\beta $$其中：\n$$ \\hat{x}_i = \\frac{x_i - \\mu}{\\sqrt{\\sigma^2 + \\epsilon}} $$ (\\mathbf{x})：输入向量，在 Transformer 中其维度即为 dim（如 512 或 4096） (\\mu)：均值 (\\sigma^2)：方差 (\\epsilon)：数值稳定性项，确保分母不为零 (\\gamma)（Gamma）：维度与 dim 一致的向量，初始化通常为全 1。模型通过训练来调整每一维特征的缩放比例 (\\beta)（Beta）：维度与 dim 一致的向量，初始化通常为全 0。模型通过训练来调整每一维特征的基准偏移 RMSNorm（Llama 使用） RMSNorm（Root Mean Square Layer Normalization，均方根归一化）是 LayerNorm 的简化版本：\n计算均方根： $$ \\text{RMS}(x) = \\sqrt{\\frac{1}{n} \\sum_{i=1}^{n} x_i^2 + \\epsilon} $$ 归一化： $$ \\bar{x}_i = \\frac{x_i}{\\text{RMS}(x)} $$ 缩放： $$ y_i = \\bar{x}_i \\cdot \\gamma_i $$ 代码实现：\nc void rmsnorm(float* o, float* x, float* weight, int size) { // 计算平方和 float ss = 0.0f; for (int j = 0; j \u0026lt; size; j++) { ss += x[j] * x[j]; } ss /= size; ss += 1e-5f; // epsilon ss = 1.0f / sqrtf(ss); // 归一化并缩放 for (int j = 0; j \u0026lt; size; j++) { o[j] = weight[j] * (ss * x[j]); } }结构组装流水线（Pre-Norm） 一个 Transformer Block 的完整流程（以 Pre-Norm 为例）：\n第一层归一化（LayerNorm 1）：对输入向量进行标准化，平滑数值 多头自注意力（Attention）：向量在这里进行\u0026quot;横向交流\u0026quot;，获取上下文 第一层残差相加（Residual Add）：将 Attention 的输出与最原始的输入直接相加 第二层归一化（LayerNorm 2）：再次标准化，为深度计算做准备 前馈神经网络（FFN）：向量在这里进行\u0026quot;纵向深挖\u0026quot;，对照知识库提取语义 第二层残差相加（Residual Add）：将 FFN 的输出与进入 FFN 前的状态相加 数学表达： $$ x_1 = x + \\text{Attention}(\\text{LayerNorm}_1(x)) $$$$ x_{out} = x_1 + \\text{FFN}(\\text{LayerNorm}_2(x_1)) $$ 位置编码：RoPE 为什么需要位置编码？ Attention 机制本身是位置无关的。如果不加位置信息，模型无法区分\u0026quot;我爱你\u0026quot;和\u0026quot;你爱我\u0026quot;。\nRoPE（Rotary Positional Embedding） RoPE 通过在向量空间中旋转 Q 和 K，使得它们的点积自动包含相对位置信息。\n核心思想：如果这个词处于句子的第 (m) 个位置，我们就把这个点绕原点旋转 (m \\cdot \\theta) 角度。\n数学推导 假设我们有一个二维向量 (\\mathbf{x} = [x_1, x_2])，它处于位置 (m)。我们定义一个旋转矩阵 (\\mathbf{R}_m)：\n$$ \\mathbf{R}_m = \\begin{pmatrix} \\cos(m\\theta) \u0026 -\\sin(m\\theta) \\\\ \\sin(m\\theta) \u0026 \\cos(m\\theta) \\end{pmatrix} $$旋转后的向量为：\n$$ \\mathbf{x}' = \\mathbf{R}_m \\mathbf{x} $$为什么这能代表相对位置？ 这是 RoPE 最精妙的地方。当我们计算 Query ((Q)) 在位置 (m) 和 Key ((K)) 在位置 (n) 的点积时：\n$$ \\text{Score}(m, n) = (\\mathbf{R}_m \\mathbf{q})^T (\\mathbf{R}_n \\mathbf{k}) = \\mathbf{q}^T \\mathbf{R}_m^T \\mathbf{R}_n \\mathbf{k} = \\mathbf{q}^T \\mathbf{R}_{n-m} \\mathbf{k} $$结论：点积的结果只与 (n-m)（即两个词的相对距离）有关。这意味着，尽管我们在每一层对 (Q) 和 (K) 做了绝对位置的旋转，但注意力机制捕捉到的却是相对距离。\n多维扩展与计算优化 对于一个 (d) 维的高维向量，RoPE 会将其拆分成 (d/2) 个二维对。每一对都会根据预设的频率 (\\theta_i) 进行不同速度的旋转：\n$$ \\theta_i = 10000^{-2i/d} $$在实际代码实现中，我们不会真的去乘一个巨大的旋转矩阵（太慢了），而是利用复数乘法的简化形式：\n$$ \\begin{pmatrix} x_1 \\\\ x_2 \\end{pmatrix} \\to \\begin{pmatrix} x_1 \\cos(m\\theta) - x_2 \\sin(m\\theta) \\\\ x_1 \\sin(m\\theta) + x_2 \\cos(m\\theta) \\end{pmatrix} $$代码实现 展开代码：// RoPE 相对位置编码：在每个头内旋转 q 和 k c // RoPE 相对位置编码：在每个头内旋转 q 和 k for (int i = 0; i \u0026lt; dim; i+=2) { int head_dim = i % head_size; float freq = 1.0f / powf(10000.0f, head_dim / (float)head_size); float val = pos * freq; float fcr = cosf(val); float fci = sinf(val); int rotn = i \u0026lt; kv_dim ? 2 : 1; // 2 = q \u0026amp; k, 1 = q only for (int v = 0; v \u0026lt; rotn; v++) { float* vec = v == 0 ? s-\u0026gt;q : s-\u0026gt;k; float v0 = vec[i]; float v1 = vec[i+1]; vec[i] = v0 * fcr - v1 * fci; vec[i+1] = v0 * fci + v1 * fcr; } } 为什么使用 head_dim 而不是全局索引 i？\n一个 dim 被切割成多个头的部分，每个头对应位置的频率相同。如果直接用全局索引 i 来计算频率，那么第一个头的旋转频率会非常快，而最后一个头的旋转频率会极其慢。这会导致不同的头对\u0026quot;位置\u0026quot;的理解完全不同，模型就乱套了。\n前向传播流程 完整的模型架构 text 输入端：词嵌入 + 位置编码 ↓ [Block 1] ↓ [Block 2] ↓ ... ↓ [Block N] ↓ 输出端：最终归一化 + 线性层 + Softmax输入端：词嵌入与位置编码 在进入第一个 Block 之前，数据需要经过：\n词嵌入层（Token Embedding）：将 Token ID 转换成维度为 dim 的向量 位置编码（Positional Encoding / RoPE）：现代模型（如 Llama）通常在每一层的 Attention 内部直接应用旋转位置嵌入（RoPE），让模型知道词与词之间的相对距离 输出端：输出头 在最后一个 Block 输出向量后，需要将其变回人类可读的文字：\n最终归一化（Final LayerNorm / RMSNorm）：对最后一层的输出进行最后的数值校准 线性层（Linear/Language Model Head）：将维度从 dim 映射回巨大的词表维度（vocab_size）。得到一个长度为 vocab_size 的长向量 (z)，称为 Logits（原始得分）。向量中的每一个数代表了对应单词的\u0026quot;得分\u0026quot;，得分越高，模型认为该词出现的可能性越大 Softmax：计算出下一个 Token 出现的概率分布 优化组件 KV Cache（推理时存在）：在推理过程中，为了加速，模型会在内存中开辟一块空间，缓存每一层已经计算过的 Key 和 Value 向量 训练期正则（如 Dropout）：训练阶段用于减轻过拟合；推理期通常关闭 前向传播代码流程 关键签名：\nc float* forward(Transformer* transformer, int token, int pos); 展开代码：float* forward(Transformer* transformer, int token, int c float* forward(Transformer* transformer, int token, int pos) { // 1. 获取 token 的嵌入向量 float* content_row = w-\u0026gt;token_embedding_table + token * dim; memcpy(x, content_row, dim*sizeof(*x)); // 2. 遍历所有层 for(unsigned long long l = 0; l \u0026lt; p-\u0026gt;n_layers; l++) { // 2.1 Attention 前的 RMSNorm rmsnorm(s-\u0026gt;xb, x, w-\u0026gt;rms_att_weight + l*dim, dim); // 2.2 计算 QKV matmul(s-\u0026gt;q, s-\u0026gt;xb, w-\u0026gt;wq + l*dim*dim, dim, dim); matmul(s-\u0026gt;k, s-\u0026gt;xb, w-\u0026gt;wk + l*dim*kv_dim, dim, kv_dim); matmul(s-\u0026gt;v, s-\u0026gt;xb, w-\u0026gt;wv + l*dim*kv_dim, dim, kv_dim); // 2.3 应用 RoPE // ... (旋转 Q 和 K) // 2.4 多头注意力 for (h = 0; h \u0026lt; p-\u0026gt;n_heads; h++) { // 计算注意力分数 for (int t = 0; t \u0026lt;= pos; t++) { score = dot_product(q, k[t]) / sqrt(head_size); att[t] = score; } // Softmax softmax(att, pos + 1); // 加权求和 for (int t = 0; t \u0026lt;= pos; t++) { xb += att[t] * v[t]; } } // 2.5 输出投影 matmul(s-\u0026gt;xb2, s-\u0026gt;xb, w-\u0026gt;wo + l*dim*dim, dim, dim); // 2.6 残差连接 for (int i = 0; i \u0026lt; dim; i++) { x[i] += s-\u0026gt;xb2[i]; } // 2.7 FFN 前的 RMSNorm rmsnorm(s-\u0026gt;xb, x, w-\u0026gt;rms_ffn_weight + l*dim, dim); // 2.8 FFN（SwiGLU） matmul(s-\u0026gt;hb, s-\u0026gt;xb, w-\u0026gt;w1 + l*dim*hidden_dim, dim, hidden_dim); matmul(s-\u0026gt;hb2, s-\u0026gt;xb, w-\u0026gt;w3 + l*dim*hidden_dim, dim, hidden_dim); // SwiGLU 激活 for (int i = 0; i \u0026lt; hidden_dim; i++) { s-\u0026gt;hb[i] = silu(s-\u0026gt;hb[i]) * s-\u0026gt;hb2[i]; } matmul(s-\u0026gt;xb, s-\u0026gt;hb, w-\u0026gt;w2 + l*dim*hidden_dim, hidden_dim, dim); // 2.9 残差连接 for (int i = 0; i \u0026lt; dim; i++) { x[i] += s-\u0026gt;xb[i]; } } // 3. 最终归一化 rmsnorm(x, x, w-\u0026gt;rms_final_weight, dim); // 4. 输出分类器 matmul(s-\u0026gt;logits, x, w-\u0026gt;wcls, p-\u0026gt;dim, p-\u0026gt;vocab_size); return s-\u0026gt;logits; } 分词器：BPE BPE（Byte Pair Encoding） BPE 是一种子词分词算法，它让模型能够自适应地处理文本：\n常见词（如 \u0026ldquo;the\u0026rdquo;）：直接合并成一个 Token，节省计算量 罕见词（如 \u0026ldquo;Transformer\u0026rdquo;）：可能拆成 \u0026ldquo;Trans\u0026rdquo;, \u0026ldquo;former\u0026rdquo; 两个 Token 从未见过的词：拆成一个个字节 编码流程 假设你输入 \u0026ldquo;hi\u0026rdquo;：\n准备：加上 \u0026lt;s\u0026gt; 和前置空格 \u0026quot; hi\u0026quot; 原子化：拆成 [空格, h, i] 寻找合并： 检查 (空格, h)，发现词典里有 \u0026quot; h\u0026quot;，分数 10.5 检查 (h, i)，发现词典里有 \u0026ldquo;hi\u0026rdquo;，分数 12.0 执行合并：因为 \u0026ldquo;hi\u0026rdquo; 分数更高，先合并后面，变成 [空格, hi] 再次合并：检查 (空格, hi)，如果词典里有 \u0026quot; hi\u0026quot; 且分数够高，合并成 [ hi] 输出：最后得到一个 ID 为什么要加前置空格？ 在英语中，绝大多数单词在句子中间出现时，前面都是带空格的（例如：\u0026ldquo;I like apples\u0026rdquo;）。因此，在模型训练时，词典里存储的 Token 大多是带前置空格的，比如：\nToken A: \u0026quot; apple\u0026quot; (ID: 12345) Token B: \u0026ldquo;apple\u0026rdquo; (ID: 6789) —— 这通常被视为一个完全不同的词 这是一个 Llama/SentencePiece 的特殊设计。它会在你的输入前面强行加一个空格。这样处理是为了让 \u0026ldquo;hello\u0026rdquo; 出现在句首和句中时，编码结果保持一致。\nUTF-8 处理 BPE 需要正确处理 UTF-8 编码的多字节字符。UTF-8 编码规则：\n码点范围 字节 1 字节 2 字节 3 字节 4 U+0000 - U+007F 0xxxxxxx U+0080 - U+07FF 110xxxxx 10xxxxxx U+0800 - U+FFFF 1110xxxx 10xxxxxx 10xxxxxx U+10000 - U+10FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 关键判断：\n(*c \u0026amp; 0xC0) != 0x80：判断当前字节是不是 UTF-8 的起始字节 0xC0 是 11000000，0x80 是 10000000 在 UTF-8 中，所有后续字节都以 \u0026ldquo;10\u0026rdquo; 开头 编码代码流程 展开代码：void encode(Tokenizer* t, char *text, int8_t bos, int8_t c void encode(Tokenizer* t, char *text, int8_t bos, int8_t eos, int *tokens, int *n_tokens) { // 1. 添加 BOS token（如果需要） if (bos) tokens[(*n_tokens)++] = 1; // 2. 添加虚拟前置空格 if (text[0] != \u0026#39;\\0\u0026#39;) { int dummy_prefix = str_lookup(\u0026#34; \u0026#34;, t-\u0026gt;sorted_vocab, t-\u0026gt;vocab_size); tokens[(*n_tokens)++] = dummy_prefix; } // 3. UTF-8 初步拆解（原子化） for (char *c = text; *c != \u0026#39;\\0\u0026#39;; c++) { // 判断是否为起始字节 if ((*c \u0026amp; 0xC0) != 0x80) { str_len = 0; } str_buffer[str_len++] = *c; str_buffer[str_len] = \u0026#39;\\0\u0026#39;; // 继续读取后续字节 if ((*(c+1) \u0026amp; 0xC0) == 0x80 \u0026amp;\u0026amp; str_len \u0026lt; 4) { continue; } // 读完一个完整字符，查表 int id = str_lookup(str_buffer, t-\u0026gt;sorted_vocab, t-\u0026gt;vocab_size); if (id != -1) { tokens[(*n_tokens)++] = id; } else { // 字节回退：如果找不到，就按字节编码 for (int i=0; i \u0026lt; str_len; i++) { tokens[(*n_tokens)++] = (unsigned char)str_buffer[i] + 3; } } str_len = 0; } // 4. BPE 合并循环 while (1) { float best_score = -1e10; int best_id = -1; int best_idx = -1; // 扫描所有相邻 token 对 for (int i=0; i \u0026lt; (*n_tokens-1); i++) { sprintf(str_buffer, \u0026#34;%s%s\u0026#34;, t-\u0026gt;vocab[tokens[i]], t-\u0026gt;vocab[tokens[i+1]]); int id = str_lookup(str_buffer, t-\u0026gt;sorted_vocab, t-\u0026gt;vocab_size); if (id != -1 \u0026amp;\u0026amp; t-\u0026gt;vocab_scores[id] \u0026gt; best_score) { best_score = t-\u0026gt;vocab_scores[id]; best_id = id; best_idx = i; } } if (best_idx == -1) break; // 没有可合并的了 // 合并最佳对 tokens[best_idx] = best_id; for (int i = best_idx+1; i \u0026lt; (*n_tokens-1); i++) { tokens[i] = tokens[i+1]; } (*n_tokens)--; } // 5. 添加 EOS token（如果需要） if (eos) tokens[(*n_tokens)++] = 2; } 解码流程 解码相对简单，就是将 Token ID 映射回字符串：\nc char* decode(Tokenizer* t, int prev_token, int token) { char *piece = t-\u0026gt;vocab[token]; // BOS 后的空格处理 if (prev_token == 1 \u0026amp;\u0026amp; piece[0] == \u0026#39; \u0026#39;) { piece++; } // 处理字节 token（如 \u0026lt;0x01\u0026gt;） unsigned char byte_val; if (sscanf(piece, \u0026#34;\u0026lt;0x%02hhX\u0026gt;\u0026#34;, \u0026amp;byte_val) == 1) { piece = (char*)t-\u0026gt;byte_pieces + byte_val * 2; } return piece; }完整流程示意 text 编码 (Encoding): \u0026#34;Hello\u0026#34; → [15496] ↓ 推理 (Inference): 模型处理 [15496] → 预测 [13]（对应 \u0026#34;,\u0026#34;） ↓ 解码 (Decoding): [13] → \u0026#34;,\u0026#34; ↓ 展示: safe_printf 打印 \u0026#34;,\u0026#34; 采样策略 如果说 forward（前向传播）是 Transformer 的\u0026quot;思考\u0026quot;过程，那么 **Sampler（采样器）**就是它的\u0026quot;决策过程\u0026quot;。\n在 forward 结束时，模型给出的不是一个确定的词，而是对词典中所有词（通常是 32,000 个）的\u0026quot;打分\u0026quot;（Logits）。采样器的任务就是根据这些分数和一些参数，最终拍板决定：下一个词到底选谁？\n三种采样风格 风格 参数条件 描述 效果 贪心采样 (Greedy) temp == 0 永远选概率最高的那一个 稳定、死板、容易循环 多项式采样 (Multinomial) temp \u0026gt; 0, topp == 1 按照概率分布\u0026quot;抽奖\u0026quot; 有创意，但也可能胡言乱语 核采样 (Top-P/Nucleus) 0 \u0026lt; topp \u0026lt; 1 只在概率加和达到 P 的\u0026quot;头部\u0026quot;词中抽奖 主流方案：平衡了多样性与逻辑性 1. 贪心采样（Greedy Sampling） 永远选择概率最高的那个词。\nc int sample_argmax(float* probabilities, int n) { int max_i = 0; float max_p = probabilities[0]; for (int i = 1; i \u0026lt; n; i++) { if (probabilities[i] \u0026gt; max_p) { max_i = i; max_p = probabilities[i]; } } return max_i; }优点：稳定、可复现 缺点：死板、容易陷入重复循环\n2. 多项式采样（Multinomial Sampling） 按照概率分布随机抽取。\nc int sample_mult(float* probabilities, int n, float coin) { float cdf = 0.0f; for (int i = 0; i \u0026lt; n; i++) { cdf += probabilities[i]; if (coin \u0026lt; cdf) { return i; } } return n - 1; }工作原理：\n从头依次累加概率 当累加值超过随机数 coin（0-1 之间）时，返回当前 token 优点：有创意、多样性高 缺点：可能选到低概率的\u0026quot;胡言乱语\u0026quot;\n3. 核采样（Top-P / Nucleus Sampling） 只在概率加和达到 P（如 0.9）的\u0026quot;核心\u0026quot;词中抽奖。\n展开代码：int sample_topp(float* probabilities, int n, float topp, c int sample_topp(float* probabilities, int n, float topp, ProbIndex* probindex, float coin) { // 1. 预筛选：剔除极低概率的词 const float cutoff = (1.0f - topp) / (n - 1); int n0 = 0; for (int i = 0; i \u0026lt; n; i++) { if (probabilities[i] \u0026gt;= cutoff) { probindex[n0].index = i; probindex[n0].prob = probabilities[i]; n0++; } } // 2. 排序：按概率从高到低 qsort(probindex, n0, sizeof(ProbIndex), compare); // 3. 截断：累加到 topp 为止 float cumulative_prob = 0.0f; int last_idx = n0 - 1; for (int i = 0; i \u0026lt; n0; i++) { cumulative_prob += probindex[i].prob; if (cumulative_prob \u0026gt; topp) { last_idx = i; break; } } // 4. 在截断后的列表中采样 float r = coin * cumulative_prob; float cdf = 0.0f; for (int i = 0; i \u0026lt;= last_idx; i++) { cdf += probindex[i].prob; if (r \u0026lt; cdf) { return probindex[i].index; } } return probindex[last_idx].index; } 工作流程：\n预筛选：为了效率，先剔除掉概率极低的词（cutoff 逻辑） 排序：将候选词按概率从高到低排列 截断：从高到低累加概率，一旦总和达到 topp（例如 0.9），剩下的词全部扔掉 再采样：在剩下的这几个高概率词（\u0026ldquo;核\u0026rdquo;）中重新抽奖 优点：平衡了多样性与逻辑性，是目前主流方案\nTemperature（温度）参数 Temperature 控制概率分布的\u0026quot;陡峭程度\u0026quot;：\nc // 应用温度 for (int q=0; q\u0026lt;sampler-\u0026gt;vocab_size; q++) { logits[q] /= sampler-\u0026gt;temperature; } // 再应用 softmax softmax(logits, sampler-\u0026gt;vocab_size);效果：\ntemp = 0：贪心模式，永远选最高概率 temp = 1：保持原始分布 temp \u0026lt; 1（如 0.5）：分布更陡峭，更倾向于高概率词（更保守） temp \u0026gt; 1（如 1.5）：分布更平缓，低概率词也有机会（更随机） 完整采样流程 关键签名：\nc int sample(Sampler* sampler, float* logits); 展开代码：int sample(Sampler* sampler, float* logits) c int sample(Sampler* sampler, float* logits) { int next; if (sampler-\u0026gt;temperature == 0.0f) { // 贪心采样 next = sample_argmax(logits, sampler-\u0026gt;vocab_size); } else { // 应用温度 for (int q=0; q\u0026lt;sampler-\u0026gt;vocab_size; q++) { logits[q] /= sampler-\u0026gt;temperature; } // Softmax softmax(logits, sampler-\u0026gt;vocab_size); // 生成随机数 float coin = random_f32(\u0026amp;sampler-\u0026gt;rng_state); // 选择采样方式 if (sampler-\u0026gt;topp \u0026lt;= 0 || sampler-\u0026gt;topp \u0026gt;= 1) { next = sample_mult(logits, sampler-\u0026gt;vocab_size, coin); } else { next = sample_topp(logits, sampler-\u0026gt;vocab_size, sampler-\u0026gt;topp, sampler-\u0026gt;probindex, coin); } } return next; } 推理循环 Generate 模式：文本续写 这是最基础的生成模式，给定一个 prompt，模型会一个词一个词地续写下去。\n展开代码：void generate(Transformer *transformer, Tokenizer *token c void generate(Transformer *transformer, Tokenizer *tokenizer, Sampler *sampler, char *prompt, int steps) { // 1. 编码 prompt int num_prompt_tokens = 0; int* prompt_tokens = (int*)malloc((strlen(prompt)+3) * sizeof(int)); encode(tokenizer, prompt, 1, 0, prompt_tokens, \u0026amp;num_prompt_tokens); // 2. 初始化 int token = prompt_tokens[0]; int pos = 0; long start = 0; // 3. 主循环 while (pos \u0026lt; steps) { // 3.1 前向传播 float* logits = forward(transformer, token, pos); // 3.2 决定下一个 token int next; if (pos \u0026lt; num_prompt_tokens - 1) { // Prompt 填充期：强制使用下一个输入词 next = prompt_tokens[pos + 1]; } else { // 自主生成期：采样选词 next = sample(sampler, logits); } pos++; // 3.3 退出条件 if (next == 1) { break; } // 3.4 解码并显示 char* piece = decode(tokenizer, token, next); safe_printf(piece); fflush(stdout); // 流式输出 // 3.5 更新状态 token = next; // 3.6 启动计时器 if (start == 0) { start = time_in_ms(); } } // 4. 报告性能 if (pos \u0026gt; 1) { long end = time_in_ms(); fprintf(stderr, \u0026#34;achieved tok/s: %f\\n\u0026#34;, (pos-1) / (double)(end-start)*1000); } free(prompt_tokens); } 生成流程详解 每一步的循环包含：\n输入：上一次生成的 Token（或者 Prompt 的第一个词） 思考：Transformer 做一次前向计算（forward），内部是大量浮点运算；具体量级随模型参数与序列长度变化 决策：采样器在几万个候选词中挑一个（sample） 翻译：分词器把数字变回字符（decode） 反馈：把选中的词塞回模型，回到步骤 1 为什么要 fflush(stdout)？ 在 C 语言中，输出通常是\u0026quot;行缓冲\u0026quot;的。如果不加这一行，程序会等模型生成完一整行才一股脑显示出来。加上 fflush 后，你就能看到模型一个字、一个字蹦出来的效果，这种\u0026quot;流式输出\u0026quot;让 AI 看起来更像是在实时思考。\nChat 模式：对话引擎 Chat 模式在 Generate 的基础上增加了对话管理和特殊格式处理。\nLlama 2 Chat 格式 下面格式对应 Llama 2 Chat / llama2.c 的实现。Llama 3 及之后的对话模板已经换成另一套 special tokens，不能照搬到新模型上。\ntext [INST] \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt; {system_prompt} \u0026lt;\u0026lt;/SYS\u0026gt;\u0026gt; {user_prompt} [/INST]组件说明：\n组件 对应代码/格式 形象比喻 核心功能 System Prompt \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt; 内部内容 底层架构/剧本设定 设定 AI 的\u0026quot;人格\u0026quot;、\u0026ldquo;规则\u0026quot;和\u0026quot;知识边界\u0026rdquo; User Prompt [INST] 里的正文 甲方的具体要求 用户当前输入的问题或指令 Instruction [INST] 标签本身 工作指令信号灯 告诉模型：\u0026ldquo;别再瞎写了，现在开始干活！\u0026rdquo; 示例：\ntext [INST] \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt; 你是一个严谨的法官，只回答法律问题。 \u0026lt;\u0026lt;/SYS\u0026gt;\u0026gt; 帮我写一份离婚协议书 [/INST]Chat 代码流程 展开代码：void chat(Transformer *transformer, Tokenizer *tokenizer c void chat(Transformer *transformer, Tokenizer *tokenizer, Sampler *sampler, char *cli_user_prompt, char *cli_system_prompt, int steps) { char system_prompt[512]; char user_prompt[512]; char rendered_prompt[1152]; int* prompt_tokens = (int*)malloc(1152 * sizeof(int)); int8_t user_turn = 1; // 1=用户输入，0=模型回复 int pos = 0; while (pos \u0026lt; steps) { if (user_turn) { // 1. 获取 system prompt（仅第一次） if (pos == 0) { if (cli_system_prompt == NULL) { read_stdin(\u0026#34;Enter system prompt (optional): \u0026#34;, system_prompt, sizeof(system_prompt)); } else { strcpy(system_prompt, cli_system_prompt); } } // 2. 获取 user prompt if (pos == 0 \u0026amp;\u0026amp; cli_user_prompt != NULL) { strcpy(user_prompt, cli_user_prompt); } else { read_stdin(\u0026#34;User: \u0026#34;, user_prompt, sizeof(user_prompt)); } // 3. 拼接成 Llama 2 Chat 格式 if (pos == 0 \u0026amp;\u0026amp; system_prompt[0] != \u0026#39;\\0\u0026#39;) { sprintf(rendered_prompt, \u0026#34;[INST] \u0026lt;\u0026lt;SYS\u0026gt;\u0026gt;\\n%s\\n\u0026lt;\u0026lt;/SYS\u0026gt;\u0026gt;\\n\\n%s [/INST]\u0026#34;, system_prompt, user_prompt); } else { sprintf(rendered_prompt, \u0026#34;[INST] %s [/INST]\u0026#34;, user_prompt); } // 4. 编码 encode(tokenizer, rendered_prompt, 1, 0, prompt_tokens, \u0026amp;num_prompt_tokens); user_idx = 0; user_turn = 0; printf(\u0026#34;Assistant: \u0026#34;); } // 5. 确定输入 token if (user_idx \u0026lt; num_prompt_tokens) { token = prompt_tokens[user_idx++]; // 填充 KV Cache } else { token = next; } // 6. EOS (=2) 结束 Assistant 回合 if (token == 2) { user_turn = 1; } // 7. 前向传播 + 采样 float* logits = forward(transformer, token, pos); next = sample(sampler, logits); pos++; // 8. 显示 Assistant 的回复 if (user_idx \u0026gt;= num_prompt_tokens \u0026amp;\u0026amp; next != 2) { char* piece = decode(tokenizer, token, next); safe_printf(piece); fflush(stdout); } if (next == 2) { printf(\u0026#34;\\n\u0026#34;); } } free(prompt_tokens); } 两种模式的区别 特性 Generate 模式 Chat 模式 用途 文本续写 多轮对话 输入格式 纯文本 [INST]...[/INST] 格式 状态管理 单次生成 多轮交互 结束条件 达到 steps 或 BOS 达到 steps 或 EOS 典型应用 故事续写、代码补全 聊天机器人、问答系统 总结 Transformer 的核心思想 Self-Attention：让每个词都能\u0026quot;看到\u0026quot;句子中的其他词，理解上下文 Multi-Head：从多个角度理解同一个句子 FFN：对每个词进行深度加工，提取语义特征 残差连接：保留原始信息，防止梯度消失 层归一化：稳定训练过程，加速收敛 位置编码：让模型理解词的顺序关系 关键优化技术 1. KV Cache 问题：每生成一个新词，都要重新计算之前所有词的 K 和 V，非常浪费。\n解决：将每一层的 K 和 V 缓存起来，新词只需要计算自己的 K 和 V，然后与缓存的进行注意力计算。\n代价：显存占用大（可能达到几 GB）。\n2. Grouped-Query Attention (GQA) 问题：标准的 Multi-Head Attention 中，每个头都有自己的 K 和 V，显存占用巨大。\n解决：让多个 Q 头共享同一组 K 和 V 头。例如：\n8 个 Q 头 2 个 KV 头 每 4 个 Q 头共享 1 组 KV 效果：显存占用减少 75%，性能损失很小。\n3. RoPE（旋转位置编码） 优势：\n相对位置编码，泛化能力强 可以外推到更长的序列 计算高效，不需要额外的参数 4. RMSNorm 相比 LayerNorm 的优势：\n不需要计算均值，只计算均方根 计算量减少约 50% 效果几乎一样 5. SwiGLU 激活函数 相比 ReLU 的优势：\n更平滑的梯度 门控机制增强表达能力 现代大模型的标准配置 内存管理策略 Weights（权重）：使用 mmap c *data = mmap(NULL, *file_size, PROT_READ, MAP_PRIVATE, *fd, 0);优势：\n零拷贝：权重数据不需要从内核缓冲区拷贝到用户缓冲区 延迟加载：操作系统仅在实际访问某页权重时才将其载入物理内存 节省内存：多个进程可以共享同一份权重文件 RunState（激活值）：使用 malloc c s-\u0026gt;x = calloc(p-\u0026gt;dim, sizeof(float));原因：\n激活值需要频繁读写 每个进程都有自己的激活值 使用堆内存更灵活 模型文件结构 text [Config 结构体] [Token Embedding Table] [Layer 0 RMS Attention Weight] [Layer 1 RMS Attention Weight] ... [Layer 0 WQ] [Layer 1 WQ] ... [Layer 0 WK] [Layer 1 WK] ... [Layer 0 WV] [Layer 1 WV] ... [Layer 0 WO] [Layer 1 WO] ... [Layer 0 RMS FFN Weight] [Layer 1 RMS FFN Weight] ... [Layer 0 W1] [Layer 1 W1] ... [Layer 0 W2] [Layer 1 W2] ... [Layer 0 W3] [Layer 1 W3] ... [Final RMS Weight] [RoPE Freq (跳过)] [Classifier Weights (可选)]理论到代码的映射 理论概念 代码实现 数据结构 Token Embedding token_embedding_table float[vocab_size][dim] Query/Key/Value wq, wk, wv float[n_layers][dim][dim] Multi-Head Attention for (h = 0; h \u0026lt; n_heads; h++) 循环并行 Attention Score score = dot(q, k) / sqrt(head_size) float[n_heads][seq_len] Softmax softmax(att, pos + 1) 原地修改 Weighted Sum xb += att[t] * v[t] 累加 FFN w1, w2, w3 + SwiGLU float[n_layers][hidden_dim][dim] Residual x[i] += xb[i] 逐元素相加 LayerNorm rmsnorm(xb, x, weight, dim) 归一化 + 缩放 RoPE 旋转矩阵应用 原地修改 Q 和 K KV Cache key_cache, value_cache float[n_layers][seq_len][kv_dim] Logits matmul(logits, x, wcls, dim, vocab_size) float[vocab_size] Sampling sample(sampler, logits) 返回 token ID ","permalink":"https://www.caulif.com/posts/transformer%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/","summary":"基于 llama2.c 的 run.c 源码注释，梳理 Decoder-only Transformer 的推理流程、RoPE、Attention、FFN、采样和 BPE。","title":"Transformer 学习记录"},{"content":"很久之前就想弄博客，今天先把这里重新整理起来。\n之后会继续记录技术学习、AI 工具折腾和一些生活碎片。写得不一定很快，但希望每一篇都能留下当时真实的问题、理解和变化。\n","permalink":"https://www.caulif.com/posts/first-post/","summary":"很久之前就想弄博客，现在把这里重新整理起来，继续记录技术和生活。","title":"博客重新开始"}]