一家酒吧把“无限量使用 token”写进营销活动后,社交媒体上讨论最多的话题是:AI 算力会不会像 WiFi、水电一样成为标配。这个问题从产品角度看很浪漫,从工程角度看却非常具体。因为 token 不是优惠券,而是大模型服务计量和计费的最小单位,它连接着模型预处理、上下文限制、API 配额和账单成本。要理解这件事,需要从 token 到底是什么讲起。
这篇文章会围绕“无限量 token”这个营销话术,拆解三层技术问题:模型侧的 token 如何计算,服务端如何做配额和限流,以及登录流程里的 token exchange 报错如何排查。最后会落到一个可运行的 FastAPI 示例上,把计量、成本估算、配额响应头完整跑通。读完可以用同样的思路,为自己的大模型应用补上用量控制和成本治理能力。
1. 先从“无限量 Token”说起,Token 在大模型服务里到底是什么
1.1 同一个词,在大众语境和工程师语境里不是同一个概念
大众语境里的 token,经常和“代币”“积分”“可用额度”混在一起。酒吧活动海报上写的“任意消费就无限量使用 token”,用户大概率理解为:我可以随便让 AI 帮我写文案、翻译、做表格,不需要额外付费。
但在工程师语境里,token 至少有两个完全不同的含义。
第一个含义是大模型推理过程中的文本切分单位。模型不是按“字”读取文本,而是把文本切成一段段 token。大模型 API 的计费、上下文窗口长度、性能评估,都围绕 token 展开。
第二个含义是认证授权场景里的令牌。比如 OAuth 登录时,服务端会给客户端返回 access token,客户端用它在请求头里证明“我是谁”。社交媒体评论区里大量出现的token exchange failed、token 失效,说的就是这类令牌。
同一篇文章如果没把这两个含义区分开,后面的讨论都会失真。酒吧活动里的“无限量 token”显然指第一种,也就是模型调用额度。但真正落地时,第二种 token 的登录问题也会一起出现,所以后面会分开讲。
1.2 Tokenizer 决定模型如何切分文本
GPT 这类大模型在接收文本前,会先用 tokenizer 把原始文本切成 token 序列。一个 token 可能是半个单词、一个单词,也可能是一个汉字、一个标点。不同模型的 tokenizer 不同,切出来的 token 数量也不一样。
下面用 OpenAI 开源的tiktoken库做个直观演示:
import tiktoken enc = tiktoken.get_encoding("cl100k_base") text_en = "hello world" text_zh = "你好,世界" print(len(enc.encode(text_en))) print(len(enc.encode(text_zh)))在这个编码器下,hello world往往只占 2 个 token,而你好,世界由于中文字符拆分方式不同,可能会占 3 到 6 个 token。这里不给出固定值,是因为 tokenizer 版本、特殊字符、空格都会影响结果。
这个细节决定了第一类常见坑:不要用字符数或字节数去估算 token 数。中英文混合文本、emoji、JSON 格式、代码片段,都会让估算产生明显偏差。
1.3 Token 数量如何影响成本和服务质量
大模型服务商通常会按“输入 token + 输出 token”计费。输入 token 是用户请求、系统提示词、上下文历史加在一起的总和;输出 token 是模型生成的内容。两者往往不等价,输出 token 的单价可能更高,因为生成过程需要逐步采样,计算资源消耗更大。
除了成本,token 还决定上下文窗口。每个模型能一次处理的 token 数有上限。如果用户的对话历史太长,超出了上下文窗口,要么截断旧消息,要么直接报错。因此“无限量 token”即使不计费,单次请求里也会被上下文窗口限制住。
实际项目中,很多成本失控问题的根源不是模型太贵,而是 prompt 太长。开发者习惯把大量说明、示例、历史聊天记录无条件塞进系统提示词里,导致每次请求的输入 token 居高不下。控制 token 用量,首先要控制 prompt 体积。
1.4 为什么“无限量”在工程上不可能
WiFi 和水电可以作为“基础设施”的类比,但没有人会说 WiFi 和水电是无限量免费供应的。它们能成为标配,靠的是稳定接入、按量计费、容量规划和故障保障。AI 算力如果要普及到水电一样的程度,需要的不是“无限量”,而是可靠、可计量、可治理。
“无限量 token”在工程上会立刻遇到三个问题:
- 成本不可控。模型调用有真实算力成本,无限量等于没有预算上限。
- 服务质量不可控。一个用户把并发打满,其他用户都要排队。
- 滥用无法追溯。没有用户维度的计量和配额,就只能靠事后看账单。
所以,技术侧要做的事情,是把“无限量”翻译成一套包含计量、配额、限流、成本的系统能力。
2. 要承接一个“无限量 Token”活动,后端至少要解决四件事
2.1 计量:没有用量记录就没有成本控制
所有成本控制都建立在用量数据上。系统必须知道每次请求是谁发的、用了哪个模型、消耗了多少输入 token、多少输出 token、什么时间发生。
最简单的做法是在模型调用入口统一记录。无论是自己封装大模型 SDK,还是经过网关转发,都必须在同一层完成计量,不能只在某个业务接口里记,否则漏掉异步任务、重试请求和内部调用。
这条记录通常包含这些字段:
| 字段 | 含义 |
|---|---|
| user_id | 用户唯一标识 |
| request_id | 请求追踪 ID |
| model | 实际调用的模型名 |
| input_tokens | 请求侧 token 数 |
| output_tokens | 模型输出 token 数 |
| ts | 请求完成时间 |
| status | success / timeout / error |
有这份用量日志后,才能做成本汇总、异常检测、用户画像和预算分摊。
2.2 配额:用户维度要有软硬上限
“无限量”活动可以给用户一种“不限量”的感知,但服务端必须有默认配额。
配额一般分三层:
- 单次请求配额。一次调用最多允许消耗多少输入和输出 token。
- 单用户单位时间配额。常见的是“每天多少 token”或者“每小时多少 token”。
- 全局限额。整个应用或整个项目的月度总预算。
配额超限时的返回码要设计清楚。推荐用 429,并在响应头或响应体里说明是“速率超限”还是“配额超限”。如果直接返回 500,用户会以为服务出故障了。
2.3 限流:防止单用户打爆服务
配额控制的是总量,限流控制的是速率。一个用户即使每天配额足够,也可能在一分钟内发出几百个请求,导致后端和模型服务承受瞬时压力。
限流常用的算法有令牌桶、漏桶和滑动窗口。对多数应用来说,按用户维度做滑动窗口限流就够了:每个用户在一个时间窗口内最多允许 N 次请求,超过就返回 429。
限流和配额都要在网关层或中间件里统一处理,不能依赖业务代码自觉。
2.4 成本换算:Token 用量要能折算成预算
计量记录的是 token,但老板和财务关心的是钱。所以系统里还需要一个成本估算模块,把 token 数乘以模型单价。
不同模型的输入单价和输出单价不同,同一个模型在不同计费模式下也可能不同。实际开发时不要写死价格,建议放到配置中心或数据库,方便调整。
下面是一个最小成本估算函数:
PRICE_TABLE = { "demo-model": {"input": 1.0, "output": 2.0}, } def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float: price = PRICE_TABLE.get(model, PRICE_TABLE["demo-model"]) return ( input_tokens / 1_000_000 * price["input"] + output_tokens / 1_000_000 * price["output"] )注意:以上价格只是示例,真实项目要以模型供应商官方的计费页为准,并且每个月重新核对一次,因为价格和计费规则都可能调整。
3. 用 FastAPI 写一个 Token 计量与配额控制示例
3.1 环境准备和项目结构
要用一个最小服务把这个逻辑跑通。这里选择 Python 和 FastAPI,因为它们上手成本低,中间件、响应头、接口文档都开箱即用。
先准备虚拟环境和依赖:
python -m venv venv source venv/bin/activate pip install fastapi uvicorn tiktoken项目结构保持简单:
token-meter/ ├── main.py └── requirements.txt如果只想验证思路,不依赖 tiktoken,也可以把 token 计数函数替换成len(text) // 2这种估算方式。但真实项目请使用模型官方对应的 tokenizer。
3.2 核心代码:中间件、配额限流和对话接口
下面是一次完整的最小实现。先看main.py:
import time from collections import defaultdict, deque import tiktoken from fastapi import FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel, Field app = FastAPI(title="Token Meter Demo") PRICE_TABLE = { "demo-model": {"input": 1.0, "output": 2.0}, } DEFAULT_QUOTA_LIMIT = 1000 QUOTA = defaultdict(lambda: {"limit": DEFAULT_QUOTA_LIMIT, "used": 0}) USAGE_LOG = [] RATE_LIMIT_MAX = 5 RATE_LIMIT_WINDOW = 10 REQUEST_HISTORY = defaultdict(deque) _encoding = tiktoken.get_encoding("cl100k_base") def count_tokens(text: str) -> int: if not text: return 0 try: return len(_encoding.encode(text)) except Exception: return max(1, len(text) // 2) def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float: price = PRICE_TABLE.get(model, PRICE_TABLE["demo-model"]) return ( input_tokens / 1_000_000 * price["input"] + output_tokens / 1_000_000 * price["output"] ) def rate_limited(user: str) -> bool: now = time.time() q = REQUEST_HISTORY[user] while q and q[0] < now - RATE_LIMIT_WINDOW: q.popleft() if len(q) >= RATE_LIMIT_MAX: return True q.append(now) return False class ChatRequest(BaseModel): model: str = Field(default="demo-model") prompt: str class ChatResponse(BaseModel): reply: str input_tokens: int output_tokens: int total_tokens: int remaining_tokens: int estimated_cost: float @app.middleware("http") async def quota_headers_middleware(request: Request, call_next): user = request.headers.get("X-User", "anonymous") response = await call_next(request) quota = QUOTA[user] response.headers["X-Token-Quota-Limit"] = str(quota["limit"]) response.headers["X-Token-Quota-Used"] = str(quota["used"]) response.headers["X-Token-Quota-Remaining"] = str( max(0, quota["limit"] - quota["used"]) ) return response @app.post("/chat", response_model=ChatResponse) async def chat(req: ChatRequest, request: Request): user = request.headers.get("X-User", "anonymous") if rate_limited(user): raise HTTPException( status_code=429, detail={"error": "rate_limited", "message": "请求过于频繁,请稍后再试"}, ) quota = QUOTA[user] input_tokens = count_tokens(req.prompt) output_tokens = min(200, max(10, input_tokens // 2)) total_tokens = input_tokens + output_tokens if quota["used"] + total_tokens > quota["limit"]: raise HTTPException( status_code=429, detail={"error": "quota_exceeded", "message": "今日 token 配额已用完"}, ) quota["used"] += total_tokens cost = estimate_cost(req.model, input_tokens, output_tokens) USAGE_LOG.append( { "ts": time.time(), "user": user, "model": req.model, "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens, "estimated_cost": cost, } ) return ChatResponse( reply=f"已收到你的请求,当前模拟回复消耗 {output_tokens} 个输出 token。", input_tokens=input_tokens, output_tokens=output_tokens, total_tokens=total_tokens, remaining_tokens=quota["limit"] - quota["used"], estimated_cost=round(cost, 6), ) @app.get("/usage/{user}") async def get_usage(user: str): quota = QUOTA[user] logs = [log for log in USAGE_LOG if log["user"] == user] return {"user": user, "quota": quota, "recent_logs": logs[-10:]}代码里没有真实调用大模型,而是用output_tokens = min(200, max(10, input_tokens // 2))模拟模型输出长度。这样方便在本地跑通整个计量链路,后面接真实模型时,只需要替换这一段。
3.3 关键设计说明
中间件里只负责附加配额响应头,不在中间件里读请求 body。这里要特别注意:FastAPI 的请求 body 是流,只能读一次。如果在中间件里执行await request.body()然后又没做缓存,后面的接口会拿到空 body,出现422 Unprocessable Entity。所以这个示例把 token 统计放在端点内部完成,中间件只读最终状态。
QUOTA和REQUEST_HISTORY都放在内存字典里,只能用于学习和本地验证。生产环境必须换成 Redis 或数据库,否则多 worker 部署时每个进程各有一份数据,配额会互相覆盖,限流也失效。
响应头里的X-Token-Quota-Remaining很有用。前端可以在请求后读取这个头,提示用户还剩多少额度,不需要额外查询接口。
3.4 启动与验证
启动服务:
uvicorn main:app --host 127.0.0.1 --port 8000 --reload发送一次正常请求:
curl -X POST http://127.0.0.1:8000/chat \ -H "Content-Type: application/json" \ -H "X-User: tester" \ -d '{"model":"demo-model","prompt":"帮我写一个 Python 限流工具"}' \ -i预期会看到类似下面的响应头:
HTTP/1.1 200 OK x-token-quota-limit: 1000 x-token-quota-used: 17 x-token-quota-remaining: 983每个用户都带有自己的配额。把X-User改成tester2,配额会重新从 0 开始,这就实现了用户维度的独立配额。
然后快速连续调用多次,会触发滑动窗口限流,响应变成:
{"detail":{"error":"rate_limited","message":"请求过于频繁,请稍后再试"}}如果需要把配额调低来测试 429,可以把DEFAULT_QUOTA_LIMIT改成 30,或者直接通过/usage/tester查看当前用量。
注意:不要只验证程序能启动,还要验证配额、限流和 429 响应。计量系统最重要的行为恰恰发生在资源不足时。
4. 另一个高频问题:登录时报 token exchange failed 怎么排查
4.1 认证 Token 与大模型 Token 不是同一个东西
上面实现的Token Meter统计的是模型输入输出 token。但在很多 AI 工具集成过程中,开发者还会遇到另一种 token:OAuth 登录令牌。
一个典型的报错形式是:
sign-in could not be completed token exchange failed: token endpoint returned status 403 forbidden: country, region, or territory not supported这句话的意思是:授权码换 access token 的请求,被 token 端点的 403 拒绝了,并且服务商在错误信息里提示“不支持当前国家、地区或区域”。很多开发者第一次看到时会去检查账号密码,实际上账号密码在这一步已经验证完了,问题出在后续的 token 交换环节。
4.2 先看懂 token exchange 在交换什么
在 OAuth 2.0 的授权码流程里,客户端先从服务商拿到一个临时 authorization code,再用这个 code 去 token endpoint 换取 access token。这个“用 code 换 token”的请求就叫 token exchange。
如果 token endpoint 返回 403,说明服务商已经识别了请求来自哪里,或者在应用配置上认为这个请求不合法。不要把 403 简单理解成“密码错”,因为授权码模式里密码已经在登录页处理过了。
4.3 按顺序排查 5 个环节
首先是完整日志。只看403 forbidden不够,一定要看后面的country, region, or territory not supported这段内容。排查顺序建议是:
| 排查步骤 | 检查项 | 怎么做 |
|---|---|---|
| 1 | 错误原始文本 | 保存完整报错,看 403 后面的区域描述 |
| 2 | 账户区域设置 | 登录服务商控制台,确认账户资料区域是否与支持范围一致 |
| 3 | 出口 IP 区域 | 云服务器检查地域,企业网络找网络管理员确认出口路由 |
| 4 | OAuth 应用配置 | 检查 client_id、redirect_uri、scope 是否与开发者后台一致 |
| 5 | 服务状态 | 查看服务商 status page,排除临时故障 |
实际案例里,最常见的原因是第三步和第二步不一致:账户注册在 A 区域,当前出口 IP 在 B 区域。如果服务商区域策略不支持这种组合,token exchange 就会失败。
注意:
token exchange failed的排查重点不是 403 本身,而是 403 后面的错误描述。状态码只能说明“被拒绝”,错误描述才会告诉你拒绝原因。
4.4 顺带理解 JWT 续签
登录类问题还经常伴随“token 失效”。OAuth 和 JWT 里的 token 都有有效期,access token 通常只有几分钟到几小时。为了避免用户频繁重新登录,系统一般会再发一个 refresh token,过期时间更长。
刷新流程可以用下面的伪代码理解:
@app.post("/auth/refresh") def refresh(refresh_token: str): payload = verify_jwt(refresh_token) if payload.get("type") != "refresh": raise HTTPException(status_code=401, detail="refresh token invalid") return {"access_token": create_access_token(payload["sub"])}一个常见的坑是:接口只校验了 refresh token 签名,没有校验type字段。这样 access token 也可能被当作 refresh token 使用,扩大了令牌泄露的影响范围。更稳妥的做法是给两种 token 分别加type声明,并且每次刷新后轮换 refresh token,不让旧的 refresh token 永久有效。
5. 从学习示例到生产环境,成本控制要升级成系统能力
5.1 学习环境和生产环境的差异
上面那个 FastAPI 示例只能用于理解流程,直接搬到生产环境会出问题。两者差异很大:
| 维度 | 学习示例 | 生产要求 |
|---|---|---|
| 用户标识 | 请求头写 X-User | 登录态、API Key、签名认证 |
| 配额存储 | 内存字典 | Redis 原子自增、数据库持久化 |
| token 计数 | tiktoken 示例编码 | 模型官方 tokenizer,支持不同模型 |
| 限流 | 单机滑动窗口 | 分布式限流,Redis Lua 脚本 |
| 成本核算 | 示例价格表 | 账单系统,按团队、项目分摊 |
| 告警 | 无 | 用量日报、预算阈值、429 监控 |
生产环境至少还要处理一个问题:真实模型调用的超时和错误重试。重试会在计量接口里重复计数,所以一定要用request_id做幂等,否则用户会被重复扣额度。
5.2 发布前检查清单
把大模型应用接入生产前,建议按这个清单过一遍:
- 所有模型入口是否都接入了统一计量?
- 是否区分输入 token 和输出 token?
- 单次请求的最大 token 数是否有限制?
- 每个用户的每日、每月配额是否明确?
- 超额请求是否返回 429 而不是 500?
- 429 响应是否带
Retry-After? - 成本估算使用的最新单价是否已同步?
- 是否有预算达到 80% 时的告警和熔断?
- 是否能按用户、按模型出日维度的用量报表?
5.3 模型成本治理的五个抓手
预算控制不是买完模型额度就结束了。实际项目里可以从五个方向同时治理。
第一,模型选型。简单任务用便宜小模型,复杂任务才用强模型。很多请求只是一句话分类或摘要,不需要每次都调最大参数模型。
第二,prompt 瘦身。系统提示词、few-shot 示例和历史对话是输入 token 的大头。要定期清理无用上下文,给系统提示词设置长度审查。
第三,缓存。对重复度高的请求,直接把模型响应缓存起来。命中缓存时不需要调用模型,也不消耗 token。
第四,限制输出长度。给模型调用参数设置max_tokens,避免模型在开放式问题里越写越长,输出 token 成本往往比输入更高。
第五,配额和告警联动。每日配额使用率达到阈值时,自动降级到小模型,或拒绝非核心调用,保护月底预算。
5.4 AI 算力像 WiFi 一样普及,还缺什么
回到最初的问题:AI 算力会像 WiFi、水电一样成为标配吗?
WiFi 能普及,依靠的是标准的网络协议、完整的终端生态、运营商的接入服务和统一的计量方式。水电能普及,依靠的是管道网络、计量表和持续运维。它们都不是免费的,但都是可靠、可计量、可订阅的。
AI 算力目前像早期电力系统:模型能力很强,但接口标准还在分裂,计费方式按 token,模型调用链路缺少统一的调度和计量规范。要让算力变成“水电”,需要的不只是便宜,而是算力调度标准化、配额计量标准化、安全审计标准化。到那个时候,用户购买的可能不是“无限量 token”,而是“一个账号,多家模型,统一账单,自动降级”。
6. 常见问题与排查速查表
6.1 四个高频坑
第一个坑:用字符数当 token 数。
现象是配额消耗跟账单对不上,用户感觉额度“跑得飞快”。原因是中英文和代码在不同 tokenizer 下的切分长度不同。解决方法是使用模型官方 tokenizer。没有官方库时,也要先抽样做标定,估算出本项目文本的平均 token 与字符比。
第二个坑:中间件读 body 后导致接口参数丢失。
现象是接口偶发 422,日志里 body 为空。原因是 FastAPI 的请求 body 只能读一次。解决方式是不要在中间件里直接消费 body,或者在中间件读取后把内容缓存在request.state里供后续使用。
第三个坑:只做限流不做配额。
现象是单用户短时间内没有打爆服务,但一天累计调用把月度预算耗尽。原因是限流控制速率,配额控制总量,缺一不可。解决方式是同时设置每秒 QPS 限制和每人每日 token 配额。
第四个坑:配额超限返回 500。
现象是用户收到“服务器内部错误”,前端无法区分是服务故障还是额度用完。解决方式是统一返回 429,并携带Retry-After和错误码:rate_limited、quota_exceeded。
6.2 排查速查表
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| Token 用量比预期多 | 用了错误 tokenizer;prompt 太长 | 统计平均输入长度,抽样 token 计数 | 接入官方 tokenizer,压缩 prompt |
| 连续请求偶尔 429 | 限流窗口太短或配额过小 | 查看响应头 X-Token-Quota-* | 调整限流参数,给用户明确提示 |
| 登录 token exchange 403 | 区域策略、账户设置、应用配置 | 看完整错误文本,检查状态页 | 按区域排查顺序处理 |
| Access token 频繁失效 | 过期时间太短 | 查看日志中 expires_in | 配置 refresh token 自动续签 |
| 不同机器配额不同 | 内存状态未共享 | 检查部署模式和负载均衡策略 | 改用 Redis 或数据库存储 |
6.3 实践建议
如果你刚开始给大模型应用加成本控制,不要急着做完整账单系统。先用日志记录每次请求的 model、input_tokens、output_tokens、user_id,再在响应头里暴露剩余配额,让前端和用户都能看到消耗。有数据之后再上告警、熔断和按部门分摊。
“无限量 token”可以成为运营活动的卖点,但服务端必须把它翻译成“有配额、有限流、有熔断、有告警”的工程实现。任何看似无限的能力,落地时都要靠边界来兜底。把边界