ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型产品身份核验落地:活体检测、状态机与失败降级

大模型产品身份核验落地:活体检测、状态机与失败降级

1. 一条产品公告背后的工程雪崩:从"加个摄像头"到六个系统联动

上个月有个做海外工具站的朋友在群里发了一张截图,某海外大模型服务商的开发者后台弹出了一条通知,大意是从某个日期起,账号需要完成人脸活体核验才能继续调用。他当时的反应很典型:不就是加个摄像头拍一下吗,两小时的事。结果两周之后他还在改代码,前后端、风控、客服流程全被拖下水。这件事本身谈不上多复杂,但它非常典型地暴露了一个认知盲区——身份核验从来不是一个功能点,它是一条横跨客户端、服务端、算法服务和运营后台的完整链路。

我把这类需求统称为"准入型改造"。它和加一个登录框、加一个支付按钮有本质区别:支付失败了无非是用户不买,准入失败了用户直接进不来,你的整个业务在他面前是关闭的。所以这类改造的第一原则不是"能不能实现",而是"失败路径设计得够不够稳"。

这篇文章想聊的,是当一个大模型产品决定从"匿名可调用"转向"身份可核验"时,工程侧真正要动哪些地方、每一步为什么这么设计、以及我在类似项目里踩过哪些现在想起来还觉得亏的坑。目标读者是正在做账号体系、风控接入或者 SDK 集成的开发同学,也包括需要评估这类改造工作量的产品同学。不需要你有计算机视觉背景,我会把活体检测那部分拆成人话讲;但基础的 HTTP、状态机、并发这些概念默认你已经熟悉。

先把核心问题摆出来:当一个服务从"谁都能匿名用"变成"用之前得证明你是你",产品形态会发生什么变化?答案比想象中剧烈。匿名调用时,你只有一个 IP 和一个设备指纹可以做限流;一旦引入身份核验,你就多了一个稳定的、跨设备的主体标识,但同时你也背上了一份必须被严格管理的敏感数据,还多了一条必然会有用户走不通的失败路径。收益和负债是同时到账的。

我见过太多团队把这件事当成"接个第三方 SDK 就完事",结果上线第一周就被三件事打懵:通过率比预期低了一截、老用户迁移引发大量投诉、以及算法服务偶尔超时导致整条登录链路雪崩。下面几节,我按"为什么做—技术怎么选—工程怎么落—参数怎么调—数据怎么管—失败怎么办"这条线,把这几个问题一次讲清楚。

2. 匿名调用走向身份可核验:产品侧到底在解决什么问题

2.1 免费额度被批量薅这件事,靠 IP 真的拦不住

先说说动机,不然容易把技术选型做偏。对话式 AI 产品的成本结构和传统 SaaS 完全不同:传统 SaaS 一个免费用户不产生边际成本,而大模型每回答一次都在烧算力。这意味着任何一个"免费额度"都会被迅速套利。我见过最夸张的一个案例,某个模拟项目上线三天,注册量看着很健康,但调用量的分布曲线完全不对——凌晨三点出现整齐的锯齿波,请求间隔精确到毫秒级。

当时团队的第一反应是加 IP 限流,结果对方换 IP 的成本比换袜子还低。后来加了设备指纹,撑了两周。再后来加了手机号验证,成本上去了,但依然有整批的接码资源在跑。每一次加码都是在提高攻击成本,而不是消灭攻击,这个认知很重要——它意味着你永远在做一个经济学问题,而不是一个技术封闭问题。

人脸活体核验的价值就在这里:它把"注册一个号"的成本从几分钟的脚本时间,拉升到了必须有一个真实在场的人配合完成动作。这个门槛的跃迁不是线性的,是数量级的。当然代价也很明确,正常用户也要付出这个成本,所以产品侧必须回答一个问题:这个门槛加在哪个环节最划算?

2.2 门槛加在注册口还是加在高价值动作口

