1. 决策路由到底在解决什么问题
第一次接触 GPUStack 的决策路由机制,是在一个多模型混跑的推理集群里。当时集群里挂了七八个不同尺寸的模型,有对话的、有代码补全的、有做嵌入的,用户请求进来之后全靠一个简单的轮询分发。表面上看负载挺均衡,实际上大模型被小请求拖死、小模型被大请求压垮的情况天天发生。后来把决策路由接进来,配合 Jev 做规则编排,整个集群的吞吐和响应稳定性才有了质的变化。
决策路由这个词听起来有点抽象,说白了就是在请求真正到达模型之前,先判断这个请求该交给谁处理。它和传统的负载均衡不是一回事。负载均衡看的是后端节点的压力,决策路由看的是请求本身的特征——输入长度、任务类型、优先级、甚至调用方身份。GPUStack 把这一层单独抽出来做成可配置的路由层,Jev 则负责把路由规则用声明式的方式表达出来,两者配合之后,路由逻辑从硬编码变成了可热更新的配置。
这套机制能解决的问题很具体:多模型共存时的资源错配、不同优先级请求的差异化调度、灰度发布时的流量切分、以及故障模型的自动摘除。适合谁来参考?如果你正在维护一个多模型的推理服务,或者准备把单模型服务扩展成模型池,又或者你已经被"某个模型被打爆、其他模型闲着"的问题困扰过,那这套东西值得花时间研究。
我下面会从机制原理、配置写法、验证方法三个层面把它拆开讲,中间穿插我自己踩过的坑和实测数据。内容基于 GPUStack 和 Jev 的常见实践整理,具体参数以你实际部署的版本为准。
2. 决策路由的机制拆解
2.1 请求进入路由层之后发生了什么
一个请求从进入网关到被分发到具体模型实例,中间要经过几个明确的阶段。理解这几个阶段,后面配置的时候才知道每个字段在哪个环节生效。
第一阶段是请求解析。路由层拿到原始请求后,会提取出一组特征向量,包括 prompt 的 token 数量、请求的 max_tokens 参数、是否带工具调用、是否流式、调用方标识等。这些特征不是随便取的,每一项都对应后面规则里可能用到的判断条件。比如 token 数量决定了这个请求是"轻"还是"重",max_tokens 决定了它预期占用显存的时间。
第二阶段是规则匹配。Jev 定义的规则集在这里被逐条求值。规则是有优先级的,从上往下匹配,命中第一条就停止。这一点很关键——它意味着规则的顺序直接决定了最终行为,把宽泛的规则放在前面会把后面的精确规则全部吃掉。
第三阶段是目标选择。规则命中后会得到一个目标集合,可能是一个模型名,也可能是一组模型加一个选择策略(比如最少连接、加权随机)。如果目标集合里有多个候选,路由层会结合实时健康状态做二次筛选,把不健康的实例剔除。
第四阶段是决策记录。每次路由的结果都会被记录下来,包括命中了哪条规则、最终选了哪个实例、耗时多少。这部分数据是后面做验证和调优的依据,很多人配置完就不管了,结果出了问题完全不知道请求走了哪条路径。
提示:决策记录一定要开,而且最好落到独立的存储里。我见过把日志和推理日志混在一起的部署,排查路由问题时翻日志翻到崩溃。
2.2 为什么用 Jev 而不是直接写代码
有人会问,路由逻辑用 Python 写个函数不就行了,为什么要引入 Jev 这层声明式配置。这个问题我在项目初期也纠结过,后来想明白了几个点。
第一是热更新。硬编码的路由逻辑改一次要重新部署服务,而推理服务重启意味着正在处理的请求全部中断。Jev 的规则集支持运行时加载,改完规则推下去,下一个请求就生效,不影响在途请求。对于需要频繁调整流量策略的场景,这个差异是决定性的。
第二是可审计。代码里的 if-else 逻辑,除了写的人没人看得懂当前生效的策略是什么。声明式规则是自解释的,运维、算法、业务方都能看懂"什么请求走什么模型",出了问题能快速定位是哪条规则的问题。
第三是关注点分离。模型团队专注把模型跑好,路由策略由平台侧统一维护,两边不用互相等。Jev 的规则语法足够表达常见的路由需求,又不会复杂到需要专门学一门语言。
当然它也有代价。声明式配置在表达复杂逻辑时不如代码灵活,比如需要根据请求内容做正则匹配再结合历史调用频次做决策,这种就得靠 Jev 的扩展函数或者干脆回退到代码层。我的经验是,八成以上的路由需求用声明式就够了,剩下两成用扩展点解决,不要为了少数场景把整个架构搞复杂。
2.3 路由决策的三个核心维度
实际配置中,路由规则基本围绕三个维度展开,我把它们和对应的典型场景整理成表,方便对照。
| 维度 | 判断依据 | 典型场景 | 配置复杂度 |
|---|---|---|---|
| 请求特征 | token 数、任务类型、是否流式 | 长文本走大模型,短问答走小模型 | 低 |
| 调用方属性 | 租户标识、优先级标签、配额 | 付费用户优先,内部测试限流 | 中 |
| 运行时状态 | 实例健康、队列深度、显存占用 | 故障摘除、过载保护 | 高 |
请求特征维度是最容易上手的,基本上配置几条基于 token 数量的规则就能看到效果。调用方属性维度需要你的请求里带上可识别的标识,这要求上游调用方配合改造。运行时状态维度最复杂,因为它涉及和 GPUStack 的健康检查模块联动,但收益也最大——它能做到真正的动态调度,而不是静态规则。
我建议的落地顺序是:先把请求特征维度的规则跑通,验证整条链路没问题;再接入调用方属性做差异化;最后再上运行时状态。一次性全上,出了问题你根本不知道是哪层的问题。
3. Jev 规则配置实战
3.1 规则文件的基本结构
Jev 的规则集是一个结构化的配置文件,核心是 rules 数组,每条规则包含匹配条件、目标、以及可选的元信息。下面是一个最小可用的例子,我把它拆开逐段解释。
version: "1" rules: - name: "short-query-to-small" priority: 100 match: all: - field: "prompt_tokens" op: "lt" value: 512 - field: "stream" op: "eq" value: true target: models: ["small-chat-7b"] strategy: "least_conn" meta: description: "短流式请求走小模型"version 字段是规则集的版本标识,改规则的时候建议同步更新,方便回滚时确认当前生效的是哪一版。rules 是规则列表,每条规则必须有 name 和 priority。name 要唯一,出问题看日志的时候全靠它定位。priority 是数值,越大越先匹配,我习惯用 100 的整数倍留出插入空间。
match 是匹配条件,支持 all 和 any 两种组合方式。all 表示所有子条件都满足才命中,any 表示满足任意一个即可。子条件里 field 是特征字段名,op 是操作符,value 是目标值。常用的 op 有 eq、ne、lt、lte、gt、gte、in、regex,基本覆盖了常见判断。
target 是命中后的目标,models 是候选模型列表,strategy 是选择策略。strategy 支持 least_conn(最少连接)、weighted(加权)、random(随机)、hash(一致性哈希)。做会话保持的场景用 hash,做负载均衡用 least_conn,做灰度用 weighted。
注意:priority 相同的规则,匹配顺序不保证稳定。不要依赖同优先级规则的执行顺序,该拉开优先级就拉开。
3.2 多条件组合与优先级设计
单条规则只能解决简单场景,真实环境里往往是多个条件叠加。比如"付费租户的长文本请求走大模型,但如果是流式且 max_tokens 超过 4096 则走另一个专门的长上下文模型"。这种需求就要靠条件组合和规则优先级配合。
我的做法是把规则按"从特殊到一般"排列。最特殊的规则优先级最高,最通用的兜底规则优先级最低。下面这个例子展示了三层结构。
rules: - name: "premium-long-context" priority: 300 match: all: - field: "tenant_tier" op: "eq" value: "premium" - field: "prompt_tokens" op: "gt" value: 8192 - field: "max_tokens" op: "gt" value: 4096 target: models: ["long-context-70b"] strategy: "least_conn" - name: "premium-normal" priority: 200 match: all: - field: "tenant_tier" op: "eq" value: "premium" target: models: ["large-chat-32b", "small-chat-7b"] strategy: "weighted" weights: [3, 1] - name: "default-fallback" priority: 10 match: all: [] target: models: ["small-chat-7b"] strategy: "least_conn"这里有个细节值得说:default-fallback 的 match 用了空数组,表示无条件命中。这是兜底规则的标准写法,保证任何请求都有去处。如果没有兜底规则,未匹配的请求会直接报错,线上环境这是大忌。
weights 字段只在 strategy 为 weighted 时生效,数组长度要和 models 对齐。上面例子里 large-chat-32b 的权重是 3,small-chat-7b 是 1,意味着大约 75% 的流量走大模型。灰度发布的时候这个比例特别好用,从 1:9 慢慢调到 9:1,观察指标再决定是否全量。
3.3 动态字段与运行时状态接入
前面两个例子用的都是请求自带的静态特征。真正体现决策路由价值的是接入运行时状态,让路由能感知后端实例的实时情况。GPUStack 暴露了一组运行时字段,可以在 match 条件里直接引用。
常用的运行时字段包括 instance_queue_depth(实例队列深度)、instance_gpu_util(GPU 利用率)、instance_healthy(健康状态)、instance_ttft_p95(首 token 延迟 P95)。这些字段的值是路由层在每次决策时实时拉取的,不是配置时写死的。
- name: "avoid-overloaded" priority: 250 match: all: - field: "instance_queue_depth" op: "gt" value: 20 target: models: ["small-chat-7b", "medium-chat-13b"] strategy: "least_conn" exclude_overloaded: true meta: description: "队列过深时切换到备用模型池"exclude_overloaded 是个布尔开关,开启后路由层会在候选实例里自动剔除队列深度超过阈值的节点。这个机制配合健康检查,能实现故障实例的自动摘除——实例挂了,健康检查标记为不健康,路由层下次决策就不会再选它。
不过这里有个坑我要提醒:运行时字段的采集是有延迟的,通常有几秒的窗口。如果你的流量突增非常快,可能规则还没反应过来,实例已经被打爆了。所以运行时状态适合做"慢变量"的调节,突发流量的防护还得靠上游的限流和队列。
3.4 配置校验与灰度发布
规则写完不能直接推全量,Jev 提供了校验和灰度两个机制。校验是静态的,检查语法、字段名、引用完整性;灰度是动态的,让新规则只对一部分流量生效。
校验命令跑一下,能挡掉大部分低级错误:
jev validate --file rules.yaml --schema gpu-stack-v1输出会列出每条规则的问题,包括未知字段、类型不匹配、优先级冲突等。我习惯在 CI 里加这一步,规则文件提交前必须过校验,避免把错误配置推到线上。
灰度发布靠的是规则里的 traffic_ratio 字段,取值 0 到 100,表示这条规则对多少比例的流量生效。没命中的流量会继续往下匹配。
- name: "new-routing-canary" priority: 280 traffic_ratio: 5 match: all: - field: "prompt_tokens" op: "lt" value: 1024 target: models: ["new-small-model"] strategy: "least_conn"上面这条规则只对 5% 的短请求生效,其余 95% 会落到后面的规则。观察一段时间,对比新模型和旧模型的延迟、错误率、成本,没问题再把比例往上调。这个过程中决策记录里的 rule_name 字段能帮你精确区分哪些请求走了新规则。
4. 验证方法与效果度量
4.1 怎么确认路由真的按预期工作
配置推上去之后,第一件事是确认路由行为符合预期。最直接的办法是构造一批特征明确的测试请求,看它们分别落到哪个模型。Jev 提供了一个 dry-run 模式,不实际调用模型,只返回路由决策结果。
jev route --dry-run --input '{"prompt_tokens": 300, "stream": true, "tenant_tier": "free"}'返回结果里会包含命中的规则名、候选模型列表、最终选中的实例。拿一组覆盖各种特征的请求跑一遍,对照你的规则设计,能快速发现规则顺序错误、条件写反之类的问题。
dry-run 只能验证静态逻辑,运行时状态相关的规则得在真实流量下验证。这时候看决策记录。每条记录包含 request_id、matched_rule、selected_instance、decision_latency_ms 等字段。把记录导出来做聚合,能回答几个关键问题:各条规则的命中比例是否符合预期、有没有请求落到兜底规则(说明前面的规则覆盖不全)、决策本身的耗时是多少。
提示:decision_latency_ms 这个指标容易被忽略。路由决策本身如果超过 10ms,在高 QPS 场景下会成为瓶颈。我遇到过规则集膨胀到几百条之后决策延迟飙升的情况,后来靠规则合并和索引优化压回去了。
4.2 关键指标与基线对比
验证路由效果不能只看"有没有报错",要看业务指标。我通常关注四组指标,上线前后各测一轮做对比。
| 指标组 | 具体指标 | 期望变化 | 采集方式 |
|---|---|---|---|
| 延迟 | TTFT P50/P95/P99 | 下降或持平 | 推理服务埋点 |
| 吞吐 | QPS、tokens/s | 上升 | 网关统计 |
| 资源 | GPU 利用率、显存占用 | 更均衡 | GPUStack 监控 |
| 质量 | 错误率、超时率 | 下降 | 网关统计 |
TTFT(首 token 延迟)是流式场景最敏感的指标。路由做得好,短请求不再排队等大模型,TTFT 的 P95 会有明显改善。我实测过一个场景,接入决策路由前 P95 是 2.3 秒,接入后降到 0.8 秒,主要收益就来自短请求被分流到了小模型。
GPU 利用率的均衡度也值得看。路由之前经常是一个实例 90% 另一个 30%,路由之后能压到 70% 和 55% 这种水平。均衡度上来了,同样的硬件能扛更多流量,这是最直接的收益。
质量指标里错误率要特别关注。路由规则写错可能导致请求被发到不支持的模型上,表现为 4xx 错误。上线初期每天扫一遍错误日志,确认没有因为路由导致的异常。
4.3 压测方案与容量评估
功能验证通过之后,要做压测确认容量。压测的关键是流量特征要贴近真实,不能只用一种请求打。我一般按线上流量的特征分布构造几组请求,按比例混合后打进去。
比如线上 60% 是短请求(token < 512)、30% 是中等请求、10% 是长请求,压测就按这个比例构造。如果只用短请求压,路由看起来完美,一上真实流量长请求就把大模型打爆了。
压测时重点观察两个东西:一是各实例的负载是否按规则预期分布,二是路由层本身有没有成为瓶颈。后者容易被忽略,路由层如果单点部署,QPS 上去之后决策延迟会拖累整体。我的经验是路由层至少双实例,前面挂个负载均衡,决策延迟控制在 5ms 以内。
容量评估的公式大概是这样的:单实例能承载的 QPS 乘以实例数,再乘以路由分流后的有效系数。有效系数取决于路由的准确度,分得越准系数越接近 1。实测下来,规则设计合理的场景,有效系数能到 0.85 以上,比不分流的 0.6 左右有明显提升。
5. 常见问题与排查技巧
5.1 请求落到了错误的模型
这是最常见的问题,表现是某个请求明显应该走 A 模型,结果走了 B 模型。排查顺序是这样的。
先看决策记录里的 matched_rule,确认命中的是哪条规则。如果命中的规则本身就不对,说明规则顺序有问题——大概率是某条宽泛规则优先级设高了,把精确规则挡住了。解决办法是调整优先级,让精确规则排在前面。
如果命中的规则是对的,但 selected_instance 不对,那问题出在目标选择环节。检查候选模型列表里是不是包含了不该有的模型,或者 strategy 配置是否合理。weighted 策略下权重配置错误也会导致流量倾斜。
还有一种情况是运行时字段导致的意外。比如某条规则依赖 instance_queue_depth,压测时队列深度普遍偏高,规则被大量触发,流量全跑到备用池去了。这种要看运行时字段的阈值设置是否合理,阈值太低会导致规则过于敏感。
5.2 规则改了不生效
改了规则文件推上去,行为没变化,通常有三个原因。
第一是缓存没刷新。Jev 的规则加载有缓存机制,默认缓存时间可能是几十秒。如果急着验证,可以手动触发刷新,或者把缓存时间调短。生产环境不建议把缓存调太短,会增加配置中心的压力。
第二是版本没更新。有些部署方式下,规则文件更新了但 version 字段没变,加载器认为没有变化就跳过了。养成改规则必改 version 的习惯。
第三是多实例不一致。路由层如果多实例部署,规则推送要保证所有实例都收到。我遇到过推送只成功了一半实例的情况,表现为行为时对时错。排查方法是逐个实例查当前生效的规则版本,不一致就重新推。
5.3 决策延迟过高
路由决策本身耗时超过预期,会拖累整体延迟。常见原因和解决办法整理成表。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 决策延迟随规则数线性增长 | 规则逐条匹配无索引 | 按特征字段建索引,减少无效匹配 |
| 特定规则决策慢 | 正则匹配复杂 | 简化正则或改用前缀匹配 |
| 运行时字段拉取慢 | 状态存储响应慢 | 加本地缓存,降低拉取频率 |
| 高峰期决策延迟飙升 | 路由层单点 | 水平扩容,前置负载均衡 |
规则数量控制在 100 条以内是比较舒服的区间,超过之后要考虑合并。很多规则其实可以合并成一条,用 in 操作符代替多条 eq 规则。我做过一次规则精简,从 180 条压到 60 条,决策延迟从 15ms 降到 4ms。
5.4 灰度期间指标异常
灰度发布时新规则只对部分流量生效,如果这部分流量的指标明显变差,要能快速回滚。回滚的方式是把 traffic_ratio 改成 0,或者直接把规则删掉重新加载。
判断是否要回滚,看几个信号:新规则覆盖的请求错误率是否高于基线、TTFT 是否明显变长、目标模型的 GPU 利用率是否异常。任何一个信号触发,先回滚再排查,不要在线上一遍调一边观察。
灰度期间还要注意流量比例的实际偏差。traffic_ratio 设 5%,实际可能因为请求特征分布不均,落到新规则的请求占比偏离 5%。这时候要看决策记录里 rule_name 的分布,确认实际比例,再决定是否调整。
6. 我踩过的几个坑和一点经验
规则优先级这块我吃过一次大亏。早期为了图省事,把所有规则都设成一样的优先级,想着反正条件不重叠。结果有两条规则的条件在边界上重叠了,一个请求同时满足两条,匹配到哪条完全看运气。表现就是同样的请求时而走 A 时而走 B,排查了半天才发现是优先级问题。从那以后我定了个规矩:任何两条规则的条件如果有重叠可能,优先级必须拉开。
运行时字段的阈值设置也是个容易翻车的地方。我一开始把 instance_queue_depth 的阈值设成 5,想着早点分流保护实例。结果正常波动下队列深度经常超过 5,规则被频繁触发,流量在模型池之间来回跳,反而增加了不稳定。后来把阈值调到 20,只在真正过载时才触发,效果就对了。阈值这东西要基于实际监控数据来定,拍脑袋设的值大概率不对。
还有一个是关于兜底规则的。有次改规则的时候手滑把兜底规则删了,推上去之后所有不匹配的请求全部报错。好在灰度比例小,影响面有限。这件事之后我在校验流程里加了一条:规则集必须包含至少一条无条件命中的兜底规则,校验不通过不允许推送。
最后分享一个验证的小技巧。构造测试请求的时候,除了正常请求,一定要构造边界请求——token 数刚好等于阈值的、字段缺失的、字段类型不对的。这些边界情况最能暴露规则设计的问题。我现在的测试集里有三成是边界用例,每次改规则都跑一遍,能挡掉大部分低级错误。
这套决策路由加 Jev 的组合,用下来最大的感受是它把"路由策略"从代码里解放出来了。策略调整不再需要发版,业务方也能参与讨论规则设计。当然它不是什么银弹,规则设计本身还是需要你对业务流量有足够理解。工具只是让表达和执行变简单了,判断力还得靠自己积累。