在应用层用 tiktoken 或 HuggingFace transformers 统计 prompt 的 token 数,计算结果是 4096 token,但实际发送给本地 llama.cpp 服务时,模型端认为 prompt 有 4312 token,导致本以为留给生成的 1024 token 空间只剩 808 token,生成被提前截断。这种 token 计数不一致的现象在混用不同 tokenizer 实现时很常见,尤其是中文内容中一个汉字对应的 token 数在不同 tokenizer 实现下可能不同。
常见原因
1. 应用层使用了不匹配的 tokenizer 库
最常见的情况:代码里用 tiktoken 的 cl100k_base 编码器计算 token 数,但底层模型是 Llama-3(使用 llama3 tokenizer,词表大小 128256)。两个 tokenizer 对相同文本会产生不同的 token 序列和计数,中文内容的差异尤为明显。
怎么判断:用同一段文本分别测试应用层 tokenizer 和模型实际使用的 tokenizer,统计 token 数。若中文文本差异超过 5%,说明 tokenizer 不匹配。
2. GGUF 文件内嵌了修改过的 tokenizer
某些社区量化版本在生成 GGUF 时修改了词表(如合并某些中文 token 或删除低频词),导致 GGUF 内嵌的 tokenizer 与 HuggingFace 官方版本不一致。用官方 tokenizer 计算的 token 数与模型实际处理的不同。
怎么判断:提取 GGUF 内嵌的 tokenizer 词表大小,与 HuggingFace 官方词表比较。Llama-3 官方词表 128256,若 GGUF 内嵌词表大小不同,说明被修改过。
3. sentencepiece 库版本不一致
Llama-2 系列使用 sentencepiece 作为 tokenizer,不同版本的 sentencepiece(0.1.99 vs 0.2.0)对某些特殊字符和多字节 UTF-8 序列的处理有差异,导致相同文本的 token 数在不同版本下不同。
怎么判断:pip show sentencepiece | grep Version 检查版本;对比开发机和生产环境的 sentencepiece 版本是否一致。
4. HuggingFace tokenizer 的 add_special_tokens 选项影响计数
tokenizer.encode(text, add_special_tokens=True) 会在序列前后添加 BOS/EOS token(增加 1-2 个 token),而 add_special_tokens=False 不添加。应用层若不统一这个选项,与模型实际处理时(通常会加 BOS)的计数就会差 1-2 个 token。对于长上下文,1-2 个 token 的差异无关紧要,但在精确的上下文边界控制场景下会造成误判。
怎么判断:检查代码中所有 tokenizer.encode() 调用,统计 add_special_tokens 参数是否一致。
5. 本地模型与 API 服务的 tokenizer 版本不同
在混合使用本地部署和云 API(如同时用本地 llama.cpp 和 OpenAI API)的架构中,两者的 tokenizer 完全不同。即使都叫”Llama-3”,llama.cpp 用 GGUF 内嵌的 tokenizer,云服务可能用了更新或微调过的版本。
怎么判断:对同一段测试文本,分别向本地服务和云 API 请求 token 计数(通过 /v1/models 的 tokenizer 接口或 echo token 数),对比结果。
6. 中文文本的 token 分割边界不一致
不同 tokenizer 对中文的处理策略不同:sentencepiece 通常将中文字符拆分为字或短词;BPE tokenizer 可能将常见中文词汇合并为单个 token。同一段中文,sentencepiece 可能计数 1000 token,而 BPE 只计数 700 token,差异达 30%。
怎么判断:用 tokenizer.convert_ids_to_tokens(tokenizer.encode("你好,世界")) 查看实际分割结果,中文是否被拆成单字 token 或合并词 token。
最短修复路径
Step 1:用 GGUF 内嵌的 tokenizer 统计 token 数
from llama_cpp import Llama
# 用与模型完全相同的 tokenizer 统计 token 数
llm = Llama(model_path="model-Q4_K_M.gguf", n_ctx=0, verbose=False)
def count_tokens(text: str) -> int:
tokens = llm.tokenize(text.encode("utf-8"), add_bos=True)
return len(tokens)
# 测试
text = "你好,这是一段中文测试文本。" * 50
print(f"GGUF tokenizer 计数: {count_tokens(text)}")
Step 2:HuggingFace tokenizer 对齐
from transformers import AutoTokenizer
# 使用与模型完全匹配的 HuggingFace tokenizer
MODEL_ID = "meta-llama/Meta-Llama-3-8B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
def count_tokens_hf(text: str, add_special_tokens: bool = True) -> int:
return len(tokenizer.encode(text, add_special_tokens=add_special_tokens))
# 与 GGUF tokenizer 对比
text = "你好,这是一段中文测试文本。" * 50
print(f"HuggingFace tokenizer 计数: {count_tokens_hf(text)}")
# 两个计数应该相差不超过 2(BOS/EOS 差异)
Step 3:排查 tiktoken 误用
import tiktoken
# tiktoken 只适用于 OpenAI 模型(GPT-3.5、GPT-4 等)
# 不要用它计算 Llama/Qwen/Mistral 的 token 数
enc = tiktoken.get_encoding("cl100k_base")
print("tiktoken cl100k:", len(enc.encode("你好,这是一段中文测试文本。" * 50)))
# 与 llama3 tokenizer 比较
# 中文文本的差异通常在 15-30%
Step 4:实现统一的 token 计数工具
from functools import lru_cache
from transformers import AutoTokenizer
from typing import Optional
class UnifiedTokenCounter:
"""统一的 token 计数工具,支持多种模型"""
_tokenizers: dict = {}
@classmethod
def get_tokenizer(cls, model_id: str):
if model_id not in cls._tokenizers:
cls._tokenizers[model_id] = AutoTokenizer.from_pretrained(
model_id, use_fast=True
)
return cls._tokenizers[model_id]
@classmethod
def count(cls, text: str, model_id: str, add_special_tokens: bool = False) -> int:
tok = cls.get_tokenizer(model_id)
return len(tok.encode(text, add_special_tokens=add_special_tokens))
@classmethod
def truncate_to_limit(cls, text: str, model_id: str, max_tokens: int) -> str:
tok = cls.get_tokenizer(model_id)
tokens = tok.encode(text, add_special_tokens=False)
if len(tokens) <= max_tokens:
return text
return tok.decode(tokens[:max_tokens])
# 使用示例
counter = UnifiedTokenCounter()
text = "很长的中文文档内容..." * 100
token_count = counter.count(text, "meta-llama/Meta-Llama-3-8B-Instruct")
print(f"Token 数: {token_count}")
Step 5:在 Ollama API 中获取精确 token 计数
# Ollama 提供了 token 计数 API
curl http://localhost:11434/api/generate \
-d '{
"model": "llama3:8b",
"prompt": "你好,这是测试文本",
"raw": true,
"stream": false
}' | python3 -c "import json,sys; r=json.load(sys.stdin); print('prompt tokens:', r.get('prompt_eval_count'))"
预防建议
- 在项目中统一使用一种 tokenizer 来计数,不要在不同模块混用 tiktoken 和 HuggingFace tokenizer。
- 建立 token 计数校准测试:用 10 段标准测试文本(英文、中文、代码各若干),记录每种 tokenizer 的计数,作为版本基准。
- 定期(每次更新 tokenizer 库时)重新运行校准测试,若与基准差异超过 1%,触发告警。
- 在 RAG 系统中,使用与 embedding 模型配套的 tokenizer 来控制 chunk 大小,而不是通用的字符数限制。
- 对于上下文接近限制的场景,在应用层保留 10-15% 的安全余量,而不是精确到最大 token 数,以吸收 tokenizer 差异带来的误差。
- 中文内容的 token 计数切勿简单地用”字符数 / N”估算,不同 tokenizer 的差异可能高达 50%。
常见问答 (FAQ)
Q: 一个汉字在不同 tokenizer 下各是多少 token? A: 差异很大。GPT-4 的 cl100k_base 中,一个汉字约 0.6 个 token(常见汉字是 1 token,生僻字可能是 2-3 token);Llama-3 的 tokenizer 一个汉字约 1-1.5 个 token;旧的 sentencepiece(Llama-2)一个汉字可能是 2-4 个 token(按 UTF-8 字节拆分)。中文文本的 token 密度差异是英文的 3-5 倍。
Q: transformers 的 fast tokenizer 和普通 tokenizer 的计数会不同吗?
A: 对于大多数现代模型,fast tokenizer(基于 Rust 的 HuggingFace tokenizers 库)和普通 tokenizer(Python 实现)的分词结果完全相同,计数一致。极少数情况下对特殊字符的处理有微小差异,但不影响实际使用。
Q: 如何在没有网络的环境中使用 HuggingFace tokenizer?
A: 先在有网络的环境中下载 tokenizer 文件:tokenizer = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B-Instruct") 会自动缓存到 ~/.cache/huggingface/,然后将缓存目录拷贝到离线环境,通过 from_pretrained("/path/to/local/tokenizer") 加载。
Q: vLLM 的 token 计数和 llama.cpp 一样吗? A: 若使用同一模型(同一个 HuggingFace 仓库),两者使用相同的 tokenizer,计数应该一致(差异在 ±1 token 以内,取决于 BOS/EOS 处理)。若模型来源不同(如 vLLM 用 HF 原始权重,llama.cpp 用社区 GGUF),tokenizer 可能有细微差异。
相关阅读
- Chat template 不匹配导致输出全是乱码
- 本地模型输出在 token 中间被截断
- RoPE scaling 设错让长上下文输出乱掉
- vLLM 报 context length exceeded
- ChatGPT 上下文窗口超限了怎么办
标签: #local-llm #llama-cpp #排查