AB
AiBoss
チュートリアル

Looped Transformer 教程:权重共享、循环次数与推理成本怎么读

チュートリアル

Looped Transformer 教程:权重共享、循环次数与推理成本怎么读

Looped Transformer 把同一组层反复使用,让参数量与推理计算量分开设计。本教程梳理它的结构、循环次数的选择方式、KV cache 与显存的关系,以及读性能表时该核对哪些数字。

看一个语言模型的规格时,最先看到的往往是参数量:3B、8B、70B。这个数字直观,也确实能说明模型规模,但它回答不了另一个问题——模型在生成一个 token 之前,到底做了多少计算。Looped Transformer 正是把这两件事拆开的一类结构:它不新增层,而是让共享的层反复参与计算,于是参数量可以保持不变,而每个 token 的计算量随循环次数变化。本教程面向需要读懂这类模型规格、评估推理成本、或准备在自己的服务里配置循环次数的读者,讲清结构、参数含义、配置方式与读表方法。文中涉及的具体模型版本、可用配置与性能数字,均以各项目官网与模型卡当前信息为准。

准备工作

这篇教程不要求你训练模型,但要把后面的内容跑通、读懂,需要先具备几样东西。

  • 一个能加载模型的推理环境。循环次数通常在推理时指定,属于运行时参数,因此你需要能实际调用模型的服务或本地推理框架,而不是只看 API 文档。
  • 模型卡与配置文件。循环结构的层数、默认循环次数、KV cache 模式,一般写在模型卡和 config 里。读配置比读宣传语可靠。
  • 对 Transformer 基本结构的概念。需要知道什么是层、attention、KV cache、prefill 与 decode 的区别。不要求会手写实现。
  • 一个测量脚本或至少一套测量口径。后面会反复强调:参数量、块应用次数、FLOPs 是三个不同的数字,要分别测、分别记。

另外要提前建立一个心理预期:循环不是「加个 for 循环就变聪明」。能利用反复计算的模型,是在训练阶段就按这个结构训练出来的,推理时改循环次数只是在这个前提下调整计算预算。

操作步骤

第一步:理解共享的是什么

普通 Transformer 每一层有各自的权重,输入表示逐层被不同的函数变换。想加深,基本就是再加一层。Looped Transformer 则让一部分层、甚至整个堆叠共享同一组权重,反复使用。文献里也会用 recurrent depth、depth-wise weight sharing 这类说法。

把两种结构并排看:一种是用四种不同的块各跑一次;另一种是用同样的两种块各跑两次。两者都是「块被应用了四次」,但需要单独保存的权重数量不同。这就是共享带来的直接差别。

有人会问:同样的处理重复做,结果不就应该一样吗?不会。第二次进入共享块的,不是原始输入,而是第一次更新后的表示。函数相同,输入变了,输出自然不同;每一轮的 attention 也是基于当时那一版表示重新计算的。

概念上,结构可以写成这样(只是示意,不是任何真实模型的实现):

h = embed(tokens)
for _ in range(num_passes):
    h = shared_transformer_stack(h)
logits = output_head(h)

关键在于:共享堆叠在循环外面只有一份。真实实现里还会涉及归一化、位置信息、attention mask、缓存管理,有些变体每一轮还会把初始表示一并传进去。

第二步:把三个数字分开记

这是整篇教程最容易被混淆的地方。有三个数字必须分开:

  • 参数量:需要单独保存的权重数量。
  • 块应用次数:表示经过块的次数。
  • FLOPs:实际执行的运算量。

如果整个堆叠被反复使用,块应用次数大致等于「固有块数 × 路径数」。例如某个公开模型是 22 层、2 遍,那么块计算就是 22 × 2 = 44 次,但它并不需要保存两套共享部分的权重。

但要注意:权重相同,不等于计算结果可以复用。每一轮都要重新算,计算量是实打实增加的。KV cache 保存的是过去 token 的键和值,用来减少生成时的重复计算,它和权重共享是两回事。

把「重复一次」与「只跑一遍同样宽度、同样堆叠」相比,大致有这样的关系:

