news 2026/10/3 1:31:15

深度强化学习驱动的机械臂容错控制:建模、奖励与训练

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度强化学习驱动的机械臂容错控制:建模、奖励与训练

简介:PDF文档《基于深度强化学习的机械臂容错控制方法》聚焦工业机械臂突发单关节故障问题,提出利用深度强化学习与无模型算法实现离线训练和在线容错控制,适用于从事机械臂智能控制、深度学习应用研究的高校师生及工程师,可作为论文写作与课题开展的参考文献。资源为单文件PDF,大小约1.03MB,内容涵盖问题分析、环境与奖罚机制建模、Rviz三维模型构建、网络训练与在线控制实验等,结构完整。论文不仅对比了传统容错控制的局限性,还展示了深度神经网络如何通过与环境的交互学习最优策略,并给出实验验证,具有较强的专业指导价值。目前已有258人学习下载,适合作为深度学习、数据分析方向的数据研究与专业参考资料。

1. 深夜实验室里,机械臂关节卡死之后怎么办

深夜的实验室,六轴机械臂正按规划轨迹抓取一个零件,突然某个关节电机抱闸锁死,末端轨迹瞬间偏离目标。传统容错控制的做法是先重新辨识模型、再重构控制器,等这一套下来,产线已经停了。基于深度强化学习的机械臂容错控制方法,解决的就是这个场景:不依赖精确动力学模型,让机械臂在关节卡死、力矩衰减这类故障下自己学会调整策略,继续把任务干完。这篇笔记要讲清楚问题怎么建模、奖励怎么设计、训练有哪些坑,适合正在做机械臂控制方向的研究生、自动化工程师,以及想从传统控制转向学习型控制的从业者。

2. 为什么容错控制盯上了深度强化学习:从模型依赖到无模型自适应

“容错控制”本身不是新概念,机械臂领域从上世纪九十年代就在做。但加上了“深度强化学习”这个定语之后,整个做法变了:不再依赖故障检测与诊断模块给出故障类型和参数,不再重新推导控制器,而是让策略网络自己从“试错”里学会补偿。这一章先把这条技术路线为什么能成立讲清楚,再给出适用边界。

2.1 传统容错控制要先过建模这道坎

传统机械臂容错控制的主流路线有三条:滑模控制、自适应控制、基于故障诊断的控制器重构。滑模控制对参数摄动有一定鲁棒性,但它需要已知标称动力学模型和故障边界;自适应控制能在线估计参数,但收敛速度往往跟不上任务节奏;基于故障诊断的重构方法更依赖故障检测的准确性,模型误差稍大,诊断结果就不可靠。

真实机械臂的动力学从来不是书上的拉格朗日方程:关节摩擦、减速器间隙、连杆柔性、重力矩计算误差,全都在模型之外。更麻烦的是,故障位置一变,比如这次是第 2 关节卡死、下次是第 5 关节力矩衰减,控制器就要重新匹配。结果就是工程师在示教器旁边反复改参数,甚至要把机械臂停下来重新辨识。总线舵机机械臂在这条路上更被动,舵机反馈往往只有位置,力矩信息几乎没有,模型不确定性更严重,传统方法很难下手。

2.2 深度强化学习切入容错控制的位置

深度强化学习把“故障诊断 + 控制重构 + 轨迹跟踪”合并成了一个过程。策略网络直接建立从观测到动作的映射,输入是关节角、角速度、末端位姿这些可测信号,输出是关节力矩或位置增量。它不显式告诉策略“哪根关节出了什么故障”,而是通过奖励函数告诉它“末端有没有完成任务、能耗高不高、动作顺不顺滑”,策略自己从回报里揣摩出补偿方式。

常见做法是在仿真环境里先训练:Gazebo、CoppeliaSim、MuJoCo、Isaac Lab 都是这个方向的主力平台,机械臂模型一般用 UR5 或 Panda。训练好之后,再通过 sim-to-real 迁移到实体。这个技术路线的好处是:策略网络可以同时处理多种故障模式,不需要为每种故障单独写控制律。训练完成后在推理阶段,一次前向传播就出动作,延迟在毫秒级,比在线自适应控制器快得多。

