不下载模型权重,只用从魔搭下载的 Qwen2.5 分词器(11.5 MB),就能做一件很实用的事:在调用大模型之前,算清楚 token 预算。 这篇写一个
budget.py:
- 统计中文、英文和代码各自的 token 密度;
- 算出 chat template 本身占多少 token;
- 按 token 预算把长文切块,并保证每一块都不会切坏字符。
先看清成品(box 上真实运行)
- 每个 token 平均对应的字符数:中文 1.71,英文 5.33,代码 3.21。
- 一句 13 token 的对话,套用模板后变成 26 token,模板开销是 13。
- 预算 120 时,长文被切成 102、102、71 token 三块,每块加上 18 token 的提示词模板都不超过 120。
- 修复 emoji 被切坏的问题之后,pytest 26 项通过(预算从 40 到 193,共 23 种)。
合规提醒:从魔搭下载的每个模型都有自己的许可证(本文用的 Qwen2.5-0.5B-Instruct 仓库里是 Apache License),商用前先读 LICENSE 和模型卡。访问令牌(ms- 开头)只放在环境变量里,不要共享账号或令牌,也不要用多个账号轮换来绕过限额。免费推理服务的额度、并发和使用条件以魔搭官方页面为准,平台可能随时调整,本文不写具体数字和价格。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
budget.py |
三个子命令:count、chat、split |
samples/ |
中文、英文、代码三个样本 |
test_budget.py |
26 项测试(需要先用 61A 下载分词器到 models/qwen25-05b) |
需要准备哪些环境?
Python 3.10 以上;pip install transformers pytest(box 上 transformers 的版本是 5.20.2,不需要 torch)。分词器用 61A 下载:python ms_fetch.py Qwen/Qwen2.5-0.5B-Instruct --dir models/qwen25-05b。
文件结构
61-modelscope-C-src/
├── budget.py
├── samples/{zh.txt,en.txt,code.py}
└── test_budget.py
第 1 步:预算工具
"""用从魔搭下载的 Qwen2.5 分词器做“上下文预算”(不需要下载模型权重)。
- count:统计每个文件的字符数、token 数和 字符/token 比值
- chat:套用模型自带的 chat template,计算整条对话实际占用多少 token(模板本身也占 token)
- split:按 token 数把长文切块(带重叠),保证每块加上提示词都不超出预算"""
import argparse, pathlib, sys
from transformers import AutoTokenizer
def load(model_dir):
return AutoTokenizer.from_pretrained(model_dir) # 只读取本地文件,不联网
def count(tok, text):
return len(tok.encode(text, add_special_tokens=False))
def chat_tokens(tok, system, user):
msgs = [{"role": "system", "content": system}, {"role": "user", "content": user}]
ids = tok.apply_chat_template(msgs, tokenize=True, add_generation_prompt=True)
if isinstance(ids, dict) or hasattr(ids, "keys"): # 不同 transformers 版本返回值不同
ids = ids["input_ids"]
return len(ids)
def split(tok, text, budget, prompt_tokens, overlap=32):
size = budget - prompt_tokens
if size <= overlap:
raise ValueError(f"预算 {budget} 扣掉提示词 {prompt_tokens} 后只剩 {size},不够切块")
ids = tok.encode(text, add_special_tokens=False)
out, i = [], 0
while i < len(ids):
j = min(i + size, len(ids))
# 字节级 BPE 中,一个 emoji 或生僻字可能占多个 token;在中间切开会解码出 U+FFFD(�)。
# 把切点往前挪,直到解码干净(原文自带 � 的除外)
while j > i + 1 and "\ufffd" in tok.decode(ids[i:j]) and "\ufffd" not in text:
j -= 1
out.append(tok.decode(ids[i:j]))
if j >= len(ids):
break
i = max(j - overlap, i + 1)
while i < j and "\ufffd" in tok.decode(ids[i:j]) and "\ufffd" not in text: # 重叠起点同样不能落在字符中间
i += 1
return out
def main(argv=None):
ap = argparse.ArgumentParser()
ap.add_argument("--model-dir", required=True)
sub = ap.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("count"); c.add_argument("files", nargs="+")
h = sub.add_parser("chat"); h.add_argument("--system", default="你是一个有帮助的助手。"); h.add_argument("user")
s = sub.add_parser("split"); s.add_argument("file"); s.add_argument("--budget", type=int, required=True)
s.add_argument("--system", default="根据资料回答问题。"); s.add_argument("--overlap", type=int, default=32)
a = ap.parse_args(argv)
tok = load(a.model_dir)
if a.cmd == "count":
for f in a.files:
t = pathlib.Path(f).read_text(encoding="utf-8"); n = count(tok, t)
print(f"{f}: {len(t)} 字符 / {n} token / {len(t) / n:.2f} 字符每 token")
elif a.cmd == "chat":
raw = count(tok, a.system) + count(tok, a.user)
full = chat_tokens(tok, a.system, a.user)
print(f"正文 {raw} token,套用模板后 {full} token(模板开销 {full - raw})")
else:
p = chat_tokens(tok, a.system, "")
parts = split(tok, pathlib.Path(a.file).read_text(encoding="utf-8"), a.budget, p, a.overlap)
for i, x in enumerate(parts, 1):
print(f"块 {i}: {count(tok, x)} token(加上提示词模板 {p} token,总计 ≤ {a.budget})")
return 0
if __name__ == "__main__":
sys.exit(main())
第 2 步:测试
import os, pytest
import budget as B
M = os.environ.get("MODEL_DIR", "models/qwen25-05b")
pytestmark = pytest.mark.skipif(not os.path.exists(M), reason="先用 61A 的 ms_fetch.py 下载分词器")
@pytest.fixture(scope="module")
def tok():
return B.load(M)
TEXT = open("samples/zh.txt", encoding="utf-8").read() * 3 + "😀表情符号测试🎉" * 20
def test_density_differs(tok):
zh, en = (open(f"samples/{f}", encoding="utf-8").read() for f in ("zh.txt", "en.txt"))
assert len(zh) / B.count(tok, zh) < 2 < 5 < len(en) / B.count(tok, en)
def test_template_overhead(tok):
assert B.chat_tokens(tok, "你好", "世界") > B.count(tok, "你好") + B.count(tok, "世界")
@pytest.mark.parametrize("budget", range(40, 200, 7))
def test_split_within_budget_and_clean(tok, budget):
p = B.chat_tokens(tok, "根据资料回答问题。", "")
parts = B.split(tok, TEXT, budget, p, overlap=8)
assert all(B.count(tok, x) + p <= budget and "\ufffd" not in x for x in parts)
assert parts[-1].endswith("🎉")
def test_budget_too_small(tok):
with pytest.raises(ValueError, match="不够切块"):
B.split(tok, TEXT, 30, 25, overlap=8)
第 3 步:运行
$ python budget.py --model-dir models/qwen25-05b count samples/zh.txt samples/en.txt samples/code.py
samples/zh.txt: 123 字符 / 72 token / 1.71 字符每 token
samples/en.txt: 357 字符 / 67 token / 5.33 字符每 token
samples/code.py: 138 字符 / 43 token / 3.21 字符每 token
$ python budget.py --model-dir models/qwen25-05b chat "用一句话介绍魔搭社区"
正文 13 token,套用模板后 26 token(模板开销 13)
$ python budget.py --model-dir models/qwen25-05b split long.txt --budget 120
块 1: 102 token(加上提示词模板 18 token,总计 ≤ 120)
块 2: 102 token(加上提示词模板 18 token,总计 ≤ 120)
块 3: 71 token(加上提示词模板 18 token,总计 ≤ 120)
$ python -m pytest -q
26 passed
这些数字只适用于 Qwen2.5 的分词器。换模型后要用对应模型的分词器重新计算。
调试示例
1. emoji 被切成了乱码。 第一版直接按 token 下标切片后解码。我在文本里加入 emoji,并把预算从 40 扫到 199,结果有 11 种预算切出的块里含有 \ufffd(�),比如预算为 43、44、45、48、59 时。原因是字节级 BPE 会把一个 emoji 拆成多个 token,切点落在中间就解码不出来了。修复方法是:切点向前挪,直到解码结果干净;重叠部分的起点同样处理。修复后,参数化测试覆盖 23 种预算全部通过,并断言最后一块以 🎉 结尾。
2. 模板本身也占 token。 “用一句话介绍魔搭社区”加上系统提示,正文只有 13 token,但 apply_chat_template 之后是 26 token。只按正文估算会低估一倍,所以切块时先算出空问题的模板开销(18 token),再从预算中扣掉。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 按字数估算总是超出预算 | 中文每 token 的字符数远少于英文 | 用真实的分词器计数 |
| 切块里出现 � | 切点落在多 token 字符的中间 | 参考调试示例 1 |
apply_chat_template 的返回值类型不一样 |
transformers 版本差异 | 兼容 list 和 dict 两种返回值(代码里已处理) |
| 找不到分词器 | 没有下载,或者路径不对 | 先运行 61A |
模板规则
- 预算 = 上下文窗口 − 输出预留 − 模板开销。
- 用目标模型自己的分词器。
- 切块后验证没有 �。
- 用参数化测试覆盖多种预算。
项目资源
| 用途 | 地址 |
|---|---|
| 魔搭社区 | https://www.modelscope.cn |
| modelscope SDK 源码 | https://github.com/modelscope/modelscope |
| 示例模型 Qwen2.5-0.5B-Instruct | https://www.modelscope.cn/models/Qwen/Qwen2.5-0.5B-Instruct |
| transformers chat templating | https://huggingface.co/docs/transformers/chat_templating |
上下文是按 token 计算的,用真实的分词器算一遍,比凭感觉估计可靠得多。