项目重复之后的变化
共享部分的参数量不随路径数成比例增加
块的运算量随应用次数增加
训练时的中间值内存可能增加,也取决于重计算等实现方式
KV cache若按路径分别保存则会增加,要共享需要额外设计
响应时间不能简单推断,受实现、硬件、输出长度影响

参数效率、计算效率、内存效率是三件不同的事。把它们混为一谈,就会把「小模型表现不错」误读成「又便宜又快又好」。

第三步:确定循环次数由谁决定

引入循环之后,紧接着要决定的是:所有输入都跑同样的次数,还是允许中途退出。公开的做法大致分三类。

固定次数。最直接。某个公开的小模型在配置里明确写了 22 层、2 遍,技术报告里也说明,在实验范围内 2 遍是比较好的折中:继续增加带来的改善很小,训练变慢,优化也不稳定。也就是说,并不是转得越多越划算。这个模型还经过了大规模预训练,之后又做了监督微调与强化学习,所以把最终性能全部归功于「循环」这一个设计是不严谨的。

运行时由调用方指定。另一类结构在输入块与输出块之间放一个共享权重的递归块,每一轮把输入表示也传进去。模型卡的示例里用 n_recurrent_loops 指定次数,推荐值是 2;想压低每 token 的计算就用 1,更看重精度可以用 4。同一个检查点就能切换,不需要重新训练。这是由执行方选择,而不是让模型自己判断何时停。

由模型学习何时停。还有一类做法在每一遍之后放一个终止门,学习在哪个深度输出;累计终止概率达到阈值就退出。某个公开的 Thinking 版本配置是 48 层、4 遍,全部走完就是 48 × 4 = 192 次块应用。这里要特别区分论文与实现:论文里描述的是「到阈值就停」,公开代码里可能把循环全部跑完,读的时候不能混。

按 token 分配深度。还有研究让不同 token 走不同深度:要么由路由器在每一阶段挑选继续处理的 token,要么一开始就为每个 token 选定深度。注意这不是「这个词永远转三圈」——路由器看到的是包含上下文的表示,同一个词放在不同位置,判断会变。

做法计算如何分配阅读时的注意点
固定遍数所有输入相同增加次数不保证改善
运行时指定由调用方按场景选KV cache 复用是另一项设置
学习终止门模型自行决定深度论文中的停止条件与公开代码行为要分开看
按 token 分配不同 token 深度不同路由与 KV 管理都成为设计对象

一旦允许中途停止,就会出现一个新问题:先停下的 token,后面 token 要引用的 KV 该怎么保存?循环次数变成可变的,需要管理的东西也随之变多。

第四步:配置 KV cache 模式

权重共享不会自动带来 KV cache 共享。这一点在实际部署时最容易踩坑。

有公开报告提到,在 KV 共享与非共享之间比较后,非共享在性能上更有利,因此采用了非共享。也有模型在论文里做过生成时缓存复用的实验,但那是额外设计,不是权重共享的必然结果。

在支持运行时指定循环次数的模型上,缓存策略通常作为配置项暴露出来。例如模型卡示例默认按路径分别保存 KV(per_loop);如果把 recurrent_kv_cache_mode 设为 last_loop,每个递归 attention 层会用当前路径的 KV 覆盖上一路径的,只保留最后一遍的那份。按模型卡的说明,这个模式可以把递归部分的 KV cache 控制在单遍的规模。

但请记住:这不等于总内存或总运算量与循环次数无关。权重、输入处理、输出阶段的占用都还在,循环本身的计算也还在。

第五步:按正确的口径测量

配置好之后,测量要覆盖四类指标,缺一类就可能得出错误结论。

  1. 参数量:区分嵌入部分与非嵌入部分,很多模型名里的数字指的是非嵌入部分。
  2. 实际块应用次数:固有层数 × 路径数;如果支持可变深度,还要记录实际执行条件。
  3. 内存分布:把权重与 KV cache 分开记,输入处理阶段与生成阶段分别记。
  4. 真实时间:首 token 延迟、生成速度、以及完成一个任务的总时间。

第四项尤其容易被忽略。同样是一个 token,它之前内部算了多少遍是不同的。有模型卡报告,增加循环次数后输出 token 数会减少。所以只看 tokens/s 不够,还要看「拿到所需精度的答案要花多久、多少钱」。

