想象一下,你买回一大箱乐高积木,里面的零件散落一地——有数百个小齿轮、连杆和面板。如果没有图纸和分类盒,拼装一个机器人简直是灾难。Flax模块系统就是JAX世界里那套“分类盒+说明书”:它把散落的权重参数收进一个个标准积木块(Module)里,再用清晰的图纸(Module树)告诉你哪块拼哪块。这样,原本需要手动管理几百个数组的苦差事,就变成了搭积木一样直观的操作。 JAX本身是一台极其高效的“数学引擎”,但它太底层了——就像给你一堆钢材和螺丝,却不给你设计图。Flax在这台引擎上搭建了一套模型骨架:用类(class)来定义网络结构,用setup方法在真正需要时才分配参数,用函数式接口把“模型长什么样”与“参数存哪里”彻底分离。这套设计让JIT编译加速、模型保存加载都变得异常干净。本篇文章将用“乐高房子”这个比喻,带你从零看懂Flax模块系统的每一块积木。
掌握Flax模块系统,是你在JAX生态中搭建任何现代模型(从Transformer到扩散模型)的入场券。它是连接研究思路与高效训练之间的最短桥梁,也是面试与工程实战中绕不开的核心基本功。
目录
可视化演示
动态展示Flax在JAX上的模块骨架:dataclass封装与子模块树、setup延迟初始化、参数状态分离式设计及序列化与JIT编译集成。
本节配有交互动效(进入视口后自动加载,约 3KB)。
nn.Module类——标准积木件
在乐高世界里,所有零件都遵循统一的接口:底部有凸粒,顶部有凹槽。Flax的nn.Module就是这样一个通用接口。任何神经网络组件——一个全连接层、一个Transformer编码器、甚至整个GPT模型——都是这个类的子类。你只需要继承它,定义自己的积木形状,剩下的“接缝”(参数注册、子模块管理)Flax自动帮你打磨好。
与PyTorch的nn.Module不同,Flax的模块更“佛系”:它不急着在构造时就创建参数。子类只需声明“我要一个全连接层”,至于这个全连接层的权重具体是多少,等真正用到时(init被调用)再说。这种延迟满足的哲学,是Flax函数式设计的基石。
你可能会疑惑:“为什么不直接在__init__里创建参数?”因为JAX崇尚纯函数:同样的输入永远产生同样的输出,不依赖任何隐藏状态。如果把参数藏在对象内部,JIT编译器就无法安心优化——它需要看到所有变量被显式传递。Flax把“零件的形状定义”和“零件的实体制造”分开,正是为了迎合纯函数这一铁律。
让我们回到乐高:nn.Module是那块万能积木底板,Dense、LayerNorm、Attention都是底板上长出的不同形状的功能块。你随手拾起一块,翻过来看底部 —— 那排整齐的凹槽,就是Flax帮你打好的“参数注册接口”。
一个追求简单的小白可能会问:“我直接写函数不行吗?为什么非要套个类?”原因在于:类提供了命名空间和复用模板。就像乐高不只给你一块积木,而是给你一整套分类盒——每个盒子标签清晰,找零件时一目了然。在大型项目中,这种组织方式决定了代码能否持续演进。
- Module符号
\text{Module}表示一个继承自nn.Module的类实例,比如一个Dense层。 - 参数集合
\text{params}: \theta表示该模块直接持有的权重和偏置,它们在setup或@nn.compact中被创建。 - 子模块集合
\text{submodules}: \mathcal{M}表示模块内部嵌套的其它模块,它们以树形结构组织。
import flax.linen as nn class MLP(nn.Module): features: int = 10 @nn.compact def __call__(self, x): x = nn.Dense(self.features)(x) return nn.relu(x)
setup方法——延迟装配说明书
乐高套件里有一张折叠的说明书,你展开它才知道哪个零件该装在哪一步。Flax的setup方法就是这张说明书:它定义了模块需要哪些子模块和参数,但真正的“拼装动作”要等到init被调用时才执行。
在这段代码里,setup只是说“我要两个Dense层”,但没有分配具体数值。当调用init时,Flax会穿行每个模块,呼叫它们的setup方法,此刻权重才被随机初始化。这种机制叫延迟初始化(lazy initialization),好处是:模块定义与数值状态解耦,同一个类可以轻松复用于不同尺寸的输入。
用乐高的语言说:setup是“图纸阶段”,init是“动工阶段”。图纸可以复印一百万份,工地却需要一块砖一块砖地砌。JAX模型之所以能轻松并行训练,正是因为——图纸(模型结构)与工地(参数状态)彻底分离,传递参数就像传递图纸复印件,无任何隐藏副作用。
这里有个微妙点:setup和@nn.compact不能混用。前者适合显式声明式编程,后者适合快捷函数式。就像一本说明书可能有两种版式——详细图文版或速查表版,但你不能同时用两份。理解这一点,能避免很多初学阶段的报错。
init 是一台“3D打印机”:输入随机种子和样本数据,输出一份完整的参数蓝图。- 初始化函数
\text{init}是Flax为每个Module自动生成的方法,它负责触发setup或@nn.compact中的参数创建逻辑。 - 随机种子
\text{key}是JAX的随机数钥匙,决定权重初始值。 - 输入样本
\text{input}是一批样例数据,用于推断参数形状。
class MyModule(nn.Module): hidden: int def setup(self): self.dense1 = nn.Dense(self.hidden) self.dense2 = nn.Dense(1)
调用链:MyModule() → 占位 → model.init(key, x) → setup() → 参数创建 → 返回参数字典函数式设计——图纸与工地的分离
假设你开了一家乐高代工厂,客户给你一张图纸,你按图生产。Flax的函数式设计也一样:模型逻辑(apply)是图纸,参数状态是生产出的实体,两者之间通过显式传递来沟通,不存在“模块偷偷修改自己内部状态”的情况。
这种设计的威力在JIT编译时体现得淋漓尽致。jax.jit要求被编译的函数是纯函数——没有全局变量、没有隐藏状态。Flax将参数作为第一个参数传入apply,使得整个前向传播天然满足纯函数要求,直接可以被JIT编译。相比之下,传统面向对象框架中“模型即对象”的做法,在JAX的纯函数哲学下反而格格不入。
换回乐高语言:普通框架让你“拼好一个机器人,然后让机器人自己去思考”;Flax则是“机器人(模块)只提供动作说明书,思考所需的能量(参数)每次从外部注入”。后者更笨拙一点点,却换来极大的灵活性——你可以在同一块GPU上同时让十个机器人(模型副本)用不同能量(参数)运行。
函数式设计并非Flax首创,但它将这一理念贯彻得最彻底。对于从PyTorch迁移过来的开发者,最大的思维转变是:不要试图修改模型内部状态,而是“传入参数、返回新参数”。这恰恰是JAX生态高效并行、无缝编译的根基。
apply 是一个纯函数:输入参数与数据,输出预测结果。它不修改任何外部状态,因此可安全地被JIT编译或并行化。- 参数化函数
f_{\theta}表示由参数 θ 确定的确定性映射,这是神经网络前向传播的本质。 - 输入维度
\mathbb{R}^{B \times D}表示批大小为 B、特征维度为 D 的输入张量。 - 输出维度
\mathbb{R}^{B \times O}表示批大小 B、输出维度 O 的预测张量。
params = model.init(key, x) # 生产:根据图纸产出参数y = model.apply(params, x) # 加工:根据参数处理输入
Module树——嵌套的乐高城堡
一座宏伟的乐高城堡,绝不是一块超大积木,而是由无数中小积木嵌套拼接而成。Flax的Module同样支持无限嵌套:一个Transformer由若干个Encoder块组成,每个Encoder块又由Attention和MLP组成,Attention里再有QKV投影矩阵……这种嵌套形成了一棵树,即Module树。
这棵树的每个节点都是一个Module,每个叶子节点挂载着具体的参数张量。Flax将整棵树视作一个"PyTree"——JAX通用的嵌套数据结构——这意味着你可以用统一的工具遍历、修改、序列化整个模型的参数。这是Flax与JAX原生生态深度融合的关键:jax.tree_map可以一键对所有参数加噪声或缩放。
用乐高来类比:Module树是“城堡的整体结构图”,每个EncoderBlock是“塔楼标准件”,Attention是“塔楼里的齿轮组”。你修改任意一层,只需替换对应的积木件,其他层不受影响——这就是模块化设计的终极意义。而Flax通过nn.Module的嵌套,把这种替换从物理世界搬进了代码世界。
理解Module树是掌握Flax的钥匙。许多进阶操作——如参数裁剪、梯度裁剪、层冻结——本质上都是对这棵树的一次遍历与变换。把模型想象成一棵圣诞树:装饰品(参数)挂在树枝(Module)上,你随时可以摘掉或挂上新装饰,而不需要砍倒整棵树。
- 树结构
\text{Tree}(\text{Module})表示以模块为节点、参数为叶子的树。 - 参数叶子
\text{params}: \text{Tree}(\text{leaf})表示参数是树中的叶子节点,每个叶子是一个张量。 - 递归子模块
\text{submodules}: \text{Tree}(\text{Module})表示子模块递归地构成完整的树。
class Transformer(nn.Module): num_layers: int hidden: int def setup(self): self.blocks = [EncoderBlock(self.hidden) for _ in range(self.num_layers)] class EncoderBlock(nn.Module): hidden: int def setup(self): self.attn = nn.MultiHeadAttention(num_heads=8, qkv_features=self.hidden) self.mlp = MLP(self.hidden * 4)
JIT与编译——乐高自动组装机
乐高拼装最耗时的是手动找零件和按图纸拼接。JIT(Just-In-Time)编译就像一台自动组装机:你把图纸(函数)和零件盒(参数)交给它,它会整体扫描一遍,生成一套最优拼装流程,下次直接执行。
Flax模块的函数式设计让JIT优化畅通无阻。因为apply(params, x)是一个纯函数,你可以直接:
同时,JAX的grad可以与jit无缝组合。由于参数作为函数入参,梯度计算天然适用于函数式求导。Flax官方推荐的使用范式是:用nn.Module定义结构,用jax.grad计算梯度,用jax.jit加速整个训练步。
乐高自动组装机的另一个伟大之处在于:它能同时处理十套图纸而互不干扰。JIT编译后的函数同样可以并行作用于多个参数副本——这就是数据并行训练的基础。每一次apply调用,都相当于自动组装机对“图纸+零件”的一次高效加工。
初学者容易忽略一点:JIT追踪的是“函数逻辑”而非“具体数值”。这意味着Python层面的控制流(如if、for)在编译时会被固定展开。Flax的模块结构恰好是静态的(Module树在初始化后不再改变),天然适应JIT的要求。这又是函数式设计带来的隐性红利。
- 编译函数
\hat{f}_{\theta}表示经过JIT编译后的高效版本函数。 - JIT变换
\text{jit}是JAX的即时编译算子,将Python函数追踪为计算图并优化执行。 - 梯度算子
\nabla_{\theta}表示对参数 θ 求梯度,与JIT组合构成训练核心。
import jaximport jax.numpy as jnp @jax.jitdef forward(params, x): return model.apply(params, x) @jax.jitdef loss_fn(params, x, y): pred = model.apply(params, x) return jnp.mean((pred - y) ** 2)
序列化与加载——装箱与拆箱
当你拼好一座宏伟的乐高城堡,最怕什么?不小心碰倒。聪明的做法是:把城堡小心拆解、分类装回盒子,下次再按图纸拼回来。模型训练同样如此——训练了一周的模型,如果断电就全没了,那将是灾难。Flax的序列化机制就是那个“收纳盒”。
Flax将模型参数导出为结构化字典后,可以用多种格式存储:
to_bytes将内部参数字典转成高效二进制格式,from_bytes则将字节流还原为参数字典。Flax还支持msgpack格式——一种紧凑的二进制序列化协议,广泛用于跨语言数据交换。整个过程中,模型结构(Module定义)与参数状态(字典)保持分离:你只需保存字典,代码中的类定义即“图纸”。
回到乐高比喻:序列化就是“拆城堡装箱”,每一块积木(张量)都被分类标号(键值);反序列化是“开箱重建”,按图纸(Module结构)一块块放回原位。工程上这意味着:你可以随时暂停实验、保存现场(checkpoint),第二天甚至换一台机器继续训练。
需要特别注意的是,Flax的序列化保存的是参数而非模型结构。加载时你需要自己重新实例化模型类,再填入参数。这看似麻烦,实则蕴含巨大灵活性:你可以修改模型结构(比如加一层),然后只加载兼容部分的参数——这就是迁移学习的核心操作。乐高城堡可以拆掉一座塔,装上更新的塔,城堡主体依然稳稳矗立。
- 内存参数
\theta_{\text{memory}}表示训练中驻留在GPU/CPU内存中的模型参数。 - 磁盘参数
\theta_{\text{disk}}表示序列化后存储在磁盘上的参数副本。 - 互逆变换
\text{serialize}与\text{deserialize}互为逆操作,保证保存与加载无损。
from flax import serialization bytes_repr = serialization.to_bytes(params) # 参数 → 字节流params_restored = serialization.from_bytes(params, bytes_repr) # 字节流 → 参数
AI 应用场景
Flax在自然语言处理中的应用
阶段:模型构建与训练
解决的问题:传统NLP模型(如BERT)包含上百个子模块嵌套,手动管理每层的权重字典极易出错且难以复用。Flax的nn.Module树形结构让整个BERT的组装像搭积木一样清晰,函数式设计又天然支持jax.jit加速训练循环。
具体案例:HuggingFace团队在transformers库中为Flax实现了完整的BERT、GPT-2、T5等模型。研究者加载FlaxBertModel后,只需调用model.init创建参数,再配合jax.jit编译训练步,即可在TPU上训练大规模语言模型。整个模型定义就是一棵嵌套的Module树——Embedding层、Encoder层、Attention层逐级嵌套。
强化学习中的策略网络
阶段:策略网络定义与参数更新
解决的问题:强化学习中策略网络和价值网络需要频繁创建多个参数副本(如多个环境并行采样)。Flax的函数式参数管理让复制、分发参数变得异常轻量,只需复制字典即可。
具体案例:在基于JAX的强化学习库(如PureJAXRL、jaxrl)中,研究者用Flax定义策略网络(Actor)和Q网络(Critic),每次采样时将参数分发给各个环境进程,收集样本后用jax.grad计算梯度。由于参数是纯字典,可以在多台设备间自由传输,数据并行和异步更新天然成立。
计算机视觉模型库
阶段:模型设计、训练与部署
解决的问题:视觉模型(ResNet、ViT)的残差连接、BatchNorm等结构在面向对象框架中需要大量样板代码。Flax的nn.Module组合机制让“积木块”可以自由拼接,比如在ResNet块中随时插入新的注意力模块。
具体案例:FlaxVision等开源项目用Flax重新实现了ResNet、ViT、ConvNeXt等经典视觉架构。开发者在定义ResNetBlock时通过setup声明conv1、bn1等子模块,在__call__中组织前向逻辑。得益于函数式设计,这些模型可以无缝迁移到TPU上训练,并在JAX生态中直接使用jax.image等预处理工具。
Python 演示
import jaximport jax.numpy as jnpimport flax.linen as nnfrom flax import serialization class SimpleMLP(nn.Module): """一个简单的多层感知机,展示Flax模块系统的核心用法""" hidden: int = 64 num_classes: int = 10 def setup(self): # 在setup中定义子模块(延迟初始化) self.dense1 = nn.Dense(self.hidden) self.dense2 = nn.Dense(self.num_classes) def __call__(self, x): x = nn.relu(self.dense1(x)) x = self.dense2(x) return x def create_model(): return SimpleMLP(hidden=32, num_classes=3) def predict(params, x): return model.apply(params, x) if __name__ == "__main__": model = create_model() key = jax.random.PRNGKey(0) x = jnp.ones((4, 16)) # 1. 初始化:触发setup,创建参数 variables = model.init(key, x) print("参数结构:", variables["params"]["dense1"]["kernel"].shape) # 2. 前向传播 y = model.apply(variables, x) print("输出形状:", y.shape) # 3. 训练一步 def loss_fn(params, x, y_true): pred = model.apply({"params": params}, x) return jnp.mean((pred - y_true) ** 2) grad_fn = jax.jit(jax.grad(loss_fn)) y_true = jnp.zeros((4, 3)) grads = grad_fn(variables["params"], x, y_true) print("梯度层名:", list(grads.keys())) # 4. 序列化与恢复 bytes_data = serialization.to_bytes(variables) restored = serialization.from_bytes(variables, bytes_data) print("序列化还原成功:", restored["params"]["dense2"]["bias"].shape)
def setup(self):定义子模块的地方,此时参数尚未创建,只是“图纸”阶段。self.dense1 = nn.Dense(self.hidden)声明一个全连接层到模块树中,Flax自动注册。model.init(key, x)触发所有setup执行,根据输入形状推断参数维度并随机初始化。model.apply(variables, x)纯函数式前向传播,显式传入参数字典,符合JAX纯函数哲学。jax.jit(jax.grad(loss_fn))将损失函数自动微分后JIT编译,加速训练步。variables["params"]["dense1"]["kernel"]通过嵌套字典按模块名/参数名访问参数。serialization.to_bytes(variables)将整个参数字典序列化为字节流,用于保存。serialization.from_bytes(variables, bytes_data)从字节流还原参数字典,继续训练。
常见误区
误区我以为定义`nn.Module`子类时,`setup`方法会立即被调用,所以可以在`__init__`里访问`self.dense1`。结果总是报错"AttributeError: …… has no attribute 'dense1'"。
正解Flax的设计哲学是“延迟初始化”。setup要等到init被调用时才执行,因为此时才知道输入形状等信息。正如乐高图纸只有在你准备动工时才展开——模块构造时只有一份“待执行”的说明书,真正的参数创建在首次init时一次性完成。
为何常见:大多数深度学习框架(如PyTorch)都在__init__中立即创建参数,从那边迁移过来的开发者自然会沿用旧有思维模式。
误区我直接把PyTorch的`nn.Module`写法翻译成Flax,处处碰壁。以为`model(x)`就该自动调用前向传播,`model.parameters()`就该返回参数列表。
正解Flax模块是“函数式积木”,不是“状态化对象”。它不保存参数,参数以字典形式独立存在。前向传播必须显式写成model.apply(params, x)。“图纸”(模型逻辑)与“工地”(参数状态)永远分开。这是JAX纯函数哲学的必然产物,不是设计缺陷。
为何常见:面向对象编程深入人心,函数式思维需要刻意练习。初学者容易用“类即对象”的惯性去理解Flax,忽略了JAX生态的根本范式。
误区我认为保存了`params`字典就等于保存了整个模型,加载后直接调用即可,不需要再实例化模块。
正解Flax的params只包含参数张量,不包含模型结构。恢复推断时必须先构造对应的Module实例,再把参数“塞”回去。设想你只有一盒乐高零件,没有图纸,是无法知道怎么拼的。模块类定义就是图纸,二者缺一不可。
为何常见:在PyTorch中torch.save(model.state_dict())确实需要搭配模型类使用,但PyTorch可以直接保存整个模型对象。Flax彻底去掉了对象状态,让“保存模型”必须显式分为“保存结构代码”和“保存参数数据”两步。
误区我一边用`setup`定义子模块,一边在`__call__`里用`@nn.compact`快捷创建参数,结果报错"cannot use both setup and compact"。
正解Flax规定一个模块只能选择一种参数定义方式:要么用setup显式声明子模块,要么用@nn.compact在__call__中定义。二者就像乐高说明书的手绘图和爆炸图——只能选一种,否则图纸自相矛盾。@nn.compact适合简洁快速定义,setup适合需要复用子模块变量的场景。
为何常见:Flax文档中两种用法都大量出现,初学者容易混着用,不理解这是两条互斥设计路线。
误区我觉得`model.apply(params, x)`的写法比`model(x)`啰嗦多了,是多余的设计。
正解显式传递参数恰恰是Flax的最大优势。它让JIT编译和自动求导成为可能,让参数可以像普通数据一样被复制、分发、序列化。就像外卖配送需要明明白白的订单(参数),而不是口头喊一声“老三样”厨房就自动出菜(隐藏状态)——前者虽然多写几个字,但可追踪、可复制、可交接。
为何常见:人脑天然偏好简洁的语法糖,但工程系统的健壮性往往来自显式而非隐式。JAX生态选择了函数式路线的“显式美德”。
面试题
Q1. Flax的nn.Module与PyTorch的nn.Module最核心的区别是什么?
- A.Flax模块不保存参数状态← 正确答案
- B.PyTorch模块不支持GPU训练
- C.Flax模块不能嵌套使用
- D.PyTorch模块没有
__call__
答案:A。Flax模块将参数作为独立字典,由apply显式传入;PyTorch模块将参数作为对象内部状态。这源于JAX的纯函数设计哲学,也是“Flax模块是乐高图纸而非实体城堡”这一比喻的体现。
Q2. module.init(key, x)的作用是什么?
- A.编译模型计算图
- B.创建并初始化模型参数← 正确答案
- C.加载预训练权重
- D.计算模型参数量
答案:B。init触发setup方法执行,根据输入x推断参数形状并随机初始化,返回包含参数和批统计量的字典。这就像乐高说明书的“动工”环节,此前所有定义都只是图纸。
Q3. Flax中model.apply(params, x)的设计体现了什么编程范式?
- A.面向对象封装
- B.函数式编程← 正确答案
- C.逻辑式编程
- D.事件驱动编程
答案:B。apply是纯函数:参数显式传入、输出只依赖输入、无隐藏状态。这与乐高“图纸+零件→产物”的模式一致,也是JAX生态中JIT编译与自动并行能高效运作的前提。
Q4. 关于Flax的setup方法,下列说法正确的是?
- A.在模块构造时立即执行
- B.在每次前向传播时执行
- C.在
init被调用时执行一次← 正确答案 - D.在反向传播时执行
答案:C。setup延迟到init时执行,且仅执行一次。遍历每个子模块创建参数后不再重复。可以理解为“装配说明书在动工时才被展开”,避免了过早分配参数导致的结构僵化。
Q5. Flax的参数字典是什么数据结构?
- A.扁平化的列表
- B.嵌套字典(PyTree)← 正确答案
- C.NumPy数组
- D.二进制字节流
答案:B。Flax参数以嵌套字典形式组织,键是模块/参数名,与Module树结构一一对应。这种结构可视为JAX的PyTree,可以被jax.tree_map等工具统一处理,就像分类盒里每个格子都有清晰标签。
Q6. Flax模型能够被jax.jit高效编译的关键原因是什么?
- A.使用GPU训练
- B.参数作为函数入参,无外部依赖← 正确答案
- C.模块是Python类
- D.使用了C++后端
答案:B。JIT编译要求函数为纯函数——无全局变量、无隐藏状态。Flax的apply(params, x)显式传入参数,天然满足纯函数要求,使编译器可以安全地优化整个计算图。正如自动组装机只需扫描“图纸+零件盒”就能生成最优流程。
Q7. 如果你想在Flax中保存模型以恢复训练,正确的做法是?
- A.只保存
params字典并附加模型类定义← 正确答案 - B.使用
pickle直接保存模块实例 - C.保存计算图与参数字典的拼接体
- D.只保存二进制权重文件即可
答案:A。Flax将模型结构与参数分离,恢复训练需要类定义(图纸)+ 参数字典(零件)。序列化params后用from_bytes还原,再构造模型实例填入参数。只保存参数或只保存结构都等于只有一半的“乐高套件”。
Q8. Flax模块系统中,模块的嵌套方式有哪些特点?
- A.子模块在
setup中声明← 正确答案 - B.模块树深度可无限嵌套← 正确答案
- C.参数只存在于叶子节点← 正确答案
- D.父模块无法访问子模块参数
答案:ABC。子模块在setup或@nn.compact中声明,按树形结构嵌套;参数挂在树的每个模块上(不仅仅是叶子);父模块可通过self.submodule访问子模块。这就像乐高城堡由塔楼、塔楼由齿轮组层层构成,每一层都清晰可查。
Q9. 关于Flax的@nn.compact装饰器,理解正确的有哪些?
- A.只能在
__call__中使用← 正确答案 - B.与
setup方法互斥← 正确答案 - C.可以在其中用
nn.Dense(...)快捷创建层← 正确答案 - D.每次调用
apply都会重新创建参数
答案:ABC。@nn.compact用于__call__快捷定义子模块,与setup互斥,参数在首次init时一次性创建,后续apply只是复用。二选一的规则好比“手工图纸”和“速查表”两种说明书模式,不可混用。
Q10. Flax函数式设计带来了哪些工程优势?
- A.参数可自由复制分发到多设备← 正确答案
- B.JIT编译优化更彻底← 正确答案
- C.模型可无缝接入JAX生态← 正确答案
- D.前向传播自动缓存最近结果
答案:ABC。参数作为纯字典可轻松复制到多台设备;纯函数无副作用使JIT能放心优化;嵌套字典即PyTree让jax.tree_map、jax.grad等工具可直接操作。Flax设计牺牲了少许语法糖,换来了JAX生态的全部能力。注意D选项的“自动缓存”不属于Flax特性。