news 2026/10/2 17:47:35

基于Unity ML-Agents的自行车机器人强化学习训练与多智能体避障仿真

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Unity ML-Agents的自行车机器人强化学习训练与多智能体避障仿真

简介:这是一套基于 Unity ML-Agents release 15 开发的智能自行车机器人仿真系统,面向智能交通、强化学习与自动驾驶领域的研究者、开发者及高校学生,用于解决自行车智能体在复杂城市交通环境中的自主导航、避障与群体协同行为建模问题。资源共 566 个文件,压缩包约 34.12MB,包含 30 个 onnx 和 26 个 pt 格式的训练模型、23 个 Unity 场景 asset、21 个 C# 脚本、14 个 prefab 预制体、15 个 mat 材质,以及 TensorBoard 训练日志和说明文档等;meta、unity 等工程文件构成完整项目,可直接打开运行或继续训练。目前已有 71 人在 CSDN 学习并下载。整套资源提供了可复现的强化学习训练环境与环岛、十字路口等多种交通场景地形,还附带了训练好的模型与配套说明,便于研究者快速复现实验、观察智能体决策过程,也可作为毕业设计或智能体仿真课程的项目基础。资源内目录结构清晰,便于按需检索和二次开发。

1. 在Unity里训一辆自行车之前:为什么这个仿真系统值得做

如果用一句话说清楚这个项目:它是在 Unity 引擎里搭出一套带真实交通要素的骑行环境,用 ML-Agents 的强化学习算法去训练一个能让自行车机器人自主导航和避障的决策模型,同时让多辆自行车组成群体,在模拟车流里跑出类似真实交通的行为。它解决的核心问题,是把传统写在代码里的 if-else 避障逻辑变成数据驱动的策略:换一个路口布局、换一种车流密度,规则脚本要逐条改,而强化学习模型只需要重新训练或微调。适合正在做机器人导航前期验证、自动驾驶决策预研、游戏 AI 行为设计,以及交通流仿真的人。我见过很多人对这套方案的第一反应是“Unity 做仿真是不是太重了”,但真正动手以后会发现,ML-Agents 已经帮你把采样、replay buffer、PPO 更新全部封装好,你要做的是把自行车的感知和运动学模型写对,剩下的事情交给训练器。

2. 搭建自行车智能体环境:Agent接口、感知输入与奖励函数

2.1 让自行车机器人进入ML-Agents决策循环:新建Agent脚本并挂载行为参数

ML-Agents 的训练闭环是这样转起来的:场景里每个挂了 Behavior Parameters 的对象就是一个 Agent,训练器按设定频率给它发决策请求;Agent 先收集观测,把观测向量传给神经网络,网络输出连续或离散动作;动作作用到自行车刚体上,环境返回奖励。对自行车机器人来说,最关键的既不是画一个好看的车模,而是把“控制量”和“运动学模型”设计得足够简单。

我一般会先用运动学自行车模型起步:控制量只有两维,一个是驱动速度,一个是前轮转角。暂不做侧倾平衡,把自行车当成一个非完整约束运动体。原因是强化学习刚上手时最怕状态空间太大,如果你把倾角、角速度、扭矩、路面坡度全部塞进观测里,训练难度会指数级上升。等路径规划和避障跑通了,再去叠加平衡控制层。

