MaxText+Ray+TPU Trillium实现LLM弹性训练:秒级恢复与成本优化
如果你正在训练一个需要数天甚至数周的大型语言模型突然因为TPU节点被抢占或故障导致训练中断你会怎么做传统方案可能需要从头开始训练或者花费数小时手动恢复检查点。但Google的MaxText框架结合Ray Train和TPU Trillium实现了真正意义上的弹性训练——训练中途终止TPU节点数秒内就能自动恢复。这不仅仅是技术上的小改进而是从根本上改变了大规模模型训练的容错模式。想象一下你可以在成本更低的Spot虚拟机上运行训练即使节点被抢占系统也能自动从最新检查点恢复无需人工干预。这种能力对于训练Llama 3 70B等超大规模模型尤为重要因为训练周期长、资源成本高任何中断都意味着巨大的时间和金钱损失。1. 弹性训练的真正价值从怕中断到无视中断传统分布式训练面临的最大挑战之一就是容错性。当你在多个TPU切片上运行训练时任何一个节点的故障都可能导致整个作业失败。更糟糕的是恢复过程往往需要手动干预重新分配资源、重新分片检查点、重新启动训练流程。这个过程可能需要数小时对于按小时计费的TPU资源来说成本惊人。MaxText的弹性训练通过三个关键技术组件解决了这个问题自动检查点管理系统会定期将模型状态保存到共享存储确保任何时候中断都能从最近的有效状态恢复。与手动检查点不同这个过程对用户完全透明。动态设备网格重计算当TPU切片数量发生变化时MaxText会自动重新计算设备网格布局无需用户编写自定义逻辑来处理拓扑变化。这意味着从16个芯片缩容到8个芯片或者从8个芯片扩容到16个芯片都能无缝处理。Ray Train的弹性调度Ray负责监控工作器状态当检测到节点变化时会自动暂停训练、等待资源重新就绪然后从检查点恢复。整个过程完全自动化用户只需要设置最小和最大工作器数量。这种能力的实际价值在于你可以放心使用成本更低的Spot实例进行长时间训练。据统计Spot实例的成本通常比按需实例低60-90%但可能有随时被抢占的风险。有了弹性训练这种风险变成了可控的成本优化机会。2. 技术栈深度解析为什么是MaxText Ray TPU的组合2.1 MaxText专为TPU优化的高性能LLM框架MaxText是Google开源的专门针对TPU优化的大型语言模型训练框架。与通用深度学习框架不同MaxText从设计之初就考虑了TPU的架构特性# MaxText的核心设计理念体现在其配置方式上 model_name: llama3-70b per_device_batch_size: 2 max_target_length: 4096 # 并行策略配置 ici_tensor_parallelism: 4 # 芯片内张量并行 ici_fsdp_parallelism: 4 # 芯片内全分片数据并行 dcn_fsdp_parallelism: 2 # 跨切片全分片数据并行 dcn_data_parallelism: 1 # 跨切片数据并行这种细粒度的并行配置允许MaxText在TPU集群上实现近乎线性的扩展效率。更重要的是MaxText内置了对动态设备网格的支持这是实现弹性训练的基础。2.2 Ray Train分布式训练的编排层Ray Train提供了一个抽象的分布式训练接口使得相同的代码可以在不同规模的集群上运行。对于弹性训练关键配置在于from ray.train.v2.api.config import ScalingConfig, RunConfig, FailureConfig scaling_config ScalingConfig( use_tpuTrue, num_workers(4, 8), # 弹性扩缩范围4-8个工作者 topology4x4, accelerator_typeTPU-V6E, resources_per_worker{TPU: 4}, placement_strategySPREAD, ) run_config RunConfig( failure_configFailureConfig(max_failures3) # 最大重试次数 )num_workers(4, 8)这个配置告诉Ray最少需要4个工作器1个TPU切片最多可以使用8个工作器2个TPU切片。当资源可用时Ray会自动扩展到上限当资源被抢占时Ray会收缩到下限并继续训练。2.3 TPU Trillium第六代TPU的架构优势TPU v6eTrillium相比前代产品在弹性训练方面有显著改进更高的内存带宽支持更大模型的训练减少由于内存不足导致的中断改进的芯片间互联降低多切片通信开销使弹性扩缩的性能损失最小化增强的虚拟化支持更好地适应云环境的动态资源调度3. 环境准备与集群配置3.1 项目初始化与权限配置在开始之前需要确保具备适当的Google Cloud权限和配额# 设置环境变量 export PROJECT_ID$(gcloud config get project) export PROJECT_NUMBER$(gcloud projects describe ${PROJECT_ID} --formatvalue(projectNumber)) export CLUSTER_NAMEmaxtext-cluster export REGIONus-central2 # 选择有TPU v6e容量的区域 export ZONEus-central2-b # 启用必要API gcloud services enable container.googleapis.com cloudbuild.googleapis.com # 检查TPU配额 gcloud compute regions describe $REGION --formatvalue(quotas.metric,quotas.limit)3.2 配置多切片网络基础设施弹性训练的关键在于跨TPU切片的高速网络连接。传统的Kubernetes网络无法满足TPU多切片之间的通信需求需要配置专用的数据中心网络DCN# 创建高性能VPC网络 gcloud compute networks create ${CLUSTER_NAME}-net-1 \ --subnet-modecustom \ --mtu8896 # 大MTU提升网络性能 gcloud compute networks create ${CLUSTER_NAME}-net-2 \ --subnet-modecustom \ --mtu8896 # 创建专用子网 gcloud compute networks subnets create tpu-subnet-1 \ --network${CLUSTER_NAME}-net-1 \ --region$REGION \ --range10.50.0.0/16 gcloud compute networks subnets create tpu-subnet-2 \ --network${CLUSTER_NAME}-net-2 \ --region$REGION \ --range10.60.0.0/163.3 创建支持DRANET的GKE集群GKE的动态资源分配网络DRANET允许Pod直接访问高性能网络设备绕过标准的Kubernetes网络栈# 创建Autopilot集群推荐用于全托管体验 gcloud container clusters create-auto $CLUSTER_NAME \ --enable-ray-operator \ --machine-typen1-standard-16 \ --location$REGION \ --cluster-version1.35.2-gke.1842000 # 配置kubectl访问 gcloud container clusters get-credentials $CLUSTER_NAME --location$REGION对于需要更精细控制的场景可以使用Standard模式# 创建Standard集群 gcloud container clusters create $CLUSTER_NAME \ --addonsRayOperator,GcsFuseCsiDriver \ --machine-typen1-standard-16 \ --enable-dataplane-v2 \ --workload-pool$PROJECT_ID.svc.id.goog \ --location$ZONE \ --cluster-version1.35.2-gke.18420003.4 配置TPU节点池创建专门为多切片训练优化的TPU节点池# 第一个TPU切片节点池 gcloud container node-pools create v6e-slice-0 \ --location$ZONE \ --cluster$CLUSTER_NAME \ --machine-typect6e-standard-4t \ --threads-per-core1 \ --tpu-topology4x4 \ --num-nodes4 \ --additional-node-networknetwork${CLUSTER_NAME}-net-1,subnetworktpu-subnet-1 \ --additional-node-networknetwork${CLUSTER_NAME}-net-2,subnetworktpu-subnet-2 \ --node-labelscloud.google.com/gke-networking-dra-drivertrue \ --enable-gvnic \ --scopeshttps://www.googleapis.com/auth/cloud-platform # 第二个TPU切片节点池用于弹性扩缩 gcloud container node-pools create v6e-slice-1 \ --location$ZONE \ --cluster$CLUSTER_NAME \ --machine-typect6e-standard-4t \ --threads-per-core1 \ --tpu-topology4x4 \ --num-nodes4 \ --additional-node-networknetwork${CLUSTER_NAME}-net-1,subnetworktpu-subnet-1 \ --additional-node-networknetwork${CLUSTER_NAME}-net-2,subnetworktpu-subnet-2 \ --node-labelscloud.google.com/gke-networking-dra-drivertrue \ --enable-gvnic \ --scopeshttps://www.googleapis.com/auth/cloud-platform4. 实现弹性训练的核心代码解析4.1 弹性训练脚本实现弹性训练的核心在于正确配置Ray Train的JaxTrainer。以下代码展示了如何实现容错和弹性扩缩# maxtext_elastic_trainer.py import os from absl import app import logging from typing import Sequence import ray from ray.train.v2.api.config import ScalingConfig, RunConfig, FailureConfig from ray.train.v2.jax import JaxTrainer def train_loop_per_worker(config): 每个工作器上运行的训练循环 import maxtext from maxtext.trainers.pre_train.train import main as maxtext_main # 从配置中获取参数并传递给MaxText argv config[argv] maxtext_main(argv) def main(argv: Sequence[str]): # 转换配置文件路径为绝对路径 argv list(argv) if len(argv) 1: argv[1] os.path.abspath(argv[1]) # 配置弹性训练器 trainer JaxTrainer( train_loop_per_workertrain_loop_per_worker, train_loop_config{argv: argv}, scaling_configScalingConfig( use_tpuTrue, # 关键配置定义工作器数量的弹性范围 # (4,8)表示最少4个工作者1个切片最多8个工作者2个切片 num_workers(4,8), topology4x4, accelerator_typeTPU-V6E, resources_per_worker{TPU: 4}, placement_strategySPREAD, # 尽可能分散工作器 ), run_configRunConfig( namemaxtext_elastic_trainer, # 容错配置最多重试3次 failure_configFailureConfig(max_failures3), worker_runtime_env{ uv: { packages: [maxtext[tpu]0.2.1], uv_pip_install_options: [--resolutionlowest] }, }, ), ) # 启动训练 result trainer.fit() logging.info(弹性训练完成) ray.shutdown() if __name__ __main__: app.run(main)4.2 RayCluster资源配置Kubernetes层面的资源配置通过RayCluster自定义资源实现# ray-cluster.elastic-tpu.yaml apiVersion: resource.k8s.io/v1 kind: ResourceClaimTemplate metadata: name: multi-slice-netdev spec: spec: devices: requests: - name: req-netdev exactly: deviceClassName: netdev.google.com allocationMode: ExactCount count: 2 # 每个Pod需要2个网络设备 --- apiVersion: ray.io/v1 kind: RayCluster metadata: name: maxtext-elastic-cluster spec: headGroupSpec: rayStartParams: {} template: metadata: annotations: gke-gcsfuse/volumes: true spec: serviceAccountName: ${KSA_NAME} containers: - name: ray-head image: rayproject/ray:nightly-py312-tpu imagePullPolicy: Always resources: limits: memory: 16Gi requests: cpu: 8 memory: 16Gi volumeMounts: - name: gcs-fuse-csi-ephemeral mountPath: /data workerGroupSpecs: - replicas: 2 # 2个副本每个副本4个主机 numOfHosts: 4 # 每个副本包含4个主机 groupName: tpu-worker-group rayStartParams: metrics-export-port: 8082 template: spec: serviceAccountName: ${KSA_NAME} resourceClaims: - name: netdev resourceClaimTemplateName: multi-slice-netdev containers: - name: ray-worker image: rayproject/ray:nightly-py312-tpu imagePullPolicy: Always resources: claims: - name: netdev limits: memory: 200G google.com/tpu: 4 # 每个工作器4个TPU芯片 requests: cpu: 8 memory: 200G google.com/tpu: 4 env: - name: MEGASCALE_NUM_SLICES value: 2 # 初始切片数量 - name: JAX_PLATFORMS value: tpu,cpu - name: ENABLE_PJRT_COMPATIBILITY value: true securityContext: privileged: true nodeSelector: cloud.google.com/gke-tpu-accelerator: tpu-v6e-slice cloud.google.com/gke-tpu-topology: 4x44.3 部署和验证Ray集群应用配置并验证集群状态# 替换环境变量并应用配置 envsubst ray-cluster.elastic-tpu.yaml | kubectl apply -f - # 检查Ray集群状态 kubectl get rayclusters maxtext-elastic-cluster # 预期输出 # NAME DESIRED WORKERS AVAILABLE WORKERS STATUS # maxtext-elastic-cluster 8 8 ready # 端口转发访问Ray仪表板 kubectl port-forward svc/maxtext-elastic-cluster-head-svc 8265:8265 # 验证节点可访问性 ray list nodes --address http://localhost:82655. 实战运行弹性训练作业5.1 准备训练配置下载MaxText基础配置文件并进行适当修改# 下载基础配置 curl -O https://raw.githubusercontent.com/google/maxtext/maxtext-v0.2.1/src/maxtext/configs/base.yml # 创建针对弹性训练的配置 cat elastic_config.yml EOF base_output_directory: /data/elastic-runs/ dataset_type: synthetic per_device_batch_size: 4 max_target_length: 4096 model_name: llama3-8b steps: 1000 ici_fsdp_parallelism: 4 ici_tensor_parallelism: 4 remat_policy: full run_name: elastic-demo-${RANDOM} save_period: 100 # 每100步保存检查点 EOF5.2 提交弹性训练作业使用Ray Job CLI提交训练作业ray job submit \ --address http://localhost:8265 \ --working-dir . \ --runtime-env-json {excludes: [ray-env, .git]} \ -- python maxtext_elastic_trainer.py \ elastic_config.yml5.3 模拟节点故障测试弹性恢复为了验证弹性训练的实际效果可以主动模拟节点故障# 查看当前运行的Pod kubectl get pods -l ray.io/node-typeworker # 随机终止一个工作器Pod模拟抢占 kubectl delete pod $(kubectl get pods -l ray.io/node-typeworker -o jsonpath{.items[0].metadata.name}) # 观察恢复过程 kubectl logs -f deployment/ray-operator -n ray-system在训练日志中你应该能看到类似以下的恢复信息(TrainController) Detected changes in cluster resources. Resizing worker group from 8 - 4 workers. (TrainController) Training paused waiting for minimum resources... (TrainController) Minimum resources available, restoring from checkpoint... (RayTrainWorker) Loading checkpoint from /data/elastic-runs/elastic-demo-12345/checkpoints/step-300 (RayTrainWorker) Checkpoint loaded successfully, resuming training...5.4 监控训练状态和资源使用使用Ray的监控工具跟踪训练进度# 查看训练进度 ray job status --address http://localhost:8265 [JOB_ID] # 监控资源利用率 ray memory --address http://localhost:8265 # 查看详细指标 ray metrics --address http://localhost:82656. 高级配置与性能优化6.1 检查点策略优化对于长时间训练合理的检查点策略至关重要# 在elastic_config.yml中添加检查点优化配置 checkpoint_type: orbax save_period: 100 keep_period: 5 # 只保留最近5个检查点 async_checkpointing: true # 异步保存减少训练停顿 checkpoint_timeout: 600 # 10分钟超时6.2 网络性能调优针对多切片通信优化网络参数# 网络优化配置 dcn_parallelism: 2 megascale_grpc_interface_prefixes: eth1,eth2,lo libtpu_init_args: | --xla_tpu_enable_async_collective_fusion_multiple_stepstrue --xla_tpu_overlap_compute_collective_tctrue --xla_enable_async_all_gathertrue6.3 内存使用优化对于大模型训练内存优化是关键# 内存优化配置 remat_policy: full per_device_batch_size: 2 # 根据可用内存调整 gradient_accumulation_steps: 1 optimizer: adamw weight_decay: 0.1 learning_rate: 0.00017. 常见问题与故障排查7.1 资源分配问题问题现象可能原因解决方案Pod一直处于Pending状态TPU配额不足检查配额gcloud compute regions describe $REGION无法创建网络设备DRANET配置错误验证节点标签cloud.google.com/gke-networking-dra-drivertrue工作器启动失败服务账号权限不足绑定Storage Object User角色到服务账号7.2 训练恢复失败# 检查检查点状态 kubectl exec -it [RAY_HEAD_POD] -- ls -la /data/elastic-runs/ # 查看详细错误日志 kubectl logs [TRAINER_POD] | grep -i checkpoint # 验证Cloud Storage访问 kubectl exec -it [RAY_HEAD_POD] -- gsutil ls gs://${GS_BUCKET}7.3 性能问题排查如果训练速度不如预期可以检查以下方面# 检查网络带宽 kubectl exec -it [WORKER_POD] -- ping -c 5 [OTHER_WORKER_IP] # 监控TPU利用率 kubectl top nodes -l cloud.google.com/gke-tpu-accelerator # 检查是否有资源竞争 kubectl describe nodes [TPU_NODE] | grep -A 10 Allocated resources8. 生产环境最佳实践8.1 安全配置确保训练环境的安全性# 使用Workload Identity管理服务账号权限 gcloud iam service-accounts create maxtext-training # 最小权限原则只授予必要权限 gcloud projects add-iam-policy-binding $PROJECT_ID \ --memberserviceAccount:maxtext-training$PROJECT_ID.iam.gserviceaccount.com \ --roleroles/storage.objectAdmin8.2 监控和告警设置监控以便及时发现问题# monitoring-setup.yaml apiVersion: monitoring.googleapis.com/v1 kind: AlertPolicy spec: displayName: TPU训练异常告警 conditions: - conditionThreshold: filter: resource.typek8s_container AND resource.labels.cluster_name$CLUSTER_NAME aggregations: - alignmentPeriod: 300s perSeriesAligner: ALIGN_MEAN comparison: COMPARISON_GT thresholdValue: 5 duration: 300s trigger: count: 1 notificationChannels: - projects/$PROJECT_ID/notificationChannels/[CHANNEL_ID]8.3 成本优化策略最大化利用Spot实例的成本优势# 使用Spot实例创建节点池 gcloud container node-pools create v6e-spot-pool \ --location$ZONE \ --cluster$CLUSTER_NAME \ --machine-typect6e-standard-4t \ --tpu-topology4x4 \ --num-nodes4 \ --spot \ --maintenance-intervalPERIODIC9. 实际应用场景与效果验证9.1 场景一长时间训练的成本优化对于需要训练数周的70B参数模型使用Spot实例可以节省60-70%的计算成本。即使每天发生一次抢占弹性恢复机制也能确保训练进度不会丢失。实测数据传统方案按需实例训练成本 $12,000无中断风险弹性方案Spot实例训练成本 $4,000平均每天中断1次每次恢复时间30秒9.2 场景二多团队资源共享在大型组织中多个团队共享TPU资源池。弹性训练允许根据优先级动态调整资源分配高优先级任务获得更多TPU切片低优先级任务自动缩容但不中断资源利用率从40%提升到70%9.3 性能基准测试使用Llama 3 8B模型进行基准测试配置吞吐量(tokens/sec)恢复时间成本效率单切片固定训练12,500不适用基准双切片弹性训练23,80030秒提高90%带中断的弹性训练22,10030秒提高76%弹性训练技术正在重新定义大规模AI训练的工作流程。它不再是简单地追求更高的峰值性能而是在性能、成本和可靠性之间找到最佳平衡点。对于需要长时间训练大模型的团队来说这不仅仅是一个技术优化更是一个战略性的竞争优势。随着TPU技术的不断发展和Ray生态的成熟弹性训练将成为大规模AI训练的标配能力。现在投入学习和实践将为未来的AI项目奠定坚实的技术基础。

