
视频还在直播问题却已经抛了过来——“刚才那个穿橙色工作服的工人是 14:23 还是 14:25 离开工位的”“过去半小时传送带上出现过几次异常停顿”“会议进行到第 38 分钟时谁提出了预算调整”这类问题不是事后的离线检索而是流式视频理解要干的活。视频流持续产生提问随时到来系统不能等视频播完之后再慢慢分析。它必须在“看”的同时持续建立一种可查询的记忆然后在提问到来时快速定位、再深度推理。ShallowStream 这个项目标题把答案写在名字里Index Shallow then Answer Deep——先做浅层索引再做深层回答。翻译成工程语言就是用轻量模型持续处理视频流把画面内容浓缩成结构化索引当有人提问时再让更强的模型基于索引结果做推理。听起来像是一个简单的分层但真正动手做起来难点全在细节里索引记到什么粒度、窗口怎么滑、深度模型什么时候触发、多路并发时怎么控成本。这篇文章想聊的不是某个具体 benchmark 或排行榜成绩而是这套“先索引、后作答”的设计思路在真实工程里该怎么理解、怎么落地、怎么排查。我会尽量从一个经历过类似问题的开发者角度把架构逻辑、代码骨架、参数权衡和故障排查串成一条完整的链路。1. 先看清问题流式视频理解难的不是模型而是状态1.1 离线分析和在线理解的本质区别先区分两个经常被混在一起谈的场景。离线视频分析的前提是视频已经完整存在。你可以解码、抽帧、逐段分析哪怕某一帧的事件检测算错了也可以重新跑一遍。整个过程是批处理准确率优先对单次分析的时延不敏感。流式视频理解不一样。视频帧还在持续到达系统不是“分析完一个文件”而是“跟着视频一起推进”并且要随时准备好回答关于过去任意时刻的问题。它至少面对三个约束不能回放过去的内容已经流向过去了索引阶段如果漏掉后面很难补。不能全量存原始帧全保留的话存储和检索成本会随时间线性膨胀一路高清视频跑一天就能塞满磁盘。不能全量算每一帧都丢给重型模型处理延迟和成本都撑不住场景复杂时结果还不稳定。很多人第一次接触流式处理时会看到 Spark Structured Streaming 里一条经典报错writestream can be called only on streaming dataset/dataframe。这句报错的潜台词是你不能把流式数据当静态表来写。流式视频理解也一样你不能把直播流当成一个可以反复重放的视频文件来处理。框架层面的流式和批式有本质差异应用层面的视频分析同样如此。所以流式视频理解真正要解决的问题不是“选哪个大模型”而是“如何持续维护一份关于视频内容的结构化记忆”。模型负责理解单帧或短片段记忆结构决定你能不能回答“过去那段时间发生了什么”。模型再强如果记忆是断裂的、不可查询的问题照样答不对。1.2 为什么“抽帧 大模型逐段描述”的朴素方案撑不住我早期搭过一个很朴素的方案视频流定期抽帧每隔 N 帧把画面送进一个多模态模型让它生成一句描述然后把描述按时间戳串起来。演示时效果还行放到真实流上问题一个接一个。第一个问题是上下文断裂。模型上下文窗口再大也装不下一整天的描述文本。当历史描述超过窗口就必须截断于是系统对“半小时前发生了什么”逐渐失忆。最终整个系统退化成一个只能回答“最近几分钟”的短时记忆工具。第二个问题是成本不可控。视频是持续产生的帧数没有上限。如果每一帧都调用重型模型成本曲线是线性往上走的流量一上来还容易拖垮下游。先不谈推理质量单是账单就会逼着你改架构。第三个问题是定位效率。用户问的往往是“某种对象在某个时间段干了什么”而不是“把刚才画面描述一遍”。如果系统里只有一串连续的文本描述要回答“橙色衣服的工人什么时候离开工位”就得把整段描述从头到尾翻一遍。这在本质上是线性扫描不是检索。流上积累一小时、一天之后这种扫描越来越慢你会非常想念一个能直接“索引到位置”的结构。所以 ShallowStream 的“Index Shallow then Answer Deep”并不只是一个花哨的口号。它是在回答流式视频理解最核心的诉求把“看视频”这种持续、高频、低成本的操作和“答问题”这种按需、低频、高成本的操作彻底拆开。2. “Index Shallow then Answer Deep”到底拆开了什么2.1 浅层索引把“看到了什么”变成“可查询的记录”“Index Shallow”里最关键的是两个词一个是 Index索引一个是 Shallow浅。Shallow 意味着这一层不能用太重的东西。它可以是轻量视觉编码器把每一帧或每几帧压缩成一个低维向量一个轻量的检测或跟踪模型只负责记录“画面里有什么对象在什么位置什么时间出现”一个基于规则或轻量分类器的场景切换检测只记录“什么时刻画面发生了明显变化”。Index 意味着这一层的输出必须是有结构、可检索的。推荐的最小结构是一个时间线条目{ stream_id: camera-01, start_ts: 2025-06-01T14:23:10Z, end_ts: 2025-06-01T14:23:18Z, objects: [person, orange_uniform], actions: [walking, leaving], embedding: [0.012, -0.037, 0.091], raw_clip_ref: s3://bucket/clips/camera-01/14-23-10.mp4 }注意这里面没有“整段画面的详细描述”只有可检索的字段和一个向量。原因很简单详细描述成本高、冗余大而且不一定能回答未来的问题结构化字段和向量则能支撑两类查询——精确过滤谁、什么时候、在哪个区域和语义检索“和这个画面相似的画面还有吗”。2.2 深度作答只在提问到来时启用强模型“Answer Deep”是另一个极端这一层只做深度推理不做持续处理。当一个用户问题到达时系统不是把整段视频送进模型而是先完成一次检索把问题解析成检索条件时间范围、对象类型、事件类型在索引层做一次快速过滤和排序召回最相关的时间片段从原始视频片段中取出对应的时间窗比如召回片段前后各扩展 2 秒把抽出的关键帧、音频片段或短视频片段连同用户问题一起交给一个大型多模态模型模型基于这些被召回的证据生成答案。这个流程的关键是重型模型只在提问时被调用而且看到的是经过剪裁的证据不是整条视频。这样既控制了成本也提高了回答质量——给模型的上下文是精确命中问题的小片段而不是一团模糊的长时段画面。2.3 这个拆解为什么能成立一句话视频内容具有极强的时序局部性和稀疏性。用户问“穿橙色衣服的工人什么时候离开工位”真正有用的信息可能只存在于几秒钟的画面里。如果能把这几秒准确找出来再让大模型集中分析效果通常比让它看一整段无差别画面要好得多。这和 Excel 里用 INDEX/MATCH 而不是整表遍历的道理是一样的先通过索引列定位目标行再取出对应的值避免一次性扫描所有数据。另一个原因是成本结构不对称。浅层索引是高频操作单位成本必须足够低深度回答是低频操作单位成本高一点可以接受。如果两者绑在一起成本就会向高频侧倾斜导致整体方案不可持续。把“看”和“想”拆开本质上是一次成本结构的重新分配。当然这个拆解有一个隐含前提索引层必须足够可靠。如果浅层索引漏掉了关键对象或关键事件深度模型再强也无济于事。这一点会在后面的排查部分展开。3. 搭建一个 ShallowStream 风格的最小流水线3.1 四个核心模块一个可运行的流水线至少要有四个模块模块职责典型形态流采集读取视频流、切分时间片、抽帧RTSP/WebRTC 拉流按 2~5 秒切分浅层索引器对每个时间片做轻量识别产出结构化条目检测模型 轻量编码器索引存储保存条目支持时间范围和向量检索Redis 向量检索库或 ClickHouse/TiDB深度回答器检索命中后剪裁证据调用大模型生成回答多模态大模型 API 或本地部署这里不绑定具体选型因为不同团队的基础设施差别很大。重点是四个模块之间的接口要稳定索引器只关心“输入一个视频分片输出一个 IndexEntry”问答器只关心“输入问题和候选片段输出答案”。接口稳定之后换模型、换存储都是内部实现的事。3.2 最小可运行示例下面是一个伪代码骨架只展示流程结构不依赖具体模型与中间件from dataclasses import dataclass from typing import List, Optional, Tuple dataclass class VideoChunk: stream_id: str start_ts: float end_ts: float frames: List[object] # 每隔固定秒数的采样帧 audio: Optional[bytes] None dataclass class IndexEntry: stream_id: str start_ts: float end_ts: float objects: List[str] actions: List[str] embedding: List[float] # 浅层编码器输出的向量 clip_ref: str # 原始片段引用 class ShallowIndexer: 浅层索引器只做轻量识别和结构化记录 def __init__(self, encoder, object_detector): self.encoder encoder self.object_detector object_detector def index(self, chunk: VideoChunk) - IndexEntry: objects set() actions set() for frame in chunk.frames: dets self.object_detector(frame) objects.update(d.label for d in dets) actions.update(self._infer_actions(dets)) vec self.encoder(chunk.frames[0]) # 示例用首帧向量代表该片段 return IndexEntry( stream_idchunk.stream_id, start_tschunk.start_ts, end_tschunk.end_ts, objectssorted(objects), actionssorted(actions), embeddingvec, clip_reffs3://clip/{chunk.stream_id}/{chunk.start_ts}.mp4, ) def _infer_actions(self, dets): # 这里可以基于位置变化/规则实现轻量动作推断 # 也可以由浅层分类器完成规则越简单越好 return [walking]class DeepAnswerer: 深度回答器检索 证据剪裁 大模型推理 def __init__(self, index_store, llm): self.index_store index_store self.llm llm def answer( self, question: str, stream_id: str, time_range: Optional[Tuple[float, float]] None, ) - str: # 1. 检索把问题转成过滤条件在索引里召回最相关的片段 candidates self.index_store.retrieve( stream_idstream_id, time_rangetime_range, top_k10, ) # 2. 剪裁证据把召回片段对应的原始视频切割出来 evidence self._cut_evidence(candidates) # 3. 推理把问题 证据片段交给大模型 return self.llm.generate(questionquestion, evidenceevidence)def run_stream_pipeline(stream_reader, indexer, index_store, poll_interval2.0): 持续从流读取新分片并写入索引。 for chunk in stream_reader.poll(poll_interval): entry indexer.index(chunk) index_store.write(entry)这个骨架跑通之后你已经拥有了一个最基本的“边看边记、按需作答”闭环。别急着加复杂功能先用一条视频流、一组简单问题验证全链路是否有断点。3.3 从单路到多路索引层必须并行化单路流跑通后最自然的扩展是接入更多路视频。这里最常见的错误是把所有索引任务串行执行——一路视频的检测卡住后面所有流都跟着堆积。多路扩展时要关注两点索引器要支持并发和排队。每一路流有自己的消费位置避免某一路断流影响其他路。索引存储按stream_id分区。不同摄像头的条目尽量落在不同分区查询时也按 stream_id 过滤避免一路查询扫全库。一个简单的做法是给每个 stream_id 分配独立的队列和索引写入线程如果场景规模再大再考虑用消息队列和流处理框架做统一调度。重要的是先想清楚索引是高频写、低频读问答是低频写、高频读对索引而言。这两类负载对存储的要求完全不同不要混在一个设计里。4. 真正决定效果的不是模型而是这几个设计参数4.1 索引的时间窗口怎么定索引窗口也就是一个 IndexEntry 覆盖多长的时间是第一个要定的参数。窗口太短条目碎片化存储膨胀检索时召回几十上百条证据剪裁和排序都变复杂。窗口太长一个条目里混入多个不相关事件对象和动作被压在一起精确粒度丢失深度模型看到的是“一锅炖”的证据。我通常会从 5 到 10 秒起步然后观察索引条目里的事件纯净度。如果一个条目里经常出现“person 既在 walking 又在 sitting”这种矛盾组合说明窗口太长需要缩短。如果检索一个问题要拼凑 20 个条目才找全上下文说明窗口太短需要加长。这里没有绝对答案因为不同场景的事件密度不一样。工位质检画面几秒就有一次事件会议室可能几分钟才有一个关键动作。建议把窗口做成配置项而不是写死在代码里。4.2 索引条目的信息密度索引记什么、不记什么直接影响整个系统的容量和回答质量。我建议至少保留对象类别和粗略位置检测框坐标粗粒度的动作标签walking、standing、leaving、picking 等一个语义向量用于做“相似画面”检索原始片段的引用地址但可以存一个降采样后的低码率版本而不是原始高清帧。不建议在索引里存每一帧的详细自然语言描述。成本高且描述容易和后续问题不匹配。整段原始视频。索引的目的是“快速定位”不是“完整归档”。一个判断标准是如果某个字段永远不可能被查询条件命中就不应该出现在索引条目里。省下来的存储和写入开销可以留给更多路视频。4.3 深度回答的触发策略深度模型什么时候被调用是成本控制的关键。常见有三种触发策略策略适用场景注意事项用户提问触发安防、会议回顾、直播问答需要控制并发避免多个问题同时压垮模型服务事件驱动触发异常检测、告警复核事件检测本身要足够准否则会产生大量无效调用定时抽检触发质量巡检、值班复核配合采样率使用成本可预测我的建议是先把“用户提问触发”跑通因为它最贴近流式理解的本质——按需深度推理。事件驱动和定时抽检都是在索引成熟之后再陆续加上的增强能力。注意同一路视频的深度回答要排队或加锁。两个问题同时到达时如果都去检索、都去调用大模型很可能会把显存或 API 配额打满导致整路服务的延迟全线升高。5. 常见问题排查从“没索引到”到“答不对”5.1 索引阶段丢失内容现象用户问“刚才那个人去哪了”系统回答“没有找到”。索引里要么没有对应条目要么条目存在但字段为空。排查链路要按这个顺序走先看输入层视频流是否断过断流重连后有没有恢复消费位置很多丢内容问题不是模型漏检而是流根本没接上。再看抽帧层轮询间隔是否太长解码失败时是否直接跳过了整个分片再看检测器检测阈值是不是设得过高导致低置信度的目标被过滤掉最后看索引写入索引存储的写入是否失败时间戳是否对齐字段是否为空这里有一个容易误判的点很多人一看到“没索引到”就怀疑模型能力其实大部分情况下是输入采样或者写入路径出了问题。这类问题有时候会隐藏在日志里就像脚本里常见的attempt to index global ... (a nil value)一样表面是索引操作失败实际是索引库本身没有初始化或者写入路径根本没走到。5.2 答案不准先查命中再查上下文现象索引里有内容检索也能返回条目但深度模型的回答明显不对。这时候不要急着换更大参数的模型。先做两步检查第一步打印检索命中的片段。看召回的start_ts、end_ts和objects字段是否真的对应问题里的实体和时间。如果没召回正确片段问题在检索层——过滤条件太严、时间范围算错、向量相似度阈值太高逐一排查。第二步如果召回正确但答案仍不对检查证据剪裁。只给模型一帧孤立的画面模型很难判断“是什么时候离开的”。正确做法是把召回片段前后各扩展 2 到 3 秒让模型看到动作的完整过程。经验很多“答不对”的问题根源不是推理能力不够而是证据给得太少、太碎。深度模型需要的是“被剪裁好的、和问题高度相关的”上下文而不是一堆原始画面的堆砌。5.3 延迟抖动浅层队列和深层拥塞要分开看现象同一个问题有时候 2 秒返回有时候 20 秒才返回。延迟抖动通常来自两个不同层面浅层索引队列堆积视频帧处理速度跟不上输入队列越积越长导致新数据的索引时间整体后移查询时“最新内容”还没进索引表现为越往后越慢。深层回答拥塞多路问题同时到达深度模型服务排队。表现为节奏性的时快时慢高峰时尤其明显。排查时先把延迟曲线按时间段拆开。如果延迟是持续上升查浅层队列如果延迟是周期性波动查深层并发和配额。还有一个容易被忽略的点索引存储膨胀之后检索速度会明显下降尤其是没有按 stream_id 分区的时候。建议给索引存储加上定期清理策略把超过保留期的条目归档或删除。6. 这套方案适合谁又在什么场景下失效6.1 适合与不适合的场景适合的场景长时监控、直播、会议录制、车间和门店场景需要回答“过去某时刻发生了什么”的问题对成本敏感但又要保留视频语义可查询性的场景需要同时看多路视频但不想每路都跑一个重型模型的场景。不适合的场景对单帧级别的极细粒度实时解析比如要求对每一帧做像素级分割并即时反馈索引层的粒度满足不了视频很短、只需要一次性分析比如一个 30 秒的短视频直接全量喂给大模型可能更简单对实时性要求极高且不能接受任何索引延迟的场景。索引本身也需要处理时间不是零成本。6.2 生产化落地前要补的几块拼图从能用 demo 跑通到能放到生产环境中间还差几块容易被忽视的拼图可观测性每个索引条目要记录 stream_id、时间戳、处理耗时检索过程要能打印命中的片段。重试和断流恢复视频流断开后如何重新消费索引缺口如何标记或补偿。存储生命周期原始片段和索引各自保留多久什么时候归档什么时候删除。评估集找一批“问题 - 期望时间片段 - 期望答案”的真实标注每次模型或参数调整后用同一套数据回归避免凭感觉判断效果。这些工程细节看起来不性感但它们才是决定一个视频理解系统能否长期运行的关键。模型可以换架构可以调唯独日志、重试、生命周期和评估这四件事需要从一开始就留好位置。6.3 回到主判断ShallowStream 这类“先索引、后作答”的设计真正价值不在于把单次推理变快而在于把流式视频理解从“一次性分析任务”变成“可维护、可查询、可迭代的系统”。它把高频低成本的“看”和低频高成本的“想”解耦让视频理解在长时、多路、成本受限的真实场景里变得可持续。如果你准备动手做类似的项目我的建议是不要一上来就追求最好的模型先把“一条流 - 索引 - 检索 - 深度回答”的最小闭环跑通然后用真实问题去压它。单次跑通只说明流程没有断真正能长期用的系统靠的是把边界处理、参数权衡和排查链路一点一点补齐。这一点和做其他任何长周期技术系统并无区别。