大模型推理性能优化:Prefill与Decode的瓶颈拆解与KV Cache调优

1. 把 Prefill 和 Decode 当成两个物种来看,是这套笔记第一个想讲明白的事

聊到高级调优,核心其实就两个字:边界。Prefill 阶段和 Decode 阶段在计算形态、访存特性、延迟目标上完全是两个物种,却挤在同一套显存和同一个调度器里,这就是性能问题最集中的来源。

先回忆一下自回归生成的过程。用户输入一段 prompt,模型先并行处理整段输入,一次性把所有 token 的中间状态计算出来,这个阶段叫 Prefill(预填充)。得到第一个输出 token 后,后续的每一个 token 都依赖前序全部 token 的信息,只能一个一个生成,这个阶段叫 Decode(解码)。哪怕总输出长度只有 500 token,Decode 也要循环 500 次,其中大部分时间 GPU 算力压根用不满。

这里有两个对性能工程极其重要的延迟指标:TTFT(Time To First Token,首 Token 耗时)和 TPOT(Time Per Output Token,每个输出 Token 的耗时)。前者由 Prefill 主导,决定用户"第一反应快不快";后者由 Decode 主导,决定"连续说话时流不流畅"。不少团队在做压测时只盯总吞吐(tokens/s),结果优化完吞吐很好看,线上用户却普遍抱怨"转圈时间久"或者"一个字一个字蹦出来",就是因为没有把这两个阶段拆开来看。

更关键的是计算形态的差异。Prefill 阶段输入是一个长序列,模型内层是标准的 GEMM,矩阵维度大、计算密度高,GPU 的算力可以发挥到相当高的水平;Decode 阶段输入只有一个 token,同样一层的计算退化成了 GEMV(矩阵乘向量),访存量远大于计算量,GPU 算力被严重浪费,性能瓶颈几乎是纯访存瓶颈。

换句话说:Pre fill 的敌人是算力不够和 batch 不够大,Decode 的敌人是显存带宽不够和 KV Cache 太大。两者放一起优化,就是本篇文章的整体框架。下面分别深挖。

2. Prefill 侧的高级优化:分块预填充、独立部署与注意力实现迭代

2.1 为什么不建议让一个大 prompt 独自占满整张卡

很多人在模拟长上下文场景时发现,一个 8k token 的 prompt 进来,Prefill 部分就要耗掉几百毫秒甚至几秒,期间其他请求的 Decode 全部被卡住。长尾延迟由此产生。解决思路之一就是 Chunked Prefill(分块预填充):把一个长 prompt 拆成若干 chunk,以 chunk 为单位加入调度队列,这时候同一条 prompt 的 Prefill 可以被其他请求的 Decode 插空执行。

这个方案的好处是消除了"长 Prompt 独占算力"的局观,坏处是 Prefill 被拆碎后计算效率略有下降,因为每块之间需要额外保存中间状态,KV Cache 也要提前为整个序列预留显存。它适合高并发、请求长度波动大的在线服务;对应的批处理大小参数也会因此调大不少。

2.2 Prefill 与 Decode 完全分家:PD 分离架构的取舍

当优化走到一定深度,很多团队会把 Prefill 和 Decode 部署到两批独立实例上:负责 Prefill 的实例专门吃大 batch 的 GEMM,负责 Decode 的实例专心做 GEMV。中间通过一个额外的缓存层(KV Cache 传输)把 prefill 结果交给 decode 实例。这种方案业内俗称 PD 分离。

它的核心收益在于:两种阶段可以独立扩缩容。线上如果首 Token 延迟敏感,就多配 Prefill 实例;如果长对话并发高,就多配 Decode 实例。代价则是多一跳网络传输、KV Cache 序列化延迟,以及系统复杂度上升。实操中需要特别注意传输吞吐与 KV 压缩的配合,否则 Prefill 侧省下的时间会全部浪费在传输上。

2.3 注意力实现的代际差异:从 FlashAttention 到 FP8

Prefill 阶段内部的优化,很大程度落到 Attention 实现上。早期推理框架的 Attention 要先把完整的 Q、K、V 矩阵存在显存里,再计算 softmax,显存和带宽消耗极大。后来出现的 FlashAttention 类实现通过分块计算和在线 softmax,把本来 O(n²) 的显存占用降为 O(n),同时大幅减少 HBM 访存,具体原理简要概括就是:不必等完整矩阵,先后台分块算部分 softmax,再用"重缩放"修正全局结果。