2.3 适合深度强化学习的故障场景:选型判断

不是所有机械臂故障都适合丢给强化学习。故障按形式分,大致有四类,适合程度差异很大。下面这张表是我的经验判断,可以当做一个快速筛选工具。

故障类型是否适合 DRL 容错理由
关节持续卡死(joint lock)非常适合状态可观测,策略能通过调整其他关节重新分配运动
关节力矩衰减(torque loss)非常适合效果是“输出打折”,策略可以学出加大指令补偿
自由摆动(free-swing)部分适合关节失去驱动力但还能被动转动,需要策略学出重力补偿,训练难度大
突发断链、飞车不适合响应速度要求极高,需要硬保护回路,DRL 不适合作为最后一道防线
Delta 并联机械臂故障不适合运动学耦合强,容错空间小,DRL 样本效率低,不如直接换冗余设计

判断标准其实就两条:故障是否可观测、是否有足够的时间让策略做出反应。满足这两条,DRL 容错才值得投入。反过来,如果故障是突发的、破坏性的,那不管策略多强,底层都需要一个硬限位或者急停保护,这个边界要在一开始就明确。

3. 故障建模与动作空间设计:容错控制的第一块基石

深度强化学习虽然号称“无模型”,但做容错控制的人必须先把故障描述清楚。故障建模的意义不是给控制器用,而是为了设计观测空间、动作空间和奖励函数——这三样直接决定了策略能不能学出来。这一章从数学描述出发,讲到观测向量怎么构造、动作空间怎么选,最后落到仿真环境里怎么注入故障。

3.1 把关节故障写成数学描述

用最简单的方式描述机械臂第 i 个关节的故障:设指令力矩为 τ_cmd,实际作用到关节的力矩为 τ_act,两者之间的关系可以写成

τ_act = α_i · τ_cmd + τ_bias

其中 α_i 是力矩保持系数,正常时 α_i = 1;力矩衰减时 0 < α_i < 1;关节完全失效时 α_i = 0。τ_bias 是偏置项,对应卡死或者额外摩擦力。

对卡死故障,关节角 q_i 被锁定在一个固定值 q_locked,此时关节无论收到什么指令都不动。对自由摆动故障,τ_act ≈ 0 且关节可以被动转动,策略能控制的只是其他关节。

这个数学模型直接影响动作空间的选择。如果动作空间是关节力矩,那力矩衰减 α_i 会直接削弱输出,策略必须学会“加码”来补偿;如果动作空间是位置增量,故障表现为跟踪误差,策略要调整的是目标位置而非出力大小。两种方案我都试过,结论是:位置增量空间训练稳、收敛快,力矩空间表达的容错能力上限更高,但训练难度明显更大。

3.2 观测空间:让策略“感觉到”自己伤了哪

策略网络不会自动知道哪根关节出了问题,它只能从观测向量里推断。观测向量的设计原则是:既要有当前的运动状态,也要有能反映“指令和实际不一致”的线索。

以一个 7 轴机械臂为例,我常用的观测向量是 42 维,构成如下:

观测分量维度说明
关节角7各关节当前角度
关节角速度7各关节当前角速度
上一步关节力矩指令7给策略反馈“上一步做了什么”
末端位置3笛卡尔空间 x/y/z
末端线速度3末端速度反馈
末端姿态四元数4末端朝向,任务一般用到
过去一步关节角误差7指令位置与实际位置的差值,故障线索
故障诊断概率4来自轻量残差诊断器,可选

关节角误差这一项很关键。卡死故障最直接的表现就是:指令位置一直在变,实际位置纹丝不动,这个信号一进观测,策略就“知道”出事了。故障诊断概率项来自一个独立的轻量诊断模块,用残差法做就行,不是必须的,但加上之后训练速度会明显提升。

