摘要: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-After、X-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 实战:读响应头 + 指数退避 + 令牌桶(可直接抄)
下面这套代码分三块:
- 解析
retry-after和anthropic-ratelimit-*头 - 429 专用的指数退避 + 抖动
- 一个简单令牌桶:既能控"请求速率",也能扩展成"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,不要先改提示词,先按这张表排:
- 看响应头:有没有
retry-after,有没有 requests/input/output remaining - 分清触发维度:RPM 还是 ITPM/OTPM
- 先削峰:并发上限 + 令牌桶 + 抖动退避
- 再治本:上下文拆分、检索 topK、提示缓存、分段输出
- 最后再谈模型: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 指向官方;你用网关就改成网关地址。把切换成本压到"改配置",后面做降级/备线会轻松很多。