MiniCPM-Robot 的多模态输入 / 图像和文本各自放在哪一段

文章导读
MiniCPM-Robot 这类多模态输入,图像通常不是以文本形式“写”进句子里:视觉侧先把图像切块编码成一组视觉 token,文本序列里只保留一段占位区间(有时是成对的起止标记,有时是单个占位符由模型内部展开)。因此“图像和文本各自放在哪一段”并没有跨实现通用的答案,需要落到本地预处理代码里的占位符定义和拼接顺序上。判断路径建议是:先在 processor / tokenizer 里找到占位符与
📋 目录
  1. 一 在预处理与分词代码里找到占位符定义
  2. 二 画出一次输入的完整序列结构
  3. 三 改一处顺序做对照实验
  4. 四 确认截断落在哪一段
  5. 五 把验证固化成一个小脚本
A A

MiniCPM-Robot 这类多模态输入,图像通常不是以文本形式“写”进句子里:视觉侧先把图像切块编码成一组视觉 token,文本序列里只保留一段占位区间(有时是成对的起止标记,有时是单个占位符由模型内部展开)。因此“图像和文本各自放在哪一段”并没有跨实现通用的答案,需要落到本地预处理代码里的占位符定义和拼接顺序上。判断路径建议是:先在 processor / tokenizer 里找到占位符与对应 token id,再把一次实际输入解码回可读文本画出段落结构,最后只改一处顺序做对照,并用截断检查排除指令或图像被切掉的可能。

多数 MiniCPM-Robot 接入场景里,图像段由视觉占位符标记,指令文本段紧随其后或位于其前,状态向量若存在通常作为独立一段拼接,具体顺序以本地预处理代码为准。建议先打印占位符与 token id 确认定义位置,再解码完整输入序列固定各段位置,然后只改顺序一处做对照并记录两次输出差异,同时检查截断后占位符数量与指令关键词是否仍在。风险边界:占位符数量与图像切块数不匹配、或截断吃掉图像段时,输出异常往往与顺序无关,需先排除这两种情况。

在预处理与分词代码里找到占位符定义

占位符一般由两类地方写入:一是 processor 的 __call__(或等价的图像处理器 + 分词器组合)在拼接 prompt 时插入;二是 tokenizer 的 additional_special_tokens 注册,配合模型 config 里声明的一个 id(命名常见为 image_token_id、image_token_index 之类,具体名以本地代码为准)。先确认“谁写入”,后面讨论顺序才有意义。下面这段骨架可以在加载完 processor 后直接执行,用来打印占位符与对应 token id:

tok = processor.tokenizer if hasattr(processor, "tokenizer") else processor

# 1) 列出注册的特殊 token 及其 id
for t in getattr(tok, "additional_special_tokens", []):
    print(repr(t), tok.convert_tokens_to_ids(t))

# 2) 看模型配置里声明的占位 id
cfg = model.config
for name in ("image_token_id", "image_token_index",
             "vision_start_token_id", "vision_end_token_id"):
    print(name, getattr(cfg, name, None))

# 3) 拿一个占位符回填验证:id 是否真的是特殊 token
ph = "<image>"   # 替换成上一步打印出来的真实占位符字符串
print(ph, tok.convert_tokens_to_ids(ph))

这里要分清两种写法:成对标记(起止各一个 token)和单占位符(一个 token 由模型内部扩成 N 个视觉 token)。替换项只有占位符字符串本身,跑完后把打印出的 id 记下来,后续所有检查都用这个 id 对齐。

画出一次输入的完整序列结构

把一次真实请求的 input_ids 解码回来,配合特殊 token 的位置索引,就能把各段钉死。建议按“视觉占位区间 → 指令文本 → 状态向量(若模型接受)→ 结束符”四段对位,状态向量是否进序列、放在哪一端,需要结合本地预处理确认,有些实现根本不进 token 序列。

段位常见位置确认方式
视觉占位区间序列开头或指令之后统计占位符 id 出现位置与数量
指令文本紧邻占位区间解码后肉眼核对关键词
状态向量末尾或独立字段看预处理是否拼进 input_ids
分隔/结束符序列末尾查 eos / sep token id
ids = inputs["input_ids"][0].tolist()
print("总长度:", len(ids))
print(tok.decode(ids, skip_special_tokens=False))