这是我建议所有团队在动手写代码之前先吵清楚的一件事。常见的三种放法:

放置位置拦截效果对正常用户的打扰工程复杂度适用场景
注册时强制最高最大,新用户第一眼就撞墙中明确的高成本服务、强风控诉求
首次调用前高较大,但用户已有预期中高有免费额度、需要先让用户体验价值
触发阈值后(如日调用超 N 次)中最小,只打扰重度用户高成熟产品、灰度改造

我个人的倾向是第三种,尤其是产品已经有存量用户的时候。原因很实在:注册口的强制核验会让转化率掉得非常难看,而这个数字很难在事后归因——你没法证明那些没注册的人是嫌麻烦还是本来就没需求。而阈值触发式的好处是,绝大多数轻度用户根本感知不到变化,只有真正在薅的人会被卡住。

但阈值触发式的工程复杂度明显更高,因为它要求你的账号体系里维护一个"核验状态"字段,并且这个字段要被计费、限流、鉴权等多个模块读取。这引出了下一节的核心:状态机设计。

2.3 一个被低估的副作用:客服工单结构会变

这一点很少有技术方案会写,但它真实存在。匿名时代,用户丢了账号大不了重注册一个;核验时代,用户一旦卡在核验失败,他没有退路,只能来找你。我在一个模拟项目里观察过,核验上线后的头两周,客服工单里"无法登录"类目的占比从 8% 涨到了 31%,其中大部分是"拍了十几次都不过"。

这类工单的处理成本极高,因为客服没法自己复现——他们不知道用户的光线、设备、网络是什么情况。所以方案设计阶段就必须配套一个东西:核验失败的诊断信息回传。至少要能记录失败发生在哪一步(活体动作没通过、图像质量不合格、比对分数低于阈值、还是服务超时),否则客服只能干瞪眼。这个字段后来成了我们排查问题的救命稻草,具体怎么设计我在第 4 节展开。

3. 活体检测在防什么:从打印照片到实时换脸的攻击阶梯

3.1 攻击面是分层的,防御也必须分层

很多同学把"人脸核验"理解成一个黑盒:传一张图进去,返回一个"是/不是"。实际上这个黑盒内部至少有三道独立的判断,而且每一道对应不同等级的攻击:

第一层是呈现攻击检测,也就是判断镜头前的是不是一张真实的、有生命体征的脸。它要防的是打印照片、屏幕翻拍、面具、头模这些东西。第二层是身份比对,也就是把现场采集的人脸和证件照或历史底库做 1:1 比对,判断"这个人是不是他声称的那个人"。第三层是设备与环境风险判断,比如是否运行在模拟器、是否使用了虚拟摄像头、画面里是否有第二块屏幕的反光。

这三层的难度和成本完全不同。第一层是纯算法问题,第二层依赖底库质量,第三层是工程对抗问题。我见过一些方案把三层混在一起做成一个接口返回一个总分,这在早期跑 Demo 的时候很方便,但一旦要调优就非常痛苦——你根本不知道分数低是因为光线差还是因为底库照片太糊。

提示:如果你在选型阶段,一定要问清楚供应商能不能把三层的分数分开返回。分不开的,后期调参基本靠猜。

3.2 动作指令式与静默式:两条路线的真实取舍

活体检测现在主流是两条路线。动作指令式要求用户配合完成眨眼、张嘴、摇头、点头等指定动作,系统通过动作序列的时序特征判断是否为真人。静默式则不需要用户配合,直接从单帧或连续帧里提取纹理、反射、摩尔纹等特征做判断。

我做过对比测试,结论是两条路线没有绝对优劣,取决于你的场景约束:

维度动作指令式静默式
防照片/翻拍强中强
防深度伪造视频中(可被预录动作序列绕过)中强(依赖模型对抗能力)
用户耗时3~8 秒1~2 秒
弱光/戴口罩场景通过率明显下降下降较少
老年人、行动不便者友好度差好
对抗成本低(加个随机动作序列即可)高(需要持续迭代模型)