using UnityEngine; using Unity.MLAgents; using Unity.MLAgents.Sensors; using Unity.MLAgents.Actuators; public class BikeAgent : Agent { [Header("运动学参数")] public float maxDriveSpeed = 12f; // 最大前进速度,约等于城市骑行速度 public float maxSteerAngle = 30f; // 前轮最大摆角,超过会显得动作假 public float wheelBase = 1.8f; // 轴距,决定转弯半径 public Transform target; public Transform spawnPoint; private Rigidbody bikeRb; private float currentSpeed; private float currentSteer; public override void Initialize() { bikeRb = GetComponent<Rigidbody>(); } public override void OnEpisodeBegin() { transform.SetPositionAndRotation(spawnPoint.position, spawnPoint.rotation); bikeRb.linearVelocity = Vector3.zero; bikeRb.angularVelocity = Vector3.zero; target.position = RandomPathPoint(); // 在可行区域里取一个合法目标点 } public override void CollectObservations(VectorSensor sensor) { // 自车速度转换到车身坐标系,再做归一化 Vector3 localVel = transform.InverseTransformDirection(bikeRb.linearVelocity); sensor.AddObservation(localVel.x / maxDriveSpeed); sensor.AddObservation(localVel.z / maxDriveSpeed); sensor.AddObservation(currentSteer / maxSteerAngle); // 目标点相对位置,按场景尺度归一化到 -1 ~ 1 Vector3 offset = target.position - transform.position; sensor.AddObservation(offset.x / 50f); sensor.AddObservation(offset.z / 50f); } public override void OnActionReceived(ActionBuffers actions) { float drive = Mathf.Clamp(actions.ContinuousActions[0], -1f, 1f); float steer = Mathf.Clamp(actions.ContinuousActions[1], -1f, 1f); currentSpeed = drive * maxDriveSpeed; currentSteer = steer * maxSteerAngle; // 自行车模型:先按前轮转角算航向角速度,再更新刚体速度 float yawRate = currentSpeed * Mathf.Tan(currentSteer * Mathf.Deg2Rad) / wheelBase; transform.Rotate(Vector3.up, yawRate * Time.fixedDeltaTime, Space.Self); bikeRb.linearVelocity = transform.forward * currentSpeed; // 稠密奖励:向目标前进,并给到达终点一个较大的正向奖励 float forwardSpeed = Vector3.Dot(bikeRb.linearVelocity, transform.forward); AddReward(forwardSpeed / maxDriveSpeed * 0.05f); AddReward(Vector3.Distance(transform.position, target.position) < 1f ? 1f : 0f); } public override void Heuristic(in ActionBuffers actionsOut) { // 人工验证环境时使用键盘控制,不需要训练器介入 actionsOut.ContinuousActions[0] = Input.GetAxis("Vertical"); actionsOut.ContinuousActions[1] = Input.GetAxis("Horizontal"); } }

代码里我刻意没有用 3D 射线传感器,而是把它放到 Unity 编辑器里配置,这样调射线根数、探测距离、layer mask 不用动代码。需要注意的是Behavior Parameters上的观测维度会被传感器组件自动累加,你在CollectObservations里压入的是向量观测,运行时顺序是“传感器先、向量后”,这个顺序一旦定了就别再改,否则旧模型全部失效。刚体要关闭角扰动,锁定 z 轴旋转,质量设 80kg 左右,这些细节直接影响训练时能不能稳定起步。

2.2 状态不一定靠射线:传感器配置和空间归一化

ML-Agents 里常见有 RayPerceptionSensorComponent3D、CameraSensorComponent 和向量传感器。自行车在交通场景里我用的最多的是 RayPerception 加向量观测。相机传感器维度太大,训练时动不动就是几万维输入,而且画面里的阴影、光照变化很容易让模型过拟合;射线传感器只取固定角度和距离,更贴近激光雷达的感知逻辑。

配置项建议值原因
Ray Count7~9 根能覆盖正前方和左右 45 度
Ray Angle起始 -45 度,结束 45 度转弯时能看到侧向障碍
Ray Distance15~20 米更远的信息对训练不是线性增益
Layer Mask静态障碍、车辆、行人别选地面和路牙,否则全是噪声
Decision Period5物理步长 0.02s 时相当于每 0.1s 决策一次

这里列的是一个起点值,不是最优值。射线太多会让观测维度从 35 涨到 60,反而拉慢训练;射线太少又会在路口看不到侧面来车。我习惯先上 7 根,等训练稳定后再加。还有归一的习惯:所有速度除以最大速度,所有距离除以场景尺度,保证输入到神经网络的量级在 -1 到 1 附近。ML-Agents 的normalize配置虽然也能自动处理,但它对自定义的距离输入并不友好。

2.3 奖励函数怎么设:先分清“要学的事情”和“不要学的事情”

奖励设计是强化学习里最容易变成玄学的部分,因为模型真的会钻空子。我给自己定了一个原则:第一优先写硬约束,第二写导航引导,最后才写风格惩罚。硬约束包括碰撞即给负奖励并结束回合,超出区域同样结束;导航引导用“前进速度投影”这种稠密量;风格惩罚是用来防止模型只想着拿前进奖励,在原地画龙。

奖励项数值说明
朝目标方向前进+0.05 × 归一化速度稠密引导,让模型学会向前骑
到达目标点+1回合终止信号,只给一次
碰撞障碍物-0.8立即结束回合
离目标超过 50 米-0.5相当于走出地图的兜底惩罚
转向角长期超过 25 度-0.02抑制画龙和原地打转

这里最容易踩的坑是奖励量纲打架。比如碰撞是 -0.8,而前进奖励每帧只有 0.05,一个回合如果跑 200 帧,累计起来前进奖励约 10,碰撞惩罚根本压不住它。解决方法是做减法而不是加法:把朝目标前进的奖励改小,同时在没前进的时候给一个微弱的负奖励。模型天生的惰性会帮你去掉无效行为。

3. 用mlagents-learn启动训练:PPO配置、训练命令与盯盘方法

3.1 先给trainer写一份能落地的YAML配置

在 release 15 对应的 2.x 分支里,训练参数完全由 YAML 控制,trainer 默认用 PPO。很多人在这个文件里照抄默认值,结果训练出来的自行车动作一顿一顿。它不是写错代码,而是batch_size、buffer_size、time_horizon之间比例不对。

behaviors: BikeAgent: trainer_type: ppo hyperparameters: batch_size: 256 buffer_size: 4096 learning_rate: 3.0e-4 beta: 0.01 epsilon: 0.2 lambd: 0.95 num_epoch: 4 learning_rate_schedule: linear network_settings: normalize: true hidden_units: 256 num_layers: 2 vis_encode_type: simple reward_signals: extrinsic: strength: 1.0 gamma: 0.99 max_steps: 2000000 time_horizon: 128 summary_freq: 10000

buffer_size是经验池容量,batch_size是每次更新抽样的样本量,建议前者是后者的八到十六倍。beta是熵权重,控制探索程度。如果你是第一次跑,先别去调epsilon和lambd,这两个在 PPO 里默认值已经够稳。max_steps指的是总训练步数,单个自行车场景 200 万步能看到明显结果;如果场景里放了十几辆车,把 200 万改成 500 万,别妄想用 30 万步训出群体行为。

3.2 从命令行启动:训练编辑器还是训练打包出来的可执行文件

先要装好 Python 侧的工具,我用pip install mlagents安装,版本要和 Unity 里的com.unity.ml-agents对齐,不然连接时会报版本不匹配。对齐以后有两种跑法,第一次建议用编辑器直接跑,因为可以随时暂停改 prefab。

# 方式一:在 Unity Editor 里直接训练 mlagents-learn config/bike_ppo.yaml --run-id=bike_nav_v1 --train # 启动命令后,切回 Unity 编辑器点 Play,控制台会显示连接建立 # 方式二:打包成可执行文件后再训练,适合批量做参数实验 mlagents-learn config/bike_ppo.yaml --run-id=bike_nav_v2 --train --env=build/BikeSim.x86_64

编辑器训练开着很多调试 UI,性能会慢不少。一批实验跑通后,我习惯打包发布版去训练,--env后面跟上可执行文件路径。--run-id相当于实验名,所有日志都写到results/bike_nav_v1下。上次训练中断了,加--resume继续;想从一个成熟模型继续微调,用--initialize-from=旧run_id。注意同一 run_id 不能重复起,否则终端会报错,这时候改成bike_nav_v3或先清空目录。

3.3 训练时看什么:TensorBoard里几根曲线说明什么问题

训练跑起来以后,另一个终端执行tensorboard --logdir results/bike_nav_v1 --port 6006。我每次训练,最先看的是四个指标:Cumulative Reward、Entropy、Policy Loss、Value Loss。

指标健康表现异常情况
Cumulative Reward先波动,后逐步上升长时间贴着 0 不动,先怀疑奖励太稀疏
Entropy初始 1 附近,缓慢下降掉到 0.1 以下,策略过于死板,调大 beta
Policy Loss围绕 0.01~0.1 波动剧烈震荡且 reward 不涨,学习率调低
Value Loss逐步收敛忽高忽低,检查观测里是不是混入了 NaN

新手最容易把熵当坏人,看到它下降就紧张。其实熵下降说明策略在学习,真正要担心的是下降太快、最后变成一个重复动作。我处理这类问题时先加beta到 0.02,再观察;如果 reward 也跟着掉,再把beta降回 0.01。训练是黑匣子,一次只动一个变量,否则你根本不知道是哪个参数救了模型。

4. 让自行车群体在复杂环境中学会避让:多智能体强化学习的训练配置

4.1 多个自行车共享一套策略还是各训各的

交通场景里基本不可能只放一辆自行车,标题里说的“自行车群体”才是难点。多智能体强化学习在这套系统里的实现方式比想象中简单:只要所有自行车挂同一个 Behavior Name,ML-Agents 就会把它们当作同策略的智能体,共享同一个网络参数。换句话说,你训练出来的是一个通用的“骑行决策脑”,然后复制到每一辆车上,让它们各自跑。

如果给不同车辆设不同 Behavior Name,比如外卖骑手和通勤骑手,它们的经验池就分开了,各训各的模型。这在语义上更合理,但训练成本翻倍。我在第一版项目里一般让所有自行车同构,共享一个 Behavior。等模型稳定了,再在观测里加入“骑手类型”这个量,让一辆车学到不同风格,而不是从零再训一遍。用同一个 Behavior 还有一个隐藏优势:智能体之间天然共享经验,群体避让的学习速度会比互不干扰的独立环境快一些。

4.2 群体场景里的奖励设计:把“车的空间”写进状态

群体避让和静态避障本质区别是:别的自行车不是固定的障碍物,它们也会对你的动作做出反应。只靠障碍物射线不够,你必须在观测里加入周围邻居的信息。我一般取最近的 6 辆车,把它们在自车坐标系下的相对位置和相对速度压进向量观测,不足 6 个时补零。

public int maxNeighborsToObserve = 6; public override void CollectObservations(VectorSensor sensor) { var allBikes = FindObjectsByType<BikeAgent>(FindObjectsSortMode.None); List<(float dist, BikeAgent bike)> neighbors = new(); foreach (var other in allBikes) { if (other == this) continue; neighbors.Add((Vector3.Distance(other.transform.position, transform.position), other)); } neighbors.Sort((a, b) => a.dist.CompareTo(b.dist)); for (int i = 0; i < maxNeighborsToObserve; i++) { if (i < neighbors.Count) { // 转成本车坐标系,便于网络理解“他在我左边还是右边” Vector3 localPos = transform.InverseTransformPoint(neighbors[i].bike.transform.position); sensor.AddObservation(localPos / 10f); float relSpeed = neighbors[i].bike.currentSpeed - currentSpeed; sensor.AddObservation(relSpeed / maxDriveSpeed); } else { sensor.AddObservation(Vector3.zero); sensor.AddObservation(0f); } } }

奖励侧,我额外给两个信号:一个是“侵入他人空间”的惩罚,当邻居距离小于 2 米时,每帧扣分;另一个是“顶头对峙”的惩罚,当对方在自己前方且接近速度大于 0,而自车横向偏移不变时,每帧扣分。后一项非常关键,它专治两辆车面对面堵死谁也不让的僵局。没有这个信号,群体训练后期就会变成互相赌气,整体奖励曲线卡在一个平台上不去。

4.3 把训练难度从“路人甲”调到“早晚高峰”:课程式场景参数

复杂环境不是一上来就把车流密度拉满。ML-Agents 的 2.x 版本把一部分课程学习功能移到了 Python 侧,我的做法是写一个简单的 Python 包装器,按阶段调整场景参数。经典方案是每训完一个阶段,让车流密度提高一点,红绿灯周期缩短一点。

import subprocess, json, time for stage in range(1, 6): density = 0.1 * stage with open("scenario_params.json", "w") as f: json.dump({"density": density, "traffic_light_cycle": 15 - stage}, f) subprocess.run([ "mlagents-learn", "config/bike_ppo.yaml", "--run-id", f"bike_nav_v{stage}", "--resume", "--env", "build/BikeSim.x86_64", ]) time.sleep(10)

场景里的车辆数和红绿灯周期在 Unity 侧读取scenario_params.json来生成。这里要注意--resume的意义:每个阶段都是在上一个模型基础上继续训练,不是从零开始。课程式训练比直接上高峰车流效果好得多,因为模型在低密度时先把“怎么骑行”学会,后面才把注意力放到“怎么避让”。我自己见过太多一上来就放几十辆车的实验,跑了三百万步奖励都是负的,不是算法不行,是难度没有循序渐进。

5. 自行车机器人训练避坑清单:五个最容易翻车的地方

5.1 学了十万步还在原地转圈,奖励给了个寂寞

现象:训练曲线一直贴着零,偶尔有一两个回合出现正奖励,但整体就是涨不上去。

原因:目标点奖励设得太大,而中间缺少稠密引导。自行车随机晃到终点附近时拿到一次 +1,大部分徘徊行为是随机的,网络根本不知道哪些动作组合导致了好结果。

解决:把单次 +1 拆成多个小奖励:每靠近目标一定距离就给一次“接近增量”,同时把无效徘徊设置成微小负奖励。模型会更早意识到“往前走”这个动作的因果联系,训练曲线起来的就会早得多。

5.2 路口明明有车,射线就是检测不到

现象:训练时自行车直线撞向障碍物,仿佛完全看不见,但手动控制模式下能轻松避开。

原因:RayPerceptionSensor 的 Layer Mask 没选对,或者射线从刚体中心射出,但刚体中心被车身模型挡住了。还有可能是射线探测距离太短,到 5 米内才感知,紧急刹车来不及。

解决:先在场景里打开射线可视化调试,逐帧确认射线真的命中目标。Layer Mask 只勾选障碍物、车辆、行人。射线组件的起点放在底盘中心,不要放在车座或车把位置。我习惯给路面单独设一层并把该层从 ray mask 排除,否则地面会在很近距离内产生无效命中。

5.3 训练速度慢到怀疑人生:观测维度和batch互相顶着

现象:GPU 占用不高,但每小时只能推进几万步,训练曲线迟迟不出来。

原因:观测维度被堆得太高,射线也上了几十根。更常见的是batch_size和buffer_size设得不匹配,比如 buffer_size 设 100 万,而 batch_size 只有 128,每轮更新都要从巨大的 buffer 里采样,数据搬运花费的时间远超计算时间。

解决:先把观测砍到必要的量,射线 7 根最多 9 根。batch_size调成 buffer 的十六分之一左右,buffer_size控制在 4096 到 8192。再检查是不是开了过多的 Unity 调试 UI,训练时把 Game 视图的解析度调低,或者直接用打包出来的 exe 训练。

5.4 群体里两辆自行车互相顶住,谁也不让

现象:训练后期,两辆车面对面停住,用鼠标看它俩就是不转弯。奖励曲线停在一个低水平不涨。

原因:碰撞惩罚只在碰撞发生时生效,但两辆车保持安全距离对峙时,谁动谁吃亏,两者的惩罚梯度相同,模型学到的策略就是“不动”。

解决:给“相对前进步”加惩罚,具体可以在奖励里判断:若最近邻居距离变近且自车横向速度接近零,就扣分。更有效的是把碰撞判定改成双方各扣分扣得重一些,并且追加“超过多少步还没越过中点”的回合级惩罚。这样模型会发现“一直堵着”比“绕一步走”更亏,就会开始寻找绕行路径。

5.5 编辑器里跑得好好的,导出模型后一运行就发疯

现象:训练模式下一切都好,把.onnx模型放进 Behavior Parameters 的 Model 字段后,自行车开始画龙或直冲障碍。

原因:推理时观测输入和训练时不一致。比如训练时传感器顺序是“射线 + 向量”,导出后你把行为参数里的传感器顺序改了;或者向量观测里混进了 Debug 用的数据;又或者normalize:true在训练侧做了归一化,但推理时模型没有拿到对应参数。

解决:保持行为参数的观测配置完全一致,改观测前先看代码里 AddObservation 的顺序。训练完成后把.onnx放入 Unity,然后用和训练完全相同的场景设置跑一遍。如果编辑器内正常,只有打包后异常,检查是不是通过可执行文件启动时把--num-envs设成了多个导致时间步不同步。我会在项目里养一个习惯:每次改动观测就改 Behavior Name,比如从BikeAgent改成BikeAgent_v2,从根上杜绝旧模型被误用。

6. 验证一个“会自主导航”的自行车模型:评估场景与落地扩展

6.1 用固定种子跑一轮场景矩阵,而不是肉眼盯着看

训练完成后别急着说“模型会自主导航”。我一般固定随机种子,准备四类评估场景:空旷直行、城市路口、密集车流、雾天低能见度。每种场景下跑 20 个回合,统计三个指标:任务完成率、平均碰撞次数、平均骑行时间。这个场景矩阵要和训练场景稍微有差别,否则你只是在验证模型背题,而不是验证泛化。

# 评估模式:不更新策略,只记录 rollout 的分值 mlagents-learn config/bike_ppo.yaml \ --run-id=bike_nav_eval \ --resume \ --env=build/BikeSim_Eval.x86_64 \ --num-envs=4

--resume用来加载已经训好的模型,去掉--train后训练器只采样不更新策略。如果曲线和训练时差不多,说明模型泛化还可以。我更推荐把统计逻辑直接写进 Unity 侧的自定义脚本,在每个回合结束时分门别类地写 CSV。这样评估不依赖训练器的日志,将来接实体车也可以直接用同一套脚本评估。

6.2 从仿真系统走到机器人本体的三个扩展方向

仿真系统做出来不是只为了看一眼动画。第一个扩展是把训练好的策略导出成.onnx,接到 ROS 小车或自行车机器人实体上做 sim-to-real。常见做法是在训练阶段加入 domain randomization:把车身质量、路面摩擦、传感器噪声全部随机化,让模型对真实世界的差异不再敏感。第二个扩展是把多智能体强化学习的结论用到交通流控制上,比如统计不同车流密度下群体的平均拥堵时长,反推红绿灯配时方案;这个方向比单车避障更有研究价值。第三个扩展是把它做成一个可复用的仿真测试平台,用来验证其他控制算法,比如 MPC 或传统路径规划在同样复杂交通环境里的表现,让强化学习模型当对照组。

我自己做这类项目时养成的习惯是:每改一次观测或奖励,就截图留下一版 TensorBoard 曲线。两个月后想反悔,全靠run_id和这些截图找回现场。奖励结构不能三天两头乱改,一次只改一个变量;改完奖励就用同一组随机种子重新评估,如果完成率变差,说明这次改动引入的复杂度比收益大,果断撤销。训练是黑匣子,但你的实验记录可以让它不再装神弄鬼。希望帮到你。

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

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

MicroPython+FreeRTOS在STM32上的系统级移植与协同设计

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

作者头像 李华
网站建设 2026/10/2 17:45:43

LeetCode 11题盛水最多容器:双指针算法详解与面试攻略

1. 先读懂题目&#xff1a;这道题到底在问什么如果你准备 Java 开发岗面试&#xff0c;LeetCode 第 11 题“盛水最多的容器”几乎是绕不开的一道题。它看起来简单&#xff0c;但真正能一次讲清楚的人不多。题目原文是给一个非负整数数组height&#xff0c;每个元素代表坐标(i, …

作者头像 李华
网站建设 2026/10/2 17:40:44

DRV8818与MK24FN1M0步进驱动实战:从原理到调试全解析

上一台三轴自动化设备调完&#xff0c;我把驱动方案定在了DRV8818PWPR和MK24FN1M0VDC12这套组合上。做工业设备和机器人控制的同行应该都知道&#xff0c;双极步进电机的驱动方案看起来简单——一个H桥、一组脉冲——但真要跑到高速不丢步、负载变化不发热、现场干扰不误动作&a…

作者头像 李华
网站建设 2026/10/2 17:39:25

ESP32-P4NRW32X深度解析:RISC-V双核与32MB PSRAM如何重塑嵌入式开发

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

作者头像 李华