ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLM推理批处理策略详解:从静态到连续批处理提升GPU利用率

LLM推理批处理策略详解:从静态到连续批处理提升GPU利用率 别只调大batch_sizeLLM 推理的吞吐瓶颈藏在批处理策略里如果你把并发请求数调大GPU 利用率就上去了吗不一定。很多人在部署 LLM 推理服务时习惯性把batch_size调大结果要么显存直接 OOM要么整体吞吐没提升多少倒是尾延迟变得不可控。真正决定 LLM 推理服务吞吐上限的往往不是模型本身而是调度层用哪种方式做批处理。LLM 推理领域有三种容易混淆的批处理模式Static Batching静态批处理、Dynamic Batching动态批处理和Continuous Batching连续批处理。这三者的差异表面上看是“什么时候把请求凑成一堆”本质上却是调度粒度的差异在请求级别调度还是在 token 级别调度。这个区别决定了 GPU 的利用率、显存占用、延迟分布和框架选型。这篇文章会先把批处理相关的核心概念讲透再对比三种方案的原理与取舍然后用 Python 模拟三种调度策略让大家直观看到为什么 Continuous Batching 会成为 vLLM、TensorRT-LLM 等主流推理引擎的默认选择最后给出工程配置建议和常见排查思路。读完之后你再回头配置推理服务大概率不会再盲目调batch_size。1. 这篇文章真正要解决的问题先想一个真实场景你写了一版模型推理服务并发压测时发现 GPU 利用率只有 30%QPS 上不去。你第一反应可能是“是不是显存不够”“是不是模型量化没做”但排查一圈后发现显存有余、模型推理也不慢问题出在请求处理方式不够高效。这就是批处理策略的用武之地。批处理的核心目标是提高 GPU 利用率。GPU 擅长并行计算如果一次只处理一个请求大量的计算单元会处于空闲状态。但如果把多个请求放到一个 batch 里同时算计算资源就能被填满。问题也随之而来LLM 推理的请求长度各不相同、完成时间各不一样长短不齐的请求塞进一个固定 batch必然会产生“木桶效应”——短请求很快就结束了但长请求还没跑完整个 batch 的资源被最慢的那个请求绑架。于是出现了三种批处理思路Static Batching提前定好 batch 大小一批请求必须全部完成后才能释放资源。Dynamic Batching在请求级别做动态排队凑够一批就送进去算允许新请求在下一轮被纳入。Continuous Batching把一个 batch 从“一组请求”细化到“一组还在生成的 token”每个解码步骤都能调整 batch 内容。这篇文章要回答的问题非常具体三种批处理分别是怎么工作的优缺点是什么。为什么 Continuous Batching 能成为当前主流 LLM 推理引擎的首选。在工程上如何选择、配置和排查批处理相关的问题。阅读这篇文章的人至少应该有“部署过模型服务、知道 prefill 和 decode 基本流程”的背景。如果你刚接触 LLM 推理前面两节概念可以帮你补齐基础。2. LLM 推理中的关键术语在对比三种批处理之前有几个术语必须先对齐否则后面的调度逻辑很容易理解错位。2.1 请求、序列与 token在推理服务中一个用户请求通常对应一个序列sequence。序列是模型生成的一串 token例如“今天天气怎么样”加模型输出的“今天天气不错适合出门”。每个 token 是模型输出的最小单元可以是一个单词、一个字或一个子词。一个序列的生命周期包含两个阶段Prefill预填充阶段模型把输入 prompt 一次性交给 GPU 计算生成第一个输出 token 之前的准备阶段。这个阶段计算密集通常并行度高。Decode解码阶段模型逐 token 生成输出。每个新 token 都依赖之前生成的 token所以是串行过程每一步都要读取之前所有 token 的 KV cache。批处理主要发生在 decode 阶段让多个序列同时进行“一次生成一个 token”的前向计算提高 GPU 利用率。2.2 为什么 KV cache 决定了批处理的上限LLM 每生成一个 token都需要读取历史 token 对应的 Key 和 Value 向量这些向量被缓存在显存里统称KV cache。序列越长KV cache 越大并发请求越多KV cache 占用也越大。批处理的大小不能只看显存总量还要看 KV cache 预留了多少。如果 batch 里有 10 个序列每个序列长度 2048 token那 GPU 显存里要同时保存 10 份 2048 token 长度的 KV cache。如果某一刻有个序列结束了它占用的 KV cache 能不能立刻释放、让新请求补进来这就是批处理调度策略要解决的核心问题。2.3 三个容易混淆的概念调度粒度、排队方式和内存分配调度粒度静态批处理和动态批处理通常在请求级别调度一个请求一旦进入 batch就一直待到生成结束。Continuous Batching 在 token 级别调度每个解码步骤都能决定“下一个 token 由哪些序列一起生成”。排队方式动态批处理允许新请求在 batch 有空位时进入但传统实现里空位往往要等整个 batch 结束才会出现。内存分配静态批处理通常为整个 batch 预留固定显存容易浪费Continuous Batching 配合 PagedAttention 这类按需分配的内存管理可以把显存利用率拉到很高的水平。小结论三种批处理的核心差异不在于“是否批量”而在于“批量何时可以重组”。3. Static Batching最简单但代价最高3.1 工作方式Static Batching 是最朴素的批处理方式。推理服务先收集一批请求假设 batch size 为 8当请求数量达到 8 后一起送入模型。这批请求同时开始 prefill然后一起进入 decode 阶段。关键在于整个 batch 是一个整体要等 batch 里所有序列都生成结束这批计算才算完成显存才会释放。这个过程可以类比为一个小组作业8 个人一起做一张卷子做完的人不能先走必须等所有人交卷。3.2 优缺点分析优点很明显实现简单逻辑直观。调度开销小不需要频繁调整 batch 内容。对于长度均匀、完成时间接近的请求效果还不错。缺点同样明显队头阻塞batch 里最长的序列决定了整个 batch 的完成时间。短请求明明已经结束却占着 GPU 资源不能释放。显存浪费为了容纳最坏情况静态批处理通常需要预留整块显存实际利用率很低。GPU 利用率波动大一批请求刚启动时prefill 计算密集利用率高等到 decode 后期有些序列已经结束剩下的序列变少GPU 利用率迅速下滑。3.3 适用场景Static Batching 更适合以下情况批处理请求长度差异很小例如固定长度的翻译任务。离线批量推理对延迟不敏感可以接受“一批全部完成再算下一批”。教学演示或简单脚本不需要考虑调度开销。小结论Static Batching 的定位是“能用”而不是“高效”。只要请求长度参差不齐它的资源浪费就不可避免。4. Dynamic Batching请求级的动态排队4.1 工作方式Dynamic Batching 是许多推理服务默认开启的升级方案它比 Static Batching 灵活在一点允许新请求在合适的时间点进入当前或下一轮 batch。以 NVIDIA Triton 的 dynamic_batching 为例大致流程是请求到达后进入队列。调度器每隔一段时间或 batch 有空位时从队列里取出请求组成一个 batch。当前 batch 计算完成后释放显存再取下一批请求。相比 Static BatchingDynamic Batching 的优势在于请求不用等满一个固定 batch 才被处理可以设置max_queue_delay在 batch 未满但延迟超时后强制发送。整体吞吐比静待 batch 凑满更高。实现也相对成熟Triton 这类框架里直接配置即可。4.2 Dynamic Batching 的边界但注意Dynamic Batching 的调度粒度还是“请求级”。一批请求一旦被送进 GPU 开始 decode通常要等这批请求全部完成才能释放显存接收新请求。也就是说Dynamic Batching 解决的是“请求怎么进入 batch”的问题没有解决“batch 内部长短不齐导致的等待问题”。这意味着短请求依然会被长请求拖累。当请求长度差异大时GPU 在 decode 末期依然会出现大量空闲。动态排队本身会增加调度的复杂度如果max_queue_delay配置不当还会增加在线请求的排队延迟。NVIDIA 官方材料里明确区分了 dynamic batching 和 in-flight batching也就是连续批处理的一种实现前者在请求完成前不释放其占用的资源后者可以在请求级别和 token 级别做更细粒度的调度。小结论Dynamic Batching 是一个折中方案比 Static 好但仍是“请求组队一起走”的思路。想要进一步榨干 GPU需要把调度粒度下沉到 token 级别。5. Continuous Batchingtoken 级调度5.1 工作方式Continuous Batching 的核心思想是iteration-level scheduling也就是把调度决策细化到每一步解码迭代。在 Continuous Batching 中推理引擎维护两组序列Running 组当前正在执行 decode 的序列。Waiting 组等待进入 GPU 的序列。每一步 decode 结束后引擎都会执行一次调度检查 Running 组中哪些序列已经生成完毕立即释放它们的 KV cache 和显存。从 Waiting 组中取出新请求补进 Running 组直到 batch 达到上限或显存不足。当前解码步只处理 Running 组里的序列每个序列生成一个 token。于是每个 batch 的内容都在动态变化。序列 A 在第一步生成了序列 B 在第一步刚开始 prefill序列 C 在第三步才加入——只要 GPU 有位置这些操作都可以发生在“同一个大循环”的不同轮次里。5.2 为什么它能提高 GPU 利用率关键点在于序列完成和释放资源不再需要等待别人。传统 batch 里短请求完成后不能立即释放因为整个 batch 还没结束。Continuous Batching 把“一个序列的结束”当作一个调度事件立即把空位让给新请求GPU 几乎不会出现因等待长序列而闲置的情况。同时Continuous Batching 通常配合按需分配显存的管理机制例如 vLLM 的 PagedAttention、TensorRT-LLM 的 KV cache 管理。它把 KV cache 划分成固定大小的 block按需分配给序列序列结束立刻回收 block显存利用率远高于固定预留方式。这个设计可以类比为收费站Static Batching 是“凑够 8 辆车才放行所有车必须一起通过”Dynamic Batching 是“前面车走完后面车可以补上”Continuous Batching 更像是每辆车都在独立闸机通行车走之后闸机立刻开放给下一辆不会因为旁边有慢车而一直占着通道。小结论Continuous Batching 的价值不是“批处理本身”而是把所有序列的生命周期管理都纳入了调度器让每一个 GPU 计算步骤都尽量满载。6. 三种批处理对比下面的表格从多个维度对比三种批处理维度Static BatchingDynamic BatchingContinuous Batching调度粒度请求组请求组token 级batch 内容能否中途变化不能一般不能请求进入后等待全部完成每步都可以变化短请求是否被长请求拖累会会基本不会显存释放时机整批结束后整批结束后序列结束后立即释放实现复杂度低中高GPU 利用率不稳尾部浪费比 Static 好仍有波动整体最高典型代表教学 demo、简单脚本Triton dynamic batchervLLM、TensorRT-LLM、TGI适用场景长度均匀的离线任务中等并发在线/离线混合高并发在线服务长尾请求多从这张表能很明显看出一个趋势调度的粒度越细GPU 利用率越高但系统实现复杂度也越高。7. 用 Python 模拟三种调度策略理论讲完下面用 Python 写三个小型模拟脚本把三种策略在“同一批请求”下的表现跑出来。这样比纯讲概念更直观。7.1 模拟的前提设定设定如下有 6 个请求每个请求需要生成的 token 数量各不相同分别为[10, 5, 8, 3, 12, 6]。每个时间步模型可以为 batch 内的每个序列生成 1 个 token。为了对比调度的差异忽略 prefill、显存和计算速度的细节只关注“序列什么时候能完成”和“GPU 上同时活跃的序列数量”。假设 Static Batching 和 Dynamic Batching 的 batch size 上限为 4Continuous Batching 也限制最多同时处理 4 个序列。请求从一开始就全部到达按顺序排入队列。7.2 静态批处理模拟Static Batching 的方式是前 4 个请求凑成一个 batch后 2 个请求必须等前一批全部完成后才开始。# static_batching_sim.py import math requests [10, 5, 8, 3, 12, 6] batch_size 4 # 按顺序切分成 batch batches [requests[i:i batch_size] for i in range(0, len(requests), batch_size)] print(静态批处理的批次划分:, batches) current_time 0 active_series [] for batch in batches: # 一批的开始时间 start_time current_time batch_remaining batch[:] while any(r 0 for r in batch_remaining): active_count sum(1 for r in batch_remaining if r 0) # 每个时间步batch 内所有未完成序列都生成一个 token batch_remaining [r - 1 if r 0 else 0 for r in batch_remaining] current_time 1 active_series.append(active_count) max_len max(batch) print(f批次完成: 开始时 {batch}, 耗时 {max_len} 步, 实际完成时刻 {current_time}) print(静态批处理总耗时:, current_time, 步) print(每个时间步活跃序列数:, active_series)这段代码里每一批请求要等 batch 内最长的序列完成后才释放资源。active_series记录每一步 GPU 上同时有多少个序列在生成 token。7.3 动态批处理模拟Dynamic Batching 的常见做法是队列里凑满 batch_size 就送入 GPU但这批请求要全部完成后才释放再开始下一批。和 Static Batching 的差别主要在于请求到达时间随机时它不需要等固定数量而是延迟达到阈值就发送。这里模拟一个简化版队列里只要有请求就按 batch_size 逐个填充但同一批内仍然“同生共死”。# dynamic_batching_sim.py import math requests [10, 5, 8, 3, 12, 6] batch_size 4 # 简化模拟请求按到达顺序进入队列 queue requests[:] batches [] while queue: batch queue[:batch_size] queue queue[batch_size:] batches.append(batch) print(动态批处理的批次划分:, batches) current_time 0 for batch in batches: start_time current_time batch_remaining batch[:] while any(r 0 for r in batch_remaining): batch_remaining [r - 1 if r 0 else 0 for r in batch_remaining] current_time 1 max_len max(batch) print(f批次完成: {batch}, 耗时 {max_len} 步, 完成时刻 {current_time}) print(动态批处理总耗时:, current_time, 步)在这个简化模型里Dynamic Batching 的耗时和静态差不多因为请求本来就同时到达批次划分也一样。真实场景中Dynamic Batching 的价值体现在请求不会同时到达时它可以边等边发减少排队延迟。7.4 连续批处理模拟Continuous Batching 模拟每一轮调度# continuous_batching_sim.py class Sequence: def __init__(self, seq_id, remaining): self.seq_id seq_id self.remaining remaining requests [10, 5, 8, 3, 12, 6] batch_size 4 # 初始化等待队列 waiting [Sequence(i, n) for i, n in enumerate(requests)] running [] current_time 0 total_token_steps 0 active_series [] # 调度循环 while waiting or running: # 将 waiting 中的序列补入 running直到 batch 满 while len(running) batch_size and waiting: running.append(waiting.pop(0)) # 当前时间步活跃序列数 active_series.append(len(running)) total_token_steps len(running) # 每个 running 中的序列生成一个 token finished [] for seq in running: seq.remaining - 1 if seq.remaining 0: finished.append(seq) # 立即移除完成的序列 for seq in finished: running.remove(seq) print(f时间 {current_time}: 序列 {seq.seq_id} 完成立即释放槽位) current_time 1 print(连续批处理总耗时:, current_time, 步) print(总 token 步数:, total_token_steps) print(每个时间步活跃序列数:, active_series)这里的关键在于序列完成时立即从running移除下一轮循环中它的 GPU 槽位就会让给等待队列里的新请求。短请求不再拖累整个 batchGPU 上的拥塞程度也更平稳。7.5 结果解释三份模拟代码运行后结果会有明显差异Static 和 Dynamic 在请求同时到达时耗时几乎一致因为一批最长的序列决定了批次耗时。但如果把请求到达时间打散Dynamic 在“等待时间”上会好于 Static。Continuous 的总耗时一定小于等于前两者因为 GPU 空位被及时复用短请求完成后立即让位给下一个请求。模拟代码没有使用任何真实模型目的是演示调度逻辑。真正部署时PagedAttention、CUDA graph、显存预分配等因素会让调度器复杂得多但核心思想一致尽早释放、及时补充、按 token 粒度调度。8. 主流推理框架中的实现理解原理后再看主流框架会更容易。8.1 vLLMvLLM 是目前最流行的 LLM 推理框架之一它默认使用 Continuous Batching并配套 PagedAttention 做显存管理。在 vLLM 中你通常不需要单独开启“连续批处理”它本身就是调度模块的核心机制。你需要关注的是这些参数--max-num-seqs控制单个 batch 内最大序列数。--max-model-len控制最大序列长度间接决定 KV cache 预留上限。--gpu-memory-utilization控制 GPU 显存使用比例影响 KV cache 可用空间。实际调优中如果并发很高但显存不够通常调小max-num-seqs或调低gpu-memory-utilization。如果吞吐上不去但显存充足可以适当调大max-num-seqs。8.2 TensorRT-LLMTensorRT-LLM 的连续批处理通常称为in-flight batching。它的调度器同样支持在解码过程中加入和移除请求且配合 TensorRT 编译优化后单卡吞吐表现往往非常突出。由于 TensorRT-LLM 的 API 和版本变化比较快具体配置项建议以官方文档为准但核心调度逻辑和上面描述的 Continuous Batching 一致。8.3 Hugging Face TGIText Generation InferenceTGI也实现了 continuous batching。它的核心思路是把 batch 里的序列按 token 生成进度动态管理请求完成即释放资源。TGI 适合快速部署配置项集中在环境变量和启动参数中比如 batch 大小、最大输入长度等。如果你对 Hugging Face 生态比较熟TGI 是验证 continuous batching 概念最快的方式之一。8.4 Triton Inference ServerNVIDIA Triton 提供 dynamic_batching 以及 in-flight batching 的能力。如果使用 Triton 的 dynamic batcher配置粒度更接近“请求级动态排队”不一定能享受到 token 级调度的收益。如果后台模型跑的是 TensorRT-LLM 或 vLLM再叠加 Triton 的调度效果会好很多。注意任何框架的版本更新都可能调整默认调度策略或参数名。本文提到的参数名以官方文档为准部署前务必确认版本差异。9. 工程配置与调优方向9.1 一般配置思路在工程里选批处理策略本质上是在“吞吐、延迟、显存、实现成本”之间做权衡。下面这几个方向可以作为参考。在线服务高并发延迟敏感优先使用支持 continuous batching 的框架例如 vLLM、TensorRT-LLM、TGI。不要手动调超大 batch试着让框架的调度器来管理并发。离线批量推理请求大小均匀Static Batching 就能胜任。如果框架配置里没有特殊的批处理选项其实也不用强行换框架。混合负载请求长度差异大连续批处理优势最明显。建议开启 continuous batching并限制最大并发序列数防止少数超长序列长期占用资源。显存紧张优先检查 KV cache 配置而不是盲目调小 batch。使用支持按需分配显存的框架比如 vLLM能更充分地利用显存。9.2 一个典型配置示例下面以 vLLM 风格为例展示如何通过启动参数控制并发和显存使用。注意这里只是示意参数名和默认值请以当前版本文档为准。python -m vllm.entrypoints.openai.api_server \ --model /models/llama-3-8b-instruct \ --gpu-memory-utilization 0.85 \ --max-model-len 8192 \ --max-num-seqs 32 \ --enforce-eager这几个参数的作用--gpu-memory-utilization 0.85告诉引擎最多使用 85% 的 GPU 显存给其他模块留余量。--max-model-len 8192限制输入加输出的最大 token 数决定 KV cache 上限。--max-num-seqs 32限制同一时间最多处理 32 个序列。--enforce-eager关闭 CUDA graph 优化方便调试时观察显存和调度行为生产环境可以移除或按需调整。如果你用的是 Triton配置 dynamic batcher 时也很简单核心是max_queue_delay_microseconds和max_batch_size两个参数。前者控制请求最多在队列里等多久后者控制单批最大请求数# config.pbtxt 示意 dynamic_batching { preferred_batch_size: [4, 8] max_queue_delay_microseconds: 500 }这个配置表示Triton 优先凑 4 或 8 的 batch但请求在队列里等待超过 500 微秒后即使 batch 没凑满也会被送进模型。工程提醒生产环境里调整连续批处理相关参数必须先做压测。不要一次性把max-num-seqs调得很大这会导致显存 OOM也不要让max_queue_delay太小否则 batch 一直凑不满GPU 一直在“小批量空转”。10. 常见问题与排查思路批处理策略相关的故障现象往往集中在显存、延迟和吞吐三个维度。下面是实际部署中比较常见的几类问题。问题现象可能原因排查方式解决方案调大 batch_size 后显存 OOM单个 batch 占用的 KV cache 超出显存查看 GPU 显存占用观察 KV cache 占比调小 max-num-seqs降低 max-model-len或升级显存更大的卡GPU 利用率很低但显存没满请求太少或 batch 大小被限制得过小查看排队请求数和当前 running 序列数提高并发请求数检查 max-num-seqs 是否过小短请求响应很快但偶发延迟极高长请求占用了整个 batch短请求排队查看调度日志确认是否启用了 continuous batching切换到支持 continuous batching 的框架限制最大序列长度使用 Triton dynamic batching 时延迟增加max_queue_delay 设置过长请求长时间等待凑批查看 Triton 指标中的 queue 时间调小 max_queue_delay_microseconds开启连续批处理后吞吐反而下降调度频率过高或每次加入新请求时 prefill 成本过高检查 token 级调度是否频繁触发 prefill增加 max-num-seqs 阈值让新请求分批进入或调低调度频率参数推理结果正确但性能不稳定请求长度极不均匀经常出现超长序列占用资源检查输入输出长度分布设置 max-model-len 上限或对超长请求做截断和告警排查的第一步永远是看指标不要凭感觉调参。至少要收集这些指标当前 running 序列数和 waiting 队列长度。每个请求的 TTFT首 token 延迟和 TPOT每 token 生成时间。GPU 利用率、显存占用、KV cache 使用率。排队延迟、调度延迟、解码延迟。如果这些指标都能看到批处理策略是否合理基本一目了然。11. 最佳实践与工程建议11.1 先明确负载类型再选批处理策略不要把“最新”当“最好”。continuous batching 虽然强但实现复杂度高如果你只是跑离线批量任务且请求长度均匀static batching 反而更简单。做技术选型时先回答三个问题请求长度是否均匀对延迟是否敏感并发量有多大11.2 控制最大并发而不是无脑拉高 batch在 continuous batching 框架里直接并发很多请求可能让每个 request 的生成速度都变慢。调大max-num-seqs会提高吞吐但每个序列分到的显存和计算资源变少单请求延迟上升。在线服务里需要在吞吐和延迟之间找一个平衡点。11.3 给 KV cache 留足余量显存不能 100% 用满。推理引擎加载权重、中间激活、CUDA context 都需要显存。把gpu-memory-utilization配置在 0.8 到 0.9 之间通常比较稳妥但具体数值要根据模型大小和输入长度压测后确定。11.4 关注超长请求对调度器的冲击即便用了 continuous batching一个超长请求依然会长期占用 KV cache 和计算资源。建议在服务入口做请求长度上限控制或者对超长请求单独设置低优先级。这个策略在业务上很常见逻辑上也说得通。11.5 不要忽略 prefill 和 decode 的差异continuous batching 中新请求加入 batch 需要做 prefill 计算而旧序列在做 decode。这两个操作对显存和计算的需求差异很大如果每个 step 都有大量新请求加入prefill 的高开销会导致decode 步变慢。生产环境里可以设置“每多少步只允许加入有限个新请求”之类的策略让调度更平稳。11.6 多机部署时别只盯着单卡调度如果你用多机多卡跑模型批处理策略还要考虑张量并行、数据并行和请求路由之间的配合。比如 tensor parallel 下同一 batch 的数据要切分到多张卡上batch 调度会直接影响多卡通信量。这时候不能只看单张卡的调度逻辑要把整个集群的负载分布一起考虑。12. 总结与后续学习方向批处理策略决定了 LLM 推理服务能否把 GPU 资源“榨干”。Static Batching 以整批为调度单位简单但浪费Dynamic Batching 在请求级别动态排队比 Static 灵活但短请求仍会被长请求拖累Continuous Batching 把调度粒度下沉到 token 级别允许序列在解码过程中随时进出是目前高并发在线推理的主流方案。从工程角度看选择批处理策略不是“越高级越好”而是匹配负载类型。如果是长短不一的在线请求优先选择支持 continuous batching 的框架如果是长度均匀的离线任务static 未必不可用。调优时重点观察 KV cache 占用、运行中序列数、排队延迟和 GPU 利用率而不是盲目调大 batch_size。想继续深入可以从这几个方向往下走学习 vLLM 的 PagedAttention 原理理解 KV cache 如何按 block 动态分配。研究 TensorRT-LLM 的 in-flight batching 与 scheduler 的关系。自己实现一个更完整的 continuous batching 调度器把 prefill、decode、显存回收都纳入模拟会更有体感。批处理策略看起来是推理引擎内部的一个小模块但它直接影响服务成本、延迟和用户体验。把这个点想明白再回看各种推理框架的配置项你会发现自己知道每一步在调什么而不是跟着网上教程盲调。
返回列表