到了 FP8 时代,Prefill 阶段的 GEMM 可以用 8 位浮点累加获得更高吞吐,但 Attention 的 softmax 部分保持较高精度,整个过程中 KV 缓存也还可以保留 FP8 或 BF16。这里我必须提醒:FP8 的精度下降因模型和任务而异,不能一刀切。我在某个代码生成模型上试过全层 FP8,质量问题在短回复上完全看不出来,一跑长尾知识问答就明显掉准确率,后来只对 Attention 部分做 FP8,其他层保持 BF16 才平衡好速度与质量。

3. KV Cache 的显存几何:公式、量化与页式管理

3.1 一条公式算清 KV Cache 占用

进入调优之前,先把 KV Cache 显存占用这条基础公式记牢:

KV Cache 大小 ≈ 2(K 和 V) × 层数 × 注意力头数 × 每头维度 × 序列长度 × 每个元素字节数

假设某 7B 级别模型一共有 28 层、40 个注意力头、每头 128 维,每个元素占 2 字节(BF16),一条 4096 token 的序列大约需要:2 × 28 × 40 × 128 × 4096 × 2 字节 ≈ 2.35 GB。这还只是单条序列。实际服务中要同时处理几十条序列,KV Cache 的增速非常恐怖。

这就是为什么很多人在单卡上跑 7B 模型,只要并发请求一多,立刻出现 OOM,因为参数只占 14GB 上下,可 KV Cache 每秒钟都在滚雪球一样变大。因此性能工程的一个核心思路就是提前给 KV Cache 划定上限,而不是让它无节制增长。

3.2 GQA 为什么能凭空砍掉一截缓存

近几年不少新模型把 MHA(多头注意力)改成了 GQA(分组查询注意力)。不要小看这个结构变动,它直接让 KV 头数降到 Query 头数的几分之一,KV Cache 体积随之一同缩小。

举例来说,假设同一层原本有 32 个 Q 头和 32 个 KV 头,改成 GQA 后 KV 头降到 8 个,KV Cache 直接变成原来的四分之一。在同样的显存预算下,并发批处理大小能提上去,单位时间吞吐自然翻倍。也就是说,在做 KV Cache 调优时,模型结构的选型比后端参数调整来得更根本。

3.3 KV Cache 量化:8bit、4bit 与精度损失的拉锯

显存不够,量化来凑,KV Cache 同样可以量化。业界当前常见的三种档位:BF16(2 字节/元素)、FP8 或 INT8(1 字节/元素)、INT4(半字节/元素)。我的实践经验是:FP8/INT8 对大部分任务几乎无损,INT4 则明显有质量回落,尤其长上下文场景,模型对 KV 精度的敏感度会放大。

如果显存极度紧张,可以只量化 K 或只量化 V,或者量化后又把一部分重要 token 的 KV 保持高精度。这类混合策略需要配合具体模型做验证。另外要注意:量化 KV Cache 虽然省显存,但增加了量化与反量化的计算开销,如果实现不好,TPOT 反而变慢。选型时建议用/usr/bin/time这类工具实测阶段耗时,而不是只看显存数字。

3.4 PagedAttention 与"显存仓库"管理

显存管理的第二步是索引式存储。传统方式是用一块连续显存装完一整条序列的 KV,遇到变长请求时内部碎片极多。PagedAttention 类方案把一个序列的 KV 切成多个固定大小的 page,通过页表把物理上不连续的显存块拼成逻辑上连续的序列。

这块内容对于性能调优的意义在于:它决定了你能在同样显存容量里装多少并发请求。页表开得快、分配策略合理,并发吞吐就能上去。实操时把显存使用率、页碎片率和 KV Cache 命中率同时监控起来,注意这三个数字往往互相牵制,找到一个合适的折中区间。

4. 一次典型调优全程:设备、显存核算与实验矩阵

4.1 设定场景:单机单卡到多机部署的起步

我以一个非常常见的起步配置为例:单机 8 卡,单卡显存 80GB,要服务一个 7B 规模对话模型。量化参数为 BF16,权重约占 14GB。如果为了更高吞吐把 batch 并发调到 64 条序列,每条平均上下文长度 2k,那么 KV Cache 需求的粗略估算如下:

