文章

用一个程序看清 LLM 的输入:从 messages 到 token IDs

用一个可运行的 Qwen3 示例,逐步打印原始消息、Chat Template 文本、token IDs 和最终输入张量,再对比回答起点、thinking 与截断参数带来的实际变化。

用一个程序看清 LLM 的输入:从 messages 到 token IDs
  1. 一个程序,把转换过程打印出来
  2. 最终喂给模型的是这两个张量
  3. 改参数,输入会怎样变化

提交给聊天 API 的 messages 是一组带角色的消息,但模型最终收到的是什么?要看清这件事,可以用 Transformers 把同一份消息的转换过程逐步打印出来,再改几个参数,比较输入哪里发生了变化。

一个程序,把转换过程打印出来

下面使用 Qwen3-0.6B、Transformers 5.20.1。只加载 tokenizer 就能检查输入,不需要下载模型权重。安装依赖后,可直接运行完整程序 show_input.py:

1
2
python -m pip install 'transformers==5.20.1' 'torch==2.8.0' 'jinja2==3.1.6'
python show_input.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
from transformers import AutoTokenizer

# 固定模型版本;这里只下载 tokenizer,不加载模型权重。
tokenizer = AutoTokenizer.from_pretrained(
    "Qwen/Qwen3-0.6B",
    revision="c1899de289a04d12100db370d81485cdf75e47ca",
)
messages = [
    {"role": "system", "content": "用中文简洁回答。"},
    {"role": "user", "content": "你好。"},
]
print("1. 原始消息:", messages)

# 消息字典 → 模板字符串:加入角色边界与 assistant 回答起点。
prompt = tokenizer.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
    enable_thinking=False,
)
print("2. 模板文本:", repr(prompt))

# 字符串 → 整数 ID 列表。模板已经处理边界,不再自动追加特殊标记。
ids = tokenizer.encode(prompt, add_special_tokens=False)
print("3. token IDs:", ids)

# 同一字符串 → 可传给模型的张量字典,同时带上 attention_mask。
inputs = tokenizer(prompt, add_special_tokens=False, return_tensors="pt")
assert inputs["input_ids"][0].tolist() == ids
print("4. 最终模型输入:", dict(inputs))
print("   input_ids 形状:", tuple(inputs["input_ids"].shape))

# IDs → 字符串:反解只是为了检查输入,没有让模型生成回答。
print("5. 反解检查:", repr(tokenizer.decode(ids, skip_special_tokens=False)))

# 固定 messages,只改变模板开关;这次直接让 apply_chat_template 编码。
for generation, thinking in [(False, True), (True, True), (True, False)]:
    changed = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        return_dict=True,
        add_generation_prompt=generation,
        enable_thinking=thinking,
    )
    changed_ids = changed["input_ids"]
    print(f"\ngeneration={generation}, thinking={thinking}, tokens={len(changed_ids)}")
    print(repr(tokenizer.decode(changed_ids, skip_special_tokens=False)))

# 模板不变,仅把编码后的序列从右侧截到 12 个 token。
tokenizer.truncation_side = "right"
short = tokenizer.apply_chat_template(
    messages,
    tokenize=True,
    return_dict=True,
    add_generation_prompt=True,
    enable_thinking=False,
    truncation=True,
    max_length=12,
)
print("\n截到 12 个 token:", repr(tokenizer.decode(short["input_ids"])))

程序里的几个调用分别完成一步转换:

调用输入 → 输出
apply_chat_template(..., tokenize=False)消息列表 → 按模型格式排列的字符串
encode(text)字符串 → 整数 ID 列表
tokenizer(text, return_tensors="pt")字符串 → 包含 input_ids、attention_mask 的 PyTorch 张量字典
decode(ids)ID 序列 → 可读字符串,用于检查,不是模型生成

其中 encode() 与 tokenizer(...) 是两种编码入口;后者还能组织模型需要的配套字段。程序都调用一次,是为了看到相同 IDs 怎样装进张量。

最终喂给模型的是这两个张量

程序第 2 步得到的字符串如下。repr() 会把换行显示为 \n,这里展开显示它的结构:

1
2
3
4
5
6
7
8
9
<|im_start|>system
用中文简洁回答。<|im_end|>
<|im_start|>user
你好。<|im_end|>
<|im_start|>assistant
<think>

</think>

