
做后端接口、做小程序、做 AI 应用的朋友这几年大概都有同一个感受token 这个词已经变成跨技术栈的“万能高频词”了。写鉴权的同学每天都在跟 access token 过期、refresh token 续签打架写小程序的同学绕不开拿 code 去换 token调大模型接口的同学每个月看账单的时候盯着 token 用量发愣。同一个词在三拨人嘴里说的完全不是一件事这也是大量沟通事故和线上故障的源头——有人问“token 怎么没了”你根本不知道他说的是登录态掉了还是额度烧完了。这篇东西就是把 token 这个横跨鉴权、平台集成、大模型计费三条线的东西按我自己的实际使用顺序捋一遍。我会先讲清楚不同语境下的 token 到底指什么再拆鉴权体系里的双 token 续签怎么做才不出坑然后是代码级的实操接着是大家最关心的 token 用量与成本怎么估、怎么省最后把我自己踩过的 401、400、token 换不到这几类故障整理成排查表。适合刚接手登录模块的新人也适合已经在做 AI 应用、想把账单压下来的老手代码都是可以直接抄去改的。1. Token到底是什么先把三种完全不同的东西拆开1.1 同名不同义鉴权令牌、访问凭证、计量单位先把最容易混淆的地方说死。日常语境里的 token至少有三层含义它们之间没有任何技术继承关系只是名字撞车了。第一类是鉴权令牌出现在登录、接口调用场景。它是一串服务端签发的字符串客户端每次请求带上它服务端验证它来确认“你是谁、你有没有过期”。这类 token 的核心特征是有时效、可验证、可撤销。你遇到的“token 失效”“token is invalid”说的都是它。第二类是平台访问凭证也就是 Personal Access Token、API Token、App Token 这一类。它更像是“长期通行证”比如你在代码托管平台、设计协作平台、CI 系统里生成的那串个人令牌用来让脚本或第三方工具以你的身份调用接口。它的特征是权限范围可裁剪、生命周期长、泄露后果严重。很多人排查“login failed. check api token”的时候其实是在这一层翻车。第三类是大模型计量单位。模型处理文本不是按字算的而是先做分词切成一个个 token输入和输出都按 token 数量计费。这里 token 是“计价粒度”跟安全没有半毛钱关系。你说的“token 用量”“token 收费标准”“1 亿 token”都在这一层。提示跟人沟通时先说清是“登录态的 token”还是“计费用的 token”能省掉一半的来回扯皮。我在团队里要求写工单必须带上下文就是这个原因。1.2 为什么服务端不直接用 Session非要发 Token这是个被问烂了但确实要讲清楚的问题。传统 Session 是服务端存状态、客户端只存一个 ID请求来了拿 ID 去查存储。它的优点是撤销方便缺点是横向扩展时要解决会话共享多机房部署时更麻烦。Token 走的是另一条路把用户身份、过期时间这些信息编码进令牌本身再用密钥签名。服务端拿到 token 只要验签 检查过期时间不需要查库就能确认身份。这就是它适合无状态服务、适合多实例部署的原因。代价也很明确令牌一旦签发在过期之前服务端很难主动让它失效因为没地方记“谁被踢了”。所以纯 Token 方案必须额外引入黑名单或者版本号机制来处理“强制下线”这种需求。想明白这个取舍后面讲续签和撤销的时候就不会觉得矛盾了。1.3 JWT 只是 Token 的一种格式不是同义词很多人把 JWT 和 Token 当成一回事这是概念滑坡。Token 是“令牌”这个抽象角色JWT 是它的一种具体实现格式长这样三段 Base64 字符串用点号连接分别是头部、载荷、签名。载荷里放的是 claims常见的有sub主体一般是用户 ID、iat签发时间、exp过期时间、jti令牌唯一 ID。jti这个字段特别有用它是后面做黑名单和防重放的关键我后面会细讲。JWT 的优点是自包含、可跨语言解析、生态成熟。缺点是载荷只是 Base64 编码不是加密任何人都能解开看。所以千万别往里面塞手机号、身份证、余额这类敏感信息。我见过有人把用户完整资料塞进 JWT 发给前端这基本等于把数据挂在公告栏上。2. 双Token续签Access Token与Refresh Token的分工逻辑2.1 为什么必须拆成两个Token先说结论如果一个系统只有一个长期有效的 token那它被盗之后攻击窗口就是“直到你手动改密码”如果只有一个短期 token 且没有续签机制那用户就会每 15 分钟被踢出去重新登录一次。这两个极端都不能接受所以业界通行做法是拆成一对。Access Token短命通常 15 分钟到 2 小时权限大、使用频率高每次业务请求都带它。它过期快即使泄露攻击窗口也有限。Refresh Token长命通常 7 天到 30 天权限极小只有一个用途去换新的 access token。它使用频率低——绝大多数时候躺在客户端存储里不动。正因为用得少服务端可以对它做更严格的管控绑定设备指纹、绑定 IP 段、一次性使用、发现复用就整族吊销。我个人的经验参数是面向 C 端的 App 和 Webaccess token 给 30 分钟refresh token 给 14 天并且 refresh token每次刷新都轮换旧的立刻作废发新的。这个轮换策略是防重放的核心后面第 5 节讲 400 报错时会用到。2.2 三种续签方案滑窗续期、双Token轮换、黑名单吊销第一种是滑窗续期。服务端在验证 access token 的时候如果发现它已经过期但还在“宽限期”内比如过期 5 分钟内就自动签发一个新的塞回响应头。实现简单但客户端要处理“响应头里冒出新 token”这种隐式行为调试起来很烦我不太推荐。第二种是双 Token 轮换也就是上面讲的方案目前最主流。客户端发现 401就拿 refresh token 去换一对新的。这个方案的难点全在客户端并发请求同时拿到 401 时不能同时发 5 个刷新请求否则会造成 refresh token 轮换冲突第 5 个请求用已经被作废的 refresh token直接被踢下线。这就是为什么刷新请求必须加“单飞”锁。第三种是黑名单吊销。用户改密码、管理员踢人、检测到 refresh token 被复用时把相关jti丢进 Redis 黑名单TTL 设成该 token 的剩余有效期。这样既不破坏无状态又能实现主动失效。注意 TTL 一定要跟着原 token 走否则黑名单会无限膨胀。2.3 签名算法、时钟偏移与并发冲突的隐藏坑签名算法上HS256对称密钥和 RS256非对称密钥都常见。单体服务用 HS256 足够多服务共享验签时 RS256 更合适——私钥只留在签发服务其他服务拿公钥验签密钥泄露面小很多。时钟偏移是个容易忽略的坑。JWT 验证依赖系统时间如果签发服务和验证服务的时间差了几分钟就会出现“刚签发就过期”或者“明明过期了还能用”的诡异现象。解决办法是在验证时给一点容差PyJWT 里就是leeway参数给 30 到 60 秒比较稳。并发冲突更常见。用户快速点几下按钮前端发了一批请求全都因为 access token 过期返回 401然后同时触发刷新。如果没有做请求排队你会看到日志里一堆 refresh 失败用户被莫名其妙踢到登录页。这个问题的修复代码我在第 3 节会给。3. 手把手落地从签发到无感刷新的完整链路3.1 服务端签发把 jti 和 typ 都用上先看签发逻辑。下面的 Python 示例基于 PyJWT重点是三个字段jti用来做黑名单和复用检测typ区分 access 和 refresh防止拿 refresh token 直接调业务接口exp控制时效。import os, time, uuid import jwt SECRET os.environ[JWT_SECRET] ALG HS256 def issue_access_token(user_id: int, ttl: int 1800) - str: now int(time.time()) payload { sub: str(user_id), iat: now, exp: now ttl, jti: uuid.uuid4().hex, typ: access, } return jwt.encode(payload, SECRET, algorithmALG)refresh token 我不建议直接发给客户端一个无状态 JWT而是签发一个随机串服务端存映射关系。原因是 refresh token 必须能一次性作废无状态 JWT 做不到这点。存储结构大致是这样def issue_refresh_token(redis, user_id: int, ttl: int 14 * 24 * 3600) - str: raw uuid.uuid4().hex uuid.uuid4().hex # 64 位随机串 key frt:{raw} redis.hset(key, mapping{uid: user_id, gen: 1}) redis.expire(key, ttl) return raw注意这里的gen字段它是“代际号”。每次刷新时gen 1如果同一个 refresh token 被用了第二次服务端发现它的代际对不上就可以判定为泄露把该用户这一代所有 refresh token 全部吊销。这个技巧是防重放里性价比最高的。3.2 客户端无感刷新单飞锁加请求重放前端最容易写错的地方就是刷新并发控制。下面这段 axios 拦截器的核心是那个refreshing变量它保证同一时刻只有一个刷新请求在飞其他请求挂起等待拿到新 token 后自动重放。let refreshing null; axios.interceptors.response.use( res res, async (error) { const { response, config } error; if (response?.status ! 401 || config._retry) { return Promise.reject(error); } config._retry true; if (!refreshing) { refreshing axios .post(/api/token/refresh, { refresh_token: getRefreshToken() }) .then(r { setAccessToken(r.data.access_token); setRefreshToken(r.data.refresh_token); return r.data.access_token; }) .catch(err { clearAllToken(); redirectToLogin(); throw err; }) .finally(() { refreshing null; }); } const newToken await refreshing; config.headers.Authorization Bearer ${newToken}; return axios(config); } );两个细节要盯住。第一config._retry标记必不可少否则刷新接口本身返回 401 时会触发无限递归。第二刷新接口走的是裸 axios 实例还是同一个实例如果走同一个实例它的 401 又会进拦截器所以刷新请求通常要单独用一个不挂拦截器的实例或者在拦截器里显式放行。注意exp到期前主动刷新比“撞 401 再刷新”体验好得多。可以在内存里解码 access token 的 exp提前 60 秒静默刷新用户完全无感。3.3 小程序场景code 换 token 千万别直接下发 session_key小程序登录有个固定套路客户端调wx.login()拿 code把 code 发给自己的后端后端拿 code 去换 openid 和 session_key。这里的坑集中在两点。第一code 是一次性的且有效期只有 5 分钟后端拿到 code 必须立刻去换不能排队。第二session_key 绝对不能下发给客户端它是用来解密用户信息的密钥泄露就等于用户信息裸奔。正确做法是后端拿到 openid 后走自己的签发逻辑发一对业务 token 给小程序session_key 只留在服务端。// 后端示例用 code 换 openid然后签发自己的 token const resp await axios.get(https://api.weixin.qq.com/sns/jscode2session, { params: { appid: APPID, secret: APPSECRET, js_code: code, grant_type: authorization_code } }); if (resp.data.errcode) { // 常见 40029 code 无效、45011 频率限制 throw new Error(exchange failed: ${resp.data.errcode}); } const { openid, unionid } resp.data; const user await upsertUser(openid, unionid); const accessToken issueAccessToken(user.id); const refreshToken await issueRefreshToken(redis, user.id);小程序端存储有个额外考虑wx.setStorageSync是明文的真机上虽然其他小程序拿不到但如果做安全加固建议 access token 只留内存refresh token 做一次简单加密再落地。这不是必须的但金融、政务类小程序我一般会加。4. Token用量与成本计费口径和压缩实操4.1 中文、英文、代码的 Token 换算经验值先给一组我自己实测下来比较接近的经验值用来做初步估算足够了。英文大约是 4 个字符对应 1 个 token常见汉字在多数现代分词器里大约 1 个字 0.6 到 1 个 token 之间浮动标点、数字、特殊符号的消耗通常更高。内容类型粗略换算说明纯英文单词文本1 token ≈ 4 字符常见英文单词多为 1 token简体中文1 汉字 ≈ 0.6~1 token分词器不同差异明显代码1 token ≈ 2~3 字符缩进、符号消耗偏高JSON / 结构化数据1 token ≈ 2~3 字符引号括号重复出现Base64 图片串消耗极大能用图片接口就别贴文本这个表怎么用比如热搜里那个“把游戏 mod 网站汉化需要多少 token”就可以直接套先统计待翻译文本的总汉字数假设 20 万字按 0.8 估算就是 16 万 token 输入再算输出译文一般和原文体量相当又是 16 万 token 输出。所以一次全量翻译大概 32 万 token 的量级。分批处理的话还得把每批的提示词开销算进去每批假设 300 token分 500 批就是 15 万 token 的额外开销这部分经常被忽略。想更准一点直接上分词器数一遍import tiktoken enc tiktoken.get_encoding(cl100k_base) text 把游戏mod网站汉化需要多少token print(len(enc.encode(text))) # 实际 token 数4.2 上下文窗口、输入输出差价与缓存命中计费不是把所有 token 一视同仁。绝大多数平台是输入和输出分开计价输出通常贵 2 到 4 倍。所以省钱的第一个方向就是压缩输出——能要求模型返回结构化短字段就别让它写长篇解释。第二个方向是上下文控制。上下文窗口是硬上限超了直接报exceeded model token limit这类错误。长对话必须做裁剪策略保留系统提示 最近 N 轮 早期对话的摘要。我一般用“滚动摘要”方案每 6 轮把早期内容压成 200 字摘要长期对话的 token 占用能降 60% 以上。第三个方向是缓存命中。很多平台对重复出现的前缀内容提供折扣价比如固定不变的系统提示、知识库前言。把稳定内容放在 prompt 最前面把变化内容放后面能吃到这个折扣。这个优化不需要改业务逻辑改一下拼接顺序就行投入产出比极高。还有一个隐形成本来源工具调用和检索结果。接知识库的时候一次检索塞进去 5000 token 的文档片段比模型自己回答贵得多。我的做法是先做一轮轻量重排只把最相关的 3 段送进去单次调用能砍掉一半以上的输入 token。4.3 Credits、包月额度与免费额度的区别这三者经常被混着说实际是三种完全不同的商业模型。Credits通常是订阅制产品内部的折算单位你付的是月费平台把它折算成 credits不同模型消耗比例不同——贵的模型一次扣得多。它的特点是额度可能不按 token 精确计而是按“请求复杂度”折算所以你在界面上看到的消耗数字跟 API 的 token 账单不是一回事。包月额度常见于订阅制一个月给你多少条消息、多少次高级模型调用。这种模式下 token 用量对用户是黑盒超了就是限流或者降级到小模型。选这种方案时要关注的是速率限制RPM、TPM而不是总量因为总量一般够用卡住你的是每分钟能发几个请求。免费额度一般给新账号试用特点是额度小、时效短、限制多。拿免费额度跑批处理任务是个坏主意跑到一半额度耗尽任务失败还得重跑重跑又消耗一次。我建议免费额度只用来做功能验证正式跑量前先把预估算清楚再决定走 API 还是订阅。提示做成本预算时把“重试”算进去。接口失败重试、模型输出格式不对重跑、解析失败再跑一遍这些在真实项目里能让实际消耗比理论值高出 20% 到 40%。5. 常见故障排查401、400与换取失败怎么定位5.1 401 token is invalid 的标准排查顺序拿到 401不要急着改代码按这个顺序查基本五步能定位第一步确认 token 有没有真的传出去。打开网络面板看请求头我见过太多次是拦截器没生效、或者某个请求走了裸 fetch 把 header 丢了。第二步确认传的格式对不对。Bearer前缀加空格大小写敏感这是硬性要求。少了空格是最常见的低级错误。第三步手动解码 payload 看 exp。JWT 中间那段 Base64 解出来就能看到过期时间拿它跟服务器时间对比。如果 exp 明明没过期却报无效那问题在验签环节。第四步检查密钥。签发和验证用的是不是同一个 secret多环境部署时最容易出事的就是测试环境签发的 token 拿去生产验证或者密钥轮换后旧服务实例还在用老密钥。第五步检查黑名单和代际号。用户改过密码、被踢过、或者 refresh token 触发过复用检测都会让 token 立刻失效这种情况下应该给前端返回一个明确的错误码而不是笼统的 401。5.2 400 invalid refresh_token: empty string 的根因这个报错信息特别直白——“期望一个长度至少为 1 的字符串实际拿到空字符串”。它跟 token 过期、密钥错误都没关系就是参数根本没传上去。根因通常有四种。一是客户端取 refresh token 的 key 写错了读出来是 undefined序列化后变成空字符串或者被过滤掉。二是刷新逻辑在“第一次登录还没写入存储”时就被触发了比如页面刷新时拦截器先跑存储里还是空的。三是用了form-data提交但字段名对不上服务端按另一个名字取取到空。四是 token 轮换后本地没更新旧值被清掉了但新值没写进去。排查方法很土但有效在刷新请求发出前打一行日志把存储里的 refresh token 长度和前后 4 位打出来。长度是 0 就说明是读取问题长度正常但还是报错那才去看字段名和编码格式。5.3 token_exchange_failed 类错误的定位思路token_exchange_failed这个错误码一般出现在 OAuth 流程里含义是“用授权码换 token 这一步失败了”。它本身是个外层包装真正的原因在details里。按我的经验成因集中在四类授权码已失效一次性且有效窗口很短设备码流程尤其容易超时回调地址不匹配注册的回调 URL 和实际请求的差一个斜杠都会失败网络层被中断请求发出去了但没收到完整响应报error sending request这种在企业网络环境里很常见要走出口白名单请求体编码不对有些服务要求application/x-www-form-urlencoded你发 JSON 就直接失败。排查时先把完整的details字段捞出来看不要只看外层错误码。然后拿同一个授权码手动 curl 一次看原始响应体。手动能成功、程序失败那问题一定在参数构造或编码上手动也失败那就去看授权码是不是已经用过了。还有一种情况值得单独说本地调试时把 token 直接拼在 URL 上比如?tokennullto...这种。tokennull说明上游没拿到 token 就硬拼了字符串跳转过去自然是被拒。这类问题在前后端联调阶段出现频率极高根治办法是统一用请求头传 token而不是 URL 参数——URL 会进浏览器历史、进日志、进 Referer本身就不安全。5.4 故障速查表现象大概率的根因第一步动作401 token is invalid未传 / 格式错 / 密钥不匹配 / 已吊销抓包看请求头400 refresh_token 为空存储读取失败或字段名不匹配打印读取值长度token 失效但 exp 未到时钟偏移、黑名单、代际冲突核对服务器时间与 jtitoken_exchange_failed授权码失效、回调不匹配、编码错误看 details 并手动重放exceeded token limit上下文超窗统计输入 token 并裁剪不同类型数据报 invalid token参数类型用错把非 token 值传进了解析函数检查调用点入参类型最后一行为什么单独列出来因为见过把 MIME 类型字符串、图片路径之类的东西误传进 token 解析函数的案例报错长得像“invalid token xxx”实际跟鉴权毫无关系纯粹是参数类型串了。排查时先确认入参本身是不是一个 token能省很多时间。6. 安全与工程实践存储、传输与生命周期管理6.1 Access Token放内存Refresh Token放HttpOnly Cookie前端存储位置的选择上我的排序是access token 放内存变量refresh token 放 HttpOnly Secure SameSite 的 Cookie。这样 XSS 攻击拿不到 refresh tokenCSRF 又有 SameSite 兜着。如果因为跨域或者小程序限制必须用 localStorage那至少要做到两点refresh token 加一层对称加密再落地并且密钥不要硬编码在前端代码里虽然这只能提高一点门槛但比明文强。另外必须配套 CSP 策略把内联脚本的执行口子堵住。Web 场景还有个细节容易漏退出登录时要同时清服务端和客户端。服务端删 refresh token 记录并把它对应的 jti 加黑名单客户端清内存和 Cookie。只清客户端等于 token 还在外面飘着。6.2 传输与日志别让 Token 出现在不该出现的地方Token 泄露最常见的渠道不是被黑客攻破而是被自己人打印在日志里。我见过日志系统里躺着几十万条带完整 Authorization 头的记录任何有日志查看权限的人都能拿去冒用身份。处理方式有三条。第一日志中间件统一脱敏对Authorization、token、access_token、refresh_token这类 key 做正则替换只留前 4 位和后 4 位。第二禁止把 token 放 URL 参数前面说过 URL 会进各种记录。第三错误上报系统也要脱敏很多团队只处理了业务日志忘了异常堆栈里也带着请求头。另外提醒一句第三方监控、APM 工具接入时要确认它们的采集范围有些默认会把完整请求体上报到云端。6.3 密钥轮换与强制失效密钥不能永久不变。我的做法是配置主密钥 备用密钥验证时两个都试签发只用主密钥。轮换时把新密钥设为主密钥旧的降为备用等所有旧 token 自然过期最长一个 refresh token 周期后再把备用撤掉。这样轮换过程对用户完全无感。强制失效的场景要提前设计好错误码别让前端靠猜。我习惯用这几个token_expired正常过期触发静默刷新、token_revoked被吊销直接跳登录、token_invalid格式或签名错误直接跳登录。前端按码走不同分支就不会出现“刷新失败还一直重试”的死循环。还有一个小细节改密码后应该吊销该用户所有 refresh token。实现上可以用一个token_version字段存用户表里签发时写进 payload验证时比对。改密码就token_version 1所有旧 token 立刻失效比维护一堆 jti 黑名单省事得多。7. 实操心得这些坑我替你先踩过了7.1 关于续签的几个血泪教训第一个教训是别用时间戳当续签判据。我早期做过一个“token 剩余有效期小于一半就自动续”的逻辑看起来优雅实测在用户频繁操作时会出现抖动——续一次、又快到一半、再续一次日志里全是刷新记录。后来改成固定提前 60 秒刷新稳定多了。第二个教训是刷新接口本身要有速率限制。客户端如果因为 bug 陷入死循环会以每秒几十次的频率打刷新接口把 Redis 打满。给刷新接口按用户 ID 做限流比如每分钟 10 次这个保护很值。第三个教训是多标签页场景要共享刷新结果。浏览器开了 8 个标签页每个页面独立维护 tokenA 页面刷新后 token 轮换了B 页面手里还是旧的下一次刷新必然失败。解决办法是用 BroadcastChannel 或者 storage 事件同步 token 变更让所有页面用同一份。7.2 关于成本控制我最后悔没早做的三件事第一件是没早点上用量监控面板。上线初期我全靠月底账单看数字等发现某个功能吃掉 70% 额度时已经烧了一个月。现在每个调用点都打标功能名 用户 ID 输入输出 token 数按天聚合哪个功能异常一眼就能看出来。CLI 类工具如果自带用量追踪也建议打开日常就能看到单次消耗。第二件是没早点给模型输出加长度上限。有段时间模型特别“话多”让它返回一个 JSON它先写 300 字解释再给 JSON。加上max_tokens和明确的“只输出 JSON不要任何额外文字”的约束后输出 token 直接降了六成。第三件是没早点把重复的提示词模板化。同一个系统提示词在十几个调用点里各写一份改一次要改十几处还容易写歪导致 token 消耗不一致。抽成模板之后既能统一长度又能保证前缀一致从而吃到缓存折扣。7.3 几个可以立刻用上的小技巧调试 token 相关问题时准备一段十行的解码脚本粘上 JWT 就能看到头部和载荷比在线解码工具安全得多因为数据不出本机。给所有涉及 token 的接口加一个X-Request-Id从客户端一路透传到日志和错误上报排查时能瞬间串起整条链路。刷新 token 的存储 key 加上环境前缀比如prod:rt:和dev:rt:避免本地调试时把生产环境的 token 写到同一个 key 上这个坑我见过至少三次。做成本预估时永远按“理论值乘以 1.3”来算。多出来的三成是重试、失败重跑、格式纠错和调试调用这些才是账单里的真实构成。团队的规范上我现在的底线是token 相关代码必须写注释说明时效和失效条件新同学接手时能一眼看懂任何打印 token 的调试代码不允许进主分支用 lint 规则卡死密钥只从环境变量读配置文件里出现的密钥一律视为事故。这三条看着简单真执行下来能挡掉大部分低级安全事故。至于 token 的模型选择我个人倾向于“先用最小可行的模型跑通链路再按效果逐步升级”。很多功能用便宜的小模型就够非要上最强的那个账单能差出十倍。等你真的量化出效果差异了再决定哪些调用点值得升级这时候花的钱才是有依据的。