单条 2k 序列的 KV Cache ≈ 2 × 28 层 × 8 个 KV 头(假设 GQA)× 128 维 × 2048 × 2 字节 ≈ 1.17 GB。64 条并发需要大约 75GB,已经逼近单卡 80GB 上限,再加上模型权重和激活值,必然放不下。这时候就必须引入量化 KV、扩大 batch 的显存上限或者说调整并发策略。

4.2 显存预算表:给参数、激活和 KV 各划一条线

把显存管理抽象成一张预算表,是生产环境落地的关键方法。一般建议将显存按如下比例划分:模型权重约 30%——这部分几乎固定;激活值与临时张量约 20%——随上下文长度变化,峰值出现在 Prefill 阶段;KV Cache 约 45%——这部分最灵活,可量化、可淘汰、可压缩;剩余 5% 作为安全余量。

表中每个数字都要根据实测调整,而不是拍脑袋。你可以在框架日志里看到各个模块的实际峰值占用,对照预算表分析哪一块超了、哪一块没用到,调优目标也随之清晰。

4.3 实验矩阵:一次只动一个变量

我在调参时习惯做一个实验矩阵,确保每一轮只改一个变量。比如第一轮测不同 KV Cache 上限(35GB、40GB、45GB)分别对吞吐和 TPOT 的影响;第二轮测量化档位(BF16/INT8/INT4)对质量与速度的双重影响;第三轮测并行配置的影响(是否开 PD 分离、Prefill 实例几台、Decode 实例几台)。表格记录每个配置下的 TTFT、TPOT、吞吐、OOM 次数、质量测试分,然后收敛到综合最优。

一个实际跑出来的例子(非真实项目名)大概是:40GB KV 预算配 INT8 量化,TPOT 从 38ms 降到 27ms,吞吐提升 32%;改成 INT4 后吞吐提升 48%,但长上下文质量分下降 3 个点,最终取舍下来还是守住了 INT8。

4.4 从实验室到线上:加压、回落、再压测

调参跑通后不要直接上线,建议按这样的节奏:先在离线环境用两到三倍的线上峰值请求压测,观察显存溢出次数和延迟分位数;如果能撑住,再把部分生产流量灰度导入;灰度期间对比 TTFT、TPOT 是否劣化,以及有无意外 OOM 导致的请求重算。整个过程重点关注"幸存率":新增 KV Cache 上限后系统在极限并发下请求成功比例有没有下降。

这套流程看着保守,但能筛掉九成"调参后立刻上线爆显存"的意外。

5. 系统级联动:前缀缓存、推测解码与调度器策略

5.1 前缀缓存让重复请求根本不走 Prefill

线上大量请求通常共享同一段系统提示词或长文档,不同用户只是在后面追加问题。PKM(前缀缓存 / 语义缓存)就是抓住这一特征,把公共前缀的 KV 结果直接复用,这样新请求的 Prefill 部分彻底消除,TTFT 从几百毫秒降到几十毫秒。

效果取决于命中率。如果系统提示词每次都不一样,缓存命中率自然低。实际收效最好的场景是"固定系统前置 + 个性化后续问题",比如客服机器人、代码补全中的文件前缀。调优时重点关注命中率统计,不要盲信框架默认开启的缓存。另外要注意缓存的容量上限和淘汰策略:KV 缓存长期不淘汰的话,显存会被垃圾数据慢慢撑爆。

5.2 推测解码如何改变 Prefill/Decode 的压力结构

推测解码(Speculative Decoding)用一个小模型先草拟多个 token,再由大模型一次验证,以此把 Decode 阶段的串行步数压缩为更少次数的前向计算。它表面上只影响 Decode,实际上对 KV Cache 和 Prefill 也有很大扰动:草稿模型生成的 KV 是否放入主模型缓存、验错后如何回滚,都会影响显存峰值和调度复杂度。

我的建议是:推测解码要作为整体系统的一部分评估,而不是只看单步延迟收益。实测中它对短回复场景收益最大,长文档生成场景因为草稿模型准确率下滑,收益会被验错概率吃掉。

5.3 调度策略:长短请求如何在队列里和平共处

