调用接口时报 context length exceeded,多数人的第一反应是「我字数没超过啊」。问题就出在这:上下文窗口的单位是 token,不是字符,也不是字数。同一个 10 万字的中文文档,在不同模型上可能占 7 万 token,也可能占 14 万 token,一个塞得进去,一个直接报错。
一、先记住三个数
| 内容类型 | 大致换算 | 说明 |
|---|---|---|
| 英文 | 1 token ≈ 4 字符 ≈ 0.75 个单词 | 几乎所有分词器都接近这个值 |
| 中文 | 1 个汉字 ≈ 0.6~1.5 token | 差异极大,取决于模型词表 |
| 代码 | 1 token ≈ 3 字符 | 缩进、括号、下划线都单独占位,比自然语言更费 |
中文这个区间之所以宽,是因为分词器根本不同:
- 国产模型(Qwen、DeepSeek、GLM 等)词表专门为中文优化过,常用双字词能压成一个 token,1 个汉字大约 0.6~0.7 token
- OpenAI 早期词表(cl100k_base,GPT-3.5/4 时代)中文覆盖差,一个汉字常被拆成一到两个 token,1 字约 1.2~1.5 token
- OpenAI 新词表(o200k_base)改善明显,1 字约 0.8~1 token
所以「128K 上下文能装多少汉字」这个问题没有唯一答案:
| 词表类型 | 128K token 约等于 |
|---|---|
| 国产模型(中文优化) | 18~21 万汉字 |
| OpenAI 新词表 | 13~16 万汉字 |
| OpenAI 早期词表 | 8~10 万汉字 |
网上流传的「128K 大约十万字」,用的是最保守的那一档。如果你用的是国产模型,实际容量比你以为的大一倍。
二、为什么不能按字数估
除了分词器差异,还有四个容易忽略的消耗项。
系统提示词和历史对话全程占位。 上下文不是「这一轮你输入的字」,而是「系统提示词 + 全部历史消息 + 本轮输入 + 预留给输出的空间」的总和。一个 2000 token 的系统提示词,聊到第 20 轮,它已经被计费 20 次,同时也一直占着窗口。
数字特别费。 分词器会按固定位数切数字,一串 11 位手机号经常被切成 4~6 个 token,一段 18 位身份证号可能 8~10 个。整篇都是数据表格的文档,token 密度比同字数的散文高不少。
emoji 和特殊符号很贵。 一个 emoji 普遍占 2~4 个 token,一段全是 emoji 的营销文案,token 数能到汉字的三倍。
JSON 和 Markdown 的符号也占 token。 每个 "、:、{、换行都是独立 token。这也是为什么把 JSON 压缩成一行再喂给模型,能省下可观的开销——结构和值都没变,只是去掉了给人看的空白。
三、图片不是按张算,是按块算
这是最容易算错的一块。以 OpenAI 的视觉模型为例:
| 模式 | 计费方式 |
|---|---|
低清(detail: "low") |
固定 85 token,无论图多大 |
高清(detail: "high") |
先缩放到最长边 2048,再按 512×512 切块,85 + 170 × 块数 |
一张 1024×1024 的图,高清模式下切成 2×2 共 4 块,计费 85 + 170×4 = 765 token。同样一张图,低清只要 85 token,差 9 倍。
所以「一次传 20 张截图让模型看」是很贵的操作:20 × 765 ≈ 15,300 token,还没开始提问就吃掉一万五。
各家规则不同(有的按像素面积折算,有的按图块),但规律一致:分辨率是成本的主要变量,不是文件大小。同一张 JPEG,压缩到 200KB 和 2MB,计费完全一样。
四、三种实测方法,别靠估
方法一:直接看接口返回的 usage
最准,因为这就是计费依据。任何一家 API 的响应里都有类似字段:
{
"usage": {
"prompt_tokens": 1284,
"completion_tokens": 356,
"total_tokens": 1640,
"prompt_tokens_details": { "cached_tokens": 0 }
}
}
跑一次真实请求,把 prompt_tokens 除以你的字数,就得到你这个模型、这类内容的实际系数。这比任何网上的换算表都可靠。
方法二:本地跑分词器
# OpenAI 系:tiktoken
import tiktoken
enc = tiktoken.get_encoding("o200k_base") # 新词表
print(len(enc.encode("这是一段中文测试文本"))) # 8 个字 → 大约 8 个 token 以内
# 对比老词表
enc_old = tiktoken.get_encoding("cl100k_base")
print(len(enc_old.encode("这是一段中文测试文本"))) # 通常更多
# HuggingFace 系(Qwen / Llama / GLM 等本地模型)
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
print(len(tok.encode("这是一段中文测试文本")))
注意开源模型的分词器要跟实际部署的模型版本对齐,同系列不同代之间词表可能换过。
方法三:粗估,够用就行
写代码时不想引依赖,用这个:
// 粗略估算,误差 ±20%,只用于限流和预检
const estTokens = (text) => {
const cjk = (text.match(/[\u4e00-\u9fa5]/g) || []).length
const rest = text.length - cjk
return Math.ceil(cjk * 0.7 + rest / 4) // 国产模型系数
}
要更准就把 0.7 换成你实测出来的值。
顺手统计一下原文有多少字、多少行、多少标点,也能帮你反推 token:
五、中文里的「隐形杀手」
全角标点。 中文的 ,。;: 在多数词表里是独立 token,一篇 3000 字的文章光标点就有 300 个 token,占 10%。
生僻字和异体字。 「囧」「槑」这类字可能被拆成 3 个字节级 token。古籍、方言、日文汉字混排时尤其明显。
表格和列表。 Markdown 表格的每一根 | 都是 token。一个 10 列 × 50 行的表格,光竖线就 500 个 token。
空行和缩进。 代码块里每行前面的 4 个空格,如果被保留就是 1 个 token;80 行的代码块白送 80 token。这也是为什么让模型读代码时,把无关的缩进层级压平能省钱。
六、超限了怎么办:按代价从低到高
| 顺序 | 做法 | 代价 | 效果 |
|---|---|---|---|
| 1 | 压缩空白、去掉多余缩进 | 无 | 省 5%~15% |
| 2 | 把 JSON 从美化版换成单行 | 无 | 省 10%~20% |
| 3 | 用低清模式传图 | 精度略降 | 省 90% |
| 4 | 历史对话做摘要,只保留最近 N 轮 | 少量信息损失 | 省 50%+ |
| 5 | 长文档先切块,相关块才送入 | 要写检索逻辑 | 按需 |
| 6 | 换更大窗口的模型 | 单价更高 | 直接解决 |
最不该做的两件事:把长文档截断一半就发(模型可能看不到关键结论),无限追加历史(越聊越贵,最后必然超限)。
七、两个常见误解
「max_tokens 设大一点,回答会更长」 —— 不是。它只是输出上限,模型该停还是会停。设成 4096 不会让它写满 4096,但如果设成 200,一段完整代码就会被硬切断。
「上下文窗口 = 输入 + 输出,所以输入最多能用满」 —— 不是。窗口是输入和输出的总和。128K 的模型,输入塞了 127K,输出只剩 1K,回答会突然断在一半。
八、速查
英文 1 token ≈ 4 字符
中文(国产) 1 字 ≈ 0.6~0.7 token
中文(OpenAI 新)1 字 ≈ 0.8~1.0 token
中文(OpenAI 旧)1 字 ≈ 1.2~1.5 token
代码 1 token ≈ 3 字符
数字 每 5~6 位约 1 token
图片(低清) 85 token / 张
图片(高清) 85 + 170 × 块数
真正的系数只能自己测一次:发一个请求,用 prompt_tokens 除以字数。测完记下来,以后所有估算都用这个数。
全部纯前端实现,输入的内容不上传服务器。