Tokenizer 漂移:本地模型 token 计数对不上

应用层的 token 计数与本地 llama.cpp 或 Ollama 服务对不上,导致上下文溢出或静默截断。用服务端自带的 tokenizer 作为基准来消除漂移。

你的 Python 应用用 tiktoken 预估 token 数,想把 prompt 控制在 8192 上限内。你算出来 7,900 token,留了点余量就把请求发给本地运行 Llama 3.1 的 Ollama 或 llama-server。结果服务端报 context length exceeded,更糟的情况是它静默截断了 prompt,拿你上下文的另一半去回答。差异是真实存在的:tiktoken 用的是 OpenAI 的词表,你的模型用的是它自己的词表,同一段文本在两边的计数本来就不一样。

最快的修复: 别再相信客户端的估算值。直接问服务端它自己数出来是多少——用你正在部署的那个模型内嵌的 tokenizer。对 llama-server,调用 POST /tokenize 并读回 token 数组的长度。对 Ollama,把 prompt 发一次,从响应里读 prompt_eval_count。这个数字才是基准,让你的预算去对齐它。下面解释计数为什么会漂移,以及怎么把两边锁死。

先排除那个”静默杀手”:Ollama 默认只有 4096 token

在你调试 tokenizer 之前,先确认服务端用的上下文大小是不是你以为的那个。截至 2026 年 6 月,如果你不显式覆盖,Ollama 的 num_ctx 默认值仍然是 4096 token;当 prompt 超过这个值时,它不会报错——而是静默丢掉最早的 token,拿剩下的部分去回答。所以你以为的”tokenizer 不匹配”,实际上可能是上下文大小不匹配:客户端以为窗口是 8192,服务端却在跑 4096。

确认当前生效的上下文大小:

# 查看已加载模型实际使用的上下文大小
ollama ps
# "CONTEXT" 一列显示生效中的 num_ctx,例如 4096

# 按请求覆盖(OpenAI 兼容 API 需要把 num_ctx 放进 options,而不是命令行 flag)
curl http://localhost:11434/api/generate -d '{
  "model": "llama3.1:8b",
  "prompt": "ping",
  "options": {"num_ctx": 8192},
  "stream": false
}' | python3 -c "import json,sys; print(json.load(sys.stdin)['prompt_eval_count'])"

一个已知的坑(截至 2026 年 6 月):OpenAI 兼容的 /v1/chat/completions 端点会忽略 num_ctx,除非你把它放进 options 传过去;而 OLLAMA_CONTEXT_LENGTH 环境变量可能因为显存不够又被压回 4096。所以要么按请求显式设置,要么在 Modelfile 里写 PARAMETER num_ctx。如果调大 num_ctx 之后”不一致”就消失了,那你从来就没有漂移过——你是被截断了。

常见原因

按命中率从高到低排序。

1. 给非 OpenAI 模型用了 tiktoken

tiktoken 是为 GPT 模型设计的(GPT-4 用 cl100k_base,GPT-4o / GPT-5 一类用 o200k_base)。拿它去给 Llama、Mistral、Qwen 数 token,词表根本就是错的。一段带花括号或 emoji 的代码,tiktoken 数出来 3 token,模型自带的 tokenizer 可能数出 7 token。中文或中英混排的差异通常在 15-30%。

怎么判断:用同一段文本分别跑两个 tokenizer 对比。纯英文差异超过约 5%(或 CJK / 代码出现任何差异)就说明 tokenizer 用错了。

2. tokenizer 家族搞错了——而且 Llama 3 不是 SentencePiece

一个值得记住的纠正:Llama 3 和 3.1 不用 SentencePiece,它们用的是基于 tiktoken 的 BPE tokenizer,词表大小 128,256(实际上它复用了 GPT-4 cl100k_base 的大部分合并规则,再加上额外 token)。只有 Llama 2 用 SentencePiece(32,000 词表)。Mistral 和不少 Qwen 版本仍然是 SentencePiece。所以”我跑的是 Llama,tiktoken 应该差不多”只对了一半:Llama 3 是 BPE 系,英文上确实更接近 GPT-4,但它 128k 的词表对代码和 CJK 的切分仍然不同,而 Llama 2 则差得很远。

模型tokenizer 类型词表大小接近 tiktoken?
Llama 2 / Code LlamaSentencePiece32,000否——多数文本计数高很多
Llama 3 / 3.1 / 3.3基于 tiktoken 的 BPE128,256英文接近,代码 / CJK 偏差
Mistral / MixtralSentencePiece32,000–32,768
Qwen 2.5 / 3基于 tiktoken 的 BPE~151,000否——自己的词表

怎么判断:打印词表大小,和你预期的对一下。

python3 -c "from transformers import AutoTokenizer; \
t=AutoTokenizer.from_pretrained('meta-llama/Llama-3.1-8B-Instruct'); \
print(t.vocab_size)"   # Llama 3.x 应为 128256

