# 多模态输入设计

> 状态：Wave 3 / Task 8 完成（inputs_embeds 路径已正式接入 minivLLM 引擎）

## 1. 视觉 token 的两种布局策略

### 1.1 bos_image_text（图片前置）

```
[BOS] [IMG_START] v_1 v_2 ... v_N [IMG_END] t_1 t_2 ... t_M
```

- 视觉 token 在序列前部，紧接着 BOS。
- 适合"看图说话"类任务（先看图，再理解文本）。
- `mm_sequence_builder.py` 实现：`build_bos_image_text(num_visual, num_text)`。
- 视觉区间：`[2, 2+num_visual)`，文本区间：`[3+num_visual, total_len)`。
- IMG_START / IMG_END 是特殊 token（Qwen3 tokenizer: 151655 / 151656）。

### 1.2 placeholder_expanded（占位符展开）

```
t_1 ... t_k [IMG_PLACEHOLDER] t_{k+1} ... t_M
```

- 文本中插入一个 `IMG_PLACEHOLDER`（151654），在 embedding 层被 `num_visual` 个 visual token 替换。
- 适合"问答 + 图"类任务（文本中有 `<image>` 标记）。
- Task 8 的 `inputs_embeds` 路径需要把 visual embeddings 注入到占位符位置。
- 示例：`k = num_text // 2`（插入位置为文本中间）。

### 1.3 选择原则

| 场景 | 推荐布局 |
|------|---------|
| 纯图片理解（无问题文本） | `bos_image_text` |
| 带图片的对话（问题中有 `<image>` 占位符） | `placeholder_expanded` |
| 模型原生支持（如 Qwen3-VL 用的格式） | 按模型 tokenizer 的 chat_template |

## 2. 图像预处理管线

```
原始图片 (任意 H×W)
  → PIL.Image.open + convert("RGB")
  → resize((224,224) 或 (336,336), BICUBIC)
  → tensor (3, H, W) / 255.0
  → CLIP normalize: mean=(0.4815, 0.4578, 0.4082), std=(0.2686, 0.2613, 0.2758)
  → 输出 (3, 224, 224) float32 tensor
```

实现：`experiments/mm_token_pipeline/image_preprocess.py`

- CLI: `--image <path> --size 224|336 --out <path>`
- 支持的尺寸：224×224、336×336
- 输出可保存为 `.pt` 文件

## 3. Patch Embedding 形状契约

```
Conv2d(3, embed_dim, patch_size, stride=patch_size)
  → (B, embed_dim, H/patch, W/patch)
  → flatten(2) + transpose → (B, num_patches, embed_dim)
```

- ViT-Base: patch=16, img=224 → num_patches = 14×14 = 196, embed_dim=768
- ViT-Large: embed_dim=1024
- CLIP ViT-B/32: patch=32 → num_patches = 7×7 = 49

实现：`experiments/mm_token_pipeline/patch_embed_demo.py`

## 4. Visual Token 生成模式

### 4.1 tiny-vit-random（教学模拟）

- `Conv2d(3, 192, 16, 16)` + 2 层 TransformerEncoder
- 加入 CLS token，最终输出 `(B, 197, 192)`
- 纯随机权重，无任何下载，仅演示 shape 变化

### 4.2 clip-reference（结构参考）

- 打印 `openai/clip-vit-base-patch32` 的配置参数
- **不下载 HF 权重**，transformers 不可用时仅输出 shape 契约
- 契约：input `(B, 3, 224, 224)` → output `(B, 50, 768)`

实现：`experiments/mm_token_pipeline/visual_token_demo.py`

## 5. 位置编码与注意力掩码

### position_ids

- 两种布局均使用顺序递增的 position_ids：`[0, 1, 2, ..., total_len-1]`
- Qwen3 使用 RoPE，位置连续递增即可
- Task 8 接 `inputs_embeds` 时，`position_ids` 需与 token 序列长度一致

### attention_mask

- 当前阶段为双向注意力（全 1）
- causal_mask: `torch.tril(torch.ones(total_len, total_len, dtype=torch.bool))`（左下三角）
- Task 8 如需因果注意力，使用 `attention_mask` 控制

## 6. Task 8 实现结果

### 6.1 引擎改动（已落地）

`minivLLM/minivllm/model/qwen3.py`：

```python
class Qwen3Model(nn.Module):
    def forward(
        self,
        input_ids: torch.Tensor | None = None,
        positions: torch.Tensor | None = None,
        kv_cache: KVCache | None = None,
        is_prefill: bool = True,
        inputs_embeds: torch.Tensor | None = None,
    ) -> torch.Tensor:
        if input_ids is not None and inputs_embeds is not None:
            raise ValueError(
                "Qwen3Model.forward cannot accept both input_ids and inputs_embeds "
                "at the same time. Use one or the other."
            )
        if inputs_embeds is None and input_ids is None:
            raise ValueError(
                "Qwen3Model.forward requires at least one of input_ids or inputs_embeds."
            )
        if inputs_embeds is not None:
            hidden_states = inputs_embeds  # 跳过 embed_tokens
        else:
            hidden_states = self.embed_tokens(input_ids)
        ...
```

`Qwen3.forward` 同样透传 `inputs_embeds` 参数。

### 6.2 测试验证

| 测试 | 结果 |
|------|------|
| `text_parity` (input_ids vs inputs_embeds=embed_tokens(input_ids)) | max\|diff\|=0.00e+00 - PASS |
| `invalid_dual_input` (同时给 input_ids + inputs_embeds) | ValueError caught - PASS |
| `validate_model.py --compare-hf --full` (Task 4 回归) | verdict=IDENTICAL - PASS |

### 6.3 与 Task 9 的接口

Task 9 将按以下方式接入 visual embeddings：

```python
text_embeds = model.model.embed_tokens(text_ids)
visual_embeds = vision_encoder(image)  # Task 9 提供
combined_embeds = torch.cat([text_embeds, visual_embeds], dim=0)
positions = torch.arange(len(combined_embeds))
hidden = model(inputs_embeds=combined_embeds, positions=positions)
logits = model.compute_logits(hidden)
```

不需要修改引擎，`inputs_embeds` 路径已完全就绪。

## 7. 示例图片

`sample_images/demo.jpg`（224×224 RGB JPEG）：
- 浅色背景 + 蓝/红几何形状 + "Hello VLM" 文字
- 可被 PIL 读出 `(224, 224, 3)`
- 用于所有脚本的输入测试