所有观测量建议归一化到 [-1, 1] 或 [-5, 5]。深度强化学习策略对输入分布极其敏感,关节角量纲是弧度、角速度是弧度每秒、末端位置是米,直接拼接在一起,量纲大的变量会主导网络权重的更新,这是很多人训练发散的隐性原因。

3.3 动作空间:力矩控制还是位置增量控制

动作空间是策略网络输出的“操作形式”,有三种常见选择:关节力矩直接控制、关节位置增量控制、末端笛卡尔速度控制。

关节力矩控制最贴近容错本质,策略直接输出 7 个关节力矩值,控制器把力矩下发到关节。这种方案的上限高,因为策略可以自由决定每个关节出力大小,理论上能对任何形式的力矩故障做出补偿。但训练难度大,容易学出抖动,对奖励函数设计的要求极高。

位置增量控制输出的是关节位置修正量,底层仍有 PID 闭环。对卡死和力矩衰减故障,PID 环会自然产生一定补偿,策略不需要从零开始学出力,训练稳定得多。代价是容错带宽被底层 PID 限制,故障程度太严重时,PID 饱和,策略也救不回来。

我的工程建议是:第一次做这个方向,先用位置增量控制把任务跑通,让策略学会“往哪儿调整”;等策略稳定了,再切换力矩空间去逼近理论上限。不要一上来就直接上力矩,训练过程中策略发散会让你怀疑人生。

3.4 在仿真环境里注入故障:以 Gazebo 和 CoppeliaSim 为例

仿真里注入故障的方式,取决于仿真器暴露的接口。Gazebo + ros_control 是机械臂仿真最经典的组合,故障注入可以直接在控制器的指令链路上做。下面这段代码演示了力矩衰减故障的注入:

#!/usr/bin/env python # fault_injection.py - 在 ros_control 指令链路上注入力矩衰减故障 import rospy from std_msgs.msg import Float64MultiArray class JointFaultInjector: def __init__(self, joint_index, alpha): self.joint_index = joint_index # 故障关节编号 self.alpha = alpha # 力矩保持系数, 1.0 正常, 0.3 表示只剩 30% 出力 self.cmd_pub = rospy.Publisher('/joint_group_controller/command', Float64MultiArray, queue_size=1) self.sub = rospy.Subscriber('/joint_group_controller/command_raw', Float64MultiArray, self.cb) def cb(self, msg): # 修改指定关节的指令力矩 msg.data[self.joint_index] = msg.data[self.joint_index] * self.alpha self.cmd_pub.publish(msg) if __name__ == '__main__': rospy.init_node('joint_fault_injector') injector = JointFaultInjector(joint_index=2, alpha=0.3) rospy.spin()

这段代码的逻辑是:策略输出的原始力矩指令先发布到 /command_raw 话题,故障注入节点订阅原始指令,把第 2 个关节的力矩乘以 0.3,再转发到真正的控制器话题 /joint_group_controller/command。这样策略看到的观测和实际执行的力矩不一致,它就必须学会补偿。

CoppeliaSim 注入故障更直接:用 sim.setJointForce 或者修改关节的 target velocity 控制模式,在 Lua 脚本里设定关节失效标志就行。MuJoCo 则通过修改 ctrl 数组的对应元素来模拟力矩衰减。无论是哪个仿真器,关键点是:故障注入必须发生在“策略输出”和“物理执行”之间,而不是直接改仿真模型参数——否则策略学到的补偿针对性太强,换一个故障位置就失效。

4. 奖励函数与训练配置:让策略学会“带伤干活”

模型搭好了,仿真环境能注入故障了,接下来就是深度强化学习最核心的环节:奖励函数设计。奖励函数决定了策略“学什么”,同样一个任务,奖励设计不同,学出来的策略风格天差地别。这一章把奖励拆开逐项讲,再给出算法选型和训练参数配置。

4.1 奖励解剖:任务完成、能耗、抖动、安全四项怎么配

机械臂容错控制的奖励函数,我一般拆成四项:

