资讯详情

从源码到实测:vLLM-Ascend Prefix Cache 如何复用前缀,又能快多少?

📅 2026/10/17 4:57:00 | 华诺云谱 👁 阅读
从源码到实测:vLLM-Ascend Prefix Cache 如何复用前缀,又能快多少?
从源码到实测vLLM-Ascend Prefix Cache 如何复用前缀又能快多少第一篇文章追踪了一次请求从 Prefill、Decode 到结束和块回收的过程第二篇把这些执行过程放进服务场景整理了调度、缓存和并行等特性的分工。这一篇选择其中一个具体问题继续深入一份文档已经处理过再围绕它提出另一个问题模型究竟能少算什么先给出实测发现在 Qwen3-0.6B、单 NPU、Eager、单请求的配置下1K 前缀没有表现出稳定的整请求收益扩大到 16K 后首 token 等待从 318.51 ms 降到 47.28 ms但固定生成 64 个 token 的总耗时只减少约 11.1%。前缀命中比例、首 token 加速比与整请求收益是三个不同的指标。下面先用对照实验确认复用再沿本地源码解释“怎样找到可复用前缀”最后分析冷、热缓存的时间差。输入采用可控的重复文本结论限定于这套配置不代表自然文档质量或多用户吞吐。实验完成于 2026-10-11、2026-10-13本文整理于 2026-10-14。1. 相同的是文档不一定是问题和答案假设一个资料问答服务先后收到两个请求A[共同文档] 问题 A请简要描述缓存的作用。 B[共同文档] 问题 B重复输入可以省下什么计算A 和 B 的后续问题不同但输入开头的文档相同。A 完成 Prefill 时已经为文档中的 token 计算了各层的 K、V。若这些状态仍在缓存中且模型等计算条件兼容B 就有机会直接复用共同前缀的 KV继续处理自己的问题。KV Cache 保存的是模型计算得到的数值张量Prefix Cache 让相同前缀对应的这些张量能够跨请求复用。它没有把“这篇文档的答案”存下来。B 仍然要处理不同的后缀并逐步生成自己的回答。复用主要减少重复的 Prefill 工作不能用命中比例直接推算整次请求的加速比例。vLLM Automatic Prefix CachingA处理共同文档 → 建立前缀 KV → 处理问题 A → 生成答案 A → 请求结束 │ 可复用内容仍在 │ ▼ B查找共同前缀 → 复用已有 KV → 处理问题 B → 生成答案 B这也连接了第一篇中的块回收问题A 结束意味着它不再占用这些块的请求引用并不必然意味着缓存内容立即被覆盖。能否继续命中要看相应内容和缓存索引是否仍然有效。请求释放、缓存保留与后续淘汰需要分别观察。2. 先把“共同前缀”固定下来实验不能只说“两段文字看起来一样”。模型实际接收的是 token ID 序列中文字符数也不等于 token 数。为了让输入关系可检查脚本直接构造并保存 token IDs。具体做法是将一段关于推理缓存的短文本分词重复并截取为1,024 个 token 的固定共同段然后分别拼接 A、B 的问题后缀。这个重复文本是机制测试输入用来控制变量不用于评价文档理解质量。A [共同段1,024 tokens] [后缀 A] B [共同段1,024 tokens] [后缀 B] B′ [共同段中改 1 个 token] [后缀 B]构造时不使用聊天模板后缀也直接以 token IDs 拼接避免把整段字符串重新分词时改变边界。A、B 保证共享这 1,024 个 token本次检查保存的输入发现两个后缀开头还共享 2 个 token因此实际最长共同前缀为1,026 个 token。预设共同段长度、实际共同前缀长度与最终缓存命中数需要分别记录。B′ 只修改 B 的一个早期 token默认是下标 17也就是第 18 个位置长度和后缀保持不变。它用于观察前缀改变后的命中行为。即使修改点后面的许多 token 相同它们所处的历史上下文已经不同不能把它们当作一段可以跳过去重新接上的普通精确前缀缓存。vLLM 的缓存标识会结合当前块及此前前缀的信息。前缀缓存设计3. 用四组条件区分缓存命中与运行预热本轮使用 Qwen3-0.6B在单 NPU、TP1、PP1 条件下离线执行。脚本设置 Eager、同步调度、每次一个请求使用贪心采样并固定生成 3 个 token。实际 block size 为 128。Chunked Prefill 保持启用调度 token 预算为 2,048大于本轮的 1,038-token 输入它不是本轮改变的变量。运行版本记录如下项目实验环境平台与设备HiDevLab A3npu-smi上报型号Ascend910每芯片 HBM 65536 MB实验只使用可见逻辑设备 0驱动26.1.110 月 13 日性能实验的driver/version.infoCANN Toolkit9.1.0内部版本V100R001C11SPC001B24310 月 13 日安装信息Python3.12.13PyTorch / torch-npu2.10.0cpu / 2.10.0.post4vLLM / vLLM-Ascend0.26.0empty / 0.26.0rc1Transformers5.14.1vLLM 运行源码提交568afb3a13806beb53bb2e6bd518269357b237c0vLLM-Ascend 运行源码提交f2f74a16c3c50a76f4349d807918e83edec1e35c设备型号按原始上报值记录不根据平台名称推断芯片后缀。10 月 13 日两轮性能实验的驱动与 Toolkit 记录一致10 月 11 日机制实验只保存了npu-smi的26.1.1版本信息未单独采集 Toolkit 安装版本。实际固件版本未采集。证据摘录与三轮配置对照见附录 A。两个运行仓库检查时均无未提交改动。模型来自环境中已有的本地文件下载记录只标记了master尚未取得模型与 tokenizer 的不可变 revision本轮保存了权重、配置和 tokenizer 文件的 SHA256用于识别实际使用的文件。对照分为 OFF 和 ON 两个独立进程以切换引擎的前缀缓存配置。ON 进程内部保持同一个引擎按组重置前缀缓存并顺序提交请求条件缓存设置与请求顺序要回答的问题OFF关闭 Prefix Cache分别执行 B 和 B′两个输入各自的参考输出是什么COLD开启缓存重置成功后执行 B开关已打开、但目标前缀尚未准备时报告什么命中WARM独立重置后先完整执行 A再执行 BA 留下的前缀能否被 B 复用PREFIX-CHANGED独立重置后先完整执行 A再执行 B′修改前缀后命中会发生怎样的变化每个进程都先执行一个无关的短请求用于预热。在 ON 进程中只有reset_prefix_cache()明确返回True后才进入相应对照组请求按顺序完成避免仍有活跃请求干扰重置。实际执行顺序可以直接展开为关闭缓存的进程加载模型 → 预热 → B → B′ → 退出 开启缓存的进程加载模型 → 预热 ├─ 清缓存 → B 观察冷缓存 ├─ 清缓存 → A 完成 → B 观察 A 留下的前缀能否复用 └─ 清缓存 → A 完成 → B′ 观察修改前缀后的行为图中的三组按从上到下的顺序执行不是并发运行。A 与随后 B 之间不清缓存不同对照组之间才清缓存。如果不在第二组前清理第一组的 B 自己也可能留下可复用内容就无法清楚说明后一次命中来自 A。例如执行完 A 后再执行 B保存的请求结果报告输入 1,038 token命中 1,024 token输出文本为可以省下。关闭缓存和冷缓存时同一个 B 也生成可以省下只是命中数为 0。文本只有这么短是因为实验固定生成 3 token并没有要求输出完整回答。输入构造、执行命令与结果文件的对应关系见附录 B复现实验与证据。完整操作过程随实验附件保存。这里的COLD 指前缀缓存状态冷不是模型冷启动。清理前缀索引不等于卸载模型也不等于把设备中的所有 KV 字节清零。模型加载、算子预热与前缀命中属于不同过程所以“第二次更快”本身不足以证明 Prefix Cache 生效。本轮脚本先观察机制不计时。输出比较也必须固定输入COLD_B、WARM_B 对照 OFF_B修改后的 B′ 对照 OFF_Bprime。A、B、B′ 是不同请求不要求它们生成相同答案。即使这些样本的输出完全一致也只说明本次固定条件下的对照结果。4. 本次实验保存的请求结果能告诉我们什么本次实验里脚本每发出一个请求都会等它完成生成再把返回结果中的字段整理成一份 JSON。因此这份文件记录的是一次完整请求的结果摘要不是模型第一轮 Prefill 或每轮执行的日志。例如先运行 A再运行 B保存的 WARM_B 原始记录包含以下字段节选文件位置见附录 B{input_tokens:1038,num_cached_tokens:1024,output_token_ids:[73670,65770,16872],output_text:可以省下,finished:true}这段记录的意思是B 输入了 1038 个 token框架报告命中 1024 个 token最后生成了 3 个 token对应文本为“可以省下”请求已经结束。它告诉我们这次请求的输入、命中和输出但没有列出第一轮、第二轮分别处理了多少 token也没有记录每轮耗时。完整 JSON 还包含请求标识、输入 token IDs 和结束原因等信息。各字段的用途如下字段含义与用途label、request_id区分对照条件并把结果对应到具体请求input_tokens实际提交的输入 token 数包含共同段与后缀input_token_ids、reported_prompt_token_ids检查提交的输入与框架报告的输入是否一致num_cached_tokens框架返回的请求级命中 token 统计cache_metric_available判断当前版本是否提供命中统计缺失值保留为null不能当成零命中output_token_ids、output_text保存生成结果跨条件比较优先核对 token IDsfinished、finish_reason检查请求是否结束以及结束原因那么1038 - 1024 14能说明什么这里要分清**记录中直接看到的**输入长度为 1038框架报告的前缀缓存命中数为 1024。**根据这两个数字推算的**还有 14 个输入 token 没有被命中的前缀缓存覆盖需要继续处理。这个减法有意义但“剩余 14 个输入 token”不等于“日志已经显示某一轮执行了 14 个 token”。调度器会决定每轮安排多少工作剩余输入可能受到本轮 token 预算等限制仅凭请求结束后的这份摘要看不出它们具体怎样分轮执行。要确认每轮实际安排了多少才需要查看 Scheduler 的逐轮调度记录。同样14 也不代表整个请求的全部计算量。处理这 14 个 token 时Attention 仍然需要使用前面缓存的 KV前面 1024 个 token 的上下文没有消失随后继续生成答案还会进行 Decode。token 数只是长度计数不能直接当作模型的运算量或耗时。所以本次保存的记录能核对“输入是什么、报告命中了多少、生成了什么”。它没有展示逐轮任务和缓存块的引用过程也没有保存 logits模型对候选下一 token 的打分或计时结果。因此本章只据这些记录解释请求级命中和输出对照不据此声称每轮执行量、候选打分或提速幅度已经得到验证。5. 源码怎样判断并找到可复用前缀先用一个资料问答的例子说明为什么需要这套机制。假设两个人先后提交了同一份很长的产品说明书只是各自的问题不同A[同一份长说明书] “这个产品有哪些主要功能” B[同一份长说明书] “使用时有哪些注意事项”这里假设模型和配置相同输入从开头到说明书末尾的 token 序列一致如果说明书前面还有系统提示等内容那些内容也必须相同。这是说明动机的例子实际实验使用的是第 2 节构造的固定 token 输入。A 处理说明书时已经计算了对应的 KV。B 到来后如果再把相同说明书从头计算一遍这部分 Prefill 工作就重复了。我们希望 B 能接着使用 A 留下的这段计算结果把后续工作用于自己的问题和回答。但框架接到 B 时并不会自动知道“它应该接着用 A 的哪部分结果”。缓存里可能留着许多请求的 KV框架需要先弄清三件事**内容对不对**B 开头这段输入是否与缓存对应的前缀相同**结果还在不在、在哪里**对应的 KV 是否仍可复用存在哪些缓存块中**能接着用到哪里**从输入开头算起连续多少个 token 的结果可以复用比如缓存只保留了说明书前半部分的 KVB 就不能把整份说明书都算作已经处理过。因此光知道“以前处理过这份文档”还不够还要找到现在确实可用的那部分结果。这就是本章要追踪的源码动机让框架能够识别相同前缀、找到对应 KV并确定可以跳过多少重复的前缀计算。实现上可以把缓存索引理解为一份查找目录框架为每个完整 token 块生成包含前文信息的“指纹”哈希在处理 A 的过程中登记“指纹 → 缓存块”B 到来后生成自己的指纹从第一块开始依次查找找到就累计遇到未命中或查完允许范围就停止返回可复用的块和对应的 token 数。哈希用来识别要找的前缀索引用来定位缓存块连续命中的块数决定能复用多长。下面沿这条主线把我们的 A→B 实验对应到具体代码。本节依据本地阅读的 vLLM 提交84030bbe3d74d99bad477a3d2e37a973ccd8865c源码链接指向公开仓库的同一固定提交入口名称与行号以该提交为准。它与第 3 节远程运行提交不同。范围限定为本次小模型所对应的普通单组 Full Attention、相同模型及兼容配置、无外部 KV connector 的完整块路径。5.1 为完整块生成标识这里的“块”先理解为按固定数量 token 划分的一小段输入。本次实验的块大小是 128也就是每 128 个连续 token 分成一组。它按 token 数划分不要求刚好是一句话或一个自然段。以 B 的 1038 个输入 token 为例分组如下按输入顺序示意第 1 块第 1128 个 token共 128 个 第 2 块第 129256 个 token共 128 个 …… 第 8 块第 8971024 个 token共 128 个 尾部 第 10251038 个 token共 14 个尚未满一块“完整块”就是这一组已经凑满 128 个 token。因而本例有 8 个完整块和一个未满块的尾部128 是本次实验的配置并非所有模型和部署都固定使用这个值。还要区分输入与存储输入块是这一段 tokenKV 缓存块则用于存放对应 token 的 K、V 数值。本例每个 KV 块有 128 个 token 的存储槽位各层有相应的 KV 存储并不是一个块只有 128 个数。给输入块生成标识是为了之后能根据相同前缀找到对应的 KV 缓存块。尾部的 14 个 token 仍需正常计算、保存 KV只是在本节的完整块哈希路径中暂时不为这段未满块的尾部生成块标识。生成哈希的用途是把“我要找哪段前缀的 KV”变成一个方便查表的键。可以把这个键理解为根据输入内容算出的“查找编号”。假设 A 已经完成B 随后到来第一块的 128 个 token 和相关条件相同查找过程可以这样理解h0、P0 均为示意名称省略缓存组信息A 完成后缓存目录中有 h0 → P0P0 对应已计算好的 KV B 到来时相同前缀 → 算出同样的 h0 → 查目录 → 找到 P0 → 复用 KVB 根据已有输入 token 就能生成这个查找键不需要先重新运行模型计算这段 KV。哈希本身也不表示缓存一定存在如果目录里已经找不到对应块就不能通过这个键复用旧结果。因此生成哈希解决“查什么”随后查缓存索引才解决“有没有、在哪里”。Request.update_block_hashes 调用请求保存的_block_hasher将生成的哈希加入request.block_hashes。具体闭包由 get_request_block_hasher 创建它在第 842 行取出一块 token IDsblock_tokensrequest.all_token_ids[start_token_idx:end_token_idx]block_hashhash_block_tokens(caching_hash_fn,prev_block_hash_value,block_tokens,extra_keys)循环只处理完整块不足一块就停止。以本次块大小 128、B 输入 1038 为例初始输入能生成 8 个完整块的标识最后 14 个 token 暂不构成第 9 个完整块。hash_block_tokens 的关键算式位于第 648 行hash_function((parent_block_hash,curr_block_token_ids_tuple,extra_keys))三个组成部分分别是前一块的哈希、本块的 token IDs以及 LoRA 等附加条件的标识。本次固定模型的纯文本对照中附加条件保持一致。哈希可以理解为用于查表的指纹它既不是 KV 内容也不是实际存放 KV 的物理块号。其中parent_block_hash将各块串起来。下面的 h0、h1 是示意名称不是采集到的真实哈希值h0 hash(统一初始值, token[0:128], 附加条件) h1 hash(h0, token[128:256], 附加条件) h2 hash(h1, token[256:384], 附加条件) ... h7 hash(h6, token[896:1024], 附加条件)因此h1 不只依赖第二块的文字还包含前面第一块的信息同理h7 的标识联系着从开头到第 1024 个 token 的前缀。这样做与因果 Attention 的语义一致一个位置的计算状态可能受前面上下文影响后面文字相同并不足以保证其 KV 可以直接复用。5.2 A 留下的块如何进入查找索引BlockPool.cache_full_blocks使用请求预先生成的哈希为缓存完整块登记索引其调用的_insert_block_hash在第 638 行执行self.cached_block_hash_to_block.insert(block_hash_with_group_id,block)可以把它理解为下面的概念映射P0、P1 只是示意物理块并非本轮测得的块号(h0, 缓存组) → 缓存块 P0 (h1, 缓存组) → 缓存块 P1 ... (h7, 缓存组) → 缓存块 P7索引在请求处理过程中维护并不是必须等请求结束才登记登记元数据的时刻也不能直接当作设备计算完成的时刻。本实验明确等 A 完整返回再提交 B因而先准备好了 A 的计算结果。A 结束时free_blocks会减少块引用并将不再被请求占用的块放回可分配队列它不必立即清除这些块的缓存索引。只要相关索引与内容仍有效后来的 B 就可能命中被淘汰或显式重置后则不能继续依靠该条记录复用。这里能看出为什么“算出哈希”不等于“缓存命中”首次 A 一样能算出 h0h7但当时索引里没有相应块B 后来用相同的标识查表才有机会找到 A 留下的块。5.3 判断是否允许查找再逐块查表本地阅读版本把开关判断提取为 prefix_cache_lookup_enabledreturnself.enable_cachingandnotrequest.skip_reading_prefix_cache允许查缓存需要同时满足两个条件引擎开启缓存而且当前请求没有要求跳过缓存读取。允许查找和实际命中是两件事。即使允许查找缓存里也可能没有对应的前缀。get_computed_blocks(request) 接收一个请求对象用它描述的输入和缓存标识查找可复用内容。若不允许查找这个提交中的函数直接返回空块集合和零计数允许查找时核心调用是computed_blocks,num_new_computed_tokens,_(self.coordinator.find_longest_cache_hit(request.block_hashes,max_cache_hit_length))这段摘录用_省略了本轮不展开的第三项管理信息。可以把核心调用读成一句话拿着当前请求的块标识在允许的长度范围内寻找最长的可复用前缀。request.block_hashes是用于查找的块标识序列包含前缀关系它不是设备中的 KV 数值本身。max_cache_hit_length限制本次查找的最长范围完整块和末端计算等边界也会影响最终命中长度5.4 节将结合实际输入说明。单组路径中coordinator 把查找转交给FullAttentionManager.find_longest_cache_hit。在 single_type_kv_cache_manager.py 第 771 行普通完整块分支的实际循环是forblock_hashinitertools.islice(full_block_hashes,max_length//block_size):cached_blockblock_pool.get_cached_block(block_hash,kv_cache_group_ids)ifnotcached_block:breakforcomputed,cachedinzip(computed_blocks,cached_block):computed.append(cached)hit_lengthlen(computed_blocks[0])*block_size这段循环的行为很具体从第一个块开始查查到就加入命中列表第一次没查到就停止。zip在这里处理缓存组本轮只需关注单组里按顺序累积的块。其中BlockPool.get_cached_block在block_pool.py第 217 行真正查询索引blockself.cached_block_hash_to_block.get_one_block(block_hash_with_group_id)未找到返回None找到返回缓存块管理对象。“最长前缀”是通过从开头累计连续命中的块得到的不需要先与 A 做一次全文比较、算出共同前缀是 1026。5.4 用这条源码链解释 1024 与 0B 与 A 的前 8 个完整块 token IDs 相同在同一引擎和相同附加条件下会得到相同的 h0h7。A 完成后相关缓存块仍可复用B 就能连续找到 8 个块命中长度为8 × 128 1024。本例不是“查到第 9 块失败”B 的初始输入只有 8 个完整块查找上限1038−11037也最多容纳 8 个完整块。因此查完这 8 个候选后就结束最后 14 个 token 中即使还有 2 个与 A 相同也不能形成额外完整块命中。查找上限保留末端重算空间的细节可另作边界实验。B′ 修改的是下标 17即第一个块内部的 token。本块 token 变化会改变 h0并通过父哈希影响后续块的标识。对本次缓存内容查第一个块就未命中循环立即break所以返回 0不会跳过第一个块再从后面相同的文字重新开始复用。以上是本地源码对现象的解释本轮只采集了请求级命中没有记录每个真实哈希、查表事件或物理块号。5.5 返回的是块与长度随后才决定计算任务最终get_computed_blocks将结果整理后返回。首轮关注前两项返回值本轮如何理解blocks找到的可复用块的管理对象描述要复用哪些缓存块num_new_computed_tokens此次查找确认可以视为已计算的 token 数这里的“已计算”来自缓存复用不是这个函数刚执行了一次模型前向这段逻辑运行在主机侧处理缓存管理信息不在这里计算 NPU Attention。找到块之后调度器使用命中长度安排剩余工作模型执行时再读取相应 KV。下面的简图连接缓存查找与调度。它依据本地阅读版本的普通新请求路径整理箭头表示处理先后与数据传递省略中间辅助调用和其他模型分支关键源码入口见附录 D。这张图是静态阅读结果不是逐轮运行日志。例如将 B 的输入长度 1,038 和热缓存命中长度 1,024 代入减法会得到初步待算量 14再经过预算限制和分配检查才形成实际调度任务。因此读源码时应分别找到“命中长度从哪里返回”与“本轮计算量在哪里写入任务单”。6. 首轮机制实验的结果OFF 与 ON 两次进程均成功结束ON 进程中的三次缓存重置均返回True。原始 JSON 已保存并核对传输前后哈希及各组输入关系。A、B、B′ 的实际输入长度均为 1,038 token。与问题直接相关的结果如下条件与输入请求级命中 token 数生成的 3 个 token IDOFF_B0[73670, 65770, 16872]COLD_B0[73670, 65770, 16872]A 完成后的 WARM_B1,024[73670, 65770, 16872]OFF_Bprime0[65770, 16872, 100768]A 完成后的 PREFIX_CHANGED_Bprime0[65770, 16872, 100768]这组结果把“开启缓存”和“实际复用”区分开了COLD_B 虽然开启缓存仍报告零命中同一引擎先完成 A 后WARM_B 报告命中 1,024 个 token。A、B 的实际共同前缀为 1,026 个 token按实际 block size 128只够组成 8 个完整块即 1,024 个 token。共享内容长度没有直接变成命中长度结果还受到完整块边界的约束。B 的输入 [128][128][128][128][128][128][128][128] [剩余 14] └──────── 8 个完整块共 1,024 token ────────┘ 本轮缓存命中 未命中 实际共同前缀还延伸到剩余部分的前 2 个 token 但这 2 个 token 不足以构成另一个完整的 128-token 块。B′ 只改变共同段中下标 17 的 token本次报告零命中。它与自己的 OFF 对照输出一致B 的三个缓存条件也得到相同的 3 个输出 token。这支持本次样本中的输出一致性尚不能推广为任意输入、长输出或所有执行配置下的验证结论。虽然1,038 - 1,024 14这个差值不能写成“日志证明首轮只计算了 14 个 token”。目前拿到的是请求级命中统计还没有逐轮调度证据也没有逐个缓存块的引用与释放记录。接口实验已回答“是否复用”要继续确认“复用怎样改变实际安排的计算”还需沿 Scheduler 补充运行观测。这轮机制实验没有计时或采集 profiler不能从它推算加速比。2026-10-13 另行执行的性能实验见下一节两轮使用相同输入但性能实验把 B 的输出长度固定为 64 token。本轮的实验脚本、执行过程和结果核对摘要统一列在附录 B。7. 命中 1024 个 token实际能快多少1038 个输入 token 中有 1024 个命中比例约为 98.7%。但这个比例数的是输入长度一次请求还包含调度、模型执行和逐步生成答案不能直接把它当成时间节省比例。7.1 把模型预热与前缀缓存预热分开模型预热是先执行请求尽量排除初始化和首次执行的影响前缀缓存预热是先处理 A把 B 所需的前缀 KV 留在缓存中。模型已经预热不代表 B 必然命中清掉前缀缓存也不等于重新加载模型。冷、热两组都开启 Prefix Cache都先完整运行同一个 A只改变 B 到来前是否保留缓存冷组清前缀缓存 → A 完成 → 再清前缀缓存 → [计时 B] 热组清前缀缓存 → A 完成 → 保留前缀缓存 → [计时 B]A 和清缓存不计入 B 的耗时。每次独立启动先做 3 对预热再做 8 对正式测量冷、热顺序交替共 3 次启动、24 对正式请求。B 固定 1038 输入、64 输出A 固定输出 3 token。模型、软件和执行方式沿用第 3 节使用 BF16max_model_len与 token 预算均为 2048完整命令见附录 B。7.2 计时范围从提交请求到收到输出脚本使用 A3 本机的单调时钟在调用add_request前开始计时每次step()返回后立即记时间再检查新增输出提交 B 收到第 1 个 token 收到第 64 个 token / 完成 │ │ │ └──── 首 token 等待 TTFT ────┘ │ └────── 首到末 token 的输出耗时 ───────┘ └────────────────────── 请求总耗时 ─────────────────────────────────┘本次每个输出事件恰好新增一个 token末 token 与完成事件同时返回。后续 token 的平均耗时 TPOT按“首到末输出耗时 ÷ 63”计算。这里测量的是本机引擎接口可见的等待时间包含主机处理、调度、模型执行、进程间通信和输出处理不含模型加载、分词、HTTP 或 SSH 网络往返。它不是纯 Prefill/Decode 算子的耗时。本地源码中add_request 提交请求step 获取并整理结果远程使用含进程间通信的SyncMPClient。版本定位见附录 D。7.3 1K 的结果命中明确收益有限包括预热在内的 66 个 B 请求全部通过核对冷组命中 0热组命中 1024每个 B 都生成相同的 64 个 token。下面只统计每次 8 对正式请求数值为各组中位数独立运行冷 TTFT热 TTFT冷总耗时热总耗时0138.29 ms38.52 ms2195.37 ms2217.57 ms0238.55 ms37.42 ms2150.13 ms2154.59 ms0338.51 ms38.21 ms2175.07 ms2179.84 ms第二次的热 TTFT 中位数降低约 2.9%说明并非完全没有改善信号。但逐对比较三次运行中热 TTFT 更快的对数分别为 4/8、7/8、4/8整请求耗时也没有跨启动一致的改善。因此这个 1K 样本确认了缓存命中和小幅首 token 改善信号尚未表现为稳定的整请求加速。各段耗时、配对差值和范围保留在附录 C不根据小差异断言缓存一定更快或更慢。7.4 为什么 98.7% 命中没有带来相近的时间节省Prefix Cache 复用了前缀 KV没有替 B 生成后面的 64 个答案 token。处理 B 的新 token 时Attention 仍要使用历史 KV后续生成步骤也仍然存在。本次冷组 TTFT 约 38 ms首到末输出却约 2.1 秒大部分等待发生在首 token 之后。以第一次运行的冷组中位数作量级示意TTFT 只占总耗时约 1.7%即使假设把这一段全部消除、后续时间不变总时间也只减少约 1.7%。这是说明时间构成的假设不是实测收益或其他负载的上限。至于 TTFT 本身为什么改善较小仅凭接口计时还不能确认原因小输入的可节省工作、主机固定开销、计算形状与运行波动都可能参与其中需要 profiler 才能进一步归因。下一节先检验一个更直接的假设把可复用前缀加长收益是否更容易显现8. 输入扩大到 16K结果会改变吗上一节的模型只有 0.6B输入约 1K。冷请求的 Prefill 本身可能就不长缓存省下的工作反映到接口时间上也可能很小。于是这一轮保持模型与输出长度不变只把可以复用的共同前缀扩大到1K、4K、8K、16K观察冷、热差距是否随之扩大。8.1 保持比较公平四档共同前缀分别有 1024、4096、8192、16384 个 token加上各自 14-token 的问题后缀B 的实际输入长度为 1038、4110、8206、16398。长前缀由原来的 1024-token 段重复构造它是用于控制长度的合成输入不是长文档质量测试。为了容纳最大输入统一把max_model_len和max_num_batched_tokens改为 184321K 也在这套配置下重新测量。因此本节表格不直接混用上一节 2048 配置的结果。其余主要条件仍是 Qwen3-0.6B、单 NPU、BF16、Eager、同步调度、并发 1B 固定输出 64 token。每次独立启动都测四种长度每个长度先预热 3 对再测 8 对三次启动改变长度的先后顺序每对冷、热也交替先后。总计 96 对正式请求每个长度各 24 对。冷组仍是“A 完成后清缓存再测 B”热组是“A 完成后保留缓存再测 B”。三次的四档测量全部通过检查冷组均命中 0热组分别命中 1024/4096/8192/16384。同一长度下包含预热的全部 B 均输出相同的 64 个 token跨三次启动也一致不同长度可以生成不同内容。原始事件、396 次成功 reset、输入、配置与统计均已复核。完整命令和日志见 附录 B 的长输入实验。8.2 长输入下首 token 收益变得明确下表按每个长度分别合并三次启动给出每组 24 个正式请求的中位数加速比定义为“冷 TTFT 中位数 ÷ 热 TTFT 中位数”不是配对加速比的中位数。共同前缀冷 TTFT热 TTFT首 token 加速比热组 TTFT 更快的配对数1K37.28 ms37.36 ms约 1.00 倍9 / 244K49.17 ms39.55 ms1.24 倍24 / 248K111.08 ms42.07 ms2.64 倍24 / 2416K318.51 ms47.28 ms6.74 倍24 / 24这次不只是合并中位数好看4K、8K、16K 的每一对正式请求热组 TTFT 都更短。16K 在三次独立启动中的冷/热中位数分别为319.23/46.88、317.92/47.70、318.34/48.17 ms趋势得到重复。1K 仍然没有明确优势。图中蓝色为冷缓存、橙色为热缓存点为每组 24 个请求的中位数误差线表示全部样本的最小最大值不是置信区间。三次独立运行的分组结果与每对差值保存在 附录 C 所列的审计与统计文件 中没有把每一对请求当成一次独立部署。8.3 首 token 快了 6.74 倍整段回答呢16K 这一档的请求总耗时中位数从冷组2410.80 ms降到热组2142.90 ms按两个中位数计算减少约11.1%总耗时加速比约1.13 倍。24 对中热组总耗时全部更短配对节省时间的中位数约为 273.71 ms。但首 token 到末 token 的输出耗时中位数冷、热仍分别约为2091.79 / 2096.08 ms平均 TPOT 约为33.20 / 33.27 ms。这部分没有出现类似首 token 的明显提速。16K 冷缓存 [首 token 等待约 319 ms] [后续 63 个 token 约 2.09 s] 16K 热缓存 [首 token 等待约 47 ms] [后续 63 个 token 约 2.10 s]这是按各段中位数取整的量级示意各段中位数之和不要求恰好等于总耗时中位数。它说明这组实验中明确的收益主要表现为更早收到第一个 token后续答案仍需逐步生成。上一节约 1.7% 的时间占比只属于原来的 1K 工作负载到了本节 16K冷组 TTFT 占总耗时约 13.2%可节省的时间空间已经改变。8.4 回到源码少算前缀但后缀仍要使用上下文第 5 节的链式哈希与完整块查找在 16K 输入上遵循同样规则。A/B 实际共享 16386 个开头 token按 128 一块报告复用 128 个完整块即 16384 个 token。B 仍有 14 个输入 token 不在命中覆盖内这依然只是输入与命中的差值不是本轮采集到的 Scheduler 逐轮任务。为何热缓存的时间也从约 37 ms 增长到 47 ms源码至少能说明命中前缀并没有让历史 KV 消失。本地 Ascend 提交cb79183…的 attention_v1.py:817 在缓存命中路径取出缓存视图和块表并保留seq_lens_list作为 KV 的上下文长度第 855 行附近 则按元数据记录的有效 query 数量截取已经准备好的 query 张量。哪些输入需要重新计算由此前的调度决定。冷请求为长前缀计算状态 → 处理问题后缀 → 开始输出 热请求找到长前缀的 KV → 后缀使用已有 KV → 开始输出 ↑ 历史上下文仍参与 Attention本地是静态阅读依据远程实际运行的 Ascend 提交仍为f2f74a16…对应缓存/上下文参数处理在第 12381269 行query 截取在第 12941298 行。具体分支未做逐轮 trace不能把静态图当作完整运行日志。这说明热请求仍有随上下文变化的工作但不能据此把那约 10 ms 的增长全部归因于读取 KV。API 时间还包括输入处理、哈希、调度、IPC 和输出处理本轮没有 profiler。计时脚本的完整输入比对发生在每次时间戳之后不影响首次 TTFT却也可能影响后续输出间隔。至此长度对照支持了一个有边界的结论在这套小模型、单请求、Eager 配置下1K 输入不容易显现缓存收益前缀扩大到 4K16K 后首 token 收益明显并能够重复16K 的总耗时也得到改善。这仍是合成重复输入的延迟实验不是多用户吞吐或自然长文档质量结论。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