special = {"image": IMG_ID, "eos": tok.eos_token_id}
for name, tid in special.items():
    pos = [i for i, x in enumerate(ids) if x == tid]
    print(name, "位置:", pos[:10], "数量:", len(pos))

解码时保留特殊 token(skip_special_tokens=False),否则占位符会被吞掉,段落结构就看不出来了。

改一处顺序做对照实验

顺序是否影响输出,用一次只改一处的对照就能看出。控制变量:同一张图、同一句指令、同样的生成长度,只把占位符从指令前挪到指令后,其他参数不动。若占位符数量与图像切块数不匹配,会先报错或输出乱码,那属于另一类问题,应先修数量再谈顺序。

MiniCPM-Robot 的多模态输入 / 图像和文本各自放在哪一段
def build(order):
    body = "请描述画面并给出下一步动作"
    text = f"{PH}{body}" if order == "img_first" else f"{body}{PH}"
    return processor(text=text, images=[img], return_tensors="pt")

for order in ("img_first", "text_first"):
    inp = build(order)
    out = model.generate(**inp, max_new_tokens=64)
    print(order, "输入长度:", inp["input_ids"].shape[-1])
    print(order, "输出:", tok.decode(out[0], skip_special_tokens=True))
    print(order, "占位符位置:", [i for i, x in enumerate(inp["input_ids"][0].tolist()) if x == IMG_ID])

记录方式建议固定四项:输入长度、占位符位置、完整输出文本、有无报错。两次输出明显不同,说明当前模型对顺序敏感,后续排错就按你实际采用的那一种顺序去对齐;差异很小或只是措辞变化,则顺序不是主要变量,问题更可能在截断或占位符数量上。

确认截断落在哪一段

多模态输入里常见的隐性故障是 max_length 或 truncation=True 把图像段或指令尾部切掉,此时输出变差跟顺序无关。判断依据很直接:比较不截断与截断两次的长度和尾部内容,再看占位符 id 的数量是否等于图像应产生的 token 数,指令里的关键动作词是否还在。

full = tok(TEXT_WITH_PH, return_tensors="pt")
cut  = processor(text=TEXT_WITH_PH, images=[img],
                 return_tensors="pt", max_length=MAX_LEN, truncation=True)

print("未截断长度:", full["input_ids"].shape[-1])
print("截断后长度:", cut["input_ids"].shape[-1])
print("未截断尾部:", tok.decode(full["input_ids"][0][-20:]))
print("截断后尾部:", tok.decode(cut["input_ids"][0][-20:]))
print("截断后占位符数量:", (cut["input_ids"][0] == IMG_ID).sum().item())

占位符数量变少,说明图像段被切;关键动作词从尾部消失,说明指令被切。两种情况的处理方向不同:前者调图像分辨率或切块配置,后者放宽长度上限或压缩指令文本。

把验证固化成一个小脚本

上面几步跑通后,把它们合成一个可以在每次改配置后重跑的检查脚本,断言三项:占位符数量与预期一致、指令关键词仍在序列中、截断前后长度差在本次修改允许的范围内。脚本结构建议如下:

def check(data, expect_ph=EXPECT_PH, keywords=("动作", "描述")):
    ids = data["input_ids"][0].tolist()
    text = tok.decode(ids, skip_special_tokens=False)
    n_ph = sum(1 for x in ids if x == IMG_ID)
    assert n_ph == expect_ph, f"占位符数量 {n_ph} != {expect_ph}"
    assert all(k in text for k in keywords), "指令关键词缺失,疑似被截断"
    assert len(ids) <= MAX_LEN, f"实际长度 {len(ids)} 超过上限"
    print("ok", len(ids), n_ph)

if __name__ == "__main__":
    check(build("img_first"))

把 EXPECT_PH 设为本地配置下图像实际切块数对应的占位数量,MAX_LEN 设为上传链路的实际上限,断言失败即打印是哪一项,这样顺序或配置改动后不必重新肉眼解码就能定位到具体段位。