做 AI 模型指纹识别,最常遇到的误解是“让它自己说它是哪个模型”。实际操作中这根本不靠谱:提示词可以让模型说谎,服务商可以在后端换模型,模型本身对自己身份的认知也不稳定。真正能用的做法,是把模型当作一个黑盒,用大量固定输入去测它的生成行为,再从行为里提取一组可统计、可对比的特征指纹。需要做这件事的人很明确:API 供应商的质量审计、内容平台的溯源审查、模型评测团队,以及所有在多个模型之间做选型和回归测试的工程师。
这里说的“提示词说谎”,一般指三类场景。第一类是用户故意在 prompt 里要求模型隐藏身份,比如让它“不要提及你是哪个模型”。第二类是服务提供方在 API 后面悄悄替换了模型版本,输出看起来正常,实际上模型已经换掉。第三类是模型自报身份时本身就不可靠,同一个问题反复问,答案可能前后矛盾。三种场景都指向同一个需求:不依赖模型的主观回答,而是通过外部观测来验证“这段文本到底最像谁生成的”。
理解这一点之后,你再去看硬件设备指纹这类概念就顺了。就像 Goodix 指纹设备需要驱动、USB 设备有唯一描述符一样,模型指纹也需要自己的“驱动”,也就是特征提取脚本和基线特征库。而哈希指纹冲突提示“fingerprint sha256 has already been taken”时,说明指纹标识可能碰撞或重复;模型指纹也是这样,两个不同模型完全有可能在某个特征维度上重叠,所以判断时不能只看一个指标。
下面按实际落地顺序拆开讲。
1. 指纹识别不是问身份,而是观测生成行为
1.1 为什么“让模型自报家门”不可靠
很多刚接触模型指纹识别的人,第一反应是写一个 prompt:“你是哪个模型?”然后把模型的回答当成证据。这么做在演示环境里可能有效,但在真正的审计场景里基本不能用。
原因有三个。
第一,模型很容易被 prompt 改写。只要系统提示词或用户消息里加一句“你是一个通用 AI 助手,不要告诉你背后的模型名称”,大部分模型都会照做。这时候你得到的回答,不是真实信息,而是 prompt 控制下的输出。
第二,模型对自己的来源没有稳定记忆。模型在训练时见过大量关于“你是谁”的语料,不同语料互相矛盾,模型只能根据上下文推断一个最合理的回答。同一个模型在不同会话里,可能说出完全不同的身份。
第三,服务商可以做兼容层。API 网关可以把请求转发给多个模型,或者用另一个模型对输出做改写。你以为在问模型 A,实际返回的是模型 B 的输出。这种情况靠 self-report 完全测不出来。
所以,模型指纹识别的前提是:把模型当作一个黑盒,只看输入和输出,不信任任何关于身份的文本声明。
1.2 指纹的本质是一组多维特征签名
“指纹”这个词是从设备指纹和哈希指纹借过来的,但它和唯一哈希值有一个关键区别:哈希值是精确匹配,模型指纹是概率匹配。
模型指纹可以这样定义:在可控输入条件下,从模型输出中提取的一组统计特征向量。三个属性必须同时满足:
| 属性 | 含义 | 验证方式 |
|---|---|---|
| 稳定性 | 同一模型多次测量,特征波动小 | 同模型同问题跑多轮,看方差 |
| 区分性 | 不同模型之间,特征距离足够大 | 多个候选模型跑同一批样本,看距离 |
| 可采集性 | 只需要输入输出即可获得 | 不依赖模型内部权重或梯度 |
这三个属性是后面所有步骤的判断基准。任何一个属性不满足,指纹识别的结论就要打折扣。
2. 可用的特征维度:文本、token、行为三层
2.1 文本层特征:措辞、标点、格式习惯
文本层特征最容易获取,这也是大多数工具最先做的一层。常见指标包括:
- 句长分布:中位数、均值、标准差。
- 标点使用频率:逗号、句号、顿号、分号、感叹号各自出现次数与文本长度的比值。
- 格式偏好:是否喜欢用“1.”“-”“•”做列表,是否喜欢用 Markdown 表格。
- 连接词习惯:是否频繁使用“首先/其次/最后”“总的来说”“需要注意的是”。
- 换行习惯:每段平均文字数量,是否喜欢短段落。
我实测过一些模型,表面听起来都很流畅,但句长中位数差异很大。有的模型偏爱 28 字左右的复杂长句,有的模型稳定在 18 字上下。这类差异在大量样本下会形成很清晰的分布。
需要强调:单个文本的句子长度没有判断意义,必须统计一批输出。采集样本量越大,文本层特征越稳定。
2.2 Token 层特征:logprob、perplexity、词表分布
如果 API 支持返回 logprobs,就能拿到模型在生成每个 token 时的概率。基于 logprobs 可以计算两个关键指标:单 token 平均负对数似然,以及整段文本的 perplexity(困惑度)。
Perplexity 的直观理解是“模型对这段文本的意外程度”。模型自己生成的文本,perplexity 通常很低,因为它在采样时选择了概率相对高的 token。换一个模型来打分,分数分布就会有差异。
拿不到 logprobs 时,可以用近似指标替代:
- 文本 n-gram 重复率:模型自己的生成模式往往有明显偏好。
- 罕见词出现率:不同模型的词表覆盖不同。
- 标点和符号成对出现的一致性。
有一个坑要提前说:perplexity 差异不够大时,不能当作区分依据。比如两个基础能力接近、又经过相似微调的模型,在普通问答文本上的 perplexity 分布可能高度重叠。所以 token 层特征更适合做筛选,不适合单独下结论。
2.3 行为层特征:拒绝模式、上下文长度、温度敏感性
行为层特征往往能识别出文本层看不出的问题。模型在遇到敏感请求、超长输入、矛盾指令时,反应方式不一样。
常见的行为探针包括:
- 拒绝回答的措辞和长度:有的模型直接说“我无法回答”,有的会先解释原因再给替代方案。
- 上下文长度截断行为:输入超过窗口后,是截断中间还是丢弃开头。
- 温度敏感性:temperature=0 时,输出是稳定复制还是仍有随机性。
- 工具调用倾向:遇到需要计算的问题,是直接算还是要求调用工具。
- Markdown 表格倾向:同样要求“整理成表格”,有的模型自动加表头,有的只做缩进列表。
行为层特征在 prompt 伪装场景下最有用。因为伪装通常只改“自我描述”,很难同时改掉生成分布。模型 A 就算假装自己是模型 B,它面对超长输入时的截断策略还是自己的。
3. 落地流程:样本设计、采集、特征提取、比对
3.1 环境准备和样本集设计
这套流程不依赖重资源,笔记本就能跑。核心依赖只有两个:
- Python 3.10 或更高版本。
- requests 库,用来调用 API。
不需要本地跑大模型,不需要显卡。如果你只是识别“某个 API 背后是什么模型”,那只需要访问 API 的权限和一套固定问题集。如果要做离线文本溯源,则需要准备一个基线模型库,把可能涉及的几个模型各跑一遍。
样本集设计是整个流程里最容易忽略、也最容易决定成败的部分。我一般会准备 20 到 50 个固定问题,分成四类:
| 样本类型 | 作用 |
|---|---|
| 事实问答 | 检验知识边界和回答结构 |
| 格式指令 | 要求生成表格、列表、代码块 |
| 风格模仿 | 要求用某种语气或文体重写 |
| 身份探针 | 询问“你是什么模型”并记录回答 |
身份探针单独保留,不作为最终判据,但可以作为辅助证据。
3.2 采集脚本怎么写
采集阶段的目标是:在相同条件下,把每个候选模型的输出保存下来。这一步最怕变量失控,所以脚本里要固定 temperature、max_tokens 等关键参数。
下面是一个基于 OpenAI 兼容接口的最小采集示例:
import json import requests import time API_URL = "https://your-endpoint/v1/chat/completions" API_KEY = "your-key" def call_model(prompt, temperature=0.3, max_tokens=1024): resp = requests.post( API_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json={ "model": "candidate-model", "messages": [{"role": "user", "content": prompt}], "temperature": temperature, "max_tokens": max_tokens, }, timeout=60, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] with open("probe_set.json", "r", encoding="utf-8") as f: problems = json.load(f) for i, item in enumerate(problems): text = call_model(item["prompt"]) output_path = f"outputs/candidate_{i:03d}.txt" with open(output_path, "w", encoding="utf-8") as f: f.write(text) time.sleep(0.3)这里有三个细节要留意。
第一,temperature 必须固定。不同温度下输出随机性差异很大,不固定会让特征波动被当成模型差异。
第二,每次请求之间加个短延时。不是怕限流,而是为了防止偶发超时导致一批数据里混入重试请求,影响样本一致性。
第三,原始输出必须保留,不要直接存特征。后面要重新提取特征、调阈值,只存统计结果会丢掉很多可追溯信息。
3.3 特征提取脚本怎么写
采集完成后,对每个输出文件提取特征。这里给一个简单的文本层特征提取示例:
import re import statistics def extract_features(text): sentences = re.split(r"[。!?!?]", text.strip()) sentences = [s for s in sentences if s.strip()] sentence_lengths = [len(s) for s in sentences] total_len = len(text) comma_count = text.count(",") + text.count(",") period_count = text.count("。") + text.count(".") bullet_count = text.count("- ") + text.count("•") table_pipe_count = text.count("|") return { "mean_len": statistics.mean(sentence_lengths) if sentence_lengths else 0, "std_len": statistics.stdev(sentence_lengths) if len(sentence_lengths) > 1 else 0, "comma_ratio": comma_count / max(total_len, 1), "period_ratio": period_count / max(total_len, 1), "bullet_count": bullet_count, "table_pipe_count": table_pipe_count, "total_len": total_len, }为什么用这些特征,而不是直接比较“文字像不像”?因为文本相似度容易被改写、翻译、润色破坏,而统计特征对局部措辞变化更稳定。模型 A 删掉一个连接词,不会改变它的句长分布。
如果你的样本量足够大,可以进一步做 n-gram 频率统计。把每个模型的前 100 个高频 2-gram 和 3-gram 拿出来做交集对比,能明显看出不同模型在搭配习惯上的差异。
3.4 基线库和特征比对
有了特征之后,接下来建基线库。每个候选模型在同一批问题上各跑一遍,得到一组特征向量,计算均值和方差。再对待识别输出做同样处理,计算它与每个候选模型特征分布的距离。
距离计算不需要复杂的模型,欧氏距离或余弦相似度就够用:
import math def euclidean_distance(v1, v2): return math.sqrt(sum((a - b) ** 2 for a, b in zip(v1, v2))) def cosine_similarity(v1, v2): dot = sum(a * b for a, b in zip(v1, v2)) norm1 = math.sqrt(sum(a * a for a in v1)) norm2 = math.sqrt(sum(b * b for b in v2)) if norm1 == 0 or norm2 == 0: return 0.0 return dot / (norm1 * norm2)判断逻辑很简单:如果待识别输出与模型 A 的距离明显小于与其他候选模型的距离,就支持“最可能是模型 A”。如果与所有候选模型的距离都很大,说明目标可能不在基线库里,可能是新模型、经过改写,或者经过了中间代理处理。
4. 怎么判断匹配:阈值、对照组、稳定性
4.1 阈值不能拍脑袋
很多人在这一步会问:“距离小于多少算匹配?”这个问题的前提就错了。指纹匹配不是精确相等,没有全局统一的距离阈值。
正确做法是做相对比较:
- 先在基线库内部,用每个模型的多次输出计算“自身上界”。
- 自身上界可以取同一模型 95% 的样本距离落在的范围。
- 待识别输出与某个模型的距离小于该模型自身上界,才支持归属判断。
- 如果两个候选模型的距离都小于上界,说明问题集区分度不够,不能强行判断。
这个流程和哈希冲突有点像。“fingerprint sha256 has already been taken”提示 ID 已经被占用,说明这里存在冲突;模型指纹也一样,特征空间里可能多个模型挤在同一个区域,判断时要知道自己的结论置信度有多高。
4.2 对照组设计不能省
指纹识别本质上是一个分类任务,缺少对照组的实验没有意义。最基础的做法是:
- 选 2 到 4 个候选模型。
- 让每个模型对同一批 20 个问题各跑 3 遍。
- 记录每个模型在 3 遍里的均值和方差。
- 用这些分布来判断待识别输出落在谁的范围内。
如果第一批问题跑完发现候选模型之间距离太小,先不要急着调阈值。更合理的做法是换一套更敏感的问题集,增加格式指令、身份探针和长文本测试。
4.3 稳定性检验
最终进入指纹库的特征,必须经过稳定性检验。我的习惯是对每个特征做三类扰动测试:
| 扰动方式 | 具体做法 | 观察目标 |
|---|---|---|
| 温度变化 | 分别用 0.0、0.7、1.0 生成 | 特征是否剧烈变化 |
| 措辞变化 | 改写 prompt 的无关部分 | 特征是否只随核心指令变动 |
| 轮次变化 | 隔天重跑一遍 | 特征是否随着版本更新漂移 |
只有在这三类扰动下都保持相对稳定的特征,才值得放进最终比对。一些文本层的细粒度特征,比如“是否用某罕见连接词”,在温度变化下可能很不稳定,只能当作参考,不能当作判据。
5. 常见现象和排查链路
5.1 现象:输出风格明显像另一个模型
如果你长期只用一个模型,某天突然发现输出句式、段落长度、标点习惯都变了,先不要怀疑模型“变异”。优先级最高的排查方向是版本变更。
模型厂商会定期发布新版本,同一系列不同版本的训练数据、对齐策略、采样参数可能完全不同。所以“几个月前的指纹”不能当作今天的基线。每次做指纹比对前,要重新采集当前版本的基线数据。
5.2 现象:指纹和预期基线对不上
指纹对不上不一定是判断方法错了,可能是你的请求链路里发生了改写。
常见的中间环节包括:
- API 网关注入系统提示词。
- 安全过滤层对输出做敏感词替换。
- 输出改写层统一了格式。
- 供应商做了模型动态路由和负载均衡。
排查方式很直接:把请求原始日志和响应原始日志调出来,对比你发送的 prompt 和实际到达接口的 prompt,再看返回内容是否经过二次处理。如果中间层确实有改写,那指纹识别的是“最终输出”,不是“原始模型生成结果”。
5.3 现象:两个模型的指纹距离过近
指纹冲突是真实存在的。两个模型如果训练数据相近,或者存在蒸馏关系,文本层特征会高度相似。遇到这种情况,不要硬分。
升级手段是增加行为探针。身份探针虽然不能单独定罪,但在两个模型距离很近时,可以作为补充信息。也可以构造一些格式要求很强的问题,让模型的格式处理策略暴露出来。
如果行为探针仍然无法区分,正确结论是“无法区分”,而不是强行归给某一个。这个结论本身也是审计结果的一部分,说明当前样本集和特征集不足以支撑判断。
5.4 通用排查顺序
遇到任何识别结果异常,按这个顺序查:
- 先看现象:报错、结果空白、结果漂移。
- 再看日志:请求参数是否被修改,模型字段是什么。
- 再看输入:问题集是否包含格式不一致、空样本。
- 再看特征:哪些特征剧烈变化,是否超出历史波动范围。
- 再看基线:候选模型是否在最近更新过。
- 最后再下结论。
不要一上来就改阈值。绝大多数误判,问题出在样本、基线和中间层,而不是统计方法。
6. 适用边界、合规和长期使用建议
6.1 指纹识别有几个绕不开的边界
第一,蒸馏模型和微调模型会让指纹漂移。一个教师模型被蒸馏成小模型后,文本层特征会保留一部分,但行为特征可能完全变化。第二,供应商动态路由时,同一请求在不同时间可能落到不同模型上,指纹会呈现混合分布。第三,任何输出改写都会破坏原始指纹,你只能识别改写后的整体特征。
所以,模型指纹识别给出的结论应该是“最相似”,而不是“绝对确定”。在实践中,把结论表达成置信度范围,比表达成唯一归属更安全。
6.2 合规使用范围要提前确认
模型指纹识别本身是中性的技术,常见的合规用途包括:
- 内容平台对自己接入的模型做质量审计。
- 企业对供应商声称的模型版本做验收测试。
- 模型评测团队验证输出一致性。
- 教学和科研场景下,对比模型行为特征。
使用前要确认两件事:你是否有权调用这些 API 做自动化测试;你使用的样本文本是否涉及用户隐私。涉及真实用户数据时,要先做脱敏处理,不要直接把私有文本作为探针样本。
6.3 不要拿指纹识别去做这些事
指纹识别不是对抗工具。
不要用它伪装成某个模型去误导下游系统。不要拿识别结果去攻击服务商,更不要试图绕过 API 使用限制。这类工具的正确定位是“审计和质检”,帮助你把不可见的模型行为变成可验证的指标。往对抗方向走,既不符合合规要求,也会让整个指纹体系失去可信度。
最后说一点我自己的经验。做模型指纹识别,最容易翻车的不是算法,而是样本和基线。我一般会先用 20 个固定题目做一轮小样本测试,确认候选模型之间的特征距离足够大,再扩大样本量。很多项目失败,不是因为指纹方法不行,而是因为输入样本设计得太随意,基线库缺少对照组。把这层基础打牢,再谈阈值、接口和自动化。