MindSpore自定义算子接入训练:func_type与bprop配置详解

1. 先从一次报错说起:前向通过后,梯度到底从哪里来

1.1 一次典型的联调现场

前阵子帮一个团队排查自定义算子的训练问题,场景很典型:业务需要在昇腾设备上跑一个融合算子,用 Ascend C 写好了 kernel,单独调用前向结果和官方算子对比完全一致,误差在允许范围内。结果把这个算子通过mindspore.ops.Custom包装塞进训练图之后,前几步 loss 还正常下降,第几步突然报出一串形状不匹配的异常,或者更隐蔽一点——loss 开始乱跳,精度对不上。

这类问题十有八九不是 Ascend C 算子本身写错了,而是Custom原语的两个配置没有对齐:一个是func_type,一个是bprop。前者决定框架怎么找到并加载你的算子实现,后者决定框架在反向传播时拿什么来计算梯度。算子本身只是个“前向函数”,训练框架不会魔法般地自动知道它的导数,尤其是自定义算子。

1.2 为什么自定义算子必须单独配置 bprop

MindSpore 在构图时有一套自动微分机制,对内置算子可以直接套用已经预置好的反向规则。但Custom原语对框架来说是一个黑盒,它既不知道你算子内部做了什么运算,也没有内置的反向梯度定义。所以你必须通过bprop参数显式告诉框架:给定算子输入、前向输出、输出梯度,应该返回什么样的输入梯度。

有同学问过:能不能在 Ascend C 里同时实现一个“反向算子”,然后在bprop里调用它?当然可以,这也是高性能场景下比较推荐的做法——反向同样在设备上执行,避免 Python 侧组合算子带来的调度开销。但这里有个前提:你必须先保证Custom的接入方式正确,否则你写的反向算子根本没机会被执行。

1.3 先把问题的责任边界划清楚

排查这类问题,我通常会先做一个二分定位:

  • 断开bprop,只跑前向。如果前向结果稳定且正确,说明算子编译、shape、dtype 推理基本没问题。
  • 接入一个最简单的bprop(比如对恒等算子返回dout本身),如果反向能跑通,说明接入链路没问题,问题出在梯度数学实现上。
  • 如果接入bprop后连图编译都过不去,那大概率是func_type和算子交付形式不匹配,或者bprop的函数签名不符合框架约定。

这个分工很关键。很多时候大家一上来就怀疑 Ascend C 的实现写错了,翻来覆去看 kernel 代码,浪费大量时间。实际上只要按这个顺序拆,问题很快就能收敛到具体层面。

2. func_type 怎么选:aot、akg、customize 的适用边界

2.1 先搞清楚这几种类型到底在说什么

Custom原语的func_type参数,官方文档里列了"akg"、"aot"、"tvm"、"hybrid"、"customize"这几种。很多同学第一次接触时容易晕,因为看起来都是“自定义算子”,但底层机制差别很大。

表格整理如下:

func_type传入内容框架如何处理典型使用场景
akg一个 Python 函数或源码字符串交给 AKG 编译器做算子编译用 MindSpore 内置 DSL 快速写简单算子
tvmTVM 的计算描述或调度交给 TVM 编译熟悉 TVM 的团队做快速验证
aot一个编译好的.so文件路径直接加载二进制,运行时不再编译用 Ascend C、CUDA C++ 等写好的高性能算子
hybrid混合脚本字符串框架解析脚本并编译介于 DSL 和底层代码之间的场景
customize已注册的自定义算子包函数通过算子注册机制匹配算子已经注册到框架的算子库中

对 Ascend C 算子来说,绝大多数情况你应该选aot。这不是唯一选择,但一定是最稳妥的。

2.2 为什么 Ascend C 算子首推 aot

Ascend C 算子的交付形态,通常是一份包含 kernel 实现和 host 侧 tiling 逻辑的代码,通过编译器编成.so文件。你拿到手的是一个二进制产物,里面已经固化了数据分块、tiling 策略、内存分配这些逻辑。

这种情况下,func_type="aot"是最贴合实际的设计。框架只需要在运行时用dlopen加载这个.so,按约定好的接口去查找并调用算子入口,完全不涉及源码级别的解析或二次编译。

