你的 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 Llama | SentencePiece | 32,000 | 否——多数文本计数高很多 |
| Llama 3 / 3.1 / 3.3 | 基于 tiktoken 的 BPE | 128,256 | 英文接近,代码 / CJK 偏差 |
| Mistral / Mixtral | SentencePiece | 32,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_special 为 false,所以公平比较时两边要设成一样。
怎么判断:对比 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 token | add_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 ps 或 llama-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 的差异都标记了词表或归一化不匹配。
怎么确认已经修好
满足以下三点就算修好:
ollama ps(或你的llama-server -cflag)显示的上下文大小,和你应用假定的一致——没有静默回落到 4096。- 对十段不同文本(英文、代码、CJK),客户端计数和服务端计数误差在 1-3 token 以内。
- 一个大小为
num_ctx - safety_margin的请求能正常跑完,不报context length exceeded,且响应里的prompt_eval_count低于num_ctx。
预防建议
- 绝不给非 OpenAI 模型用
tiktoken——永远加载模型对应的 tokenizer(HuggingFaceAutoTokenizer,或经/tokenize用 GGUF 自带的)。 - 把服务端的计数(
/tokenize长度或prompt_eval_count)当基准,让客户端去校准它。 - 始终用
apply_chat_template计数,把 template token 算进预算。 - 显式锁定
num_ctx(请求options、Modelfile或-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: tiktoken 的 cl100k_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_special 为 false,比较时客户端要设成一样。
Q: 多模型部署里的漂移怎么处理?
A: 维护一个按模型名索引的 tokenizer 注册表({"llama3.1": ..., "mistral": ..., "qwen2.5": ...}),把每次预计数都路由到对应 tokenizer。绝不要在不同家族之间共用一个 tokenizer。
相关阅读
- Chat template 不匹配导致输出全是乱码
- 本地模型输出在 token 中间被截断
- RoPE scaling 设错让长上下文输出乱掉
- vLLM 报 context length exceeded
- ChatGPT 上下文窗口超限了怎么办
- 本地模型不遵守 tool calling 格式
标签: #local-llm #llama-cpp #排查