1. 从一次推理延迟抖动说起:为什么要做 token 级路由
去年帮一个做智能客服的朋友排查线上问题,现象很怪:同一台推理服务器,白天响应稳定在 200ms 左右,一到晚上高峰期就飙到 1.5s,GPU 利用率却只有 40% 上下。日志翻了个底朝天,最后定位到根因——请求长度分布极度不均。短请求(十几个 token 的意图识别)和长请求(上千 token 的多轮对话摘要)混在同一个 batch 里,长请求把整个 batch 的完成时间拖死,短请求明明算完了也得干等,显存里塞着一堆已经算完的序列占着位置不放。
这个问题在推理系统里非常典型。传统做法是按请求(request-level)做调度和路由:一个请求进来,要么全走这条路径,要么全走那条路径,batch 也是整请求进整请求出。可真实业务里,一个请求内部的 token 之间差异巨大——开头几个 token 可能是系统提示词,中间是用户问题,结尾是格式约束。不同位置的 token 对算力的需求、对精度的敏感度、对延迟的容忍度完全不同。TokenRouter这个项目要解决的核心问题就在这:把路由和调度的粒度从"请求"下沉到"token",让每个 token 或者每组 token 能独立决定走哪条计算路径、进哪个 batch、什么时候可以提前释放。
说白了,它想干的事是:让推理系统像交通调度一样,不再按"整车"放行,而是按"每节车厢"甚至"每个座位"来动态分配车道。这个思路对做推理优化的同学来说,价值在于它把优化空间从请求级一下子打开到了 token 级,理论上能同时改善吞吐、延迟和显存利用率。适合谁看?如果你在做 LLM 推理服务、写过 continuous batching、折腾过 PagedAttention 或者 speculative decoding,这篇会比较对胃口;如果你只是调用 API 的应用层同学,也能从中理解为什么有些推理服务快有些慢。
2. TokenRouter 的整体设计与思路拆解
2.1 核心思路:把"路由决策"从请求级下沉到 token 级
先把这个概念讲清楚。所谓 token-level routing,指的是在推理过程中,针对序列中的每一个 token(或每一组 token),动态决定它的计算路径。这个"路径"可以有好几层含义,我按从浅到深列一下:
- 计算精度路径:这个 token 走 FP16 还是 INT8,走全量注意力还是稀疏注意力。
- 模型分支路径:走主干大模型还是走一个小型草稿模型(类似 speculative decoding 的思路,但粒度更细)。
- 调度路径:这个 token 进哪个 batch、和哪些 token 拼在一起、什么时候可以出队。
- 显存路径:KV cache 放在哪一级存储,是否值得保留,是否可以提前驱逐。
传统 request-level routing 的问题是决策太粗。一个请求里如果只有 10% 的 token 需要高精度,剩下 90% 其实可以走低精度快速通道,但 request-level 只能整请求统一处理,要么全高要么全低,浪费就出来了。TokenRouter 的设计动机就是消除这种"粒度错配"。
2.2 为什么不是 request-level 就够了:三个真实痛点
我拿实际场景说明为什么必须下沉到 token 级。
痛点一:混合长度 batch 的尾部浪费。假设一个 batch 里有 8 个请求,长度分别是 20、30、50、80、120、200、500、1000。continuous batching 虽然能在请求完成时立刻换入新请求,但一个 batch 内所有序列必须同步推进到最长序列结束。那 7 个短请求算完后,它们的 slot 被占着,新请求进不来,GPU 在给一个长序列"陪跑"。如果按 token 级调度,短序列的 token 算完就能立刻释放 slot,长序列的 token 继续留着,利用率能明显改善。
痛点二:prefill 和 decode 的资源冲突。prefill 阶段是计算密集型,decode 阶段是访存密集型。混在一起跑,GPU 的算力和带宽都没吃满。TokenRouter 允许把 prefill token 和 decode token 分开路由到不同的计算队列,各自用最适合的 kernel。
痛点三:不同 token 的精度需求差异。有研究观察到,序列里"关键 token"(比如实体名、数字、代码符号)对量化误差敏感,而大量"过渡 token"(比如虚词、标点、重复模式)对精度不敏感。token 级路由可以让敏感 token 走高精度路径,其余走低精度,整体精度损失可控而速度提升明显。
2.3 架构选型:为什么用"路由器 + 执行器"分离的设计
TokenRouter 的架构我理解下来是两层:一个轻量的Router(路由器)和一个可插拔的Executor(执行器)池。Router 负责对每个 token 做决策,Executor 负责实际计算。这个分离设计有几个好处:
- Router 本身要足够轻,不能成为瓶颈。所以它通常是一个小 MLP 或者基于规则的打分器,跑在 CPU 或者一个小 CUDA kernel 上,单 token 决策开销控制在微秒级。
- Executor 可以异构。你可以挂一个 FP16 的大模型 executor,再挂一个 INT8 的小模型 executor,Router 根据 token 特征分发。
- 决策和执行解耦后,Router 的策略可以热更新,不用重启整个推理服务。
注意:Router 的决策延迟必须远小于它节省下来的计算时间,否则就是负优化。经验值是 Router 单 token 开销要控制在目标 token 计算时间的 5% 以内。
2.4 与已有方案的对比:它站在谁的肩膀上
TokenRouter 不是凭空冒出来的,它和几个已有方向有交集但又有区别。我做个表对比一下,方便你判断它适合什么场景。
| 方案 | 路由粒度 | 主要优化目标 | 典型局限 |
|---|---|---|---|
| Continuous Batching | 请求级 | 吞吐 | batch 内长尾拖累 |
| PagedAttention | 请求级(显存块) | 显存利用率 | 不解决计算路径差异 |
| Speculative Decoding | token 级(草稿验证) | 延迟 | 只优化 decode,不涉及精度/调度 |
| MoE 路由 | token 级(专家选择) | 参数量与算力平衡 | 绑定在模型结构内,不涉及系统调度 |
| TokenRouter | token 级(多维) | 吞吐+延迟+显存综合 | 实现复杂度高,Router 本身有开销 |
可以看到,TokenRouter 的野心更大,它想统一处理精度、分支、调度、显存四个维度的路由。代价就是系统复杂度上去了,这也是为什么它更适合有一定推理优化基础的团队。
3. 核心细节解析与实操要点
3.1 Router 的决策特征怎么选
Router 要决策,就得有输入特征。我梳理了几类在实践中比较有效的特征,你可以根据自己场景取舍:
- 位置特征:token 在序列中的绝对位置和相对位置。开头(系统提示)和结尾(格式约束)通常需要更高精度。
- 注意力权重统计:这个 token 被后续 token 关注的强度。高关注度的 token 往往是语义关键点,值得走高精度。
- token 类型:通过一个轻量分类器判断是数字、代码、实体还是普通文本。数字和代码对量化敏感。
- 历史路由反馈:这个 token 之前走过的路径效果如何,形成一个闭环。
实操中我建议先用规则打底(位置 + token 类型),跑通了再上学习型 Router。一上来就训一个 Router 网络,很容易因为特征工程没做好而效果不如规则。
3.2 决策开销的控制:别让路由器变成新瓶颈
这是最容易翻车的地方。我见过有团队 Router 用了一个 6 层 Transformer,单 token 决策要 200 微秒,结果整体吞吐反而降了 30%。控制开销有几个手段:
- 批量决策:不要一个 token 一个 token 地调 Router,攒一批(比如 64 个)一起决策,摊薄 kernel launch 开销。
- 查表 + 缓存:对于重复出现的 token 模式(比如固定的系统提示词),决策结果直接缓存,命中率能到 60% 以上。
- 决策降频:不是每个 token 都要重新决策,可以每 N 个 token 决策一次,中间沿用上次结果。N 取 4 到 8 比较常见。
提示:Router 的开销要用 profiler 实测,不要拍脑袋估算。我一般会在 Router 前后各打一个 CUDA event,看真实耗时占比。
3.3 执行器池的异构管理
Executor 池里可能同时有不同精度、不同规模的执行器。管理要点:
- 显存隔离:不同 executor 的 KV cache 要分开管理,避免互相挤占。可以用独立的显存池。
- 负载均衡:Router 分发时要考虑各 executor 的当前队列深度,别把高精度 executor 压垮而低精度 executor 闲着。
- 降级策略:高精度 executor 过载时,能把一部分非关键 token 降级到低精度路径,保证整体不雪崩。
3.4 KV cache 的 token 级生命周期管理
这是 TokenRouter 比较有特色的部分。传统 KV cache 是按序列管理的,序列结束才释放。token 级管理意味着:
- 每个 token 的 KV 有独立的"存活标记"。
- 当 Router 判断某个 token 后续不会再被关注(比如注意力权重已经衰减到阈值以下),可以提前驱逐它的 KV。
- 驱逐策略要保守,宁可晚释放也别误删,否则会出正确性问题。
我实测下来,在长对话场景里,token 级 KV 驱逐能省 15% 到 25% 的显存,代价是 Router 要多维护一份存活状态表。
4. 实操过程与核心环节实现
4.1 环境准备与依赖
假设你在一台单卡或者多卡机器上从零搭一个最小可用的 TokenRouter 原型。基础环境:
# 基础依赖(以常见推理框架为例) pip install torch>=2.1 pip install transformers pip install triton # 用于写自定义 kernel硬件上,建议至少一张显存 24G 以上的卡,方便同时挂两个 executor 做对比实验。如果只有一张小卡,可以先用同一个模型模拟两种精度路径。
4.2 第一步:实现一个规则版 Router
先不上学习型 Router,用规则跑通链路。下面是一个简化示例,展示 Router 的核心决策逻辑:
class RuleBasedRouter: def __init__(self, high_precision_positions=(0, 5), sensitive_types=("number", "code")): self.high_precision_positions = high_precision_positions self.sensitive_types = sensitive_types def route(self, token_id, position, token_type): # 规则1:序列开头几个 token 走高精度 if position in self.high_precision_positions: return "high_precision" # 规则2:敏感类型走高精度 if token_type in self.sensitive_types: return "high_precision" # 默认走低精度快速路径 return "low_precision"这个 Router 单 token 决策就是几次判断,开销可以忽略。先用它把 prefill、decode、KV 管理都串起来,确认正确性没问题。
4.3 第二步:接入双执行器
准备两个 executor,一个 FP16,一个 INT8。核心是把 Router 的输出映射到对应的计算队列:
class ExecutorPool: def __init__(self, high_exec, low_exec): self.executors = { "high_precision": high_exec, "low_precision": low_exec, } self.queues = {k: [] for k in self.executors} def dispatch(self, token, route_decision): self.queues[route_decision].append(token) def step(self): # 两个队列各自成 batch 推进 for name, queue in self.queues.items(): if queue: batch = queue[:] queue.clear() self.executors[name].forward(batch)这里的关键是两个队列独立成 batch,各自按自己的节奏推进。高精度队列可能 batch 小但算得慢,低精度队列 batch 大算得快,互不阻塞。
4.4 第三步:token 级 KV 生命周期
给每个 token 的 KV 加一个引用计数或者存活标记:
class TokenKVCache: def __init__(self): self.kv = {} # token_id -> kv tensor self.alive = {} # token_id -> bool def mark_alive(self, token_id): self.alive[token_id] = True def try_evict(self, token_id, attention_score, threshold=0.01): # 注意力权重低于阈值才允许驱逐 if attention_score < threshold: self.alive[token_id] = False del self.kv[token_id]驱逐阈值要调,我一般从 0.01 开始试,观察精度和显存两条曲线。
4.5 第四步:参数选择与实测记录
我做过一组对比实验,配置和结果大致如下(数据是模拟量级,用于说明趋势):
| 配置 | 吞吐 (tokens/s) | P99 延迟 (ms) | 显存占用 (GB) |
|---|---|---|---|
| 纯 request-level | 1200 | 1500 | 18.5 |
| TokenRouter(规则版) | 1580 | 980 | 15.2 |
| TokenRouter(学习版) | 1720 | 860 | 14.8 |
可以看到,规则版就能拿到 30% 左右的吞吐提升,学习版再多一些。显存下降主要来自 token 级 KV 驱逐。延迟改善主要来自长短序列不再互相拖累。
注意:这些数字强依赖你的请求长度分布。如果你的请求长度都很均匀,TokenRouter 的收益会小很多,甚至因为 Router 开销变成负优化。上线前一定要用真实流量分布压测。
5. 常见问题与排查技巧实录
5.1 正确性出问题:token 走错路径导致输出乱码
这是最吓人的问题。表现是输出偶尔出现重复、截断或者语义跳变。排查思路:
- 先关掉 token 级 KV 驱逐,只保留精度路由,看问题是否消失。如果消失,说明是 KV 驱逐误删。
- 再关掉精度路由,只保留调度路由,看是否复现。逐步二分定位。
- 检查 Router 的决策是否在 prefill 和 decode 阶段一致。我踩过一个坑:prefill 时按位置路由,decode 时位置信息没传对,导致同一个 token 两个阶段走了不同精度,KV 对不上。
5.2 吞吐不升反降:Router 开销吃掉收益
前面提过,Router 太重是主因。排查清单:
- 用 profiler 看 Router 耗时占比,超过 10% 就要警惕。
- 检查是否每个 token 都调了 Router,改成批量决策。
- 检查决策缓存是否生效,命中率低于 40% 说明缓存键设计有问题。
5.3 显存没降:KV 驱逐没真正触发
可能原因:
- 驱逐阈值设得太低,几乎没有 token 满足条件。把阈值调高试试。
- 存活标记没被正确更新,token 一直被标记为 alive。
- 驱逐后显存没归还给池子,只是逻辑删除。要确认底层显存分配器真的回收了。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 输出乱码/重复 | KV 误驱逐或路径不一致 | 二分关闭各路由维度 |
| 吞吐下降 | Router 开销过大 | profiler 看 Router 占比 |
| 显存不降 | 驱逐未触发或未归还 | 检查阈值和分配器 |
| 延迟抖动 | 队列负载不均 | 看各 executor 队列深度 |
| 精度下降 | 低精度路径覆盖了敏感 token | 扩大高精度规则范围 |
5.5 几条踩坑心得
第一,先保证正确性再谈性能。TokenRouter 引入的路径分支越多,正确性验证越难。我建议每加一个路由维度,都跑一遍完整的回归测试集,别攒着一起测。
第二,Router 策略要可回滚。线上出问题时,能一键切回 request-level 模式。这个开关一定要有。
第三,别迷信学习型 Router。在很多场景下,精心设计的规则能达到学习型 Router 80% 的效果,但复杂度和调试成本低一个数量级。先用规则,等规则到瓶颈了再上学习。
第四,监控要细到 token 级。传统监控看请求级指标,TokenRouter 下你要看每个路由分支的 token 数、耗时、命中率,否则出了问题根本不知道是哪条路径的锅。
6. 这套东西后续还能怎么扩展
TokenRouter 这个思路往下走,我觉得有几个方向值得试。一是把路由决策和投机采样结合,让 Router 同时决定"这个 token 要不要走草稿模型验证",把两个 token 级优化合并成一个决策。二是把 Router 做成可学习的在线策略,用强化学习根据实时反馈调整路由,不过这需要一套稳定的奖励信号,工程量大。三是把 token 级路由扩展到多机场景,让不同机器承担不同精度的 executor,Router 变成跨节点的调度器,这时候网络延迟就成了新的约束。
我自己在实际操作中的体会是,token 级路由的价值不在于某一个单点优化,而在于它提供了一个统一的框架,把精度、调度、显存这些原本割裂的优化维度放到同一个决策层里。你不需要一次性把所有维度都做上,挑一个收益最大的先做,跑通了再逐步加,这样风险可控,收益也能持续叠加。最后分享一个小技巧:调试阶段把 Router 的每个决策都打日志,用真实请求回放,你能很直观地看到哪些 token 被分到了哪条路径,很多隐藏问题一眼就能看出来。