首页 / 资讯中心 / 文章详情

LLM高并发调用限流实战:从秒杀思维到并发闸门的架构演进

LLM高并发调用限流实战:从秒杀思维到并发闸门的架构演进 ★ FEATURED ARTICLE
1. 从一次线上事故说起为什么秒杀那套限流思路在 LLM 场景下会翻车去年年底我接手了一个 AI 客服中台项目底层对接了三家不同厂商的大模型服务上层是十几个业务方通过 HTTP 接口调用。上线第一周就出了事某个业务方做了一轮批量数据清洗瞬间打进来两千多个请求结果整个中台的响应时间从平均 1.8 秒飙到 40 秒以上所有业务方的调用全部超时连正常的在线客服对话都断了。当时我的第一反应是加限流毕竟做了这么多年后端秒杀场景下的 QPS 限流、令牌桶、漏桶、Sentinel 流控规则这些东西闭着眼睛都能写。于是我按照秒杀系统的经典套路在网关层给每个业务方配了 QPS 阈值超过就快速失败。结果呢问题只解决了一半——业务方的请求确实被拦住了但 LLM 调用本身的并发问题依然存在因为真正的瓶颈不在入口而在调用汇聚点。这个经历让我彻底想明白了一件事AI 接口的高并发和秒杀高并发本质上是两种完全不同的东西。秒杀的核心矛盾是瞬时流量洪峰 库存扣减的强一致性而 LLM 接口的核心矛盾是长耗时调用 上游配额有限 下游连接池脆弱。你把秒杀那套闸门挂在入口等于在洪水来的时候只堵住了上游河道下游的水库该溢还是溢。这篇文章我想把踩过的坑、试过的方案、最终落地的架构完整讲一遍。如果你正在做 AI 应用开发、LLM 中台、或者任何需要对接大模型 API 的后端系统尤其是被并发问题折磨过的同行这篇内容应该能帮你少走至少两个月的弯路。关键词里的 AI、LLM、并发闸门、QPS 限流、Sentinel 这些概念我会结合实际代码和配置逐个拆开讲。2. LLM 调用和秒杀请求的本质差异先搞清楚你在限什么2.1 耗时量级差了三个数量级限流模型必须换秒杀场景下一个下单请求的处理时间通常在 10 到 50 毫秒之间瓶颈在数据库行锁和库存扣减。而一次 LLM 调用哪怕是流式返回首 token 延迟普遍在 500 毫秒到 3 秒完整响应时间从 2 秒到 30 秒不等。如果是 GPT-4 这类大模型做复杂推理单次调用超过 60 秒都很正常。这个差异带来的直接后果是秒杀场景下 QPS 是有效指标LLM 场景下 QPS 几乎没意义。你限制每秒 100 个请求但如果每个请求要占用连接 10 秒那实际并发连接数就是 1000上游厂商的配额早就爆了。正确的指标应该是并发数Concurrency也就是同时处于已发出、未返回状态的请求数量。我后来在 Sentinel 里把流控模式从 QPS 改成了并发线程数控制效果立竿见影。具体配置后面会详细讲。2.2 上游配额是硬约束不是你能弹性扩容的秒杀系统的容量瓶颈在你自己的数据库理论上可以通过分库分表、缓存预热、队列削峰来扩容。但 LLM 接口的瓶颈在上游厂商的账户配额这个配额是合同写死的你没法临时扩容。我对接的三家厂商配额分别是A 厂商 60 并发、B 厂商 30 并发、C 厂商 20 并发。注意这是账户级的不是接口级。也就是说你所有业务方加起来对 A 厂商的同时调用不能超过 60 个。一旦超过要么被限流返回 429要么请求排队直到超时。这就意味着限流闸门必须挂在调用汇聚点——也就是所有业务方请求最终汇聚到某个厂商客户端的那一层。挂在网关层只能控制入口流量控制不了实际打到厂商的并发。2.3 连接池和线程池的脆弱性被严重低估秒杀场景下HTTP 连接池通常配置几百个连接请求快速进出连接复用率高。但 LLM 调用是长连接占用一个连接被占住 10 秒连接池很快就耗尽了。我踩过的最大的坑是用默认的 HttpClient 连接池配置最大 50 连接结果 60 个并发请求打进来第 51 个开始就阻塞在连接获取上然后 Tomcat 线程池被拖垮整个服务雪崩。后来我把连接池按厂商配额精确配置并且加了连接获取超时才稳住。下面这张表是我总结的两种场景的核心差异建议你对照自己的系统检查一遍维度秒杀高并发LLM 接口高并发单请求耗时10-50ms2-60s核心限流指标QPS并发数瓶颈位置自身数据库上游厂商配额扩容方式分库分表、缓存无法弹性扩容连接池压力低快速复用高长占用失败重试代价低高浪费配额限流闸门位置网关入口调用汇聚点3. 并发闸门到底该挂在哪三种方案的实测对比3.1 方案一网关层限流我最初的选择也是最先被淘汰的最直觉的做法是在 Spring Cloud Gateway 里配 Sentinel 流控规则按业务方维度限 QPS。配置大概长这样spring: cloud: sentinel: datasource: flow: nacos: server-addr: localhost:8848 dataId: gateway-flow-rules rule-type: flow然后在 Sentinel 控制台给每个路由配 QPS 阈值。这个方案的问题在于它只能控制入口速率控制不了在途并发。业务方 A 每秒发 10 个请求每个请求耗时 10 秒那 A 的在途并发就是 100早就超过厂商配额了但网关层看 QPS 完全正常。我实测下来网关限流只能防住恶意刷接口这种场景对正常的业务高峰毫无办法。3.2 方案二业务层限流粒度太细维护成本爆炸第二个想法是在每个业务方的 Service 层加限流。每个业务方调用 LLM 之前先 acquire 一个信号量。这个方案能控制并发但问题是配额是账户级的业务方之间会互相挤占。业务方 A 占了 50 个并发业务方 B 就只剩 10 个但 B 可能正在做紧急的在线客服A 在做离线批处理优先级完全不同。而且业务方有十几个每个都要配一套限流逻辑维护成本极高。我试了两周就放弃了。3.3 方案三调用汇聚点限流最终落地方案最终我选择的方案是在所有业务方和厂商客户端之间加一层统一的 LLM 调用网关闸门挂在这一层。架构大概是业务方A ─┐ 业务方B ─┼─→ LLM调用网关 ─→ 厂商A客户端配额60 业务方C ─┤ ├→ 厂商B客户端配额30 ... ─┘ └→ 厂商C客户端配额20这一层的核心职责有三个按厂商维度做并发控制每个厂商一个独立的信号量或 Sentinel 资源阈值等于配额打个 8 折留缓冲。按业务方维度做优先级排队高优先级业务在线客服可以抢占低优先级业务离线批处理的配额。统一做超时、重试、降级避免每个业务方自己实现一套。这个方案的好处是闸门位置和瓶颈位置对齐了。厂商配额是 60我就在汇聚点控制并发不超过 4880%这样既不会触发厂商限流又留了 20% 的弹性空间应对突发。4. 用 Sentinel 实现并发闸门的完整配置与代码4.1 Sentinel 并发线程数模式的正确用法Sentinel 的流控规则里有个grade参数0是 QPS1是并发线程数。LLM 场景必须用1。配置示例FlowRule rule new FlowRule(); rule.setResource(llm_vendor_a); rule.setGrade(RuleConstant.FLOW_GRADE_THREAD); // 并发线程数模式 rule.setCount(48); // 并发阈值 rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_DEFAULT); // 快速失败 FlowRuleManager.loadRules(Collections.singletonList(rule));这里有个坑要注意并发线程数模式统计的是当前正在执行该资源的线程数所以你的 LLM 调用必须在 Sentinel 的SphU.entry()和entry.exit()之间执行否则统计不准。我见过有人把 entry 写在异步回调外面结果并发数永远是 1。正确的写法public String callLlm(String vendor, String prompt) { Entry entry null; try { entry SphU.entry(llm_ vendor); // 实际的 LLM 调用同步阻塞直到返回 return llmClient.invoke(prompt); } catch (BlockException e) { // 被限流走降级逻辑 throw new LlmThrottledException(vendor vendor 并发已满); } finally { if (entry ! null) { entry.exit(); } } }4.2 连接池配置必须和并发阈值对齐这是我最想强调的一点。Sentinel 的并发阈值是 48那你的 HTTP 连接池最大连接数必须大于等于 48否则请求会阻塞在连接获取上Sentinel 统计的并发数会虚高导致误限流。我用的 OkHttp 配置OkHttpClient client new OkHttpClient.Builder() .connectTimeout(5, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) // LLM 响应可能很慢 .writeTimeout(10, TimeUnit.SECONDS) .connectionPool(new ConnectionPool(60, 5, TimeUnit.MINUTES)) .build();注意readTimeout要设得足够大LLM 调用 60 秒很正常。但也不能无限大否则一个卡死的请求会一直占着连接。我的经验是设为 P99 响应时间的 1.5 倍。4.3 优先级排队的实现思路Sentinel 本身不直接支持优先级队列但可以通过多资源 动态规则来实现。思路是给每个业务方分配一个优先级高优先级的业务方在低优先级业务方被限流时可以借用配额。具体做法是给每个厂商资源配两个规则一个是保底配额所有业务方共享一个是弹性配额只对高优先级业务方开放。低优先级业务方只能用到保底配额高优先级业务方可以用到保底 弹性。// 保底规则所有业务方共享阈值 30 FlowRule baseRule new FlowRule(llm_vendor_a_base); baseRule.setGrade(RuleConstant.FLOW_GRADE_THREAD); baseRule.setCount(30); // 弹性规则只对高优先级业务方开放阈值 18 FlowRule elasticRule new FlowRule(llm_vendor_a_elastic); elasticRule.setGrade(RuleConstant.FLOW_GRADE_THREAD); elasticRule.setCount(18);高优先级业务方先尝试 acquire 弹性资源失败再 acquire 保底资源低优先级业务方只 acquire 保底资源。这样在高峰期低优先级业务方会被限流但高优先级业务方依然能拿到配额。5. 那些文档里不会写的踩坑记录5.1 流式返回的并发统计陷阱LLM 的流式返回SSE是个大坑。如果你用SphU.entry()包住整个流式过程那并发数统计是对的但线程会被占住直到流结束。如果你在流开始时就entry.exit()那并发数会严重低估因为实际连接还占着。我的做法是流式调用单独用一套并发控制不用 Sentinel 的线程数模式而是用Semaphore手动控制。因为流式调用的生命周期跨越了多个线程IO 线程和业务线程Sentinel 的线程模型不适用。private final Semaphore streamSemaphore new Semaphore(48); public void streamLlm(String prompt, ConsumerString onChunk) { if (!streamSemaphore.tryAcquire()) { throw new LlmThrottledException(流式并发已满); } try { llmClient.stream(prompt, onChunk); } finally { streamSemaphore.release(); } }5.2 重试会放大并发必须加熔断LLM 调用失败重试是常见需求但重试会瞬间放大并发。我遇到过厂商 A 短暂抖动返回了一批 500结果重试逻辑把并发从 48 瞬间打到 96直接把厂商 B 的配额也拖垮了因为降级逻辑切到了 B。解决方案是重试必须走独立的并发控制并且加熔断。我用 Sentinel 的熔断降级规则当某个厂商的异常比例超过 50% 时直接熔断 30 秒期间所有请求走降级逻辑返回缓存结果或友好提示不再重试。DegradeRule degradeRule new DegradeRule(llm_vendor_a); degradeRule.setGrade(RuleConstant.DEGRADE_GRADE_EXCEPTION_RATIO); degradeRule.setCount(0.5); // 异常比例 50% degradeRule.setTimeWindow(30); // 熔断 30 秒 degradeRule.setMinRequestAmount(10); // 至少 10 个请求才统计5.3 超时时间设置的经验值超时设置太短正常请求被误杀太长卡死请求占着连接不放。我的经验值是非流式调用P99 响应时间 × 1.5通常 30-60 秒流式调用首 token 超时 10 秒整体超时 120 秒连接获取超时2 秒超过说明连接池不够用这些值不是拍脑袋定的我是用了一个月的时间把每次调用的耗时打到 Prometheus然后看 P50、P95、P99 分位数再往上留 50% 的余量。5.4 配额打 8 折的缓冲逻辑厂商说配额是 60你千万别真的用到 60。因为厂商的统计口径和你的可能有偏差而且网络抖动、重试、流式调用都会让实际并发略高于你的统计值。我统一打 8 折60 的配额用 4830 的用 2420 的用 16。实测下来这样基本不会触发厂商的 429。6. 监控告警没有可观测性的限流就是盲人摸象6.1 必须监控的四个核心指标限流配好了不代表万事大吉你必须能实时看到系统的状态。我监控的四个核心指标是各厂商的实时并发数这是最关键的直接反映配额使用率。限流触发次数按业务方和厂商维度统计能看出谁在挤占配额。调用耗时分布P50、P95、P99用于动态调整超时和并发阈值。异常率和熔断状态及时发现厂商故障。这些指标我用 Micrometer 打到 Prometheus然后在 Grafana 上做了看板。并发数的采集要注意Sentinel 的ClusterNode里有curThreadNum可以直接读。ClusterNode node ClusterBuilderSlot.getClusterNode(llm_vendor_a); double currentConcurrency node.curThreadNum();6.2 告警阈值怎么定告警不能太敏感否则天天响也不能太迟钝否则出事才发现。我的设置是并发数超过阈值 80% 持续 1 分钟警告并发数超过阈值 95% 持续 30 秒严重限流触发次数 5 分钟内超过 100 次警告熔断触发立即严重告警这套阈值跑了大半年误报率很低该抓的问题基本都抓到了。6.3 动态调整规则的能力业务是变化的配额可能调整业务方可能增减。所以限流规则必须支持动态调整不能写死在代码里。我用 Nacos 做规则的数据源Sentinel 控制台改完规则直接推送到 Nacos所有实例实时生效不用重启。spring: cloud: sentinel: datasource: flow: nacos: server-addr: ${nacos.addr} dataId: llm-flow-rules groupId: SENTINEL_GROUP rule-type: flow degrade: nacos: server-addr: ${nacos.addr} dataId: llm-degrade-rules groupId: SENTINEL_GROUP rule-type: degrade7. 从秒杀思维切换到 LLM 思维几个认知上的转变做完这个项目我最大的收获不是技术方案而是认知上的转变。分享几个我觉得最重要的点。第一限流的位置比限流的算法重要得多。很多人一上来就纠结用令牌桶还是漏桶用 Sentinel 还是 Hystrix但其实最关键的问题是闸门挂在哪。挂错了位置算法再精妙也没用。第二并发数比 QPS 更接近本质。LLM 场景下QPS 是个误导性指标。你应该关心的是同时有多少个请求在途因为这直接对应上游配额和连接池占用。第三留缓冲比压榨性能更重要。秒杀场景下我们习惯把资源用到极致但 LLM 场景下留 20% 的缓冲是保命的。因为上游配额是硬约束一旦触发限流恢复代价很高。第四可观测性是限流的前提。没有监控的限流就是盲人摸象你不知道阈值该设多少也不知道限流有没有生效。先做监控再做限流。最后分享一个我实际用下来很稳的小技巧给每个厂商客户端加一个预热逻辑。服务刚启动时连接池是空的第一批请求会慢一些。我在启动后主动发几个轻量请求比如让模型返回一个固定字符串把连接池预热起来这样正式流量进来时不会有冷启动的抖动。这个技巧在秒杀场景下也常用但在 LLM 场景下效果更明显因为 LLM 的连接建立和 TLS 握手开销更大。
阅读完成 · 觉得有帮助?
咨询建站