引言
同一个用户连续追问三轮,应用已经把聊天记录完整保存到数据库,为什么第二轮仍然很慢?单台机器测试时明明可以复用前文,为什么增加几台机器以后,重复计算反而变多?给网关加上“会话编号”以后,为什么问题依然存在?
这些问题常被统称为“会话保持问题”,实际涉及三个不同层次:应用是否记住聊天历史、网关是否把相关问题交给合适的服务实例,以及模型服务是否仍然保留着上一次计算的“笔记”。任意一层断开,模型都可能重新阅读整段历史。
一个简单的生活类比理解
可以把大模型想象成一位图书管理员。读者递来一摞资料并提出问题,管理员需要先读完资料、在草稿纸上整理重点,然后再一个字一个字地组织回答。
这里的“通读资料”叫作预填充,也就是Prefill;“草稿笔记”叫作KV Cache;“逐步写出回答”叫作解码,也就是Decode;前台选择哪位管理员,则对应网关的路由决策。
如果第二次提问仍然交给原管理员,并且草稿纸还在、资料开头也完全相同,管理员就可以直接翻看笔记,只阅读新增加的部分。如果换了一位管理员,或者原来的笔记已经被清理,就只能从头再读。所谓会话保持,解决的正是“尽量把相关问题交给拥有那份笔记的人”;它能增加复用机会,却不能保证笔记一定存在。
文中出现的一些术语
后文会反复出现下面这些词。它们描述的是同一条处理链路,而不是互相独立的技术。
| 术语 | 通俗解释 |
|---|---|
GPU | 负责大规模并行计算的处理器,是模型真正执行计算的主要设备 |
Prefill | 模型先通读全部输入并形成中间计算结果的阶段,中文常译为“预填充” |
KV Cache | Prefill留下的中间计算结果,相当于模型读完前文后写下的草稿笔记 |
| 前缀 | 输入中从开头起保持不变的部分,例如多轮对话共同拥有的聊天历史 |
| 前缀缓存 | 保存并复用相同前缀的KV Cache,避免再次通读这部分内容 |
| 缓存命中 | 需要的笔记仍然存在并能直接使用;找不到或不能使用则叫“未命中” |
| 缓存命中率 | 已成功复用的请求或输入Token占比;统计口径不同,结论也可能不同 |
| 冷缓存与热缓存 | 刚启动或缺少常用内容的缓存称为冷缓存;已经积累较多常用内容的称为热缓存 |
Decode | 模型利用已有笔记,一个Token接一个Token生成答案的阶段 |
TTFT | 从发出请求到收到第一个输出Token的时间,也就是用户等到第一个字的时间 |
| 吞吐 | 单位时间内处理的请求数或Token数,用来衡量整个服务的处理能力 |
| 尾延迟 | 最慢那一小部分请求的耗时,常用P95或P99观察 |
先来分清三种“会话状态”
“保持会话”在不同团队口中可能代表完全不同的事情。
| 状态 | 保存的内容 | 常见位置 | 丢失后的结果 |
|---|---|---|---|
| 应用会话 | 消息、用户偏好、工具结果 | 数据库、Redis等高速内存数据库 | 模型不知道历史,需要重新取数 |
| 请求运行态 | 当前生成请求的序列状态和KV块 | 推理引擎、GPU高速显存 | 正在生成的请求失败,通常需要重试 |
| 可复用前缀缓存 | 已处理前缀对应的数字中间结果块,技术上称为K/V张量块 | GPU高速显存、服务器内存、磁盘或远端缓存 | 请求仍能执行,但需要重新Prefill |
大多数兼容OpenAI API格式的接口仍然是“这次请求只看这次输入”:应用需要发送本轮所需的完整上下文,或者使用服务端明确提供的会话型接口。即使服务端替应用保存了消息,也不代表它承诺长期保留某台机器上的KV Cache。
KV Cache为什么决定多轮会话的性能
Prefill与Decode分别在做什么
现在再看它们的技术含义。大多数聊天模型采用Transformer结构,它是一类善于计算不同输入片段之间关系的神经网络。模型以“根据前文预测下一个Token”的方式反复生成内容,这种方式称为自回归生成。一次推理通常分为两个阶段:
Prefill阶段读取整个输入,为模型每一层计算后续还要使用的中间结果,并产生第一个输出Token。输入越长,要阅读和计算的内容通常越多,因此它是TTFT的重要组成部分。Decode阶段每次生成一个新Token。它复用前面保存的中间结果,不必在每一步都重新计算全部历史;这一阶段还会受到显存读取速度、同时处理多少请求以及调度方式的影响。
模型用来判断“当前应该关注前文哪些部分”的计算方法叫作注意力机制。这些中间结果在注意力机制中分为Key和Value两组数字,所以简称为K/V。普通读者不必先理解矩阵运算,只需要记住:它们是模型读过前文后留下的计算笔记,不是聊天文本本身。
为什么“通读输入”会很贵?在经典全注意力中,一个Token需要与许多其他Token计算关联。输入长度翻倍时,需要比较的组合数量大约会变成原来的四倍,因此其理论计算量随长度近似按平方增长。
多轮对话为什么天然适合前缀复用
假设聊天模板每轮都把新消息追加在末尾:
第一轮:系统提示 + 用户问题A
第二轮:系统提示 + 用户问题A + 助手回答A + 用户问题B
第三轮:系统提示 + 用户问题A + 助手回答A + 用户问题B + 助手回答B + 用户问题C
第二轮包含第一轮的完整前缀,第三轮又包含第二轮的完整前缀。如果推理引擎支持前缀缓存,并且请求落到仍保存这些块的实例上,就只需对新增尾部执行Prefill。
vLLM的自动前缀缓存,也就是Automatic Prefix Caching,是一个典型实现。它先把KV Cache切成固定大小的块,再为每个块计算一个内容指纹,这个指纹称为哈希。当前块的Token编号、前一个块的哈希以及模型执行身份都会参与计算,因此只有从输入开头连续相同的完整块才能复用。
命中到底需要哪些条件
“同一个会话”只是业务语义。要复用张量,至少要满足下面几类条件:
| 条件 | 常见破坏因素 |
|---|---|
| 相同模型执行身份 | 模型版本、权重、量化方式、LoRA适配器变化 |
相同的连续Token前缀 | 模板、空格、消息顺序、工具定义或图片变化 |
| 到达持有缓存的执行位置 | 轮询到其他Pod、跨地域或跨处理域 |
| 缓存仍然存在且可用 | LRU淘汰、进程重启、缩容、显存压力 |
| 块边界满足引擎规则 | 尾部不足一个完整块,只能从下一个边界重新算 |
| 隔离身份一致 | 租户盐、缓存命名空间或安全域不同 |
这里的“相同输入”是模板渲染和分词后的完整前缀相同,不是用户肉眼看起来意思相近。以下细节都可能改变Token序列:
- 系统时间、随机请求编号被插入系统提示;
- 工具定义顺序不稳定,或
JSON这种结构化请求格式的字段顺序和默认值发生变化; - 聊天模板、表示文本开始和结束的
BOS/EOS特殊标记、角色分隔符或分词器版本变化; - 历史消息被摘要、裁剪或重新排序;
- 多模态占位符、图片内容或
LoRA适配器发生变化。
因此,更稳妥的提示词结构是“稳定内容在前,易变内容在后”:静态系统提示、固定工具定义、长文档放在前部,时间戳、用户本轮问题和随机字段放在尾部。托管服务的官方文档同样强调前缀一致性;OpenAI还明确说明,维持同一会话并不能保证命中,缓存状态位于具体机器上,路由会影响复用。
缓存命中率的真正痛点
请求命中率容易掩盖问题
至少应区分两种指标:
请求命中率 = 至少命中一个缓存块的请求数 ÷ 可缓存请求数
Token命中率 = 所有请求命中的输入Token数之和 ÷ 所有输入Token数之和
假设100个请求都只命中16 Token,请求命中率可能是100%,但对每个包含约8000 Token的输入几乎没有价值。对成本和Prefill工作量更有解释力的是按Token加权的命中率。OpenAI官方也建议用所有请求的已缓存输入量除以总输入量,也就是汇总cached_tokens / input_tokens。
使用vLLM时,可以从它公开的监控数据中观察以下指标。名称看起来较长,但含义并不复杂:
vLLM指标 | 它在回答什么问题 |
|---|---|
prefix_cache_queries | 总共查找了多少个前缀缓存块 |
prefix_cache_hits | 其中多少个块查找成功 |
prompt_tokens_total | 模型总共收到多少输入Token |
kv_cache_usage_perc | 当前KV Cache空间用了多少 |
time_to_first_token_seconds | 用户等到第一个输出Token用了多久 |
不过,Token命中率也不直接等于节省的计算比例。注意力计算、分块边界、批处理、远端缓存加载和排队都会影响最终延迟,所以必须同时看TTFT和吞吐。
多副本把一份热前缀切成多份冷缓存
下面假设会话S1的前三轮分别被轮询到三个Pod:
单实例测试往往看不出这个问题,因为请求只能回到同一个缓存池。一旦上线轮询、随机或最少连接,多轮会话和共享系统提示就会在多个副本上重复预热。扩容越积极,短时间内冷缓存比例可能越高。
热点、排队与命中率互相拉扯
如果某个Pod保存着全公司的公共20K Token系统提示,把所有请求都送过去能提高命中率,却可能让它排队,而其他GPU空闲。此时一次缓存命中节省的Prefill时间,可能小于额外排队时间。
生产路由的目标不应是“最大化命中率”,而应是“在满足负载和可靠性约束时,最小化请求的预计完成成本”。一个便于理解的抽象评分是:
节点成本 = 未缓存Prefill成本
+ 排队成本
+ Decode占用成本
+ 远端KV加载成本
+ 故障惩罚
- 本地缓存收益
不同项目对这些项的归一化、权重和阈值不同,但方向相同:缓存局部性只是调度信号之一。
缓存索引会变旧
路由器观察到“请求P刚被送到Pod-A”,只能推测Pod-A可能拥有前缀P。之后还可能发生:
- 请求失败,缓存根本没有写成;
- 推理引擎因显存压力先于路由器淘汰了块;
Pod重启或被缩容,路由器尚未清理索引;- 多个网关副本各自只看到一部分流量;
- 路由器和推理引擎采用不同的分词器、块大小或缓存身份。
这就是“近似前缀索引”与“真实KV Cache目录”的差别。近似方案部署简单、侵入小;精确方案则需要引擎发布块的增加和删除事件,并处理事件丢失、乱序、重放、启动快照和版本兼容。
扩缩容和故障是正常路径
硬会话粘滞若没有失效策略,会把请求持续发给已经不存在或过载的节点。服务端会话映射还要处理TTL、并发请求、滚动发布和跨路由副本一致性。这里的TTL是“这条记录还能存活多久”,到期后绑定会被清理。真正可用的方案必须规定:
- 目标节点不健康时如何解除绑定;
- 新节点加入时是否迁移会话;
- 路由器重启后如何恢复映射;
- 重试是否仍发往同一节点;
- 把
Prefill和Decode部署到不同机器时,应该绑定哪个阶段。
常见会话保持技术方案
会话保持并不是一个单独开关,而是一条从低成本到高精度的技术光谱。
基于Client IP、Cookie或Header的粘滞
最简单的做法是选一个稳定标记,让相同标记尽量落到同一后端。Client IP是客户端的网络地址,Cookie是浏览器保存并随请求带回的小段标记,Header则是请求附带的元数据字段。网络地址转换NAT会让多台设备共用一个公网地址,因此相同Client IP未必代表同一个用户。在用于管理容器和服务的Kubernetes平台中,Service是访问一组后端实例的统一入口;它原生支持按客户端地址保持会话,即sessionAffinity: ClientIP,默认关闭,并且可以设置保持时间。
| 路由键 | 优点 | 主要问题 |
|---|---|---|
Client IP | 无需改客户端 | 经过NAT后许多用户可能共用一个公网地址,代理和移动网络也会改变地址 |
Cookie | 浏览器接入方便 | 非浏览器客户端不自然,需要签名和过期控制 |
自定义Header | 语义清晰,适合API | 需要客户端配合,必须防伪造和越权 |
这类方案完全不知道输入前缀。同一个会话若分叉或改写历史,仍会去旧节点;不同用户共享相同长文档,也无法跨会话复用。它适合先快速改善多轮聊天,不适合把缓存命中率当作严格目标。
一致性哈希与Rendezvous Hash
哈希可以把任意会话编号变成一个看似随机但结果稳定的数字。对session_id、租户标识或稳定前缀摘要做一致性哈希,就能用这个数字选择后端,而不必另外保存一张集中式会话表。后端集合稳定时,同一个键会选择同一节点;扩缩容时也只需移动一部分键,而不是让全部会话重新分配。Envoy中的Ring Hash和Maglev都是这类确定性负载均衡算法。
最高随机权重哈希,也就是Rendezvous Hash,做法更加直观:让一个会话分别与每个健康节点组成一对,计算每一对的稳定分数,然后选择最高分的节点。
对每个健康节点:
分数 = hash(会话编号 + 节点编号)
选择分数最高的节点
它实现简单,节点变化时迁移也相对有限。AIBrix的调用方会话键方案就使用Rendezvous Hash选Pod。
优点是路由器无状态、多副本容易得到一致结果。缺点是它只记住“按规则应该去哪”,不知道缓存是否仍在,也不知道该节点是否排队。生产实现通常会加入虚拟节点,也就是让一台真实机器在哈希空间中拥有多个落点,并结合节点权重、健康过滤和限制单节点压力的有界负载机制;节点身份还应稳定,不能直接依赖频繁变化的临时地址。
服务端会话映射
路由器也可以维护session_id → endpoint映射,其中endpoint就是某个可接收请求的后端地址。首次请求按正常策略选节点,成功后建立绑定,后续请求优先或强制使用该节点。映射可放在路由进程内,也可放在Redis等外部存储。
服务端映射能明确设置过期时间,也就是TTL,并实现重绑定和软硬两种模式:
- 请求路由硬亲和:目标健康时只选绑定节点。命中概率高,但热点和尾延迟风险大。
- 请求路由软亲和:把绑定节点当作加分项,仍允许负载策略选择其他节点。命中率略低,故障和突发流量下更稳。
也可以由服务端在响应中返回一个不透明、带签名的会话令牌,令牌内编码节点身份,客户端后续回传。这样可减少服务端查表,但需要密钥轮换、过期和防篡改设计。直接用Base64把IP:Port转换成另一串字符只是在编码,既不是加密,也不能证明内容没有被篡改,还可能暴露内部网络地址。
近似前缀感知路由
会话粘滞只能回答“这个会话上一次去了哪里”,却无法发现不同会话之间的公共内容。例如,公司里的多个用户都使用相同的系统提示、工具定义和产品手册,只是最后提出的问题不同。即使他们的session_id完全不同,前面那一大段输入仍然可能共用缓存。
前缀感知路由要回答的问题是:哪个Pod最可能已经处理过这次输入的开头部分?如果路由器不能直接读取推理引擎的缓存目录,它就根据过去的转发记录维护一本“路线笔记”:某段前缀之前被发往Pod-A,那么Pod-A现在可能仍保留着对应的KV Cache。新请求带着相同前缀到来时,路由器优先考虑Pod-A,同时还要检查它是否健康、是否过载。
这里最容易误解的一点是:“近似”修饰的是缓存位置,不是文本内容。两句话意思相近但实际Token不同,不能复用同一份KV Cache;只有从开头开始连续一致的内容才可能命中。“近似”是说路由器只能推测“缓存大概还在Pod-A”,无法保证缓存没有被淘汰。
为什么要把前缀切块
如果为整段输入只计算一个指纹,末尾哪怕只增加一个问题,整段指纹也会改变,路由器便无法知道前面有多少内容相同。因此,常见做法是把输入切成固定大小的块,再从前向后计算一条哈希链。
下面用每块16 Token举例。真实项目的块大小可以不同,有的网关为了避免加载模型分词器,还会按字符或消息切块。
可以按从左到右的顺序阅读这张图:
块1包含第1至16个Token。路由器把块内容与模型、租户等缓存身份一起计算,得到短指纹H1。- 计算
H2时,不仅使用块2,还把H1放进去。因此,H2代表的不是孤立的块2,而是“从开头经过块1,再到块2”的完整路径。 H3同样由H2和块3得到,所以它代表从输入开头一直到第48个Token的路径。- 右侧三个“索引记录”不是
KV Cache本身,只是路由器记下的对应关系:这些前缀指纹之前被送到过Pod-A。
父块哈希参与下一块哈希非常重要。假设两份文档的中间都出现“退款规则”四个字,但此前内容不同;如果只比较当前块,路由器可能误以为它们能够复用。加入父块哈希后,只要前面的任意块不同,后面生成的哈希也会不同,从而确保匹配的是“从开头连续相同”,而不是“中间偶然出现相同片段”。
再看一个具体例子。第一次请求有48 Token,并被送到Pod-A,路由器记下H1、H2和H3可能位于Pod-A。第二次请求的前32 Token与第一次完全相同,但从第33个Token开始变成了新问题。它计算出的H1和H2仍然相同,新的H3却不同。路由器由此估算:Pod-A可能命中前两个块,也就是前32 Token。
从第一次请求到后续命中的完整过程
下面这张图把“学习位置”和“利用位置”放在同一条时间线上。不同项目写入索引的时机并不完全相同:有的在成功选定目标后记录,有的等后端接收请求或正常响应后再记录。图中采用确认请求正常处理后再记录的方式,并省略了鉴权、限流和失败重试等普通网关步骤。
图中的流程分成两个阶段:
- 第一次请求是学习阶段。索引里没有记录,路由器只能按普通负载均衡选择
Pod-A。图中的示例在请求被正常处理后把三个前缀指纹与Pod-A关联起来;具体实现也可能在成功选定目标或确认后端接收请求时记录。 - 第二次请求是利用阶段。索引发现前两个块曾被发往
Pod-A,于是把它列为缓存候选。不过,如果Pod-A正在严重排队,路由器仍可选择其他节点,避免为了命中制造热点。 - 最终检查发生在推理引擎内部。即使索引指向
Pod-A,引擎仍会根据自己的块规则和真实缓存状态判断能否复用。路由器不能替引擎宣布命中。
真正的推理引擎通常按模型给每个Token分配的数字编号,也就是Token ID,建立缓存身份。网关若采用同一个分词器,就能更接近引擎的分块结果;如果为了降低接入成本而按整理成统一格式的消息或字符切块,计算出的匹配长度就只是估算值。
为什么记录可能不准确
路由器记下的是“请求曾经去过哪里”,而不是“缓存此刻一定在哪里”。两者会因为以下情况逐渐偏离:
- 请求虽然被分配到
Pod-A,但在完成缓存写入前就失败了; Pod-A后来因为显存不足淘汰了旧块,路由器却还没删除记录;Pod-A重启或缩容退出,原来的缓存随之消失;- 多个路由器副本各自只看到一部分请求,得到的索引并不完整;
- 网关与推理引擎使用不同的分词器、块大小或缓存身份,双方认为“相同”的范围不一致。
因此,近似索引通常还要设置容量和过期时间,并在目标失效时删除旧记录。它的优势是不必修改推理引擎,能够兼容多种后端,而且不同会话也可以共享公共前缀;缺点是需要不断学习和纠正,路由器重启后可能要重新积累记录。可以把它理解为商场里的导购图:它能指引你去最可能有货的柜台,但真正有没有货,仍要由柜台库存确认。
这种方案适合多轮对话、共享系统提示、多人围绕同一份长文档提问,以及暂时无法接入引擎缓存事件的场景。如果业务要求准确知道每一个缓存块位于哪里,就需要下一节介绍的KV Events精确路由。
基于缓存变更通知,也就是KV Events的精确路由
近似索引只根据历史请求猜测缓存位置。更精确的办法是让推理引擎在创建或删除缓存块时主动发出一条通知,这种通知称为KV Events。这些通知通常经过一个负责传递消息的事件通道,再交给索引器处理。路由器持续查询这个索引,就能获得更接近真实状态的“前缀块到节点集合”目录。vLLM的KV Events、llm-d Router精确前缀索引和NVIDIA Dynamo的索引器都采用了这类思路。
精确是相对近似索引而言,不代表永不出错。事件系统仍需面对:
- 事件丢失和乱序,需要序列号、缺口检测与重放;
- 路由器启动时需要快照或从健康节点重建;
- 引擎版本、块大小、哈希算法和多模态身份必须一致;
- 高频块事件会消耗网络、内存和服务器通用处理器
CPU; - 请求到达与事件可见之间存在短暂窗口。
适用场景是副本多、前缀价值高、引擎支持事件协议,并且团队能运维事件消息通道和恢复流程的集群。
分布式与分层KV Cache
前面的方案都在努力把请求送到缓存所在位置。另一个方向是让缓存能够移动或从共享存储加载,从而降低严格粘滞的必要性。
图中的高带宽显存GPU HBM是GPU自带的高速内存,速度最快但容量小、价格高;动态随机存取内存CPU DRAM是服务器主内存,速度较慢但容量通常更大;NVMe则是常见的高速固态硬盘接口。缓存越往右移动,通常容量越大、单位成本越低,但取回也越慢。
可将它分为两种工作模式:
- 存储模式:把低频
KV从GPU卸载到CPU、本地盘或远端存储,后续请求按需加载,扩大缓存容量并支持跨请求、跨进程复用。 - 传输模式:当负责通读输入的
Prefill实例和负责生成答案的Decode实例分开部署时,需要把已有KV直接送到目标实例。这种架构常简称为PD分离。P2P表示节点之间点对点传输;RDMA可以让机器通过高速网络直接访问对方内存;NVLink是NVIDIA GPU之间的高速连接;TCP则是普通网络广泛使用的传输协议。
分布式缓存不是免费的“全局显存”。一次远端命中仍要做索引查询、把数据整理成适合传输的格式,也就是序列化,再经过网络传输并装入GPU。如果加载耗时高于重新计算,命中反而更慢。工程上应建立盈亏阈值:短前缀直接重算,长前缀或高复用前缀才值得远端加载。
托管模型服务的提示词缓存
使用托管API时,用户无法指定具体机器,服务提供方负责缓存和路由。例如OpenAI Prompt Caching会复用匹配的提示词前缀,并在响应的usage.input_tokens_details.cached_tokens中报告命中量。当前文档明确指出缓存状态位于单机,请求只有到达保存匹配条目的机器且条目未过期才能复用;prompt_cache_key在部分模型上可帮助相关请求路由,但不会固定机器,也不保证命中。不同模型代际的断点、最低可缓存长度、保留策略和计费规则存在差异,应以所用模型的实时文档为准。
对于调用方,能做的主要是保持稳定前缀、合理设置服务方提供的缓存键、监控cached_tokens,以及避免把所有高并发流量塞进同一个路由键。不能把自建集群的Pod粘滞语义直接套在托管服务上。
如何在缓存局部性与负载之间做决策
缓存局部性可以理解为“需要的笔记就在当前Pod手边”。局部性越好,需要重新执行的Prefill越少。但是,手里有笔记的Pod不一定最空闲。如果它前面已经排了很多请求,等待时间可能比重新计算还长。
继续使用图书管理员的类比:管理员甲保存着这份资料的笔记,但前面排着8个人;管理员乙没有笔记,却几乎不用排队。选择甲可以少读资料,选择乙可以少等队伍。路由器真正要比较的不是“谁有笔记”,而是“去谁那里更可能更早拿到答案”。
下面的数字只用于说明思路,并不是任何模型的实测结果:
如果Pod-A只排队0.2秒,而复用缓存能节省3秒,它就更值得选择。可见,正确目标不是让每次请求都命中缓存,而是在缓存收益、排队时间、实例健康和故障风险之间找到更合适的平衡。
先分清本文的“软亲和”是什么
“亲和”只表示“倾向让两个对象靠在一起”。具体是哪两个对象、在哪个阶段靠在一起,要看上下文。本文讨论的请求路由软亲和易于和Kubernetes调度器里的软亲和搞混淆。
| 容易混淆的概念 | 在什么时候做决定 | 决定什么 | 关键含义 |
|---|---|---|---|
Kubernetes调度软亲和 | 创建或重新调度Pod时 | Pod部署到哪台服务器节点 | 尽量满足部署位置偏好,条件不允许时仍可放到其他节点 |
Kubernetes Service会话亲和 | 网络请求进入Service时 | 相同Client IP的流量发往哪个后端 | 它通常按固定规则保持流量,不理解提示词缓存,也不是本文的动态加分策略 |
| 本文的请求路由软亲和 | 每次模型请求到达时 | 这次请求交给哪个推理Pod | 缓存或会话关联的Pod会获得优先分,但过载或故障时可以改选其他Pod |
Kubernetes调度软亲和关注的是工作负载放在哪里运行。例如,尽量把某个应用Pod放到带GPU的节点,或者尽量不要让两个副本挤在同一台机器上。它发生在Pod部署阶段。
本文的请求路由软亲和关注的是已经运行的多个推理Pod中,这一次请求发给谁。它发生在请求转发阶段,并且每次请求都可以重新判断。这里的“软亲和”是推理路由策略的通俗名称,不是一个统一的Kubernetes API字段;llm-d Router、NVIDIA Dynamo等项目的具体配置和评分方式也不完全相同。
对应的请求路由硬亲和则是:只要绑定节点还被认为可用,就必须把请求发给它,不再比较其他节点。两者可以简单记成:
- 请求路由硬亲和:绑定节点是唯一答案。
- 请求路由软亲和:绑定节点是优先答案,但不是唯一答案。
还要注意,“硬过滤”与“硬亲和”也不是一回事。硬过滤只是先排除不健康、模型不匹配或已经超过负载上限的节点;硬亲和则是用会话绑定把候选范围限制为某一个节点。
一次路由决策到底经过哪些步骤
成熟的路由器通常先解析请求,再经过筛选、统一尺度、评分、选择和反馈五个步骤:
可以按下面的方式理解图中的每一步:
- 硬过滤:先删除一定不能选的节点。例如节点已经宕机、没有加载请求所需的模型,或者队列已经超过保护上限。即使这些节点缓存再多,也不能继续参与评分。
- 统一尺度:队列长度可能是
12个请求,缓存占用可能是80%,前缀匹配可能是6000 Token,这些数字不能直接相加。路由器需要把它们换算成相近的范围,例如都变成0至1的分数。 - 综合评分:缓存匹配越多就加分,队列越长、正在运行的请求越多或近期失败越多就减分。会话绑定也可以作为一个加分项,这正是请求路由软亲和。
- 选择目标:选择综合分数最合适的节点,并保留次优节点。首选节点在真正发送前失效时,可以快速改用备用节点。
- 结果反馈:请求开始后增加正在处理的请求数,结束后再减少;发生失败时记录故障;近似前缀路由还会更新“前缀可能在哪个
Pod”的索引。下一次决策由此获得较新的信息。
这个顺序体现了一个重要边界:先保证节点能正确、稳定地处理请求,再考虑缓存收益。缓存只是性能优化,不能让错误模型、故障节点或严重过载节点重新进入候选列表。
把评分公式翻译成自然语言
实现中经常会看到类似下面的公式。公式中的endpoint就是一个候选后端,整条公式想表达的只有一句话:前缀命中多、排队少、缓存空间较空、正在处理的请求少且近期没有失败的节点,得分更高。
score(endpoint) = 3 × prefix_score
+ 2 × (1 - queue_normalized)
+ 2 × (1 - kv_usage)
+ 1 × (1 - inflight_normalized)
- failure_penalty
每一项的含义如下:
| 公式项 | 通俗含义 | 为什么这样计算 |
|---|---|---|
prefix_score | 前缀缓存匹配分 | 匹配越多越好,所以直接加分 |
1 - queue_normalized | 队列空闲分 | 队列越长,原始值越大;用1减去它后,队列越短得分越高 |
1 - kv_usage | 缓存余量分 | 缓存越满,继续接收请求和保留新缓存的空间越少 |
1 - inflight_normalized | 当前处理能力余量 | 正在处理的请求越少,得分越高 |
failure_penalty | 近期故障惩罚 | 最近超时或失败越多,减掉的分数越多 |
名称中的normalized表示“已经统一尺度”。例如,某项变成0.8并不表示有0.8个请求,而是表示它在统一后的0至1范围内已经比较高。公式前面的3、2、1叫作权重:3 × prefix_score表示这个示例最重视前缀缓存,1 × (1 - inflight_normalized)表示对正在运行请求数的重视程度相对较低。
假设Pod-A前缀匹配很好,但队列和缓存占用都接近上限;Pod-B只匹配少量前缀,却非常空闲。软亲和不会因为Pod-A“曾经绑定过”就直接结束判断,而是让两台机器都完成评分。最终Pod-B仍可能胜出,这正是前面示意图中“宁可重新Prefill,也不等待拥塞节点”的情况。
这些权重只是解释原理的示例,不能直接复制到所有集群。Higress ai-endpoint-picker当前默认权重大致采用queue=2、kvCache=2、prefixCache=3、inflight=1,并将每项转换到0至1的范围;其前缀分还同时考虑匹配比例和绝对匹配长度。一个500 Token的请求全部命中,与一个20000 Token的请求只命中500 Token,虽然都命中了500 Token,但节省比例和总体价值并不相同。
生产环境还需要根据实际观测调整权重和保护上限。例如,长文档问答通常更重视前缀缓存;短问短答的重新计算成本较低,可以更重视队列;节点经常发生瞬时故障时,则应提高故障惩罚。最终应以TTFT、吞吐和尾延迟是否改善为准,而不是只看评分或命中率。
为什么通常推荐请求路由软亲和
对标准的OpenAI兼容推理接口来说,只要应用在请求中带上完整上下文,换一个没有缓存的Pod通常只会触发重新计算,不会改变答案的正确性。因此,KV Cache更像一项性能优化,而不是必须依赖的业务状态。
请求路由软亲和通常更适合作为默认值,原因有四点:
- 缓存节点过载时可以改选空闲节点,避免形成热点;
- 绑定节点故障或缩容后可以立即重新选择,不会无限重试旧节点;
- 近似前缀索引可能已经过期,软亲和允许负载和健康信息纠正错误判断;
- 扩容产生新节点时,新节点仍有机会接收流量并逐渐建立自己的缓存。
只有后端保存了无法在请求中重建、也无法迁移的真实会话状态时,才可能需要请求路由硬亲和。例如,某种专用接口只保存了服务端会话句柄,客户端没有携带完整上下文。即便如此,也必须明确绑定节点失效后的报错、重建或恢复方式。
可以把这一节浓缩成一句话:请求路由软亲和不是“尽量把Pod部署在一起”,而是“每次请求优先去有用缓存所在的推理Pod,但健康和负载不合适时允许换人”。
开源技术方案调研
Higress ai-endpoint-picker:多信号端点选择
项目地址:Higress项目仓库。
Higress是一个可以接收请求、执行插件并把请求转发到后端服务的API网关。本节只介绍当前的ai-endpoint-picker插件。它以WASM形式运行在网关内部;WASM可以先理解为一种体积较小、与网关主程序隔离的插件运行方式。
插件直接在一个后端实例组中执行前面介绍的“筛选→统一尺度→评分→选择→反馈”流程。源码将这个后端组称为上游集群upstream cluster。它使用Higress提供的逐节点指标,并通过指定后端override host能力明确选择最终节点,不需要再部署一个独立的端点选择器Endpoint Picker,简称EPP。
一次请求的处理过程如下:
图中最关键的是“多种信号”而不是单独追求前缀命中:
- 队列长度反映目标
Pod前面还有多少请求在等待; KV Cache利用率反映缓存空间是否接近用满;- 近似前缀分反映该
Pod可能复用多少已有输入; - 本地并发表示插件观察到该节点正在处理多少请求;
LoRA、节点故障等信息用于排除不匹配或风险较高的节点。
这些信号经过统一尺度和加权后,插件选择综合得分更合适的Pod。因此,缓存较多但已经拥塞的节点不一定胜出,缓存较少但明显更空闲的节点仍可能被选中。最后,override host能力把路由结果交给Higress,由网关将请求发到指定后端。
插件维护一个有容量上限的本地近似前缀索引,并按加权LRU清理价值较低或较久未使用的记录。只有成功设置目标后,它才记录本次请求的前缀位置。不能解析或超过处理预算的请求会跳过前缀评分,队列等其他负载信号仍可继续工作。
插件还采用fail-open设计,也就是缓存感知优化出错时不拦截正常请求,而是交给Higress底层网络代理Envoy的默认负载均衡继续处理。这保证了智能路由是可降级的性能优化,而不是推理服务的必经故障点。
前缀索引位于当前WASM插件实例内,既不是推理引擎的真实缓存目录,也不会天然在多个网关副本之间共享。如果部署多个Higress副本,每个插件实例可能根据自己看到的请求形成不同索引;插件重启后也需要重新积累记录。因此,它更适合单个路由视图或能够接受近似效果的场景,不能被当作全局强一致的缓存目录。
Gateway API Inference Extension:标准化选择接口
项目地址:项目仓库。
Gateway API Inference Extension是一套为容器与服务编排平台Kubernetes扩展推理网关能力的规范。它用推理实例池InferencePool表示一组可以运行某个模型的后端端点,再让端点选择器Endpoint Picker,简称EPP,根据请求内容和节点状态选择具体目标。EPP通过Envoy的外部处理协议ext_proc与网关交换信息;通俗地说,就是网关把“这次请求有什么特征”交给选择器,选择器再告诉网关“请发给这个节点”。
它是一套可扩展的接口和模式,不等于某个固定调度算法,也不负责替推理引擎管理KV Cache。官方前缀缓存感知提案选择了EPP本地近似索引:将请求切成字符块,用“当前块内容+父哈希”生成哈希链,根据历史调度推测缓存位置,并建议将前缀命中率、队列长度和KV Cache利用率组合评分。提案也明确承认,多EPP副本会让内存索引失去全局视图。
因此,更准确的表述是:InferencePool + EPP为智能端点选择提供标准连接点,llm-d Router等项目可以在这个连接点上实现近似或精确缓存感知策略。
llm-d Router:近似、精确和会话亲和的可组合管线
项目地址:项目仓库。
llm-d Router把请求处理拆成数据生产、过滤、评分和选择插件,能同时使用:
- 固定大小
Token块的近似前缀索引; - 消费
vLLM或SGLang KV Events变更通知的精确前缀索引; - 用
session-affinity-filter过滤器实现只保留绑定节点的请求路由硬亲和; - 用
session-affinity-scorer评分器给绑定节点加分,实现请求路由软亲和; - 负载、优先级、模型和
LoRA等信号。
会话身份支持两类交互。一类由服务端响应返回x-session-token,其中编码了目标在Kubernetes中的命名空间和名称namespace/name,客户端后续回传;另一类由客户端传x-session-id,服务端维护带TTL的会话到Pod映射。硬绑定节点失效时,过滤器会放开候选并重新绑定,避免请求永远困在故障节点上。
在Prefill/Decode分离场景中,它还可以分别处理Prefill和Decode阶段的会话关联。适合已经采用Kubernetes Gateway API、希望组合策略并逐步从近似索引升级到引擎事件索引的团队。代价是组件和配置较多,精确模式对引擎事件、分词和恢复机制有更强要求。
AIBrix:会话键与有界负载前缀路由
项目地址:项目仓库。
AIBrix提供两种直接的会话亲和方式:
- 简单模式把目标
Pod的网络地址和端口IP:Port做Base64编码并写入响应x-session-id,后续请求解码后回到该节点;节点不可用时随机回退并签发新值。 - 调用方提供稳定会话键时,使用
Rendezvous Hash无状态选择Pod。
其prefix-cache策略默认维护路由器本地前缀表,也可启用缓存事件同步组件KV Event Sync,根据真实缓存变更更新索引。选择时先让前缀匹配率更高的节点排在前面,匹配率相同时再选择当前请求更少的节点。它还根据所有节点的平均负载和节点间负载差异计算一条上限,过载的缓存节点不会入选;此时会回退到整个集群中请求最少的节点。
这套实现很适合说明“缓存亲和必须受负载上限约束”。简单会话模式的Base64令牌更适合受信内部环境;若暴露给不受信客户端,应在外层增加签名、不透明映射或网关校验。
NVIDIA Dynamo:把缓存层级纳入成本函数
项目地址:项目仓库。
NVIDIA Dynamo Router的路由成本不只统计“命中了多少块”,还区分设备、主机、磁盘和共享缓存的前缀收益,并结合当前和新增的未缓存Prefill工作量、可能的Decode块及可选的活动请求成本,选择预计成本最低的节点。
它支持会话请求头X-Dynamo-Session-ID和请求路由硬亲和或软亲和hard/soft模式。硬模式精确分派到绑定目标,目标失效后清除绑定并重新选择;软模式把目标作为正常选择流程中的建议。多路由副本会通过事件消息通道尽力同步会话绑定,但项目文档明确说明这不是权威存储,路由重启会清空绑定;需要严格亲和时仍应使用入口粘滞或外部权威映射。会话亲和也不会在后端创建会话,更不会代替后端生命周期协议。
SGLang Router:实验性的缓存感知与进程内粘滞
项目地址:SGLang项目仓库、SGLang Router源码目录。
当前仓库把sgl-router放在experimental实验目录中,因此成熟度需要谨慎看待。它的cache_aware策略可以使用本地Radix Tree,也就是适合查找公共前缀的树形结构;也可以查询外部KV Indexer缓存位置索引服务。它会在前缀收益与Prefill压力之间做选择;没有有效缓存候选时,使用Power of Two Choices,简称P2C,随机抽取两个节点并选择负载较低的一个。
sticky粘滞策略维护进程内“路由键→后端推理实例”的映射,并按空闲时间淘汰。它部署简单,但多个路由进程的映射不一致,进程重启也会丢失;项目说明建议高可用场景使用一致性哈希或外部状态。适合实验、单路由器和低复杂度部署,不应在没有额外一致性设计时承担严格亲和。
LMCache与Mooncake:让缓存跨越引擎实例
项目地址:LMCache项目仓库、LMCache官方文档;Mooncake项目仓库。
LMCache是推理引擎之外的KV Cache管理层,可将缓存扩展为“GPU高速显存→服务器内存→本地磁盘→远端后端”的层级。它能连接Redis/Valkey等键值数据库,也能连接像云端文件仓库一样按对象保存数据的S3兼容存储、Mooncake,以及负责在不同设备和存储之间高效搬运数据的NIXL库。NIXL全称为NVIDIA Inference Xfer Library,其中Xfer是Transfer,也就是“传输”的缩写。存储模式用于跨请求、会话和进程复用,传输模式用于Prefill/Decode分离和节点间KV移动。
LMCache还支持集中式共享和节点之间直接传输的P2P点对点共享。官方文档中的旧版进程内模式,也就是in-process跨实例示例,已经标记为弃用。生产设计应优先评估让缓存服务独立运行的多进程模式MP mode,而不是照抄旧示例。
Mooncake由高性能传输组件Transfer Engine和分布式存储组件Mooncake Store等部分组成。前者负责在不同网络和计算设备之间移动数据,支持批量传输、选择更合适的数据路径、聚合多张网卡带宽和失败切换;后者利用集群中的服务器主内存和SSD固态硬盘构建分布式KV Cache池,并与vLLM、SGLang等生态集成。
两者与缓存感知路由不是互斥关系。路由先选“本地命中且不拥塞”的节点;没有本地命中时,再决定从哪个节点或共享层加载,或者直接重算。适合长上下文、跨实例复用价值高、具备高速网络和存储运维能力的场景。对于短提示、低重复率或普通以太网环境,传输开销可能不划算。
开源方案对比
| 项目 | 主要状态来源 | 跨路由副本 | 负载联合决策 | 更适合的场景 |
|---|---|---|---|---|
Higress ai-endpoint-picker | 本地近似索引+后端指标 | 本地索引不共享 | 是,多信号加权 | 单集群内轻量、低延迟端点选择 |
Gateway API EPP参考算法 | EPP本地近似索引 | 多副本视图会退化 | 提案建议联合评分 | 需要标准接口和可替换调度器 |
llm-d Router | 近似索引或引擎KV Events | 取决于索引与部署 | 是,可组合插件 | Kubernetes大规模推理与PD分离 |
AIBrix | 本地表或KV Event Sync | 可选事件同步 | 是,有界负载 | 需要会话键、前缀和负载多策略组合 |
NVIDIA Dynamo | 多层缓存索引+活动负载 | 事件平面尽力同步 | 是,成本函数 | 多节点、多缓存层和分离式推理 |
SGLang Router | 本地Radix Tree或外部索引 | 本地粘滞不共享 | 是 | 实验和SGLang生态验证 |
LMCache/Mooncake | 真实可加载的外部KV | 缓存可跨实例 | 依赖上层路由 | 长上下文、跨实例共享和高速互联 |
没有一个项目能同时做到零侵入、精确目录、无状态、多副本强一致、无网络开销和绝对负载均衡。选型本质上是在命中收益、状态复杂度、引擎耦合与故障恢复之间取舍。
推荐的生产架构
对于同时包含多轮会话、共享系统提示和长文档问答的自建集群,可以采用下面的分层设计。它不是要求一次性上齐所有组件,而是明确每层职责。
基本架构
建议按三个阶段建设:
- 先建立基线。确保推理引擎真正开启前缀缓存;固定模板和工具顺序;采集
Token命中率、TTFT、队列和KV利用率。 - 再加入请求路由软亲和或近似前缀路由。对拥塞节点设置硬上限,未命中或解析失败时回退最少请求,验证命中率提升是否真的转化为
TTFT下降。 - 最后评估精确索引和共享缓存。只有长前缀重算成本足以覆盖事件、存储和传输开销时,才引入
KV Events、点对点缓存传输或远端存储。
故障降级顺序
生产实现应让优化失效时仍能正确推理:
只有在后端确认接收或响应流成功建立后,再写入新的会话绑定和近似缓存位置,能减少“请求没有成功,索引却错误地记录了缓存位置”的情况。流式响应是服务端一边生成、一边把内容发给客户端;如果客户端已经收到一部分内容,再次重试可能得到两段回答,因此必须谨慎处理。