AI 工具

ModelScope Tokenizer:调用大模型前估算 Token 预算

不下载模型权重,只用从魔搭下载的 Qwen2.5 分词器(11.5 MB),就能做一件很实用的事:在调用大模型之前,算清楚 token 预算。 这篇写一个 budget.py: - 统计中文、英文和代码各自的 token 密度; - 算出 chat template 本身占多少 token; - 按 token 预算把长文切块,并保证每一块都不会切坏字符。

BINBHO · 图文教程

不下载模型权重,只用从魔搭下载的 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 计算的,用真实的分词器算一遍,比凭感觉估计可靠得多。