那个"老年人友好度"是我一开始完全没考虑的维度,直到有个模拟项目上线后收到反馈说家里长辈怎么都过不了眨眼检测。后来我们改成了随机动作 + 允许超时重试,才算缓解。如果你的用户群体年龄跨度大,动作指令式一定要做动作难度分级,比如默认只要求转头,连续失败两次后降级为只需要正视镜头。

3.3 深度伪造这层防御,别指望一次性做完

2024 年之后,实时换脸的成本已经低到离谱,一张显卡加一个开源模型就能做到接近实时。这意味着任何静态的防伪模型都会在几个月内被绕过,你必须把这件事当成一个持续对抗的过程来设计。

工程上能做的有三件事。第一,把算法服务做成可热替换的,别把模型烧死在客户端里,否则每次升级都要发版审核,黄花菜都凉了。第二,保留原始采集帧(哪怕只保留很短的时间),因为新的检测模型往往需要回溯历史样本做验证。第三,做异常样本的自动归集,把低分样本和人工复核结论关联起来,形成你自己的对抗样本库。

这三件事我在第一个项目里一件都没做,结果模型升级那次整整卡了三周——客户端版本覆盖率上不去,老版本用户还是用旧模型在跑。这个教训值多少钱呢,大概是一个团队一个月的工时。

4. 一次完整的落地拆解:核验流程怎么焊进账号体系

4.1 先画状态机,别急着写接口

我强烈建议动手前先把状态机画出来。核验不是一个同步接口,它至少涉及这几个状态:未核验、核验中、核验通过、核验失败(可重试)、核验失败(锁定)、核验过期(需重新核验)。少一个状态,后面就会用 if-else 补救,代码会烂得非常快。

几个容易漏掉的点。"核验中"这个状态必须有,因为用户可能在传输过程中断网、关页面、切后台,如果没有中间态,用户下次进来会看到一个既不是通过也不是失败的悬空状态,前端只能猜。"核验过期"也经常被漏,如果业务要求定期重新核验(比如每 180 天),那就要有一个定时任务去扫描并降级状态,而不是在每次请求时现算——现算在 QPS 高的时候是灾难。

状态迁移必须由服务端单向驱动,客户端只能发起请求、查询结果,不能直接改状态。这一点在早期图省事的时候很容易破例,比如让客户端在核验成功后自己 set 一个本地标记,服务端信任这个标记。这个做法等于把整个风控体系的门钥匙交给了客户端,逆向一下就没有了。我见过真的有人这么干。

4.2 前后端交互的时序与超时兜底

一次完整的核验流程,我习惯拆成四步:申请核验令牌 → 采集并上传 → 异步检测 → 轮询/回调取结果。为什么不做成同步的?因为活体检测的算法耗时波动很大,从 200ms 到 3s 都有可能,同步接口会把你整个网关的超时设置绑死。异步化之后,客户端拿到一个task_id,用轮询或者长连接等结果,服务端的超时策略可以独立配置。

伪代码大致是这样:

# 1. 申请令牌(服务端) def issue_verify_token(user_id, scene): # 幂等:同一用户同一场景 5 分钟内复用未过期的令牌 existing = cache.get(f"vt:{user_id}:{scene}") if existing and not expired(existing): return existing token = gen_token(user_id, scene, ttl=600) cache.set(f"vt:{user_id}:{scene}", token, ttl=600) return token # 2. 创建检测任务 def create_task(token, frames, meta): assert verify_token(token) task_id = uid() # 先落库再投递,避免消息丢失导致状态悬空 db.insert_verify_task(task_id, user_id, status="pending", meta=meta) queue.push("liveness.detect", {"task_id": task_id, "frames": frames}) return task_id # 3. 结果回调 def on_detect_result(payload): # 必须校验签名和 task_id 合法性 verify_signature(payload) task = db.get_task(payload["task_id"]) if task.status != "pending": return # 幂等,重复回调直接丢 db.update(task_id, status=payload["status"], score=payload["score"]) state_machine.transit(task.user_id, payload["status"])

