给 AI 出图做合规标识:真正的难点是「全链不漏」
起因:一个以为很小、结果拖了两周的需求
先说结论,免得你以为这又是一篇讲「怎么给图片打水印」的入门文。给单张图加水印是 20 行代码的事,PIL 画个角标、或者往 EXIF 里塞个字段,半天写完。我们真正卡住的地方是另一回事——怎么保证产品里每一个出图的口子都不漏加标识。这两件事的难度差着一个数量级。
背景交代一下:我们做的是一个 AI 图像产品(叫图叮),用户在里面用文生图、图生图、各种编辑工具产出图片。今年年初,合规这条线压过来,任务描述写得轻描淡写:「所有 AI 生成的图加上标识」。当时评估工时给了两天。最后实际花了将近两周,而且其中有几天是在排查一个线上已经漏出去的口子。
这篇写我们踩的坑和最后的落地方式。规则本身还在演进,各家实现也不一样,这里讲的是我们的工程实践,不是标准答案。涉及法规条款和国标细节的地方,以官方发布为准,我拿不准的会说「大概是这样」,不硬编。
法规到底要求什么:两类标识
先把需求边界说清楚,不然后面无从谈起。
国内针对 AI 生成合成内容的标识,主要看《人工智能生成合成内容标识办法》,2025 年 9 月 1 日施行;到 2026 年 4 月起,商用场景的 AI 内容标注是强制的。配套还有一份规定具体标识方法的强制性国标(编号我记不准,就不硬报了,你按官方发布为准)。
对我们这种「生成服务提供者」——就是实际出图的一方——义务是给自己产出的图加两类标识:
一类是显式标识,加在内容本身「可感知」的位置。人一眼能看出来这是 AI 生成或合成的。落地形式就是图片上的文字说明、角标、水印这些。核心是「人可读」。
另一类是隐式标识,写在文件的元数据里。机器可读,不影响观感。里边一般要带生成服务提供者的信息、内容属性(是生成还是合成)之类。核心是「机读、不干扰画面」。
这两类不是二选一,是都要有。显式给人看,隐式给机器和后续核验用。理解到这一层,需求其实还不复杂。真正的复杂度藏在下一个问题里:这两类标识,到底该加在系统的哪个位置?
第一版设计:在生成后加一个统一中间层
一开始的直觉方案是「谁出图谁加」。文生图的接口出图后加一下,图生图的接口出图后加一下……很快就否掉了。原因很简单:出图的接口不止一两个,而且以后还会增加。让每个业务接口各自记得去调标识逻辑,等于把「不能漏」这件事寄托在每个开发者的记忆力上。分散加,必漏。 这不是如果,是迟早。
第二版设计就往「收口」走。我们把它抽成一个统一的中间层,逻辑上所有生成结果都要流过它才能对外:
生成来源(自有模型 / 外部路由 / 编辑工具)
│
▼
┌─────────────────────┐
│ 标识收口层 │
│ ├─ 隐式:写元数据 │
│ └─ 显式:叠水印/角标 │
└─────────────────────┘
│
▼
落地到对象存储 → 对外 URL / 下载 / 透传下游
隐式标识这部分实现上不难,拿到图的二进制,往元数据里写入约定字段,再写回存储。显式水印稍微要斟酌位置和样式,后面单说。
这版设计写完,自测通过,文生图、图生图、编辑工具的出图,元数据齐全、水印也在。当时觉得这事收尾了。收尾个屁。
踩的坑:外部路由把裸 URL 直接透传出去了
问题出在一个我们自己没算进「出图口」的路径上。
我们产品里有一部分能力不是自有模型跑的,而是走第三方模型路由——把请求转给外部的出图服务,人家出完图,返回结果给我们。这条链路的返回结构,是一个指向上游 CDN 的图片 URL。当时的代码,拿到这个 URL,基本上是原样塞进给前端的响应里返回了。
你已经看出问题了。这条路径上的图,根本没流过我们的标识收口层。上游返回的是一个已经落在人家 CDN 上的、没加任何标识的原图 URL,我们直接透传给了用户。等于开了一个后门,把没标识的原图泄漏到了外面,绕过了我们自己所有的标识逻辑。
更麻烦的是,泄漏的是外部 CDN 的直链。这意味着:
- 图根本不在我们的存储里,我们连「拿回来加个标识再返回」的机会都是后补的;
- 这个直链一旦发出去,可以被缓存、被转存,标识补加也管不到已经流出去的那份;
- 从审计角度看,这类图在我们系统里几乎没留下「它是一张 AI 图」的痕迹,出问题都难复盘。
发现的过程也不体面。是做全量核对的时候,拿一批线上产出图逐个检查元数据,发现有一批图元数据是空的、水印也没有,顺着 task 类型回溯,才定位到是外部路由这条链。如果不是主动去核对,这个洞会一直开着。
复盘下来,根因不是某段代码写错了,是设计假设错了。第一版收口层的隐含前提是「所有图的二进制都会经过我们的手」。而外部路由这条链,给我们的是 URL 不是二进制,它天然就绕开了那个前提。我们把「透传」当成了一个无害的动作,实际上任何一次直接透传,都是一个未受控的出图口。
修法:不信任任何「直接透传」
修复的方向定得比较决绝:不再相信任何形式的直接透传。 只要是要发给用户、发给下游、能被外部拿到的图,一律先落到我们自己的收口层,加完标识再对外,没有例外。
对外部路由这条链,具体做法是把「返回上游 URL」改成「把上游图取回、走收口、转存到自己的存储、返回我们自己的 URL」:
外部路由返回上游 URL
│
▼
把二进制取回本地
│
▼
标识收口层(隐式 + 显式) ← 和自有出图共用同一层
│
▼
转存到我们的对象存储
│
▼
返回我们域名下的 URL(绝不返回上游直链)
这里有个工程取舍值得说:多一次「取回—转存」,延迟和存储成本都上去了。但这条链上如果继续用上游直链,标识就没地方落,合规这条是硬的,成本只能认。我们在取回这一步加了缓存和并发控制,把额外开销压到可接受范围,但「必须转存、绝不返回上游直链」这条没有商量。
顺带把出口重新梳理了一遍。我们逼着自己列出系统里所有「图能出去」的口子,一个个对照有没有走收口层:
- 生成结果返回给前端展示 —— 走;
- 用户点下载/导出 —— 走;
- 透传给下游业务系统 —— 走;
- 外部路由的产出 —— 走(就是这次补的);
- 分享/外链、历史记录回看、批量导出 —— 逐个确认。
列出来才发现,「出图口」比最初以为的多得多。把它们显式枚举出来这个动作本身,就已经堵掉了一多半的潜在漏点。
光收口还不够:加一道全链审计防旁路
修完外部路由那个洞,我们没敢就此打住。因为收口层这套东西有个软肋:它防得住「已知的出图口」,防不住「以后有人新加了一个出图口、忘了走收口」。今天所有口都收好了,不代表下个季度某个同事加需求的时候还记得这条规矩。靠人记得,就等于没防住。
所以又加了一道出口审计,思路是「不信任代码写对了,而是持续验证结果对不对」。分两层:
第一层,出口处的运行时校验。 在最终对外返回图之前,加一个断言:这张图有没有过收口层的标记?实现上,收口层给每张处理过的图打一个内部标记(比如在我们自己的记录里登记这张图的 asset 已标识、或在元数据里留一个我们能识别的内部字段)。出口处检查这个标记,缺了就拦下来、告警,而不是放行:
def emit_image(asset):
if not asset.is_labeled(): # 没过收口层
alert("发现未标识图片出口", asset)
asset = labeling_pipeline(asset) # 兜底补一道
return asset.public_url
这层的价值是,就算有人新开了个口子忘了走收口,只要他最终调的是统一的出口函数,就会被拦。当然,如果连出口函数都绕过了,这层也管不到——所以还有第二层。
第二层,离线全链抽样核对。 定期从对象存储里,按产出时间和 task 类型抽样,拉图检查显式和隐式标识在不在。这层是用来兜住「运行时校验也被绕过」的极端情况的,也是我们当初发现外部路由那个洞的手段。它慢、有延迟,但覆盖面最广,是最后一道网。
两层配合起来,逻辑是:收口层负责「正常都对」,审计层负责「异常能被发现」。 单靠前者是脆的,加上后者才敢说这条线是收住的。
几个具体取舍,顺手记一下
水印加在哪、能不能被去掉。 显式水印我们放在对画面干扰较小、但又不至于随手裁掉的位置,做过几版位置和透明度的权衡。得承认,任何可见水印理论上都能被 P 掉或裁掉,我们没打算做到「绝对去不掉」——那不现实。显式标识的目标是「正常观看的人一眼能识别」,不是「对抗恶意去除」。对抗那一面,更多靠隐式标识和记录。
隐式元数据会被下游二次处理抹掉。 这是隐式标识的老问题。图片一旦经过某些压缩、转码、社交平台的重新上传,元数据经常被整段清掉。我们的态度是:元数据该写还是写,它在「本产品直接产出、直接分发」的链路里是有效的;但不把合规性单点押在元数据上,显式标识和我们自己的产出记录作为并行的证据。别指望一个容易被抹掉的东西扛全部责任。
统一中间层 vs 各出口分散,已经用血证明了。 前面那个洞就是「有一条链没走中间层」的直接后果。分散加,漏的不是水印,是整条合规链。这个教训现在写进了我们内部的评审清单:任何新增「图能对外的路径」,评审必须回答一个问题——它走没走收口层。
收尾
回头看,这个需求最反直觉的地方在于:它听起来是个「加水印」的活,实际是个「找全所有出口并保证不漏」的活。技术上没有什么高深算法,难在系统性——你得先承认自己不知道系统里到底有多少个出图口,然后老老实实把它们枚举出来、收到一个点上、再拿审计去兜住未来的新增。
合规规则还在往前走,国标和实施细则也在细化,我们这套之后大概率还要改。写出来不是说我们做得多对,是这个坑(外部裸 URL 透传泄漏)挺典型,做 AI 图像产品的团队多半会遇到同款,分享出来省点排查时间。
我们这个产品叫图叮,搜一下名字能看到。合规这块的实践还在迭代,后面有新踩的坑再更。

浙公网安备 33010602011771号