r = r_task + r_energy + r_smooth + r_safety

  • r_task:任务完成奖励。抓取到目标位置给一个大的正奖励,末端距离目标越近给一个小的正向引导。这里要注意稀疏奖励和密集奖励的权衡,后面避坑章节会细讲。
  • r_energy:能耗惩罚,与所有关节力矩平方和负相关。不加这一项,策略会学出“用蛮力干活”,关节力矩经常超过电机额定值。
  • r_smooth:动作平滑项,惩罚相邻两步力矩之差。不加这一项,策略输出的动作会高频抖动,仿真里看不出来,实机上会烧驱动器。
  • r_safety:安全约束,关节角度越限或末端速度超限时给一个大的负奖励。

下面是一个简化版的奖励函数实现:

# reward.py - 机械臂容错控制奖励函数示例 def compute_reward(state, action, prev_action, goal, config): # state: 当前关节角/末端位姿, action: 当前动作, prev_action: 上一步动作 # 1. 任务完成奖励: 末端距离目标越近奖励越高 dist = np.linalg.norm(state.ee_pos - goal) r_task = -config['w_dist'] * dist # 距离惩罚 if dist < config['goal_threshold']: # 到达目标 r_task += config['goal_reward'] # 稀疏到达奖励 # 2. 能耗惩罚: 力矩平方和, 抑制"蛮力" r_energy = -config['w_energy'] * np.sum(action**2) # 3. 动作平滑: 与前一步动作的差异, 抑制高频抖动 r_smooth = -config['w_smooth'] * np.sum((action - prev_action)**2) # 4. 安全约束: 关节角越限给大惩罚 r_safety = 0.0 if np.any(state.joint_pos > config['joint_limit_high']) or \ np.any(state.joint_pos < config['joint_limit_low']): r_safety = -config['safety_penalty'] return r_task + r_energy + r_smooth + r_safety

逻辑说明:这个奖励函数先算距离引导项,让策略有明确的学习方向;再叠加能耗和平滑两个正则项,防止策略走极端;最后用安全约束划定“红线”。四个项的权重系数,决定了策略的“性格”。

参数说明:w_dist 建议在 1.0 左右,确保距离信号是主导;w_energy 从 0.01 开始调,太大策略会“偷懒”不做事,太小学出蛮力;w_smooth 在 0.05~0.1 之间;goal_reward 建议设置在 50~100,不能太小,否则稀疏奖励信号被淹没在密集惩罚里。每个系数调整前,先打印各项奖励的量级,确保没有一项完全压制其他项。

4.2 算法选型:PPO、SAC、TD3 在容错场景怎么选

深度强化学习在连续控制领域的算法选择,基本就是 PPO、SAC、TD3 三选一。三者在机械臂容错控制场景下的表现差异如下表。

算法核心特点样本效率容错控制场景建议
PPO稳定、超参少、实现成熟中首选,绝大多数场景能跑出结果
SAC最大熵框架,探索充分高算力有限时用,能在更少步数内收敛
TD3确定性策略,方差小中高需要平滑动作时用,但要调 target smoothing

我一般默认 PPO 起步。理由很现实:PPO 的参数容错度最高,哪怕奖励函数设计得不是很好,它也有较大概率收敛到一个可用的策略;SAC 和 TD3 在奖励尺度设置不当时更容易发散。如果仿真环境速度快、算力充足,直接跑 PPO 也是稳妥的选择。SAC 适合的情况是:任务本身需要探索很多不同的运动模式,比如故障位置随机出现在任意关节,SAC 的探索能力更占优。

4.3 训练参数与网络结构的参考配置

下面是一套在机械臂容错控制任务里经过验证的 PPO 配置:

参数参考值说明
网络结构MLP 256/256,tanh 激活两层各 256 个神经元,够用且稳定
学习率3e-4常用默认值,发散时降到 1e-4
折扣因子 gamma0.99任务步数长,需要关注远期回报
GAE lambda0.95平衡偏差与方差
clip 系数0.2PPO 策略更新的信任域
batch size2048数据量足够大,更新稳定
episode 长度200 步单次任务长度,按实际任务时间调整
总训练步数2M~5M容错任务比正常任务需要更多样本