3. 计数时没套 chat template

chat template 会注入特殊 token(<|begin_of_text|><|start_header_id|>system<|end_header_id|><|eot_id|> 等)。每个都有 token ID,都要占窗口。如果你只数原始消息内容,服务端的实际占用就会系统性地偏高。

怎么判断:分别用和不用 apply_chat_template 计数,差值就是 template 开销。Llama 3.1 带系统提示词时,每轮对话大约多 20-40 个 token,全是 template token。

4. add_special_tokens 不一致(BOS/EOS)

在 HuggingFace 里,tokenizer.encode(text, add_special_tokens=True) 会带上 BOS/EOS,add_special_tokens=False 不带。推理服务通常会加 BOS。如果你的计数器用 add_special_tokens=False,每段就会少算 1-3 个 token——在 20 万的预算里无所谓,但当你卡着边界精确切片时就是致命的。注意服务端这边是对称的:llama-server/tokenize 默认 add_specialfalse,所以公平比较时两边要设成一样。

怎么判断:对比 len(tokenizer.encode(text))len(tokenizer.encode(text, add_special_tokens=True)),看你代码里用的是哪个。

5. 被改过或重新量化的 GGUF tokenizer

GGUF 文件内嵌了词表和 byte-fallback 规则。有些社区量化版在打包时重新生成或裁剪了词表(合并 token、删掉低频词),导致 GGUF 内嵌的 tokenizer 和官方 HuggingFace 版本对不上。归一化规则(空白处理、Unicode 形式)不同,即使输入是完全相同的 UTF-8,也会漂移。

怎么判断:把同一段字节串分别发给 /tokenize 端点和你的 HF tokenizer。如果 token ID 不一样,说明内嵌词表已经分叉了——以 GGUF 为准。

6. 系统提示词没算进预算

很多 RAG 流程只数用户问题和检索到的 chunk,忘了系统提示词。一个 500 token 的系统提示词放进 8192 的窗口,可用的是 7,692 token,不是 8,192。

怎么判断:把 system_prompt_tokens + all_message_tokens + expected_completion_tokens 加起来。如果超过生效中的 num_ctx,就会溢出或被截断。

我属于哪一类?

现象最可能的原因跳转
计数对得上但输出还是被切 / 上下文”变短”num_ctx 默认 4096,静默截断上面那节
英文文本,客户端计数远低于服务端tokenizer 用错(给 Llama/Qwen 用了 tiktoken)原因 1、2
每轮固定差 20-40 token没数 chat template原因 3
一直差 1-3 tokenadd_special_tokens / BOS 不一致原因 4
只在特定字符 / 代码上差GGUF 词表被改过或归一化不同原因 5
差值正好等于系统提示词长度系统提示词没算进预算原因 6

最短修复路径

Step 1:给模型用对 tokenizer

from transformers import AutoTokenizer

# 正确:用模型对应的 tokenizer
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")

def count_tokens(text: str) -> int:
    return len(tokenizer.encode(text, add_special_tokens=False))

# 错误:给 Llama 模型用 tiktoken
# import tiktoken
# enc = tiktoken.encoding_for_model("gpt-4")  # 不要用在 Llama/Qwen/Mistral 上

Step 2:把 chat template 算进 token 数

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B-Instruct")

def count_chat_tokens(messages: list[dict]) -> int:
    """统计完整 chat 负载的 token 数,含 template 开销。"""
    templated = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
    )
    return len(templated)

messages = [
    {"role": "system", "content": "You are a helpful assistant."},
    {"role": "user", "content": "Explain Docker networking in detail."},
]
print(f"Total tokens: {count_chat_tokens(messages)}")

Step 3:用服务端自带的 tokenizer 交叉校验(基准)

服务端内嵌的 tokenizer 是推理时唯一算数的计数。让客户端去对齐它,而不是反过来。

# llama-server:POST /tokenize 返回 {"tokens": [id, id, ...]}。
# 数数组长度即可。add_special 默认为 false,两边设成一样才公平。
curl -s http://localhost:8080/tokenize \
  -H "Content-Type: application/json" \
  -d '{"content": "your text here", "add_special": false}' \
  | python3 -c "import json,sys; print('server tokens:', len(json.load(sys.stdin)['tokens']))"

# Ollama:把 prompt 发一次,读 prompt_eval_count。
curl -s http://localhost:11434/api/generate \
  -d '{"model": "llama3.1:8b", "prompt": "your text here", "stream": false}' \
  | python3 -c "import json,sys; print('prompt tokens:', json.load(sys.stdin)['prompt_eval_count'])"

目标是误差在 1-3 token 以内(仅 BOS/EOS 差异)。差距更大且稳定,就直指上面的原因 2-5。

