
1. 推理账单失控之后路由缓存批处理省下的钱1.1 从一张让人心跳加速的账单说起事情得从三个月前说起。我们团队负责的一个智能问答服务日活不算夸张大概两万出头但每个用户平均会触发四到六轮对话。上线第一个月推理成本还在预算范围内第二个月开始爬坡到了第三个月财务直接把账单截图甩到群里那个数字让我盯着屏幕愣了十几秒——比预估高了将近四倍。不是用户量暴涨也不是有人在恶意刷接口。排查了一圈问题出在推理请求的调用模式上大量请求带着完全相同或高度相似的前缀却每次都从头计算路由层没有做任何亲和性调度同一个会话的连续请求被打散到不同的推理实例上缓存命中率低得可怜再加上部分批量任务没有做合并处理一条一条地发单次推理的固定开销被反复放大。这三个问题单独拎出来都不算致命但叠在一起就像水龙头没关紧、水管还漏、排水口又堵着钱就这么哗哗地流走了。后来我们用路由缓存加批处理的组合拳把账单压回了合理区间降幅大概在六成左右。这篇文章就把整个排查和优化的过程拆开来讲包括我们踩过的坑和最后跑通的方案。如果你也在做推理服务的成本优化或者正在被类似的账单问题困扰下面这些内容应该能帮你省下不少试错时间。我会从问题定位讲起然后分别拆解请求路由、前缀缓存和批处理三个核心手段最后给出一套可以直接参考的落地方案。2. 问题定位钱到底花在了哪里2.1 先搞清楚计费逻辑再谈优化很多人一看到账单高第一反应是“是不是调用量太大了”然后就去限流。限流当然有用但它是最粗暴的手段会直接伤害用户体验。在动手之前必须先搞清楚推理服务的计费到底是怎么算的。主流的推理服务计费通常看两个维度输入 token 数和输出 token 数。有些还会区分预填充阶段和解码阶段但归根结底你付的钱和“模型实际计算了多少 token”强相关。这里有个关键点容易被忽略即使两个请求的前缀完全一样如果系统没有做缓存复用它依然会为这段前缀重复计费。我们当时拉了一份抽样数据随机取了一千条请求统计它们的前缀重复情况。结果很直观超过七成的请求其前 500 个 token 的前缀在当天出现过至少两次以上。这意味着什么意味着我们花了大量的钱在反复计算同样的内容。提示在优化之前一定要先拿到真实的请求样本统计前缀重复率、平均输入长度、平均输出长度、请求时间分布。没有这些数据后面所有的优化都是拍脑袋。2.2 三个成本黑洞的量化把问题拆开看我们当时主要面临三个成本黑洞每个都能用数据量化出来。第一个是前缀重复计算。前面说了七成请求的前缀高度重复。假设每次重复计算 500 个输入 token按当时的调用量估算这部分浪费占总输入成本的百分之四十左右。第二个是路由无亲和性。同一个会话的多轮对话理想情况下应该落到同一个推理实例上这样上一轮计算过的 KV 缓存还能继续用。但我们当时的负载均衡是纯轮询会话请求被打散缓存复用率几乎为零。这部分导致的额外开销保守估计占总成本的百分之十五到二十。第三个是批处理缺失。有些离线任务比如每天定时跑的文档摘要是一条一条串行发的。每条请求的固定开销网络往返、调度、预热被重复支付而如果合并成一个批次这部分开销可以摊薄到几乎忽略不计。这部分占比相对小一些大概百分之十但优化起来最简单属于“顺手就能做”的。三个问题加起来解释了账单超支的绝大部分原因。接下来就是逐个击破。3. 请求路由让对的请求落到对的实例上3.1 为什么路由策略直接决定缓存命中率请求路由这个词听起来很底层但它的逻辑其实很朴素当一个请求进来时你把它发给哪个推理实例最简单的做法是轮询每个实例轮流接客绝对公平。但公平不等于高效。推理服务和普通的无状态服务有个本质区别它是有状态的或者说它的“状态”体现在 KV 缓存上。一个请求计算完之后它的中间结果Key-Value 缓存会留在那个实例的显存里。如果下一个请求能复用这段缓存就能省掉重新计算的时间 and 成本。但如果下一个请求被路由到了另一个实例那段缓存就白算了。所以路由的核心目标不是“均匀分配”而是“让相似或相关的请求尽量落到同一个实例上”。这就是所谓的亲和性路由。实现亲和性有很多种方式常见的有基于会话 ID 的哈希、基于前缀内容的哈希、或者维护一个前缀到实例的映射表。我们最后选的是基于前缀哈希加会话 ID 的混合策略。具体来说对于同一个会话的连续请求用会话 ID 做哈希保证落到同一实例对于不同会话但前缀相同的请求用前缀的指纹做二次哈希尽量让它们也聚到一起。这个策略实现起来不复杂但效果立竿见影。3.2 一致性哈希的落地细节说到哈希路由就绕不开一致性哈希。普通的取模哈希有个致命问题一旦实例数量变化几乎所有请求的路由目标都会变缓存全部失效。一致性哈希通过把实例和请求都映射到一个环上使得实例增减时只影响相邻的一部分请求。我们用的是带虚拟节点的一致性哈希。每个物理实例对应若干个虚拟节点分散在哈希环上。请求根据其前缀指纹找到环上顺时针方向的第一个虚拟节点从而确定目标实例。虚拟节点的数量我们设的是 128这个数字是试出来的太少会导致负载不均太多会增加查找开销。128 在我们的场景下负载标准差能控制在百分之五以内。import hashlib class ConsistentHashRouter: def __init__(self, nodes, virtual_nodes128): self.virtual_nodes virtual_nodes self.ring {} self.sorted_keys [] for node in nodes: self.add_node(node) def _hash(self, key): return int(hashlib.md5(key.encode()).hexdigest(), 16) def add_node(self, node): for i in range(self.virtual_nodes): virtual_key f{node}#{i} hash_val self._hash(virtual_key) self.ring[hash_val] node self.sorted_keys.append(hash_val) self.sorted_keys.sort() def get_node(self, request_key): if not self.ring: return None hash_val self._hash(request_key) for key in self.sorted_keys: if hash_val key: return self.ring[key] return self.ring[self.sorted_keys[0]]这段代码是简化版实际生产环境还要考虑实例健康检查、权重、故障转移等。但核心逻辑就是这些。有了这个路由层之后我们的缓存命中率从不到百分之十提升到了百分之六十以上。注意一致性哈希的 key 选择很关键。如果你用请求 ID 做 key那每个请求都是独立的亲和性为零。必须用会话 ID 或前缀指纹这种能体现“相关性”的字段。3.3 路由层的容错与降级路由层引入之后也带来了新的风险如果某个实例挂了原本应该落到它上面的请求怎么办如果哈希环没有及时更新这些请求会一直失败。我们的做法是加了一层故障转移机制。每个请求在路由时会拿到一个主实例和一个备实例环上的下一个节点。如果主实例健康检查失败直接走备实例。同时后台有个守护进程定期探测实例状态一旦发现异常就把它从哈希环上摘掉并触发缓存的重新分布。降级策略也很重要。当系统负载过高时亲和性路由可以临时退化为轮询优先保证请求能发出去而不是死守着缓存命中率。这个开关我们是通过配置中心动态控制的压力大的时候自动切换压力降下来再切回去。4. 前缀缓存把重复的计算省下来4.1 前缀缓存的工作原理前缀缓存的核心思想一句话就能说清楚如果两个请求的开头一段内容完全一样那么这段内容的计算结果可以复用不需要重新算。在 Transformer 架构的推理过程中输入序列会被逐层计算生成 Key 和 Value 矩阵。这些矩阵在解码阶段会被反复用到。如果前缀相同那么这部分 KV 矩阵就是一样的。前缀缓存就是把这部分矩阵存下来下次遇到相同前缀时直接加载跳过预填充阶段的计算。这里有个关键概念叫缓存命中。当一个新请求进来时系统会拿它的前缀去缓存里查看有没有匹配的。匹配的长度越长省下的计算就越多。理想情况下如果两个请求只有最后几个 token 不同那前面所有的计算都能复用。我们实测下来在一个典型的客服问答场景里系统提示词加上历史对话的前几轮往往能占到输入总长度的百分之六十到八十。这部分如果全部命中缓存输入侧的成本能直接砍掉一大半。4.2 缓存粒度与失效策略的权衡前缀缓存不是银弹它有几个需要仔细权衡的点。首先是缓存粒度。你是按整个前缀缓存还是按固定长度的块缓存按整个前缀缓存命中时收益最大但存储开销也最大而且一旦前缀有一个 token 不同整个缓存就废了。按块缓存存储效率高但命中时可能只能复用一部分。我们最后选的是分层缓存以 256 个 token 为一个块块内做精确匹配块间做前缀树索引。这样既能复用长前缀又不会因为一个 token 的差异导致全部失效。其次是失效策略。缓存不能无限增长显存是有限的。常见的淘汰策略有 LRU最近最少使用、LFU最不经常使用和 TTL生存时间。我们用的是 LRU 加 TTL 的混合策略超过一定时间没被访问的缓存直接清掉同时显存占用超过阈值时按 LRU 淘汰。from collections import OrderedDict import time class PrefixCache: def __init__(self, max_size1000, ttl3600): self.cache OrderedDict() self.max_size max_size self.ttl ttl def get(self, prefix_hash): if prefix_hash not in self.cache: return None value, timestamp self.cache[prefix_hash] if time.time() - timestamp self.ttl: del self.cache[prefix_hash] return None self.cache.move_to_end(prefix_hash) return value def put(self, prefix_hash, value): if prefix_hash in self.cache: self.cache.move_to_end(prefix_hash) self.cache[prefix_hash] (value, time.time()) if len(self.cache) self.max_size: self.cache.popitem(lastFalse)这个实现很基础生产环境还要考虑并发安全、持久化、跨实例共享等问题。但思路是一致的用哈希做 key用有序字典维护访问顺序用时间戳做过期判断。提示前缀缓存的命中率高度依赖请求的相似性。如果你的业务场景里每个请求都是完全独立的那前缀缓存基本没用。所以在投入之前先统计一下真实请求的前缀重复率。4.3 缓存共享与隔离的取舍单实例内的前缀缓存好做难的是跨实例共享。如果每个实例各自维护一份缓存那缓存利用率会很低因为同一个前缀可能在多个实例上各存了一份。我们试过两种方案。一种是集中式缓存所有实例共享一个远程缓存服务。好处是利用率高坏处是网络延迟会抵消一部分收益而且远程缓存服务本身可能成为瓶颈。另一种是分布式缓存每个实例维护本地缓存但通过一致性哈希路由保证相同前缀落到相同实例间接实现共享。最后我们选了后者也就是前面说的路由加本地缓存的组合。原因是推理服务对延迟极其敏感走网络查缓存的那几毫秒可能比重新计算还慢。本地缓存加亲和性路由虽然利用率不如集中式但延迟最低综合收益最高。当然本地缓存也有代价实例扩容或缩容时缓存需要重新预热。我们的做法是在低峰期做扩缩容并且新实例上线后先跑一段时间的“影子流量”把热点前缀的缓存建起来再正式接客。5. 批处理把零散的请求攒起来一起算5.1 批处理的收益从哪来批处理的逻辑更简单与其一条一条地发请求不如攒一批一起发。为什么这样能省钱因为推理服务在处理单个请求时有很大一部分开销是固定的——网络往返、调度、模型加载、显存分配。这些开销和请求的实际计算量无关发一条也是这么多发十条也是这么多。把十条请求合并成一个批次固定开销只付一次摊到每条请求上就变得很小。而且现代推理框架对批量输入有专门的优化矩阵运算的并行度更高GPU 利用率也更好。我们实测下来批大小为 8 的时候单条请求的平均延迟只增加了不到百分之二十但吞吐量提升了将近四倍折算到成本上降幅超过百分之五十。批处理特别适合那些对实时性要求不高的场景比如离线文档处理、定时任务、日志分析。对于在线对话批处理需要更精细的控制不能为了攒批而让用户等太久。5.2 动态批处理的参数调优静态批处理很简单攒够固定数量就发。但静态批处理有个问题如果请求来得不均匀要么攒不够一直等要么攒太快批次很小。所以我们用的是动态批处理核心参数有两个最大批大小和最大等待时间。最大批大小决定了单批次的上限超过这个数就得拆成多批。这个值不能太大否则显存扛不住延迟也会飙升。我们设的是 16这是在显存和吞吐之间试出来的平衡点。最大等待时间决定了攒批的耐心。设得太短批次攒不大收益有限设得太长用户等得着急。我们设的是 50 毫秒这个值是根据用户体验的容忍度定的。超过 50 毫秒还没攒够就直接发出去不再等。import asyncio import time class DynamicBatcher: def __init__(self, max_batch_size16, max_wait_ms50): self.max_batch_size max_batch_size self.max_wait max_wait_ms / 1000 self.queue [] self.lock asyncio.Lock() async def add_request(self, request): async with self.lock: self.queue.append(request) if len(self.queue) self.max_batch_size: batch self.queue[:self.max_batch_size] self.queue self.queue[self.max_batch_size:] return await self.process_batch(batch) await asyncio.sleep(self.max_wait) async with self.lock: if request in self.queue: batch self.queue[:] self.queue [] return await self.process_batch(batch) async def process_batch(self, batch): # 实际调用推理服务的逻辑 pass这段代码是示意性的实际实现要考虑超时、异常、优先级等问题。但核心思想就是攒批、等超时、发车。注意批处理不是越大越好。批大小超过一定阈值后延迟会非线性增长而且显存溢出风险急剧上升。一定要在自己的硬件和模型上做压测找到那个拐点。5.3 批处理与流式输出的兼容在线对话场景通常需要流式输出用户希望看到字一个一个蹦出来而不是等整段话生成完再显示。这和批处理有天然的矛盾批处理要攒够一批才发流式输出要求尽快返回第一个 token。我们的解法是分阶段处理。预填充阶段可以批处理因为这部分不涉及流式输出攒批的收益最大。解码阶段则按请求独立进行保证流式体验。这样既拿到了批处理的大部分收益又不牺牲用户体验。具体实现上我们把请求拆成两个阶段第一阶段只做预填充计算 KV 缓存这个阶段可以攒批第二阶段做解码逐个 token 生成这个阶段每个请求独立走。两个阶段之间通过一个队列衔接预填充完成后把 KV 缓存传给解码阶段。这个方案实现起来比纯批处理复杂不少但收益也很明显预填充阶段的计算量通常占整个推理的百分之七十以上把这部分批处理化成本降幅非常可观。6. 常见问题与排查技巧实录6.1 缓存命中率上不去怎么办这是最常见的问题。你兴冲冲地上了前缀缓存结果一看监控命中率只有个位数。别急按下面这个顺序排查。先看请求本身有没有重复前缀。如果业务场景里每个请求都是完全独立的那缓存命中率低是正常的不是系统的问题。拉一批真实请求统计一下前缀重复率如果低于百分之二十那缓存收益本身就有限。再看路由是否生效。如果路由层没有正确地把相同前缀的请求导向同一实例那缓存写了也白写。检查路由日志看看相同前缀的请求是不是落到了不同实例上。如果是检查哈希 key 的选择和一致性哈希环的配置。最后看缓存是否被过早淘汰。显存不够时缓存会被频繁清理导致刚写进去就被删了。调大缓存容量或者优化淘汰策略把真正热点的前缀留住。问题现象可能原因排查方法解决方向命中率低于 10%请求前缀本身不重复统计真实请求前缀重复率评估缓存收益必要时放弃命中率波动大路由不稳定检查一致性哈希环和健康检查固定路由策略减少实例变动命中率逐渐下降缓存淘汰过快监控缓存淘汰频率和显存占用调大容量或优化淘汰策略特定前缀从不命中哈希冲突或 key 设计问题打印具体前缀的哈希值和路由结果调整哈希算法或 key 字段6.2 批处理导致延迟飙升怎么调批处理引入的延迟主要来自等待时间。如果你发现 P99 延迟明显上升先看最大等待时间是不是设得太长了。50 毫秒是个比较保守的值如果业务对延迟极其敏感可以降到 20 毫秒甚至 10 毫秒代价是批次变小、收益降低。另一个常见原因是批大小超过了显存承载能力导致推理框架频繁做显存交换反而拖慢了速度。这时候要调小最大批大小或者升级硬件。还有一个隐蔽的问题是请求长度差异过大。如果一个批次里有的请求输入很长、有的很短那整个批次的处理时间会被最长的那个请求拖累。解法是按长度分桶把长度相近的请求放在一起批处理。这个优化我们后来加上了P99 延迟直接降了三分之一。6.3 路由变更后的缓存雪崩每次扩缩容或实例重启哈希环都会变化导致大量缓存失效。如果这时候流量正好很大所有请求都变成缓存未命中推理压力瞬间飙升可能直接把服务打挂。这就是缓存雪崩。我们的应对策略是渐进式路由切换。新实例上线后不是立刻接管流量而是先接一小部分同时预热缓存。等缓存命中率恢复到正常水平再逐步增加流量权重。整个过程可能持续几分钟到十几分钟但能避免流量突刺。另外我们保留了一部分兜底容量专门应对缓存雪崩时的突发计算需求。这部分容量平时闲置成本不高但关键时刻能救命。提示任何涉及缓存和路由的变更都要在低峰期做并且准备好回滚方案。缓存系统的故障往往不是渐进的而是断崖式的。6.4 监控指标应该看哪些优化做完不是终点持续监控才能保证效果不退化。我们重点盯这几个指标缓存命中率最核心的指标直接反映前缀缓存的收益。按小时和按天看趋势突然下降要立刻排查。路由亲和性统计相同前缀的请求落到同一实例的比例。这个指标低于百分之八十说明路由策略有问题。批处理平均批大小反映批处理的效率。如果平均批大小长期低于 4说明攒批逻辑需要调整。单请求平均成本把账单除以请求数得到每条请求的平均成本。这个指标最能反映优化的最终效果。P99 延迟成本优化不能以牺牲用户体验为代价。延迟超过阈值时要有自动降级机制。7. 落地清单与参数参考7.1 从零搭建的步骤清单如果你准备在自己的服务上落地这套方案可以按下面的顺序来。每一步都验证通过后再进入下一步不要一次性全上。数据采集接入请求日志记录每条请求的前缀哈希、会话 ID、输入长度、输出长度、时间戳。跑一周拿到真实分布。前缀重复率分析基于采集的数据统计前缀重复率、热点前缀分布、会话长度分布。判断缓存和批处理的潜在收益。路由层改造引入一致性哈希路由用会话 ID 或前缀指纹做 key。先小流量灰度观察缓存命中率变化。前缀缓存接入在推理实例上启用前缀缓存配置合理的容量和淘汰策略。监控显存占用和命中率。批处理接入从离线任务开始逐步扩展到在线场景。先做预填充阶段的批处理再做全流程。监控与告警把前面说的核心指标接入监控面板设置合理的告警阈值。压测与调优用真实流量做压测找到批大小、等待时间、缓存容量的最优组合。7.2 关键参数速查表下面这些参数是我们实际跑下来觉得比较稳的配置供参考。注意这些值高度依赖具体的模型、硬件和业务场景一定要自己压测验证。参数推荐值说明一致性哈希虚拟节点数128负载均衡和查找开销的平衡点前缀缓存块大小256 token块内精确匹配块间前缀树索引缓存最大容量显存的 30%留足空间给模型本身和临时计算缓存 TTL3600 秒超过一小时未访问的缓存清理最大批大小8 到 16根据显存和延迟要求调整最大等待时间20 到 50 毫秒延迟敏感场景取小值路由亲和性阈值80%低于此值需要检查路由策略7.3 成本收益的粗略估算最后说一下收益预期免得大家期望过高或过低。在我们的场景里三个手段的贡献大致是这样的前缀缓存贡献了降幅的百分之五十左右路由亲和性贡献了百分之三十批处理贡献了百分之二十。三者叠加总成本降了约六成。但这不是放之四海皆准的数字。如果你的请求前缀重复率很低缓存收益会大打折扣如果你的流量本身就很平稳批处理收益也有限。所以先做数据采集再决定投入方向不要盲目照搬。我在实际落地过程中最大的体会是成本优化不是一锤子买卖而是一个持续迭代的过程。业务在变流量模式在变最优参数也在变。建立好监控和反馈闭环比一次性调出完美参数重要得多。另外任何优化都要以不伤害用户体验为前提延迟和成本的平衡点需要根据业务的实际容忍度来定没有标准答案。