几个关键点值得单独说。令牌要幂等,用户手抖连点两次不能生成两个任务,否则可能出现一次通过一次失败,用户看到的结果莫名其妙。先落库再投递队列,这个顺序不能反,反了的话消息发出去了但数据库里没记录,回调回来找不到任务,状态就永久悬空了。回调必须幂等,算法服务超时重试是常态,同一个 task_id 回来两次很常见。

超时兜底尤其重要。我建议在服务端设一个pending状态的最大存活时间,比如 90 秒,超过就自动置为可重试失败,并给用户一个明确的文案。不设的话,用户会卡在一个转圈页面里等到天荒地老,然后来投诉你。

4.3 存量用户怎么迁:灰度节奏比技术方案更影响成败

新用户走新流程很简单,难的是存量。我在一个模拟项目里做过一次迁移,方案是分三批:第一批随机 5% 的活跃用户,观察七天;第二批扩大到 30%,同时开始给未核验用户推送提示;第三批全量,并设置一个 30 天的宽限期,期内用户仍可正常使用但每次登录会看到提醒。

这里最反直觉的一点是:第一批千万不能选最活跃的用户。活跃用户对体验变化最敏感,而且他们的使用路径最复杂,一改动就容易出问题,然后他们是最有影响力的一批人,抱怨传播得最快。选那些调用频次中等、近期有活跃记录的普通用户,才是最安全的样本。

另外,宽限期的文案非常关键。我见过写"根据要求,您需要完成身份核验"的,几乎必然引发反感;改成"为保障账号安全,建议完成一次身份确认,完成后可继续使用全部功能",配合一个明确的"稍后提醒我"按钮,接受度会好很多。这不算话术包装,这是对用户情绪的尊重——没人喜欢被强制,但大多数人愿意配合一个有解释的请求。

5. 阈值调参的真实战场:通过率、误识率与那百分之二的人

5.1 FAR 和 FRR 是一条跷跷板,没有两端都好的解

任何一个身份核验系统都有两个核心指标:误识率(把别人认成你,FAR)和误拒率(把你认成别人,FRR)。这两个指标天然对冲,你把阈值调高,误识率降下来,误拒率就上去了。所有声称"我们两项指标都很优秀"的说法,本质上是在某个特定数据集上做到了平衡,换个场景就不成立。

所以真正的问题是:你的业务更怕哪一种错误?对于金融类场景,误识的代价远大于误拒,阈值应该往严了调。对于内容生成类工具,误拒的代价(用户流失)可能更痛,阈值就要松一些。这个决策必须由产品和技术一起定,技术单方面调参一定会出事。

我在一个项目里犯过这个错:上线初期为了"安全",把比对阈值调得偏高,结果上线第一天通过率只有 76%,客服被打爆。后来回看数据,被拒的绝大部分是光线条件差的移动端用户。改了光照归一化前置处理后,通过率回到了 93%。

5.2 把阈值拆到场景,别用一个数字打天下

一个更成熟的做法是按场景拆阈值。同一个用户,在"日常登录"和"修改安全设置"这两个动作上的风险等级完全不同,用同一个阈值就是浪费。

场景风险等级建议阈值策略失败后行为
日常登录低宽松,优先保通过率允许直接重试 3 次
首次激活中标准允许重试,第 3 次转人工
修改绑定信息高严格直接转人工复核
高频调用触发中高标准偏严允许重试,失败则限流

这套分层的好处是,你可以在不影响大盘通过率的前提下,把高风险动作守得非常死。代价是配置复杂度上升,所以我建议把阈值和场景做成一份独立的配置表,用配置中心热更新,而不是硬编码在代码里。核验这种对抗性业务,参数调整的频率会高到让你怀疑人生,硬编码等于给自己挖坑。

5.3 一个必须监控的指标:单用户重试次数分布