网络结构不建议一开始就堆大网络。机械臂容错控制的状态空间维度不算高,42 维观测、7 维动作,256/256 的两层 MLP 已经有足够的表达力。更大的网络反而更容易过拟合到训练时的故障分布,泛化性更差。

随机种子这件事我只能说很玄学。同一个配置,换一个随机种子,训练结果可能从 95% 成功率掉到 60%。我的习惯是每个配置跑 3~5 个种子,报告结果时取中位数,不要只挑最好看的那次。

5. 容错控制训练避坑:从策略发散到实机移植的 5 个教训

这一章全是踩坑记录。深度强化学习训练翻车的方式千奇百怪,但机械臂容错控制方向的高频问题来来回回就是那么几个:训练发散、仿真与实机脱节、泛化性差、奖励设计有漏洞。每条按“现象 → 原因 → 解决”的顺序写,可以直接照着排查。

5.1 训练一上来就发散:机械臂原地抽搐

现象:训练刚开始没多久,仿真里的机械臂末端开始乱飞、关节反复抖动,loss 曲线一路飙升,像网络上常说的“mujoco 加载机械臂乱动”那个样子。

原因:最可能是奖励信号的尺度太大,策略网络被巨大的梯度推着走,直接学崩;其次是学习率设置过高。我见过有人奖励里距离项没归一化,末端距离以米为单位,数值在 0.01 量级,而能耗项力矩平方和数值在 100 量级,策略直接忽略了距离信号,只优化力矩,表现为“一动不动”。

解决:第一步,打印每个奖励项的均值和方差,确认没有一个项比其他项大两个数量级以上。第二步,把学习率从 3e-4 降到 1e-4 重新训。第三步,对所有观测量做 clip,限制在 [-5, 5] 范围内。这三步做完,绝大多数“原地抽搐”的问题能缓解。

5.2 仿真满分、实机零分:sim2real 差距

现象:仿真里成功率 95%,换上真机之后末端偏差巨大,抓取任务完全失败,机械臂偏差肉眼可见。

原因:仿真模型太干净。真实机械臂有摩擦、关节间隙、电机力矩延迟、通信延迟,仿真里全都没建模;而且训练时观测没有加噪声,策略对理想的观测信号过度依赖。

解决:域随机化(domain randomization)是最有效的手段。把机械臂连杆质量随机 ±10%、关节摩擦系数随机、执行器增加 30~50ms 延迟、观测加入高斯噪声。这样策略学到的不是某一组精确参数下的动作,而是一个能适应参数摄动的策略。另外,实机部署时优先用位置增量动作空间,底层 PID 能帮你吸收一部分模型误差。

5.3 换一种故障就失效:泛化性差

现象:训练时只注入第 2 关节卡死,策略成功率高;换成第 4 关节卡死,成功率直接掉到 20% 以下。

原因:策略过拟合到训练时的故障模式。深度强化学习的策略网络不是天生会“举一反三”的,它只会对见过的观测分布做反应。故障位置一变,观测向量的模式就变了,策略自然懵。

解决:训练时把故障类型和故障位置随机采样,每个 episode 重新随机抽一个关节、随机选卡死或者力矩衰减。更进一步可以用课程学习(curriculum learning):先让策略在轻度故障下学会基本任务,再逐步提高故障严重程度。这样策略学到的是一个泛化的“补偿能力”,而不是针对某根关节的肌肉记忆。

5.4 末端到了但力矩大到吓人

现象:训练几百轮之后成功率上去了,但是关节力矩输出远超电机额定值,仿真里看着没什么,实机上会直接烧驱动器。

原因:奖励函数里没有能耗约束,或者能耗权重设得太小。策略发现“出力最大”是到达目标最简单的途径,就会往这个方向收敛。

