
做过多路请求处理的人应该都有这种感觉最直接的方案是“来一个问题跑一次推理”一个循环下去什么问题都能解决但一旦并发上来、问题变多这套朴素方案立刻变得很难受。要么反复加载模型导致算力浪费要么上下文混在一起分不清谁是谁要么单个问题超时把整个服务拖挂。类Jev项目Kev正是奔着这个问题去的。简单说Kev是一套多问题隔离作答的系统核心就八个字一次前向多问题隔离作答。这篇文章是Kev实战系列的第二篇聊透它的架构设计。换句话说把本应该多次前向计算才能完成的多问题处理压缩到一次前向里完成同时保证每个问题之间的计算、状态、结果完全隔离。这个架构对三类人特别有价值需要做批量问答服务的开发者、在研究多路并发推理方案的工程师、以及想理解如何设计隔离式批量处理系统的同学。下面我从架构动机开始一层层把Kev拆开讲。1. 为什么需要“一次前向多问题隔离作答”单问题循环模式的困境1.1 朴素循环方案的核心浪费先看一个最常见的实现方式def answer_all(questions): answers [] for q in questions: a model.forward(q) answers.append(a) return answers这段代码看着清爽但性能账一算就心疼。每次model.forward(q)都会经历完整的模型加载、计算图构建、算子调用和结果返回过程。假设一个模型前向需要300ms10个问题就是3秒而且如果模型是用PyTorch写的每个前向之间还隔着Python解释器的调度开销、张量分配开销实际损耗比纸面计算更高。更隐蔽的问题是如果这些前向之间共享了模型内存中的某些缓存数据比如KV Cache那么第一个问题做完后留下的中间数据如果不清理会残留到第二次前向影响第二个问题的结果。所以通常还得定期清缓存、重建上下文这部分开销同样不可忽视。1.2 多问题场景下的隔离需求把多个问题一次性喂给模型铅笔最朴素的念头是“把问题拼成一个长字符串一次性输出”。听起来很美但仔细一想问题很大问题A的结尾可能正好和问题B的开头在语义上粘连模型会自己脑补出两个问题之间的“过渡句”最后给出的答案就会混着两个问题的信息。这就是为什么Kev要在架构层面对每个问题做隔离而不是简单拼接。隔离需求有三层计算隔离问题A在前向计算的任何阶段都感知不到问题B的存在。状态隔离问题A产生的KV缓存、中间激活值、临时变量不能残留在问题B的计算环境中。结果隔离输出序列中问题A的答案区间和问题B的答案区间要能清晰切分不混行。1.3 Jev走了哪条路Kev为什么不同Jev是上一篇文章里搭的早期版本项目采用的方案是“串行前向 人为清缓存”。每来一个问题Jev就执行一次单前向结束后把临时缓冲区全部复位。方案本身没有错在问题数量少、请求稀疏的场景下完全够用而且调试方便。但随着问题越来越密集串行模式的延迟逐步叠加整个系统的吞吐量被锁死了。Kev在Jev的基础上做了架构重构核心改动就是把“一次前向只做一个问题”变成“一次前向做多个隔离问题”。这不是简单的批处理因为普通的batch推理虽然算力利用效率高但问题之间在注意力层仍然有机会相互影响。Kev要在共享同一个前向过程的前提下把每个问题装进独立的“隔离舱”互不串扰。这就需要在架构层面引入一套新的机制。2. Kev的整体结构一次前向的数据通路2.1 入口汇聚多路问题如何汇成一批Kev的入口是一个轻量的网关层。实际项目里用的是异步消息队列客户端把问题投递到队列里Kev的调度线程每隔固定时间窗口比如200ms或者积攒到一定数量比如32个问题就做一次批量聚合组成一个批次。class BatchBuilder: def __init__(self, max_batch_size32, window_ms200): self.queue deque() self.max_batch_size max_batch_size self.window_ms window_ms async def collect(self): batch [] deadline time.time() self.window_ms / 1000 while len(batch) self.max_batch_size and time.time() deadline: q await self.queue_get() batch.append(q) return batch窗口机制是为了平衡延迟和吞吐窗口太短批次经常不满前向的并行收益打折扣窗口太长最早到达的问题等太久延迟飙升。实战中我一般把窗口设为“满足吞吐目标的最大可接受延迟的1/3”比如要求P95延迟低于600ms窗口就设在200ms左右留足后端推理的时间余量。每个问题在进入批次时会被分配一个全局唯一的problem_id,这个ID会贯穿整个前向过程最终用来切分答案。2.2 单次推理引擎与批处理布局Kev的推理核心是一个标准的Transformer前向过程,但输入张量的形状从[seq_len, hidden_dim]变成了[batch_size * seq_len, hidden_dim]。矩阵形状可以变化机制上必须解决的问题是同一个批次的多段文本在自注意力层怎么互相避开。Kev的做法是在每一层Transformer的多头自注意力里使用块对角掩码。块的含义很直接一个批次里有N个问题注意力矩阵就被切成N个方形小块只有每块内部允许相互可见块与块之间的信息通路全部用-inf填充。这个掩码矩阵在模型初始化阶段就生成好了前向过程中不参与梯度计算纯粹是布尔张量开销很小。2.3 输出分割从混合输出到独立答案一次前向结束后模型的输出张量同样是[batch_size * seq_len, vocab_size]的形状里面混着所有问题的答案token。Kev的答案回收层负责把这段混合输出按问题粒度切回独立答案。切分的依据不是单纯的token位置而是每个问题在批次中的起始偏移量和结束偏移量。因为Transformer是自回归式的解码每个问题的答案区间严格落在自己的seq区间内不跨越边界。拿到整个批次的输出token索引后按照偏移量切片、拼接每个切片就对应一个问题的完整答案。这里有一个关键设计前向过程里Kev并没有把问题之间的边界token删掉而是用一个特殊的SEP占位token隔开。即使掩码已经做了隔离显式边界仍然有必要。原因在于带有全局位置编码的Transformer对绝对位置敏感如果单纯拼接两个问题而不给边界标记模型容易把后一个问题的位置信息错认成前一个问题的延续影响生成质量。3. 隔离机制是怎么做到的掩码、路由与状态管理3.1 注意力隔离块对角掩码的构造细节Kev的核心掩码是块对角结构。假设批次里有三个问题长度分别是3、2、4那么注意力分数矩阵就是一个9x9的矩阵其中只有三道对角块是有效区域问题长度行范围列范围允许看到的区域问题130-20-2仅问题1自己的3个token问题223-43-4仅问题2自己的2个token问题345-85-8仅问题3自己的4个token在实现层面这个掩码的计算过程是先从全零矩阵出发按每个问题的长度在对角线位置填上1然后取反、乘上一个大负数。代码实现很直接def build_block_diag_mask(seq_lens, dtypetorch.float32): total sum(seq_lens) mask torch.zeros(total, total) start 0 for length in seq_lens: mask[start:startlength, start:startlength] 1.0 start length causal_mask mask # 结合因果掩码每个位置只能看到它之前的位置 causal torch.tril(torch.ones(total, total)) full_mask causal * mask return full_mask.masked_fill(full_mask 0, float(-inf))这个full_mask就是前向过程中注意力分数要加上的偏置。-inf经过softmax之后权重归零问题之间的信息通路被彻底切断。3.2 问题路由与身份标签设计掩码解决了“能不能看到”的问题“答案给谁”的问题则由路由层处理。Kev在汇聚阶段给每个问题打上标签除了problem_id之外还有一个answer_slot字段用来指示这个问题的答案在返回结构体里应该写入哪个位置。class ProblemEntry: def __init__(self, qid, text, answer_slot): self.qid qid self.text text self.answer_slot answer_slot批次推理结束后Kev的回收模块拿到的是按字节序排列的答案数组直接按answer_slot填入对应的结果槽位再做一次报文组装返回给上层调用方。这样路由逻辑和推理逻辑完全解耦前向引擎根本不需要关心“谁在等我回答”它只需要保证输出的token顺序和输入的问题顺序一致即可。3.3 状态隔离KV缓存与临时上下文的管理Transformer前向过程中每一层都会计算Key和Value矩阵这些矩阵在做增量解码时要被复用这就是KV Cache。Kev对KV Cache的管理方式是一条独立的隔离策略批次里每个问题的KV Cache单独存储互不引用。这样做的好处是第i个问题在做后续的token生成时只能访问自己前缀带来的K、V向量不会误读其他问题的内容。class IsolatedCache: def __init__(self, num_layers): self.cache {layer: {} for layer in range(num_layers)} def store(self, layer, qid, k, v): self.cache[layer][qid] (k, v) def retrieve(self, layer, qid): return self.cache[layer].get(qid)这里的qid维度隔离在概念上很像操作系统里的进程地址空间每个问题跑在各自的“地址空间”里虽然底层共享同一套物理内存同一个模型权重、同一块显存但逻辑上完全隔离访问越界的情况在架构设计上就不可能发生。3.4 异常隔离单个问题崩溃不拖垮整个批次一次前向跑到底最怕的就是某个问题在中途触发异常导致整批结果全部报废。Kev在异常处理上采用“槽位回收”策略把意外问题直接剔除剩余问题继续完成前向。做法是在批次构建阶段预留一层运行时异常映射表当某个问题在数据预处理阶段就发现长度超限或者格式非法Kev不会重新组合整个批次而是把对应problem_id标记为aborted同时将该问题在GPU侧的输入张量区域直接置零补padding前向照常进行。等推理结束后正常问题正常返回结果异常问题返回一个标准的错误码。这个设计的妙处在于它把“整批失败”降级成了“单点失败”前向计算的开销没有增加一分钱。代价是该问题占用的计算slot被浪费了但比起整个批次重新跑一遍这点浪费完全可以接受。4. 一次前向的性能账吞吐、显存与延迟的取舍4.1 一次前向等效于多少次顺序前向做架构设计不能只讲机制性能账必须算清楚。假设单问题顺向前向耗时T批次大小N那么理想情况下一次前向处理N个问题的耗时不是N×T,而是接近T加上一个远小于N×T的边际增量。原因在于模型权重只加载/常驻一次前向过程中所有问题共享同一批参数。算子启动开销被摊薄比如矩阵乘法的kernel launch时间只付一次。计算密度提升GPU或CPU上的向量化单元利用更充分。实测场景下一个12层的Transformer模型BatchSize8时一次前向处理8个问题的耗时大约是单问题前向的2.1倍到2.8倍。换句话说吞吐量提升到了原来的3到4倍。批次继续增大到32耗时比例约为5到6倍吞吐提升5倍左右。批次再往上收益开始递减原因主要是显存带宽打满和计算单元竞争。4.2 显存开销增长的实测观察显存成本必须单独评估。一次前向的显存开销主要由三块组成模型权重固定、激活值随批次线性增长、KV Cache随批次和序列长度线性增长。以768维hidden、12层、单问题平均序列长度512为例每个token的KV Cache占用大致是2K、V两组向量× 768hidden_dim× 12层数× 2字节fp16精度 36864字节 ≈ 36KB单个问题512个token的KV Cache约18MB。BatchSize8时KV Cache部分就需要约144MB。实战中发现一批32个问题同时进来时显存峰值主要被激活值吃掉了KV Cache反而不是大头。需要盯住的是“批次内最大序列长度”如果某个超长问题把整个批次的padding长度顶到很高那激活值的膨胀速度远超线性预期这时候要做长度裁剪。4.3 延迟与吞吐的权衡边界一次前向的代价是批次里所有问题的完成时间被拉齐到最慢的那个问题。也就是说短问题的答案也要等长问题做完才能返回。这对实时性要求极高的场景是个问题。我常用的经验法则是如果P50问题的预期耗时为200msP95问题预期耗时为900ms那么BatchSize16时P50问题的实际返回时间会被拉到接近P95的水平。要保住短问题的延迟必须控制批次上限或者做时间窗动态切割——到期就把当前积累的问题打包先跑不等满额。Kev的BatchBuilder里把窗口和上限并成两个控制旋钮就是为了在做吞吐和保延迟之间找平衡。下面这个表是我在一台8GB显存的消费级GPU上跑出来的典型对比数据能直观看出取舍关系批次大小单问题平均耗时P95延迟吞吐量问题/秒显存峰值1280ms300ms3.51.2GB8520ms580ms15.32.8GB16780ms860ms20.54.1GB321.4s1.6s22.87.6GB批次从8涨到32吞吐只增加了不到50%显存却涨了接近两倍P95延迟从580ms暴涨到1.6s。所以在Kev的默认配置里我通常把批次上限压在8到16之间具体看业务对延迟的容忍度。5. 工程落地Kev在本地部署中的具体实现5.1 数据结构选型从问题到答案槽Kev的核心数据结构围绕两个概念批次输入和答案槽。批次输入是一个列表列表每个元素是ProblemEntry答案槽是一个定长数组长度等于批次大小用answer_slot索引写入结果。dataclass class BatchResult: slot: int qid: str answer: str error: Optional[str] None为什么用定长数组而不是字典因为GPU前向输出的结果本身就是按索引排列的连续向量用定长数组可以直接用下标写入省掉一次哈希计算。在批次上千级别时这个细节能省几毫秒。答案槽的索引在汇聚阶段就固定不变所以回收阶段也不需要重新排序。5.2 一次前向主流程的伪代码整个Kev前向主流程可以浓缩成下面这段伪代码def forward_batch(questions): entries [ProblemEntry(qidq.id, textq.text, answer_sloti) for i, q in enumerate(questions)] batch_tokens tokenize(entries) mask build_block_diag_mask(seq_lens) if use_cuda: batch_tokens batch_tokens.to(cuda) mask mask.to(cuda) outputs model.forward(batch_tokens, attention_maskmask) answers slice_by_offsets(outputs, offsets[e.answer_slot for e in entries]) results [] for entry, ans in zip(entries, answers): results.append(BatchResult( slotentry.answer_slot, qidentry.qid, answerans )) return results注意这里slice_by_offsets不能直接对logits做最好先argmax取token索引再做去padding和拼接最后交给解码器生成文本。如果显存充裕也可以直接保留logits回流到计算图但Kev定位是推理服务不做训练所以直接解码更省开销。5.3 部署配置与依赖建议Kev的本地部署依赖很简单核心就三样PyTorchCPU版或GPU版均可、tokenizer库、以及一个消息队列服务本地用Redis Stream就够。配置通过一个yaml文件统管kev: inference: device: cuda # 可选 cuda / cpu max_batch_size: 16 window_ms: 200 max_seq_len: 512 dtype: fp16 kv_cache: isolated # 隔离模式 queue: type: redis_stream endpoint: localhost:6379CPU环境也能跑只是批次收益比GPU小很多。我之前在一台4核的笔记本CPU上测试BatchSize4时单问题耗时反而比BatchSize1略高原因很简单CPU没有足够的并行单元来分摊多问题计算批处理反而引入了张量拼接和拷贝的额外开销。所以一个忠告CPU部署时默认批次大小不要超过4或者干脆保持单问题前向。6. 实战中踩过的坑与排查链路6.1 答案张冠李戴路由错位排查第一次跑通Kev的批次推理时出现了很诡异的症状问题1的答案变成了问题2的答案而且只发生在批次大小大于1的情况。一开始怀疑是注意力掩码的问题但检查了半天掩码生成逻辑没有错。最后定位到根因BatchBuilder在汇聚问题时用的answer_slot是基于“问题进入队列的顺序”分配的但tokenize之后重新排列了问题的顺序比如按长度做了padding优化导致前向输出的顺序和answer_slot的映射表对不上了。修复方式是在tokenize之前冻结排序或者在排序后同步更新每个ProblemEntry的answer_slot。这个坑提醒我一次前向管线里的“顺序”必须由同一个数据结构统一管理前端路由和后端推理各自维护一份顺序很容易错位。6.2 显存峰值暴涨批次长度失控有一段时间Kev在跑几个超长文本问题时显存直接被打爆。检查发现max_seq_len512只对单个问题生效但padding是批次级的。也就是说只要批次里有一个问题长度是380其他所有短问题都被pad到380整批序列全被拉长。BatchSize16时实际计算的token数量远高于预期的均值。排查链路第一步打开GPU显存监控确认峰值发生在model.forward而不是数据加载阶段。第二步检查每个问题的长度分布发现一个2200字的超长文档混进了批次。第三步定位到预处理阶段没有对问题做长度裁剪。第四步加了一个动态长度裁剪超过max_seq_len的部分截断同时给超长问题单独标记length_warning答案里附上“部分内容被截断”的提示。显存问题解决后P95延迟也降回正常水平。这个坑的本质是“最慢者拖垮整批”在架构里必须对长度分布做预期管理不能指望所有问题长度均匀。6.3 空响应与全零输出掩码位置偏移还有一次批次中部分问题返回空字符串而且只在某个特定长度组合下复现。复现路径问题A长度15、问题B长度21、问题C长度9特定顺序下C返回空。排查过程从掩码入手打印出构建好的full_mask矩阵逐块检查后发现问题在build_block_diag_mask里用了total作为矩阵总长度但个别问题的seq_len在tokenize后发生了变化比如加了特殊token导致偏移量错位对角线块被错位切割。某个小方块里全是-inf,该问题在注意力层全部被屏蔽模型只能基于位置编码猜答案最后解码出来全是padding token。修复方式很简单tokenize之后重新读取每个问题的实际seq_len再进入掩码构建。这个坑给到的经验是任何“动态长度”场景下长度信息必须在最后一个可能修改长度的地方获取而不是最早读一次就缓存到后面用。6.4 隔离模式下的性能回退与排查另一个值得记录的案例是当我把KV Cache切换成isolated模式时本来只有2.8GB显存峰值的批次直接跳到4.5GB。原因是隔离模式下每个问题独立缓存KV不再有共享复用缓存总量随批次线性增长而且每一层的缓存都要单独维护一个qid索引结构Python层开销也大了。我最后的处理方法是默认隔离模式只开在层维度不做KV交叉复用如果确认问题之间没有任何信息交互需求且信任数据源可以把KV Cache切换成batched模式但此时必须接受理论上存在串扰风险。这个权衡再次验证了隔离是有成本的架构设计里所有关于隔离的选择本质上都是在“风险”和“性能”之间做交易。7. 本地部署常见的搜路与诊断命令7.1 检查批次计算是否正确跑通以后不要急着上业务先做一组对比测试用单问题模式跑10个问题记录答案再用一次前向模式跑同样10个问题对比答案是否一致。因为掩码和隔离机制的存在理论上两批答案应该完全相同。python kev_cli.py --mode single --questions sample.json python kev_cli.py --mode batched --questions sample.json diff result_single.json result_batched.json如果diff有差异优先怀疑序列排序和掩码偏移问题。这是最廉价、最有效的回归测试方法。7.2 定位显存问题显存问题最常见的信号是CUDA out of memory。我的排查习惯是先在配置里把dtype从fp16切回fp32跑一遍如果fp32能跑通说明主要是缓存精度问题导致的显存翻倍而不是代码逻辑问题。如果fp32也跑不通再用torch.cuda.max_memory_allocated()打印各阶段显存峰值定位到具体层。torch.cuda.reset_peak_memory_stats() outputs model.forward(batch_tokens, attention_maskmask) print(torch.cuda.max_memory_allocated() / 1024**3)7.3 检查是否真的隔离了验证隔离效果有一个很简便的方法构造两个长度相同但内容完全相反的问题比如“今天天气怎么样”和“今天天气非常糟糕”放进同一个批次。如果两个问题给出的答案风格、观点不互相沾染基本可以判定注意力隔离生效了。更严格的测试是把问题B的文字前后颠倒让模型难以理解看问题A的答案质量是否受影响。这个测试跑完架构设计心里就有底了。8. 下一步可以这样扩展Kev目前的架构已经把“一次前向、多问题隔离作答”跑通了但继续往下深挖还有几个方向。一个是用连续批处理替代固定时间窗批量聚合让新到的问题能插进正在跑的前向过程进一步提高GPU利用率另一个是给隔离机制加上更细力度的权限控制做到“问题A看不到问题B”的同时允许指定问题之间共享同一个上下文池适合做多轮对话场景。还有一个我打算尝试的是把块对角掩码的构造放进自定义CUDA算子省掉Python层生成掩码的时间。这个扩展可以在下一篇实战里细化到时候拿真实数据对比一下收益有多大。就我个人经验来说Kev这个架构在改进型推理服务里的收益远比初学者想象的要大但也远比追求极致性能的人希望的要小隔离一定是有成本的批次越大、隔离粒度越细浪费的计算slot就越多。这个度怎么拿捏需要在真实业务需求里反复试才能找到感觉。