Amazon Bedrock 限流应对实战指南
如果你真的把大语言模型(LLM)的业务流量跑在 Amazon Bedrock 上,那么限流(throttling)绝不是偶发的边角情况——在规模化场景下,它就是常态。当你收到 ThrottlingException 或 TooManyRequestsException,其实是 Bedrock 在告诉你:你在该区域、该模型上的请求速率或 token 速率已经越过了配额。
解决办法很少是靠改一个配置就能搞定,它更像一套分层防御:先搞清楚配额,再做智能重试,限制并发数,优雅降级,处理超时,并且知道什么情况下该找 AWS 提工单。下面这些策略,是我在生产环境的 LLM 负载中反复用到的,写成通用指南——不涉及具体业务,只讲模式,以及每种模式在什么情况下值得上。
TL;DR
- 先摸清配额:查 Service Quotas,弄清哪些限制可调(软限制)、哪些不可调(硬限制)。
- 遇到限流错误时,用指数退避 + 完全抖动(full jitter)重试。这是底线,不是上限。
- 限制并发,别再往配额吃不下的地方硬塞负载。
- 加一层熔断器,遇到限流风暴时主动后退,而不是继续猛砸。
- 超时要单独处理:模型超时就缩小批次;客户端读超时则调整参数后重试。
- 用跨区域推理配置文件(cross-region inference profile),把 token 需求分散到多个区域。
- 只有在按需模式加上前面这些手段仍然不够时,才考虑预留吞吐量(provisioned throughput)。
第一步:搞清你撞的是哪个配额
在写第一行重试代码之前,先找到真实的限制值。Bedrock 的配额按模型、按区域划分,还按类型拆开:每分钟 token 数(TPM)、每分钟请求数(RPM),部分模型还把输入/输出 token 配额单独计算。
- Amazon Bedrock 配额总览——包含默认配额会随账号年龄、使用历史和区域变化这一点。
- bedrock-runtime 端点的配额——推理时实际撞到的运行时调用限制。
- 在 Service Quotas 控制台的 Amazon Bedrock 分类下查看这些配额。
- 扩缩容与吞吐量最佳实践——每个模型的 TPM 和 RPM 配额是如何相互影响的。
软限制与硬限制
这个区别决定了你整套策略的走向。
软限制(可调整)。 在 Amazon Bedrock 端点与配额表里,这类限制标注为 Adjustable: Yes,可以申请调高。
通过 Service Quotas 申请提高配额;较小的增幅通常会自动批准,较大的会转给 AWS Support 审核。硬性上限(不可调整)标记为 Adjustable: No,再怎么申请也改不了。唯一的办法是从架构层面入手:降低请求量、分散到多个区域,或者改变访问模式。实用的原则是:如果是软限制且你经常逼近上限,就提交提额申请,同时把下面这些防御措施建好,因为提额需要时间,而且永远不可能让限流彻底消失。如果是硬限制,就别浪费时间申请,直接改架构。一个常被忽略的细节:Bedrock 在请求开始时就按输入 token 数加上 maxTokens 来预留 TPM 配额。如果不设 maxTokens,它会默认取模型最大值,预留的配额远超实际所需,结果就是比实际 token 用量更早被限流。把 maxTokens 设成实际需要的上限。
第一层:带退避和抖动的重试
这是最基础的防御。捕获限流错误码(ThrottlingException、TooManyRequestsException),等待一段时间后重试。好的重试和有害的重试之间差两个细节:
指数退避。 每次重试等待更久:base * 2^attempt,设一个上限。这给配额窗口留出恢复时间。
完全抖动(full jitter)。 在等待窗口内随机化延迟。没有抖动的话,所有被限流的调用方会在同一时刻重试,形成同步踩踏,又被限流一次。有了抖动,重试就散开了。
一个精简的写法:
import random, time
from botocore.exceptions import ClientError
THROTTLE = {"ThrottlingException", "TooManyRequestsException"}
def call_with_backoff(fn, *, base=2.0, cap=15.0, max_retries=5):
for attempt in range(max_retries + 1):
try:
return fn()
except ClientError as exc:
code = exc.response.get("Error", {}).get("Code", "")
if code not in THROTTLE or attempt == max_retries:
raise
delay = min(base * 2 ** attempt, cap)
time.sleep(random.uniform(0, delay)) # full jitter
关于 boto3 客户端自带的重试功能,有几点说明。
Boto3 自带重试模式(standard 或 adaptive),通过 botocore.config.Config(retries=...) 配置。你可以直接用它,也可以关掉它(max_attempts=0),自己接管重试循环。当你想让退避、抖动和错误分类跟系统其他部分保持一致,而不是让两层重试互相打架时,自己写循环是值得的。
在 Step Functions 工作流里做同样的重试
如果 Bedrock 调用跑在 Step Functions 状态机里,你不需要手写退避循环。只要在任务的 Retry 块里声明,服务就会帮你套用带抖动的指数退避。Step Functions 的错误处理新增了 JitterStrategy 字段,让等待时间分散开、不再同步——这和在代码里用 full jitter(完全随机抖动)是同一个道理。
"InvokeBedrock" : {
"Type" : "Task" ,
"Resource" : "arn:aws:states:::bedrock:invokeModel" ,
"Retry" : [
{
"ErrorEquals" : [ "ThrottlingException" , "TooManyRequestsException" ],
"IntervalSeconds" : 2 ,
"MaxAttempts" : 15 ,
"BackoffRate" : 2.0 ,
"MaxDelaySeconds" : 30 ,
"JitterStrategy" : "FULL"
}
],
"Catch" : [
{
"ErrorEquals" : [ "States.ALL" ],
"Next" : "HumanReview"
}
],
"Next" : "NextStep"
}
这些字段和代码版本一一对应:IntervalSeconds 是基础延迟,BackoffRate 是指数倍数,MaxDelaySeconds 是上限,MaxAttempts 是重试次数,JitterStrategy: FULL 则让每次等待时间随机化。重试耗尽后,Catch 会把流程导向兜底步骤,比如人工审核,这样重试失败也能优雅降级,而不是让整个执行一败涂地。
第二层:限制并发
重试处理的是已经失败的请求。并发上限则从一开始就阻止你制造超出配额承受能力的负载。如果 50 个 worker 同时调用同一个模型,不管退避写得多巧妙,照样会被限流。给进行中的 Bedrock 调用设一个天花板。
在异步 Python 里,信号量(semaphore)就能做到:
import asyncio
semaphore = asyncio.Semaphore(10) # max concurrent Bedrock calls
async def bounded_call(fn):
async with semaphore:
return await fn()
除了应用内部,在基础设施层也要限制并发。
如果文档以事件形式到达,就把它们放进 SQS 队列,让 SQS-to-Lambda 的事件源映射来强制实施 maximum_concurrency。工作会在队列中等待,直到有槽位空出来,无需轮询,也不用自定义计数器:
resource "aws_lambda_event_source_mapping" "sqs_to_worker" {
event_source_arn = aws_sqs_queue.work.arn
function_name = aws_lambda_function.worker.arn
batch_size = 1
scaling_config {
maximum_concurrency = 5
}
}
并发上限要跟配额对齐,而不是跟你理论上能跑多少 worker 对齐。正确的数字是那个能让稳态 token 速率压在 TPM 限制之下的值。
第三层:给限流风暴装个断路器
退避和并发上限能应付正常压力。但持续的限流风暴需要更强力的后退手段。断路器会盯着限流率,一旦在时间窗口内越过阈值,就暂停所有调用进入冷却,然后以降低后的并发恢复,之后再逐步回到全速。
状态机是这样的:
- 闭合(正常):调用正常流动,统计限流次数。
- 断开(触发):窗口内出现 N 次限流后,暂停所有调用进入冷却,并把并发上限减半。
- 半开(恢复中):冷却结束后,以降低后的上限恢复调用;如果一段时间内干净无异常,就恢复全并发;如果再次被限流,就重新断开。
这样就把一个会让限流越来越糟的反馈循环(重试、限流、再重试、再限流)变成了主动卸载负载、直到 Bedrock 恢复的机制。它跟并发限制器配合使用:断路器负责把上限调低,再调回来。
第四层:处理超时——超时不是限流
限流很少单独出现,最爱凑热闹的邻居就是超时。超时有两种截然不同的类型,对应的修复方式也正好相反。
模型超时:缩小工作量
ModelTimeoutException 意味着请求本身太大,没能在模型的时间预算内完成。这不是速率问题,所以重试同样的负载只会再次超时。正确的做法是减少工作量:把批次减半或把输入分块,然后用更小的量重试。
async def call_shrinking(fn, items, *, max_retries=3):
batch = list(items)
for attempt in range(max_retries + 1):
try:
return await fn(batch)
except Exception as exc:
code = getattr(exc, "response", {}).get("Error", {}).get("Code", "")
if code != "ModelTimeoutException" or attempt == max_retries:
raise
batch = batch[: max(1, len(batch) // 2)] # 减半后重试
客户端超时:先调预算,再重试偶发的超时
客户端一侧的 ReadTimeoutError 或 ConnectTimeoutError 又是另一回事:这是 SDK 在模型给出响应之前就放弃等待了。提示词一大、生成长度一长,常常会超过 boto3 默认的读超时,于是超时看起来像失败,实际只是客户端太没耐心。要做两件事:
- 给 boto3 客户端设置一个贴近实际的
read_timeout,别让一个慢但有效的响应被拦腰截断。 - 把偶发的连接超时和限流一样当作可重试的情况,而不是让它们直接冒出来变成硬失败。
from botocore.config import Config
client = boto3.client(
"bedrock-runtime",
config=Config(
read_timeout=300,
retries={"max_attempts": 0},
),
)
RETRYABLE = {"ThrottlingException", "TooManyRequestsException"}
# 也可按异常类名重试 ReadTimeoutError / ConnectTimeoutError
先分类,再反应。 限流要退避,模型超时要缩小 batch,客户端超时要延长预算再重试,而 ValidationException 则完全不该重试。
第五层:用跨区域推理配置摊开请求量
配额是按区域划分的。跨区域推理配置(cross-region inference profile)可以让单次按需调用由多个区域之一来承接,这样你的 token 需求就会分散到各区域的配额上,而不是全压在一个区域里。对按需工作负载来说,这往往是性价比最高的一步改动:不用额外买容量,有效上限却提高了,因为你在从多个区域资源池里取用。
它并不是无限的余量,也不会改变单个区域内某个模型的硬上限;但对波动较大的按需流量来说,确实能腾出实打实的空间。
第六层:预置吞吐量,只在真正需要时启用
如果按需模式加上前面所有手段,在可预期的高负载下依然被限流,Bedrock 还提供了预置吞吐量(Provisioned Throughput):以承诺购买的方式预留容量(模型单元),保证稳定的 token 速率。对于这块预留容量,限流会被彻底解除,因为容量归你了。把它当作最后一层,而不是第一层。不管用不用都在计费,所以只适合稳定、可预期的流量。对于突发或不可预测的流量,按需模式下的重试、并发上限和跨区域配置文件,通常经济性更好。
实战调参
上面这些层是工具箱。每个旋钮拧多紧,取决于具体工作负载,有几种模式值得直说。
重试的激进程度要匹配调用失败的代价。 重、贵的调用值得多试几次、退避上限更高、并发压得更紧,因为丢一次代价大。高频、便宜的任务,更适合用上游队列限制并发,加上工作流级别的自重试,最终落到人工审核兜底,每次调用的重试保持轻量。没有唯一正确的数字;合适的设置就是能让该工作负载的稳态 token 速率压在配额之下。
两个容易搞错的地方:
- “回退模型”可能悄悄指向同一个模型。 如果主模型和回退模型通过共享配置指向同一个模型 id,那回退实际是在对同一个模型多试几次,而不是模型多样性。在配置指向真正不同的模型之前,叫它“回退”是高估了它的作用。
- 重试、退避、并发不等于预留容量。 完全靠重试、退避、并发上限、按需跨区域推理配置文件搭出来的防线,在真正的尖峰下没有预留容量兜底。这可以是一种有意的按需策略,但要明确说清,因为按需加重试仍然有上限。
怎么决定该用哪招
遇到限流时,一个简短的判断顺序:
先查配额。这个限额是软限还是硬限?
如果配额属于“软限制”(soft limit),而你的用量已经逼近上限,那就立刻提交提额申请,和其他措施一起推进。先确认重试逻辑里带了退避(backoff)和抖动(jitter);如果没有,先补上这一块,这是成本最低的一招。在应用层和基础设施层同时限制并发,把稳态请求速率压在每分钟 token 数(TPM)上限之下。如果限流风暴反复出现、而不只是偶尔发生,那就加一个熔断器(circuit breaker)。把超时和限流分开处理:模型超时就缩小批量(batch),客户端超时就调整读取超时时间并配置重试。再借助跨区域推理配置文件(cross-region inference profile),把按需流量分摊到多个区域。只有前面的手段都覆盖不了、且流量稳定可预测又足够大时,才值得购买预置吞吐(provisioned throughput)。
限流不是撞一次就能破的墙,而是一种需要持续管理的压力。能把服务稳住的系统,都把配额当成一条必须纳入设计的硬约束,而不是等着去捕获的异常。