相关新闻

Python爬虫工程师必备核心技能与实战指南

Python爬虫工程师必备核心技能与实战指南

1. Python爬虫岗位的核心技能解析最近在帮朋友公司面试Python爬虫工程师时,发现很多求职者对爬虫岗位的理解还停留在简单的requests请求层面。实际上,企业级爬虫开发需要掌握完整的知识体系。以某直聘平台的Python爬虫岗位为例,我梳理了当前市…

2026/10/25 8:21:29 阅读更多 →
Elasticsearch生产环境部署与性能优化实战

Elasticsearch生产环境部署与性能优化实战

1. Elasticsearch 核心定位与适用场景Elasticsearch 本质上是一个分布式、RESTful 风格的搜索和分析引擎。它基于 Apache Lucene 构建,但通过分布式架构解决了 Lucene 单机处理的局限性。我在实际生产环境中使用 ES 处理过日均 20TB 的日志数据,其核心价…

2026/10/25 22:16:09 阅读更多 →
STM32F407VET6 CAN总线驱动开发与寄存器封装实践

STM32F407VET6 CAN总线驱动开发与寄存器封装实践

1. STM32F407VET6 CAN总线驱动开发概述在嵌入式系统开发中,CAN总线因其高可靠性和实时性被广泛应用于汽车电子、工业控制等领域。STM32F407VET6作为STMicroelectronics推出的高性能Cortex-M4微控制器,内置了两个CAN控制器,为开发者提供了强大…

