P15 · Thinker→Talker 输入构造(含“差一行”)
vLLM 先把 stop token 追加到输出再判停,所以 thinker 的 hidden 会多一行;talker 吃进去,音频末尾就多出杂音——这就是“差一行”。
mine/p15_talker_inputs.py
验收 ./check.sh p15
提示 A-22 · 参考解 C-22 solutions/p15_talker_inputs.py
题面速览
- 在系统中的位置:非流式(full payload)模式下,thinker 结束时把累积的 hidden 发给 talker(Qwen2.5-Omni 布局,Qwen3-Omni 类似)。talker 的 prompt = thinker 的 prompt 部分 + 两个特殊位置,之后每个 decode 步消费一行 thinker 回复向量。
- “差一行”:
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)时没有多余行,全部保留
- talker prefill 布局(Qwen2.5-Omni):
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"),最后一个字是你真正说的话,一个字都不能剪。还有:磁带后面多出一截空白(多捕获的行),正确的剪法是“只保留应该有的长度”,而不是“永远剪掉最后一秒”——否则空白被剪了,咔哒声还在。
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] |
易错点
- 用
hidden[:-1]去掉 stop 行:多捕获了行时砍掉的是多余行,stop 行还在。 - finish="length" 也 drop:把真正的最后一个字丢掉。
- stop 但 output_ids 为空:没有“stop 那一行”可丢;硬 drop 会切掉一行 prompt。
- 先切片后检查:切片越界不报错,只悄悄返回短 tensor;R < P 的检查要放最前面。
- 原地修改调用方的 output_ids:调用方可能还要拿它 detokenize;返回新 list。
[D]vs[1, D]:直接 cat 1-D 向量会报维度错;别把(ids, embeds)返回顺序写反。- 队列空时返回 None:调用方下一步还要拿它;应原样返回
[0, D]的空队列。
逐步图:trim 的推导(例 1)
hidden.shape[0] < P?7 < 4 → 否,继续。
hidden 行:0123456
finish == "stop" 且 output 非空 → drop = 1;target = 4 + 3 − 1 = 6
rows = hidden[:6] → prefill = 行 0..3,decode = 行 4, 5(第 6 行属于 stop token,丢掉)
0123456
[21, 22, 151645][:3−1] = [21, 22](新 list)
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])
关键问题
- 为什么 stop 结束的请求 hidden 行数会比 talker 真正需要的多一行?这一行对应哪个 token 的 forward?
- finish_reason == "length" 时最后一个生成 token 是正常内容还是 stop token?要不要丢?
- prefill 和 decode 的分界点由什么决定:hidden 的行数,还是 len(prompt_ids)?
- hidden 行数 > target,多出的行怎么处理?< P 呢?为什么处理方式不一样?
- talker prefill 最后两个位置的 embedding 各是哪两个向量相加?对应的 id 是什么?
- first_reply 已在 prefill 里用掉,那 decode 时 reply_queue 的第一行应该是第几个生成 token 的行?
- 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详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- stop token 被采样出来后先 append 再判停,它那一步的 hidden 已经被累积;这一行是“产生 stop token 的那次 forward 之后”记录的行,不属于回复内容。
- 普通内容(只是到了 max_tokens),全部保留,drop=0。
- 分界点是
P = len(prompt_ids);hidden 行数只用于检查和截断。 -
target:截断到 target,静默丢弃;< P:连 prompt 都不完整,prefill 无法构造,必须报错。介于 P 和 target 之间:按切片自然得到较短的 decode(spec 不要求报错)。
- 倒数第二位:
spk_embed + codec_pad_embed,id = pad_id;最后一位:first_reply + codec_bos_embed,id = start_id。 - 一般从第 2 个生成 token 开始(第 1 个已作为 first_reply 进 prefill);这个函数本身不关心,只取 queue 头。
- 原来的空 queue,形状
[0, D]。
自检:① 例 2 的 5 行 hidden 下 decode 是否恰好 2 行;② build_talker_prefill 返回的 embeds 前 P 行与 prompt_embeds 完全相等;③ decode 第 3 步起一直用 fallback,queue 形状保持 [0, D]。
面试官追问 & 对照源码
- async_chunk 模式下 chunk 0 应该带哪些东西?之后的 chunk 呢?
对照源码: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