Claude API 429 限速治理:RPM/ITPM/OTPM + 令牌桶(Kotlin)

摘要:CSDN 上聊 Claude,最常见的技术痛点不是"模型好不好",而是调用一多就 429。本文按工程视角拆开 Claude API 的三类限速(RPM/ITPM/OTPM),解释为什么"缓存命中"能显著放大有效吞吐,再给一套 Kotlin 可直接抄的实现:解析响应头、指数退避 + 抖动、令牌桶限流、按输入/输出 token 做预算。最后附一页排查清单,适合直接落到线上网关或 SDK。


1. 先把 429 的"原因"对齐:你可能撞的不是同一面墙

Claude API 的限速不是单一维度。官方对 Messages API 的速率限制按三类指标衡量:每分钟请求数(RPM)、每分钟输入 token(ITPM)、每分钟输出 token(OTPM)。超过任何一条都会 429,并且会返回 retry-after 告诉你应该等多久再试。

参考官方文档:速率限制

我见过最多的误判是:大家只盯 RPM,但真正触发的是 ITPM(上下文太长、工具定义太长、历史对话反复塞回去),或者 OTPM(一次性让它吐很长)。


2. CSDN 上最常见的 429 场景(以及对应解法)

结合 CSDN 问答里开发者提的问题,核心集中在"高并发/批量处理时怎么控速、怎么读响应头、怎么选策略"。例如这类问题会明确提到 Retry-AfterX-RateLimit-* 之类的头部解析与算法选择:Claude 3.7 API 调用时如何处理速率限制?

我把它们收敛成三类:

场景 A:并发一上来就 429(突发流量)

你需要的不是"多重试几次",而是把突发流量削平。

  • 先做客户端/网关层并发上限(Semaphore)
  • 再做令牌桶限流(Token Bucket)
  • 429 时严格尊重 retry-after,并加抖动,避免"雷鸣羊群"重试风暴

场景 B:你以为请求不多,但还是 429(ITPM 被打满)

很常见:每次请求带 10k/50k 甚至更长的输入 token,RPM 看起来不高,但 ITPM 直接触顶。

解法更偏输入治理:

  • 拆上下文:固定层/半固定层/动态层分开
  • 能缓存的内容尽量用提示缓存(Prompt Caching)
  • 长文档先分块检索 topK,再拼上下文,不要硬塞全文

场景 C:输出太长导致 OTPM 触顶或请求变慢

你不一定要换模型,先把输出变短:

  • 默认 max_tokens 给一个上限
  • 先让模型给"提纲/计划",再分段输出
  • 对结构化任务,强制 JSON schema,避免"多写两段解释"

2.5 你需要多大吞吐:用 ITPM/OTPM 反推"并发上限"

写限流之前,先估一个量级。不用特别准,能让你不瞎猜就行。

假设你每个请求平均:

  • 输入(未缓存)(in) tokens
  • 输出 (out) tokens

那在 ITPM/OTPM 上限分别为 (ITPM_limit)、(OTPM_limit) 时,你每分钟能稳定处理的大致请求数约为:

req/min \\approx \\min\\left(\\frac{ITPM_limit}{in}, \\frac{OTPM_limit}{out}\\right)

然后再和 RPM 上限取最小值。

真正上线后,把"平均 in/out token"和"p95 in/out token"都打点出来,你会更快找到是输入过长还是输出过长在拖后腿。

3. 关键细节:缓存命中会影响 ITPM(这点很容易省出吞吐)

官方文档里有一个很"工程友好"的设定:对大多数模型,从缓存读取的输入 token(cache_read_input_tokens)不计入 ITPM ,只有未缓存输入(input_tokens)和缓存写入(cache_creation_input_tokens)计入。

原理与示例见官方解释:缓存感知 ITPM

总输入 token 的计算是:

text 复制代码
total_input_tokens = cache_read_input_tokens + cache_creation_input_tokens + input_tokens

但在多数模型的 ITPM 计数里,通常只算:

text 复制代码
itpm_counted_tokens ≈ cache_creation_input_tokens + input_tokens

所以同样是"带 200K 文档",如果文档命中缓存,你的 ITPM 压力会小很多。这个结论对高并发特别关键。


4. Kotlin 实战:读响应头 + 指数退避 + 令牌桶(可直接抄)