一个完整示例

下面用一个最小流程,把上面的概念串起来。示例只演示配置与测量口径,不绑定任何具体框架的 API;实际调用方式以你所使用项目的文档为准。

场景

假设你手上有一个支持运行时指定循环次数的检查点,需要在「省算力」和「要精度」两种需求之间切换,并确认切换后的真实成本。

1. 读取配置,确认结构

先看模型卡与 config,确认三件事:固有层数是多少、默认循环次数是多少、KV cache 默认模式是什么。把这三个值记下来,后面所有计算都基于它们。

固有层数: 22
默认循环次数: 2
块应用次数: 22 × 2 = 44
KV cache 模式: per_loop(按路径分别保存)

2. 用默认次数跑一次基线

用推荐值跑一遍,记录输出质量与耗时。这一步的目的是建立参照,不要一上来就调参。

n_recurrent_loops = 2
recurrent_kv_cache_mode = "per_loop"

3. 降低次数,观察计算量变化

把循环次数改成 1,块应用次数降到 22。此时每 token 的计算量下降,但精度可能变化。记录同一批输入上的结果差异。

n_recurrent_loops = 1

4. 提高次数,观察收益是否递减

再改成 4,块应用次数升到 88。注意观察两件事:质量是否真的提升,以及输出 token 数是否变化。公开的评估数据里就出现过这种情况——某类任务随次数提升,另一类任务从 2 次到 4 次反而略有下降。所以不要假设所有指标都单调上升。

n_recurrent_loops = 4

5. 切换缓存模式,比较显存

在循环次数为 4 的情况下,把缓存模式从按路径保存改为只保留最后一遍,再测一次显存占用。

n_recurrent_loops = 4
recurrent_kv_cache_mode = "last_loop"

预期现象是递归部分的 KV cache 回落到单遍规模,但总占用不会回到循环 1 次的水平,因为权重和循环计算都还在。

6. 汇总成一张表

把四组配置的结果放在一起,形成决策依据:

循环次数块应用次数KV 模式关注指标
122per_loop最低计算量下的质量下限
244per_loop基线质量与耗时
488per_loop质量增益与显存增长
488last_loop显存回落幅度与质量是否受影响

这张表就是你后续做容量规划与 SLA 设定的依据。具体数值请以你自己环境中的实测为准,不同硬件与实现差别很大。

注意事项

不要把它和 RNN、CoT、持续学习混为一谈

传统 RNN 在从上一个 token 到下一个 token 之间传递状态,重复使用同一组权重;这里说的循环是在深度方向上更新表示。两者重复的轴不同:一个沿序列位置,一个沿计算深度。位置之间的信息交换方式也不同,前者靠递归状态传播,后者靠 attention。

不过,Looped Transformer 在自回归生成时仍然是一个 token 一个 token 地出,并不是「因为不像 RNN 就能一次生成整段」。

与 Chain of Thought 的关系也不是二选一。CoT 把中间步骤作为文本生成出来,因此增加了到最终答案的计算;Looped Transformer 则在输出文本之前反复更新表示来增加计算。两者可以叠加:内部先算几轮,再生成 CoT。已有模型提供 Thinking 版本就是这种组合。

至于持续学习:推理时循环多少遍,都不会更新参数。根据输入改变内部状态,和把新知识固化进权重,是两件事。KV cache 也不等于跨会话持续学习。有实验显示,循环带来的效果更多体现在「组合使用已存知识」的能力上,而不是大幅提升同等参数下的事实记忆容量。当然,这是特定实验条件下的结论。

「内部算得多」不等于「解释更可信」

一个自然的担心是:内部计算变多,输出的推理步骤会不会变短,人是不是就更看不清了。这里要把三件事分开:内部计算增加、输出推理步骤变短、以及这些步骤是否忠实反映真实判断依据。第一件不必然导致第二件,第二件也说明不了第三件。