解决:把能耗项的权重 w_energy 从 0.01 往上调,同时加入力矩平滑项 w_smooth。观察训练过程中平均关节力矩的曲线,确认它在合理范围内。这里有个经验:如果策略学会了“完成任务”,但平均力矩一直在涨,那就是奖励里还缺约束,不要急着部署。

5.5 奖励稀疏训不出结果

现象:训练跑了几十万步,成功率还是 0,策略完全学不会。

原因:任务完成奖励是稀疏的二进制信号,“末端到达目标才给奖励,否则给 0”,中间的过程没有梯度信息,策略不知道往哪个方向改进。这在机械臂抓取这类长时程任务里特别常见。

解决:用 dense reward 做引导:加上末端到目标的距离惩罚,或者用 potential-based shaping(每步的奖励与“距离目标的变化量”挂钩)。想省事就直接在奖励函数里加 -w_dist·dist,距离减小就相当于正奖励;想更高效就搭配经验回放(比如 HER)让策略也能从失败的 episode 里学到东西。注意 shaping 奖励不能太强,否则策略会只缩短距离、不学习完成任务的完整动作链。

6. 用故障注入矩阵验证容错策略:一套可以直接复用的评估流程

策略训练好之后,最怕的就是只在一个场景里验证就下结论。我吃过这个亏:训练时只在第 2 关节卡死的情况下测试,结果写进报告里,评审一问“换第 5 关节力矩衰减 50% 呢”,当场翻车。后来我养成了一个习惯——用故障注入矩阵做系统性评估。

故障注入矩阵至少覆盖三个维度:故障位置、故障类型、故障发生时刻。下表是一个 9 组测试的最小配置矩阵。

测试组故障位置故障类型严重程度发生时刻
1关节 2卡死完全卡死任务开始前
2关节 4力矩衰减保持 30% 出力任务开始前
3关节 6卡死完全卡死任务中途
4关节 2力矩衰减保持 50% 出力任务中途
5关节 3自由摆动完全失效任务开始前
6关节 5卡死完全卡死任务中途
7关节 1力矩衰减保持 60% 出力任务中途
8关节 6自由摆动完全失效任务中途
9无故障无无无

每组跑至少 20 个 episode,统计成功率、任务完成时间、末端 RMS 误差、平均关节力矩四个指标,再与两个基线对比:无容错策略(训练时不注入故障)和传统滑模容错控制。注意故障发生时刻要随机化,落在 episode 的不同时间点,策略必须能在执行中途“察觉”故障并重新规划剩余动作。

评估结果用箱线图画出来,重点看成功率中位数和方差。如果中位数高但方差大,说明策略不稳定,需要回去看奖励函数里的平滑项;如果中位数低,说明策略对某种故障模式真的没学会,要把这种故障加入训练分布。

这套流程走完,我才敢说一个容错策略是可部署的。机械臂容错控制这个方向,深度强化学习不是万能药,但在“故障位置随机、故障形式多样、任务重复执行”的场景里,它确实能做出传统方法做不到的自适应能力。我的最后一条建议是:把故障注入矩阵当成训练的一部分,而不仅仅是验证手段——让故障出现在训练数据里,策略才有机会真正学会应对。希望这篇笔记帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 1:30:17

DRV8818PWPR+STM32L496AG工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:29:52

多智能体协同围捕Python源码解析:Voronoi与MADDPG实践

简介&#xff1a;面向计算机专业课程设计与期末大作业的高分项目&#xff0c;提供多智能体协同围捕算法在多种仿真环境下的Python实现与项目说明&#xff0c;适合正在做课程设计、期末大作业或需要项目实战练习的学习者。内容覆盖单出口、多出口、凸环境、封闭环境等典型围捕场…

作者头像 李华
网站建设 2026/10/3 1:29:47

DRV8818与MKV44F64VLH16工业步进控制实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:28:43

DRV8818PWPR与TM4C123GH6PZ工业级步进电机精准控制实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:28:21

DRV8818+PIC18F47K40工业级步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华