有些同学会尝试把 Ascend C 算子源码包装成一个 Python 函数,然后func_type="akg"传进去。这个方案对简单的表达式算子可能有效,但一旦你的算子用了复杂的 tiling 策略、多核调度、或者依赖自定义的TPipe流程,AKG 编译器很难正确处理。我见过太多因为强行走 akg 路线导致生成代码异常、访存越界、结果随机错误的 case,最后都老老实实换回 aot 编译产物。

2.3 func_type 填错时最常见的两种现象

这里分享一下我实际排查中遇到的错误现象,方便大家对照:

第一种,把 aot 的.so路径传给了func_type="akg"。框架会把字符串当作 AKG 源码或模块名去处理,通常会报类似NameError、cannot find module或invalid syntax的错误。你一看报错位置在编译前端,就知道是类型搞错了。

第二种,把 aot 模块名填成了.so文件的相对路径。昇腾设备执行环境下,当前工作目录很可能和你编译时的目录不一致。框架报Invalid AOT function或者加载失败时,先检查路径是否是绝对路径,以及设备环境上是否有该文件的读权限。这个错误很隐蔽,因为你本地跑得很顺利,一上训练环境就挂。

还有一种稍少见的情况:func_type="customize"。这个类型要求你的算子已经通过算子注册接口写进框架的算子库,Custom传入的只是一个注册名或函数引用。对 Ascend C 开发起步阶段的团队,我一般不建议用 customize,因为你要额外维护注册表信息、shape/dtype 推导逻辑,复杂度远大于直接丢一个.so给 aot。

3. bprop 函数编写:签名、layout 与梯度回传的细节

3.1 bprop 的入参顺序与会返回约定

弄清func_type之后,重心就落在bprop上。这个函数的签名有严格约定,我先把规则说清楚。

bprop接收三类参数,顺序是:

  1. 前向算子的全部输入,按位置依次传入;
  2. 前向算子的输出;
  3. 输出梯度dout。

返回值必须是一个 tuple,元素的个数与前向输入个数一致,顺序也一一对应。即使只有一个输入,也要写成(grad_x,),绝对不能直接返回一个 Tensor。

举例,假设你的前向算子定义为y = f(x, scale),那么 bprop 函数应该是:

def bprop(x, scale, out, dout): # 计算对 x 的梯度 grad_x = ... # 计算对 scale 的梯度 grad_scale = ... return (grad_x, grad_scale)

有些开发者会把out当梯度用,或者在返回时忘了加逗号,导致框架解包失败。这些都属于低级错误,但出现频率极高。

3.2 梯度怎么算:从数学表达到算子实现

bprop内部不限制你用 MindSpore 原生算子写,还是再调用一个 Ascend C 算子。关键是把数学表达式的偏导关系写对。

举一个最简单的例子:y = x + bias。

这个算子的偏导很简单:dy/dx = 1,dy/dbias = 1。所以bprop写成:

def bprop(x, bias, out, dout): return (dout, dout)

注意,dout是输出梯度,它的 shape 和前向输出out完全一致。因为加法对输入形状不敏感,所以梯度直接透传即可。

再举一个带参数的:y = x * scale。

def bprop(x, scale, out, dout): grad_x = dout * scale grad_scale = (dout * x).sum() return (grad_x, grad_scale)

这里有两个细节:scale如果是标量,梯度要以 reduce_sum 的形式回传;scale如果和x同 shape,则不需要 sum。具体要不要聚合,取决于你的前向算子定义里参数是否参与了广播运算。

3.3 out_dshape 与 out_dtype 不匹配的连锁反应

Custom在构造时还需要指定out_dshape和out_dtype。这两个参数描述了前向输出的形状和数据类型,框架在构图阶段会拿来推导后续算子的 shape 和 dtype。

我遇到过一个特有意思的坑:前向算子跑出来的值和预期一致,但out_dshape写错了。结果反向传播时,dout的 shape 和out对不上,bprop里做乘法直接报 shape mismatch。

所以我的建议是:把out_dshape和out_dtype当作对外承诺的一部分,必须和 Ascend C kernel 真实的行为保持一致。你可以先不接bprop,把前向输出打印出来,确认 shape 和 dtype 之后,再按这个结果填参数。千万不要“估摸着”填,最后坑的一定是反向阶段。