下面这套代码分三块:

  1. 解析 retry-afteranthropic-ratelimit-*
  2. 429 专用的指数退避 + 抖动
  3. 一个简单令牌桶:既能控"请求速率",也能扩展成"token 速率"

4.1 Gradle 依赖(OkHttp + 协程)

kotlin 复制代码
dependencies {
  implementation("com.squareup.okhttp3:okhttp:4.12.0")
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
}

4.2 解析限速头部

官方列出的响应头包括 retry-after,以及 anthropic-ratelimit-requests-* / anthropic-ratelimit-input-tokens-* / anthropic-ratelimit-output-tokens-* 等。

参考:响应头部

kotlin 复制代码
import okhttp3.Headers
import java.time.Instant

data class RateLimitSnapshot(
  val retryAfterSec: Long?,
  val reqRemaining: Long?,
  val inTokRemaining: Long?,
  val outTokRemaining: Long?,
  val reqResetAt: Instant?
)

fun parseRateLimit(headers: Headers): RateLimitSnapshot {
  fun long(name: String) = headers[name]?.trim()?.toLongOrNull()
  fun instant(name: String) = headers[name]?.trim()?.let { runCatching { Instant.parse(it) }.getOrNull() }

  return RateLimitSnapshot(
    retryAfterSec = long("retry-after"),
    reqRemaining = long("anthropic-ratelimit-requests-remaining"),
    inTokRemaining = long("anthropic-ratelimit-input-tokens-remaining"),
    outTokRemaining = long("anthropic-ratelimit-output-tokens-remaining"),
    reqResetAt = instant("anthropic-ratelimit-requests-reset")
  )
}

4.3 令牌桶(先控 RPM,后续可扩到 ITPM/OTPM)

kotlin 复制代码
import kotlin.math.min
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource

class TokenBucket(
  private val capacity: Double,
  private val refillPerSec: Double
) {
  private var tokens: Double = capacity
  private var last = TimeSource.Monotonic.markNow()

  @Synchronized
  fun tryConsume(n: Double = 1.0): Boolean {
    val now = TimeSource.Monotonic.markNow()
    val elapsed = (now - last).inWholeMilliseconds / 1000.0
    last = now
    tokens = min(capacity, tokens + elapsed * refillPerSec)
    if (tokens >= n) {
      tokens -= n
      return true
    }
    return false
  }

  suspend fun acquire(n: Double = 1.0, poll: Duration = 0.05.seconds) {
    while (!tryConsume(n)) {
      kotlinx.coroutines.delay(poll)
    }
  }
}

4.4 调用骨架:尊重 retry-after,没给就退避 + 抖动

kotlin 复制代码
import kotlinx.coroutines.delay
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.math.min
import kotlin.random.Random

private val JSON = "application/json; charset=utf-8".toMediaType()

private fun String.jsonString(): String =
  buildString {
    append('"')
    for (ch in this@jsonString) {
      when (ch) {
        '\\' -> append("\\\\")
        '"' -> append("\\\"")
        '\n' -> append("\\n")
        '\r' -> append("\\r")
        '\t' -> append("\\t")
        else -> append(ch)
      }
    }
    append('"')
  }

fun buildMessagesBody(model: String, userText: String, maxTokens: Int = 800): String =
  """
    {
      "model": "${model}",
      "max_tokens": $maxTokens,
      "messages": [
        { "role": "user", "content": ${userText.jsonString()} }
      ]
    }
  """.trimIndent()

suspend fun callClaudeSafely(
  client: OkHttpClient,
  rpmBucket: TokenBucket,
  apiKey: String,
  bodyJson: String,
  maxRetries: Int = 5
): String {
  val url = "https://api.anthropic.com/v1/messages"

  var attempt = 0
  var backoffMs = 800L

  while (true) {
    rpmBucket.acquire(1.0) // 先控请求速率(RPM)

    val req = Request.Builder()
      .url(url)
      .post(bodyJson.toRequestBody(JSON))
      .addHeader("x-api-key", apiKey)
      .addHeader("anthropic-version", "2023-06-01")
      .addHeader("content-type", "application/json")
      .build()

    client.newCall(req).execute().use { resp ->
      val text = resp.body?.string().orEmpty()
      if (resp.isSuccessful) return text

      if (resp.code != 429 || attempt >= maxRetries) {
        throw RuntimeException("HTTP ${resp.code}: $text")
      }

      val rl = parseRateLimit(resp.headers)
      val sleepMs = rl.retryAfterSec?.let { it * 1000L }
        ?: run {
          val jitter = Random.nextLong(0, 250)
          min(backoffMs + jitter, 10_000L)
        }

      delay(sleepMs)
      backoffMs = min(backoffMs * 2, 10_000L)
      attempt++
    }
  }
}

