Token 预估这件小事:当 Agent 开始精打细算
开发 Agent 类项目久了,会患上一种职业病------Token 焦虑症。
每当用户甩过来一本《三体》全文,第一反应不是"哇,大刘写得真好",而是"这得烧掉多少 Token 啊?上下文窗口还够不够用?"
在真实的生产环境中,有些场景需要在消息真正发出去之前就做好预算:
- 上下文压缩:哪些历史消息该扔了?
- 成本预估:这次调用大概多少钱?
- 长度截断:要不要提前警告用户?
这时候,一个靠谱的 Token 预估方案就成了刚需。据我算知道两种主流方案:HuggingFace Tokenizers 和 Tiktoken。(实际上还有一种就是根据字符乘以固定权重,如中文x0.6,因模型而异,对于精度不高也可接受,这里不过多讨论了)**
网上搜索、问AI都告诉我Deepseek可以使用Tiktoken近似预估,但是我实测下来并不是这样。
测试使用NodeJS,Python也有同样的库,只是接口略有差异。
方案一:Tiktoken ------ OpenAI 的"官方答案"
javascript
const tiktoken = require('tiktoken');
const enc = tiktoken.get_encoding('cl100k_base');
const tokens = enc.encode(text).length;
Tiktoken 是 OpenAI 开源的 BPE 分词器,速度快、体积小,如果你的模型就是 GPT 系列,那它堪称完美。
优点:
- 闪电般的速度:初始化<100毫秒完成,编码只需 2-3 毫秒
- 轻量级:纯 Rust 实现,Node 端只是薄封装
- OpenAI 官方 :用
cl100k_base预估 GPT-4/GPT-3.5,基本零误差
但问题来了------你的 Agent 只用 OpenAI 吗?
方案二:HuggingFace Tokenizers ------ 多模型兼容的"瑞士军刀"
javascript
const { Tokenizer } = require('@huggingface/tokenizers');
const tokenizerJson = JSON.parse(fs.readFileSync('qwen3.json', 'utf-8'));
const tokenizer = new Tokenizer(tokenizerJson, {});
const encoded = tokenizer.encode(text);
HF Tokenizers 是 HuggingFace 生态的底层基础设施,支持从 Qwen、DeepSeek 到 GLM-4 等几乎所有开源模型。
优点:
- 模型覆盖广:只要拿到 tokenizer.json,就能 1:1 复现
- 精准匹配:与模型实际行为一致,无"方言"差异
- 可定制:支持自定义预处理、后处理规则
代价:
- 初始化慢:首次加载需要 300-500 毫秒
- 编码稍慢:单次编码 6-94 毫秒,取决于文本长度
实测对决:中英混合战场
为了公平公正,我准备了 6 份测试文档------5 份技术文档(中英混合)和 1 份 14 万字的中文短篇小说,分别用 Qwen3、DeepSeek、GLM-4 的 tokenizer 与 Tiktoken 的 cl100k_base 进行对比。
小规模文本(2K-4K 字符)
| 模型 | HF Tokens | Tiktoken | 差异 | 偏差率 |
|---|---|---|---|---|
| Qwen3 | 1,026 | 1,225 | -199 | -16.24% |
| DeepSeek | 1,129 | 1,225 | -96 | -7.84% |
| GLM-4 | 972 | 1,225 | -253 | -20.65% |
初步结论 :在小文本场景下,Tiktoken 普遍高估 7%-20% 的 Token 数。这意味着如果你用 Tiktoken 预估 Qwen3 的调用成本,会多算 16% 的预算------对成本控制来说,这是"宁可错杀"的安全边际;对用户体验来说,这是"虚惊一场"的过度警告。
大规模纯中文文本(14万字)
这才是真正的考验:
| 模型 | HF Tokens | Tiktoken | 差异 | 偏差率 |
|---|---|---|---|---|
| Qwen3 | 96,906 | 182,289 | -85,383 | -46.84% |
| DeepSeek | 87,057 | 182,289 | -95,232 | -52.24% |
| GLM-4 | 93,035 | 182,289 | -89,254 | -48.96% |
结果显而易见 Tiktoken 在纯中文长文本上翻倍预估 !182K vs 87K,差出一倍的上下文空间。如果你的 Agent 用 Tiktoken 做预检,可能会误判上下文溢出,提前触发不必要的压缩策略,甚至拒绝服务。
性能对比
基于我的本地环境测试,不同环境可能略有差异,但是结论不变
| 指标 | HF Tokenizers | Tiktoken |
|---|---|---|
| 首次初始化 | 300-500ms | ~90ms |
| 复用后编码(平均) | 93-122ms | 11-14ms |
| 内存占用 | 较高(需缓存 JSON) | 极低 |
性能结论 :Tiktoken 在速度上确实占优,但 HF Tokenizers 的"慢"主要体现在一次性初始化成本上,复用后单次编码在 100ms 内,对 Agent 的预检流程完全可接受。
关键问题:谁更接近 API 返回的 usage?
这是本文的核心问题。我们来看一个具体例子:
假设你调用 Qwen3 API,发送了 14 万字的中文小说,API 返回
usage.prompt_tokens = 96850。你的预估工具报了多少?
| 方案 | 预估结果 | 误差 |
|---|---|---|
| HF Tokenizers (Qwen3) | 96,906 | +56 (0.06%) |
| Tiktoken (cl100k_base) | 182,289 | +85,439 (88%) |
结果一目了然 :HuggingFace Tokenizers 的预估与 API 实际返回的 usage 几乎完全一致 (误差 < 0.1%),而 Tiktoken 的偏差在中文场景下高达 88%。
为什么会这样?
-
词表差异 :Qwen/DeepSeek/GLM 等中文优化模型采用了针对中文压缩率更高的 BPE 词表,单个汉字平均 0.5-0.7 tokens,而 GPT 的
cl100k_base对中文通常 1-2 tokens/字。 -
特殊 token 处理:各模型的特殊 token 定义不同,HF Tokenizer 能精确还原。
-
预处理逻辑:部分模型(如 GLM-4)有独特的空格处理、换行符归一化逻辑,Tiktoken 无法模拟。
如何获取 tokenizer.json
巧妇难为无米之炊。获取 tokenizer 文件很简单:
从魔搭社区(推荐,国内快):
bash
# 安装魔搭 CLI
pip install modelscope
# 下载 tokenizer.json 和 tokenizer_config.json(可选,提高精度)
modelscope download --model qwen/Qwen3-8B tokenizer.json tokenizer_config.json --local_dir ./tokenizers/qwen3
从 HuggingFace:
bash
pip install huggingface-hub
huggingface-cli download Qwen/Qwen3-8B tokenizer.json tokenizer_config.json --local-dir ./tokenizers/qwen3
注意 :tokenizer_config.json 包含特殊 token 定义和 chat_template,建议一并下载以提高预估精度(不过我反正是没用,诸君自行实验吧~)。
实战建议:如何优雅地实现 Token 预估
基于以上测试,给出 Agent 项目的实战方案:
1. 多模型支持架构
javascript
class TokenEstimator {
constructor() {
this.hfCache = {}; // 缓存 HF tokenizer 实例
this.ttCache = {}; // 缓存 Tiktoken 编码器
}
async estimate(modelName, text) {
// 国产模型 → HF Tokenizers
if (['qwen3', 'deepseek', 'glm-4'].includes(modelName)) {
return this.estimateWithHF(modelName, text);
}
// OpenAI 系列 → Tiktoken
if (['gpt-4', 'gpt-3.5-turbo'].includes(modelName)) {
return this.estimateWithTiktoken('cl100k_base', text);
}
}
// 预加载常用 tokenizer,避免运行时卡顿
async warmup() {
await Promise.all([
this.loadHF('qwen3'),
this.loadHF('deepseek'),
this.loadTiktoken('cl100k_base')
]);
}
}
2. 成本控制策略
javascript
// 在消息发送前进行预算检查
async function preflightCheck(messages, model) {
const totalTokens = await estimator.estimate(model, concatMessages(messages));
const MODEL_LIMITS = {
'qwen3-72b': 32768,
'deepseek-chat': 64000,
'glm-4': 128000
};
const limit = MODEL_LIMITS[model];
// 预留 10% 的缓冲给系统提示词和输出
if (totalTokens > limit * 0.9) {
return {
allowed: false,
reason: `预估 Token 数 (${totalTokens}) 接近上下文上限 (${limit}),建议压缩历史消息`,
suggestion: compressStrategy(messages, totalTokens, limit * 0.8)
};
}
// 成本预估(以 Qwen3 0.5元/百万tokens 为例)
const estimatedCost = (totalTokens / 1e6) * 0.5;
return { allowed: true, estimatedTokens: totalTokens, estimatedCost };
}
3. 性能优化技巧
- 预加载 :在 Agent 启动时
warmup(),避免首次调用的 500ms 延迟 - 实例复用:如测试代码所示,缓存 tokenizer 实例,编码耗时从 500ms 降至 100ms
- 异步化:Token 预估放在独立线程,不阻塞主流程
- 采样预估:超长文本(>10万字)可先采样前 1000 字估算压缩率,再推算全文
精准预估是一种"工匠精神"
在 Agent 开发中,Token 预估不是"差不多就行"的辅助功能,而是成本控制的核心基础设施。
- 用 Tiktoken 预估 OpenAI 模型,是"门当户对"的精准;
- 用 HF Tokenizers 预估国产模型,是"入乡随俗"的尊重;
- 混着用,就是"指鹿为马"的灾难。
精准的预估,让 Agent 既不会浪费预算,也不会辜负用户的期待。
附:完整的测试代码(脚本是AI写的)。
javascript
const { Tokenizer } = require('@huggingface/tokenizers');
const path = require('path');
const fs = require('fs');
// 尝试加载 tiktoken(如果可用)
let tiktoken;
try {
tiktoken = require('tiktoken');
} catch (e) {
console.log('⚠️ tiktoken 未安装,将跳过 tiktoken 对比测试\n');
}
/**
* 读取 docs 目录下的所有文本文件以及 data 目录下的 txt 文件
*/
function loadTestDocuments() {
const docsDir = path.join(__dirname, '../../docs');
const dataDir = path.join(__dirname, '../../data');
const documents = [];
// 读取 docs 目录下的 Markdown 文件
if (fs.existsSync(docsDir)) {
const files = fs.readdirSync(docsDir).filter(f => f.endsWith('.md'));
for (const file of files) {
const filePath = path.join(docsDir, file);
const content = fs.readFileSync(filePath, 'utf-8');
documents.push({
name: file,
content: content,
charCount: content.length,
type: '中英混合',
});
}
} else {
console.error(`⚠️ docs 目录不存在: ${docsDir}`);
}
// 读取 data 目录下的 TXT 文件
if (fs.existsSync(dataDir)) {
const files = fs.readdirSync(dataDir).filter(f => f.endsWith('.txt'));
for (const file of files) {
const filePath = path.join(dataDir, file);
const content = fs.readFileSync(filePath, 'utf-8');
documents.push({
name: file,
content: content,
charCount: content.length,
type: '纯中文',
});
}
} else {
console.error(`⚠️ data 目录不存在: ${dataDir}`);
}
return documents;
}
/**
* 使用 HuggingFace Tokenizer 计算 Token 数(带性能监控)
*/
function countTokensHF(modelName, text, cache = {}) {
const tokenizerPath = path.join(__dirname, `../src/common/utils/tokenizer/${modelName}.json`);
if (!fs.existsSync(tokenizerPath)) {
throw new Error(`Tokenizer file not found: ${tokenizerPath}`);
}
let tokenizer;
let initTime = 0;
// 检查缓存
if (cache[modelName]) {
tokenizer = cache[modelName];
} else {
// 首次加载:读取文件并实例化
const start = Date.now();
const tokenizerJson = JSON.parse(fs.readFileSync(tokenizerPath, 'utf-8'));
tokenizer = new Tokenizer(tokenizerJson, {});
initTime = Date.now() - start;
cache[modelName] = tokenizer; // 存入缓存
}
// 统计编码耗时
const encodeStart = Date.now();
const encoded = tokenizer.encode(text);
const encodeTime = Date.now() - encodeStart;
return {
tokens: encoded.ids.length,
initTime,
encodeTime,
};
}
/**
* 使用 Tiktoken 计算 Token 数(带缓存机制)
*/
function countTokensTiktoken(encodingName, text, cache = {}) {
if (!tiktoken) {
throw new Error('tiktoken not available');
}
let enc;
let initTime = 0;
// 检查缓存
if (cache[encodingName]) {
enc = cache[encodingName];
} else {
// 首次加载:获取编码器
const start = Date.now();
enc = tiktoken.get_encoding(encodingName);
initTime = Date.now() - start;
cache[encodingName] = enc; // 存入缓存
}
// 统计编码耗时
const encodeStart = Date.now();
const tokens = enc.encode(text);
const encodeTime = Date.now() - encodeStart;
return {
tokens: tokens.length,
initTime,
encodeTime,
};
}
/**
* 运行对比测试
*/
async function runComparisonTest() {
console.log('='.repeat(80));
console.log('Token 分割对比测试:HuggingFace Tokenizers vs Tiktoken');
console.log('='.repeat(80));
console.log();
// 加载测试文档
const documents = loadTestDocuments();
if (documents.length === 0) {
console.error('❌ 没有找到测试文档');
return;
}
console.log(`📄 加载了 ${documents.length} 个测试文档:\n`);
documents.forEach((doc, idx) => {
console.log(` ${idx + 1}. ${doc.name} (${doc.charCount.toLocaleString()} 字符) [${doc.type}]`);
});
console.log();
// 定义测试配置
const testConfigs = [
{
name: 'Qwen3',
hfModel: 'qwen3',
tiktokenEncoding: 'cl100k_base',
},
{
name: 'DeepSeek',
hfModel: 'deepseek',
tiktokenEncoding: 'cl100k_base',
},
{
name: 'GLM-4',
hfModel: 'GLM-4.6',
tiktokenEncoding: 'cl100k_base',
},
];
// 对每个文档进行测试
const hfCache = {}; // 用于复用 HuggingFace Tokenizer 实例
const ttCache = {}; // 用于复用 Tiktoken 编码器实例
for (const doc of documents) {
console.log('-'.repeat(80));
console.log(`📝 文档: ${doc.name} [${doc.type}]`);
console.log(` 字符数: ${doc.charCount.toLocaleString()}`);
console.log('-'.repeat(80));
const results = [];
for (const config of testConfigs) {
const result = {
model: config.name,
hfTokens: null,
tiktokenTokens: null,
diff: null,
diffPercent: null,
hfInitTime: 0,
hfEncodeTime: 0,
ttInitTime: 0,
ttEncodeTime: 0,
};
try {
// HuggingFace Tokenizer
const hfResult = countTokensHF(config.hfModel, doc.content, hfCache);
result.hfTokens = hfResult.tokens;
result.hfInitTime = hfResult.initTime;
result.hfEncodeTime = hfResult.encodeTime;
// Tiktoken
if (tiktoken) {
const ttResult = countTokensTiktoken(config.tiktokenEncoding, doc.content, ttCache);
result.tiktokenTokens = ttResult.tokens;
result.ttInitTime = ttResult.initTime;
result.ttEncodeTime = ttResult.encodeTime;
result.diff = result.hfTokens - result.tiktokenTokens;
result.diffPercent = ((result.diff / result.tiktokenTokens) * 100).toFixed(2);
console.log(`\n ${config.name}:`);
console.log(` HF Tokens: ${result.hfTokens.toLocaleString()} (初始化: ${result.hfInitTime}ms, 编码: ${result.hfEncodeTime}ms)`);
console.log(` Tiktoken: ${result.tiktokenTokens.toLocaleString()} (初始化: ${result.ttInitTime}ms, 编码: ${result.ttEncodeTime}ms)`);
console.log(` 差异: ${result.diff > 0 ? '+' : ''}${result.diff} (${result.diffPercent}%)`);
} else {
console.log(`\n ${config.name}:`);
console.log(` HF Tokens: ${result.hfTokens.toLocaleString()} (初始化: ${result.hfInitTime}ms, 编码: ${result.hfEncodeTime}ms)`);
console.log(` Tiktoken: 不可用`);
}
results.push(result);
} catch (error) {
console.error(` ❌ ${config.model} 测试失败:`, error.message);
}
}
console.log();
}
// 总结统计
console.log('='.repeat(80));
console.log('📊 总结统计');
console.log('='.repeat(80));
if (tiktoken) {
for (const config of testConfigs) {
const modelResults = [];
let totalHfInitTime = 0;
let totalHfEncodeTime = 0;
let totalTtInitTime = 0;
let totalTtEncodeTime = 0;
for (const doc of documents) {
try {
const hfResult = countTokensHF(config.hfModel, doc.content, hfCache);
const ttResult = countTokensTiktoken(config.tiktokenEncoding, doc.content, ttCache);
modelResults.push({
hfTokens: hfResult.tokens,
ttTokens: ttResult.tokens,
diff: hfResult.tokens - ttResult.tokens,
hfInitTime: hfResult.initTime,
hfEncodeTime: hfResult.encodeTime,
ttInitTime: ttResult.initTime,
ttEncodeTime: ttResult.encodeTime
});
totalHfInitTime += hfResult.initTime;
totalHfEncodeTime += hfResult.encodeTime;
totalTtInitTime += ttResult.initTime;
totalTtEncodeTime += ttResult.encodeTime;
} catch (e) {
// 跳过错误的
}
}
if (modelResults.length > 0) {
const avgDiff = modelResults.reduce((sum, r) => sum + r.diff, 0) / modelResults.length;
const maxDiff = Math.max(...modelResults.map(r => Math.abs(r.diff)));
const totalHf = modelResults.reduce((sum, r) => sum + r.hfTokens, 0);
const totalTt = modelResults.reduce((sum, r) => sum + r.ttTokens, 0);
const overallDiffPercent = (((totalHf - totalTt) / totalTt) * 100).toFixed(2);
const avgHfEncodeTime = totalHfEncodeTime / modelResults.length;
const avgTtEncodeTime = totalTtEncodeTime / modelResults.length;
console.log(`\n${config.name}:`);
console.log(` 平均差异: ${avgDiff > 0 ? '+' : ''}${avgDiff.toFixed(2)} tokens`);
console.log(` 最大差异: ${maxDiff} tokens`);
console.log(` 总体差异: ${overallDiffPercent}%`);
console.log(` 平均编码耗时: HF=${avgHfEncodeTime.toFixed(2)}ms, TT=${avgTtEncodeTime.toFixed(2)}ms (复用实例后)`);
console.log(` 结论: ${Math.abs(parseFloat(overallDiffPercent)) < 1 ? '✅ 基本一致' : '⚠️ 存在差异'}`);
}
}
}
console.log('\n' + '='.repeat(80));
console.log('✅ 测试完成');
console.log('='.repeat(80));
}
runComparisonTest().catch(console.error);