简介面向多智能体强化学习的学习者与开发者Keras 版 MADDPG 项目提供了一套可运行、可拆解的多智能体连续动作策略实现适合理解 actor-critic 协同训练、集中式训练与分布式执行、目标网络更新、经验回放及噪声探索等关键机制并可用于机器人协作、策略博弈和交通控制等场景的实验复现。压缩包共 21 个文件以 12 个 h5 模型权重和 7 个 Python 脚本为主体另含 README 说明与 LICENSE整体约 7.86MB目录组织便于按训练、预测、环境交互、回放缓冲和模型加载等环节查阅。已有 816 人学习下载说明其在强化学习实践群体中具备一定参考价值。读者可从中获得多智能体训练与预测代码、已保存的 actor/critic 网络权重、环境与回放模块的实现思路也能据此开展算法对照实验、二次开发和关键参数调试。1. 把单智能体 DDPG 复制 N 份为什么必然翻车多智能体环境里最反直觉的一件事给每个智能体各配一份 DDPG每个都在老实优化自己的策略训练曲线却会集体发散。原因不在代码写错而在一个被悄悄破坏的数学前提——对任意一个智能体来说其他智能体的策略一直在变它面对的转移概率分布跟着漂移Q 函数的 Bellman 备份失去了不动点保证。这份用 Python 和 Keras 落地的 MADDPG 实现核心动作就一件事训练时让 Critic 看到所有人的观测和动作执行时再退回各自独立决策。搞懂这个「训练与执行信息不对称」的设计动机比背下那几行梯度公式有用得多后面所有关于张量形状和损失函数的坑几乎都能从这个动机里推出来。2. 集中式 Critic为什么训练时允许「偷看」别人的动作2.1 环境非平稳性到底破坏了哪个数学前提单智能体强化学习能收敛靠的是一条很朴素的假设状态转移核P(s | s, a)是固定的。有了这条假设Bellman 最优算子才是一个压缩映射Q 值反复迭代才会逼近唯一不动点经验回放里几十万条旧数据也才敢和新数据混在一起训。多智能体把这个假设拆掉了。对智能体 i 而言真实的环境是P(s | s, a_1, ..., a_N)而a_{-i}来自其他智能体正在学习的策略。训练早期别人乱动训练中期别人开始收敛训练后期别人又因为你的变化而重新调整——对 i 来说环境的「物理规律」每隔几千步就换一次。旧经验里「我往左走会撞上别人」的结论在别人策略变了之后就不成立了但回放池还在一遍遍把这条过期经验喂给 Q 网络。表现出来就是那种很玄学的训练曲线某一段时间内某个智能体突然变强另外几个立刻崩溃接着强者也因为队友的行为分布突变而崩整体来回震荡、谁都不收敛。很多人第一反应是调学习率、加梯度裁剪、换优化器都不解决问题因为病根不在优化器。MADDPG 的处理方式很直接既然非平稳性来自「别人在变」那就让 Critic 在训练时把别人的动作也当成输入显式建模。Q 函数从Q_i(o_i, a_i)变成Q_i(x, a_1, ..., a_N)其中x是全局状态通常就是所有智能体观测的拼接。这样一来给定一组联合动作Critic 要评估的是一个条件明确、参数固定的函数策略评估重新变得稳定。注意它并没有消灭非平稳性只是把非平稳性关进了训练阶段让执行阶段保持干净。2.2 Q_i(x, a_1…a_N) 的输入拼接方案名称里的「集中式」常被误解成「有一个中央控制器」实际上集中只体现在 Critic 的输入维度上。每个智能体依然拥有自己独立的 Actor 和独立的 Critic参数不共享只是 Critic 的输入把全场信息都吃了进去。拼接方式有两种常见做法。第一种是环境本身提供了全局状态s直接拿s当x第二种是环境只给各智能体的局部观测o_1..o_N那就把它们按固定顺序 concat 成x。第二种更通用代价是 Critic 的输入维度随智能体数量线性增长N 大的时候第一层全连接会明显变宽。Critic 的输入总维度是sum(obs_dim_i) sum(act_dim_i)。这个数字算错一次后面所有报错都会以「shape mismatch」的形式在莫名其妙的地方炸出来值得在构造函数里断言一次。另外要强调的是每个智能体的 Critic 是独立的奖励函数不同、动作空间不同、甚至连观测维度都可能不同硬共享一套 Critic 参数会让梯度互相打架。2.3 一次完整的训练迭代时序把一次迭代拆开看顺序错了结果就会完全不同这也是很多人写完能跑但学不出来的原因。每个智能体用自己的 Actor 对当前观测出动作叠加探索噪声clip 到动作空间边界。环境 step 一步拿到联合奖励r_1..r_N、下一时刻全局状态x、done 标志。把(x, a_1..a_N, r_1..r_N, x, done)整条存进共享回放池而不是每个智能体各存一份。从回放池采样一个 batch。更新每个 Critic目标值用target Actor在x上生成的全部动作来算。更新每个 Actor其他智能体的动作直接用回放池里的真实动作不重新采样。对每个 target 网络做一次软更新。第 5 步和第 6 步用的动作来源不一样这是 MADDPG 里最容易被忽略的细节。算 TD 目标时要用 target Actor 输出保证目标是「慢变量」训练稳定而算 Actor 梯度时其他智能体的动作必须是回放池里那条真实转移里的动作因为你要评估的是「在当前这个联合动作上下文里我改自己的动作会怎样」。如果这里也用 target Actor 重新生成别人的动作Critic 收到的输入就和它训练时见过的分布对不上梯度方向会偏。3. Keras 里的 Actor/Critic 建模张量形状、激活和软更新怎么写3.1 Actortanh 之后为什么还要乘一个 act_limitActor 的输出层几乎固定是 tanh因为确定性策略梯度要求动作对参数可导而 tanh 的梯度性质好、输出天然有界。但 tanh 的值域是[-1, 1]真实动作空间往往是[-2, 2]或者力矩范围[-10, 10]直接输出会让策略永远够不到动作空间边缘。import tensorflow as tf from tensorflow.keras import layers, Model class Actor(Model): 单智能体的确定性策略网络观测 - 动作 def __init__(self, obs_dim, act_dim, act_limit, hidden64): super().__init__() self.act_limit act_limit # 动作空间边界标量或每维不同的向量 self.fc1 layers.Dense(hidden, activationrelu) self.fc2 layers.Dense(hidden, activationrelu) self.out layers.Dense(act_dim, activationtanh) def call(self, obs, trainingFalse): h self.fc2(self.fc1(obs)) # tanh 输出 [-1, 1]乘上边界后映射到真实动作空间 return self.out(h) * self.act_limit这里的act_limit是硬约束不是可学习参数。有些实现会让网络自己学缩放结果就是训练初期缩放系数乱飘动作幅度忽大忽小。固定边界的做法更稳代价是动作空间不对称时比如[0, 3]需要额外做一次平移通常的写法是(tanh 1) / 2 * (high - low) low把归一化区间重新对齐。隐藏层宽度 64 是论文里给出的起点对于观测维度几十以内、智能体数量个位数的任务基本够用。层数不建议堆太深确定性策略梯度对网络容量的敏感度远低于对目标网络更新频率的敏感度。3.2 Critic多路观测在 concat 前必须做的事Critic 的输入是两路全局状态和联合动作。二者维度不同、语义不同但都是连续实数可以在特征维度上直接拼接。class Critic(Model): 集中式动作价值网络全局状态 联合动作 - Q 值 def __init__(self, state_dim, joint_act_dim, hidden64): super().__init__() self.concat layers.Concatenate(axis-1) # 在特征维拼接 self.fc1 layers.Dense(hidden, activationrelu) self.fc2 layers.Dense(hidden, activationrelu) self.q layers.Dense(1) # 输出标量 Q def call(self, state, joint_action, trainingFalse): x self.concat([state, joint_action]) h self.fc2(self.fc1(x)) return self.q(h)Concatenate层放在__init__里而不是在call里临时调用函数式 API是为了避免在tf.function追踪过程中反复创建层对象那会让计算图膨胀、训练越跑越慢。这是个很隐蔽的性能坑功能上完全正确但跑几万步之后能明显感觉到每一步的耗时在涨。拼接顺序必须全局唯一。训练时按[o_1, o_2, ..., o_N]拼评估时按[o_2, o_1, ...]拼网络上不会报错因为维度完全一致但 Critic 会认为自己在评估一个完全不同的状态输出全错。这个问题不会以任何异常形式暴露只能靠代码审查或者给每路输入加名字校验来防。输出层不加激活函数因为 Q 值理论上可以是任意实数负奖励任务里 Q 为负是常态。加了 sigmoid 或者 relu 会让 Critic 永远无法表达负价值表现出来就是「训练前期还行后期怎么也上不去」。3.3 目标网络与 tau 的工程实现DDPG 系列的稳定性很大程度上靠 target 网络撑着。Actor 和 Critic 各有一份 target 副本参数不参与梯度更新只做缓慢跟随。def soft_update(target_model, source_model, tau0.01): theta_target - tau * theta_source (1 - tau) * theta_target for tw, sw in zip(target_model.weights, source_model.weights): tw.assign(tau * sw (1.0 - tau) * tw)传统的硬更新是每隔 C 步整体拷贝一次中间那段参数是阶梯状变化的TD 目标会跟着阶梯跳训练曲线就呈现出周期性的抖动。软更新把每次的更新幅度压到tau0.01相当于给目标网络加了一个时间常数 100 步的低通滤波TD 目标平滑得多。几个常见的取值区间tau在 0.005 到 0.01 之间比较稳任务越长、奖励越稀疏可以取得更小如果tau调到 1.0等价于没有目标网络训练大概率发散如果调到 0.001 以下目标几乎不跟随学到的策略永远落后于当前策略表现为「学得很慢但看起来不崩」。这里没有放之四海皆准的最优值我的习惯是从 0.01 起步看到训练曲线有明显周期性抖动再往下调。拷贝初始参数时要注意target 网络必须在构造完成后立刻与主网络权重对齐一次否则它从一个随机初始化开始跟随前几万步的 TD 目标全是噪声。3.4 自定义 train_step两个损失不能用同一个 tapeActor 和 Critic 的更新目标相互依赖但参数互斥硬塞进一个GradientTape里会互相污染梯度。正确的做法是分开求导、分开 apply。tf.function def train_step(batch, agents, targets, optimizers, gamma0.95): # batch 形状约定 # state [B, state_dim] 全局状态 # actions [B, N, act_dim] 联合动作回放池里的真实动作 # rewards [B, N] 每个智能体各自的奖励 # next_state [B, state_dim] # dones [B, N] state, actions, rewards, next_state, dones batch joint_act tf.reshape(actions, [tf.shape(actions)[0], -1]) # [B, N*act_dim] # ---- 1. 计算 TD 目标全部用 target 网络 ---- next_obs_list tf.split(next_state, agents.num_agents, axis-1) # 每个 [B, obs_dim] next_acts [targets.actors[i](next_obs_list[i]) for i in range(agents.num_agents)] next_joint_act tf.concat(next_acts, axis-1) # [B, N*act_dim] for i in range(agents.num_agents): with tf.GradientTape() as tape: q_next targets.critics[i](next_state, next_joint_act) # 用 stop_gradient 冻结目标done 截断后续回报 y rewards[:, i] gamma * (1.0 - dones[:, i]) * tf.stop_gradient(q_next) q_now agents.critics[i](state, joint_act) critic_loss tf.reduce_mean(tf.square(q_now - tf.stop_gradient(y))) grads tape.gradient(critic_loss, agents.critics[i].trainable_weights) optimizers.critics[i].apply_gradients(zip(grads, agents.critics[i].trainable_weights)) # ---- 2. 更新 Actor只有自己的动作参与求导 ---- for i in range(agents.num_agents): with tf.GradientTape() as tape: obs_i tf.split(state, agents.num_agents, axis-1)[i] my_act agents.actors[i](obs_i) # 需要梯度穿过这里 # 其他智能体的动作来自回放池视为常量 others [joint_act[:, j * agents.act_dim:(j 1) * agents.act_dim] for j in range(agents.num_agents) if j ! i] new_joint_act tf.concat( others[:i] [my_act] others[i:], axis-1 ) # 策略梯度方向最大化 Q所以损失取负号 actor_loss -tf.reduce_mean(agents.critics[i](state, new_joint_act)) grads tape.gradient(actor_loss, agents.actors[i].trainable_weights) optimizers.actors[i].apply_gradients(zip(grads, agents.actors[i].trainable_weights)) # ---- 3. 软更新全部 target 网络 ---- for i in range(agents.num_agents): soft_update(targets.actors[i], agents.actors[i], tau0.01) soft_update(targets.critics[i], agents.critics[i], tau0.01)几个关键点值得逐条拆开。第一next_joint_act在循环外算一次就够了target Actor 的输出在这一步对所有 Critic 是共享的没必要在循环里重复前向。第二tf.stop_gradient(y)不是可选项y里含有q_next如果不在计算 critic_loss 前截断梯度会顺着q_next反向传回 target Critic 再传到 target Actor导致 target 网络也被更新整套慢变量机制失效几十步内就能看到 Q 值爆炸。第三Actor 更新里others直接切自joint_act本来就是常量张量不参与求导不需要额外加stop_gradient但如果你把动作从别的地方现算出来就必须加。第四每个智能体的 optimizer 必须是独立实例共用一个优化器对象会让第二次apply_gradients拿着另一套变量去找对应的动量槽Keras 会直接报变量不匹配。第五reduce_mean而不是reduce_sum用 sum 会让损失量级随 batch size 线性放大等效于把学习率乘了 batch 倍数。提示gamma取 0.95 是这类多智能体协作任务的常见起点比单智能体常用的 0.99 更短视一些。原因是对手的策略变化带来额外的方差折扣因子太大等于让 Critic 去预测一个更远、更不确定的未来TD 误差压不下来。4. 经验回放与探索噪声多智能体里最容易写错的是维度4.1 回放池到底存谁的观测单智能体的回放池存(s, a, r, s, done)多智能体版本最直接的推广是存(x, a_1..a_N, r_1..r_N, x, done_1..done_N)也就是一条转移记录整个系统的快照。这和「每个智能体各存一个池」有本质区别。共享一个池意味着所有智能体在同一批数据上训练采样时间对齐你能从同一个 batch 里同时取出别人当时的动作——这一点在 Actor 更新时是刚需。如果每个智能体各存各的不同池的采样顺序不同同一个 batch 里各个智能体的状态根本不是同一时刻的Critic 收到的「全局状态 联合动作」就是拼凑出来的虚构转移学到的 Q 完全无效。存储格式上有个细节actions建议维度是[capacity, N, act_dim]rewards是[capacity, N]。这样采样后得到[B, N, act_dim]喂给 Critic 前一次reshape到[B, N*act_dim]就行顺序天然和拼接约定一致。如果一上来就存成扁平[capacity, N*act_dim]也能跑但做 per-agent 的分析和调试时会很别扭。4.2 采样后 [B, N, D] 的三种拆法拿到[B, N, D]之后有三种常见操作用途完全不同混用是形状类 bug 的主要来源。拆法一是tf.split(x, N, axis-1)用于把扁平化的全局状态切回每个智能体的观测喂给各自的 Actor。前提是全局状态本身就是按[o_1, o_2, ..., o_N]拼起来的且每个智能体的观测维度相同维度不同就只能存下标切片区间。拆法二是tf.reshape(x, [B, -1])用于把[B, N, D]压平成[B, N*D]喂给 Critic。压平顺序必须和高维张量的内存布局一致Keras 默认行优先reshape没问题但如果你用transpose先转成[N, B, D]再 reshape得到的就是完全乱掉的拼接结果。拆法三是x[:, i, :]用于取单个智能体的数据比如算各自的奖励。这种写法最直观但要注意i是智能体序号还是 batch 序号两个维度都是「第几个」非常容易搞反。搞反了不会报错只会让训练悄悄变差。4.3 动作更新的两种动作来源replay 动作与 target 动作前面时序里提过一次这里展开说因为它是 MADDPG 和普通 DDPG 在代码结构上最大的区别。算 Critic 的 TD 目标时next_actions必须由 target Actor 基于next_state现场生成。这是标准 DDPG 做法目的是让目标值里的动作部分也是慢变量避免「目标跟着策略一起动」导致的自举发散。算 Actor 的梯度时others必须取自回放池。论文里的梯度表达式大致是「Critic 对第 i 个动作输入的偏导乘以 Actor 对参数的偏导」其他动作在这里是作为固定上下文出现的。如果这里也用 target Actor 生成别人的动作会出现两个问题一是 Critic 在训练时见到的动作分布和评估时不同Q 值的可靠性下降二是别人的动作里带了别人的参数反向传播会通过 Critic 传回别人的 Actor你的更新就不再是「只优化自己」了。代码上体现为两处不一样的写法前者是targets.actors[i](next_obs_i)后者是joint_act[:, ...]切片。很多实现为了让代码对称把两处都写成现场生成训练能跑但收敛明显更差且很难定位原因。4.4 探索噪声OU 过程与高斯噪声的取舍确定性策略本身没有探索能力全靠外部噪声。MADDPG 原始论文用的是 Ornstein-Uhlenbeck 过程核心是让噪声有时间相关性——这一时刻往左偏下一时刻大概率还往左偏适合有惯性的连续控制任务。import numpy as np class OUNoise: 带均值回归的时序相关噪声适合动量型的连续控制任务 def __init__(self, action_dim, mu0.0, theta0.15, sigma0.2): self.action_dim action_dim self.mu mu # 回归目标通常为 0 self.theta theta # 回归速度越大越快回到均值 self.sigma sigma # 扩散强度决定噪声幅度 self.state np.zeros(action_dim) def reset(self): self.state np.zeros(self.action_dim) def sample(self): dx self.theta * (self.mu - self.state) \ self.sigma * np.random.randn(self.action_dim) self.state self.state dx return self.state.copy() def __call__(self): return self.sample()theta控制噪声的「记忆长度」倒数大致就是相关时间sigma控制扰动幅度。这两个参数调起来很看任务sigma太大智能体根本学不到有效行为太小则探索不到新区域。常见的起点是theta0.15、sigma0.2然后根据训练前期的动作幅度微调。高斯噪声则是每步独立采样实现只有一行但没有时序相关性在需要平滑动作的任务上探索效率偏低。它的优势是简单、无状态、多智能体之间天然独立。用哪种取决于任务的动力学特性机械臂、车辆这类有惯性的场景用 OU资源分配、离散化连续动作这类场景高斯就够了。还有一个容易被忘掉的操作每个 episode 开始时要把噪声状态reset()。OU 过程是有状态的不重置会带着上一个 episode 末尾的偏移进入新 episode探索行为在 episode 之间不独立。另外评估阶段必须把噪声彻底关掉不只是乘个 0.1 的系数而是完全不加。5. 排查现场MADDPG 跑不起来时的五条血泪经验5.1 形状对不上的两类报错第一条Critic 的输入维度对不上报错位置却在 Dense 层。现象是构造完 Critic 第一次前向就抛维度不匹配错误信息指向第一个全连接层看起来像是state_dim算错了。原因九成是state_dim和joint_act_dim的定义不一致——比如按N * obs_dim算的全局状态维度但实际环境返回的拼接状态里混进了额外的时间步特征或者某个智能体的观测是图像被压平后的高维向量被当成了标量维度。解决方式是在 Critic 的构造函数里直接断言assert state_dim sum(obs_dims)并且把obs_dims列表打印出来对一遍。更稳的做法是让环境包装器统一负责状态拼接网络层只接受一个明确命名的state_dim不在任何地方重复计算这个数字。第二条单步推理正常一进 batch 就炸。现象是拿单条经验喂网络没问题一旦从回放池采[B, N, D]就报错。原因是网络期望[B, D]而传进去的是三维张量Keras 的Dense只在最后一维做变换多余的N维会被当成 batch 的一部分结果维度全乱。解决方式是显式做 reshape 并加注释标明每个维度的含义。我一般在train_step开头写一段注释把state [B, state_dim]、actions [B, N, act_dim]、joint_act [B, N*act_dim]全部标清楚后面每一处 reshape 都对照这段注释检查。5.2 训练不收敛的三类症状第三条Q 值单调上涨然后爆到 inf。现象是训练前几百步 Critic 损失稳步下降看着一切正常然后某个时刻 Q 值突然飙到极大之后全是 NaN。原因通常是忘了加tf.stop_gradient梯度顺着 TD 目标传回了 target 网络target 跟着一起更新慢变量机制失效形成正反馈。解决方式是逐行检查 TD 目标那一行确认q_next被tf.stop_gradient包住且 target 网络的权重只在soft_update里被写过。另一种可能是奖励没有做缩放量级太大的奖励直接把 Q 顶飞加一个奖励归一化能缓解。第四条所有智能体学到同一个行为或者干脆不动。现象是训练几千步之后各智能体的动作输出几乎完全一致或者全部贴在动作空间边界上。前者通常是 Critic 的输入拼接顺序在训练和评估时不统一导致 Q 值失效策略梯度变成了随机噪声后者一般是探索噪声的sigma太小策略很早就退化成确定性输出卡在局部最优。解决方式是给每个智能体加一个身份特征或者固定不同的初始噪声种子同时检查拼接顺序的一致性。动作贴边的话把sigma调大、或者在训练前期用线性衰减的噪声系数。第五条损失在降但 episode 回报不涨。现象是 Actor 和 Critic 的损失曲线都很好看实际跑评估环境却始终在原地打转。原因可能是gamma设得太大导致 Critic 在拟合一个过于长远的、方差极大的目标也可能是 target 网络的软更新频率远低于 Critic 的学习速度目标值和当前值之间始终隔着一大截。解决方式是先把gamma降到 0.9 附近跑一遍看回报是否开始上升同时检查tau是否在合理区间可以试着从 0.01 提高到 0.02观察是否改善。如果都不行把 batch size 调大、学习率调小重新跑一轮短训练对照。5.3 评估阶段的隐蔽陷阱第六条评估指标一直在抖看不出模型好坏。现象是每轮评估的回报方差极大同一份权重跑多次结果能差一倍。原因通常是评估时忘了关噪声或者Actor的call里没有区分 training 和 inference 分支导致某些只在训练期生效的层如 dropout虽然在 MADDPG 里不该出现在评估时依然在起作用。解决方式是给Actor.call加上training参数并在推理时显式传False评估循环里完全不调用噪声采样。评估用固定随机种子的多个 episode 取平均不要只看单次结果。第七条训练环境表现很好换个初始状态就崩。现象是训练时那几条固定的初始条件上回报很高稍微扰动初始状态就完全失效。原因是回放池容量太小、训练步数太少策略过拟合到了少数几条轨迹上。多智能体任务的状态空间随智能体数量指数增长同样的步数下样本覆盖度比单智能体差得多。解决方式是加大回放池容量、延长训练、在环境中给初始状态加随机扰动做数据增广。判断是否过拟合的一个简单办法是比较训练集和留出初始状态上的回报差距差距超过 30% 就该怀疑覆盖度了。6. 设计取舍Critic 为什么不能上线以及如何验证你实现对了集中式 Critic 带来的表达能力是有代价的这个代价在多智能体数量增加时会迅速放大。输入维度是sum(obs_dim) sum(act_dim)N 个智能体加起来第一层全连接的参数量是 O(N)而实际需要覆盖的联合状态动作空间是指数级的样本复杂度随之上升。这就是为什么 MADDPG 在 3 到 5 个智能体的任务上表现不错到十几个智能体就开始吃力。更根本的限制在执行阶段Critic 需要全局信息但真实部署时每个智能体只能看到自己的局部观测全局状态根本拿不到。所以 Critic 天生只能活在训练阶段上线时被整个丢掉只留 Actor。这个「训练用重装备、执行用轻装备」的取舍是 CTDE 范式的核心也是它和端到端集中式方法的分水岭。还有一层取舍是参数共享。同构智能体共享一套 Actor 参数能大幅降低样本需求但会让所有智能体学到完全相同的策略在需要角色分工的任务上是灾难。异构任务下共享 Critic 更危险不同智能体的奖励尺度不同共享会让梯度互相拉扯。验证实现是否正确最有效的方式不是看损失曲线而是做几个可控的小实验验证项做法期望现象Critic 拟合能力固定策略只用随机策略采数据训 CriticCritic 损失单调下降并收敛到接近 0梯度路径对 Actor 损失求导后检查其他智能体参数是否有非零梯度应为全零被stop_gradient或常量切片挡住目标网络打印 target 权重与主权重的差值范数应随训练缓慢增长不应归零也不应爆炸单智能体退化把 N 设为 1 跑一遍行为应等价于标准 DDPG回报曲线形状相似单智能体退化测试是我最推荐的一项因为它能一次性排掉绝大多数结构性错误如果 N1 都学不好问题一定在 Critic 拟合或目标网络而不是多智能体的逻辑。这类实现最容易踩的坑不是公式写错而是那些「能跑但悄悄变差」的地方——拼接顺序、reshape 语义、噪声开关。从那以后我每次搭多智能体训练循环都会先把 N1 的退化实验跑通再逐步加到 2 个、3 个智能体每一步都对照回报曲线确认没有性能悬崖绝不一次性堆上去。希望帮到你。本文还有配套的精品资源点击获取