你上线前至少做两处增强:

  • 把"短任务"和"长任务"分桶(两个 bucket / 两个并发队列)
  • 增加 ITPM/OTPM 预算:把估算 token 也纳入限流(否则 RPM 控住了,ITPM 还是会撞)

5. 一页排查清单(我写给自己的)

遇到 429,不要先改提示词,先按这张表排:

  1. 看响应头:有没有 retry-after,有没有 requests/input/output remaining
  2. 分清触发维度:RPM 还是 ITPM/OTPM
  3. 先削峰:并发上限 + 令牌桶 + 抖动退避
  4. 再治本:上下文拆分、检索 topK、提示缓存、分段输出
  5. 最后再谈模型:Haiku 做前置过滤,Sonnet 做主力,Opus 只用于关键路径

6. 顺带一提:把"限流/多 Key/多模型"放到网关层,业务会轻很多

上面这套方案你当然可以直接写在业务代码里,但当你遇到这些情况时,更推荐把它下沉到"统一接入层/网关层":

  • 多服务、多实例要共享同一套限流(否则每个实例都以为自己没超)
  • 需要多 Key 池轮换、分项目/分用户限额、账单可追踪
  • 同一套业务要在 Claude / GPT / Gemini 之间切换或做备用线路

这时通常有两条路:

  • 自己搭网关(可控,但要自己维护稳定性/审计/计费统计)
  • 用现成的聚合网关把接入层先收口

如果你想要一个"尽量少改代码"的方案,可以了解下 147api:按其介绍,它提供 OpenAI 接口风格的聚合管理 ,并覆盖 GPT、Claude、Gemini 等主流模型;同时支持更友好的结算方式与 key 管理,适合把"限流、重试、轮换、统计"集中在一个地方做,业务侧只保留最小调用逻辑。

最实用的落地方式是把 base_url / api_key / model 配置化,做到"一键切换 + 可回滚"。示例(Kotlin):

kotlin 复制代码
data class LlmEndpoint(
  val baseUrl: String,
  val apiKey: String,
  val model: String
)

fun endpointFromEnv(): LlmEndpoint =
  LlmEndpoint(
    baseUrl = System.getenv("LLM_BASE_URL") ?: "https://api.anthropic.com",
    apiKey = System.getenv("LLM_API_KEY") ?: error("LLM_API_KEY missing"),
    model = System.getenv("LLM_MODEL") ?: "claude-sonnet"
  )

你用官方直连就把 LLM_BASE_URL 指向官方;你用网关就改成网关地址。把切换成本压到"改配置",后面做降级/备线会轻松很多。

相关推荐
哪 吒1 小时前
GPT-5.4上线,编程能力超过Claude Opus 4.6
gpt·ai·chatgpt·openai·claude·gemini
北凉军1 小时前
idea无限试用30天
java·ide·intellij-idea
摘星编程1 小时前
AR 眼镜拯救社恐:我用 Kotlin 写了个拜年提词器
kotlin·ar·restful
ノBye~1 小时前
Spring的IOC详解
java·开发语言
147API1 小时前
Claude 模型选型:Opus/Sonnet/Haiku + 成本/限速预算(Kotlin)
android·开发语言·kotlin·147api
a187927218312 小时前
【教程】打通本地 IDE AI 与云端 AI 的记忆壁垒:基于 COS 的跨 AI 终端记忆共享与通信系统
人工智能·ai·ai编程·claude·mem·agents·vibe coding
代码探秘者2 小时前
【Redis】双写一致性:延迟双删 / 读写锁 / 异步通知 / Canal,一文全解
java·数据库·redis·后端·算法·缓存
6+h2 小时前
【Java】JDK、JRE、JVM三者最通俗的讲解
java·jvm·python
tsyjjOvO2 小时前
代理模式详解:静态代理、JDK 动态代理、CGLIB 动态代理
java·开发语言·代理模式