模型架构:把计算花在哪里,把过去存在哪里
假设我们要让一个模型读完工坊一整年的记录。它需要记住某次订单的编号,也要理解反复出现的工作流程。若每来一个词元,都让所有参数参与计算,再重新查看此前所有位置,代价会不断增加。
研究者于是追问两个不同的问题:一次处理能否只动用部分参数?读过的内容能否压进一份较小的状态?混合专家、稀疏注意力、线性注意力和状态空间模型,就从这些问题走出不同路线。
本专题承接注意力机制、Transformer与推理加速。需要用到矩阵乘法、加权平均和递推;暂时忘了可以回看第一篇。资料核查截至 2026-10-20,包含 2026 年 8 月的一份架构报告。本页运行的是可手算的机制实验,没有训练或测量这些大型语言模型。
1. 先认清三笔不同的开销
在一个常见的 Transformer 层中,注意力让不同位置交换信息,前馈网络则对每个位置做进一步变换。生成时还要保存或恢复过去的信息。它们的成本不在同一个地方。
| 想改变的部分 | 一个常见办法 | 仍要追问的代价 |
|---|---|---|
| 每个词元使用多少前馈参数 | 混合专家,只选择部分专家 | 路由、负载、通信和全部权重存放在哪里 |
| 当前查询查看哪些位置 | 稀疏注意力,只访问部分位置 | 选位置本身的成本,以及漏掉重要信息的可能 |
| 怎样保存此前信息 | 线性注意力或状态空间递推 | 状态容量、更新规则和精确检索能力 |
| 怎样兼顾不同需求 | 在不同层组合多种机制 | 层的比例、接口、训练稳定性和整体成本 |
这里的“稀疏”有两个对象。混合专家在参数模块中做选择;稀疏注意力在序列位置之间做选择。一个模型可以同时采用两者,也可以只采用其中一种。
2. 混合专家:有很多参数,每次只叫来几组
把普通前馈网络想成一组可学习的矩阵变换。混合专家(Mixture of Experts,MoE) 准备多组这样的变换,再由路由器为当前输入选择其中几组。“专家”是模型组件的名称,并不表示它们天然就是数学老师、程序员或某种固定职业。
设输入向量是 ,路由分数为 。共有 个专家,选择分数最高的 个,组成集合 。本页采用一种简单约定:只在选中的分数上做 softmax,再把专家输出相加:
是非负权重,选中项之和为 1; 是第 个专家对这个输入的输出。未选中的专家不需要执行。不同论文会使用不同评分、归一化和共享专家设计,这个公式是我们马上要运行的教学版本。
总参数和本次参与计算的参数,要分开数
取输入宽度 2、隐藏宽度 3。每个专家由两个无偏置矩阵组成,分别有 和 个参数,共 12 个。四个专家共 48 个;路由矩阵形状为 ,另有 8 个。
因此,这个小层总共保存 56 个参数。若每次选两个专家,一个词元会使用 个参数。六个词元一共执行 12 次专家计算;把四个专家全算一遍则是 24 次。
这还不是“整个模型快了一倍”。路由、调度和输出汇合也要时间;在多台设备上,还可能需要把词元发往持有对应专家的设备。批量中不同词元也可能合起来用到所有专家,因此不能据单词元的激活参数量决定整批权重的存储需求。
只选两组,仍可能让两组挤满、两组闲着
我们的六个输入故意比较相似,路由器也使用固定的教学参数。结果六个词元全选了专家 0 和 1,请求次数是 [6, 6, 0, 0]。如果每个专家最多接收三次分配,再按词元顺序处理,后面三个词元的两次分配都会被丢掉。
图 专题 1-1:限制容量可以限制工作量,却不会自动把路由分配好。虚线表示每个专家接收三次分配的上限。
这个人为反例采用“溢出就丢弃、权重不重新归一化”的规则,也没有残差支路,因此被完全丢弃的词元得到零输出。真实系统可以选择不同策略,例如改善负载分配或保留全部词元。先读清处理规则,再比较吞吐与质量,才不会把少算了任务误认成无代价加速。
一个值得精读的实例是 DeepSeek-V3 技术报告 v1,第 2.1.2 节。它区分共享专家与路由专家,并用额外偏置影响专家选择;输出混合权重仍来自原始亲和分数。报告还保留了较小的序列级辅助平衡损失。因此,“无辅助损失的负载平衡策略”不能读成“整个训练没有任何辅助损失”。本页没有实现这套完整路由,也没有复现报告的训练结果。
3. 线性注意力:先汇总,再让查询来读
普通 softmax 注意力对当前查询计算各个历史位置的分数,再归一化。线性注意力的一条路线,是把相似度换成可分解的点积,从而改变计算顺序。这条思路见 Katharopoulos 等,ICML 2020。
为了看清运算,下面让查询 和键 已经是非负特征向量,值为 ;分母需要大于零。到第 步时,输出是:
这仍然是一个加权平均,但权重是点积,而不是点积经过指数函数后的 softmax 权重。这里换了注意力函数,不能说只是对普通 softmax 做了完全等价的加速。
利用乘法分配律,可以先把历史项累起来。取 、,维护矩阵 与向量 :
初值都为零。外积 把“这个键对应这个值”的关联加进状态。计算时先写入当前项再读取,所以这里的因果范围包含当前位置。
对于固定的特征维度,每来一个词元,只需更新这份固定大小的状态。长度增长时,不必新增一整条历史键和值。但矩阵状态也可能很大:它有 个数,另加 个归一化量。是否划算,要连同维度一起算。
本页的两维键、标量值例子只需保存 4 个状态数。保存全部键和值则在长度 32 时需要 96 个数,长度 128 时需要 384 个数。这只是单头历史状态的元素计数,没有算权重、训练激活、临时缓冲或 Python 对象,更不是实际进程内存测量。
代码同时执行“逐项计算全部历史权重”和“递推维护状态”,二者在浮点容差内相同。把相同点积分数换成 softmax 后,输出却不同。这个对照可以帮我们守住两条界线:同一数学函数的两种实现,与两种不同数学函数。
4. 压进状态之后,旧信息怎样被保留和改写
假设有两个名字 A、B,键分别是 和 。依次写入:
A 对应 10
B 对应 20
A 现在对应 30
归一化累加会把两个 A 的值平均,最后读取 A 得到 20,读取 B 也得到 20。如果任务需要所有历史观测的平均,这样做有道理;如果任务问“现在 A 对应多少”,我们更希望读到 30。
状态空间模型:先从一条递推看起
最简单的离散状态更新可以写成:
是内部状态, 是新输入, 是读出的结果;三个矩阵分别决定旧状态怎样变化、新输入怎样写入、状态怎样被读取。状态空间模型(State Space Model,SSM)的具体设计还要说明这些矩阵如何参数化、是否随输入改变,以及怎样高效计算。
先缩成只有一个数的例子:。第一次写入 1,以后只输入 0。再过 50 步,留下的是 。本页实际递推得到: 时约为 0.000014, 时约为 0.605006。较慢衰减能保留更久,也会让不再需要的信息消失得更慢。
这条标量递推帮助理解状态与时间尺度;它没有实现 Mamba 的完整模块。Mamba-2 的 SSD 论文研究特定状态空间计算与结构化矩阵之间的联系,并据此组织高效算法。标题中的“Transformers are SSMs”也不意味着任意 softmax Transformer 都能原样压成这一个标量。
delta 更新:先读旧值,再写入差额
Gated Delta Networks v1 的公式 8把衰减与有针对性的更新结合起来。这里使用与前面一致的状态方向;原文的状态矩阵是它的转置。把一次更新拆开看:
控制整体衰减, 控制写入强度; 是新值与当前读出值的差。这个读出没有上一节的归一化分母。完整网络还包含投影、归一化、局部卷积与门,本页只计算这条核心递推。
先取理想边界 ,并使用单位正交键。写入 A=30 时,先读出旧 A=10,差额是 20,再把这 20 加进 A 的方向。因此 A 变成 30,B 仍是 20。
如果末步先乘 0.5,旧状态中的 A 和 B 都会减半。随后更新 A,可以让它回到 30,但 B 留在了 10。门没有免费判断“B 还重要”,它只执行我们指定的衰减规则。
图 专题 1-2:上图使用人工键值与门;下图是独立的标量递推。它们展示更新规则的后果,没有经过语言训练。
再把第二个键改成与 A 不正交的 ,先写 A=10,再给新键写 30。程序再次查询 A,得到约 26.213203。因为两条键共享方向,新写入影响了旧关联。不能从正交键上的漂亮结果推出“固定状态能精确保留任意多条记录”。
5. 怎样把这些机制放回一篇论文
阅读架构论文时,先把三个层次分开:公式规定怎样更新;算子规定怎样把同一计算交给硬件;整套训练决定模型最后学会什么。我们的算例主要检查第一层。
以 Gated Delta Networks 为例,可以沿“公式 8 → 模块图 → 实验设置 → 结果表 → 吞吐图”阅读。v1 第 4 节的 1.3B 对照使用 FineWeb-Edu 的 100B 词元、LLaMA 2 词元化器与 4K 训练长度;混合模型还包含滑动窗口注意力。它提供了特定设置下的比较,不能把结果完全归因于刚才那条孤立的递推公式。作者的高速分块算法也不是本页的 Python 循环。实验设置
混合架构值得关注,因为它允许一部分层压缩历史,另一部分层保留更直接的访问。Mamba-2 论文的 350M、48 层、7B 训练词元消融中,少量注意力层改善了困惑度。但这仍是指定规模和训练条件下的观察;不能把某个比例变成以后所有模型的标准答案。第 9.2.3 节
2026 年的一次继续追踪
Qiu 等的 On the Design of Qwen3.8-Next Architecture: Evaluation, Efficiency, and Training Stability,v1 发布于 2026-08-31,继续把 Gated DeltaNet 与注意力结合,并在后续训练中引入按微块选择上下文的 QSA。报告原文
读它时可以先看第 2.1.1 节的对照条件:三个 28 层、25B-A3B 检查点,先用 4K 上下文训练 400B 词元,再用 32K 训练 80B 词元。表 1 中 GDN 混合方案的九项平均较高,但 EvalPlus 为 49.71,低于全注意力的 51.01。这是作者报告的结果,本书没有重跑。它提醒我们同时看逐项能力、训练条件与成本,不能只摘“平均更高”。对照与表 1
报告中的算子加速与完整模型收益也应分别读。某个核在长序列上更快,并不直接决定短请求、并发服务或整轮训练的加速比例。若把固定状态层与仍保存历史的注意力层混合,整个模型的历史存储也不能笼统称作常数。具体成本需要把每类层分别相加。
6. 亲手运行这些差别
完整脚本使用本书现有的 NumPy 环境,无需 GPU、外部数据或预训练权重:
python src/examples/architecture_lab.py
固定种子 7 只用于生成教学权重和小数组;没有用它选择最好结果。实际输出为:
专家请求数:[6, 6, 0, 0];实际调用 12 次,全算需 24 次。
总参数 56;每个词元使用 32 个;稀疏与遮罩路径一致:True
容量为 3 时接收:[3, 3, 0, 0];丢弃 6 次分配。
线性注意力两种计算一致:True;与 softmax 对照最大差:0.041269
读取 A/B:平均 [20.0, 20.0];delta [30.0, 20.0];末步衰减 [30.0, 10.0]
非正交键更新后再读 A:26.213203(原值 10)。
50 步后旧脉冲:0.8 门 0.000014;0.99 门 0.605006
范围:固定输入与未训练参数的机制检查,没有测量语言能力或端到端速度。
“稀疏与遮罩路径一致”比较的是:只执行被选专家,与全算后用相同路由权重筛选,是否得到同样输出。它没有声称选两个专家与把四个专家都混合得到相同结果。
线性注意力的 softmax 对照直接使用相同点积、没有额外除以维度平方根,目的是只改变归一化函数。不能把这行数值当作两个训练完成的网络之间的质量差。
可以下载全部输入、参数与计算记录,也可以保存一次新运行:
python src/examples/architecture_lab.py --out runs/my-architecture-lab.json
输出文件必须尚不存在。记录保留各词元的专家选择、混合权重、路由输出和容量限制后的输出、线性注意力的完整输入输出、记忆读数和脉冲轨迹,以及环境和代码指纹。
展开完整代码
"""前沿架构:手算规模的专家路由、线性注意力与记忆更新;不是语言模型训练。"""
from argparse import ArgumentParser
import hashlib
import json
from pathlib import Path
import platform
import numpy as np
def softmax(values):
weights = np.exp(values - values.max(axis=-1, keepdims=True))
return weights / weights.sum(axis=-1, keepdims=True)
def expert(x, first, second):
return np.maximum(x @ first, 0) @ second
def routed(x, router, first, second, top_k=2, capacity=None):
"""按词元顺序分配;超过容量就丢弃该次分配,不重新归一化。"""
n_experts = len(first)
if not 1 <= top_k <= n_experts:
raise ValueError("top_k 必须位于专家数量范围内")
if capacity is not None and capacity < 0:
raise ValueError("容量不能为负")
ids = np.argsort(-(x @ router), axis=-1, kind="stable")[:, :top_k]
scores = np.take_along_axis(x @ router, ids, axis=1)
weights = softmax(scores)
output = np.zeros_like(x)
accepted = np.zeros(n_experts, dtype=int)
requested = np.bincount(ids.ravel(), minlength=n_experts)
dropped = 0
for token in range(len(x)):
for slot, index in enumerate(ids[token]):
if capacity is not None and accepted[index] >= capacity:
dropped += 1
continue
output[token] += weights[token, slot] * expert(x[token], first[index], second[index])
accepted[index] += 1
return output, ids, weights, requested, accepted, dropped
def linear_recurrent(q, k, v):
"""q/k 已是非负特征;写入当前位置后读取,包含当前位置的因果注意力。"""
state = np.zeros((k.shape[1], v.shape[1]))
normalizer = np.zeros(k.shape[1])
outputs = []
for query, key, value in zip(q, k, v):
state += np.outer(key, value)
normalizer += key
denominator = query @ normalizer
if denominator <= 0:
raise ValueError("当前查询没有正的匹配权重")
outputs.append(query @ state / denominator)
return np.array(outputs), state, normalizer
def linear_explicit(q, k, v):
similarity = np.tril(q @ k.T)
return similarity @ v / similarity.sum(axis=1, keepdims=True)
def delta_memory(k, v, decay, write):
"""S 的形状是 d_k × d_v;门值为手工给定,没有训练投影或门。"""
state = np.zeros((k.shape[1], v.shape[1]))
history = []
for key, value, alpha, beta in zip(k, v, decay, write):
state = alpha * state
error = value - key @ state
state = state + beta * np.outer(key, error)
history.append(state.copy())
return np.array(history)
def scalar_state(inputs, decay):
state, history = 0., []
for value in inputs:
state = decay * state + value
history.append(state)
return history
def run():
rng = np.random.default_rng(7)
x = np.array([[1 + i / 10, i / 10] for i in range(6)])
router = np.array([[2., 1., -1., -2.], [0., .2, .1, 0.]])
first = rng.normal(size=(4, 2, 3))
second = rng.normal(size=(4, 3, 2))
output, ids, weights, requested, accepted, dropped = routed(x, router, first, second)
# 全算后遮罩仅作独立执行路径对照,不作为“稀疏省计算”的实现。
all_outputs = np.stack([expert(x, a, b) for a, b in zip(first, second)], axis=1)
mask = np.zeros((len(x), len(first)))
np.put_along_axis(mask, ids, weights, axis=1)
reference = np.einsum("ne,ned->nd", mask, all_outputs)
limited, _, _, _, limited_load, limited_drops = routed(
x, router, first, second, capacity=3)
q = rng.uniform(.1, 1., size=(7, 3))
k = rng.uniform(.1, 1., size=(7, 3))
v = rng.normal(size=(7, 2))
recurrent, _, _ = linear_recurrent(q, k, v)
explicit = linear_explicit(q, k, v)
scores = q @ k.T
scores[np.triu_indices(len(q), 1)] = -np.inf
conventional = softmax(scores) @ v # 单独对照 exp(dot),未加缩放。
keys = np.array([[1., 0.], [0., 1.], [1., 0.]])
values = np.array([[10.], [20.], [30.]])
_, state, normalizer = linear_recurrent(keys, keys, values)
queries = np.eye(2)
averages = (queries @ state) / (queries @ normalizer)[:, None]
delta = delta_memory(keys, values, [1., 1., 1.], [1., 1., 1.])
decayed = delta_memory(keys, values, [1., 1., .5], [1., 1., 1.])
mixed_keys = np.array([[1., 0.], [2 ** -.5, 2 ** -.5]])
collision = delta_memory(mixed_keys, np.array([[10.], [30.]]), [1., 1.], [1., 1.])
return {
"kind": "untrained_synthetic_mechanism_checks",
"seed": 7,
"moe": {
"inputs": x.tolist(), "router": router.tolist(),
"first": first.tolist(), "second": second.tolist(),
"ids": ids.tolist(), "weights": weights.tolist(), "output": output.tolist(),
"requested": requested.tolist(), "accepted": accepted.tolist(),
"calls": int(accepted.sum()), "dense_calls": int(len(x) * len(first)),
"total_parameters": int(first.size + second.size + router.size),
"parameters_used_per_token": int(2 * first[0].size + 2 * second[0].size + router.size),
"reference_max_error": float(np.max(np.abs(output - reference))),
"capacity": 3, "limited_load": limited_load.tolist(),
"dropped_assignments": limited_drops, "limited_output": limited.tolist(),
},
"linear": {
"q": q.tolist(), "k": k.tolist(), "v": v.tolist(),
"recurrent": recurrent.tolist(), "explicit": explicit.tolist(),
"max_error": float(np.max(np.abs(recurrent - explicit))),
"softmax_max_difference": float(np.max(np.abs(recurrent - conventional))),
},
"memory": {
"keys": keys.tolist(), "values": values.tolist(),
"average_read": averages.ravel().tolist(),
"delta_read": (queries @ delta[-1]).ravel().tolist(),
"decayed_read": (queries @ decayed[-1]).ravel().tolist(),
"collision_read": float(collision[-1, 0, 0]),
"impulse_traces": {str(a): scalar_state([1.] + [0.] * 50, a) for a in [.8, .99]},
"cache_counts": [{"tokens": n, "kv_scalars": n * 3,
"normalized_linear_scalars": int(state.size + normalizer.size)}
for n in [32, 128]],
},
}
def main():
parser = ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, help="将完整机制算例保存为新 JSON 文件")
args = parser.parse_args()
report = run()
report["environment"] = {"python": platform.python_version(), "numpy": np.__version__,
"code_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
with args.out.open("x", encoding="utf-8") as file:
json.dump(report, file, ensure_ascii=False, indent=2, allow_nan=False)
file.write("\n")
moe, linear, memory = report["moe"], report["linear"], report["memory"]
print(f'专家请求数:{moe["requested"]};实际调用 {moe["calls"]} 次,全算需 {moe["dense_calls"]} 次。')
print(f'总参数 {moe["total_parameters"]};每个词元使用 {moe["parameters_used_per_token"]} 个;稀疏与遮罩路径一致:{moe["reference_max_error"] < 1e-12}')
print(f'容量为 3 时接收:{moe["limited_load"]};丢弃 {moe["dropped_assignments"]} 次分配。')
print(f'线性注意力两种计算一致:{linear["max_error"] < 1e-12};与 softmax 对照最大差:{linear["softmax_max_difference"]:.6f}')
print(f'读取 A/B:平均 {memory["average_read"]};delta {memory["delta_read"]};末步衰减 {memory["decayed_read"]}')
print(f'非正交键更新后再读 A:{memory["collision_read"]:.6f}(原值 10)。')
print(f'50 步后旧脉冲:0.8 门 {memory["impulse_traces"]["0.8"][-1]:.6f};0.99 门 {memory["impulse_traces"]["0.99"][-1]:.6f}')
print("范围:固定输入与未训练参数的机制检查,没有测量语言能力或端到端速度。")
if __name__ == "__main__":
main()
7. 接下来值得比较什么
要继续研究,可以围绕一个问题建立对照,而不是把所有新组件一起换上。下面的实验尚未执行:
- 路由与分布变化:先在一类输入上训练路由器,再改变输入分布,记录每个专家的负载、丢弃率和任务质量。负载均匀不自动等于分工有用。
- 压缩与检索:固定状态大小,改变关联条数、键的相似程度、重复更新和干扰距离,观察错误发生在哪里。
- 混合比例与总预算:固定数据和评价,把权重、激活、历史状态、通信、训练与生成时间分别记账,再比较不同层组合。
如果关心算子速度,还需要相同设备、精度、形状、预热与同步条件。高效的整块矩阵计算可能胜过理论操作更少的许多小循环,原因可以回到硬件与分布式训练寻找。
目前仍值得追问:训练分布改变后,专家是否还能有效分工?固定状态在哪些查询上足够,哪些查询需要更直接的历史访问?混合架构的收益有多少来自结构,又有多少来自同时调整的优化器和训练配方?这些问题需要分开的证据,单个平均榜单分数不能全部回答。
自测与小实验
- 四个专家、每次选两个,是否意味着整台设备只需要保存一半专家权重?
- 本例为什么减少了专家调用次数,却没有报告“加速两倍”?
- 手算 A=10、B=20、A=30 后,归一化累加读取 A 的结果。
- 在正交单位键与无衰减条件下,把最后的写入强度改成 0.5,delta 更新后 A 是多少?
- 为什么末步衰减会影响未被当前查询选中的 B?
- 有些层维护固定状态、有些层保存全部 KV,整个模型的历史存储是否与长度无关?
- 把专家容量从 3 改成 6,比较完整输出;再改变路由分数,观察专家负载是否变化。
- 给非正交键的反例推导一个解析答案,并说明它能限制哪句过强的结论。
参考答案与提示
- 不能。不同词元可能选不同专家;权重仍需存在于某处,设备布局与搬运另有成本。
- 调用计数没有包含路由、调度、通信与硬件执行效率,也没有进行计时。
- A 的两次值为 10 与 30,匹配权重相同,平均为 20。
- 旧值为 10,差额为 20,写一半后得到 20;B 仍为 20。
- 衰减作用于整份旧状态。只在 A 方向写回差额,不会补回 B 丢失的部分。
- 不能。保存全部 KV 的层仍随长度增加;应按不同层的状态形式求和。
- 容量 6 足以接收当前全部请求,应恢复无容量限制时的输出。改变路由后要重新核对选择和负载,不能只看总次数。
- 新键先读到 ,差额为 。A 方向的新值为 ,约 26.213203。它否定了这个更新规则能对任意非正交键都无干扰改写的说法。
原始资料与更新入口
本次实际阅读范围是线性注意力的基本形式、Mamba-2 的状态与混合消融、Gated DeltaNet 的递推和实验设置、DeepSeek-V3 的路由部分,以及 Qwen3.8-Next 报告的架构与对照部分。这不是对所有架构论文的系统综述,也没有验证下面仓库在当前机器上的安装或 GPU 性能。
| 文献与本页所用版本 | 作者实现或相关代码入口 |
|---|---|
| Katharopoulos、Vyas、Pappas、Fleuret,Transformers are RNNs: Fast Autoregressive Transformers with Linear Attention,ICML 2020 会议版 | fast-transformers |
| Dao、Gu,Transformers are SSMs: Generalized Models and Efficient Algorithms Through Structured State Space Duality,2024-05-31 v1 | Mamba 作者仓库 |
| Yang、Kautz、Hatamizadeh,Gated Delta Networks: Improving Mamba2 with Delta Rule,2024-12-09 v1;后发表于 ICLR 2025 | GatedDeltaNet |
| DeepSeek-AI,DeepSeek-V3 Technical Report,2024-12-27 v1 | DeepSeek-V3 |
| Qiu 等,On the Design of Qwen3.8-Next Architecture: Evaluation, Efficiency, and Training Stability,2026-08-31 v1 | 报告链接的线性注意力算子库 FlashQLA,不是完整训练复现包 |
本专题首版日期为 2026-10-20。后续更新会优先检查新方法改变了哪一种选择或状态更新、比较条件是否一致,再决定是否需要修订这里的解释。读到更新的模型名称时,也可以先用这几个问题拆开它,而不必从头背一套新术语。