尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

LLM应用流量治理实战:基于Token的复合限流架构设计与实现

LLM应用流量治理实战:基于Token的复合限流架构设计与实现 1. 项目概述当LLM应用遭遇流量洪峰最近半年我深度参与了一个面向企业客户的LLM大语言模型应用平台项目。这个平台允许不同部门的团队接入我们提供的统一API来调用底层的多个大模型如GPT-4、Claude等完成各类文本生成与分析任务。项目上线初期风平浪静但随着接入团队和业务量的激增我们很快遇到了一个经典且棘手的问题如何公平、高效、稳定地管理所有用户对昂贵LLM API的调用想象一下这个场景市场部的自动化内容生成脚本在凌晨突然启动瞬间发起上千个请求耗光了当月预留的绝大部分Token配额导致其他团队在白天上班时所有请求都被拒绝。又或者某个用户写了个有问题的循环代码疯狂调用API不仅产生了天价账单还因为触发上游供应商的速率限制导致整个平台的服务质量下降。更现实的是我们需要确保付费更高的VIP客户的关键业务请求总能比免费试用用户的批量任务获得更快的响应和更高的成功率。这些问题归根结底是“资源分配”和“系统保护”问题。而解决它们的核心武器就是Rate Limiting速率限制。但LLM场景下的Rate Limiting远比传统的API限流复杂因为它计费和消耗的核心单位是Token而非简单的请求次数。一个复杂的分析请求可能消耗数万Token而一个简单的问候可能只需几十Token。如果只限制请求数对资源消耗的控制将是失效的。因此我们设计并落地了一套结合了Per-User Token配额管理、滑动窗口限流和优先级队列的复合型限流方案。这不仅仅是加几个中间件配置那么简单而是一个需要深入业务逻辑、权衡公平与效率、并充分考虑容错的生产级系统工程。接下来我将详细拆解我们是如何思考、设计与实现这套方案的包括其中的技术选型、踩过的坑以及最终沉淀下来的实战经验。2. 核心架构设计与技术选型2.1 为什么传统的QPS限流在LLM场景下失灵在项目初期我们尝试过使用简单的QPS每秒查询数限流。例如通过Nginx的limit_req模块或Redis的INCR命令给每个用户设置每秒10次请求的限制。这很快暴露了问题资源计量不精准用户A的10次请求可能都是“总结这篇100字短文”消耗~50 Token/次总消耗500 Token而用户B的1次请求是“分析这份100页的PDF”消耗~100,000 Token。后者单次请求的消耗是前者总量的200倍但QPS限流对此完全无感知。成本控制失效LLM API的成本直接与消耗的Token数挂钩。QPS限流无法防止用户因程序BUG或恶意行为在短时间内消耗巨额Token导致成本失控。公平性缺失对于按Token包月付费的企业用户他们关心的是Token配额是否被合理、平滑地使用完而不是每秒能调用几次。QPS限制无法体现这种按资源付费的公平性。因此我们得出结论LLM应用的速率限制必须以Token为基本单位进行核算。这引出了我们方案的核心——Per-User Token配额系统。2.2 分层限流架构从用户到全局我们采用了分层防御的策略将限流分为三个层级用户级限流Per-User Token Quota这是最核心的一层。每个用户或API Key拥有独立的Token预算如每月100万Token。系统需要实时追踪其消耗并在接近或超出限额时进行限制或告警。这一层保障了成本的公平分摊和预算控制。业务级/渠道级限流滑动窗口即使用户Token充足我们也不能允许其无限制地突发调用。例如上游的OpenAI API对每个账号有TPMTokens per minute和RPMRequests per minute的限制。为了避免我们的某个用户行为触发上游限制而影响其他用户我们需要在平台层面对每个上游模型渠道实施更细粒度的滑动窗口限流。这一层保护了上游服务的稳定性也平滑了平台自身的流量。请求调度级限流优先级队列当流量超过系统实时处理能力时我们需要一个缓冲和调度机制。不是简单地拒绝请求而是将其放入队列并按照优先级策略如VIP客户优先、高付费套餐优先、关键业务请求优先进行排序处理。这一层优化了用户体验和业务价值确保了高优先级任务的服务质量。2.3 技术栈选型与理由配额与计数存储Redis理由我们需要一个高性能、支持原子操作和复杂数据结构的存储来维护用户的Token消耗计数。Redis的INCRBY、DECRBY命令是原子操作完美适用于计数。它的Sorted Set有序集合是实现滑动窗口算法的理想数据结构。此外Redis的高性能和持久化选项AOF/RDB满足了生产环境对速度和可靠性的要求。滑动窗口算法实现Redis Sorted Set Lua脚本理由相比计数器或漏桶/令牌桶算法滑动窗口能更精确地控制任意时间窗口内的流量。我们使用Redis Sorted Set以时间戳毫秒为score以请求的唯一ID或Token消耗量为member。每次请求时通过Lua脚本原子性地执行“添加当前请求”、“移除窗口外旧请求”、“计算窗口内总和”的操作。Lua脚本保证了操作的原子性避免了并发竞争条件。优先级队列RabbitMQ或Redis Streams理由当请求需要排队时我们需要一个成熟的消息队列。RabbitMQ的优先级队列Priority Queue功能是原生支持的可以定义0-255的优先级。对于更简单的场景Redis 5.0的Streams数据结构也可以模拟一个优先级队列通过不同的stream作为不同优先级的通道。我们最终选择了RabbitMQ因为它提供了更完善的消息确认、持久化和死信队列机制适合对可靠性要求更高的业务。业务逻辑层Python (FastAPI)理由我们的应用主体使用Python的FastAPI框架开发。它异步性能好生态丰富。我们将限流逻辑封装成独立的中间件Middleware和依赖项Dependency可以灵活地应用到不同的路由上。注意这里没有选择一些开箱即用的限流库如django-ratelimit因为它们通常基于QPS且难以深度定制以支持基于Token的复杂计算和与消息队列的联动。自己实现虽然前期工作量稍大但获得了完全的掌控力和灵活性。3. 核心模块实现细节拆解3.1 Per-User Token配额管理实现用户配额管理不仅仅是“计数-扣减”那么简单它涉及配额周期、超额策略和精度问题。1. 数据结构设计Redis我们为每个用户设计了两类主要Keyuser_quota:{user_id}: 一个Hash结构存储配额元信息。HSET user_quota:user_123 total 1000000 # 总配额 HSET user_quota:user_123 used 350000 # 已使用量 HSET user_quota:user_123 reset_at 1717228800 # 配额重置时间戳如每月1号0点 HSET user_quota:user_123 plan “premium” # 套餐类型关联不同限流规则user_token_window:{user_id}: 一个Sorted Set用于实现基于Token的滑动窗口限流见下一节。这里存储的是每次请求消耗的Token数。2. 扣减流程与原子性扣减配额必须是原子操作防止超卖。我们使用Redis Lua脚本实现local key KEYS[1] -- user_quota:user_id local tokens_to_use tonumber(ARGV[1]) local now tonumber(ARGV[2]) -- 获取当前已使用量和总量 local used redis.call(‘HGET’, key, ‘used’) local total redis.call(‘HGET’, key, ‘total’) used tonumber(used) or 0 total tonumber(total) or 0 -- 检查配额是否充足 if used tokens_to_use total then return {false, “Insufficient quota”, used, total} end -- 原子性增加已使用量 redis.call(‘HINCRBY’, key, ‘used’, tokens_to_use) local new_used used tokens_to_use return {true, “OK”, new_used, total}在FastAPI中我们会在处理LLM请求之前先预估本次请求可能消耗的Token数可以通过用户输入文本长度进行简单估算或调用模型的tokenizer然后执行这个Lua脚本。如果返回配额不足则直接拒绝请求并返回429状态码和友好提示。3. 配额重置与超额处理重置我们有一个后台定时任务Celery Beat在每天零点检查所有用户的reset_at字段。如果当前时间大于等于reset_at则将used重置为0并计算下一个重置时间点如下个月1号。超额策略我们提供了两种模式由用户在订阅时选择硬限制达到配额后立即拒绝直到下一个周期。适用于对成本控制极其严格的场景。软限制计费达到配额后请求仍可继续但系统会记录超额使用的Token数并生成账单。这提供了更好的用户体验适合业务连续性要求高的客户。3.2 滑动窗口限流算法实战Per-User Token配额是“总量控制”而滑动窗口限流是“流速控制”。我们为每个用户对每个模型渠道都设置了一个滑动窗口限制例如“用户A调用GPT-4模型每分钟不能超过10万Token”。1. 算法核心Redis Sorted Set Lua假设限制为每分钟limit个Token。local key KEYS[1] -- 例如 rate_limit:gpt-4:user_123 local now tonumber(ARGV[1]) -- 当前时间戳(毫秒) local window_size tonumber(ARGV[2]) -- 窗口大小(毫秒)如60000 local limit tonumber(ARGV[3]) -- 限制数如100000 local tokens_this_request tonumber(ARGV[4]) -- 本次请求的Token消耗量 local request_id ARGV[5] -- 本次请求唯一ID -- 1. 移除窗口之外的所有旧记录 redis.call(‘ZREMRANGEBYSCORE’, key, 0, now - window_size) -- 2. 获取当前窗口内的所有记录这里我们获取的是member即请求ID但我们需要的是Token数 -- 由于Sorted Set的member不能直接存储数字我们设计member为 request_id:token_count local current_records redis.call(‘ZRANGE’, key, 0, -1, ‘WITHSCORES’) -- 3. 计算当前窗口内已使用的Token总数 local current_usage 0 for i 1, #current_records, 2 do local member current_records[i] -- 从member中解析出token_count例如 “req_abc:1500” - 1500 local token_count tonumber(string.match(member, “:(%d)$”)) or 0 current_usage current_usage token_count end -- 4. 判断是否超限 if current_usage tokens_this_request limit then return {false, “Rate limit exceeded”, current_usage, limit} end -- 5. 未超限将本次请求记录加入窗口 local member_to_add request_id .. “:” .. tokens_this_request redis.call(‘ZADD’, key, now, member_to_add) -- 设置Key的过期时间避免无用数据堆积 redis.call(‘EXPIRE’, key, window_size / 1000 60) -- 额外多留1分钟缓冲 return {true, “OK”, current_usage tokens_this_request, limit}2. 预估与真实消耗的差异处理这里有一个关键细节我们在限流时使用的是预估的Token消耗但LLM API返回的才是真实消耗。两者可能有差异特别是对于模型输出部分。我们的处理方式是限流检查用预估值确保系统不会在窗口内承诺超出限制的流量。配额扣减用真实值请求完成后用真实消耗值去更新user_quota:{user_id}中的used字段。同时也需要异步地去更新滑动窗口Sorted Set中对应请求记录的Token数。因为更新Sorted Set的member需要先删除再添加我们将其作为一个低优先级的后台任务执行避免影响主请求链路。即使更新稍有延迟对限流精度的影响也在可接受范围内。3.3 优先级队列集成与调度当用户的请求通过了配额和滑动窗口检查但平台自身的请求处理池已满例如所有工作进程都在忙时请求将进入优先级队列。1. 队列与优先级设计我们在RabbitMQ中为每个模型渠道如gpt-4.request.queue创建了一个优先级队列。消息的优先级数字越大优先级越高。优先级0默认优先级普通用户、非关键任务。优先级5高级套餐用户。优先级10VIP客户、系统关键任务如告警通知的生成。消息体包含请求的所有必要信息用户ID、请求参数、回调地址等以及一个从用户套餐和请求元数据中计算出的priority字段。2. 生产者逻辑FastAPI 中间件在FastAPI的请求中间件中在通过了前述所有检查后我们尝试将请求提交给后台的Worker池处理。如果Worker池已满通过信号量或数据库连接池状态判断则不是返回“服务不可用”而是执行以下操作async def enqueue_request(request_data, priority): channel await get_rabbitmq_channel() # 获取连接通道 await channel.queue_declare(queueMODEL_QUEUE_NAME, arguments{ ‘x-max-priority’: 10 # 声明队列支持的最大优先级 }) await channel.basic_publish( exchange“, routing_keyMODEL_QUEUE_NAME, bodyjson.dumps(request_data), propertiespika.BasicProperties( delivery_mode2, # 持久化消息 prioritypriority, ) )然后向客户端返回一个202 Accepted状态码以及一个唯一的task_id客户端可以轮询另一个API来获取任务结果。3. 消费者逻辑Worker我们有一组独立的Worker进程使用Celery或简单的asyncio循环它们持续地从RabbitMQ队列中消费消息。RabbitMQ会确保高优先级的消息被优先投递给空闲的消费者。async def worker_loop(): channel await get_rabbitmq_channel() await channel.basic_qos(prefetch_count1) # 公平分发一个Worker一次只处理一个请求 await channel.basic_consume(queueMODEL_QUEUE_NAME, on_message_callbackprocess_message) # ... 启动消费process_message函数负责实际调用LLM API处理完成后将结果存入缓存如Redis并通知可能正在轮询的客户端。4. 生产环境部署与调优实录4.1 性能瓶颈与优化1. Redis热点Key问题所有用户的配额检查和限流都频繁读写Redis。对于超大型用户其user_token_window:{user_id}这个Sorted Set可能会在流量高峰时成为热点Key。优化方案引入本地缓存Local Cache进行缓冲。例如使用内存中的LRU缓存缓存用户最近1分钟的Token消耗总量。每次请求先检查本地缓存如果命中且未超限则直接通过并异步更新Redis。我们设置了较短的本地缓存过期时间如5秒并在更新Redis时使用INCRBY命令这样即使有少量误差也能在下一个时间窗口内被纠正。这大幅降低了Redis的QPS。2. Lua脚本执行开销每个请求执行两个Lua脚本配额检查滑动窗口是有开销的。我们通过将两个检查合并到一个复杂的Lua脚本中减少了网络往返次数。但脚本本身变得复杂。我们对其进行了性能剖析确保没有慢循环。3. 预估Token的准确性糟糕的Token预估会导致限流误杀预估过高或放行过多预估过低。我们做了以下改进建立预估模型不是简单用“字符数 * 系数”来估算。我们收集历史请求数据输入文本、模型、真实消耗Token数训练了一个简单的回归模型针对不同模型和任务类型摘要、翻译、代码生成进行更精准的预估。动态调整系数为每个用户维护一个动态调整的系数基于其近期“预估/实际”比值的移动平均进行微调。4.2 监控、告警与容灾1. 监控大盘我们在Grafana中建立了限流专题看板监控以下核心指标全局总请求量、总Token消耗、平均响应时间、各优先级队列长度。用户级Top N用户的Token消耗速率、配额使用百分比、被限流请求数。系统级Redis内存/CPU使用率、RabbitMQ消息堆积数、Worker进程负载。业务级不同模型渠道的调用成功/失败率失败原因分类配额不足、速率限制、模型超时等。2. 告警规则紧急告警某个核心模型渠道的失败率在5分钟内超过10%RabbitMQ某个队列消息堆积超过1000条且持续增长Redis连接失败。预警VIP用户的配额使用率达到80%某个用户的请求被限流频率异常升高可能提示程序BUG滑动窗口限流的拒绝率持续高于1%。3. 降级与容灾Redis不可用我们实现了降级模式。如果连接Redis失败系统会切换到一个“宽松模式”仅基于内存中的简单计数器进行非常宽松的限流并记录日志。同时所有请求的Token消耗会被记录到本地文件或直接发送到消息队列待Redis恢复后异步补录。这确保了核心LLM服务在限流组件故障时仍能基本可用尽管失去了精确控制。上游API限制我们为每个上游渠道实现了Circuit Breaker熔断器。当连续失败次数达到阈值如10次或错误率超过阈值如50%熔断器会“跳闸”在接下来的一段时间内如30秒直接拒绝发往该渠道的所有请求并快速返回一个友好的错误信息如“服务暂时拥挤”。这避免了在 upstream 服务不稳定时持续发送请求浪费资源和时间。熔断器会在休眠期后进入“半开”状态试探性发送一个请求如果成功则闭合恢复。4.3 配置化管理与动态调整我们将所有限流规则用户配额、滑动窗口的limit和window_size、优先级映射规则都存储在配置中心如Consul或数据库中。这样我们可以实现动态扩容在促销活动前临时调高某些用户的配额或流速限制。快速止损当发现某个用户密钥泄露或被恶意利用时可以立即将其配额设置为0或将其加入黑名单。A/B测试对不同用户群体应用不同的限流策略观察对系统负载和用户体验的影响。5. 常见问题排查与实战心得5.1 典型问题速查表问题现象可能原因排查步骤与解决方案用户反馈“配额不足”但管理后台显示配额充足。1. 本地缓存与Redis数据不一致。2. 预估Token远大于实际消耗导致配额被“虚占”。3. 配额重置任务失败used字段未清零。1. 检查该用户请求日志对比本地缓存命中情况和Redis操作记录。2. 核查该用户近期请求的预估/实际Token比例调整预估模型。3. 检查后台任务日志手动执行重置脚本。高优先级请求仍然排队很久。1. Worker进程全部僵死或过载。2. RabbitMQ队列优先级未正确设置x-max-priority。3. 消息的priority属性未正确赋值。1. 检查Worker进程状态和日志重启或扩容。2. 使用RabbitMQ管理界面检查队列属性。3. 抓取一条队列中的消息检查其properties中的优先级字段。Redis CPU使用率持续高位。1. 热点Key问题。2. Lua脚本过于复杂或存在慢循环。3. 有大量Key未设置过期时间导致内存膨胀RDB/AOF重写开销大。1. 使用redis-cli --hotkeys命令查找热点Key实施本地缓存优化。2. 使用SCRIPT KILL命令分析慢脚本进行优化。3. 扫描并清理无过期时间的临时Key为所有限流相关Key确保设置合理的过期时间。滑动窗口限流不准确偶尔会放过超出限制的请求。1. 分布式环境下多个应用实例的时间戳不同步。2. “移除旧请求”和“添加新请求”非原子操作未用Lua脚本。3. 网络延迟导致多个请求的检查-通过-记录顺序错乱。1. 部署NTP服务保证所有服务器时间同步。2.必须将窗口计算和记录放入同一个Lua脚本中执行保证原子性。3. 确保Redis部署在低延迟的网络环境中或考虑使用Redis集群的同区域分片。5.2 踩坑心得与最佳实践Token预估宁可略高不可过低在限流环节使用略高于平均水平的预估值例如增加10%-20%的缓冲可以在保护系统的同时减少因预估过低导致窗口内实际流量超限的风险。被“误杀”的请求可以通过重试机制解决而系统过载则是灾难性的。优先级不要滥用最初我们设计了10个优先级等级后来发现管理混乱。最终简化为3-4个明确等级如低、中、高、系统并与清晰的业务规则套餐等级、任务类型绑定。过多的优先级会增加调度复杂度和调试难度。监控必须覆盖“限流本身”不仅要监控被限流的结果更要监控限流决策的过程。例如记录每次配额检查的“前/后”使用量、滑动窗口的“当前使用量/限制量”。这些日志在排查配额突然耗尽或限流突然变严的问题时至关重要。设计面向失败的接口当请求被限流或进入队列时返回给客户端的HTTP状态码和消息体必须清晰、友好。429 Too Many Requests用于限流202 Accepted和task_id用于排队503 Service Unavailable用于熔断。同时在响应头中提供可选信息如X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset帮助客户端实现自适应重试。定期进行压力测试和混沌工程实验通过模拟流量洪峰观察限流系统在极端情况下的表现。随机杀死Redis或RabbitMQ节点验证系统的降级和恢复能力。这些测试能暴露出在平稳运行期无法发现的问题。实施这套复合限流方案后我们的平台再未因单个用户的异常行为而影响全局服务成本预测的准确性大幅提升VIP客户在流量高峰期的体验也得到了保障。它从一个“救火”的临时方案演变成了支撑平台稳定性和商业模型的基础设施。这个过程让我深刻体会到在LLM应用这类资源敏感型系统中精细化的流量治理不是可选项而是生命线。
返回列表