nano-vllm-omni · 提示 ← P13P14 →
#22 · Track D · 模型胶水(纯函数) · 书 p.96

P15 · Thinker→Talker 输入构造(含“差一行”)

vLLM 先把 stop token 追加到输出再判停,所以 thinker 的 hidden 会多一行;talker 吃进去,音频末尾就多出杂音——这就是“差一行”。

⏱ 40 min 难度 易 实现 mine/p15_talker_inputs.py 验收 ./check.sh p15 提示 A-22 · 参考解 C-22 solutions/p15_talker_inputs.py

题面速览

prompt: P 个 token,生成: [a, b, <stop>]
vLLM 采样出 <stop> → 先追加到 output → 再检查 stop → FINISHED_STOPPED
累积的 hidden 行数 = P + 3(包含 <stop> 那一步)
talker 应该只消费 a、b 两行 → 去掉最后一行,并从 output_ids 里去掉 stop id
finish_reason = "length"(到达 max_tokens)时没有多余行,全部保留
embeds = [prompt_embeds (P 行)] + [spk_embed + codec_pad_embed] + [first_reply + codec_bos_embed]
ids    = [mask_id] * P + [pad_id, start_id]                  # 共 P + 2 个位置
decode: input_k = reply_queue[k] + embed(上一步采样的 codec);回复向量用完后用 fallback(tts_pad)

特殊 id 来自 config,这里通过参数传入。

验收要点:stop 结束:去掉一行与 stop id · length 结束:全部保留 · prefill 行数不足时报错 · talker prefill 的 ids/embeds 布局 · decode 每步消费一行,耗尽后用 fallback。

直觉与类比

录音机的“咔哒”声

你说完后伸手按停止键——录音机先录下了按键的“咔哒”声才停。播放前得把结尾那下咔哒剪掉(finish="stop" → drop 1)。如果是磁带用完自动停的(finish="length"),最后一个字是你真正说的话,一个字都不能剪。还有:磁带后面多出一截空白(多捕获的行),正确的剪法是“只保留应该有的长度”,而不是“永远剪掉最后一秒”——否则空白被剪了,咔哒声还在。

座位表:P 个普通座 + 两个特殊座

talker 的 prefill 有 P + 2 个座位。前 P 个坐 prompt 的每一行(名牌统一写 mask_id——talker 只看人本身,即 embedding)。第 P 号是“主持人席”:说话人 + codec pad,名牌 pad_id;第 P+1 号是“开场席”:回复第一行 + codec bos,名牌 start_id——talker 从这里开始说出第一帧 codec。之后每个 decode 步回复的下一行“上台”一次;人都上完了,由替补(fallback)一直顶上。

框架视角 · shape trace

例子:P = 3,output 4 个(含 stop),R = 8,D = 2。

# 张量 / 量 形状 发生了什么
1 hidden [8, 2] prefill 3 行 + 每个 decode 步 1 行(含 stop 那步)+ 1 行多捕获
2 P,检查 R ≥ P 标量 P = 3;8 ≥ 3,继续(否则 ValueError)
3 drop、应有行数 标量 stop 且有输出 → drop = 1;3 + 4 − 1 = 6
4 rows = hidden[:6] [6, 2] 行 6(stop)和行 7(多余)都被截掉
5 prefill / decode [3, 2] / [3, 2] 按 P 切开;decode 行数 == 新的 len(output_ids) = 3
6 spk+pad、first_reply+bos [2] → [1, 2] 1-D 向量先变成一行才能和 [P, D] 沿 dim 0 拼接
7 embeds / ids [5, 2] / 5 个 P + 2 个位置,ids 与 embeds 一一对应
8 reply_queue [2, 2] → [1, 2] → [0, 2] 每步取队头一行;空了之后用 fallback,队列保持 [0, 2]

易错点

逐步图:trim 的推导(例 1)

▶ prompt_ids=[11,12,13,14](P=4),output_ids=[21, 22, 151645],hidden 7 行(第 r 行 = [r, r]),finish="stop"
1. 检查

hidden.shape[0] < P?7 < 4 → 否,继续。

hidden 行:0123456

2. drop 与 target

finish == "stop" 且 output 非空 → drop = 1;target = 4 + 3 − 1 = 6

3. 截断与切分

rows = hidden[:6] → prefill = 行 0..3,decode = 行 4, 5(第 6 行属于 stop token,丢掉)

0123456

4. output_ids

[21, 22, 151645][:3−1] = [21, 22](新 list)

5. 对比:length 结束 + 多捕获

prompt [11, 12](P=2),output [5, 6],hidden 5 行,finish="length" → target = 4:prefill 行 0,1、decode 行 2,3,第 4 行超出 target 不要。

