做后端这些年,我越来越觉得,凡是和 API 打交道的项目,最后都绕不开两样东西:一层中转,一把 Token。上个月帮团队搭了一个内部的中转 API 网关,把好几家模型厂商的接口统一收敛到一个入口后面,用 Token 做全链路的鉴权、续期和用量统计。踩了不少坑,也把 Token 这套机制从头到尾梳理了一遍。这篇文章就把我的理解、落地代码和排查实录一次性讲清楚,适合正在做接口聚合、API 网关或者打算统一管理大模型调用的同学参考,能帮你少走几个星期的弯路。
1. 中转 API 这个架构,到底在解决什么问题
1.1 为什么不能直接调厂商接口
很多人一开始接大模型 API,习惯在代码里直接写死各家厂商的 Base URL 和 Key。比如同时接 DeepSeek、智谱、豆包,项目里就会出现三四份环境变量,每个调用点还要单独处理不同的错误码、重试策略和计费维度。这种写法短期没问题,时间一长就暴露出三个痛点:
第一,密钥散落各处。前端若不小心把 Key 打包进产物,或者代码库被同事 push 到不规范的仓库,密钥就相当于裸奔。第二,接口风格不统一。有的厂商用 SSE 流式返回,有的返回 JSON 数组,错误码更是各写各的,业务方每次接新渠道都要做一次适配。第三,没法统一计量。各部门调了多少、超没超预算、有没有人在拿公司 Key 做非业务实验,根本查不清楚。
中转 API 层要解决的就是这三件事。它相当于一个统一的“前置代理”,上游是所有厂家接口,下游只暴露一个你自己的域名。业务方只要拿着你发的 Token 来访问,其他的事情网关全包了。
1.2 中转层比“转发”多做的那几件事
纯转发其实一个反向代理就能做,但落地之后你会发现,真正值钱的是挂在转发链路旁边的那些能力。
最常见的功能是密钥收敛。所有上游 Key 只存在网关这一层,业务侧永远只看到你自己的入口地址和 Token,厂商 Key 不落地到业务机器。其次是鉴权与计量,网关收到请求后先解析 Token,校验有效期、权限范围和频次,再决定放行还是拒绝。每放行一次,就记一条用量流水,包含调用的接口、消耗的 Token 数、所属项目。再往上是限流与熔断,比如给某个测试项目加一个每分钟 600 次的上限,厂商那边如果开始报 429 或超时,网关还能排队重试,尽量不让业务侧感知到波动。
所以这个“中转”里面,最核心的不是网络转发性能,而是转发之前那一层的规则执行能力。网络性能交给成熟的网关组件解决,而 Token 却决定了一个中转 API 能不能安全、可控地对外提供服务。
1.3 什么样的场景适合引入中转
不是所有项目都需要中转。我建议按这几个条件判断:你对接的上游 API 超过 2 家;调用方团队超过 2 个;你需要统一跟踪成本和调用量;或者你准备把能力开放给外部合作方。
满足其中两条,就有充分理由搭一个中转层。反过来,如果只是一个开发者在本地调单家 API,直接用官方 SDK 做一个小封装就够了,不要为了架构而架构。中转层本身也是要维护成本的,关键是要算出这笔账值不值。
2. Token 机制,为什么能成为中转层的核心
2.1 Cookie、Session 和 Token,它们到底差在哪
聊 Token 之前,我先讲一个经常被混淆的三者对比。Web 场景里我们见过三类凭证:Cookie、Session、Token。
Cookie 是浏览器自动携带的一个值,由服务端通过 Set-Cookie 下发,最大问题是只能由浏览器管理,移动端 App 和服务器之间的调用没法愉快地用它。Session 是服务端在内存或 Redis 里存一份会话数据,客户端只拿一个 Session ID,这种方式服务端有状态,一旦多副本部署,就要考虑 Session 同步或者共享存储。Token 则是最灵活的一种:它是一个自包含的字符串,服务端把用户身份、权限、过期时间直接编码进去,验签通过就信任,完全不需要在服务端保存会话状态。
这就是常说的“无状态认证”。接入中转 API 更看重这种特性,因为一个网关后面可能有几十个业务方,如果每个请求都要回源查一次会话状态,性能瓶颈立刻就会出现。Token 验签是纯 CPU 操作,天然适合放在网关这种高并发入口处。
2.2 JWT 的 Header、Payload 和 Signature
最常见的 Token 形态叫 JWT。它由三段组成,用两个点隔开,形如 xxxxx.yyyyy.zzzzz。
第一段 Header 是 JSON,记录签名算法和 Token 类型,通常长这样:
{ "alg": "HS256", "typ": "JWT" }第二段 Payload 是关键,里面放标准字段和自定义字段。标准字段里最常用的是iss(签发者)、sub(主题,一般是用户或项目 ID)、aud(受众,表示这个 Token 该给谁用)、exp(过期时间戳)、iat(签发时间戳)。自定义字段可以放scope、project_id、role这些。
第三段 Signature 是把前两段拼接后,用密钥或私钥计算出来的签名。签名的作用是防止有人篡改 Payload。比如有人把exp改成一年后,签名校验必然失败,因为签发方计算签名时用的不是这个被改过的内容。
一个比较重要的设计点:JWT 是 Base64 编码而非加密,Payload 里的内容任何人都能肉眼解码。所以不要往 Payload 里放密码、手机号、身份证这类敏感信息,只放 ID 和权限标记。敏感的判断让网关去查库,Token 本身只负责证明“你是谁、能干什么、到什么时候有效”。
2.3 Access Token 和 Refresh Token 的双 Token 设计
单 Token 方案有一个经典问题:为了安全要把过期时间设短,比如 2 小时;但设短后,业务方每两小时就要重新登录一次,体验很差。设长呢?泄露风险又变大。
成熟的做法是双 Token:Access Token 负责短期访问,有效期设为 30 分钟到 2 小时;Refresh Token 负责长效续期,有效期设为 7 到 30 天。Access Token 过期后,业务方拿 Refresh Token 去请求网关的续期接口,换一个新的 Access Token。这样既保证了短期凭证泄露后影响范围有限,又不必频繁打断用户操作。
中转 API 里这个设计尤其重要。业务方的后端服务是无人值守的,不可能每次 Access Token 过期都让运维手动登录。Refresh Token 可以让服务自动续期,只要在代码里加一个“检测到 401 就尝试刷新”的拦截器即可。
3. 从零到一搭建一套能上线的 Token 体系
3.1 签发 Token:选算法、定字段
签发 Token 的第一步是选签名算法。内部中转 API 最稳妥的选择是 HS256,一个共享密钥,签发和验签都在自己手里,简单高效。如果有多个独立服务都需要验签,且它们分属不同团队,那更适合 RS256,用私钥签发、公钥验签,验签方不需要拿到私钥。
然后是定义 Payload 字段。我建议最少包含这几项:
{ "iss": "my-api-gateway", "sub": "project_1024", "aud": "gateway-inner", "exp": 1735689600, "iat": 1735686000, "scope": [ "llm:deepseek:chat", "llm:zhipu:glm4" ] }scope字段推荐写成数组,网关在转发时检查请求路径对应的权限是否在 scope 内。这样同一个 Token 可以控制调用方只能访问特定的模型或接口,而不是一把钥匙开所有门。
下面是一个用 Python 签发和验签的极简参考实现,生产上建议换成更完整的密钥管理体系:
import jwt import time SECRET = "replace-with-a-strong-random-secret" def issue_access_token(project_id: str, scopes: list[str]) -> str: now = int(time.time()) payload = { "iss": "my-api-gateway", "sub": project_id, "aud": "gateway-inner", "iat": now, "exp": now + 7200, "scope": scopes, } return jwt.encode(payload, SECRET, algorithm="HS256") def verify_access_token(token: str) -> dict: try: return jwt.decode( token, SECRET, algorithms=["HS256"], audience="gateway-inner" ) except jwt.ExpiredSignatureError: raise PermissionError("token expired") except jwt.InvalidTokenError: raise PermissionError("invalid token")注意:签发时务必加上
aud,验签时也校验aud。很多人 JWT 泄露就是因为签发时没限制受众,随意一个服务都能验签通过。
3.2 登录验证与续期在实际代码里怎么落
登录验证的流程不复杂:用户提交 AK/SK 或者密码,网关验证身份后下发 Access Token 和 Refresh Token。后续每个请求都带上 Access Token,网关验签通过后放行。
续期流程我用伪代码拆一下:
def refresh_token_pair(refresh_token: str) -> dict: # 1. 先校验 refresh token 是否合法且未过期 claims = verify_refresh_token(refresh_token) # 2. 判断是否在吊销名单中 if is_revoked(claims["token_id"]): raise PermissionError("refresh token revoked") # 3. 签发新 access token,并把旧的 refresh token 吊销,轮换新的 refresh token new_access = issue_access_token(claims["sub"], claims["scope"]) revoke(claims["token_id"]) new_refresh = issue_refresh_token(claims["sub"], claims["scope"], max_age_days=7) return {"access_token": new_access, "refresh_token": new_refresh}这里有一个很多教程不会强调的点:Refresh Token 要不要轮换?我的答案是要。每次刷新时,把旧 Refresh Token 吊销,签一个新的。好处是如果 Refresh Token 泄露了,攻击者只能用到下一次刷新为止;同时能明显减少长期有效凭证的数量。代价是每次刷新都要查一次吊销名单,但这笔开销完全值得。
吊销名单建议用 Redis 的 SET 存,Key 为 Token ID,过期时间设为该 Token 的剩余有效期。查询时先查缓存,再验签,能挡住绝大部分已吊销的请求。
3.3 存储、时钟和安全逃不掉的几个细节
Token 相关的安全细节里,我最想提醒的是“时钟偏移”。JWT 的exp校验依赖服务端时间,如果签发 Token 的机器和验签的机器时间差超过几十秒,业务方就会遇到“明明没过期却报了过期”的诡异问题。生产环境全部统一用 NTP 同步,并在验签时允许一个小的时钟偏移量,比如 30 秒,但不要把偏移量调得太大,否则等于给 Token 延寿。
关于客户端存储:浏览器端建议放内存而不是 localStorage,避免 XSS 直接把 Token 拿走;移动端放系统安全存储;后端服务之间调用,Token 放环境变量或密钥管理服务,不进代码仓库。
关于服务端存储:JWT 本身不存服务端,但 Refresh Token 的吊销名单、Token 与项目 ID 的绑定关系需要存储。我建议 Access Token 不落库,Refresh Token 只存哈希值,防止数据库泄露后攻击者直接拿 Hash 去换新 Token。泄露的 Hash 没有密钥也签不出合法凭证。
还有一个容易被忽略的点:日志脱敏。任何层面都不要把明文 Token 打进日志。有一次线上排查问题,我搜网关日志,发现 Access Token 完整出现在响应体日志里,这意味着任何能读日志的人都能冒充调用方。立刻把日志模块改了,统一把 Authorization 头替换成截断后的摘要。
4. 高效调用,不止是“把请求发出去”
4.1 调用量、Token 用量和 Prompt Token
中转 API 的价值之一是用量计量。对大模型接口来说,计费单位不是次数,而是 Token。一次请求可能返回几百 token,也可能上万,具体取决于输入输出的长度。
用量统计至少要区分三类:请求次数、输入 token 数(Prompt Token)、输出 token 数(Completion Token)。不同的模型计费规则不同,比如 DeepSeek、智谱这类模型,输入和输出单价往往不一样,有的还区分缓存命中与未命中。所以网关在记录流水时,要把厂商响应里的prompt_tokens、completion_tokens都截获下来再入库。
有了用量数据才能做预算控制。我见过太多项目月底被多张大额账单吓到,原因就是没有按项目维度设置配额。网关里面应该维护一个“项目-模型-时间段”的额度表,超了就返回 429 或 402。这里有一个实用技巧:不要在请求进来时才查配额,那样会有并发穿透;应该把配额扣减放到异步队列里,网关只做预检查,真正扣减由消费 Kafka 或 Redis 流的程序来做,既能保证性能又能保证最终一致性。
4.2 限流、熔断和重试策略
限流这个环节,巧妙的地方在于它用的算法也叫令牌桶。这个“令牌”和我们的 Access Token 概念不是一回事,但思路类似:桶里放令牌,请求来了取一个,取不到就等或者直接拒绝。令牌按固定速率生成,桶满则不再增加,这样既能应对突发流量,又能保证长期平均速率。
中转 API 建议做两层限流。第一层在入口,按调用方的 Token 维度限流,防止某个业务方真实流量把自己打爆。第二层在出口,按上游厂商维度限流,防止上游给我们的整体配额被打爆。两层限流配合,任何一层出问题都不会直接打到厂商那边。
超时和重试也必须有。给上游调用设置三个关键数值:连接超时 3 秒、读取超时 60 秒、总超时 120 秒。重试只在特定错误码下进行,比如 429、502、503,而对 400、403 这类确定性错误不要重试,重试只会放大错误。重试要带退避,第一次等 200 毫秒,第二次 500 毫秒,第三次 1 秒,超过三次直接熔断。
4.3 大模型接口最容易踩的 1048576 Token 坑
热搜词里出现了一条非常典型的报错:api error: 400 this model's maximum context length is 1048576 tokens. however, you requested ... tokens。这是把输入内容塞太多,超出了模型的上下文窗口。
处理方法我之前系统整理过,核心思路有三条。第一,截断与摘要。对超长文档,先做摘要,把摘要结果塞进上下文,完整文档放到检索库里按需取。第二,RAG 化。不要试图把整份知识库塞给模型,而是先检索出最相关的几个片段再拼接。第三,分治拆解。如果任务是对超长内容做分析,就先拆成多个子任务分别调用,最后再汇总。
这些处理不光能避免报错,还能显著降低成本。毕竟 Token 是按量计费的,每减少一次超长输入,省下的都是真金白银。我在网关日志里做过统计,优化前不少项目单次请求干了一万多输入 token,优化后降到了三千以内,账单直接缩了一半。
5. 常见故障排查与避坑实录
5.1 token exchange failed 系列错误怎么查
热词里反复出现sign-in could not be completed token exchange failed: token endpoint returned status 403。这类问题集中出现在使用 AI 编程助手或第三方 SDK 登录时,本质是 OAuth 授权码换 Token 的环节失败。
排查第一步是看状态码。403 表示授权服务器拒绝了这次换发请求,原因通常是回调地址与注册的不一致、授权码已过期、应用未通过审核,或者来源区域不在允许范围内。第二步是检查回调地址。很多 SDK 内置回调地址,而你在平台注册 Callback 时填了别的值,两边对不上就会 403。第三步是看刷新令牌状态。token exchange failed: error sending request说明网关到授权服务器的网络请求本身失败了,优先检查网络、代理、证书和防火墙,与 Token 本身无关。
我把这些常见情况整理成了速查表:
| 报错特征 | 常见原因 | 排查方向 |
|---|---|---|
| 403 forbidden | 回调地址不一致、授权码过期、区域策略 | 核对回调地址、重新发起授权、确认部署区域 |
| 400 invalid refresh_token | Refresh Token 为空、被吊销、格式错误 | 检查刷新请求参数、确认凭证有效、重新登录 |
| error sending request | 网络不通、证书失效、域名解析失败 | 检查网络连通性、证书链、DNS 解析 |
| access token could not be refreshed | 账号在其他设备登出、刷新令牌已吊销 | 引导用户重新授权登录 |
| organization has been disabled | 组织账户被停用或欠费 | 联系平台管理员续费/恢复组织 |
5.2 invalid refresh_token 为空的诡异案例
failed to refresh token: 400 bad request: invalid 'refresh_token': empty string这条报错,字面意思是刷新请求里没带上 refresh_token。我遇到过最典型的一种情况:前端发起刷新请求时,把refresh_token放在了请求体里,但网关框架用的解析规则是只读表单字段,前端发的是 JSON,两边字段没对上。
还有一种情况是本地存 Refresh Token 时用了稍有不同的 Key,同一次登录写进去和读出来路径不一致。排查时不要一上来就怀疑后端逻辑,先打印请求落地的参数结构。另一个容易忽视的点是大小写,refresh_token和refresh-Token被当成两个完全不同的字段,也是常见老坑。
5.3 权限声明缺失、Scope 不匹配和 Docker API 问题
报错api scope is not declared in the privacy agreement通常出在第三方应用申请权限时,开发者声明的权限范围和平台审核通过的不一致。解决方法是重新申请目标权限,并把这几个 Scope 的用途写清楚再提交审核。对于自建中转 API,逻辑上是等价的:调用方 Token 里的 scope 不包含目标接口所需权限,网关返回 403。设计时建议在错误响应体里带上required_scope字段,直接告诉调用方你缺哪个权限,能省一大半工单沟通。
另外一条和容器场景相关的报错permission denied while trying to connect to the docker api也值得一提。这不是 HTTP API 的问题,而是本地 Docker socket 权限不足。解决方式是把当前用户加入 docker 组,或者调整 Docker context 指向正确的远程端点。它和 Token 没有直接关系,但经常出现在同一批开发者的日常工作流里,放一起记录便于排查。
5.4 几个被严重低估的“隐形坑”
排查 Codex 类工具登录失败时,有一条非常坑的原因:系统时间不准。JWT 和 OAuth 都强依赖时间,本机时间偏差超过几分钟,平台会认为 Token 已过期或未生效。遇到这类问题先校准系统时间,往往秒好。
另一个隐形坑是多个账号同时登录。如果你是先用公司账号登录,后来切到个人账号,前一个账号的 Access Token 被吊销,此时刷新 token 就会返回logged out相关的错误。排查时优先看当前登录会话是谁,不要盯着 Token 本身改一天。
还有一个我觉得业内默认都知道但文档很少写的点:Token的aud字段和网关路由不匹配时,SDK 会报非常误导人的“验签失败”。因为很多 SDK 验签时报错信息是统一的Invalid token,不会暴露是 audience 不匹配。遇到这种问题,把你生成 Token 时的aud和网关验签配置的aud拉出来对比,10 分钟就能定位。
6. 实践心得和最后想说的几个建议
以前我总觉得 Token 是一个再基础不过的概念,直到亲手做中转 API 层,才意识到这里面细节极多。最重要的心得是:不要把 Token 当成一个“字符串”来对待,要当成一套生命周期制度。签发、校验、续期、吊销、审计,每一环都要有明确策略。尤其是吊销,很多团队嫌麻烦直接省略,线上出了安全问题又只能改密钥全量重签,付出的代价比当初实现吊销名单大得多。
另外一个值得坚持的习惯是,所有跟 Token 相关的异常,在网关里都要有独立的错误码和日志标记。比如ERR_TOKEN_EXPIRED、ERR_TOKEN_REVOKED、ERR_SCOPE_MISMATCH,不要一律返回 401 就完事。没有区分度的错误码会让下游排查非常痛苦,也会让你的工单量暴涨。
如果这篇文章只留一句话,我会说:中转 API 让接口收敛到一处,而 Token 让这处入口变得安全、可控、可计量。把这个逻辑想透了,剩下的都是工程细节,按流程来,稳得很。