Tokenizer 版本不一致导致 token 计数对不上

本地模型的 token 计数与 API 端不一致,导致截断位置错误或上下文溢出。从 tokenizer 版本管理、GGUF 内嵌 tokenizer 与外部库的差异给出对齐方案。

在应用层用 tiktoken 或 HuggingFace transformers 统计 prompt 的 token 数,计算结果是 4096 token,但实际发送给本地 llama.cpp 服务时,模型端认为 prompt 有 4312 token,导致本以为留给生成的 1024 token 空间只剩 808 token,生成被提前截断。这种 token 计数不一致的现象在混用不同 tokenizer 实现时很常见,尤其是中文内容中一个汉字对应的 token 数在不同 tokenizer 实现下可能不同。

常见原因

1. 应用层使用了不匹配的 tokenizer 库

最常见的情况:代码里用 tiktokencl100k_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 可能有细微差异。

相关阅读

标签: #local-llm #llama-cpp #排查