ms-swift ChatML 渲染
Qwen3.5 推理模式
Qwen3.5 是 hybrid-thinking 模型。同一套模型和 ChatML 模板同时支持 thinking 与 non-thinking;两种模式的区别不是切换模型结构,也不是禁止某些 token,而是模型开始生成前,assistant 位置已经预填哪一种 response prefix
thinking
<|im_start|>assistant
<think>
模型从思考区继续生成
non-thinking
<|im_start|>assistant
<think>
</think>
模型从答案区直接生成
is_thinking声明模板具备什么能力;enable_thinking选择本次生成的起始前缀;add_non_thinking_prefix规范训练数据中已经存在的普通assistant回答。三者位于不同阶段,不能视为同一个开关
1. 从 TemplateMeta 到实际 response prefix
Qwen3.5在swift/template/templates/qwen.py中注册为thinking模板,并同时提供两种assistant前缀
register_template(
QwenTemplateMeta(
MLLMTemplateType.qwen3_5,
template_cls=Qwen3_5Template,
default_system=None,
thinking_prefix='<think>\n',
non_thinking_prefix='<think>\n\n</think>\n\n',
agent_template='qwen3_5',
is_thinking=True,
)
)
| 字段 | Qwen3.5 的值 |
模板语义 |
|---|---|---|
is_thinking |
True |
模板属于 thinking 类型 |
thinking_prefix |
<think>\n |
思考区已经打开,模型继续生成推理过程 |
non_thinking_prefix |
<think>\n\n</think>\n\n |
思考区为空且已经关闭,模型直接生成答案 |
is_thinking=True不表示每次推理都必须思考。Qwen3.5同时注册非空的non_thinking_prefix,因此它是hybrid-thinking,而不是只能进入thinking的纯思考模板
- 未显式传参时,三个参数的默认值在
swift/arguments/base_args/template_args.py中定义
@dataclass
class TemplateArguments:
response_prefix: Optional[str] = None
enable_thinking: Optional[bool] = None
add_non_thinking_prefix: bool = True
get_template()将参数传入Template.__init__()
class Template(ProcessorMixin):
def __init__(
self,
processor: Processor,
template_meta: 'TemplateMeta',
default_system: Optional[str] = None,
max_length: Optional[int] = None,
*,
# 省略与 thinking 无关的关键字参数
response_prefix: Optional[str] = None,
enable_thinking: Optional[bool] = None,
preserve_thinking: Optional[bool] = None,
add_non_thinking_prefix: bool = True,
) -> None: ...
template_meta没有默认值,它是构造模板时必须提供的协议对象。对于Qwen3.5,该对象就是前面register_template()注册的QwenTemplateMeta;其is_thinking=True、thinking_prefix和non_thinking_prefix均来自注册代码。构造函数随后复制这份模板元数据,并把尚未解析的enable_thinking=None转换成实际布尔值
template_meta = deepcopy(template_meta)
if enable_thinking is None:
enable_thinking = (
template_meta.is_thinking and not template_meta.non_thinking_prefix
)
self.response_prefix = response_prefix
self.template_meta = template_meta
self.enable_thinking = enable_thinking
self.add_non_thinking_prefix = add_non_thinking_prefix
| 实例字段 | 参数入口默认值 | Qwen3.5 的实际初值 |
|---|---|---|
self.response_prefix |
None |
None |
self.template_meta |
无;构造函数必传 | Qwen3.5 注册项的副本 |
self.enable_thinking |
None |
根据模板元数据解析为 False |
self.add_non_thinking_prefix |
True |
True |
- 模板支持
thinking,但一次未显式配置的生成默认走non-thinking
template_meta.is_thinking = True
bool(template_meta.non_thinking_prefix) = True
True and not True
↓
enable_thinking = False
ms-swift v4.3.0使用enable_thinking = template_meta.is_thinking,Qwen3.5默认开启thinking;从v4.3.1开始改成is_thinking and not non_thinking_prefix,hybrid-thinking模型默认关闭thinking。关键实验应显式设置--enable_thinking true/false,不要依赖版本默认值
- 请求到达后,
_get_enable_thinking()先读取单条样本的chat_template_kwargs,没有提供时才回退到模板实例的全局值;_get_response_prefix()又在它之前增加一层显式前缀覆盖
def _get_enable_thinking(self, inputs=None):
enable_thinking = (
None
if inputs is None
else inputs.chat_template_kwargs.get('enable_thinking')
)
if enable_thinking is None:
enable_thinking = self.enable_thinking
return enable_thinking
def _get_response_prefix(self, inputs=None):
response_prefix = (
None
if inputs is None
else inputs.chat_template_kwargs.get('response_prefix')
)
if response_prefix is None:
response_prefix = self.response_prefix
if response_prefix is not None:
return response_prefix
enable_thinking = self._get_enable_thinking(inputs)
if enable_thinking:
return self.template_meta.thinking_prefix
else:
return self.template_meta.non_thinking_prefix
enable_thinking并不直接拼接字符串;它只在没有显式response_prefix时,帮助_get_response_prefix()从模板注册的两种前缀中选择一个
单条样本 chat_template_kwargs.response_prefix
↓ 未设置
全局 response_prefix
↓ 未设置
单条样本 chat_template_kwargs.enable_thinking
↓ 未设置
全局 enable_thinking
↓
thinking_prefix / non_thinking_prefix
有效 response_prefix is None
→ 样本级与全局均未显式指定前缀
→ 根据解析后的 enable_thinking 选择模板前缀:
thinking → template_meta.thinking_prefix
non-thinking → template_meta.non_thinking_prefix
有效 response_prefix is not None
→ 已显式指定前缀(包括空字符串 '')
→ 直接使用 response_prefix
→ 不再使用 template_meta.thinking_prefix / non_thinking_prefix
2. 推理路径:前缀如何进入 ChatML
Qwen3.5从公开编码入口进入ChatML拼接的完整方法解析链是
Template.encode()
↓
Template._encode_truncated()
↓
Qwen3VLTemplate._encode() # Qwen3_5Template 继承该实现
↓
Template._encode(self, inputs) # Qwen3VLTemplate 显式调用基础实现
↓
Qwen3_5Template._swift_prepare_inputs()
↓
Template._swift_encode()
Qwen3_5Template先规范消息内容,真正拼接ChatML的公共控制流位于基础类Template._swift_encode();thinking/non-thinking的前缀在该方法中写入prompt。推理请求的最后一轮没有现成的assistant回答。在_swift_encode()中,只需关注最后一轮的两个分支
response_prefix = self._get_response_prefix(inputs)
if response is not None:
context_list.append('{{RESPONSE}}')
elif response_prefix:
context_list.append(response_prefix)
-
SFT样本已经提供assistant标签,进入{{RESPONSE}}分支;推理请求的response is None,因此把_get_response_prefix()选出的前缀直接写到ChatML prompt末尾。enable_thinking通过前缀选择影响输入token -
enable_thinking=True:<think>尚未闭合,模型从思考区继续生成,随后自行产生</think>和最终答案
<|im_start|>assistant
<think>
enable_thinking=False:空思考块已经作为prompt写完,模型的生成起点位于</think>之后。non-thinking因而是一种prefill protocol,不是模型自己生成空<think></think>,也不是解码器通过token blacklist强制关闭思考
<|im_start|>assistant
<think>
</think>
返回语义:为什么 ms-swift 会显示空 <think></think>
enable_thinking=False 时,空 <think></think> 已经作为 response_prefix 写入 prompt,模型只在它后面生成答案。
因此,ms-swift 输出空 think,而 Transformers 和 vLLM 不输出的差异不在采样阶段,而在生成完成后的 response 构造阶段
Transformers · model.generate():transformers/generation/utils.py 中的标准处理方式是按输入长度截取新增 token
outputs = model.generate(**inputs, return_dict_in_generate=True)
input_length = inputs.input_ids.shape[1]
generated_tokens = outputs.sequences[:, input_length:]
response = tokenizer.decode(generated_tokens[0])
Transformers:对decoder-only模型,outputs.sequences包含prompt + completion。常见推理代码按照输入长度切片,只解码新增token;空思考块属于prompt,因此不会出现在response中。严格来说,model.generate()没有过滤<think></think>:直接解码完整的outputs.sequences仍然可以看到它
原生 vLLM:vllm/v1/engine/detokenizer.py 将输出缓冲区初始化为空,只追加新生成的 token
for new_token_id in new_token_ids:
self.token_ids.append(new_token_id)
self.output_text += self.decode_next(new_token_id)
vllm/v1/engine/output_processor.py 随后读取生成文本,作为 CompletionOutput.text 返回
finished = finish_reason is not None
delta = self.output_kind == RequestOutputKind.DELTA
text = self.detokenizer.get_next_output_text(finished, delta)
- 原生
vLLM:在detokenizer内部,prompt token只用于初始化前缀状态,保证第一个生成token能在正确的文本边界上解码;output_text本身仍从空字符串开始,并且只接收new_token_ids。因此,原生vLLM的CompletionOutput.text只包含模型新生成的答案。空<think></think>位于prompt中,从未写入这个输出缓冲区,所以不会出现在返回文本里
ms-swift:swift/template/base.py 先截取新增 token,再在解码阶段恢复模板前缀
def get_generate_ids(
self, generate_ids: Union[torch.Tensor, List[int]], num_prompt_tokens: int
) -> Union[torch.Tensor, List[int]]:
if self.skip_prompt:
generate_ids = generate_ids[..., num_prompt_tokens:]
return generate_ids
def decode_generate_ids(
self,
generate_ids: List[int],
*,
is_finished: bool = True,
first_token=True,
template_inputs=None,
**kwargs,
) -> Any:
if kwargs.get('spaces_between_special_tokens') is None:
kwargs['spaces_between_special_tokens'] = False
generate_ids = self.skip_stop_tokens(generate_ids, is_finished)
response = self.tokenizer.decode(generate_ids, **kwargs)
response_prefix = self._get_response_prefix(template_inputs)
if first_token and response_prefix:
response = response_prefix + response
return response
ms-swift(Transformers后端):Qwen3.5继承基础模板的skip_prompt=True。因此,ms-swift的Transformers backend先通过get_generate_ids()切掉整个prompt,再由decode_generate_ids()解码新增token;随后ms-swift重新调用_get_response_prefix(),显式执行response_prefix + response。空<think></think>正是在这一步被拼回最终response,而不是模型本轮新生成的token
ms-swift · vLLM backend:swift/infer_engine/vllm_engine.py 不直接返回原生 vLLM 的 output.text,而是读取 output.token_ids,再次调用 Swift 模板解码
for output in result.outputs:
output.token_ids = list(output.token_ids)
response = self.template.decode_generate_ids(
output.token_ids,
template_inputs=inputs['template_inputs'],
)
ms-swift(vLLM后端):原生vLLM返回的是completion,而ms-swift的vLLM backend返回的是经过模板重建的assistant response。底层采样没有改变,变化的只是返回文本是否重新包含response_prefix
3. SFT 路径:标签规范化与 Loss Mask
SFT样本已经包含最后一轮assistant回答,因此response is not None,编码器把{{RESPONSE}}放入ChatML,而不会把推理用的response_prefix再追加一次。此时模式来自assistant标签本身- 进入
_swift_encode()之前,Qwen3_5Template._swift_prepare_inputs()先对消息执行与Qwen3.5 Hugging Face Jinja模板一致的规范化:user/system/tool执行strip();assistant若已经包含完整<think>...</think>,则被整理成固定换行结构
elif role == 'assistant':
stripped = content.strip()
if '</think>' in stripped and '<think>' in stripped:
before, _, after = stripped.partition('</think>')
reasoning = (
before.rstrip('\n').rsplit('<think>', 1)[-1].lstrip('\n').strip()
)
rest = after.lstrip('\n')
message['content'] = f'<think>\n{reasoning}\n</think>\n\n{rest}'
else:
message['content'] = stripped
- 随后
_add_non_thinking_prefix()扫描已有assistant消息。只要内容既不以<think>开头,也不以完整的non_thinking_prefix开头,就在原回答前补空思考块
def _add_non_thinking_prefix(self, inputs, thinking_prefix='<think>'):
messages = inputs.messages
non_thinking_prefix = self.template_meta.non_thinking_prefix
if non_thinking_prefix:
if (
not self.is_training
or self.loss_scale.base_strategy == 'last_round'
) and not self.template_meta.preserve_thinking:
start_idx = get_last_user_round(messages)
else:
start_idx = -1
for i, message in enumerate(messages):
if not self._is_add_non_thinking_round(messages, i, start_idx):
continue
content = message['content']
if isinstance(content, str):
if not content.startswith(
(thinking_prefix, non_thinking_prefix)
):
message['content'] = non_thinking_prefix + content
start_idx决定规范化覆盖哪些轮次:默认SFT的loss_scale=default从头处理所有assistant回答;last_round策略只处理最后一个user之后的assistant回答。这与loss的轮次范围保持一致,但不改变普通回答补空思考块、已有thinking回答保持不变的判断。两类SFT数据经过这一阶段后共享统一外壳
thinking 样本
assistant: <think>
真实推理过程
</think>
最终答案
non-thinking 样本
assistant: <think>
</think>
最终答案
add_non_thinking_prefix=True不会从普通答案推导出真实思维链,只把没有推理过程显式编码为空思考区;已有<think>真实推理</think>的样本保持thinking,普通回答被归一化为non-thinking
assistant 原始标签 |
_add_non_thinking_prefix() |
编码后的类型 |
|---|---|---|
<think>真实推理</think>答案 |
不修改 | thinking |
| 普通答案 | 前置空 <think></think> |
non-thinking |
-
这也解释为什么
enable_thinking不是混合SFT的数据开关:最后一轮已有response时,_swift_encode()已经进入{{RESPONSE}}分支,_get_response_prefix()的返回值不会被追加到该标签前 -
空思考块虽然存在于
input_ids,却不需要模型学习生成。标准参数类swift/arguments/base_args/template_args.py会识别is_thinking=True且non_thinking_prefix非空的hybrid-thinking模板,并自动组合ignore_empty_think
def _set_loss_scale(self):
if (not self.disable_ignore_empty_think
and getattr(self, 'template_meta', None) is not None):
template_meta = self.template_meta
if template_meta.is_thinking and template_meta.non_thinking_prefix:
if self.loss_scale and 'ignore_empty_think' not in self.loss_scale:
self.loss_scale = self.loss_scale + '+ignore_empty_think'
ignore_empty_think在字符串形式的ChatML context中匹配开头的空思考块,将其权重设为0
{
"^<think>\\s*</think>\\s*": [0.0]
}
_encode_context_list()随后逐段tokenize,将权重为0的部分转成labels=-100
for context, loss_weight in zip(
context_list,
loss_scale_list,
):
token_list = self._tokenize(context)
input_ids += token_list
if loss_weight > 0.0:
labels += token_list
else:
labels += [-100] * len(token_list)
- 空思考块保留在
input_ids中,但不参与监督;答案token与模板要求的结束符仍参与训练。下图只展开assistant response span,模板suffix的监督由后续EOS逻辑单独处理
普通 assistant 回答
↓ add_non_thinking_prefix
<think></think> + 最终答案
↓ ignore_empty_think
[空思考块, 最终答案]
[权重 0, 权重 1]
↓ tokenize / _encode_context_list
input_ids: [空思考 token | 答案 token]
labels: [-100 ... | 答案 token]
- 标准
CLI会自动追加ignore_empty_think;对应配置为
--add_non_thinking_prefix true \
--loss_scale default+ignore_empty_think
4. OPD 路径:rollout 模式与训练序列对齐
- 普通
SFT使用已有标签,所以enable_thinking不选择数据类型;OPD / on-policy GKD在训练内部先让学生模型在线生成,因此其中包含一段真正的推理路径 _preprocess_inputs()在生成前删除样本中已有的response
def _preprocess_inputs(
self, samples: List[OnPolicySample]
) -> List[OnPolicySample]:
"""Preprocess samples before inference"""
samples = self._set_inputs_system(samples)
samples = self._add_prompt_id_to_inputs(samples)
for s in samples:
remove_response(s.messages)
return samples
- 删除后,最后一轮重新变成等待生成的空
assistant,因此复用第 2 节的推理分支
离线样本
↓ remove_response
assistant(content=None)
↓ _get_response_prefix
enable_thinking=True → thinking rollout
enable_thinking=False → non-thinking rollout
↓
学生生成 response_token_ids
swift/rlhf_trainers/rollout_mixin.py的_generate_completions()先预处理样本,再进入template.generate_context()
def _generate_completions(
self, samples: List[OnPolicySample]
) -> List[OnPolicySample]:
samples = self._preprocess_inputs(samples)
mode = 'train' if self.model.training else 'eval'
if self.use_fast_infer:
samples = self._fast_infer(samples)
else:
with (
unwrap_model_for_generation(
self.model_wrapped,
self.accelerator,
gather_deepspeed3_params=self.args.ds3_gather_for_generation,
),
self.template.generate_context(),
self.multi_turn_completion_length_context(),
):
samples = self._infer_single_or_multi_turn(
samples, self.request_config
)
if mode == 'train':
self.model.train()
return samples
-
enable_thinking只在推理阶段生效与OPD训练中它会改变样本格式并不矛盾:OPD训练内部会执行一次学生推理。rollout完成后还存在一个token对齐问题。response prefix属于prompt,学生返回的response_token_ids通常只包含新增completion;重新编码训练序列时,必须把生成时使用的前缀补回,否则rollout与训练使用的token上下文不一致 -
swift/rlhf_trainers/utils.py将逐样本模板参数交给_get_response_prefix(),沿用 rollout 的优先级和模型专用规则,再生成prefix ids
def get_response_prefix_ids(
template: Template,
sample_enable_thinking: Optional[bool] = None,
*,
chat_template_kwargs: Optional[Dict[str, Any]] = None,
) -> Optional[List[int]]:
kwargs = dict(chat_template_kwargs or {})
if sample_enable_thinking is not None:
kwargs['enable_thinking'] = sample_enable_thinking
inputs = StdTemplateInputs(messages=[], chat_template_kwargs=kwargs)
prefix_str = template._get_response_prefix(inputs)
if prefix_str:
return template.tokenizer.encode(prefix_str, add_special_tokens=False)
return None
encode_sample()传递整组chat_template_kwargs,并把返回的prefix ids与 rollout token 一起交给replace_assistant_response_with_ids()
def encode_sample(
sample: OnPolicySample,
template: Template,
*,
encode_prompt_only: bool = False,
) -> Dict[str, Any]:
data = sample.to_template_dict()
if sample.response_token_ids:
loss_mask = sample.response_loss_mask or None
msgs = data.get('messages')
if msgs is not None:
msgs = [m.copy() for m in msgs]
ctk = sample.extra.get('chat_template_kwargs') or {}
prefix_ids = get_response_prefix_ids(template, chat_template_kwargs=ctk)
data['messages'] = replace_assistant_response_with_ids(
msgs,
sample.response_token_ids,
loss_mask,
non_thinking_prefix_ids=prefix_ids,
)
if encode_prompt_only:
messages = data.get('messages', [])
if messages and messages[-1].get('role') == 'assistant':
data = {
**data,
'messages': messages[:-1] + [{**messages[-1], 'content': None}],
}
encoded = template.encode(data, return_length=True)
return encoded
- 虽然参数名仍叫
non_thinking_prefix_ids,当前调用传入的实际是本条样本所选择的prefix:thinking时为<think>\n,non-thinking时为空思考块。辅助函数在completion尚未包含该前缀时将其补到最后一轮,并把前缀对应的loss mask设为0
if non_thinking_prefix_ids:
n_prefix = len(non_thinking_prefix_ids)
last_ids = list(completion_ids[-1])
if last_ids[:n_prefix] != list(non_thinking_prefix_ids):
if loss_mask is None:
loss_mask = [[1] * len(ids) for ids in completion_ids]
completion_ids[-1] = list(non_thinking_prefix_ids) + last_ids
loss_mask[-1] = [0] * n_prefix + list(loss_mask[-1])
-
上下文一致:重新编码的
assistant序列与rollout时模型实际看到的response prefix一致 -
监督一致:属于
prompt的prefix token被补回序列,但loss_mask=0,训练只消费学生真正生成的completion -
逐样本设置
chat_template_kwargs.enable_thinking即可混合rollout;未设置时回退到全局--enable_thinking
{"chat_template_kwargs": {"enable_thinking": true}}
{"chat_template_kwargs": {"enable_thinking": false}}
- 三类场景最终可以由最后一轮
assistant是否已经存在统一解释
| 场景 | 编码前的最后一轮 | 实际分支 | 模式来源 |
|---|---|---|---|
| 普通推理 | assistant(content=None) |
追加 response_prefix |
response_prefix / enable_thinking |
普通 SFT |
assistant(content=label) |
追加 {{RESPONSE}} |
标签内容与 add_non_thinking_prefix |
OPD rollout |
先删除 response,再变成 None |
追加 response_prefix |
全局或逐样本 enable_thinking |
Qwen3.5推理模式的核心不是一个布尔变量,而是一套围绕assistant起点建立的prefix protocol。enable_thinking只负责在没有response、需要生成时选择前缀;add_non_thinking_prefix只负责在已有response、需要训练时规范标签;OPD先删除response再生成,所以从训练流程重新进入前一条推理分支
Qwen3-Omni 多模态编码
1. Qwen3-Omni 的 ChatML 结构
Qwen3-Omni沿用Qwen的ChatML格式:消息以<|im_start|>{role}开始,以<|im_end|>结束- 工具定义放在
system中,工具调用与thinking放在assistant中;工具结果由<tool_response>包装,作为下一条user消息
<|im_start|>system
{SYSTEM}
# Tools
<tools>
{TOOL_DEFINITIONS}
</tools>
{TOOL_CALL_INSTRUCTION}<|im_end|>
<|im_start|>user
图像:<|vision_start|><|image_pad|><|vision_end|>
视频:<|vision_start|><|video_pad|><|vision_end|>
音频:<|audio_start|><|audio_pad|><|audio_end|>
{QUERY}<|im_end|>
<|im_start|>assistant
<think>
{REASONING}
</think>
<tool_call>
{"name":"{TOOL_NAME}","arguments":{TOOL_ARGUMENTS}}
</tool_call><|im_end|>
<|im_start|>user
<tool_response>
{TOOL_RESULT}
</tool_response><|im_end|>
<|im_start|>assistant
<think>
{REASONING_AFTER_TOOL}
</think>
{RESPONSE}<|im_end|>
tools、tool call、tool response和thinking均为可选内容。image、video和audio作为user内容中的行内占位区段
image → <|vision_start|><|image_pad|><|vision_end|>
video → <|vision_start|><|video_pad|><|vision_end|>
audio → <|audio_start|><|audio_pad|><|audio_end|>
<|image_pad|>、<|video_pad|>和<|audio_pad|>只标识媒体插入位置。tokenizer初次编码时,每份媒体仍只对应一个pad token;实际槽位数量由媒体processor的输出决定
ChatML中的单个媒体pad token只是语义占位符。模型接收的input_ids中,该token已被扩展为与对应encoder输出长度一致的连续placeholder
ChatmlTemplateMeta定义角色骨架,Qwen3OmniTemplate定义模型使用的媒体占位token
@dataclass
class ChatmlTemplateMeta(TemplateMeta):
prompt: Prompt = field(
default_factory=lambda: [
'<|im_start|>user\n{{QUERY}}<|im_end|>\n' '<|im_start|>assistant\n'
]
)
chat_sep: Optional[Prompt] = field(default_factory=lambda: ['<|im_end|>\n'])
suffix: Prompt = field(default_factory=lambda: ['<|im_end|>\n'])
system_prefix: Optional[Prompt] = field(
default_factory=lambda: ['<|im_start|>system\n{{SYSTEM}}<|im_end|>\n']
)
class Qwen3OmniTemplate(Qwen2_5OmniTemplate):
version = 'omni_v3'
placeholder_tokens = [
'<|image_pad|>',
'<|audio_pad|>',
'<|video_pad|>',
]
2. 标准 messages 与内部输入
ms-swift的标准多模态样本在messages.content中保存媒体占位符,在 顶层数组中保存媒体数据
{
"messages": [
{"role": "system", "content": "你是个有用无害的助手"},
{
"role": "user",
"content": "<image>图片中是什么,<video>视频中是什么,<audio>音频中是什么"
},
{
"role": "assistant",
"content": "图片中是一头大象,视频中是一只小狗在草地上奔跑,音频中有鸟鸣声"
}
],
"images": ["/xxx/x.jpg"],
"videos": ["/xxx/x.mp4"],
"audios": ["/xxx/x.wav"]
}
swift/template/template_inputs.py中的StdTemplateInputs.from_dict()将首条system消息移出普通对话轮次。媒体数组与content中的占位符保持原有顺序,不会在这一阶段加载或编码
StdTemplateInputs(
system="你是个有用无害的助手",
messages=[
{
"role": "user",
"content": (
"<image>图片中是什么,"
"<video>视频中是什么,"
"<audio>音频中是什么"
),
},
{
"role": "assistant",
"content": (
"图片中是一头大象,视频中是一只小狗在草地上奔跑,"
"音频中有鸟鸣声"
),
},
],
images=["/xxx/x.jpg"],
videos=["/xxx/x.mp4"],
audios=["/xxx/x.wav"],
)
- 第
i个媒体占位符与对应数组的第i个元素配对:<image>对应images[i],<video>对应videos[i],<audio>对应audios[i]。仅提供顶层媒体数组时,_add_default_tags()会将缺少的标记补到首条消息开头;显式标记则确定媒体在文本中的位置
3. replace_tag() 将标准标记改写为 Omni 标记
Template._swift_encode()先把system、user和assistant编译为ChatML context list。此时user内容仍保留标准标记
<|im_start|>system
你是个有用无害的助手<|im_end|>
<|im_start|>user
<image>图片中是什么,<video>视频中是什么,<audio>音频中是什么<|im_end|>
<|im_start|>assistant
图片中是一头大象,视频中是一只小狗在草地上奔跑,音频中有鸟鸣声<|im_end|>
Template._simplify_context_list()先通过_split_special_tokens()把<image>、<video>和<audio>从相邻文本中分离,再由_pre_tokenize()逐个调用模板的replace_tag()
context_list, loss_scale_list = self._split_special_tokens(
context_list, loss_scale_list
)
context_list, loss_scale_list = self._pre_tokenize(
context_list, loss_scale_list, inputs
)
Qwen3-Omni继承qwen.py中的Qwen2_5OmniTemplate.replace_tag(),使用omni_v3分支
def replace_tag(self, media_type: Literal['image', 'video', 'audio'],
index: int, inputs: StdTemplateInputs) -> List[Context]:
from qwen_omni_utils import fetch_image, fetch_video
kwargs = (
{'image_patch_size': self.processor.image_processor.patch_size}
if self.version == 'omni_v3' else {}
)
sampling_rate = inputs.chat_template_kwargs.get('sampling_rate')
if sampling_rate is None:
sampling_rate = self.sampling_rate
if self.mode == 'vllm':
inputs.mm_processor_kwargs['do_resize'] = False
if media_type == 'image':
inputs.images[index] = fetch_image(
{'image': inputs.images[index], **inputs.chat_template_kwargs},
**kwargs,
)
if self.version == 'omni_v2_5':
return ['<|vision_bos|><|IMAGE|><|vision_eos|>']
elif self.version == 'omni_v3':
return ['<|vision_start|><|image_pad|><|vision_end|>']
elif media_type == 'audio':
if self.mode != 'vllm':
inputs.audios[index] = load_audio(inputs.audios[index], sampling_rate)
if self.version == 'omni_v2_5':
return ['<|audio_bos|><|AUDIO|><|audio_eos|>']
elif self.version == 'omni_v3':
return ['<|audio_start|><|audio_pad|><|audio_end|>']
视频分支记录实际采样帧率,并替换视频标记;以下节选 use_audio_in_video=False 的路径
elif media_type == 'video':
video = inputs.videos[index]
video_inputs = {'video': video, **inputs.chat_template_kwargs}
if isinstance(video, list):
from qwen_omni_utils import vision_process
video_inputs['sample_fps'] = vision_process.FPS
_video, sample_fps = fetch_video(
video_inputs, return_video_sample_fps=True, **kwargs
)
inputs.mm_processor_kwargs.setdefault('fps', []).append(sample_fps)
if isinstance(_video, torch.Tensor):
_video = _video.to(torch.uint8)
inputs.videos[index] = _video
if self.version == 'omni_v2_5':
return ['<|vision_bos|><|VIDEO|><|vision_eos|>']
elif self.version == 'omni_v3':
return ['<|vision_start|><|video_pad|><|vision_end|>']
replace_tag()同时完成两项变换:加载媒体数据,并将 标准占位符替换为Omni专用标记。示例中的user消息由此变为
<|im_start|>user
<|vision_start|><|image_pad|><|vision_end|>图片中是什么,
<|vision_start|><|video_pad|><|vision_end|>视频中是什么,
<|audio_start|><|audio_pad|><|audio_end|>音频中是什么<|im_end|>
_encode_context_list()逐段调用 tokenizer。此时每份媒体只有一个pad token,还未按特征长度展开
4. 媒体预处理与占位 token 扩展
Qwen2_5OmniTemplate._encode()先取得包含单个媒体pad token的input_ids,再调用processor生成媒体张量text=''使processor只处理媒体;临时产生的文本input_ids与attention_mask会被删除
encoded = Template._encode(self, inputs)
inputs.audios = self._trim_omni_v3_audios(inputs.audios)
media_inputs = self.processor(
text='',
audio=inputs.audios or None,
images=inputs.images or None,
videos=inputs.videos or None,
do_resize=False,
return_tensors='pt',
)
media_inputs.pop('input_ids')
media_inputs.pop('attention_mask')
input_ids = encoded['input_ids']
labels = encoded['labels']
loss_scale = encoded.get('loss_scale')
config = self.config.thinker_config
- 图片与独立视频的占位长度均为
T × H × W / merge_size²。media_grid_thw分别取image_grid_thw或video_grid_thw,merge_size来自 processor
def _get_new_tokens(i):
token_len = media_grid_thw[i].prod() // (merge_size**2)
return token_id * token_len
input_lengths是feature_attention_mask.sum(dim=1)得到的有效声学帧数;下面的原函数将它转换为音频 token 数
def _get_feat_extract_output_lengths(self, input_lengths):
if self.version == 'omni_v2_5':
return ((input_lengths - 1) // 2 + 1 - 2) // 2 + 1
elif self.version == 'omni_v3':
input_lengths_leave = input_lengths % 100
feat_lengths = (input_lengths_leave - 1) // 2 + 1
return (
((feat_lengths - 1) // 2 + 1 - 1) // 2
+ 1
+ (input_lengths // 100) * 13
)
processor已经给出视觉网格和音频有效帧数时,三类placeholder的实际数量可以直接由同一段代码得到
merge_size = 2
image_grid_thw = torch.tensor([[1, 32, 48]])
video_grid_thw = torch.tensor([[8, 24, 40]])
feature_attention_mask = torch.ones((1, 300), dtype=torch.long)
image_placeholder_len = int(image_grid_thw[0].prod() // (merge_size**2))
video_placeholder_len = int(video_grid_thw[0].prod() // (merge_size**2))
audio_placeholder_len = int(
self._get_feat_extract_output_lengths(feature_attention_mask.sum(dim=1))[0]
)
assert image_placeholder_len == 384
assert video_placeholder_len == 1920
assert audio_placeholder_len == 39
- 扩展前,每份媒体在
ChatML中只有一个pad token。_extend_tokens()只重复中间的媒体token,两侧边界token保持不变
<|vision_start|>
<|image_pad|> * image_placeholder_len
<|vision_end|>
<|vision_start|>
<|video_pad|> * video_placeholder_len
<|vision_end|>
<|audio_start|>
<|audio_pad|> * audio_placeholder_len
<|audio_end|>
- 代入该示例的实际长度
<|image_pad|> * 384
<|video_pad|> * 1920
<|audio_pad|> * 39
- 其中
*表示重复相同token,不是写入ChatML的文本字符。token id层的扩展前结构为
vision_start_id = self._tokenize('<|vision_start|>')[0]
vision_end_id = self._tokenize('<|vision_end|>')[0]
audio_start_id = self._tokenize('<|audio_start|>')[0]
audio_end_id = self._tokenize('<|audio_end|>')[0]
image_segment = [
vision_start_id,
config.image_token_id,
vision_end_id,
]
video_segment = [
vision_start_id,
config.video_token_id,
vision_end_id,
]
audio_segment = [
audio_start_id,
config.audio_token_id,
audio_end_id,
]
_extend_tokens()只替换中间的媒体token,起止标记不变
image_segment = (
[vision_start_id]
+ [config.image_token_id] * image_placeholder_len
+ [vision_end_id]
)
video_segment = (
[vision_start_id]
+ [config.video_token_id] * video_placeholder_len
+ [vision_end_id]
)
audio_segment = (
[audio_start_id]
+ [config.audio_token_id] * audio_placeholder_len
+ [audio_end_id]
)
assert len(image_segment) == 386
assert len(video_segment) == 1922
assert len(audio_segment) == 41
图像区段的 386 个
token由 1 个vision_start、384 个image_pad和 1 个vision_end构成。placeholder数只计算中间的媒体token,不包含两侧边界token;video和audio区段遵循相同规则
feature_attention_mask决定audio placeholder的重复长度;image_grid_thw与video_grid_thw决定image/video placeholder的重复长度。三类长度在同一个_encode()中计算,并统一交给_extend_tokens()改写input_ids
new_tokens = get_new_tokens(i)
token_len = len(new_tokens)
input_ids = (
input_ids[: idx + added_tokens_len]
+ new_tokens
+ input_ids[added_tokens_len + idx + 1 :]
)
labels = (
labels[: idx + added_tokens_len]
+ [-100] * token_len
+ labels[added_tokens_len + idx + 1 :]
)
processor输出的pixel_values、pixel_values_videos、input_features保存在media_inputs;image_grid_thw、video_grid_thw与feature_attention_mask同时保留,供collator、MRoPE和模型forward使用。媒体token的label为-100,不参与文本交叉熵损失
encoded['input_ids'] = input_ids
encoded['labels'] = labels
encoded['loss_scale'] = loss_scale
encoded.update(media_inputs)
return encoded
use_audio_in_video=True时,_get_new_tokens_use_audio_in_video()按video_second_per_grid将video token与视频音轨产生的audio token交错排列;普通image、video和独立audio仍走相同的_extend_tokens()路径
5. Batch、attention_mask 与多模态 RoPE
- 单样本编码完成后,
Template._data_collator()将不同长度的input_ids补齐到batch内最大长度,并据有效序列长度构造二维attention_mask
seq_lens = [len(seq) for seq in res['input_ids']]
res['attention_mask'] = [
torch.ones(seq_len, dtype=torch.int64) for seq_len in seq_lens
]
res['input_ids'] = self._pad_sequence(
res['input_ids'], self.tokenizer.pad_token_id
)
res['attention_mask'] = self._pad_sequence(res['attention_mask'], 0)
-
扩展后的
image、video和audio token都属于有效序列,其attention_mask为 1;只有batch padding位置为 0。attention_mask描述语言序列,feature_attention_mask描述有效声学帧,二者不能混用 -
视觉数据由基础
collator沿媒体维拼接
pixel_values = [
b['pixel_values'] for b in batch if b.get('pixel_values') is not None
]
res['pixel_values'] = torch.concat(pixel_values)
pixel_values_videos = [
b['pixel_values_videos']
for b in batch
if b.get('pixel_values_videos') is not None
]
res['pixel_values_videos'] = torch.concat(pixel_values_videos)
res['image_grid_thw'] = self.concat_tensor(batch, 'image_grid_thw', 0)
res['video_grid_thw'] = self.concat_tensor(batch, 'video_grid_thw', 0)
- 音频特征先按
batch内最大声学长度补齐,再沿音频样本维拼接
max_length = max(x.shape[-1] for x in input_features)
for i, input_feature in enumerate(input_features):
mask = feature_attention_mask[i]
input_features[i] = F.pad(
input_feature,
(0, max_length - input_feature.shape[-1]),
)
feature_attention_mask[i] = F.pad(
mask,
(0, max_length - mask.shape[-1]),
)
res['input_features'] = torch.concat(input_features)
res['feature_attention_mask'] = torch.concat(feature_attention_mask)
| 字段 | 形状 | 坐标含义 |
|---|---|---|
input_ids |
[B, L] |
ChatML 文本 token 与扩展后的媒体 token |
attention_mask |
[B, L] |
语言序列有效位置 |
pixel_values |
[ΣP_image, patch_dim] |
展平后的图像 patch |
image_grid_thw |
[N_image, 3] |
每幅图像的 (T,H,W) patch 网格 |
pixel_values_videos |
[ΣP_video, patch_dim] |
展平后的视频 patch |
video_grid_thw |
[N_video, 3] |
每段视频的 (T,H,W) patch 网格 |
input_features |
[N_audio, 128, F_max] |
补齐后的 mel 特征 |
feature_attention_mask |
[N_audio, F_max] |
有效声学特征帧 |
get_rope_index()根据 token 序列、视觉网格、音频长度和视频时间间隔,构造Qwen3-Omni的多模态位置
feature_attention_mask = inputs.get('feature_attention_mask')
audio_feature_lengths = torch.sum(feature_attention_mask, dim=1)
position_ids, _ = self._get_get_rope_index()(
input_ids,
inputs.get('image_grid_thw'),
inputs.get('video_grid_thw'),
attention_mask,
self.use_audio_in_video,
audio_feature_lengths,
video_second_per_grid,
)
- 模型返回的
position_ids形状为[3, B, L],三个轴分别表示temporal、height和width。普通文本与音频在三个轴上使用相同的一维递增位置;图像和视频token使用各自网格的三维坐标。视频temporal坐标还乘以video_second_per_grid × position_id_per_seconds,从而保留抽帧后每个时间网格的实际间隔。ms-swift在collator内临时增加线性的text position轴,形成[4, B, L]
text_position_ids = torch.arange(seq_len, device=position_ids.device).expand(
1, *position_ids.shape[1:]
)
position_ids = torch.concat([text_position_ids, position_ids], dim=0)
- 训练输入随后将三维多模态位置保存在
position_ids,并把第一维线性位置单独保存为text_position_ids,供causal mask、packing和sequence parallel逻辑使用
6. post_encode 与 embedding scatter
Qwen2_5OmniTemplate._post_encode()会在模板侧提前计算多模态embedding。Qwen3-Omni明确覆盖该实现
class Qwen3OmniTemplate(Qwen2_5OmniTemplate):
version = 'omni_v3'
norm_bbox = 'norm1000'
placeholder_tokens = [
'<|image_pad|>',
'<|audio_pad|>',
'<|video_pad|>',
]
def _post_encode(self, model, inputs):
return inputs
Qwen3-Omni的post_encode是直通操作。input_ids、pixel_values、pixel_values_videos、input_features及其长度元数据保持不变;embedding计算与替换发生在Qwen3OmniMoeThinkerForConditionalGeneration.forward()内部
- 语言序列首先经过词嵌入层。此时媒体
pad token与普通文本token一样,只对应词表中的占位向量
if inputs_embeds is None:
inputs_embeds = self.get_input_embeddings()(input_ids)
- 设语言模型隐藏维度为
D,则
input_ids : [B, L]
inputs_embeds : [B, L, D]
- 音频塔将
input_features编码为[N_audio, D]。模型在input_ids中定位全部audio_token_id,将布尔mask扩展到embedding维,再按序写入音频特征
audio_features = self.get_audio_features(
input_features,
feature_attention_mask,
audio_feature_lengths,
return_dict=True,
).last_hidden_state
_, _, audio_mask = self.get_placeholder_mask(
input_ids,
inputs_embeds=inputs_embeds,
)
inputs_embeds = inputs_embeds.masked_scatter(
audio_mask,
audio_features,
)
- 图像与视频共享视觉塔。视觉编码器的
pooler_output已完成merge_size²空间下采样,其第一维分别等于N_image与N_video
image_outputs = self.get_image_features(
pixel_values,
image_grid_thw,
return_dict=True,
)
image_embeds = image_outputs.pooler_output
image_mask, _, _ = self.get_placeholder_mask(
input_ids,
inputs_embeds=inputs_embeds,
image_features=image_embeds,
)
inputs_embeds = inputs_embeds.masked_scatter(
image_mask,
image_embeds,
)
video_outputs = self.get_video_features(
pixel_values_videos,
video_grid_thw,
return_dict=True,
)
video_embeds = video_outputs.pooler_output
_, video_mask, _ = self.get_placeholder_mask(
input_ids,
inputs_embeds=inputs_embeds,
video_features=video_embeds,
)
inputs_embeds = inputs_embeds.masked_scatter(
video_mask,
video_embeds,
)
get_placeholder_mask()直接由token id构造三类位置掩码
special_image_mask = input_ids == self.config.image_token_id
special_video_mask = input_ids == self.config.video_token_id
special_audio_mask = input_ids == self.config.audio_token_id
n_image_tokens = special_image_mask.sum()
n_video_tokens = special_video_mask.sum()
special_image_mask = special_image_mask.unsqueeze(-1)
special_video_mask = special_video_mask.unsqueeze(-1)
special_audio_mask = special_audio_mask.unsqueeze(-1)
-
多模态实现中常说的
scatter_mask,在当前Qwen3-Omni源码中对应special_image_mask、special_video_mask、special_audio_mask及其返回后的image_mask、video_mask、audio_mask。这些mask共同定义scatter的目标位置 -
以图像为例,
image_mask的逻辑形状为[B,L,1],在masked_scatter()中广播为[B,L,D]。若存在N_image个图像token,则mask中参与替换的标量数为N_image × D;image_embeds的元素数同样为N_image × D。Transformers在写入前检查二者相等
torch_compilable_check(
n_image_tokens * inputs_embeds.shape[-1] == image_features.numel(),
"Image features and image tokens do not match",
)
video使用相同检查。audio的一致性由ms-swift与模型共用的卷积输出长度公式保证。masked_scatter()按mask中True位置的线性顺序消费source;processor输出媒体特征的顺序与messages中占位符的顺序一致,因而第一个媒体占位区间接收第一份媒体的embedding,后续媒体依次对应
scatter 前
[text embedding]
[image_pad embedding × N_image]
[text embedding]
[video_pad embedding × N_video]
[text embedding]
[audio_pad embedding × N_audio]
scatter 后
[text embedding]
[image encoder output × N_image]
[text embedding]
[video encoder output × N_video]
[text embedding]
[audio encoder output × N_audio]
- 视觉编码器还返回
deepstack_features。image_mask | video_mask构成visual_pos_masks,文本解码器的前若干层在相同视觉位置继续叠加不同视觉层级的特征
visual_pos_masks = video_mask | image_mask
if deepstack_visual_embeds is not None and layer_idx in range(
len(deepstack_visual_embeds)
):
hidden_states = self._deepstack_process(
hidden_states,
visual_pos_masks,
deepstack_visual_embeds[layer_idx],
)
ms-swift在DeepSpeed路径中通过_compat_qwen3_omni_mixed_data()接管部分forward逻辑,但保持相同语义:视觉与音频特征仍由各自token mask写入inputs_embeds,DeepStack特征仍由visual_pos_masks定位
7. 输入链路的张量闭合
- 三种模态共享同一项对齐条件:语言序列中的媒体
token数必须等于对应encoder输出的embedding数 replace_tag()确定媒体边界,image_grid_thw、video_grid_thw与音频长度函数确定槽位数量,_extend_tokens()在input_ids中建立槽位,get_placeholder_mask()与masked_scatter()完成最终的embedding写入
messages.content 中的 <image>/<video>/<audio>
↓ StdTemplateInputs.from_dict
messages + images/videos/audios
↓ Template._swift_encode
包含标准媒体标记的 ChatML context list
↓ _split_special_tokens + replace_tag
<|image_pad|> / <|video_pad|> / <|audio_pad|>
↓ tokenizer
每种媒体一个初始 placeholder token
↓ Qwen3-Omni processor
pixel_values / pixel_values_videos / input_features
image_grid_thw / video_grid_thw / feature_attention_mask
↓ _get_feat_extract_output_lengths + _extend_tokens
与下采样特征等长的多模态 input_ids
↓ data_collator
input_ids + attention_mask + position_ids + media tensors
↓ Qwen3OmniMoeThinker.forward
text embedding + image/video/audio encoder output
↓ get_placeholder_mask + masked_scatter
统一的 inputs_embeds[B, L, D]