还有一个容易被忽略的问题:dout的 dtype。在混合精度训练场景下,网络前向可能是 FP16,但你的 Ascend C 算子内部计算以 FP32 为主,输出也可能被框架转换为 FP16。此时dout传进bprop时,dtype 可能和你预期不一致。稳妥的做法是在bprop里显式做dout.astype(ms.float32),计算完梯度后再转回去,避免 dtype 不匹配导致的隐式截断误差。

4. 一次完整的联调验证:从算子源码到梯度对比

4.1 准备一个最小的 Ascend C 算子原型

理论说再多,不如跑一遍完整链路。这里我们用一个虚构的简单算子FusedAddScale来做演示,它做的事情是:

y = (x + bias) * scale

Ascend C 侧的 kernel 代码我在这里不完整展开,因为不同版本的工具链接口有差异,核心流程是:定义 kernel 类、重写Process方法、在 host 侧实现 tiling 数据准备。交付产物是一个编译好的.so文件,假设路径为/data/custom_ops/libfused_add_scale.so。

如果你是从零开始编译,记得在 CMake 里链接好 Ascend C 的运行时库,并指定-ascend-platform=...之类的目标平台参数。编译完以后,先用一个小程序直接调用 so 里的算子接口,确认前向结果正确,再做 MindSpore 接入。

4.2 Python 侧接入 Custom 算子与 bprop

Python 侧接入的代码大致如下:

import mindspore as ms import mindspore.ops as ops from mindspore.ops import Custom def fused_add_scale_bprop(x, bias, scale, out, dout): # y = (x + bias) * scale # dy/dx = scale # dy/dbias = scale # dy/dscale = x + bias grad_x = dout * scale grad_bias = dout * scale grad_scale = dout * (x + bias) return (grad_x, grad_bias, grad_scale) custom_op = Custom( "/data/custom_ops/libfused_add_scale.so", func_type="aot", out_dshape=[8], out_dtype=[ms.float32], bprop=fused_add_scale_bprop )

注意out_dshape和out_dtype都用了列表形式,因为 Custom 支持多输出,虽然这里只有一个输出,也保持统一的写法。bprop接收的参数个数,正好是前向输入的个数加上out和dout。

4.3 梯度校验脚本怎么写

接入完成以后,千万不要直接丢进训练网络看 loss。先做一次孤立的梯度校验。

用 MindSpore 的grad接口,对同一个计算过程同时做“自定义算子梯度”和“等效原生算子表达式梯度”,然后对比数值:

import mindspore as ms import mindspore.ops as ops from mindspore import grad @ms.jit def custom_forward(x, bias, scale): return custom_op(x, bias, scale) @ms.jit def native_forward(x, bias, scale): return (x + bias) * scale grad_custom = grad(custom_forward, grad_position=(0, 1, 2)) grad_native = grad(native_forward, grad_position=(0, 1, 2)) x = ms.Tensor([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0], ms.float32) bias = ms.Tensor([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], ms.float32) scale = ms.Tensor([2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0], ms.float32) gc = grad_custom(x, bias, scale) gn = grad_native(x, bias, scale) for i, (c, n) in enumerate(zip(gc, gn)): print(f"input_{i}, custom_grad: {c.asnumpy()}, native_grad: {n.asnumpy()}")

这里有一个容易踩的坑:grad_position必须指向前向函数的所有输入,如果你少写了某个索引,bprop里对应输入的梯度根本不会被触发,但框架不会报错,只会默默忽略。结果就是你对比了半天,以为梯度一致,其实某个参数的梯度压根没被求过。

校验时除了对比数值是否一致,还要关注梯度的 shape。比如scale原本是 shape 为[8]的向量,但你的数学定义如果把它当标量使用,那么梯度应该是标量,而不是向量。写bprop的时候要清楚自己在代码里定义的数学语义到底是什么。

5. 训练落地时容易被忽略的坑

5.1 多卡环境下的 so 路径与编译缓存

在实际训练任务里,很少有单卡跑完的场景。多卡训练时,每个进程都要能加载同一个.so文件。这里最典型的坑是相对路径问题。

