Amazon Bedrock 限流应对实战指南

Dev.to AI 2026-10-21T09:45:32.197563

如果你真的把大语言模型(LLM)的业务流量跑在 Amazon Bedrock 上,那么限流(throttling)绝不是偶发的边角情况——在规模化场景下,它就是常态。当你收到 ThrottlingException 或 TooManyRequestsException,其实是 Bedrock 在告诉你:你在该区域、该模型上的请求速率或 token 速率已经越过了配额。

解决办法很少是靠改一个配置就能搞定,它更像一套分层防御:先搞清楚配额,再做智能重试,限制并发数,优雅降级,处理超时,并且知道什么情况下该找 AWS 提工单。下面这些策略,是我在生产环境的 LLM 负载中反复用到的,写成通用指南——不涉及具体业务,只讲模式,以及每种模式在什么情况下值得上。

TL;DR

第一步:搞清你撞的是哪个配额

在写第一行重试代码之前,先找到真实的限制值。Bedrock 的配额按模型、按区域划分,还按类型拆开:每分钟 token 数(TPM)、每分钟请求数(RPM),部分模型还把输入/输出 token 配额单独计算。

软限制与硬限制

这个区别决定了你整套策略的走向。

软限制(可调整)。 在 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 限制之下的值。

第三层:给限流风暴装个断路器

退避和并发上限能应付正常压力。但持续的限流风暴需要更强力的后退手段。断路器会盯着限流率,一旦在时间窗口内越过阈值,就暂停所有调用进入冷却,然后以降低后的并发恢复,之后再逐步回到全速。

状态机是这样的:

这样就把一个会让限流越来越糟的反馈循环(重试、限流、再重试、再限流)变成了主动卸载负载、直到 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 默认的读超时,于是超时看起来像失败,实际只是客户端太没耐心。要做两件事:

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 速率压在配额之下。

两个容易搞错的地方:

怎么决定该用哪招

遇到限流时,一个简短的判断顺序:

先查配额。这个限额是软限还是硬限?

如果配额属于“软限制”(soft limit),而你的用量已经逼近上限,那就立刻提交提额申请,和其他措施一起推进。先确认重试逻辑里带了退避(backoff)和抖动(jitter);如果没有,先补上这一块,这是成本最低的一招。在应用层和基础设施层同时限制并发,把稳态请求速率压在每分钟 token 数(TPM)上限之下。如果限流风暴反复出现、而不只是偶尔发生,那就加一个熔断器(circuit breaker)。把超时和限流分开处理:模型超时就缩小批量(batch),客户端超时就调整读取超时时间并配置重试。再借助跨区域推理配置文件(cross-region inference profile),把按需流量分摊到多个区域。只有前面的手段都覆盖不了、且流量稳定可预测又足够大时,才值得购买预置吞吐(provisioned throughput)。

限流不是撞一次就能破的墙,而是一种需要持续管理的压力。能把服务稳住的系统,都把配额当成一条必须纳入设计的硬约束,而不是等着去捕获的异常。

查看原文