1. 从一次 Django 429 说起:把 Claude 调用收口到 TaoToken
上周线上一个 Django 服务突然开始刷 429。日志长这样:anthropic.RateLimitError: Error code: 429 - {'type': 'rate_limit_error'},接口是同步的,QPS 一上来就整片红。排查下来不是 Django 本身的问题,也不是 Gunicorn worker 不够,而是调用链上游的限流被我们自己的重试逻辑放大了——每次 429 之后立刻重试,重试又打满,最后雪崩。
这类问题的解法通常分两层:一层是客户端要有正确的退避与并发闸门,另一层是把模型调用的出口统一到一个可控的入口,让 base_url、Key、模型名这三样东西集中管理。我这次选的是把 Claude 调用的 base_url 切到 TaoToken,Key 从 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=django_429_intro 获取,后面所有工具的出口都指向 https://taotoken.net/api 。顺带说一句背景:最近围绕 Claude 调用与模型蒸馏的行业讨论很热,但对后端工程师来说,真正影响发版的是「请求发去哪、Key 怎么轮换、429 怎么退避」,这篇就只讲这些能落地的东西。
本文的读者画像很明确:你是一个 AI 应用后端工程师,用 Django 写业务,用 Claude Code 写代码,可能还用 Codex 或 CC Switch 管多个供应商。你每天的痛点是 Token 在烧、429 在报、配置散落在四五个文件里。下面按「定位 → 取 Key → 改 Django → 改 Claude Code → 改 Codex → CC Switch 三件套 → 上线检查」的顺序走一遍,每段都有可复制的配置。
2. 先定位 429 到底是谁发出来的
在改任何配置之前,必须先确认 429 的来源。Claude 调用链上至少有三处可能限流,处理方式完全不同。
第一处是上游供应商侧限流。典型特征是响应体里带rate_limit_error,并且可能带retry-after头。这种只能退避,不能硬扛。
第二处是客户端并发失控。Django 里如果用asyncio.gather或线程池并发打模型接口,短时间内请求数会远超你的预期。这种情况表现为「明明 QPS 不高,但一有批量任务就 429」。
第三处是本地网关或代理层限流。如果你的服务前面有 Nginx、Kong、或者公司自建网关,limit_req配置过紧也会返回 429,但响应体通常不是 JSON 结构。
区分方法很简单:先用 curl 单发一条请求,绕开 Django。能通就说明不是 Key 或 base_url 的问题,问题在客户端并发或本地网关。
curl -sS -o /tmp/resp.json -w "%{http_code}\n" \ https://taotoken.net/api/v1/messages \ -H "x-api-key: YOUR_API_KEY" \ -H "anthropic-version: 2023-06-01" \ -H "content-type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "max_tokens": 128, "messages": [{"role": "user", "content": "ping"}] }'拿到状态码后,对照下面这张表判断该改哪里。这张表建议直接贴进你的排障手册。
| HTTP 状态 | 典型响应体片段 | 含义 | 对应动作 |
|---|---|---|---|
| 429 | rate_limit_error | 触发 RPM / TPM 上限 | 读retry-after,指数退避 + 抖动 |
| 401 | authentication_error | Key 无效或未带 | 检查 Key 是否有多余空格、是否用对 header |
| 403 | permission_error | 当前 Key 无该模型权限 | 换模型或调整 Key 的模型范围 |
| 404 | not_found_error | base_url 或路径拼错 | 确认 base_url 为https://taotoken.net/api,不要手写/v1 |
| 400 | invalid_request_error | 参数非法,常见是max_tokens超限 | 校验请求体字段 |
| 500 / 529 | overloaded_error | 上游过载 | 指数退避,不要立即重试 |
| 无状态码 | ConnectTimeout | 出口不通或 DNS 异常 | 检查出网策略与域名解析 |
这里有个高频坑:Anthropic SDK 会自己在 base_url 后面拼/v1/messages。所以你在配置里写https://taotoken.net/api,最终请求的是https://taotoken.net/api/v1/messages。如果你写成https://taotoken.net/api/v1,就会变成/api/v1/v1/messages,直接 404。这个错误在从其他供应商迁移过来时特别容易犯。
3. 取 Key 与统一出口:先把环境变量定下来
确认要换出口之后,第二步是把凭据准备好。打开 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=django_key_prepare ,控制台里可以创建用于服务端的 API Key,创建入口在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=django_key_create 。Key 只在创建时完整展示一次,建议直接写进你的密钥管理系统,而不是.env里长期躺着。
拿到 Key 之后,先在项目里固定三个变量,避免后面每个文件各写一套:
export TAOTOKEN_BASE_URL="https://taotoken.net/api" export TAOTOKEN_API_KEY="YOUR_API_KEY" export TAOTOKEN_MODEL="claude-sonnet-4-20250514"服务端不要用ANTHROPIC_*命名给业务代码,原因后面 Codex 那节会讲:ANTHROPIC_*是 Claude Code 这类 CLI 工具认的前缀,业务代码用同名变量容易在本地开发时被 shell 环境污染,出现「本地能跑、容器里 401」的诡异现象。业务侧统一用TAOTOKEN_*,CLI 工具侧单独配,两边隔离。
如果你是在 K8s 里跑 Django,建议用 Secret 挂载:
apiVersion: v1 kind: Secret metadata: name: taotoken-secret type: Opaque stringData: TAOTOKEN_BASE_URL: "https://taotoken.net/api" TAOTOKEN_API_KEY: "YOUR_API_KEY" TAOTOKEN_MODEL: "claude-sonnet-4-20250514"Django 的settings.py里只做一次读取,不要在每个 view 里os.getenv:
# settings.py import os TAOTOKEN_BASE_URL = os.environ.get("TAOTOKEN_BASE_URL", "https://taotoken.net/api") TAOTOKEN_API_KEY = os.environ.get("TAOTOKEN_API_KEY", "YOUR_API_KEY") TAOTOKEN_MODEL = os.environ.get("TAOTOKEN_MODEL", "claude-sonnet-4-20250514") # 并发闸门与重试参数,按你的配额调整 TAOTOKEN_MAX_CONCURRENCY = int(os.environ.get("TAOTOKEN_MAX_CONCURRENCY", "4")) TAOTOKEN_MAX_RETRIES = int(os.environ.get("TAOTOKEN_MAX_RETRIES", "4")) TAOTOKEN_BACKOFF_BASE = float(os.environ.get("TAOTOKEN_BACKOFF_BASE", "0.8"))TAOTOKEN_MAX_CONCURRENCY这个参数是治 429 的关键。很多团队的 429 不是配额小,而是并发没有上限。把它设成一个显式数字,比事后加监控有效得多。
4. Django 侧改造:base_url 收口与 429 退避
现在进入正题。Django 里调用 Claude 有两种常见写法,分别是直接用 Anthropic SDK 和用 httpx 手写。两种都给出可运行示例,你按项目现状选。
先说 SDK 方式。注意base_url只写到/api,不要带/v1:
# services/llm_client.py from anthropic import Anthropic from django.conf import settings _client = None def get_client() -> Anthropic: global _client if _client is None: _client = Anthropic( base_url=settings.TAOTOKEN_BASE_URL, # https://taotoken.net/api api_key=settings.TAOTOKEN_API_KEY, max_retries=0, # 重试自己控制,见下文 timeout=30.0, ) return _client def chat(prompt: str) -> str: resp = get_client().messages.create( model=settings.TAOTOKEN_MODEL, max_tokens=1024, messages=[{"role": "user", "content": prompt}], ) return "".join(block.text for block in resp.content if block.type == "text")关键点是max_retries=0。SDK 内置重试是「立即重试 + 少量退避」,在 429 场景下不够。把重试权收回来自己写,才能加并发闸门和抖动。
重试与闸门的实现如下。这里用BoundedSemaphore控并发,用带抖动的指数退避处理 429。抖动的意义在于避免多个 worker 在同一时刻一起重试,形成第二波流量高峰:
# services/retry.py import random import time from threading import BoundedSemaphore from anthropic import APIStatusError, RateLimitError from django.conf import settings _gate = BoundedSemaphore(settings.TAOTOKEN_MAX_CONCURRENCY) def _sleep_backoff(attempt: int, retry_after: float | None = None) -> None: if retry_after is not None: time.sleep(retry_after) return base = settings.TAOTOKEN_BACKOFF_BASE delay = min(base * (2 ** attempt), 20.0) # full jitter:在 [0, delay] 内随机 time.sleep(random.uniform(0, delay)) def call_with_retry(fn, *args, **kwargs): last_exc = None for attempt in range(settings.TAOTOKEN_MAX_RETRIES): with _gate: try: return fn(*args, **kwargs) except RateLimitError as exc: last_exc = exc retry_after = None resp = getattr(exc, "response", None) if resp is not None: raw = resp.headers.get("retry-after") if raw: try: retry_after = float(raw) except ValueError: retry_after = None _sleep_backoff(attempt, retry_after) except APIStatusError as exc: # 5xx / 529 才重试,4xx 直接抛出 if exc.status_code in (500, 502, 503, 504, 529): last_exc = exc _sleep_backoff(attempt) else: raise raise last_exc这段代码有两个容易被忽略的细节。第一,只有 429 和 5xx 才重试,400/401/403/404 立即抛出,否则会把配置错误掩盖成「偶发失败」。第二,retry-after优先于自算退避,因为上游给的等待时间通常更准确。
如果你不想引入 SDK,用 httpx 也一样。手写的好处是便于统一加 trace header,方便你在日志里把 Django 请求和模型请求串起来:
# services/http_client.py import httpx from django.conf import settings def chat_via_http(prompt: str, request_id: str) -> str: url = f"{settings.TAOTOKEN_BASE_URL}/v1/messages" headers = { "x-api-key": settings.TAOTOKEN_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", "x-request-id": request_id, } payload = { "model": settings.TAOTOKEN_MODEL, "max_tokens": 1024, "messages": [{"role": "user", "content": prompt}], } with httpx.Client(timeout=30.0) as client: resp = client.post(url, headers=headers, json=payload) resp.raise_for_status() data = resp.json() return "".join( block.get("text", "") for block in data.get("content", []) if block.get("type") == "text" )如果你在 Django 里用 async view,那就把httpx.Client换成httpx.AsyncClient,同时把信号量换成asyncio.Semaphore。注意不要在线程池里混用 asyncio 信号量,那等于没有闸门。
最后,记得在 Django 的日志配置里把模型调用的关键字段打出来,否则下次 429 你还是只能靠猜:
LOGGING = { "version": 1, "disable_existing_loggers": False, "handlers": {"console": {"class": "logging.StreamHandler"}}, "loggers": { "llm": {"handlers": ["console"], "level": "INFO"}, }, }在call_with_retry里加上logger.info("llm_call attempt=%s model=%s", attempt, settings.TAOTOKEN_MODEL)。有了 attempt 计数和 request_id,你就能一眼看出 429 是瞬间打满还是持续超配额。
5. Claude Code 改供应商:settings.json 与 ANTHROPIC_*
Django 服务改完了,接下来是本地开发链路。Claude Code 读的是ANTHROPIC_*前缀的环境变量,这是它和业务代码必须分开配置的根本原因。
最省事的方式是写进~/.claude/settings.json,这样不用每次开终端都 export:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", "ANTHROPIC_MODEL": "claude-sonnet-4-20250514", "ANTHROPIC_SMALL_FAST_MODEL": "claude-sonnet-4-20250514", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" } }三个字段的含义要分清:ANTHROPIC_BASE_URL决定请求出口,ANTHROPIC_AUTH_TOKEN是凭据,ANTHROPIC_MODEL决定主模型。ANTHROPIC_SMALL_FAST_MODEL用于后台小任务,也建议一起指过去,否则某些版本会尝试用默认模型发起请求,出现「主流程能通、补全报 401」的割裂现象。
如果你更习惯用 shell 配置,等价的写法是:
export ANTHROPIC_BASE_URL="https://taotoken.net/api" export ANTHROPIC_AUTH_TOKEN="YOUR_API_KEY" export ANTHROPIC_MODEL="claude-sonnet-4-20250514"改完之后做一次自检,确认 Claude Code 真的走了新出口:
claude --version env | grep -E "^ANTHROPIC_(BASE_URL|MODEL)"如果启动时报 401,按这个顺序查:先确认变量名是ANTHROPIC_AUTH_TOKEN而不是ANTHROPIC_API_KEY(不同版本对这两个的优先级处理不一致);再确认值里没有换行符或首尾空格;最后确认ANTHROPIC_BASE_URL没写成https://taotoken.net/api/v1。
关于 Claude Code 的更多参数说明和供应商配置细节,可以对照官方文档页面 https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=django_claude_code_doc 核对,避免照着旧版博客抄了已废弃的字段。
6. Codex 用 config.toml:不要把 ANTHROPIC_* 套过来
这一节是踩坑重灾区。Codex 走的是 OpenAI 兼容协议,配置读的是~/.codex/config.toml,不是settings.json,也不认ANTHROPIC_*环境变量。把 Claude Code 那套变量复制到 Codex 上,结果一定是连不上。
正确的写法是声明一个自定义 provider,把base_url指向 TaoToken:
# ~/.codex/config.toml model = "gpt-5" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api/v1" env_key = "TAOTOKEN_API_KEY" wire_api = "chat"三个字段需要特别注意。base_url这里要带/v1,因为 Codex 走的是 OpenAI 兼容的/v1/chat/completions路径,它不会像 Anthropic SDK 那样自动补/v1。env_key指向的是环境变量名而不是 Key 本身,所以还需要:
export TAOTOKEN_API_KEY="YOUR_API_KEY"wire_api = "chat"表示使用 chat completions 协议。如果你的模型走 responses 协议,改这个值即可,但不要和base_url的路径混着改,否则会出现 404。
验证 Codex 是否生效:
codex --version printenv TAOTOKEN_API_KEY | head -c 8输出的前 8 位字符应该和你在控制台看到的 Key 前缀一致。这一步能排除「环境变量没进到 Codex 进程」这类问题,尤其在 macOS 上用 GUI 启动终端时很常见。
7. CC Switch 三件套:一次切换,别改四个文件
如果你同时用 Claude Code、Codex 和业务代码,手动维护三套配置迟早出错。CC Switch 这个概念的核心是把配置收敛成三件套:供应商(Base URL)、凭据(API Key)、模型(Model)。切换供应商时只改这三项,其余保持不动。
三件套映射到各工具的关系如下:
| 项目 | 值 | Claude Code | Codex | Django 业务 |
|---|---|---|---|---|
| 供应商 | https://taotoken.net/api | ANTHROPIC_BASE_URL | base_url(带/v1) | TAOTOKEN_BASE_URL |
| 凭据 | YOUR_API_KEY | ANTHROPIC_AUTH_TOKEN | env_key指向的变量 | TAOTOKEN_API_KEY |
| 模型 | claude-sonnet-4-20250514 | ANTHROPIC_MODEL | model | TAOTOKEN_MODEL |
维护这套表的时候遵守两条规则。第一条,路径层级不要互相抄:Anthropic 系写到/api,OpenAI 兼容系写到/api/v1。第二条,Key 只在一处定义,其他位置引用变量名。如果某天需要轮换 Key,你只改一个 Secret,三个工具同时生效。
CC Switch 的实际操作流程可以按这个顺序走:先在控制台创建新 Key(https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=django_ccswitch_key ),然后把新值写入你的密钥管理,接着更新~/.claude/settings.json与~/.codex/config.toml,最后重启终端并跑一次上面的自检命令。整个过程不需要动 Django 代码,因为 Django 读的是环境变量。
有一个细节值得单独提醒:不要在.bashrc里同时 exportANTHROPIC_BASE_URL和业务用的TAOTOKEN_BASE_URL之外,还保留旧的供应商变量。残留的旧变量会按 shell 加载顺序覆盖新值,表现为「明明改了 settings.json,但 Claude Code 还是连到旧地址」。清理时用env | grep -iE "anthropic|openai|taotoken"过一遍,把不认识的删掉。
8. 上线前的检查清单
改完配置不等于改完行为。下面这份清单建议在发版前逐条过一遍,尤其是从旧供应商迁移过来的项目。
第一,确认 base_url 层级正确。业务侧https://taotoken.net/api,Codex 侧https://taotoken.net/api/v1,两者不要搞反。
第二,确认重试只覆盖 429 和 5xx。用一次故意的错误 Key 跑单测,确认 401 会立即抛出而不是重试四次,否则线上排障会被重试日志淹没。
第三,确认并发闸门生效。写一个并发 20 的压测脚本打模型接口,观察日志里的 attempt 分布。如果 attempt 全是 0,说明没有触发限流;如果 attempt 集中在 2-3,说明闸门开得太大,需要下调TAOTOKEN_MAX_CONCURRENCY。
第四,确认 Token 消耗有观测。至少记录每次调用的输入输出字符数与模型名,否则你无法判断 429 是并发问题还是单次请求过大。长上下文请求在 TPM 口径下消耗远超预期,这类 429 靠退避解决不了,只能拆请求或改模型。
第五,确认 Key 没有硬编码进代码仓库。用grep -r "YOUR_API_KEY" --include="*.py" --include="*.json" --include="*.toml"扫一遍,凡是命中真实 Key 的地方全部改成环境变量引用。
第六,确认本地与线上一致。很多人本地跑通就发版,结果线上 404,原因是线上容器里TAOTOKEN_BASE_URL写的是带/v1的旧值。把这条写进 CI 的环境变量校验里。
把这份清单跑完,Django 的 429 通常会从「随机爆发」变成「可预期的退避」,这是排障该有的终点。
9. 下一步:把出口和 Key 都收口
回头看这次排障,真正的收益不是「把 429 压下去了」,而是把散落在 Django、Claude Code、Codex 三处的模型出口收口成同一套三件套。以后换供应商、轮换 Key、调整模型,改动量从「四个文件加一个 Secret」降到「一个 Secret 加一次重启」。
如果你现在正准备做迁移,建议按这个路径走一遍:先在 https://taotoken.net/models/detail/chat?utm_source=taotoken_aicg_blog_end&utm_content=django_cta_chat 用模型对话页面验证 Key 与模型名能通,再根据用量选择 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=django_cta_plan 里合适的方案,然后在 https://taotoken.net/console/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=django_cta_keys 创建服务端专用 Key,最后照着 https://taotoken.net/doc/ClaudeCodeAnthropic?utm_source=taotoken_aicg_blog_end&utm_content=django_cta_doc 把 Claude Code 的settings.json对齐。业务代码那边只需要记住一件事:base_url写https://taotoken.net/api,Key 用YOUR_API_KEY占位,其余交给环境变量。