模板把角色和消息边界写进文本,并在末尾放入 assistant 起点和空思考块。这些内容是模板拼出来的,模型还没有运行。

第 3 步将整段字符串编码成 24 个 token IDs:

1
2
3
[151644, 8948, 198, 11622, 104811, 110485, 102104, 1773,
 151645, 198, 151644, 872, 198, 108386, 1773, 151645, 198,
 151644, 77091, 198, 151667, 271, 151668, 271]

例如,151644 对应 <|im_start|>,151645 对应 <|im_end|>,198 是一个换行,271 是两个换行。角色名称与正文也都被编码进同一条序列。

第 4 步得到真正可以传给 model.generate(**inputs) 的输入:

1
2
3
4
5
6
7
8
9
10
11
{
    "input_ids": tensor([
        [151644, 8948, 198, 11622, 104811, 110485, 102104, 1773,
         151645, 198, 151644, 872, 198, 108386, 1773, 151645, 198,
         151644, 77091, 198, 151667, 271, 151668, 271]
    ]),
    "attention_mask": tensor([
        [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
         1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
    ])
}

两个张量的形状都是 [1, 24]:一条输入,24 个位置。input_ids 决定模型读取哪些 token;attention_mask 中的 1 表示有效位置,本例没有填充,所以全是 1。模型内部再将 IDs 查成向量,进行后续计算。

此时,原始的 Python 消息字典已经完成转换。调用顺序可以画成:

sequenceDiagram
    participant P as Python 程序
    participant T as Tokenizer
    participant M as LLM
    P->>T: apply_chat_template(messages, tokenize=False)
    T-->>P: 带角色和边界的字符串
    P->>T: tokenizer(prompt, return_tensors="pt",<br/>add_special_tokens=False)
    T-->>P: input_ids + attention_mask 张量
    P->>M: 后续可调用 generate(**inputs)
    Note over P,M: 示例停在打印输入;没有加载或运行 LLM

改参数,输入会怎样变化

程序末尾始终使用同一份 messages,只调整开关。三组实际结果如下;表中省略相同的 system 和 user 部分,只列它们之后追加的文本:

add_generation_promptenable_thinking追加的文本(repr)总 token 数
FalseTrue''17
TrueTrue'<|im_start|>assistant\n'20
TrueFalse'<|im_start|>assistant\n<think>\n\n</think>\n\n'24
  • add_generation_prompt=True:让模板加上助手回答的起点,模型接下来从这里续写。它本身不调用模型。
  • enable_thinking=False:在这个 Qwen 模板中预填一个空思考块,因此比开启 thinking 时多 4 个 token。它是模型模板读取的变量,不是所有模型统一的开关。

最后一组保持模板不变,开启 truncation=True, max_length=12。输入从右侧被裁成:

1
'<|im_start|>system\n用中文简洁回答。<|im_end|>\n<|im_start|>user'

用户正文和 assistant 起点都没了。截断只按 token 数裁剪,不会自动保留完整消息。 这里的 max_length 限制输入长度,不是允许模型回答多少 token。

程序中的另外几个参数控制转换方式,而非消息内容:

参数在这个程序中的作用
tokenize=False / True前者停在模板字符串;后者继续编码。程序前半段拆开观察,后半段直接得到 IDs
add_special_tokens=False编码已套模板的文本时,不再自动追加 BOS/EOS 等标记;不会删除文本里已有的标记。本例 Qwen 改成 True 结果也相同,其他 tokenizer 可能不同
return_tensors="pt"把列表装成 PyTorch 张量,并带上批次维度;这一步不选择聊天格式
return_dict=True直接编码时返回带 input_ids 等字段的对象;设为 False 则只返回 IDs
skip_special_tokens=False反解时保留特殊标记,方便核对输入。设为 True 只影响显示,不改变已经准备好的输入 IDs

skip_special_tokens=True 也不是“只显示助手回答”:它按 tokenizer 的注册信息过滤标记,本例中的角色名称和 <think> 仍会保留。因此,观察最终输入时先保留特殊 token,再用 repr() 检查换行。

关于 API 服务在哪一层完成这些转换,见 OpenAI API 与模型输入。模板规则参考 Hugging Face 的 Chat templates 与 Writing a chat template;本文的完整实验记录保留了实际 IDs 和参数对照结果。

本文由作者按照 CC BY 4.0 进行授权