你的编译脚本如果写了相对路径,比如./build/libxxx.so,在单卡调试时没问题,一旦放到多卡分布式拉起脚本里,工作目录可能已经变了,框架在设备侧找不到文件。建议在所有代码里用绝对路径,或者在环境变量里统一注入算子库根目录,再在 Python 侧拼出完整路径。

另一个和编译相关的问题是缓存。MindSpore 对aot算子会在内存里缓存已加载的.so,如果你在调试过程中重新编译了算子、改了 kernel 逻辑,但文件名没变,可能会出现“改了代码但训练结果没变化”的诡异现象。遇到这种请先确认加载的.so的修改时间,或者干脆在编译脚本里把产物名加上版本号,每次调试用新文件名,避免缓存干扰。

5.2 混合精度训练下的梯度 dtype 陷阱

昇腾设备上混合精度训练是常态。你的 Ascend C 算子内部可能用 FP32 累加,对外输出 FP32,但训练图里 Inserted 了一些 Cast 节点,导致传到bprop的dout是 FP16。

如果bprop里直接用这个 FP16 的dout去和 FP32 的输入做乘法,框架会做隐式类型提升,看起来没问题,但精度会有损耗。损失函数的梯度经过多层反向传播之后,微小误差会被放大。

我的习惯是:在bprop入口处,先按前向输入的数据类型做一次明确的dout对齐。比如前向输入x是 FP32,就执行dout = dout.astype(ms.float32),把后续所有计算都统一在 FP32 域内完成。如果最终梯度需要返回给下游 FP16 算子,框架会自动再插入 Cast,不需要你在bprop里操心。

5.3 关于 bprop 到底该用原生算子还是 Ascend C 反向算子

这个问题我被问过很多次。性能敏感的场景,我推荐你在bprop内部再调用一个编译好的 Ascend C 反向算子;开发和验证阶段,用 MindSpore 原生算子组合即可。

原因很简单:训练性能瓶颈往往在反向计算上,如果反向部分用 Python 组合原生算子,每一步都要经过框架的调度、内存分配,开销比设备上直接执行一个融合反向算子大不少。但反过来,反向算子的开发成本也不低,你需要额外维护一份 Ascend C 代码、编译流程、精度测试。

我通常的做法是:先写一个纯 Python 组合的bprop,保证功能正确,把整条训练链路跑通。然后 profile 一下算子耗时,如果发现反向确实占比比较高,再决定要不要用 Ascend C 重写反向算子,并用前向同款流程接入Custom。

5.4 调试时最实用的一个小技巧

最后分享一个我一直在用的调试技巧:不要直接看 loss 曲线,loss 下降不等于梯度正确,loss 异常也不一定能指向具体哪个算子。更有效的办法是,在bprop返回之前,用一个临时调试标志把梯度值打到日志里,和理论梯度做一轮离线对比。

比如你可以这样写:

def bprop(x, bias, scale, out, dout): grad_x = dout * scale grad_bias = dout * scale grad_scale = dout * (x + bias) if debug_flag: print("grad_x:", grad_x.sum()) print("grad_bias:", grad_bias.sum()) print("grad_scale:", grad_scale.sum()) return (grad_x, grad_bias, grad_scale)

只要打印梯度的全局汇总数值,就能快速判断反向是否合理。一旦发现梯度和理论值对不上,再缩小范围,用上一节的梯度对比脚本定位。这个习惯帮我省下了大量排查时间,尤其是多个自定义算子叠加在同一个网络里的时候,它能直接告诉你究竟是哪个算子污染的梯度。

相关新闻

gamdl 配置调优实战:下载路径、视频分辨率、音频编码一行命令搞定

gamdl 配置调优实战:下载路径、视频分辨率、音频编码一行命令搞定

gamdl 配置调优实战:下载路径、视频分辨率、音频编码一行命令搞定 【免费下载链接】gamdl A command-line app for downloading Apple Music songs, music videos and post videos. 项目地址: https://gitcode.com/GitHub_Trending/ga/gamdl gamdl 是一个下载…

2026/10/21 2:55:03 阅读更多 →
scrcpy 完整指南:数据线一插,1 秒出画面直控手机

scrcpy 完整指南:数据线一插,1 秒出画面直控手机

