生成式视频的成本结构很反常识:钱花在生成上,但赔钱赔在审核上。一条片子渲染出来要几分钟,撞到平台风控被封号,要的是一两周。
我这套流水线的日更量不大,一天一条短片,走七八个平台。看起来是低频、低风险的小生意。但三件事叠在一起,风险就不小了:内容全由 AI 生成、跨平台重复投递、完全无人值守。第二件事尤其危险——同一个号一天里发同一题材的多个变体,平台侧看到的不是「创作者勤快」,而是「疑似批量投放」。
所以我在生成之后、发布之前插了一道闸门:先抽帧,再过模型,过不了就不出门。这篇写这道闸门怎么搭、为什么不能只查关键词、以及抽帧参数怎么定才既不漏也不烧钱。
平台风控看的是行为 不只是画面
先把问题定准。很多团队理解审核是「画面别越线」,于是只做 NSFW 检测。但真正让账号出事的是另外两层:
第一层是画面合规。色情、暴力、血腥、未成年人相关,这是红线,触碰即封。这一层最容易被注意到,也最容易做。
第二层是内容标识合规。国内从《人工智能生成合成内容标识办法》落地后,AI 生成内容必须带显式标识(画面角标或文字说明)和隐式元数据。缺标识不是「内容不好」,是「手续不全」,平台的处理往往是下架而非提醒。
第三层是行为特征。同一账号短时间高频发同类内容、多平台同步秒发、标题模板高度雷同,这些信号与画面无关,但同样会触发风控告警。这一层最隐蔽,因为你在本地看不到任何异常。
我这道闸门只解第一、第二层,第三层靠发布侧限速处理。认清边界很重要:闸门不是万能保险,它只负责「这条片子的画面和标识能不能过」,「这个号能不能这么发」是另一套机制。
抽帧不是越多越好
视频审核的第一个工程问题是成本。一条 8 秒、25fps 的片子有 200 帧,每帧跑一次多模态模型,成本是每帧几厘到几分钱。按 200 帧算,一条短片的审核费用可能超过生成费用。
但抽少了会漏。违规画面可能只出现两三帧——一秒钟的直视镜头,或是转场瞬间的擦边构图。
我的做法是两个抽帧通道并行:
- 定时抽帧:固定 1 fps,保证时间轴上均匀覆盖,负责兜住持续存在的风险(比如人物穿着、场景性质)。
- 场景切换抽帧:算相邻帧的直方图差异,超过阈值就认为发生了镜头切换,额外抽该切换点附近的一帧。
第二通道很关键。生成式视频的镜头切换不像实拍那样有明确的剪辑点,往往是模型自己「想」转场,切换处的画面最容易失控——人物突然变形、背景突兀替换、肢体畸变。这些帧如果只靠 1 fps 采样,大概率被跳过。
# 抽帧:1fps 均匀采样 + 直方图差分场景切换补采
import subprocess, os
import numpy as np
from PIL import Image
def sample_frames(video, outdir, fps=1.0, hist_thresh=0.32):
os.makedirs(outdir, exist_ok=True)
base = os.path.join(outdir, "t_%04d.jpg")
subprocess.run(["ffmpeg", "-y", "-i", video, "-vf", f"fps={fps}",
"-q:v", "3", base], check=True, capture_output=True)
frames = sorted(f for f in os.listdir(outdir) if f.endswith(".jpg"))
picks = list(frames) # 通道一:均匀
prev = None
for name in frames: # 通道二:场景切换
arr = np.asarray(Image.open(os.path.join(outdir, name)).convert("L"))
hist = np.histogram(arr, bins=64, range=(0, 256))[0].astype(float)
hist /= hist.sum() + 1e-9
if prev is not None and 0.5 * np.abs(hist - prev).sum() > hist_thresh:
picks.append(name)
prev = hist
return sorted(set(picks))
0.5 * Σ|h1 - h2| 是直方图交集的补,取值 0~1,等价于「两帧分布差了多少」。阈值 0.32 是我在几十条片子上调出来的:低于 0.25 会把运镜造成的亮度渐变也当切换,高于 0.4 会漏掉硬切。
单帧命中不等于违规
第二件事是判定策略。早期的版本我对每一帧单独判定,只要有一帧超阈值就整条挂掉。结果是误杀率很高:人物侧脸仰拍被判成异常姿势、暖色调夜景被判成血腥、白色床单被判成肤色区块。
不是模型不行,是单帧信息天生不足。人眼看一个画面,会自动带上下文:这是卧室还是手术室、这个动作是打闹还是殴打。分类器看到的只是一张图。
所以判定要从「帧级」改成「镜头级」:
VIOLATION = {"porn": 0.80, "gore": 0.80, "minor": 0.50} # minor 阈值必须最低
def judge(frame_scores, shot_of):
"""frame_scores: {frame: {cat: score}};shot_of: {frame: shot_id}"""
for cat, thr in VIOLATION.items():
shots = {}
for f, sc in frame_scores.items():
if sc.get(cat, 0) >= thr:
shots.setdefault(shot_of[f], []).append(f)
for shot, hit in shots.items():
if len(hit) >= 2: # 同一镜头连续命中才算数
return f"BLOCK:{cat}:{shot}", hit
return "PASS", []
三处细节值得说明:
阈值按类目分级。 色情、血腥用 0.80,宁可漏一点也不乱杀;涉未成年人单独压到 0.50——这一类风险不对称,误杀的代价是废一条片子,漏放的代价是账号没了。风险不对称时,阈值就该不对称。
同一镜头两次命中才升级。 单帧孤立命中可能是噪声,同镜头多帧命中说明风险是「持续存在」的。这一条把误杀率压下去了大半。
返回命中帧而不是布尔值。 人要复核时,看那一帧比看「审核未通过」这五个字有用得多。我把它画成一张缩略图联系表(contact sheet),一次看完。
提示词也要审 主要防绕词
画面审完,还有一层:提示词。这一层不是防「写了脏话」,而是防绕字。
现在的模型对违禁词有基本抵抗,但对手法很低级:全角半角混写、拼音缩写、形近字替换、加零宽字符、用「那个词」的同义描述。这些手法对规则引擎是噪音,对语义模型却几乎不构成障碍。
我的做法是先把提示词做一遍规范化,再交给分类器,而不是反过来调模型:
import re, unicodedata
def normalize(prompt):
s = unicodedata.normalize("NFKC", prompt) # 全角→半角、兼容字符归一
s = s.replace("\u200b", "").replace("\u200c", "") # 剥零宽字符
s = re.sub(r"(?<=[一-龥])\s+(?=[一-龥])", "", s) # 中文词内空格干扰
return s.lower()
NFKC 这一步很关键,它顺手解决了三个问题:全角字母编号、装饰性花体、罗马数字变体。零宽字符是另一类常见绕过手段,肉眼看不见但会打断关键词匹配,直接剥掉。
规范化之后再做两级判定:高风险直接拦,中风险转人工队列。中风险不自动放行,但也别自动拒绝——中间态的意义是攒样本,人工复核的结论回灌,下一版阈值就有依据了。
标识检查 最容易被漏的一步
《人工智能生成合成内容标识办法》要求 AI 生成内容带显式标识。很多团队做了画面审核、做了关键词过滤,唯独漏了这一条,最后片子被下架还不知道为什么。
我这套流水线在 2026-10-07 做过一次纠正:此前为了规避平台限流,把平台的「AI 生成声明」字段从 AI 生成改成了虚构作品;当天恢复为 AI 生成,因为合规优先于流量。改的位置不止一处——发布脚本、job 生成模板、GUI 侧默认值共七八个地方,漏改任何一处都会造成「以为标了其实没标」。
工程上的教训是:这类开关必须有单一真源,且要有校验。我在闸门里加了一条硬检查——送审的 job 里 creativeStatement 不等于 ai_generated,直接拒发:
def check_declaration(job):
if job.get("creativeStatement") != "ai_generated":
return "BLOCK:declaration_missing"
return "PASS"
注意这是代码层拦截,不是靠人记。同样是这套流水线,2026-09-26 曾因为「改配置但执行路径不读配置」导致停发指令两小时未生效——那次教训的结论就是:约束必须落在执行路径上,写在文档和任务描述里都不算数。
把闸门放在生成和发布之间
完整链路长这样:
四个环节里,真正花钱的只有第二环。所以我给它加了两条省钱规则:
先跑便宜的先决条件。 标识检查是纯字段比对,零成本,放在最前面。标识都不对的片子根本不用送去抽帧。
抽帧结果按内容哈希缓存。 同一条片子重投多个平台时(我的流水线要发七八个平台),审核结论只算一次。缓存键用视频文件的 SHA256 前 16 位 + 审核器版本号,避免换了模型还用旧结论。
import hashlib, json
def cache_key(path, model_ver):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return f"{h.hexdigest()[:16]}:{model_ver}"
这里有个我吃过亏的点:不要用文件的 mtime 或 ctime 当缓存键。同一份内容被转码、被原子替换、被同步工具碰一下,时间戳就变了,缓存全失效,审核费重算一遍。内容哈希慢几秒,但它是唯一靠得住的判据。
闸门挡不住的部分
最后说清楚这道闸门做不到什么,避免产生虚假的安全感。
它挡不住语义违规。画面干净、提示词干净,但整条片子的叙事在宣扬什么,模型判不了,人也常常判不了。这类问题只能靠选题侧的判断——我的流水线选题固定在四个技术方向上,天然规避了这个风险,但这不叫解决了,叫没碰到。
它挡不住跨平台差异。同一条片子,A 平台过、B 平台不放过,实际发生过不止一次。闸门给出的是「普遍合规」的判断,具体平台的红线还得单独维护。
它更挡不住账号级风控。账号如果已经被打了自动化标签,发什么都会中招。这时候该做的是停发、换环境、人工试水,而不是优化内容——这个判断跟内容质量完全无关,闸门再准也没用。
所以闸门的定位要说准:它是降低返工率的工程手段,不是合规责任的外包。 一条片子该不该发、这个号还能不能发,仍然要人来定。
收尾
这道闸门上线后,我这边的变化很具体:抽帧审核通过就走,不通过的直接进复核队列,平均每条片子多花 20 秒机器时间和几分钱的审核费。换回来的是发布后零惊动——不用盯着平台后台等审核结果,也不用半夜起来处理下架通知。
比起「内容做得更好」,这种「少出错」的改进收益更稳定。内容质量是波动量,工程闸门是常量。