压测跑到一半,单实例的 QPS 卡在一个数上不去,很多人第一反应是换更大的卡、调 batch size、改 attention 后端。我遇到过几次类似情况,最后把火焰图和请求延迟分布拉出来一看,时间根本没花在 GPU 上,而是花在请求该发给谁这件事上。这也是为什么 SGLang Model Gateway 这个组件值得单独拎出来讲——它看着像个普通反向代理,实际上承担了大模型推理服务里最脏最累的一层调度逻辑。我先把话说清楚:这篇聊的是SGLang Model Gateway在整套推理链路里负责的核心功能。它能干什么?简单说就是给你的多个推理 worker 套一个统一的 OpenAI 兼容入口,同时替你决定每个请求落到哪个副本、失败了怎么换、新副本什么时候能接流量、前缀缓存怎么复用。适合谁看?已经在单实例上跑通模型、准备扩到多副本的开发者;被多副本之后命中率反而掉了困扰的运维同学;以及正在评估要不要自研一层调度网关的团队。下面这些内容,一部分来自组件本身的设计逻辑,一部分是我在类似结构上踩出来的经验,凡是属于常见实践补充的地方我会明确标出来,你按自己版本的实际参数名对照着看。1. 为什么多副本之后必须有一层网关,而不是让客户端自己轮询1.1 从单实例直连到多副本前置的分水岭单实例阶段,架构是非常干净的:客户端直连http://host:port/v1/chat/completions,模型进程自己管 batch、自己管 KV 缓存,一切都在一个进程里。这个阶段你几乎不需要网关,硬加一层反而多一次网络跳转。真正的分水岭出现在三个条件同时成立的时候:第一,单卡放不下模型或者单实例吞吐不够,必须多副本;第二,副本之间不是完全等价的,有的正在预热、有的负载高、有的刚重启缓存是空的;第三,业务侧希望只配置一个 endpoint,不想在客户端里维护一堆地址。这三条一凑齐,客户端自己轮询就撑不住了——它不知道哪个副本现在有空,不知道哪个副本上刚好有这段 system prompt 的缓存,更不知道某个副本已经在假死边缘。我见过的一种典型翻车是这样:客户端用最朴素的轮询把请求平均分给 4 个副本,结果长 prompt 的请求恰好都落在缓存为空的副本上,prefill 全量重算,TTFT(首 token 延迟)直接翻倍。这时候你会觉得扩副本没用,其实是调度策略把收益吃掉了。1.2 网关不是反向代理:三个它必须自己做的决策把 Model Gateway 当成 nginx 那种纯转发是会出问题的。它至少要在三个层面做独立决策:决策层纯反向代理的做法网关必须做的做法选副本轮询 / 最少连接结合前缀匹配、实时负载、健康状态综合打分副本管理静态配置 upstream动态注册、心跳探测、自动摘除与回流请求语义无感知解析请求体做缓存亲和、按错误类型决定是否重试第一层是选谁。这里的难点在于大模型请求的成本极度不均匀——一个 200 token 的问答和一个 30K token 的文档摘要,在 GPU 上的耗时差两个数量级。用连接数当负载指标会严重失真,因为一个连接可能正在跑超长 prefill。第二层是谁还能用。推理进程的故障形态很讨厌:它可能端口还通、HTTP 还能返回 200,但内部已经卡在某个死锁的 batch 上,或者显存碎片化到新请求全部 OOM。这种假死靠 TCP 探活是发现不了的,必须靠应用层的心跳和真实的健康端点。第三层是失败了怎么办。这个后面会单独展开,先说结论:不是所有 5xx 都值得重试,流式请求的重试尤其要小心,否则你会在客户端看到重复输出的半截内容。1.3 统一入口带来的隐性收益除了调度本身,网关还有一个容易被低估的价值:它把OpenAI 协议兼容这件事收敛到了一处。业务侧代码里写的永远是标准/v1/chat/completions,后面换模型、换推理框架、加副本、做灰度,客户端一行都不用改。这在多团队共用一个推理集群时特别重要——你不可能要求每个业务方都理解你的副本拓扑。另外一个收益是可观测性。所有请求都过网关,意味着你能在一个地方统计到:每个副本的 QPS、TTFT 分布、缓存命中率、错误率、重试次数。这些指标如果分散在 N 个 worker 上,排查问题时的成本是指数级上升的。2. 路由策略的真实取舍:cache_aware 不是万金油2.1 几种常见策略的适用边界网关通常内置多种路由策略,我按实际使用频率排一下:策略决策依据适合的场景明显不适合的场景round_robin顺序轮转请求长度均匀、无长 system prompt大量共享前缀的对话类应用random随机快速验证、压测基线生产环境,方差大least_load实时的 running/waiting 请求数请求长度差异大、副本算力不均副本缓存差异极大时cache_aware前缀近似匹配 负载兜底多轮对话、固定 system prompt、RAG每个请求完全独立无共享前缀power_of_two随机抽两个取较优大规模副本、决策开销要低副本数很少时收益不明显很多人第一反应是那我全用 cache_aware 不就完了。实测下来不是。cache_aware 的收益完全取决于你的请求里有多少可复用的前缀。如果你的业务是每个请求都是独立的短问答,前缀各不相同,那这套机制只是在增加决策开销,而且因为它在匹配失败时会退化成按负载选,你还要多付一次树的查找成本。判断标准很简单:把线上请求的 prompt 前 1000 个 token 抽样出来,统计它们的最长公共前缀分布。如果超过 60% 的请求共享同一段 system prompt 或者同一份 RAG 上下文,那 cache_aware 值得上;如果共享率很低,老老实实用 least_load 更稳。2.2 cache_aware 是怎么近似记住前缀的这里要说清楚一个容易误解的点:网关并不知道每个 worker 上真实的 KV 缓存内容。它维护的是一棵自己记录的近似 radix 树,记录的是我曾经把哪些 token 前缀发给过哪个 worker。流程大致是这样:请求进来后,网关把 messages 拼成一段 token 序列(或者直接用原始文本的哈希),在树里查找最长公共前缀。如果这段前缀之前被发到 worker A,并且树里记录 worker A 仍然在线且负载可接受,那就优先选 A。如果匹配到的前缀很长,但 A 现在的负载已经很高,那就要在复用缓存和避免排队之间做权衡——这一步通常是拿匹配长度和一个阈值比较。这套设计的精妙之处在于它不追求精确。维护精确的缓存目录需要 worker 主动上报、需要处理淘汰、需要跨进程同步,成本极高而且容易出错。近似的好处是纯本地计算、零额外 RPC,代价是偶尔会误判——树里说 worker A 有这段前缀,实际上 worker A 的缓存已经被淘汰了。但误判的代价只是没省到 prefill,不影响正确性,这个交换非常划算。需要提醒的是,树的更新是有延迟的。请求发出去到 worker 实际开始 prefill 之间有个时间窗,如果这个窗口里另一个相同前缀的请求把 worker 打满了,第一个请求的预期命中就落空了。所以你会观察到高并发下 cache_aware 的实际命中率比理论值低一截,这是正常的。2.3 我会在什么时候强制回退到 round_robin有几个场景我会把策略切回最笨的 round_robin,都是踩过之后总结的:灰度发布期间。新版本 worker 和老版本混跑,前缀缓存不通用(不同版本的 tokenizer 或 attention 实现可能不一致),cache_aware 会把大量请求黏到老 worker 上,新 worker 吃不到流量,灰度失去意义。压测取基线时。你要测的是单副本在无缓存干扰下的极限吞吐,这时候任何亲和性都会污染数据。副本数很少(比如 2 个)且请求极不均匀时。两个副本的情况下,cache_aware 的负载兜底逻辑经常被触发,反而制造了抖动,不如老老实实按负载分。切换成本很低,大部分实现都是启动参数级别的事,所以我的建议是:别把它当成一次性选择,按发布阶段和流量特征动态调。3. 缓存命中率:把省下来的 prefill 折算成实打实的吞吐3.1 命中率为什么必须在网关侧统计worker 自己当然知道自己的缓存命中了多少,但那个数字对你判断调度策略好不好没有帮助。因为你看到的是 4 个 worker 各自 40% 的命中率,却不知道如果调度得更好能到多少。网关侧的统计能回答更关键的问题:一个带共享前缀的请求进来,被分到了有缓存的副本还是没缓存的副本?这两个桶的 TTFT 差异有多大?如果差异很小,说明缓存复用没起作用,可能是 prefix 长度不够,或者 worker 侧的缓存淘汰太快。如果差异很大但命中比例低,那就是调度策略的问题,该调阈值了。我习惯在网关日志里给每个请求打三个标记:matched_prefix_len、target_worker、expected_cache_hit。压测之后对着这三个字段聚合,基本能定位 80% 的调度问题。3.2 阈值参数的调参手感cache_aware 通常有个缓存匹配长度阈值之类的参数,含义是:最长公共前缀超过这个长度,才认为值得为了缓存而放弃负载均衡。这个值的设定直接决定了策略的性格。调小(偏向复用):命中率高,但容易出现热点副本,长尾延迟变差。 调大(偏向均衡):负载更平,但缓存白建了。我的一般做法是从一个中间值起步,然后看两个指标:副本间的 QPS 标准差,以及 P99 TTFT。如果标准差超过均值的 30%,说明热点太严重,往上调;如果 P99 TTFT 明显高于 P50 的 3 倍以上,且缓存命中率低于预期,说明亲和性不够,往下调。还有一个常被忽略的参数是负载再平衡的触发条件——绝对阈值和相对阈值。绝对阈值管的是负载到了这个数就别再往上堆了,相对阈值管的是当最优副本和次优副本的负载差超过这个比例时,放弃缓存亲和。后者是防止热点的关键,很多人只调前者,结果副本间负载依然不均。提示:这些参数名在不同版本里差异很大,有的叫cache-threshold,有的叫balance-abs-threshold,对照你手上版本的启动参数帮助信息看,别照抄。3.3 一个被忽略的细节:请求体的规范化缓存能不能命中,很大程度上取决于同样的语义是否产生了同样的字节。我遇到过一次命中率异常低的问题,查了两天才发现:两个业务方调用同一个模型,一个传temperature: 0.7,另一个传temperature: 0.70,还有的显式传了top_p: 1.0。这些参数在语义上等价,但序列化出来的请求体不同,如果网关的前缀提取逻辑是直接拿原始 JSON 做哈希,就会认为是两个不同的请求。正确做法是在网关侧做一层规范化:只提取真正影响前缀的部分(通常是 messages 里 role 和 content 拼接后的结果,加上 tools 定义),忽略采样参数。这个细节文档里通常不会写,但它在多业务方共用一个集群时影响巨大。另外一个坑是 system prompt 的位置。有的客户端把 system 放在 messages 数组第一个,有的放在最后,还有的用独立的 system 字段。这些在语义上可能等价,但如果你的 tokenizer 拼接顺序不同,前缀就不同。统一在网关做一次重排,能把命中率拉上来一截。4. Worker 生命周期:注册、心跳、摘除和回流4.1 启动竞态:别让还没预热的副本接流量这是最容易踩的坑,没有之一。推理 worker 启动之后,进程活着、端口通了,但它还在加载权重、编译 CUDA graph、预热 kernel。这个阶段如果网关把流量打过去,轻则超时,重则 OOM 崩掉,然后网关认为它挂了,摘除,重启,再打流量,再崩——进入死循环。正确的做法是让 worker 主动报到:worker 完成所有预热步骤后,才向网关的注册端点发一个请求,把自己加进可用列表。网关侧不应该用端口探通来判定就绪。但这里还有个更隐蔽的问题:即使 worker 报到了,它的缓存也是空的。如果这时候 cache_aware 策略认为这个新副本负载最低,会瞬间把大量请求甩过去,而这些请求在新副本上全部要重新 prefill,延迟爆炸。我的处理办法是给新加入的 worker 一个缓升期:前 N 秒内只分配比例流量,或者干脆在路由打分时给新 worker 加一个惩罚项,随着时间衰减。4.2 心跳失败和假死不是一回事心跳失败有两种,处理方式完全不同。一种是进程真的没了:连接被拒、端口不通。这种好办,直接从列表里摘掉,正在进行的请求快速失败或者重试到别的副本。另一种是假死:进程还在,HTTP 端口还能返回 200,但内部的调度循环卡住了。这种情况如果只探 TCP 或者只探/healthz返回 200,是发现不了的。必须让健康端点反映真实的业务状态——比如返回当前 running 请求数、最近一次成功处理的耗时、队列积压深度。网关根据这些字段判断:如果 running 请求数长时间不降,或者队列深度持续增长,就标记为不健康。我一般会配置连续失败次数阈值,比如连续 3 次探测失败才摘除,避免网络抖动导致误摘。同时配一个成功恢复阈值,比如连续 2 次成功才重新加入,防止一个时好时坏的副本反复进出造成流量抖动。4.3 摘除之后的连接排空摘除一个 worker 不是从列表里删掉就完事了。它上面可能还有几个正在生成的请求,这些请求如果被立刻切断,客户端会收到半截内容。对于流式请求来说,这比直接报错还难受——用户已经看到一半的输出了。正确做法是两阶段摘除:先标记为不再接收新请求,然后等待一个排空窗口(比如 30 到 60 秒),让存量请求自然结束,再真正从路由表移除。这个窗口要覆盖你的 P99 生成时长,如果长文本生成很常见,窗口还要拉长。注意:排空窗口和健康检查频率要配合。如果检查间隔是 10 秒,窗口是 30 秒,那实际留给请求的时间可能只有 20 秒出头,因为最后一轮检查完成前不会开始计时。5. 重试这件事:收益很大,坑也很大5.1 哪些错误值得重试,哪些重试了更糟网关的自动重试看起来是个纯粹的加分项,实际上如果无差别对待所有错误,它会把你带进更深的坑。错误类型是否重试原因连接被拒 / 连接超时是副本大概率已挂,换一个副本能恢复502 / 503是通常是副本暂时不可用504 网关超时谨慎可能副本还在算,重试会双倍消耗算力400 参数错误否换个副本还是同样的错误,纯浪费429 限流视情况如果是网关自身限流,重试只会加重500 内部错误谨慎可能是显存 OOM,换副本确实能缓解,但要限次数我见过最糟糕的配置是5xx 全部重试 3 次,结果一个 OOM 的副本导致请求量翻三倍,把剩下健康的副本也压垮了。重试次数一定要小,通常 1 到 2 次封顶,并且要有全局的重试预算控制。5.2 流式请求的重试几乎是个陷阱流式输出(stream: true)的重试有个本质困难:你已经开始往外吐 token 了,这时候上游断了,你没法从头再来——客户端已经收到了一部分内容,再发一遍会产生重复。可行的方案只有两种。第一种是首 token 之前允许重试:在 SSE 的第一个 chunk 发出之前,如果连接失败,可以安全地换副本重来。一旦第一个 chunk 发出去了,就不允许重试,只能中断并让客户端感知到错误。第二种是带上幂等标记:客户端在请求头里带一个唯一 ID,网关记录这个 ID 对应的副本,重试时优先回原副本,这样至少能利用已有的 KV 缓存,减少重复计算。但这仍然无法避免重复输出,只是降低损失。我的实践是:只在首 chunk 之前重试,并且在网关侧明确记录流式中途失败的次数,这是一个需要盯的指标。如果这个数很高,说明后端稳定性有问题,该去修副本而不是加重试。5.3 重试放大的雪崩防护当集群里有副本开始不稳定时,重试会形成正反馈:失败的请求重试到其他副本,其他副本压力上升,开始变慢,超时变多,于是更多请求重试。这个过程可以在几十秒内把整个集群打垮。防护手段有三个:一是给重试加指数退避,别让重试在同一时刻集中爆发;二是给网关设一个全局的重试比例上限,超过就让请求直接失败,快速暴露问题;三是让熔断机制介入,如果某个副本连续失败率超过阈值,直接把它踢出路由表一段时间,而不是让每个请求都去试一次。6. PD 分离拓扑下,网关的活比想象中多6.1 prefill 和 decode 分段后的请求编排当推理集群采用 prefill/decode 分离部署时,一个用户请求在网关这一层要变成两次内部调用:先发给 prefill 实例算出 KV 缓存,再把 KV 的传输句柄交给 decode 实例继续生成。这意味着网关要维护两套 worker 池、两套健康状态、两套负载指标。而且这两类实例的瓶颈完全不同:prefill 是算力密集型,看的是 GPU 利用率;decode 是访存密集型,看的是 batch 内的并发数和显存带宽。用同一套负载指标去调度这两类实例,结果一定难看。我在这类拓扑上会分开配置路由策略:prefill 池偏向 cache_aware(因为前缀复用直接省算力),decode 池偏向 least_load(因为 decode 阶段缓存复用收益有限,均衡更重要)。6.2 KV 传输失败的回退路径KV 传输链路是这个架构里最脆弱的一环。网络抖动、传输服务重启、显存不足,都可能让传输失败。网关必须有一条明确的回退路径:要么重新走一次 prefill(浪费算力但保证正确性),要么直接向客户端报错。我倾向于前者,但要有次数限制。同时这个回退次数必须单独监控,因为它是看起来服务正常但成本翻倍的典型信号。7. 怎么确认网关自己不是瓶颈7.1 必须盯的几组指标网关做的是轻量但高频的活:解析 JSON、拼 token 前缀、查树、算负载分数。单次开销很小,但乘以每秒几千请求就不可忽略。我一般会盯这几组:网关自身的决策延迟(从收到请求到选出 worker 的时间),P99 应该在一毫秒级,超过就该查了每个 worker 的分流比例,理想情况接近均衡,偏差超过 30% 要查策略缓存命中率的分桶统计,按 matched_prefix_len 分桶看 TTFT重试率和流式中途失败率,这两个是稳定性预警worker 摘除次数,频繁进出说明副本不稳定或者健康检查太敏感7.2 压测脚本最容易骗自己用固定的短 prompt 压网关,你会得到一个非常漂亮的 QPS 数字,然后上线之后完全对不上。原因很简单:真实流量的 prompt 长度分布是长尾的,而且带共享前缀。固定短 prompt 既压不出网关的 token 处理开销,也压不出缓存亲和的效果。我的做法是准备三组流量:纯短请求(测网关极限吞吐)、带 4K 共享前缀的请求(测缓存亲和收益)、长度服从对数正态分布的混合流量(最接近真实)。三组都跑一遍,心里才有底。7.3 一次完整的排查链路我复现过一次典型问题,过程值得记下来:现象是多副本部署后,整体吞吐只有单副本的 2.2 倍(4 个副本)。第一步看网关的分流比例,发现 4 个 worker 的 QPS 是 40/30/20/10,严重不均。第二步查策略配置,确认用的是 cache_aware。第三步拉网关日志里的matched_prefix_len,发现大量请求匹配到了很长前缀,都黏在 worker 1 上。第四步看 worker 1 的实时负载,已经排队了。结论是缓存的收益超过了负载惩罚。修改方式有两步:把负载再平衡的相对阈值调低,让负载差超过一定比例时主动放弃缓存亲和;同时把新 worker 的缓升惩罚加上,避免启动初期全空缓存被当成最闲。改完之后分流变成 27/25/24/24,整体吞吐到了 3.6 倍。这个案例说明的是:调度策略的效果不能只看单个指标,必须把分流比例和单请求延迟放在一起看,单独看任何一个都会得出错误结论。8. 部署形态和参数速查8.1 网关放哪里网关本身是无状态的(近似 radix 树可以重建),所以部署形态比较自由。常见三种:跟业务服务同机部署(延迟最低,但资源竞争)、独立小规格实例(推荐,隔离性好)、和 worker 混部(省机器,但容易互相干扰)。我倾向于独立部署,规格不用大,但要保证网络带宽充足,因为流式响应会把每个 token 都过一遍网关,长连接数量可能很高。网关要部署多个实例时,注意近似 radix 树是每个实例各自维护的,实例之间不同步。这意味着同一个会话的连续请求如果被负载均衡到不同网关实例,缓存亲和就失效了。解决办法是让上层负载均衡做会话保持,或者接受一定的命中率损失——实测下来后者影响不大,因为对话类请求本来就有间隔,缓存可能已经淘汰了。8.2 参数速查表参数类别关注点经验取值方向路由策略按业务前缀共享率选共享率高用 cache_aware,否则 least_load缓存匹配阈值平衡命中率与热点从中间值起步,看副本 QPS 标准差调负载再平衡阈值防止热点宁可激进一点,热点比缓存失效更伤重试次数防止雪崩1 到 2 次,配合全局预算健康检查间隔发现故障的速度5 到 10 秒,配合连续失败阈值摘除排空窗口保护流式请求覆盖 P99 生成时长新 worker 缓升期避免冷缓存被灌满30 秒起步,按预热耗时调8.3 几个我踩过的具体坑坑一:把 worker 注册地址写成了服务发现域名。网关启动时会去解析域名拿 IP,但容器重启后 IP 变了,网关还拿着旧地址打。要么用稳定的实例标识,要么让 worker 主动注册并且网关定期刷新。坑二:网关的请求体大小限制。长文档输入的 request body 可能有几 MB,默认的 body size limit 直接把请求拒了,返回一个看起来像客户端错误的 413。这个错误不会触发重试,业务侧会以为是自己的问题。坑三:超时配置的层级关系。客户端超时、网关到 worker 的超时、worker 内部生成超时,这三个必须满足客户端 网关 worker。如果反过来,网关会在 worker 还在正常生成时把请求掐断,然后重试,重复计算。坑四:日志里打了完整 prompt。调试期很方便,上线后是灾难——日志量爆炸,而且有数据合规风险。至少要做成可开关的 debug 级别,并且对内容做脱敏。最后分享一个我一直在用的判断方法:当你不确定某个调度参数该怎么调时,先把网关的日志拉出来,按target_worker和expected_cache_hit做交叉统计,看预测命中但实际没命中的比例。这个比例高,说明缓存记录和实际状态脱节,该关注 worker 的缓存淘汰策略;这个比例低但命中率还是不理想,那问题就在路由决策本身。这个拆法比盲目调阈值靠谱得多,也是我在多次调参里觉得最省时间的一招。