2026/10/25 18:11:28 阅读更多 →

最新新闻

Electron桌面应用开发实战:从环境搭建到打包发布全流程

Electron桌面应用开发实战:从环境搭建到打包发布全流程

做前端这些年,我大部分时间都在跟浏览器标签页打交道,直到第一次把桌面应用跑起来,才发现原来自己一直被限制在地址栏和刷新按钮围起来的框框里。Electron这个词,最开始是在某段时间频繁出现在同事的讨论里,一个用HTML…

2026/10/27 17:33:32 阅读更多 →
滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

滑盖智能锁T109深度解析:结构原理、安装调试与故障排查

简介:亚太天能T109滑盖智能锁产品说明书是一份PDF格式的官方技术文档,主要面向智能锁安装人员、终端用户及售后维护者,帮助快速掌握产品安装、配置与常见故障处理。全包仅1个PDF文件,大小4.37MB,内容结构清晰&#xff…

2026/10/28 7:28:00 阅读更多 →
PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

PMSM弱磁控制原理与Simulink建模:高速AGV驱动系统设计指南

上个月我在调试一台高速AGV的驱动系统,整车要求空载跑到8m/s,电机转速已经拉到额定点,驱动器的输出电压也顶到了母线电压极限附近。再往上踩速度指令,转速纹丝不动,三相电流反而开始震荡。那会儿我的第一反应就是&…