输出变短可能来自训练方法、模型能力、生成设置等多种原因。而且,长的 CoT 本来也不保证完整复述了内部计算——关于 CoT 解释不忠实的研究早已存在,这不是循环结构独有的问题。所以「短就是藏」和「长就可信」都是过度推断,也不能从文本长度反推闭源模型的结构。

真正值得评估的是:可观测的步骤减少之后,监控与验证是否变得更难。这是一个合理的议题,但不要把它和「不透明」「不忠实」混成一个问题。

读性能表时要核对的五件事

这类技术不是靠提示词「想三遍」就能用上的,它属于模型结构与训练层面的事。使用方能立刻做的,是改变读性能表的方式。

  1. 对齐了什么:参数量、训练 token 数、FLOPs、评测设置是否可比。
  2. 实际算了多少次:固有层数、路径数、可变深度的执行条件。
  3. 内存花在哪:权重与 KV cache 分开看,输入处理与生成阶段分别看。
  4. 真实时间如何:首 token 延迟、生成速度、任务完成总时间。
  5. 论文与手头实现是否一致:终止门、缓存共享、对应的运行时与配置。

第三项尤其要注意:权重共享不代表 KV cache 共享,前面已经说明过。

关于「计算预算对齐」的比较

一个合理的质疑是:把同样大小的模型跑两遍,效果比跑一遍好,这可能只是「多花了计算」,说明不了结构本身更优。要判断结构好坏,需要看同等预算下是否仍然占优。

已有研究专门做了这类对齐:用 MoE 结构,尽量让每 token 训练 FLOPs、非嵌入总参数量、KV cache 量三者接近,再比较。做法不是简单地把同一模型跑两遍,而是让中间一半的层用两次、收窄宽度、调整专家数与 attention 配置。即便如此,预算也不是完全一致——论文给出的配置示例中,FLOPs 与参数量仍有小幅差异,KV cache 差异控制在较小范围内。所以看到「同条件」时,最好把内部分项也看一遍。

在这类设置下,研究估算了达到相同验证损失所需的训练 FLOPs 节省比例,并验证了参数量较大的配置。但这是 MoE、宽度、共享范围等组合出的配方结果,不能推广成「任何模型加循环都能按同样比例获益」。「有效」和「到处都有效」差别很大。

结构谱系不是替代关系

这类思路并非突然出现。2018 年公开、次年在会议上发表的 Universal Transformer 就已经在深度方向重复同一变换,并处理了按位置停止计算的问题。此后陆续出现把推理时计算量通过内部迭代放大的工作、按 token 分配深度的研究、大规模预训练并学习终止判断的模型、使用固定两遍的公开模型,以及把计算、参数、KV 预算对齐来比较的研究,再到支持运行时选择循环次数的模型。

这不是一张「新技术替换旧技术」的表,更像是同一想法朝不同成本结构与用途分出的枝杈。

还有一个具体例子可以说明「固有块数」与「块应用次数」的区别:某研究采用「前 2 块 + 重复 4 块 + 后 2 块」的结构,共享部分用 32 次,那么块应用次数是 2 + 4 × 32 + 2 = 132 次,而固有块只有 8 个。该模型在每一轮还会把初始表示传给共享部分,训练时也会改变循环次数——为了让推理时能增加次数,这些都要在训练阶段准备好。

部署时的现实提醒

想在本地跑小模型的人,会看重「权重可以保持很小」这一点。但如果不看长输入、多循环下的内存占用,就容易出现「本以为装得下」的情况。规划容量时,请把权重、KV cache、输入处理与生成阶段分开估算,并以你所使用版本的官方说明与实测为准。

最后回到那个核心判断:Looped Transformer 有意思的地方,在于把「持有多少权重」和「用这些权重做多少计算」变成两个可以分别设计的量。无论是运行时由调用方选次数,还是按 token 学习深度,计算如何分配本身都成了设计对象。但循环要花计算,增加不一定改善,论文里该停的地方在公开代码里也可能全部跑完。既对性能有期待,也承认实现里有各种具体取舍——两边都看,才是读这类模型的正确姿势。而无论对象是模型外部的智能体循环,还是模型内部的表示更新,有一点是共通的:次数多不等于更好,真正要比的是「什么变好了,为此多花了多少计算和内存」。