# 多模态应用：把图片、语音和视频接入可靠流程

## 用途、程度与前置

用户希望上传截图解释界面、拍票据提取字段、发送语音获得摘要时，输入就不再只有文本。本章适用于图像问答、OCR 辅助、语音转录、音频摘要和短视频理解。L1 要求是能选择合适任务链路、设计上传与结果界面，并理解文件格式、时长和模型能力的限制。

前置是文件上传、异步任务与基本后端校验。完整示例使用 Python 3.12 标准库生成并预检一秒静音 WAV，不需要麦克风、密钥、网络或模型。静音不能证明语音识别效果，预检也不能代替内容安全或恶意文件扫描；它只验证入口合同。

## 多模态不是给文本接口塞一个路径

语言模型接收到图片、音频或视频，通常需要对应模型和处理器。文件路径只是你电脑上的位置，远端 API 不能凭这个路径读取文件；需要上传得到资源 id、发送允许的内容编码，或提供供应商可访问的地址。不同模型支持的模态、格式和尺寸不同，接入时必须核对当前合同。

一些模型将对话 content 表示成有类型的项目列表，比如文本项目、图像项目和音频项目；处理器负责把媒体转换成模型输入，聊天模板还决定角色和特殊标记。不能假设所有模型都共用一种字段结构，也不能只换 model 字符串就完成跨供应商迁移。[多模态输入与模板](https://huggingface.co/docs/transformers/main/chat_templating_multimodal)

先明确任务是“识别文字”“理解场景”还是“生成媒体”。OCR 的目标是提取可见文字；视觉问答可能推断关系，但也可能把模糊数字看错。票据金额等字段需要格式与交叉校验，原图和定位证据应可回看。模型给出合理故事不意味着图上真的有这些事实。

语音链路可以拆为录音、转码、转录、说话人或时间段处理、摘要。视频通常还涉及抽帧、音轨、时间戳和片段组合。抽帧频率影响成本与漏检风险；只看若干帧无法保证理解瞬间发生的动作。字幕、画面和音频彼此可能冲突，需要保留来源而不是合并成一个无法追溯的答案。

## 前端流程要表达真实状态

上传完成不代表处理完成。推荐产品状态至少包含 uploading、queued、processing、succeeded、failed、cancelled。显示进度时区分字节上传进度和后台处理阶段，不要用随机百分比伪装真实进度。长任务可以返回 202 与 job_id，前端轮询或订阅状态，刷新页面后仍可恢复查看。

以下是自建后端的**示例合同**，不是某供应商 API，也不是本章已实现的 HTTP 服务：POST /api/media-jobs 接收受控文件引用与 task；成功返回 {job_id,status:"queued"}；GET /api/media-jobs/{id} 返回状态、结果或稳定错误码。任务查询和文件下载每次都要检查归属，不因为知道 id 就能读取。

错误码应区分 FILE_TOO_LARGE、UNSUPPORTED_FORMAT、DURATION_LIMIT、PROCESSING_TIMEOUT、NO_SPEECH 和 PROVIDER_ERROR。静音或无法识别是有效业务状态，不必总显示“服务器异常”。结果字段可为 null，并提供原因；不要用空字符串混合表达“没说话”“未处理”“识别失败”。

字幕和转录片段应保存 start_ms、end_ms 与文本，播放时按同一时间基准高亮。切片前后的时间偏移必须加回总时间轴；否则每段字幕都从零开始，界面看上去能播放却无法跳转。用户修订的文本应作为独立版本保留，避免后台重跑覆盖人工结果。

## 完整示例：生成 WAV 并验证音频合同

保存为 audio_preflight.py。wave 模块面向 WAV 中支持的 PCM 音频，本例选择单声道、16 位、16000 Hz 作为教学合同；真实转录模型不一定要求这些参数，应按目标服务调整。[wave 标准库](https://docs.python.org/3/library/wave.html)

```python
# audio_preflight.py
import json
import wave
from pathlib import Path
from tempfile import TemporaryDirectory

MAX_BYTES = 2 * 1024 * 1024
MAX_SECONDS = 30.0

def inspect_audio(path: Path) -> dict:
    size = path.stat().st_size
    if size > MAX_BYTES:
        raise ValueError("FILE_TOO_LARGE")
    try:
        with wave.open(str(path), "rb") as source:
            channels = source.getnchannels()
            width = source.getsampwidth()
            rate = source.getframerate()
            frames = source.getnframes()
            if channels != 1 or width != 2 or rate != 16000:
                raise ValueError("UNSUPPORTED_AUDIO_PARAMETERS")
            duration = frames / rate
            if not 0 < duration <= MAX_SECONDS:
                raise ValueError("DURATION_LIMIT")
            # 教学文件很小；生产应分块读取并设置处理资源限制
            payload = source.readframes(frames)
            if len(payload) != frames * channels * width:
                raise ValueError("TRUNCATED_AUDIO")
    except (wave.Error, EOFError) as error:
        raise ValueError("UNSUPPORTED_FORMAT") from error
    return {
        "mime": "audio/wav", "bytes": size, "channels": channels,
        "sample_rate": rate, "duration_seconds": duration,
        "status": "ready_for_transcription"
    }

def main() -> None:
    with TemporaryDirectory(prefix="audio-preflight-") as folder:
        target = Path(folder) / "silence.wav"
        with wave.open(str(target), "wb") as output:
            output.setnchannels(1)
            output.setsampwidth(2)
            output.setframerate(16000)
            # 16000 帧，每帧 2 字节，构造一秒静音
            output.writeframes(b"\x00\x00" * 16000)
        print(json.dumps(inspect_audio(target), ensure_ascii=False))

if __name__ == "__main__":
    main()
```

```bash
python audio_preflight.py
```

预期输出 mime 为 audio/wav、bytes 为 32044、channels 为 1、sample_rate 为 16000、duration_seconds 为 1.0、status 为 ready_for_transcription。它没有转录文本，因为脚本没有调用识别模型。

逐段解析：先检查文件大小，再交给格式解析器，避免相信扩展名或客户端 MIME；getnframes 返回帧数而不是字节数；时长是帧数除以采样率，声道数不能再除一次。样本宽度以字节计，16 位对应 2 字节。读取后的字节长度检查可发现本例中的截断内容，但不意味着能识别所有恶意媒体。

应用上传入口还应采用随机存储名、隔离对象存储、私有默认权限、短期下载授权和生命周期清理。将文件发给外部模型前明确数据用途与保留策略；供应商 URL 可访问性不等于任何人都应永久访问这个文件。短期签名 URL 也应避免写入可公开查看的日志。

## 结果评测和人工校验怎么做

图像提取先评字段准确率、必填缺失、格式正确性和是否引用正确区域。金额可以和各明细求和、币种、日期等业务规则交叉检查；校验失败标为待复核，不能要求模型自动“凑平”。语音转录可以用人工文本做词或字级错误统计，再关注关键实体、数字和否定词；摘要还需要独立检查遗漏与事实一致性。

多模态同样存在提示注入。截图上的“系统指令”、录音中的操作要求以及 OCR 转出的文字，都是不可信输入；转录任务应转录内容，不能把内容当成授权执行命令。模型如果建议访问图片中的 URL，也必须经过后端访问控制与网络边界检查。

成本和延迟不只由上传文件大小决定。图像处理可能与分辨率、切块和细节模式有关，音视频可能按时长或其他计量方式收费。保存实际用量及配置版本，先压测具有代表性的长短样本；不能只用一张小图推断所有上传体验。

## 常见错误与排障

浏览器录音常见容器格式未必是 WAV，给文件改后缀不会完成转码。解析失败时查真实编码、容器、采样率与服务支持范围。转录时间对不上，检查毫秒和秒混用、分段偏移以及前端播放器的时间基准。上传成功却模型读不到文件，检查权限、签名过期和地址可达性，不要公开整个存储桶解决。

模型总是漏掉图片角落信息，可以尝试合理分辨率或任务分块，但必须评估是否丢失上下文。将原图无限放大不会恢复不存在的细节。对于模糊手写数字，界面应提供核对原图和修正渠道。

## 先选任务链路，再选支持“多模态”的模型

“支持图片”只说明可以接收某类图像输入，不表示它适合精确读表、数物体、识别手写金额或理解医学影像。任务需要的能力不同，评价标准也不同。对票据提取，关键可能是金额和日期准确；对页面截图，关键可能是布局关系和可见文案；对视频摘要，关键可能是事件顺序与遗漏。

先把目标拆成输入、处理、输出和人工复核。输入可能是一张原图、一页 PDF 渲染图、连续音频或若干视频片段。处理可以是 OCR、语音转录、场景理解或生成。输出应有明确结构，例如字段值加证据位置、字幕片段加时间戳，而不是默认只返回一段自然语言。

传统工具与大模型可以组合。能通过确定解析取得的 PDF 文本，不一定需要把每页转成图片送视觉模型；简单音频格式检查也不需要语言模型。把确定步骤放在前面，能降低成本并使错误更容易定位。大模型更适合处理需要语义判断和上下文理解的部分，但其输出仍要按合同验证。

不要把每个失败都归因于模型能力。图像在上传时被压得过小、音频声道被错误合并、视频抽帧漏掉关键瞬间，都可能使模型根本没有足够证据。先确认输入实际包含什么，再评价生成结果。保存受控的处理版本与参数，让你能够复现模型看到的媒体，而不只是保留用户最初上传的文件。

## 音频的采样率、声道、位深和容器各代表什么

采样率表示每秒采集多少个采样时刻，声道数表示每个时刻有几路信号，位深表示每路样本的表示精度。对于未压缩 PCM，数据量大致由时长、采样率、声道和每样本字节数共同决定。文件还包含容器头和元信息，不能把理论音频字节数直接当作实际文件大小。

容器负责组织音轨和元数据，编码负责表示声音。WAV 常见于 PCM，但并不代表所有 WAV 都使用本章支持的格式；WebM、Ogg 或 MP4 也可能包含不同编码。扩展名和 MIME 是线索，不是完整证据。解码后再重采样才真正改变采样率，修改文件名或头部数字会让声音速度和时长解释错误。

单声道转录可能把多路声音混合，节省资源但也可能损失说话人线索。左右声道分别来自不同说话者的电话录音，不应不加判断地混成一条音轨。是否需要说话人分离、声道保持和时间对齐，应该由产品任务决定，而不是由某段示例代码的默认值决定。

实时处理与文件处理的容错不同。实时链路需要持续处理小块数据、端点检测、取消和重连；文件处理可以完整检查时长与格式，再提交后台任务。分片到达不代表每片都能独立解码成完整文件，MediaRecorder 的数据块通常需要按容器规则组合。不能把每个 dataavailable 事件都随意当成一份独立音频上传。

## 第二个完整示例：在浏览器录制并观察真实格式

保存为 recorder.html，在同一目录执行 python -m http.server 8082 --bind 127.0.0.1，然后访问 http://127.0.0.1:8082/recorder.html。示例仅在本机浏览器录制和回放，不上传数据。需要浏览器支持相关 API，并由你点击按钮后自行授予麦克风权限；教材编写时未代替你进行录音测试。

```html
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>本机录音实验</title>
<button id="start">开始录音</button>
<button id="stop" disabled>停止录音</button>
<p id="status">最长录制十秒，数据只留在当前页面。</p>
<audio id="player" controls></audio>
<script>
const start = document.querySelector("#start");
const stop = document.querySelector("#stop");
const status = document.querySelector("#status");
const player = document.querySelector("#player");
let recorder, stream, timer, currentUrl;
function releaseStream() {
  stream?.getTracks().forEach(track => track.stop());
  stream = undefined;
}
stop.onclick = () => {
  if (recorder?.state === "recording") recorder.stop();
};
start.onclick = async () => {
  start.disabled = true;
  const parts = [];
  try {
    if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
      throw new Error("当前浏览器缺少录音能力");
    }
    stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    const options = MediaRecorder.isTypeSupported("audio/webm")
      ? { mimeType: "audio/webm" } : {};
    recorder = new MediaRecorder(stream, options);
    recorder.ondataavailable = event => {
      if (event.data.size) parts.push(event.data);
    };
    recorder.onstop = () => {
      clearTimeout(timer);
      const blob = new Blob(parts, { type: recorder.mimeType });
      if (currentUrl) URL.revokeObjectURL(currentUrl);
      currentUrl = URL.createObjectURL(blob);
      player.src = currentUrl;
      status.textContent = "实际格式：" + blob.type + "，字节数：" + blob.size;
      releaseStream();
      start.disabled = false;
      stop.disabled = true;
    };
    recorder.onerror = () => {
      status.textContent = "录音失败，请检查设备与权限";
      releaseStream();
    };
    recorder.start();
    stop.disabled = false;
    status.textContent = "正在录音";
    timer = setTimeout(() => stop.click(), 10000);
  } catch (error) {
    releaseStream();
    start.disabled = false;
    stop.disabled = true;
    status.textContent = error.name + "：" + error.message;
  }
};
window.addEventListener("pagehide", () => {
  clearTimeout(timer);
  releaseStream();
  if (currentUrl) URL.revokeObjectURL(currentUrl);
});
</script>
</html>
```

正常停止后，页面应显示实际 MIME 和字节数，并可回放。具体格式由浏览器支持情况决定，不保证是 WAV。拒绝麦克风权限时应显示错误，而不是一直停在录音中。getUserMedia 需要安全上下文；本地回环开发环境与正式 HTTPS 部署的条件需要分别确认。[媒体授权](https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia)、[录制接口](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder)

getUserMedia 返回 Promise，成功得到 MediaStream，失败可能涉及权限、设备缺失或设备占用。MediaRecorder.start 开始收集数据，stop 触发最后的数据事件与停止事件。关闭录音器不等于释放麦克风轨道，所以示例显式停止 stream 的 tracks；替换回放 URL 时也释放旧对象 URL，避免多次录制不断保留内存。

十秒限制使用前端计时器，只是交互约束，不是服务器安全边界。后台标签页、设备暂停或浏览器调度可能影响精确时间，因此上传后仍需服务端读取真实媒体时长。客户端显示的文件格式同样不能代替服务端解析。

## 分段处理必须保留时间和内容来源

长音频可以分段处理，但切点可能落在一个词或一句话中间。适量重叠能减少边界遗漏，也会带来重复转录。简单删除相同字符串可能误删真实重复说话，应该结合时间范围与上下文合并，并保留原始片段供核查。对重要数字和人名，必要时回放边界附近音频。

每段模型返回的时间戳可能相对该片段起点，合并时必须加上片段在原音频中的偏移。若中间做过静音裁剪、加速或重采样，时间映射还需要记录处理关系。转码通常不应改变实际时长，但错误参数可能改变；不能假设所有处理后都可以直接沿用原始时间轴。

字幕和摘要也有不同证据粒度。字幕贴近原始话语，摘要合并和概括多个片段。摘要中的每条行动项最好能关联到支持它的片段或原文，而不是让用户只能相信一段流畅文字。说话人分离结果可能错，把重要任务自动分配给某个人之前需要独立核对。

## 第三个完整示例：合并局部时间戳

保存为 merge_segments.py，Python 3.12，仅标准库。它处理人工构造的片段元数据，不执行语音识别。

```python
# merge_segments.py
import json

def shift_segments(offset_ms, rows):
    if type(offset_ms) is not int or offset_ms < 0:
        raise ValueError("片段偏移必须是非负整数毫秒")
    output = []
    previous = -1
    for row in rows:
        start, end, text = row["start_ms"], row["end_ms"], row["text"]
        if type(start) is not int or type(end) is not int or not 0 <= start < end:
            raise ValueError("局部时间范围无效")
        if start < previous:
            raise ValueError("片段顺序无效")
        if not isinstance(text, str) or not text.strip():
            raise ValueError("字幕文本为空")
        previous = start
        output.append({
            "start_ms": offset_ms + start, "end_ms": offset_ms + end, "text": text
        })
    return output

merged = shift_segments(0, [
    {"start_ms": 0, "end_ms": 1000, "text": "您好"}
]) + shift_segments(10000, [
    {"start_ms": 0, "end_ms": 1500, "text": "请检查订单"}
])
merged.sort(key=lambda row: row["start_ms"])
assert merged[1]["start_ms"] == 10000
assert merged[1]["end_ms"] == 11500
print(json.dumps(merged, ensure_ascii=False))
```

执行 python merge_segments.py，第二条字幕应从一万毫秒开始，到一万一千五百毫秒结束。把局部 end_ms 改成负数，程序应拒绝。它不处理重叠去重或说话人合并，边界已明确；实际产品在此基础上增加对应策略，不能宣称这个小函数完成完整字幕系统。

## 图像与视频入口的实用处理顺序

图像处理先检查格式、尺寸、方向和体积，再决定缩放、裁剪或分块。某些照片方向来自元数据，忽略它可能让模型看到旋转内容。缩小大图可以节省成本，但会损失小字；按区域切块可以看清细节，却可能失去全局结构。需要通过代表性样本比较两种策略，而不是固定使用最大或最小分辨率。

PDF 要区分有文本层与扫描图像。可提取文本时应保留页码和布局线索；表格字段跨行跨列时，简单拼接可能错配金额和名称。扫描页经过 OCR 后还应保留原页引用，让用户查看识别依据。模型推测的缺失字不应与真实提取字无区别保存。

视频抽帧需要说明覆盖范围。每隔若干秒取一帧可以概览场景，却可能漏掉一秒内发生的关键动作。事件敏感任务可能需要更密集采样或专门模型，代价是更多计算和输入。视频音轨与画面也可能包含不同信息，不能只根据其中一条就宣称已理解全部内容。

输入中的人脸、声音、身份文件和位置可能涉及敏感信息。只保留完成任务需要的内容，并在上传、转码、模型调用、缓存和日志各阶段维护访问边界。删除操作要覆盖衍生文本和索引，而不仅是原文件；调试时也不能把私人媒体复制到公开问题记录中。

## 长任务状态与取消不能只存在于页面内存

用户上传文件后关闭页面，再回来时应该能通过任务编号查询实际状态。上传完成、解析完成、模型处理完成和结果发布是不同阶段，后端需要持久记录状态与版本。前端刷新不应重新创建同一任务；重复点击提交可以使用业务幂等标识返回已有任务，避免重复转码和计费。

取消也有阶段差异。尚未开始的任务可以从队列中取消，正在转码的任务需要通知工作进程，已经提交给供应商的任务则取决于其取消能力。即使用户不再等待，也可能有已发生的资源消耗。界面应该区分取消请求已收到和后台已确认停止，不要瞬间显示“已完全取消”却继续处理。

模型结果保存前还要确认任务没有被撤销或替换。用户修订转录文本之后重新生成摘要，应建立新的结果版本并记录使用了哪份文本，不能让较早任务晚到的结果覆盖人工修订。可以在提交结果时比较预期版本，发现不一致则保留为旧版本或明确丢弃，避免异步竞争。

## 转录指标为什么需要任务语义补充

词错误率通常关注替换、删除和插入相对于参考词数的比例；中文任务也可能使用字级统计。不同分词、数字写法和标点规范会影响结果，所以比较前要固定规范化规则。把“二十元”和“20 元”统一是否合理，取决于后续任务，不能为了降低错误率随意改参考答案。

总体错误率很低也可能漏掉重要否定词，例如“不要退款”被转成“要退款”。因此还应单独评估金额、日期、姓名、否定和行动项。摘要评价又是另一层：转录基本正确，摘要仍可能把建议写成已完成事实。为每个阶段准备独立证据和关键字段检查，比只给整个多模态系统一个满意度分数更能指导修复。

## 综合练习：把时间戳结果用于可核查摘要

练习要求生成两条摘要条目，每条都包含 evidence_start_ms 与 evidence_end_ms，且范围必须落在原音频时长内。摘要内容本身可以先人工提供，重点是验证时间证据合同。

<details><summary>完整参考实现</summary>

下面函数可保存为 validate_evidence.py 独立运行。它只验证时间范围，不证明摘要文字真实，因此还需要人工或其他事实检查。

```python
def validate_evidence(items, duration_ms):
    if type(duration_ms) is not int or duration_ms <= 0:
        raise ValueError("总时长无效")
    for item in items:
        start, end = item["evidence_start_ms"], item["evidence_end_ms"]
        if type(start) is not int or type(end) is not int:
            raise ValueError("时间必须使用整数毫秒")
        if not 0 <= start < end <= duration_ms:
            raise ValueError("证据范围超出音频")
    return True

assert validate_evidence([
    {"text": "用户提出订单问题", "evidence_start_ms": 10000, "evidence_end_ms": 11500}
], 12000)
try:
    validate_evidence([
        {"text": "错误范围", "evidence_start_ms": 11000, "evidence_end_ms": 14000}
    ], 12000)
except ValueError:
    print("越界证据已拒绝")
```

执行后应打印越界证据已拒绝。前端点击摘要可以跳转到该时间段，但播放器能跳转只是导航正确，不代表摘要正确。把结构、时间、事实三种验收分开，才能知道每个检查实际提供了什么保证。

</details>


## 练习、提示与参考解答

练习：把音频改为双声道，应得到参数错误；把音频长度改成三十一秒，应得到时长限制；解释为什么一秒静音不会证明“转录正确”。

提示：生成双声道时每帧字节数随之变化；时长用帧数而不是文件字节数直接计算。

<details><summary>参考答案</summary>

调用 setnchannels(2)，写入每帧四字节的静音，会被 channels 检查拒绝。改成单声道写入 16000 * 31 帧，文件仍可能低于大小限制，但会命中 DURATION_LIMIT。静音只能验证格式和长度路径；评估转录需要包含真实语音及人工标注，还要覆盖噪声、口音、数字和无语音样本。

</details>

## 可验证验收与自测

验收标准输出、双声道拒绝、超时长拒绝和截断检测。能描述任务状态与错误码，说明原始媒体、提取文本和生成结论各自来源；真实模型质量和浏览器录音兼容性需另外实测。

1. **把 webm 重命名为 wav 能转码吗？** 不能，文件名不会改变容器与编码。
2. **模型返回转录就可以自动执行业务动作吗？** 不可以，识别内容不是用户授权。
3. **为什么结果要保留时间戳或区域信息？** 方便回看证据、人工修订和定位错误，也让界面能够正确跳转。