有些请求 prompt 很短、输出很长,有些则 prompt 很长、输出很短。调度器如果按队列顺序走,短请求很可能会被前面的长输出卡死,于是需要更细的优先级策略。进阶做法是区分"Pre fill 阶段是否必须立即做":短 prompt 的 Prefill 可以在解码间隙穿插完成,长 prompt 的 Prefill 尽量放在负载低谷期。

这里有一个我在实测中遇到的反直觉结论:把过多资源预留给 Prefill 中的大请求,不一定更优;相反,大请求的 Prefill 切片后穿插进 decode 间隙,系统整体尾延迟反而更稳定。这在很大程度上取决于业务场景,不能一概而论。

5.4 过载保护与回退预案

再稳的调优也会遇到突发流量。集群上的各实例必须有一套过载保护机制:比如当 KV Cache 使用率达到一定阈值时,拒绝新请求或把新请求引导到其他实例;当显存接近上限时,提前对旧请求做抢占或把 KV 缓存落盘。这里要重点测试"抢占后重新计算"的开销是否可接受,因为被抢占请求如果是长上下文,重算代价会非常大。

研究 KV Cache 不够用、请求过多时,部分的替代方案是 CPU offload:把一部分 KV 数据搬到主存,用时再回传。这个方法能救急,但回传速度会成为新瓶颈。我通常只把它定位成最后保险,不作为常规手段。

6. 生产环境里摸爬滚打后最想告诉你的事

6.1 显存碎片和活跃序列的动态平衡

PagedAttention 虽然把物理碎片管理得很好,但也不是没有坑。我发现高并发下的页表分配和释放过于频繁,会导致分配器本身出现"软碎片"——空闲 page 总量充足,却因为分布不连续而无法满足大请求。解决办法就是控制单次分配 page 的大小和数量,并定期做显存整理。此处不要迷信某个框架默认值,要实际跑一段时间看看你的服务是不是属于"快速分配和释放"的类型。

6.2 多卡张量并行与 KV Cache 的重复放大

很多人想当然认为张量并行后 KV Cache 占用减半,但实际上 Attention 头被切分到多张卡,每张卡分别缓存自己负责的那部分头,整体的 KV Cache 总量和单卡配置并不完全同步,反而可能因为重复缓存而出现更多占用。实践中要注意部署时给每张卡留出足够的 KV Cache 余量,并检查多卡之间的负载是否均衡,而不是只看整机显存总和。

6.3 指标采集要以"阶段"为单位,而不是只看整体

线上监控建议把 TTFT 和 TPOT 分开记录,并把 Prefill 阶段与 Decode 阶段的耗时分别统计。只盯"平均生成速度"容易掩盖 Prefill 长尾。这一点在长上下文服务里尤其明显:原本 Prefill 慢的请求如果恰好排在了一批解码请求后面,整体耗时会雪上加霜。加上阶段切分的指标后,你才能一眼看出瓶颈到底在预填充还是解码。

6.4 关于"最大 KV 预算"的常识与反常识

不是每个部署都需要把 KV Cache 预算开到最大。我踩过的坑是:为了压榨硬件,把 KV 预算调到接近显存上限,结果激活值一有波动就 OOM,触发流程里最重的重算。因为 KV 缓存可以量化、可以淘汰,但激活值是立即要用、不能省的。所以宁可给 KV 预留 80%,也别让它突破安全线,系统稳定性优先于峰值吞吐。

这套学习笔记写到这里,本质上体现了"性能工程"四个字的分量:它不是单一参数的事,而是把模型结构、推理框架、显存管理、调度策略串起来一起做的系统工程。练好这套拆解方法,比照着教程抄一个调优参数有用十倍。

相关新闻

从20%转化异常到1,833.56分方案:Coomia DIP决策闭环全解析

从20%转化异常到1,833.56分方案:Coomia DIP决策闭环全解析

1. 一次转化异常背后的完整决策链条1.1 从数据异常到决策闭环的起点转化率从预期的基准线掉到20%附近,这个信号在任何业务场景里都足够刺眼。我见过不少团队遇到这种情况的第一反应是“赶紧改页面”“赶紧换素材”,但真正有效的做法是先停下来&#xff0…

