我用 AI 写了一套实时视频检测系统,然后推翻了它
背景
我在做一个实时直播流 AI 检测系统:从 SRS 流媒体服务器拉取直播视频,交给 Python 端的 AI 模型做目标检测,再把检测结果(比如人物位置的边界框)同步叠加到前端播放画面上。
核心难题只有一个:怎么让检测结果和视频画面对齐。
V1:一个"优雅"的方案
我选择了 H.264 SEI(Supplemental Enhancement Information)。这是视频编码标准中专门用来携带附加数据的机制------把 AI 检测结果直接塞进视频帧的码流里,数据和画面在物理层面绑定,天然同步,不可能错位。
听起来很完美。但要实现它,得先理解 WebRTC 送过来的视频数据长什么样。
为什么实现 SEI 这么复杂
WebRTC 传输视频时,底层走的是 RTP 协议。一个视频帧不会完整地送过来------它会被拆成一堆 RTP 包。这些包到达时可能乱序、可能丢失,你需要按序号重组。
重组之后拿到的也不是"一帧画面",而是 H.264 的 NALU(Network Abstraction Layer Unit)。一个 RTP 包可能包含一个完整 NALU,也可能包含多个小 NALU(STAP-A 聚合包),也可能只是一个大 NALU 的一个分片(FU-A 分片包)。你需要处理所有这些情况才能拿到完整的 NALU。
而 NALU 本身也有不同类型:SPS(序列参数集)、PPS(图像参数集)、IDR(关键帧)、非 IDR Slice(普通帧)......SEI 需要被插入到正确的位置------SPS/PPS 之后、Slice 之前------而且必须是 Annex-B 格式(以 00 00 00 01 起始码分隔),还要符合 user_data_unregistered 规范(16 字节 UUID 前缀 + payload)。
这些概念------RTP 分包、NALU 类型、Annex-B 格式、SEI 编码规范------是实现 V1 方案的前置知识。 为了搞清楚这些,我查了大量 H.264 和 WebRTC 的资料。
理解了这些之后,架构变成了这样:
SRS → WebRTC 拉流 → Go(pion 解包组帧) → FFmpeg 解码成 JPEG
→ NATS 发给 Python → AI 推理 → 结果写入 SEI → 重排序 → FFmpeg 推回 SRS
技术栈:Go + pion/webrtc + FFmpeg(×2) + NATS + Python。为了让它跑起来,我写了:
- RTP 解包与 H.264 组帧:用 pion 从 WebRTC 连接中拉取 RTP 包,处理 STAP-A/FU-A 等封包格式,重组成完整的 Annex-B 格式 H.264 帧
- 双 FFmpeg 管道:一个负责把 H.264 解码缩放成 JPEG 供 AI 推理,另一个负责把带 SEI 的 H.264 重新封装推回 SRS
- NATS 多模型扇出:支持同时调用多个 AI 模型,并行 request-reply,动态超时
- 15 Worker 并发池 + 重排序缓冲:并发处理帧以提高吞吐,再按帧序号重新排列保证输出有序
- 动态超时机制:每帧的 NATS 超时 = 缓冲上限 - 已消耗时间 - 安全余量,推理时间不够就丢弃结果保证流不卡
代码量 500+ 行,Go 端单独就 8 个文件。用 640×360 30fps 的测试流验证,系统完整跑通,SEI 正确嵌入,前端播放器能实时解析并画框。
问题出现
当我把测试流换成 1920×1080 时,输出视频开始卡顿。
我的第一反应是做局部优化------分析 1080p 下的内存占用、GC 压力、SEI 扫描性能,列出 P0-P4 的优化清单。但优化完一轮后,卡顿依然存在。
这时候我停下来问了一个问题:如果从需求本身重新推导,当前的架构是不是一开始就走偏了?
拆解发现
回到需求:把 AI 检测结果同步显示在直播画面上。
为了实现"同步",我选了 SEI。但 SEI 要求修改 H.264 码流。修改码流要求拿到裸 NALU。拿裸 NALU 要求解封装。解封装后还得重新封装推回去。而且推回去的流必须是连续的、时间戳正确的。
每一步都是上一步的连锁后果。
更致命的是拉流协议的选择。我用了 WebRTC(WHEP)来从 SRS 拉流。WebRTC 基于 UDP,是为实时交互设计的------可以丢包、可以乱序、不保证每帧完整。但我的管道下游是 RTMP 推流,它需要一个连续、完整、时间戳准确的 H.264 码流。
于是出现了根本矛盾:用一个有损的实时协议拉流,处理后喂给一个需要无损输入的推流协议。
我写的那 200 多行 handler.go------worker pool、reorder buffer、动态超时------本质上都是在弥合这个矛盾。重排序缓冲靠墙钟等待来决定是否跳帧,这个机制天然引入抖动。原始流的时间戳在 WebRTC 传输后丢失,FFmpeg 只能靠帧到达间隔猜帧率。
卡顿不是某个 buffer 太小,而是架构本身在制造卡顿。
退一步看全局
跳出 SEI 方案,重新审视"把结果和帧对齐"这个需求,所有方案可以分成两类:
同路:结果和视频物理上走同一条路,不可分离。
| 方案 | 做法 | 代价 |
|---|---|---|
| 烧录进画面 | 服务端解码→画框→重编码→推流 | 重编码吃资源,框不可交互 |
| SEI 嵌入 | 修改 H.264 码流 | 工程复杂度高,当前方案的问题 |
| 推送图片 | JPEG over WebSocket,替代视频流 | 带宽高,画面不流畅 |
旁路:结果走独立信道,前端做同步匹配。
| 方案 | 离视频的距离 | 同步精度 | 代价 |
|---|---|---|---|
| FLV script tag | 近(同一容器) | 高(容器时间戳) | 锁定 HTTP-FLV,延迟 1-3 秒 |
| WebRTC DataChannel | 中(同一连接) | 中 | SRS 不原生支持,开发量大 |
| WebSocket | 远(独立连接) | 低 | 两条连接可能漂移 |
| SSE | 远(独立连接) | 低 | 同 WebSocket,但更简单 |
旁路方案的核心顾虑是"同步精度不够"。但仔细想想:
- AI 推理本身需要 100-300ms,每秒只能产出 3-10 个结果
- 前端直接用"最新一次推理结果"画框
- 偏差最多 1-2 帧(33-66ms),人眼不可感知
- 推理频率本身就远低于视频帧率,精确到帧的绑定是过度要求
结论
最终方案:视频原样走 WebRTC 播放,检测结果走 WebSocket 旁路推送。
原始流 → SRS → WebRTC → 前端播放(不改流)
原始流 → 截帧 → Python 推理 → WebSocket → 前端 Canvas 画框
不需要改码流,不需要双 FFmpeg,不需要 reorder buffer,不需要 pion 组帧。卡顿问题从根源消失,因为原始流从未被修改过。
教训:和 AI 协作时,什么时候该踩刹车
这个项目全程有 AI 参与。在我提出"输出视频卡顿"时,AI 做了详细的性能分析------内存估算、GC 影响、管道吞吐量------然后给出了一长串优化建议。这些分析在局部上是合理的 (虽然有些细节算错了),但它们共同的盲区是:默认当前架构是对的,只需要优化。
直到我明确要求"从需求本身重新分析,看看是不是一开始就走偏了",AI 才跳出局部优化的框架,开始质疑 SEI 方案本身的必要性。
这暴露了一个 AI 协作中的模式问题:
什么时候该意识到需要重新审视
- 优化了一轮但问题还在------如果局部修复没用,问题可能不在局部
- 解释一个设计决策时,需要讲很长的"因为所以"链------因为用了 SEI → 所以需要改码流 → 所以需要拉流解封装 → 所以需要 WebRTC... 连锁越长,初始假设的杠杆越大
- 发现自己在写大量的"对抗性"代码------reorder buffer 对抗乱序,动态超时对抗延迟耦合,残帧检测对抗丢包。如果代码主要在解决架构自身制造的问题,架构可能就是问题
- AI 给出的优化建议越来越精细,但收益越来越边际------说明已经在局部最优附近打转了
怎么提示 AI 退后一步
当你意识到可能走偏了,不要问"这个 bug 怎么修"或"这段代码怎么优化"。试试这些方向:
"不要看代码实现,从需求本身重新推导,当前方案是不是最合理的路径?"
"如果我们从零开始设计,已知目标是 X,有哪些根本不同的方案?各自的 trade-off 是什么?"
"当前架构中,哪些复杂度是需求本身要求的,哪些是我们的设计选择制造的?"
"假设一个资深工程师刚加入项目,看到这套架构,他会问什么问题?"
关键是把 AI 从"优化当前实现"模式切换到"质疑当前假设"模式 。AI 默认会尊重你已有的代码和架构------这通常是对的,但在需要根本性反思时,你必须明确告诉它:退后一步看。
这可能是 AI 协作中最反直觉的一点:AI 最擅长在给定框架内做精细工作,但质疑框架本身这件事,目前还是得由人来发起。
更深一层:陌生领域怎么避免一开始就选错
AI 协作的教训说的是"走偏了怎么纠正"。但还有一个更根本的问题:面对不熟悉的领域,有没有策略能一开始就选对方向?还是说必须得碰过钉子?
答案是:两者都需要,但比例不是你想的那样。
逻辑能覆盖的部分
面对陌生领域,有一个策略确实有用:在动手之前,把"既要又要"列表写出来,然后找矛盾。
这个项目的需求列表:
- 检测结果要和帧同步
- 延迟要低
- 前端要方便提取展示
- 后端要方便处理
这些单独看都合理。但如果当时追问一句------"同步精度到底要多高?"------答案会是"AI 每秒只出 3-10 个结果,精确到帧没有意义"。这一条一旦松动,SEI 方案的前提就不成立了,后面的复杂度全部不需要。
问题在于:当时我不知道该追问这一句。因为我还不了解 AI 推理的实际吞吐量,也不了解人眼对同步偏差的感知阈值。你需要知道什么才能做出正确决策,这件事本身需要经验。
逻辑覆盖不了的部分
SEI 方案在技术上是成立的------H.264 规范就是支持这么做。如果你去搜"视频流附加自定义数据",SEI 大概率是排名最高的答案。它在概念层面优雅、正确、标准。
你没法靠纯逻辑推导出"这条路走下去工程代价会爆炸"。 因为代价藏在实现细节里------RTP 分包重组、Annex-B 格式、时间戳管理、reorder buffer------这些东西只有做了才知道有多重。你读 H.264 规范的时候不会看到一行字说"注意:如果你用 WebRTC 拉流再改码流推回去,会非常痛苦"。
这就是经验的价值:不是告诉你正确答案,而是告诉你哪些看起来可行的路径走下去会很疼。
一个折中策略:先做最小验证,晚做架构决策
虽然经验无法完全替代,但有一个做法能降低踩坑的代价。
回到这个项目。如果当时不急着选 SEI,而是先花半天时间:
- 用 FFmpeg 截几帧图发给 Python,确认推理延迟是 100-300ms
- 在前端手动模拟:视频播着,
setTimeout延迟 100ms 画个框,看看效果能不能接受 - 能接受 → WebSocket 旁路就够了,不需要碰码流
- 不能接受 → 再考虑 SEI
这个验证不需要任何 H.264 知识,不需要 Go,不需要 pion,不需要 NATS。 半天就能做完,而且做完之后对"同步精度到底要多高"这个关键问题就有了体感,后面的技术选型自然会不同。
踩钉子不可避免,但可以控制钉子的大小。先用最小成本验证最大的假设------这条不需要经验,靠逻辑就能做到。
定性需求 vs 定量需求
经验的作用不是直接告诉你选什么方案,而是告诉你在决策之前该去摸清哪些数字。
没经验的时候,你看到"结果要和帧同步",会把它当成一个定性需求,然后去找技术上最"正确"的同步方案------SEI。
有经验的话,你会先把它转成定量问题:同步偏差多少毫秒可以接受?然后去量:推理多快、人眼阈值多少、帧间隔多少。数字一出来,方案自然收窄。
定性需求导向"最正确"的方案,定量需求导向"够用"的方案。 前者容易过度工程化,后者才是工程决策的正常姿势。