01234

prompt_ids=[1,2,3,4](P=4)而 hidden 只有 3 行 → ValueError。

三级提示

L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)

例 1、例 2 见逐步图。

例 3:prefill 布局与 decode 消费(D=2):

prompt_embeds = [[1,2],[3,4]]   first_reply = [0.5,0.5]
spk = [10,20]   codec_pad = [1,1]   codec_bos = [100,200]
mask_id=7, pad_id=5, start_id=6
ids    = [7, 7, 5, 6]
embeds = [[1,2], [3,4], [11,21], [100.5,200.5]]       # [P+2, D] = [4, 2]
step last_codec_embed 输出 input 剩余 queue 行数
1 [0.1,0.2] [1.1, 1.2] 1
2 [0.3,0.4] [2.3, 2.4] 0
3 [0.5,0.6] [-8.5, -8.4] 0 ← 用 fallback
4 [0.7,0.8] [-8.3, -8.2] 0 ← 一直 fallback

(reply_queue = [[1,1],[2,2]],fallback = [-9,-9])

关键问题

  1. 为什么 stop 结束的请求 hidden 行数会比 talker 真正需要的多一行?这一行对应哪个 token 的 forward?
  2. finish_reason == "length" 时最后一个生成 token 是正常内容还是 stop token?要不要丢?
  3. prefill 和 decode 的分界点由什么决定:hidden 的行数,还是 len(prompt_ids)?
  4. hidden 行数 > target,多出的行怎么处理?< P 呢?为什么处理方式不一样?
  5. talker prefill 最后两个位置的 embedding 各是哪两个向量相加?对应的 id 是什么?
  6. first_reply 已在 prefill 里用掉,那 decode 时 reply_queue 的第一行应该是第几个生成 token 的行?
  7. talker_decode_input 在 queue 为空时返回的“剩余 queue”是什么形状?

细节

  • target = P + len(output_ids) − drop:它是“talker 应当看到的总行数”,而不是 hidden.shape[0] − drop。
  • mask_id / pad_id / start_id 是 talker(codec)词表里的特殊 id;prompt 部分统一填 mask_id,模型只看 embedding。
  • decode 输入 = 文本侧(thinker 回复向量)+ 音频侧(上一步 codec id 的 embedding);文本用完后换成 fallback(真实系统里是 tts_pad 的 embedding)。
L2思路与常见坑思路拆分与常见坑(中度剧透)
  • 拆成三个纯函数:trim 只做切片与边界检查;prefill 只做 cat;decode 只做“取头 + 相加”。每个 3–8 行。
  • trim 的顺序:① 先算 P 并检查 hidden.shape[0] < P → ValueError;② 算 drop;③ 算 target 并截断 rows;④ 按 P 切成 prefill / decode;⑤ output_ids 去尾。
  • 边界:stop 但 output_ids 为空——只在 output_ids 非空时 drop。
  • prefill 拼接:一次 torch.cat 三块([P,D]、[1,D]、[1,D]),dim=0;ids 用 list 乘法再拼两个特殊 id;返回 (ids, embeds)。
  • decode 的剩余 queue:有行时返回 queue[1:](K=1 时是 [0, D]);空时原样返回空 queue。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. stop token 被采样出来后先 append 再判停,它那一步的 hidden 已经被累积;这一行是“产生 stop token 的那次 forward 之后”记录的行,不属于回复内容。
  2. 普通内容(只是到了 max_tokens),全部保留,drop=0。
  3. 分界点是 P = len(prompt_ids);hidden 行数只用于检查和截断。
  4. target:截断到 target,静默丢弃;< P:连 prompt 都不完整,prefill 无法构造,必须报错。介于 P 和 target 之间:按切片自然得到较短的 decode(spec 不要求报错)。

  5. 倒数第二位:spk_embed + codec_pad_embed,id = pad_id;最后一位:first_reply + codec_bos_embed,id = start_id。
  6. 一般从第 2 个生成 token 开始(第 1 个已作为 first_reply 进 prefill);这个函数本身不关心,只取 queue 头。
  7. 原来的空 queue,形状 [0, D]。

自检:① 例 2 的 5 行 hidden 下 decode 是否恰好 2 行;② build_talker_prefill 返回的 embeds 前 P 行与 prompt_embeds 完全相等;③ decode 第 3 步起一直用 fallback,queue 形状保持 [0, D]。

面试官追问 & 对照源码

对照源码:model_executor/stage_input_processors/qwen2_5_omni.py:261-317 · model_executor/models/qwen2_5_omni/qwen2_5_omni.py:760-847 · model_executor/stage_input_processors/qwen3_omni.py:409-542