这个指标很少有人在初期就埋,但它是最能提前预警问题的。正常用户的分布应该是重试 0~1 次占绝大多数,如果一个用户的单日重试次数超过 5 次,要么是他设备/环境确实有问题,要么是他在尝试攻击。前者需要产品侧引导(比如提示"请在光线充足处操作"),后者需要风控侧介入。

我在项目里加了这个监控之后,发现了一个隐蔽问题:某型号安卓手机的前置摄像头在特定系统版本下会输出异常色偏的帧,导致静默检测的纹理特征判断失败。这个 bug 光看整体通过率是看不出来的,因为它只影响一小部分机型,被大盘数据稀释了。是重试次数分布这个指标把它揪出来的。

提示:如果条件允许,把失败样本按设备型号、系统版本、网络类型做交叉统计,你对问题的定位速度会快一个数量级。

6. 核验数据不是资产是负债:采集、传输与生命周期的工程细节

6.1 采什么、存什么、存多久,这三个问题要在写代码前定死

人脸数据属于敏感个人信息,这一点没有讨论空间。工程上必须贯彻的是最小必要原则:能不留原始帧就不留,能存特征向量就不存图片,能当场比对完就丢弃就不要落盘。

我在方案里通常这么分层:客户端采集到的原始帧,在完成活体检测后立即丢弃,最多保留一个极短周期的临时缓存用于失败重试;服务端只保存检测结论(是否通过、分数、失败原因码)和一份不可逆的特征向量(如果后续需要做 1:N 比对);原始图像一律不进入业务数据库,如果算法侧因为模型迭代需要样本,走独立的、有严格审批和脱敏流程的样本库。

存储时长也要写死在代码里,不能靠"到时候再清理"。我们用的是带 TTL 的存储,特征向量保留周期和业务上的核验有效期一致,到期自动失效。很多数据泄露事故不是因为被攻破,是因为该删的没删。

6.2 传输链路上的几个必做项

这块的具体做法比较标准化,但漏掉任何一项都是硬伤。传输层必须全程加密,这个不用多说。更重要的是防重放:上传的每一帧或每一段视频流都要带一次性 nonce 和时间戳,服务端校验时间窗口(比如 ±60 秒)并记录已使用的 nonce,防止攻击者截获一次合法请求反复提交。

还有一个容易忽略的点是客户端完整性校验。如果攻击者能改你的客户端代码,他就能在采集环节直接把预先准备好的图像喂进去,后面所有的活体检测都白做。所以关键路径上要做签名校验,至少让改包的成本高到不值得。这不是银弹,但能过滤掉 90% 的低成本攻击。

另外,如果你用的是第三方算法服务,要注意不要把原始帧直接透传给对方的公网接口。更稳妥的做法是通过你自己的服务端中转,或者使用对方提供的专线/私有化部署方案。多一跳会增加延迟,但省掉的是数据出域的合规风险。

6.3 用户侧的知情与可控,其实是技术方案的一部分

这一点听起来像产品话题,但它直接影响你的技术设计。用户需要知道自己的数据被用来做什么、保留多久、怎么删除。这意味着你的系统必须支持数据删除请求,而且要能真的删干净——包括缓存、备份、算法侧的样本库。

我在设计时会把"删除"做成一个异步任务,有明确的执行记录和状态查询接口。不这么做的后果是,一旦有人来问"我的数据删了吗",你只能回答"应该删了"。这个回答在真正的审计面前是站不住的。

7. 核验失败的那条路:降级、申诉与那些被算法遗忘的人

7.1 失败原因分类,比失败本身重要十倍

前面提过失败原因回传,这里展开说一下怎么设计。我建议的原因码至少分这几类:LIVENESS_FAIL(活体未通过)、QUALITY_LOW(图像质量不合格)、MISMATCH(比对分数不足)、NO_REFERENCE(无可用底库)、SERVICE_TIMEOUT(服务超时)、DEVICE_RISK(设备环境异常)。