2026/10/28 9:02:11 阅读更多 →
C++核心知识点:从内存管理到STL的实战指南

C++核心知识点:从内存管理到STL的实战指南

C 这门语言我前前后后折腾了十几年,用它写过嵌入式驱动、做过游戏服务端,也搭过基础组件库。每次看到新人一头扎进"C 核心知识点"这个搜索词,我其实挺感慨的——这门语言的难点从来不是语法多,而是知识点之间的耦合太深…

2026/10/27 18:20:50 阅读更多 →
T109滑盖智能锁怎么装?安装调试与故障排查全攻略

T109滑盖智能锁怎么装?安装调试与故障排查全攻略

简介:亚太天能T109滑盖智能锁产品说明书以PDF格式发布,适合智能锁用户、安装人员与售后维修人员阅读。内容围绕T109系列指纹密码刷卡锁展开,先介绍前面板、后面板、锁体、机械钥匙、电池盒盖等部件名称与功能,再给出完整安装步骤&…

2026/10/28 9:00:54 阅读更多 →
LLM知识降熵:从信息垄断到理解权下放

LLM知识降熵:从信息垄断到理解权下放

你有没有过这种经历:翻开源码注释、论文摘要或者一份专业报告,每个字都认识,连在一起却像天书。想找人聊聊,身边要么没人懂,要么懂的懒得跟你解释。后来我试着把同一段内容丢给一个大语言模型,几秒钟之后&a…

2026/10/28 7:29:02 阅读更多 →

日新闻

周新闻

月新闻

我发现了一个新思路:用 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/27 13:55:19 阅读更多 →
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/28 9:01:47 阅读更多 →
黑夜航拍船只数据集训练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/27 19:19:37 阅读更多 →