2026/10/20 5:20:04 阅读更多 →
AI编程模型迭代加速:从Argon到Carbon,什么是真正的代码生成“手感”?

AI编程模型迭代加速:从Argon到Carbon,什么是真正的代码生成“手感”?

最近圈子里有个消息挺有意思:某头部科技公司还没把内部代号Argon的编程模型推到全员使用,下一代Carbon就已经开始小范围内测了。参与内测的人给出的评价很耐人寻味——他们说Carbon的编程“手感”已经接近内部基准模型Opus 5.5。这话听起来像一句随口的评…

2026/10/20 5:20:04 阅读更多 →
ResidualQuant:面向循环Transformer KV Cache的2-bit残差量化方案

ResidualQuant:面向循环Transformer KV Cache的2-bit残差量化方案

做模型推理优化这几年,我最大的一个体感是:真正卡住长上下文生成的,往往不是算力,而是内存带宽和缓存容量。模型权重可以离线反复压缩,已经卷到 4-bit、甚至更低;可 KV Cache 是运行时动态增长的数据&#…

2026/10/20 5:20:04 阅读更多 →

最新新闻

主动学习新思路:损失预测模块如何精准筛选高价值样本

主动学习新思路:损失预测模块如何精准筛选高价值样本

1. 为什么主动学习要换一种采样思路做深度学习项目,尤其在数据标注预算有限的时候,大家最先想到的办法多半是“先随便标一部分,训练一版模型,再用模型挑一些‘难样本’送给标注员”。这套逻辑本身没问题,难就难在“难样…

2026/10/20 6:02:23 阅读更多 →
REA架构:React+Electron+Ant Design桌面应用开发实战

REA架构:React+Electron+Ant Design桌面应用开发实战

项目标题: "rea"这个标题乍一看像拼写错误,也像某个缩写,又像随手打出来的三个字母——但正因如此,它在当前语境下反而具备极强的信号价值。过去半年里,我在多个技术社区、设计协作平台和前端开发群组中反复观察到&…

2026/10/20 6:02:23 阅读更多 →
验证码逆向分析实战:某DN验证码机制与加固思路

验证码逆向分析实战:某DN验证码机制与加固思路

验证码逆向分析这个方向,我前前后后接触了不少轮。最近一次是在评估某内部系统的登录安全时,碰到某DN验证码这个环节,于是专门花了一段时间,把它从请求链路、JS执行逻辑、参数生成到服务端校验机制整个拆了一遍。这篇文章就是基于…

2026/10/20 6:02:23 阅读更多 →
PyTorch实战:从零搭建神经网络全流程指南

PyTorch实战:从零搭建神经网络全流程指南

1. 为什么选择 PyTorch 快速搭建神经网络1.1 从零手写到框架搭建的认知转变很多刚入门深度学习的朋友,第一次接触神经网络时,往往是从 NumPy 手写反向传播开始的。我自己当年也是这样,用 NumPy 实现一个两层全连接网络,光矩阵求导…

2026/10/20 6:02:23 阅读更多 →
ArtCraft:数字工具与实体材料融合的创意生产方法论

ArtCraft:数字工具与实体材料融合的创意生产方法论

1. 项目概述:ArtCraft 不是“艺术手作”的简单拼接,而是一套可落地的创意生产方法论ArtCraft 这个词乍看像英文单词组合,但实际在中文语境里它早已脱离字面意思,成为一类特定创作实践的代称——它指的不是泛泛而谈的“手工艺术”&…

2026/10/20 6:02:23 阅读更多 →
PLMS自适应滤波器:脉冲噪声下的LMS鲁棒性改进原理与Matlab实现

PLMS自适应滤波器:脉冲噪声下的LMS鲁棒性改进原理与Matlab实现

前几天,一位做车载音频的工程师朋友跟我抱怨,说他的回声消除算法在实验室里测得好好的,一到实际路测就“抽风”:安静路段收敛得很干净,可一旦出现开关门、喇叭鸣笛这类突发噪声,滤波器的权值就像被弹弓弹了…

2026/10/20 6:01:22 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* 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 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* 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 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* 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 1:01:30 阅读更多 →

周新闻

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

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

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

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

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

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

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

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

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在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/19 2:54:50 阅读更多 →
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/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练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/18 12:16:16 阅读更多 →