Step 4:搭一个把每个组成部分都算上的预算计算器

def check_context_budget(
    system_prompt: str,
    user_message: str,
    context_chunks: list[str],
    max_context: int = 8192,
    max_completion: int = 1024,
    safety_margin: int = 128,
) -> dict:
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_message + "\n\n" + "\n\n".join(context_chunks)},
    ]
    prompt_tokens = count_chat_tokens(messages)
    total_needed = prompt_tokens + max_completion + safety_margin

    return {
        "prompt_tokens": prompt_tokens,
        "max_completion": max_completion,
        "total_needed": total_needed,
        "available": max_context,         # 必须等于服务端生效的 num_ctx
        "fits": total_needed <= max_context,
        "overflow": max(0, total_needed - max_context),
    }

max_context 要填 ollama psllama-server 启动 flag 里生效中的 num_ctx,而不是模型理论上的最大值。

Step 5:用边界字符测试 tokenizer 一致性

test_cases = [
    "Hello, world!",
    "def foo(x: dict[str, int]) -> None:",
    "Unicode emoji test",
    "https://example.com/path?query=value&key=123",
    "<|begin_of_text|>",  # 特殊 token——不应被意外拆开
    "你好,世界",          # CJK——漂移最大的来源
]

for text in test_cases:
    client_count = count_tokens(text)
    server_count = get_server_token_count(text)  # 经 /tokenize 或 prompt_eval_count
    diff = abs(client_count - server_count)
    print(f"{text[:30]!r}: client={client_count}, server={server_count}, diff={diff}")

任何超过 2-3 token 的差异都标记了词表或归一化不匹配。

怎么确认已经修好

满足以下三点就算修好:

  1. ollama ps(或你的 llama-server -c flag)显示的上下文大小,和你应用假定的一致——没有静默回落到 4096。
  2. 对十段不同文本(英文、代码、CJK),客户端计数和服务端计数误差在 1-3 token 以内。
  3. 一个大小为 num_ctx - safety_margin 的请求能正常跑完,不报 context length exceeded,且响应里的 prompt_eval_count 低于 num_ctx

预防建议

  • 绝不给非 OpenAI 模型用 tiktoken——永远加载模型对应的 tokenizer(HuggingFace AutoTokenizer,或经 /tokenize 用 GGUF 自带的)。
  • 把服务端的计数(/tokenize 长度或 prompt_eval_count)当基准,让客户端去校准它。
  • 始终用 apply_chat_template 计数,把 template token 算进预算。
  • 显式锁定 num_ctx(请求 optionsModelfile-c),别一不小心继承了 4096 默认值。
  • num_ctx 之下留至少 128 token 的安全余量(CJK 内容多时留 10-15%)。
  • 换模型版本(Llama 2 到 3,或任何重新量化的 GGUF)时重测预算——词表不一样。
  • 在启动时缓存 tokenizer;HF tokenizer 每次加载要 100-500ms。
  • requirements.txt 里钉死 transformers 版本;小版本之间 tokenizer 行为可能变。

常见问答 (FAQ)

Q: 同一段文本,为什么 tiktoken 数出来比 Llama 3 少? A: tiktokencl100k_base(约 10 万 token)和 Llama 3 的 BPE 词表(128,256 token)在英文上大量重叠,但在代码和非拉丁文字上分叉。Llama 3 经常把花括号、运算符、CJK 字符切成独立 token,所以那里计数更高。两者都是 BPE,只是词表不同——接近不等于相等。

Q: Llama 的 tokenizer 不是 SentencePiece 吗? A: 只有 Llama 2(以及 Code Llama、Mistral)是。Llama 3、3.1、3.3 已经换成基于 tiktoken 的 BPE tokenizer,词表大小 128,256。如果你一直把 Llama 3 当 SentencePiece 处理,这很可能就是你漂移的根源。

Q: 怎么在 Ollama 上数 token 又不浪费一整次生成? A: 截至 2026 年 6 月,Ollama 没有独立的 tokenize 端点,所以把 prompt 配一个极小的生成发过去("options": {"num_predict": 1}),读 prompt_eval_count。要做到真正零成本计数,就用 HuggingFace tokenizer 镜像同一模型,或用 llama-server/tokenize——后者不跑模型。

Q: llama.cpp 的 /tokenize 端点和 GGUF 推理时用的一致吗? A: 一致。/tokenize 用的就是 GGUF 内嵌、推理引擎也在用的那个 tokenizer,所以它是该文件的基准。记住它默认 add_specialfalse,比较时客户端要设成一样。

Q: 多模型部署里的漂移怎么处理? A: 维护一个按模型名索引的 tokenizer 注册表({"llama3.1": ..., "mistral": ..., "qwen2.5": ...}),把每次预计数都路由到对应 tokenizer。绝不要在不同家族之间共用一个 tokenizer。

相关阅读

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