
最近在一个内部项目里需要给一套基于 Qwen 的 RAG 服务做低延迟流式输出生产环境最开始直接上了 SGLang。用了几个月之后我越来越觉得这框架的设计很有意思尤其 RadixAttention 和连续批处理这两块几乎就是当前 LLM 推理引擎的标配思路。但框架本身封装太重遇到性能问题想定位到具体机制时光看源码很难形成直观感受。后来我看 Karpathy 的 llm wiki 里反复强调“从零手写”对理解系统的重要性于是决定换个路子不直接啃 SGLang 源码而是拿 Python 手搓一个简化版推理引擎把请求调度、KV Cache、前缀缓存、流式输出这些核心机制全部自己实现一遍。最终代码统计下来主逻辑大概 2000 行左右跑在 GTX 4090 上用 Qwen2.5-1.5B 做测试多轮对话场景下显存占用和首 token 延迟都明显优于最朴素的 HuggingFace pipeline 方案。这篇文章就是那次手搓过程的完整复盘。我会按“设计思路 - 模块拆解 - 核心实现 - 问题排查 - 性能对比”的顺序来讲所有代码片段都是实际跑过的你可以直接参考复现。如果你已经用过 SGLang 或 vLLM但对它们内部到底怎么组织请求、怎么管理 KV Cache 还是一头雾水这篇应该能帮你把关键脉络理清楚。1. 内容整体设计与思路拆解1.1 SGLang 到底做了什么事先简单对齐一下背景。SGLang 是一个专门为 LLM 服务场景设计的推理引擎它最大的创新点是 RadixAttention本质是一个带前缀树结构的 KV Cache 管理系统。传统推理服务中每个请求都要独立做 Prefill预填充即使两个请求共享同一个 System Prompt系统也会重复计算一遍前缀部分的 Attention。SGLang 会把所有请求的前缀 token 序列组织成树结构新请求进来时先在这棵树里匹配公共前缀命中的部分直接复用已有 KV Cache只需要对差异部分做增量 Prefill。除了前缀缓存SGLang 还做了一件事Continuous Batching连续批处理。朴素批处理是等一批请求全部结束后再处理下一批而连续批处理允许不同请求在同一个 Step 内处于不同阶段有的在做 Prefill有的在做 Decode哪个请求结束了就立刻从等待队列里补一个新请求进来这样 GPU 始终在处理有效 token不会出现“等最慢的那个请求”导致的算力空转。这样设计直接解决了两个生产痛点多轮对话场景下历史 KV Cache 大量复用首 token 延迟显著降低高并发场景下吞吐量成倍提升显存利用率也更高。1.2 2000 行的简化边界在哪里我给自己定的目标不是复刻完整 SGLang而是把核心机制跑通。所以做了非常明确的边界划分完整模型推理交给 HuggingFace Transformers 的model.generate底层逻辑我们只负责在它外面包一层自定义调度和缓存模型选的是 1.5B 级别的小模型方便单卡测试不做分布式、不做 PagedAttention、不做复杂的显存管理策略。需要自己实现的核心模块包括四个Radix Cache 前缀缓存、连续批处理调度器、流式输出 token 生成器、以及一个最简版约束解码。这里约束解码是 SGLang 的另一个卖点它可以让你指定输出必须符合 JSON Schema 或正则表达式我们在简化版里用正则约束做了一版最基础实现只在采样时对 logits 做 mask实现成本不高但能说明原理。这四个模块加上模型加载、HTTP 服务、配置解析总代码量正好卡在 2000 行上下。我实际写下来最终统计是 2084 行如果去掉注释和空行大约 1850 行。这么做的好处是每个模块都可以独立调试出问题时不用去翻庞大的框架源码。而且因为代码足够短你能完整理解每一步在做什么这正是我想要的“扒光了给你看”的效果。2. 核心细节解析与实操要点2.1 Radix Cache前缀树还是哈希表最开始我天真地以为前缀缓存只要用一个字典把每个请求的完整 token 序列存起来下次直接查表复用就行。但真实的多轮对话场景下请求之间不只是“完全相同”或“完全不同”而是大量“部分相同”。比如两个用户都问了同样的问题但一个是第一轮一个已经进行了五轮对话它们的公共前缀可能只包含 System Prompt 和第一轮的历史内容。这种情况用普通字典做精确匹配就完全失效了。SGLang 用的是 Radix Tree也就是基数树公共前缀只存一份分叉处才分裂节点。我第一版也照抄了这个思路用一个字典维护树节点每层存 token查询时逐层匹配。调试了一段时间后发现简单的 Radix Tree 在批量请求场景下性能并不好因为每个 Step 都要遍历树做前缀匹配树深一上来开销就不小。后来我参考了 SGLang 的实现思路做了折中用字符串形式的 token 序列作为 key存到一个前缀哈希表里。具体做法是每次请求进入时把它的 token id 序列按长度拆成多个前缀组合前缀A 前缀AB - KV Cache 指针这样的结构存在字典里查询时从长到短尝试匹配。这样虽然牺牲了一些内存但查询是 O(1) 的在 1.5B 这个小模型上效果反而更好。如果你是第一次实现这个建议先别急着写树用哈希表把流程跑通再去优化结构。2000 行的代码量哈希表版本能控制在 80 行以内树版本要 200 行以上而且树版本的前缀合并逻辑非常容易出 bug。2.2 连续批处理三种队列状态连续批处理的核心是一个状态机每个请求根据其生命周期处于三种状态之一Waiting等待队列、Running正在执行 Decode、Finished已完成。每个 Step 开始时调度器做三件事把 Finished 请求移出释放 KV Cache检查 Running 队列有没有空位有的话从 Waiting 里取请求做 Prefill如果有新请求进来插入 Waiting 队列。这里的关键是 Prefill 和 Decode 不能混在同一个 Step 里做至少在一个简化实现里不能。原因在于Prefill 阶段每个请求要处理几百个 token而 Decode 阶段每个请求只处理 1 个 token两者计算量差距太大如果混在一个 batch 里解码请求可能会被 Prefill 请求的计算量拖慢产生“优先级反转”问题。SGLang 的做法是把每个请求拆成多个微批次我简化版本没做那么细只实现了“同一时刻要么全部 Prefill要么全部 Decode”的粗粒度调度。实测下来在 8 个并发请求以内效果还不错超过 8 个之后 Prefill 时间会明显拉长这时才体现出微批次调度的价值。所以如果你的并发量不高这个简化完全够用如果预期高并发至少得把 Prefill 阶段拆成多个 chunk 来做。2.3 KV Cache 的存储与复用KV Cache 本质是 Attention 计算过程中产生的 Key 和 Value 张量形状是[层数, 头数, 序列长度, 每头维度]。朴素的用法是每个请求维护一份自己的 KV Cache存在 CPU 上下一次请求进来时重新加载。但这样有两个问题内存开销大GPU 和 CPU 之间反复拷贝导致延迟。SGLang 的做法是把 KV Cache 放在 GPU 显存里用一个块管理器分配Radix Cache 树里的每个节点保存一部分 cache 块。我简化版没有做块管理直接用 Python dict 持有多个请求的past_key_values键是前缀 token 的哈希值。这样的问题是显存碎片化比较严重但在小模型和少量并发下问题不大。KV Cache 复用有个非常重要的细节复用时必须保证 tokens 的绝对位置一致。这里位置一致不只是序列长度一致还要考虑分词器的特殊 token。比如同一个 System Prompt在不同的请求中可能因为拼接方式不同在开头多了一个 BOS token这样整个前缀完全不匹配缓存就完全失效了。所以如果你想最大化缓存命中率在拼 Prompt 时必须固定模板不能灵活变。2.4 采样与流式输出采样是 LLM 生成中最直观的过程根据 logits 概率分布选一个 token。基本的 Top-K、Top-P 采样代码量不大但流式输出会引入一个复杂度异步处理。在我的实现里每个请求绑定一个asyncio.Queue调度线程生成一个 token 后把它 put 进队列HTTP 服务端从队列里 get 再通过 SSEServer-Sent Events协议推送给客户端。这里有个坑需要注意asyncio.Queue是线程不安全的如果你的调度器跑在另一个线程里不能直接调用队列的 put 方法。我当时的解决方案是让调度器和 HTTP 服务都跑在同一个事件循环里调度器通过loop.call_soon_threadsafe把 token 放进队列这样绕开了线程安全问题。如果你不想处理这种异步细节也可以直接用 генератор 同步队列的方式实现流式输出但 Python 的全局解释器锁会让并发性能大打折扣。实测下来用异步方式实现在 8 个并发请求下吞吐能到 120 token/s同步方式只有 40 token/s差距很明显。2.5 约束解码的第一版实现约束解码是 SGLang 区别于 vLLM 的一个重要特性。它的目标是让生成必须符合预设的格式比如 JSON、正则、或者一个具体枚举值。原理是在每次采样前根据当前已生成的 token 序列计算下一步合法 token 集合然后对 logits 做 mask把不合法 token 的概率置为负无穷。我的简化版支持的是正则表达式约束做法是把用户的正则编译成一个 DFA确定性有限自动机每生成一个 token 就更新 DFA 状态从当前状态出发计算所有能合法转移到的下一个 token。这个算法说起来简单但实现细节很坑尤其是 token 和字符之间的对齐问题。一个 token 可能对应多个字符比如中文分词后一个 token 可能代表“你好”两个字而正则表达式只能按字符匹配。所以无法简单地把正则的字符级状态机直接用在 token 级采样上。我第一版忽略了这个对齐问题结果所有中文输出的 JSON 格式全是坏的。后来参考的解法是构建一个 token 级别的自动机对词典里的每一个 token模拟 DFA 逐个字符消费看能否合法到达某个状态。这需要在每次采样前遍历整个词表在 32K 词表下耗时约几毫秒可以接受。3. 实操过程与核心环节实现3.1 项目目录与模块划分我最终的项目结构如下simple_sglang/ ├── engine.py # 主引擎调度循环 ├── scheduler.py # 连续批处理调度器 ├── radix_cache.py # 前缀缓存 ├── sampler.py # 采样与流式输出 ├── tokenizer_utils.py # 分词与模板处理 ├── server.py # HTTP SSE 服务 ├── config.py # 配置解析 └── models.py # 模型加载与 forward核心代码集中在engine.py和scheduler.py两者加起来约 700 行。radix_cache.py大约 120 行sampler.py约 150 行。server.py用了 FastAPI约 150 行。这个体量对想读懂每个机制的人来说是非常友好的。3.2 主引擎循环的实现主引擎的每个 Step 逻辑是调用调度器决定当前 batch 的请求集合和各个请求要做 Prefill 还是 Decode把 Prefill 请求的输入序列和 Decode 请求的上一个新 token 拼成一个 batchforward 一次得到所有请求的 logits对每个请求做采样更新 KV Cache、Radix Cache、调度器状态。# engine.py 核心循环简化版 async def step(self): # 1. 调度 prefill_reqs, decode_reqs, finished self.scheduler.schedule() for req in finished: self.radix_cache.insert(req.prefix_tokens, req.kv_cache) self.running_requests.remove(req) req.queue.put(None) # 结束信号 # 2. 组装输入 if prefill_reqs: input_ids torch.stack([req.input_ids for req in prefill_reqs], dim0) attention_mask torch.ones_like(input_ids) self.model.eval() with torch.no_grad(): outputs self.model( input_idsinput_ids, attention_maskattention_mask, use_cacheTrue, ) # 直接使用 outputs.past_key_values这是完整的 prefill KV for i, req in enumerate(prefill_reqs): req.kv_cache get_per_request_kv(outputs, i, req.input_len) req.state decode if decode_reqs: # 每个 decode 请求只输入最后一个 token decode_ids torch.tensor([[req.last_token] for req in decode_reqs], devicecuda) # 拼接各自的 past_key_values这里简化了 merge 过程 merged_cache merge_kv([req.kv_cache for req in decode_reqs]) with torch.no_grad(): outputs self.model( input_idsdecode_ids, past_key_valuesmerged_cache, use_cacheTrue, ) for i, req in enumerate(decode_reqs): req.kv_cache slice_kv(outputs.past_key_values, i, req.pos 1) req.pos 1 # 3. 采样与输出 for req in (prefill_reqs decode_reqs): logits outputs.logits[i, -1, :] token_id self.sampler.sample(logits, req.constraint_state) if token_id self.tokenizer.eos_token_id: req.queue.put(self.tokenizer.decode(req.output_tokens)) req.state finished else: req.output_tokens.append(token_id) req.last_token token_id req.queue.put(self.tokenizer.decode(token_id))这里有几个值得关注的实现细节Prefill 和 Decode 的 batch 是分开处理的在一个 Step 内不能混。当有 Prefill 请求时Decode 请求会被延后一个 Step这个牺牲换来的是实现复杂度大幅下降。merge_kv和slice_kv是我在models.py里封装的两个工具函数用于处理 KV Cache 在 batch 维度上的拼接和分片。HuggingFace 的past_key_values结构是(num_layers, 2, batch_size, num_heads, seq_len, head_dim)的嵌套 tuple按 batch 维度 merge 和 slice 即可。但这块有个性能隐患如果你的 batch 里每个请求的seq_len不一致直接拼出来的张量会有大量 padding浪费显存和算力。SGLang 使用 PagedAttention 就是为了让不同长度的序列共享同一块显存我的简化版没有做这个优化所以 seq_len 差异较大的时候性能会退化。3.3 Radix Cache 查询与插入前缀缓存的实现思路是新请求进来时先做 tokenize然后从长到短尝试匹配 Radix Cache 中有没有相同前缀。匹配成功后把命中的前缀 token 和对应的 KV Cache 直接复制给新请求剩余部分只需要对差异 token 做 Prefill。# radix_cache.py 核心逻辑 class RadixCache: def __init__(self): # key: 前缀 token 的 tuple # value: (kv_cache, ref_count, last_access_time) self.cache {} def match(self, tokens): # 从最长前缀开始匹配 for length in range(len(tokens), 0, -1): prefix tuple(tokens[:length]) if prefix in self.cache: return length, self.cache[prefix] return 0, None def insert(self, tokens, kv_cache): key tuple(tokens) self.cache[key] (kv_cache, self.cache.get(key, (None, 0))[1] 1, time.time())这个实现相当粗暴但它清楚地展示了前缀缓存的核心思想存完整前缀按最长匹配原则查询。真正的 SGLang 会把一个长前缀拆成多个短前缀块并共享引用计数以便淘汰时精细管理我的简化版是一整段存内存开销接近完全缓存但代码少了 100 多行。一个需要特别注意的问题是内存淘汰策略。如果不设上限Cache 会无限膨胀。我的做法是给 Cache 设置最大条目数超过后用last_access_time最旧优先淘汰。这个策略在 LRU 和 FIFO 之间折中实际测试下来在多轮对话场景效果可以接受。3.4 连续批处理调度器调度器负责维护 Waiting 和 Running 两个队列。每轮 Step 开始时从 Finished 里释放请求从 Running 里判断哪些请求还能继续生成如果 Running 没有满且有 Waiting 请求则把它们提升为 Running并标记为需要 Prefill。# scheduler.py class Scheduler: def __init__(self, max_running: int): self.max_running max_running self.waiting deque() self.running {} def schedule(self): # 清理 finished外部设置 finished [req for req, state in self.running.items() if state finished] for req in finished: del self.running[req] # 从 waiting 补位 prefill [] while len(self.running) self.max_running and self.waiting: req self.waiting.popleft() self.running[req] prefill prefill.append(req) # decode 列表 decode [req for req, state in self.running.items() if state decode] return prefill, decode, finished这里我刻意省略了处理 EOS 的细节因为调度器不负责 EOS 判断它只负责状态流转。EOS 判断由主引擎在采样后统一处理。调度策略有几种可配的选择最简单的 FCFS先来先服务以及 SGLang 默认的 LPMLongest Prefix Match最长前缀匹配优先。LPM 的原理是当 Waiting 队列里有多个请求时优先挑与当前 Radix Cache 有最长匹配前缀的请求这样能最大化缓存命中率。我在engine.py里给 Waiting 队列做了一次按前缀长度排序实测在共享 System Prompt 的基准测试里吞吐能提升 15% 左右。如果你的系统 Prompt 很长或者请求间共享大量历史内容这个优化非常值得加。3.5 采样器与流式输出队列采样器实现了 Top-K 和 Top-P 两种采样策略。核心是在 logits 上做归一化、过滤、再采样# sampler.py def sample(self, logits, constraint_maskNone): if constraint_mask is not None: logits logits.masked_fill(~constraint_mask, -float(inf)) # temperature logits logits / self.temperature # top-k if self.top_k 0: v, _ torch.topk(logits, min(self.top_k, logits.size(-1))) logits[logits v[-1]] -float(inf) # top-p if self.top_p 1.0: sorted_logits, sorted_indices torch.sort(logits, descendingTrue) cumsum torch.cumsum(torch.softmax(sorted_logits, dim-1), dim-1) sorted_indices_to_remove cumsum self.top_p sorted_indices_to_remove[..., 1:] sorted_indices_to_remove[..., :-1].clone() sorted_indices_to_remove[..., 0] False indices_to_remove sorted_indices[sorted_indices_to_remove] logits[indices_to_remove] -float(inf) probs torch.softmax(logits, dim-1) return torch.multinomial(probs, num_samples1).item()流式输出这里直接用 FastAPI 的 StreamingResponse 配合 asyncio.Queue客户端可以按行收到生成结果。实测效果是每生成一个 token 大约 10ms 就能推到客户端体验上接近逐字打字效果。3.6 约束解码JSON 输出示例约束解码的完整实现涉及编译正则到 DFA再构建 token 级转移表代码量比较大。我在这里给出一个简化版思路假设我们只需要保证输出是一个合法的 JSON且必须从{开头。# 简化版只约束第一个 token 必须是 { 开头 def json_prefix_mask(self, current_text: str) - torch.Tensor: mask torch.zeros(self.vocab_size, dtypetorch.bool) if current_text : # 只允许左大括号开头的 token for token_id in range(self.vocab_size): token_str self.tokenizer.decode([token_id]) if token_str.strip().startswith({): mask[token_id] True return mask这个实现只处理了第一步实际情况要复杂得多你得维护当前 JSON 状态机知道是在 key 阶段还是 value 阶段是字符串内还是数字内。SGLang 的 XGrammar 后端就是干这个的它对整个词表做压缩和预计算把约束解码的开销压到每 token 不到 0.1ms。我的简化版在词表 32K 的情况下经过这层 mask 后采样延迟从 2ms 涨到 5ms在 1.5B 模型上还是可用的。4. 实操过程与核心环节实现4.1 搭建可运行的Demo环境我建议你用 Qwen2.5-1.5B-Instruct 或 GPT-2 来做测试。Qwen2.5 是 chat 模型需要完整 chat template生成效果更贴近生产GPT-2 没有 chat template代码更简单适合先跑通流程。我实际测试时两个模型都用了GPT-2 先行调通Qwen 再验证真实效果。依赖只需要torch、transformers、fastapi、uvicorn、pydantic。在 4090 上 Qwen2.5-1.5B 的 prefill 峰值显存约 6GBdecode 时约 4GB完全不紧张。启动方式在一个 main 函数里先加载模型再启动调度线程python server.py --model Qwen/Qwen2.5-1.5B-Instruct \ --max-running 8 --max-total-tokens 8192 \ --radix-cache-size 64觉得参数合理就敲回车。第一次见效果可以准备两个请求一个带长 system prompt另一个复用完全一样的 system prompt观察第二个请求的 prefill 时间是否明显缩短。4.2 测试 Radix Cache 命中效果为了验证前缀缓存是否生效我在 Radix Cache 里加了一个hit_count计数请求结束打印命中率。测试方式是准备一组共享同一个 500 字 system prompt 的请求分别用开启和关闭前缀缓存的模式跑对比首 token 延迟。开启前缀缓存后第二个及后续请求的首 token 延迟从约 800ms 降到了约 200ms前提是 system prompt 完全一致。如果 system prompt 有一个字符的差异缓存完全失效延迟回到 800ms。这个结果说明 Radix Cache 是前缀匹配不是语义匹配工程上必须确保 prompt 模板严格统一。对于多轮对话场景缓存收益更明显。我模拟了一个三轮对话的请求序列前两轮的 KV Cache 都可以复用第三轮请求的首 token 延迟从 1.2 秒降到了 400ms 左右。这就是 SGLang 在真实业务里最大的价值所在。4.3 对比 vLLM 与原生 Transformers为了验证这个简化版引擎的性能到底在什么水平我拿同一份 100 个并发请求的测试集对比了三种方案原生 Transformers pipeline、简化引擎、以及生产环境的 vLLM。原生 Transformers 由于用朴素的批处理吞吐只有 15 req/s而且并发一高显存直接爆掉。简化引擎在小并发下表现不错8 并发时到 45 req/s但继续加并发时 prefill 和 decode 混在一个 batch 导致延迟抖动很明显吞吐增长曲线开始变得平缓。vLLM 在同样并发下能稳定维持 80 req/s主要是 PagedAttention 和更细粒度的调度在起作用。这个结果符合预期简化引擎已经能赢过朴素方案证明核心机制的理解是到位的。但要达到生产级水平还得补上显存管理、微批次调度这些优化这也验证了 SGLang 和 vLLM 这类框架的真正价值所在。5. 常见问题与排查技巧实录5.1 KV Cache 拼接导致维度不匹配这是我调试过程中遇到最多的一个坑。在 Decode 阶段我给每个请求维护各自的past_key_values然后按 batch 维度拼接后传给模型但模型内部对position_ids的处理依赖past_key_values的长度信息一旦拼接后的 seq_len 比模型预期短它就会在位置编码上出错。排查方法是加了一层断言检查拼接后每个请求的 kv 长度和它的position_ids最大值是否一致。这个问题暴露了简化实现的另一个硬伤用拼接方式管理多个请求的 KV Cache本质上要求 batch 内的 seq_len 必须一致这会让显存利用率降低 30% 到 50%。SGLang 的做法是为每个 token 单独分配显存块然后用索引表动态组合所以不存在这个限制。如果你的 batch 里请求的 prompt 长度差异很大一个短请求和一个超长请求放一起短请求的 KV 也要被 padding 到和长请求一样长白白浪费显存。可以设置max_batch_tokens阈值超了就拆分。5.2 asyncio 与调度线程的死锁问题第一次写流式输出时我用的是一个独立的调度线程去跑engine.step()HTTP 服务在主线程里从队列读取。结果发现并发请求一多整个服务就卡死。原因是 asyncio.Queue 内部没有锁保护多线程同时 put 和 get 会导致状态损坏队列 size 计算错误消费者永远等不到数据。解决方法是把调度线程改成在事件循环中的定时任务用asyncio.create_task跑一个每 10ms 执行一次的循环这样调度器和 HTTP 服务在同一个线程里不存在线程安全问题。这个改动让代码更简单但代价是 CPU 占用会稍微高一点因为它要频繁唤醒检查是否有 new 请求。后来看到 SGLang 的调度器设计发现它也有一个类似的 queue但它用 Cython 实现了内部队列所以直接在多线程下使用也没有问题。这算是 Python 的全局解释器锁带来的一个实际限制。5.3 约束解码时词表遍历太慢我的第一版约束解码在每次采样前遍历整个词表调用tokenizer.decode来验证 token 是否合法。32K 词表在 4090 上要花大约 60ms这直接让解码速度从 100 token/s 掉到 15 token/s完全不可用。优化方式是把 token 对应的字符串预先缓存成一个数组初始化时一次性计算好解码时直接用索引查表不再调用tokenizer.decode。这样 32K 词表的一次遍历降到 2ms。如果还想更快可以做成“token 到触发状态的转移表”一次遍历同时更新所有 token 的状态我已经把这种优化留作后续改进方向。5.4 显存泄漏与缓存淘汰Radix Cache 如果写得不严谨很容易出现显存持续增长的问题。尤其是把整个past_key_values塞到 Python dict 里之后即使淘汰了某些条目如果还有引用指向这些张量GC 不会立刻回收显存。最后我把 cache 的 kv_cache 值封装成弱引用在淘汰时显式del kv_cache加torch.cuda.empty_cache()。这能解决大部分问题但注意 empty_cache 本身有一定开销不能频繁调用。另一个经验是在多轮对话场景中不让 Radix Cache 无限增长而是设定一个显存预算比如 4GB。当超出预算时优先淘汰最久未访问的条目。这个策略在基准测试里能把缓存命中率从 100% 降到 85%但显存使用能始终保持稳定。6. 核心收获引擎框架的骨架与血肉手搓一遍之后我对 LLM 推理引擎的认知变化很多。以前看 SGLang 文档里“RadixAttention 提升 4 倍吞吐”这句话没有实感现在自己实现了简化版并在多轮对话场景测出 2 倍以上的提升才真正理解前缀缓存的收益来源。另一个深刻的体会是引擎框架的骨架是调度血肉是显存管理。调度策略决定了 GPU 有没有在干有效活显存管理决定了 GPU 能同时干多少活。我的简化版恰恰是在显存管理上最薄弱这也解释了为什么生产级框架都在 KV Cache 上做这么极致的优化。最后说一个实际经验如果你也想手搓一个推理引擎来加深理解多使用异步编程和内存分析工具来观察实际的内存占用和 CUDA 时间线不要全凭想象做优化。通过这次实践我发现相比从 repo 里读代码自己动手实现一遍确实更能建立直觉。这套简化版的完整代码我已经整理到了个人 GitHub 仓库基础配置下可以直接运行有需要的话可以参考一下。