scrcpy 完整指南:数据线一插,1 秒出画面直控手机 【免费下载链接】scrcpy Display and control your Android device 项目地址: https://gitcode.com/GitHub_Trending/sc/scrcpy USB 一连,scrcpy 把手机画面送进电脑窗口只需 1 秒&…

2026/10/21 2:55:02 阅读更多 →
PyTorch+LSTM实现股票多步预测:从数据清洗到实战部署全解析

PyTorch+LSTM实现股票多步预测:从数据清洗到实战部署全解析

简介:基于PyTorch的LSTM多变量多步股票预测完整工程包,面向有深度学习基础、希望将时间序列模型应用到金融场景的开发者。压缩包共11个文件,以Python主程序为核心,附带600519股票Excel数据、项目配置XML、运行结果图等&#xff0c…

2026/10/21 2:54:02 阅读更多 →

最新新闻

MALA幅度感知线性注意力如何高效改造YOLO26

MALA幅度感知线性注意力如何高效改造YOLO26

最近在忙目标检测模型的落地优化,把精力主要放在了YOLO26的注意力机制改造上。试过不少主流的注意力模块,要么计算量上去了推理变慢,要么就是涨点不明显,白折腾。后来读到一篇当时挂在学术预印平台上的新工作,核心是提…

2026/10/21 3:33:25 阅读更多 →
Kettle ETL实战:为什么老工具在混乱数据场景中依然不可替代

Kettle ETL实战:为什么老工具在混乱数据场景中依然不可替代

1. 为什么今天还在用Kettle做ETL?一个被低估的“老派”工具的真实价值很多人看到“Kettle”这个词,第一反应是:这玩意儿不是十年前就该淘汰了吗?现在不都上Airflow、Dagster、dbt,或者直接云厂商的DataFlow、Glue、Dat…

2026/10/21 3:33:25 阅读更多 →
用pgzrun零基础实现克隆人游戏:Python游戏开发入门实践

用pgzrun零基础实现克隆人游戏:Python游戏开发入门实践

1. 项目概述:为什么一个“克隆人”游戏值得用pgzrun重做?最近在某高校的编程入门工作坊里,我带学生做了一个小项目——用Python复刻经典街机游戏《阿肯色克隆人》(Arkansas Clone),但没碰Pygame原生API&…

2026/10/21 3:33:25 阅读更多 →
MapLibre Native 地图样式切换指南:3 种方式让地图不重启应用就换外观

MapLibre Native 地图样式切换指南:3 种方式让地图不重启应用就换外观

MapLibre Native 地图样式切换指南:3 种方式让地图不重启应用就换外观 【免费下载链接】maplibre-native MapLibre Native - Interactive vector tile maps for iOS, Android and other platforms. 项目地址: https://gitcode.com/GitHub_Trending/ma/maplibre-na…

2026/10/21 3:33:25 阅读更多 →
REA 逆向工程快速上手:把 REA 装进 AI 编码助手,跑通第一次二进制分析

REA 逆向工程快速上手:把 REA 装进 AI 编码助手,跑通第一次二进制分析

REA 逆向工程快速上手:把 REA 装进 AI 编码助手,跑通第一次二进制分析 【免费下载链接】rea Reverse engineer anything with agents, from app behavior down to native binaries. 项目地址: https://gitcode.com/GitHub_Trending/rea2/rea REA&…

2026/10/21 3:33:25 阅读更多 →
51单片机贪吃蛇实战:12864驱动、EEPROM掉电保存与嵌入式分层设计

51单片机贪吃蛇实战:12864驱动、EEPROM掉电保存与嵌入式分层设计

简介:本资源是一套面向大学本科课程设计的51单片机贪吃蛇游戏完整开发方案,适用于单片机原理与应用、嵌入式系统实践等课程学习与项目实训。资源包含Proteus仿真工程(.pdsprj)、Keil C51源码(.c/.a51)、编译…

2026/10/21 3:32:25 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事:AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里,按宣传口径,本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”,而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起:用户到底在追求什么“封号重开”这四个字,放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流,背后是大量的时间和精力成本。正常情况下,没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”,而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时,就意识到:单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/20 9:50:45 阅读更多 →