1. 多模态视频理解工程的整体设计思路
1.1 为什么视频理解不能直接“喂”给模型
做过视频理解的人都有一个共识:视频本质上是一串连续的图像帧加上音轨,而绝大多数多模态大模型的原生输入是图像 + 文本,并不直接吃视频流。所以工程上要做的第一件事,就是把视频“翻译”成模型能消化的形式——抽帧成图,再配上文字提示,最后组装成一次可提交的请求。
这个翻译过程听起来简单,但真正落地时会发现三个绕不开的约束。第一是Token 预算,每一帧图像在模型侧都会被编码成一定数量的视觉 Token,帧数越多,Token 消耗越大,成本和延迟同步上升。第二是信息密度,视频里大量帧是冗余的(比如静止画面、缓慢移动),无脑均匀抽帧既浪费预算又抓不住关键动作。第三是时序语义,很多问题(“他什么时候放下杯子”“这个动作持续了几秒”)依赖帧与帧之间的先后关系,抽帧策略必须保留时间线索。
所以一个合格的多模态视频理解工程,核心就是在这三个约束之间找平衡:用尽量少的帧,保留尽量多的有效信息,并让 Prompt 能准确指向这些信息。这也是我把标题拆成“抽帧策略、帧预算、Prompt 组装”三块的原因,它们是一条流水线上的三个工位,任何一个出问题,最终答案都会崩。
1.2 整条流水线的四个阶段
我把实际项目里的处理链路拆成四段,后面每个章节都会围绕它们展开。
- 预处理阶段:解码视频、读取元信息(时长、帧率、分辨率)、判断是否需要音频轨。
- 抽帧阶段:根据策略决定抽哪些帧、抽多少帧,输出一组带时间戳的图像。
- 帧预算阶段:把抽出的帧按 Token 成本做裁剪、缩放、去重,控制在模型可接受的范围内。
- Prompt 组装阶段:把帧序列、时间戳、任务指令拼成结构化输入,提交给模型。
这四段里,抽帧和帧预算是“省钱”的,Prompt 组装是“花对钱”的。很多人只关注 Prompt 写得好不好,却忽略了前面帧选得对不对,结果就是 Prompt 再精致,模型看到的画面本身就是错的,答案自然离谱。
1.3 适用人群与典型场景
这套东西适合三类人参考。第一类是做视频问答、视频摘要、内容审核的工程师,需要把视频理解能力接进自己的产品。第二类是做多模态 Agent 的开发者,视频只是其中一种输入模态,需要统一 Token 预算管理。第三类是研究侧的同学,想复现视频理解实验,需要一套可控、可解释的抽帧基线。
典型场景包括:短视频内容打标、长视频章节切分、监控片段异常检测、教学视频知识点定位、直播回放精彩片段提取。这些场景的共同点是视频长度差异极大(从几秒到几小时),如果只有一套固定抽帧参数,要么短片段浪费预算,要么长视频丢信息。所以策略必须是自适应的。
2. 抽帧策略:从均匀采样到内容自适应
2.1 均匀抽帧:最简单也最容易踩坑的基线
均匀抽帧就是按固定间隔取帧,比如每秒 1 帧(1 fps)或每 N 帧取 1 帧。它的优点是实现简单、时间分布均匀、帧与帧之间的时间差恒定,方便模型做时序推理。代码上基本就是按帧号取模:
import cv2 def uniform_sample(video_path, fps_target=1): cap = cv2.VideoCapture(video_path) src_fps = cap.get(cv2.CAP_PROP_FPS) step = max(int(round(src_fps / fps_target)), 1) frames, idx = [], 0 while True: ret, frame = cap.read() if not ret: break if idx % step == 0: ts = idx / src_fps frames.append((ts, frame)) idx += 1 cap.release() return frames这段代码看着没问题,但实测下来有两个坑。第一个坑是变帧率视频,很多手机拍摄或经过转码的视频,标称 30fps 但实际帧间隔不均匀,按帧号取模会导致时间戳漂移。解决办法是用CAP_PROP_POS_MSEC按时间戳定位,而不是按帧号。第二个坑是关键帧对齐,视频编码里 I 帧(关键帧)解码成本低,P/B 帧依赖前后帧,如果抽到的全是依赖帧,解码会变慢。工程上可以优先在关键帧附近取样,减少解码开销。
提示:均匀抽帧适合“内容变化平缓”的视频,比如讲座、访谈。一旦视频里有快速动作或场景切换,均匀采样很容易刚好错过关键瞬间。
2.2 基于场景切换的抽帧:抓住“变化点”
视频里真正有信息量的往往是场景切换的那几帧。检测场景切换最常用的方法是计算相邻帧的直方图差异或结构相似度(SSIM),差异超过阈值就认为发生了切换。
import cv2 import numpy as np def scene_change_sample(video_path, threshold=0.6): cap = cv2.VideoCapture(video_path) src_fps = cap.get(cv2.CAP_PROP_FPS) frames, prev_hist, idx = [], None, 0 while True: ret, frame = cap.read() if not ret: break gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) hist = cv2.calcHist([gray], [0], None, [64], [0, 256]) hist = cv2.normalize(hist, hist).flatten() if prev_hist is not None: diff = cv2.compareHist(prev_hist, hist, cv2.HISTCMP_BHATTACHARYYA) if diff > threshold: frames.append((idx / src_fps, frame)) else: frames.append((idx / src_fps, frame)) prev_hist = hist idx += 1 cap.release() return frames阈值threshold是这套方法的核心参数。设太低会把镜头内的小抖动也当成切换,帧数爆炸;设太高会漏掉真正的场景变化。我的经验是先用 0.5 到 0.7 之间试,再根据抽出的帧数反推调整。如果抽出的帧数超过预算上限,就提高阈值;如果关键场景没抓到,就降低阈值。
场景切换抽帧的另一个变体是结合运动量。用光流或帧差计算画面运动强度,运动剧烈的地方多抽,静止的地方少抽。这比单纯看直方图更贴近“内容变化”的本质,但计算成本更高,适合离线处理。
2.3 混合策略:均匀打底 + 变化点补强
纯均匀会漏关键帧,纯场景切换在长静态片段里会抽得太少。实际项目里我用的最多的是混合策略:先按较低频率(比如 0.5 fps)均匀抽一遍作为“底图”,保证时间轴上有基本覆盖;再在场景切换点额外插入帧,保证变化瞬间不丢。
具体做法是维护一个帧集合,均匀帧先放进去,场景切换帧再按时间戳去重后加入。去重时设一个最小时间间隔(比如 0.2 秒),避免同一瞬间抽好几帧。这样最终帧序列既有均匀的时间骨架,又在关键点上有加密。
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 均匀抽帧 | 实现简单、时间均匀 | 易漏关键帧、变帧率下漂移 | 讲座、访谈、监控 |
| 场景切换 | 抓住变化点、信息密度高 | 静态片段抽太少、阈值难调 | 剪辑视频、动作片段 |
| 混合策略 | 兼顾覆盖与关键点 | 实现稍复杂、需去重 | 通用长视频理解 |
2.4 抽帧策略选择的实操心得
选策略之前先问自己一个问题:这个视频的“信息”是均匀分布的,还是集中在少数瞬间?如果是前者,均匀抽帧就够;如果是后者,必须上场景切换或混合策略。
还有一个容易被忽略的点是音频。很多视频的关键信息在语音里,比如“接下来我要演示第二步”。如果只抽帧不看音频,模型可能完全抓不到重点。工程上可以先用语音识别把音频转成文字,再把文字按时间戳对齐到帧序列,作为 Prompt 的一部分。这样即使画面帧抽得少,语义信息也不丢。
注意:抽帧不是越多越好。我见过有人为了“保险”把 1 小时视频抽成 3600 帧,结果 Token 直接爆掉,请求被拒。帧数和 Token 预算是强绑定的,下一章专门讲怎么算这笔账。
3. 帧预算:Token 成本的精算与控制
3.1 一帧到底消耗多少 Token
这是整个工程里最容易被低估的问题。不同模型对图像的 Token 编码方式不同,但大体遵循一个规律:图像被切成固定大小的 patch,每个 patch 对应一个视觉 Token。比如某类模型把图像缩放到固定分辨率后切成 14x14 的 patch,一张 336x336 的图就是 24x24=576 个 Token。
所以一帧的 Token 成本取决于输入分辨率和模型的 patch 划分方式。分辨率越高,patch 越多,Token 越多。这就引出一个关键取舍:视频帧真的需要原分辨率吗?对于“识别画面里有没有人”这种任务,224x224 就够了;对于“读取画面里的文字”这种任务,可能需要 448x448 甚至更高。
我的做法是先确定任务所需的最小分辨率,再据此估算单帧 Token。比如任务只需要判断动作类型,224x224 足够,单帧约 256 Token;如果需要读字幕,448x448,单帧约 1024 Token。这个数字直接决定了帧预算的上限。
3.2 帧预算的计算公式
把上面的逻辑整理成一个可操作的公式:
可用帧数 = (总 Token 预算 - 文本 Token - 系统开销) / 单帧 Token其中总 Token 预算由模型上下文窗口决定,比如 128K 上下文,留出 4K 给输出,再留 2K 给系统提示和文本指令,剩下约 122K 给视觉。如果单帧 256 Token,理论上能放约 476 帧;如果单帧 1024 Token,只能放约 119 帧。
这个计算看起来简单,但实际用的时候要留安全余量。因为不同模型对图像的 Token 计算可能有额外开销(比如图像前后的特殊标记),而且 Prompt 里的时间戳、任务描述也会占 Token。我一般会在理论值上打八折,避免请求被截断。
def estimate_frame_budget(total_tokens, text_tokens, tokens_per_frame, safety=0.8): visual_budget = total_tokens - text_tokens max_frames = int(visual_budget / tokens_per_frame) return int(max_frames * safety) # 示例:128K 上下文,文本占 3K,单帧 256 Token budget = estimate_frame_budget(128000, 3000, 256) print(budget) # 约 390 帧3.3 超预算时的三种裁剪手段
当抽出的帧数超过预算,不能简单粗暴地从头截断,那样会丢掉视频后半段的信息。我常用三种手段,按优先级排序。
第一种是降分辨率。把每帧从 448x448 缩到 224x224,单帧 Token 直接降到四分之一,帧数就能翻四倍。代价是细节丢失,适合对细节要求不高的任务。
第二种是去冗余帧。相邻帧如果差异很小,可以合并成一帧。用前面提到的直方图差异或 SSIM 做判断,差异低于阈值的帧直接丢弃,只保留代表帧。这一步通常能砍掉 30% 到 50% 的帧,而且几乎不丢信息。
第三种是分段采样。把视频按时间切成若干段,每段按比例分配帧数。比如 10 分钟视频切成 10 段,每段 1 分钟,预算 100 帧就每段 10 帧。这样保证每个时间段都有覆盖,不会出现“前面密后面空”的情况。
| 裁剪手段 | Token 节省 | 信息损失 | 适用情况 |
|---|---|---|---|
| 降分辨率 | 高(约 75%) | 中(细节丢失) | 任务不依赖细节 |
| 去冗余帧 | 中(30%-50%) | 低 | 静态片段多 |
| 分段采样 | 可控 | 低 | 长视频均匀覆盖 |
3.4 帧预算与延迟的隐性关系
Token 预算不只影响成本,还直接影响响应延迟。视觉 Token 越多,模型前向计算量越大,首 Token 延迟和总生成时间都会上升。实测下来,视觉 Token 从 10K 涨到 50K,延迟可能翻两三倍。
所以如果产品对延迟敏感(比如实时问答),帧预算要压得更紧。我的经验是实时场景单次请求视觉 Token 控制在 20K 以内,离线批处理可以放宽到 100K 以上。这个阈值不是绝对的,要结合具体模型的推理速度和硬件条件实测。
提示:做帧预算时,先把“最坏情况”算出来——最长视频、最高分辨率、最多帧数,看会不会超。如果会超,就在抽帧阶段提前限制,而不是等到组装 Prompt 时才发现放不下。
4. Prompt 组装:让模型看懂帧序列
4.1 帧序列的结构化表达
抽出来的帧是一堆图像,直接丢给模型,模型不知道哪张在前哪张在后,也不知道每张对应什么时间。所以 Prompt 里必须把帧和时间戳绑定,并且明确告诉模型这是按时间顺序排列的。
我常用的格式是给每帧加一个文本标记,比如:
[帧 1 | 00:00.0] <image> [帧 2 | 00:02.5] <image> [帧 3 | 00:05.0] <image> ... 问题:视频中的人在第几秒放下了杯子?这样模型既能看到画面,又能通过时间戳建立时序关系。时间戳的精度根据任务定,动作识别用 0.5 秒精度就够,精细动作分析可能需要 0.1 秒。
有一点要注意:帧标记本身也占 Token。如果帧数很多(比如 300 帧),每帧标记 10 个 Token,就是 3000 Token。所以标记要尽量简洁,能省则省。我一般用[F1|0.0]这种短格式,把说明放在系统提示里统一解释。
4.2 任务指令的写法:从模糊到可执行
Prompt 组装里最容易出问题的是任务指令太模糊。比如“描述这个视频”,模型不知道你要描述什么粒度、什么方面。好的指令应该是可执行、可验证的。
对比一下:
- 模糊:“这个视频讲了什么?”
- 可执行:“按时间顺序列出视频中出现的所有动作,每个动作标注开始时间(秒)和持续时长。”
后者明确了输出格式(时间顺序、动作、开始时间、持续时长),模型更容易给出结构化答案,后续也方便程序解析。
对于视频问答,我习惯把指令拆成三部分:角色设定 + 任务描述 + 输出格式。角色设定让模型进入状态(“你是一个视频内容分析助手”),任务描述说清楚要做什么,输出格式约束结果结构。这三段加起来通常 100 到 300 Token,相对于视觉 Token 可以忽略,但对结果质量影响很大。
4.3 少样本示例的取舍
在 Prompt 里放一两个示例(few-shot)能显著提升输出稳定性,尤其是格式复杂的任务。但示例本身占 Token,而且如果示例和实际视频差异大,反而会误导模型。
我的判断标准是:任务格式越复杂,越值得放示例;任务越开放,示例越可能限制模型。比如“输出 JSON 格式的动作列表”这种任务,放一个示例能省掉大量格式纠错;而“总结视频内容”这种开放任务,放示例反而让模型照搬示例的措辞。
如果决定放示例,示例要短、要典型、要和实际任务同分布。我一般控制在 1 到 2 个,每个不超过 100 Token。
4.4 Prompt 组装的完整代码骨架
把上面几块拼起来,一个可复用的组装函数大概长这样:
def build_video_prompt(frames, question, task_type="qa"): # frames: [(timestamp, image_placeholder), ...] lines = [] for i, (ts, _) in enumerate(frames, 1): lines.append(f"[F{i}|{ts:.1f}] <image>") frame_block = "\n".join(lines) if task_type == "qa": instruction = ( "你是一个视频内容分析助手。下面是按时间顺序抽取的视频帧," "每帧标注了序号和时间戳(秒)。请根据这些帧回答问题。" "如果信息不足,明确说明无法判断,不要编造。" ) output_format = "输出:先给结论,再给依据(引用帧序号)。" elif task_type == "summary": instruction = ( "你是一个视频摘要助手。下面是按时间顺序抽取的视频帧。" "请按时间顺序总结视频内容,每个阶段标注起止时间。" ) output_format = "输出:分点列出,每点包含时间段和内容概述。" prompt = f"{instruction}\n\n{frame_block}\n\n问题:{question}\n{output_format}" return prompt这个骨架的好处是任务类型可切换,问答和摘要共用同一套帧表达,只是指令和输出格式不同。实际项目里还可以加更多任务类型,比如“定位某个动作出现的所有时间点”“判断视频是否包含某类内容”。
4.5 Prompt 组装的避坑经验
第一个坑是帧序号和时间戳不一致。如果抽帧时时间戳算错了,Prompt 里的时间就是错的,模型基于错误时间推理,答案必然错。所以抽帧阶段的时间戳要单独校验,最好和视频播放器对一遍。
第二个坑是图像占位符和实际图像顺序不匹配。多模态接口通常要求文本里的图像标记和传入的图像数组顺序一致。如果组装时顺序乱了,模型看到的画面和标记对不上,结果会非常诡异。我的做法是组装完 Prompt 后,打印一遍帧序号和图像数组的对应关系,人工抽查前几帧。
第三个坑是指令里的否定句。比如“不要描述无关内容”,模型有时会反过来关注“无关内容”。更好的写法是正面约束:“只描述与问题相关的画面元素”。
注意:Prompt 里的时间戳单位要统一。我见过有人混用秒和毫秒,导致模型把 2.5 秒理解成 2.5 毫秒,时序推理全乱。统一用秒,保留一位小数,是最稳妥的做法。
5. 常见问题与排查技巧实录
5.1 模型答非所问:先查帧再查 Prompt
遇到模型答非所问,很多人的第一反应是改 Prompt。但我的经验是先查帧。把抽出的帧单独存成图片,按顺序看一遍,问自己:如果我是模型,只看这些帧,能回答这个问题吗?
常见情况是帧抽得太稀,关键动作刚好在两帧之间,模型根本没看到。这时候改 Prompt 没用,得回去调抽帧策略。另一种情况是帧抽得不少,但分辨率太低,画面糊成一团,模型看不清细节。这时候要提分辨率或换更清晰的帧。
只有确认帧没问题,才去查 Prompt。Prompt 的问题通常是指令模糊、输出格式没约束、或者示例误导。
5.2 Token 超限的排查路径
Token 超限的报错通常很直接,但定位原因需要一步步来。我的排查顺序是:
- 打印实际组装的 Prompt 长度(字符数近似 Token 数)。
- 分别统计文本部分和视觉部分的 Token 占比。
- 如果视觉占大头,回去看帧数和单帧分辨率。
- 如果文本占大头,检查是不是示例太长或帧标记太啰嗦。
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 请求被截断 | 总 Token 超上下文 | 减帧或降分辨率 |
| 视觉 Token 异常高 | 分辨率过高或帧数过多 | 检查单帧尺寸和帧数 |
| 文本 Token 异常高 | 示例过长或标记冗余 | 精简示例和标记 |
| 延迟过高 | 视觉 Token 过多 | 压缩帧预算 |
5.3 时序推理错误的典型表现
时序推理错误的表现很有特点:模型能描述画面内容,但时间关系全错。比如把“先开门后开灯”说成“先开灯后开门”。这种错误八成是时间戳信息丢失或错乱。
排查时先确认 Prompt 里每帧都有时间戳,且时间戳单调递增。如果时间戳没问题,再看帧的抽取顺序是不是和实际时间顺序一致。有些抽帧实现会并行处理,导致帧顺序打乱,这时候要在组装前按时间戳排序。
还有一种情况是帧间隔太大,模型无法判断先后。比如两帧间隔 10 秒,中间发生了什么完全不知道,模型只能猜。这时候要么加密抽帧,要么在 Prompt 里明确说明“帧之间可能有未展示的内容”。
5.4 独家避坑清单
- 抽帧前先探测视频元信息,时长、帧率、分辨率、是否有音频,这些决定了后续所有参数。
- 时间戳用浮点秒,保留一位小数,避免整数截断导致时序精度丢失。
- 帧标记尽量短,把解释放在系统提示里,省下的 Token 可以多放几帧。
- 组装完 Prompt 先本地打印检查,确认帧序号、时间戳、图像顺序三者一致再提交。
- 保留原始抽帧结果,方便出问题时回溯,不用重新解码视频。
- 对长视频做分段处理,每段独立抽帧和组装,最后合并结果,避免单次请求过大。
- Prompt 里的指令用正面表述,少用否定句,减少模型理解偏差。
5.5 性能与成本的平衡技巧
如果项目对成本敏感,可以在抽帧阶段就做预筛选。比如先用一个轻量模型(或简单的图像分类器)判断每帧是否包含目标物体,只把包含目标的帧送进大模型。这样能大幅减少视觉 Token,代价是增加一次轻量推理。
另一个技巧是缓存重复内容。如果多个视频有相同的片头片尾,可以识别出来只处理一次。对于批量处理场景,这个优化能省不少。
还有一个容易被忽略的点是输出 Token 也要算进预算。如果任务要求模型输出很长的描述,输出 Token 可能占几千,这部分要从总预算里扣掉,留给视觉的就更少了。所以任务设计时,输出格式要尽量紧凑。
6. 从工程视角看这套流水线的扩展方向
6.1 音频与画面的联合理解
纯视觉的抽帧会丢掉音频信息,而很多视频的关键语义在语音里。把语音识别结果按时间戳对齐到帧序列,作为 Prompt 的一部分,能让模型同时利用画面和语音。实现上就是在帧标记之间插入对应的转录文本,比如:
[F1|0.0] <image> [语音|0.0-2.5] 大家好,今天讲抽帧策略 [F2|2.5] <image>这样模型既能看到画面,又能读到讲解内容,理解会更准确。代价是文本 Token 增加,需要在帧预算里预留空间。
6.2 自适应帧预算的在线调整
固定帧预算适合离线批处理,但在线场景下视频长度不可预知,需要动态调整。我的思路是先按低频率抽一遍,估算总帧数和 Token,如果超预算就提高去冗余阈值或降分辨率,直到落在预算内。这个过程可以迭代两三次,找到满足预算的最大信息量方案。
6.3 多视频对比场景的处理
有些任务需要对比多个视频,比如“这两个视频的动作有什么不同”。这时候帧预算要在多个视频间分配,不能一个视频占满。我的做法是每个视频分配相同帧数,Prompt 里用分隔符明确区分视频边界,指令里说明“视频 A”和“视频 B”的对应关系。
6.4 结果可解释性的增强
模型给出答案后,如果能标注答案依据的是哪几帧,可解释性会强很多。实现方式是在 Prompt 里要求模型引用帧序号,比如“结论:第 5 帧到第 8 帧显示人物在跑步”。这样人工复核时可以直接跳到对应帧验证,排查问题也更快。
我个人在实际操作中的体会是,这套流水线里最值得花时间打磨的是抽帧策略,因为它决定了模型能看到什么。Prompt 写得再好,也救不回没抽到的关键帧。帧预算和 Prompt 组装更像是“守门员”,保证不超限、不混乱。把这三块都做扎实,视频理解的准确率和稳定性会有明显提升。后续如果要做产品化,建议先把抽帧和预算做成可配置的模块,不同任务用不同参数,而不是一套参数打天下。