分类的价值在于每类的后续动作完全不同。SERVICE_TIMEOUT应该自动允许重试并且不计入失败次数,因为那是你的问题不是用户的问题。QUALITY_LOW应该给出具体的引导文案,比如"请摘下口罩""请正对镜头""环境光线过暗"。MISMATCH才是最需要谨慎处理的,因为它可能意味着真的人不对,也可能意味着底库照片太旧。

我见过最糟糕的实现是所有失败都返回一句"核验失败,请重试",用户重试十次还是同样的结果,最后只能放弃。这不是技术问题,这是懒惰。

7.2 人工通道不是兜底,是必需品

任何自动化的身份核验系统都会有走不通的人,这是数学必然,不是 bug。可能是烧伤患者、可能是面部特征随年龄变化极大的老人、可能是长期卧床无法完成动作的人。你的系统必须为他们留一条路。

方案上通常是"多次失败后自动转人工",但这中间有几个设计要点。转人工的触发条件不能是单纯的重试次数,还要看失败原因码——全是SERVICE_TIMEOUT的用户转人工毫无意义,应该自动重试。人工通道的响应时效要明确告知用户,否则他会一直刷新页面。人工审核的数据要单独存储、单独授权访问,不能和普通工单混在一起。

我个人的体会是,这条通道的使用率会远低于你的预期,但它的存在本身就是产品成熟度的标志。而且它往往是最早暴露出系统设计缺陷的地方——因为只有走到这一步的用户,才会把完整的失败链路走一遍。

7.3 无障碍适配:别等到被投诉了才想起来

最后说一个特别容易被忽略的点。动作指令式的活体检测对视障用户、肢体障碍用户极不友好。如果你的产品面向大众,这块必须提前考虑。可行的做法包括:提供语音引导、允许通过辅助设备完成、以及前面说的失败后转人工。

还有一个技术细节:屏幕朗读软件和摄像头采集会互相干扰。某些设备上,屏幕朗读开启时,前端获取摄像头的权限或分辨率会被限制。这个坑我是被用户反馈才发现,测试阶段完全没覆盖到。所以如果你的用户基数够大,测试用例里一定要包含开启无障碍功能的场景。

8. 最后分享几条我用真金白银换来的经验

写到这里,前面几节基本把这条链路走完了。最后补几条零散的、但我觉得最有价值的东西。

第一条,上线前一定要做一次"断网演练"。人工把算法服务的网络掐掉,看整个登录链路会怎样。我第一次做的时候发现,算法服务不可用会导致登录接口直接 500,因为代码里没写超时和降级。后来改成超时 3 秒、失败后进入可重试状态,才算稳住。这类演练成本极低,收益极高。

第二条,别把核验写进登录主流程的关键路径。更好的做法是登录归登录,核验归核验,登录成功后如果检测到需要核验,再跳转或者弹层引导。这样即使核验服务挂了,用户至少还能进到产品里看到一条说明,而不是面对一个白屏。

第三条,灰度期间一定要自己当用户走一遍全流程。用不同的手机、不同的光线、不同的网络,甚至故意在电梯里做一次。我就是在电梯里发现弱网下采集超时没有友好提示,用户只看到一个卡住的进度条。这种东西坐在工位上是永远发现不了的。

第四条,把这次的失败样本留下来。无论最终用的是哪家算法、哪个模型,失败样本是你自己的资产。下一次升级、换供应商、调阈值,这些样本能让你在半小时内验证效果,而不是重新走一遍完整的测试周期。这件事我在第二个项目才想明白,第一个项目的数据全丢了,重来时欲哭无泪。

这类准入型改造,技术难度其实都不算高,难的是把所有边界都想清楚。真正让人头疼的从来不是"怎么实现核验",而是"核验失败的人怎么办""老用户怎么过渡""数据多久删"。这些问题没有标准答案,但只要你提前想到了,方案就不会太差。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进