近期,阿拉巴马州总检察长办公室宣布对 OpenAI 启动数据泄露相关调查。这条新闻给应用开发者的提醒很直接:当大模型 API 成为应用基础设施的一部分,数据泄露就不只是“模型服务商自己的合规问题”,而是所有调用方都需要重新审视的风险面。使用 OpenAI API 时,用户输入的提示词、系统消息、被检索的文档片段、向量库内容,甚至日志里的完整对话,都可能离开自己的服务边界。等监管通知到了才开始搭建防护,排查成本会成倍上升。
这篇文章不讨论具体案件的进展和结果,而是从一个接入大模型 API 的开发者视角,把“AI 数据泄露”拆成可操作的工程问题:数据是怎么出去的、敏感内容如何识别和脱敏、调用链路应该如何加防护、泄露发生后又该按什么顺序排查。目标是让读者在自己的项目里能复制一套相对完整的数据安全接入方案。
1. 大模型 API 带来的数据平面,比你想象中更宽
1.1 用户请求从发起到返回,经过了哪些敏感数据节点
传统 HTTP 接口通常只转发少数参数,链路相对清晰。AI 应用不一样,一次对话往往同时包含多种类型的数据:用户输入、系统级指令、历史会话、检索到的业务文档、外部工具返回结果。每一个数据源都可能夹带敏感信息,而且会被反复拼接进同一个请求里。
一个常见的 OpenAI API 调用流程至少包含以下节点:
- 客户端把用户输入发送到自己的后端服务。
- 后端从数据库或向量库读取上下文。
- 后端把系统提示词、上下文、用户输入拼成 messages。
- OpenAI SDK 把消息发送到模型服务接口。
- 日志系统记录请求参数、响应和耗时。
- 可观测性系统把 trace 或指标发送到监控后端。
传统做法通常只关心“传输层加密”和“数据库加密”,但大模型应用真正要处理的是“提交出去的内容是否应该提交”。数据一旦到达第三方服务,应用能控制的就只有自己这一侧:谁在调用、什么内容可以被发送、发送之后留下了哪些记录。
1.2 为什么关键词过滤和 WAF 规则解决不了自然语言里的敏感信息
很多团队会先做一层关键词黑名单,禁止输入包含“身份证”“手机号”等字眼。问题在于敏感信息不总是以完整字段出现,而是嵌在自然语言中。例如:
帮我联系上次那位客户,他的订单尾号是 8823“客户”“8823”本身不是标准 PII 字段,但结合上下文可能定位到具体个人。数据库中该客户对应的姓名、手机号、地址在后端拼接上下文时可能已经被查询出来,最终被送进模型。
传统 Web 防火墙主要检测已知攻击特征,对大段自然语言中的语义敏感信息识别能力有限。AI 应用里的防护需要在提交前对文本内容执行结构化识别,而不仅仅是正则匹配几个固定关键词。
1.3 数据泄露事件里的责任链通常不只是模型厂商
当监管机构启动调查,最终调查对象可能包括模型服务商,也可能包括使用模型的开发者或企业。这是因为数据泄露发生在哪一层并不总是清晰:
- 模型服务商的训练数据、缓存机制、内部权限是否失控。
- 调用方是否在无授权情况下把用户个人信息发送给模型。
- 调用方是否完整记录了对话,导致数据库被拖库后直接泄露。
- 第三方插件、agent 工具是否在没有提示的情况下把数据转发到其他接口。
作为应用开发者,能做的是在自己可控的范围内减少“即使模型服务商安全,数据仍然可能泄露”的情况。换句话说,默认假设模型请求会被记录、会被日志系统复制、会被中间环节看到,然后按这个前提设计代码。
2. 在请求进入模型之前,先完成数据分级与 PII 识别
2.1 用数据分级决定哪些内容“允许进入模型”
不是所有数据都需要靠算法识别后才决定是否发送,更可靠的做法是先做业务层分级。数据分级要回答的问题是:某一条业务数据能否离开当前网络环境,能否发送给外部模型接口。
下面是一个常见分级示例,实际内容需要结合公司数据规范调整:
| 级别 | 数据示例 | 是否允许发送给外部 LLM |
|---|---|---|
| L0 | 通用知识、脱敏后的统计数据 | 允许 |
| L1 | 内部产品名称、非敏感运营信息 | 允许,按最小化原则发送 |
| L2 | 用户昵称、订单号、脱敏邮箱 | 需授权,且只能发送必要字段 |
| L3 | 姓名、手机号、明文邮箱、身份证号 | 原则上禁止 |
| L4 | 密钥、密码、支付信息、健康数据 | 禁止 |
分级表不应该是墙上文档,而是要落到代码里。接入点至少需要一份允许发送的字段白名单。
2.2 用 Presidio 识别并遮盖文本中的常见敏感实体
Presidio 是微软开源的一套 PII 识别与匿名化工具,常见用法是先分析文本,再对识别出的实体进行替换。在调用任何大模型 API 前,先对文本运行一次分析,可以把一部分明显风险挡在门外。
安装和最小运行示例如下:
pip install presidio-analyzer presidio-anonymizer python -m spacy download en_core_web_lg最小识别与匿名化代码:
from presidio_analyzer import AnalyzerEngine from presidio_anonymizer import AnonymizerEngine analyzer = AnalyzerEngine() anonymizer = AnonymizerEngine() sample_text = "Customer Alice contact email alice@example.com and phone 123-456-7890." results = analyzer.analyze(text=sample_text, language="en") anonymized = anonymizer.anonymize(text=sample_text, analyzer_results=results) print(anonymized.text) print([(r.entity_type, r.start, r.end, r.score) for r in results])这段代码的作用是:先用预设识别器找出人名、邮箱、电话等实体,再用<PERSON>、<EMAIL_ADDRESS>、<PHONE_NUMBER>这类占位符替换原文。输出结果会保留句子结构,但不再包含可直接定位到个人的明文信息。
需要注意,Presidio 对非英语实体的识别效果不完全相同。中文姓名、中文地址、公司内部编号等需要增加自定义规则,或者引入实体识别模型做补充。下面可以通过自定义识别器实现简单的业务编号识别,但更完整的方案还需要结合业务字典。
2.3 把“字段最小化”和“脱敏”放在数据读取阶段,而不是发送阶段
很多泄露不是发生在模型 API 请求体里,而是发生在后端查询阶段。为了生成回答,代码从用户表里查出完整记录,然后一次性拼进 Prompt:
def build_context(user_row: dict) -> str: return f"用户名:{user_row['name']} 电话:{user_row['phone']} 地址:{user_row['address']}"这条看似简单的查询让姓名、电话、地址全量进入内存、日志和模型上下文。更稳妥的方式是设计一个专门面向大模型调用的“视图对象”,只保留必要的语义字段。
class LlmCustomerContext: def __init__(self, user_row: dict): # 只保留需要参与推理的字段 self.customer_tier = user_row.get("tier") self.region = user_row.get("region") self.is_returning = user_row.get("order_count", 0) > 1 # 不保存 phone、email、address 等可身份识别字段 def render(self) -> str: return f"customer tier is {self.customer_tier}; region is {self.region}; returning: {self.is_returning}"这里的原则是:模型需要的不是“完整用户档案”,而是能帮助推理的少量特征。如果某个字段只是为了定位数据,应该先用业务 ID 完成检索,再把检索结果中的可识别字段剥掉,再交给模型。字段最小化不是一种优化手段,而是数据泄露发生时决定后果严重程度的关键因素。
注意:不要只依赖 PII 识别器。自然语言表达多变,识别器只能覆盖已知实体模式,业务层禁止发送的原始字段必须从代码上避免读取。
3. 在调用 OpenAI API 前增加输入网关、输出限制和审计点
3.1 输入网关要拦截的并不是所有的“异常”,而是“不该外发的数据”
很多团队把安全网关设计成黑名单过滤,发现“违规词”就拒绝请求。这样做的问题在于大模型应用里,用户输入本身就可能含有业务敏感信息,例如客服助手处理退货时,用户会自然地描述订单号、地址、电话。把这些内容一律拒绝,产品功能就无法使用。
更合理的设计是:
- 对可识别的明文 PII 进行匿名化或伪匿名化。
- 对高敏感级别字段禁止外发。
- 对无法判断的内容,只发送最小必要部分。
- 在发送前生成审计记录,而不是发送后补日志。
下面用一个简单的LlmInputGuard示例说明拦截顺序。
import hashlib import logging from presidio_analyzer import AnalyzerEngine from presidio_anonymizer import AnonymizerEngine logger = logging.getLogger("llm.guard") class LlmInputGuard: BLOCKED_ENTITY_TYPES = {"PHONE_NUMBER", "CREDIT_CARD"} def __init__(self): self.analyzer = AnalyzerEngine() self.anonymizer = AnonymizerEngine() def process(self, text: str) -> str: if not text or len(text) > 4000: raise ValueError("prompt violates length policy") results = self.analyzer.analyze(text=text, language="en") for r in results: if r.entity_type in self.BLOCKED_ENTITY_TYPES and r.score > 0.55: logger.warning( "blocked_pii", extra={ "entity_type": r.entity_type, "prompt_hash": hashlib.sha256(text.encode("utf-8")).hexdigest(), "rule": "blocked_entity", }, ) raise PermissionError("prompt contains blocked entity") anonymized_text = self.anonymizer.anonymize( text=text, analyzer_results=results ).text return anonymized_text这个类做了三件事:限制输入长度;检测高置信度敏感实体;在通过检测后执行匿名化。关键点是记录日志时没有保存原文,而是保存了提示词哈希,这样既能做审计,又不会把明文复制一份到日志系统。
3.2 调用 OpenAI API 时必须把请求 ID 和日志设置好
调用第三方模型接口的代码要尽量简短,但安全职责不能省。至少需要做到:API Key 从环境变量读取、请求超时有限制、每条请求都携带本系统生成的 request_id、日志中不记录完整 messages。
下面是一个基于 Python OpenAI SDK 的示例:
import hashlib import logging import os from uuid import uuid4 from openai import OpenAI logger = logging.getLogger("llm.openai") client = OpenAI( api_key=os.getenv("OPENAI_API_KEY"), timeout=60.0, max_retries=1, ) def safe_chat_completion(system_prompt: str, user_prompt: str, guard: LlmInputGuard): request_id = f"req_{uuid4().hex}" safe_user_prompt = guard.process(user_prompt) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": safe_user_prompt}, ] logger.info( "llm_request_start", extra={ "request_id": request_id, "prompt_hash": hashlib.sha256(user_prompt.encode("utf-8")).hexdigest(), "char_len": len(user_prompt), "model": "gpt-4o-mini", }, ) try: response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, user=request_id, temperature=0.2, ) logger.info( "llm_request_end", extra={ "request_id": request_id, "response_id": getattr(response, "id", ""), "finish_reason": response.choices[0].finish_reason, }, ) return response except Exception as exc: logger.error( "llm_request_error", extra={ "request_id": request_id, "error_type": exc.__class__.__name__, "message": str(exc)[:300], }, ) raise这段代码里有两个容易被忽略的安全点。
第一个是user=request_id。OpenAI 的接口支持调用方传入最终用户标识,这里用本系统的请求 ID 填充。后续排查时,可以拿着这个请求 ID 与服务商侧日志、自己日志、账单做关联。实际项目中还可以更进一步,把 request_id 放进 prompt 的元数据段或业务回调参数中,但需要评估该信息是否会污染模型输出。
第二个是异常日志只保留异常类型和截断后的消息,不打印完整请求参数。某些 SDK 在异常对象中会携带请求体信息,完整打印异常可能把明文 Prompt 记录到日志。这种日志往往是后续数据泄露事故里最先被审查的内容。
3.3 响应内容同样可能带回敏感字段,需要出口过滤
输入侧做安全处理后,输出侧常常被忽视。模型可能复述用户输入中的敏感内容,也可能在工具调用结果里返回数据库中的原始字段。如果不对输出做过滤,前端页面或下游系统可能直接展示或保存这些信息。
输出侧至少要判断:
- 输出是否包含卡号、手机号等实体。
- 输出是否包含禁止外发的内部名称。
- 输出是否可能被记录到前端埋点日志中。
出口过滤与输入过滤复用同一套 PII 识别逻辑即可。更严格的项目会采用“最小可用输出”策略:让模型只返回结构化 JSON 字段,再在后端做二次校验。
{ "summary": "一句话结论", "needs_human": false, "confidence": 0.92 }如果产品不允许模型自由输出姓名、电话,就不要让模型直接生成这些字段,而是让模型返回业务实体 ID,再由后端通过权限系统拼接可展示数据。
3.4 日志、向量库和跟踪平台里都不该出现明文 PII
很多隐蔽泄露发生在“辅助系统”。例如:
- 为了调试,在日志里打印完整 messages。
- 为了构建 RAG,把原始对话和用户档案一起写入向量库。
- 为了监控链路,把 Prompt 放入 APM 的 tag 中。
这些数据最终可能被日志采集平台、监控系统、数据库备份等多处复制。即使原始模型服务商没有泄露,任何一处副本泄露都可能变成数据事件。
建议把“日志中不出现明文 PII”作为代码审查规则。可以用一个类似下面的工具类统一记录请求摘要:
def log_prompt_without_pii(logger, request_id, prompt): logger.info( "prompt_security_summary", extra={ "request_id": request_id, "prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), "char_len": len(prompt), "contains_email": "@" in prompt, }, )即使确实需要保留完整对话用于产品功能,也应该把完整内容写入独立的加密存储系统,并且设置访问权限和数据保留期限,而不是把完整对话直接推进通用消息队列或日志平台。
注意:RAG 索引里的数据可以被搜索、被备份、被导出。向向量库写入数据前,必须像写数据库一样先做脱敏和权限判断。
4. 当疑似“数据已经出去”时,按调用链排查而不是凭感觉改代码
4.1 先定义泄露的可能表现和常见根因
AI 应用数据泄露不一定会立刻产生可见业务故障。常见现象包括:
| 现象 | 可能原因 | 排查入口 |
|---|---|---|
| 日志平台里出现完整手机号或邮箱 | 日志打印了完整 messages | 搜索应用日志中的明文 PII |
| 向量库查询结果返回其他用户资料 | 建索引时未按用户隔离 | 检查索引文档中的归属字段 |
| 内部员工或 API Key 账单出现异常调用 | Key 权限过大或被硬编码在代码中 | 查看服务商控制台的调用记录 |
| 某个 Prompt 在第三方后台审计里包含内部字段 | 系统提示词或上下文拼接了内部数据库字段 | 检查系统提示词构造代码 |
| 模型输出的错误信息含数据库列名或内部地址 | 异常处理直接拼接了底层异常 | 检查异常堆栈日志 |
排查顺序建议:先确定“什么内容出去了”,再确定“从哪个代码路径出去的”,最后再定“影响的用户范围”。如果一上来就修改 Prompt,很可能只封住了表面问题。
4.2 用 request_id 和 prompt_hash 关联请求
在前面的示例中,日志中记录了request_id和prompt_hash。这两个字段就是事故排查时的锚点。
当用户反馈“AI 回答里出现了我的手机号”,排查可以这样展开:
- 找到该用户的会话 ID。
- 在应用日志里搜索会话 ID,取到关联的多个 request_id。
- 用 request_id 搜索 OpenAI SDK 调用记录、网关日志和回调日志。
- 用 prompt_hash 反向找到这条 Prompt 是否被日志系统、监控系统、数据库复制。
查找日志的命令通常可以这样执行:
grep -rn "<request_id>" /var/log/your-app/ | head -50如果代码实现正确,日志里不应该出现完整 Prompt,只有 request_id 和哈希值,这样可以快速缩小范围。如果发现日志里存在完整 Prompt,就说明日志服务本身也是一个泄露点,需要立即调整采集策略,并考虑历史日志的清洗。
4.3 从代码、日志、账单和数据库四个方向查
实际排查往往会同时看多个方向,控制在一张表里可以提升可操作性:
| 排查方向 | 检查内容 | 建议命令或操作 |
|---|---|---|
| 代码层 | 是否拼接了受限字段 | 搜索<user_row>、phone、email拼接点 |
| 日志层 | 是否出现明文 PII | grep -rE "[0-9a-zA-Z._%+-]+@[0-9a-zA-Z.-]+\\.[0-9a-zA-Z]+" /var/log/ |
| 账单层 | 模型调用量是否突增 | 登录服务商控制台查看 token 消耗趋势 |
| 数据库层 | 是否存储了不该存的对话明文 | 检查对话表字段、向量库 collection |
很多团队把时间花在“改系统提示词”上,但真正泄露的数据可能在数据库备份或可观测性系统里。排查时应先确认最容易被复制的内容在哪里,再决定清理顺序。
5. 数据泄露响应流程和长期加固项
5.1 事件分级:不能所有异常都用同一处理方式
事件响应要先分级,否则轻微日志泄露会被当成灾难处理,而真实的大范围泄露又可能被低估。
| 事件级别 | 示例 | 响应时间 |
|---|---|---|
| P4 | 单条测试日志出现邮箱 | 记录并修复,下一迭代处理 |
| P3 | 少数真实用户手机号进入日志 | 当天清理并排查同一批次请求 |
| P2 | 多个用户 PII 进入日志或向量库 | 停止相关调用,启动应急小组 |
| P1 | API Key 泄露、大面积用户数据可能外发 | 立即轮换密钥、暂停高危接口、通知法务 |
实际分级阈值需要与安全和法务确认,但原则是:涉及真实 PII 数量越多、外发到第三方服务的路径越深,响应级别越高。
5.2 事件发生后先做五个动作,而不是先删库
发生疑似泄露时,团队最容易进入两种极端状态:一边什么都不做,一边把所有日志和代码立刻删除。正确的第一步是保留证据。
推荐顺序:
- 立即轮换可能泄露的 API Key,并暂停高风险调用。
- 标记相关 request_id,导出日志、账单、监控数据用于分析。
- 保留现场,不要直接清理日志表或向量库。
- 确认事件影响范围:哪些用户、哪些字段、哪些外部系统收到数据。
- 通知数据负责人和法务,按合规要求评估是否构成“数据泄露通知义务”。
这个顺序的关键是:先切断问题的影响面,再保留证据,最后再进入修复。删除现场数据会让后续影响评估变得非常困难。
5.3 长期加固:把安全从“临时操作”变成“每次上线前的门禁”
一次事件处理完后,要回到工程链路里做沉淀。建议在 CI 或发布流程中加入以下检查:
- 代码扫描禁止出现
api_key、password等硬编码。 - 对调用大模型 API 的服务增加自动化测试,测试用例包含含 PII 的输入。
- 日志采集配置中增加 PII 正则过滤或脱敏插件。
- 向量库索引构建脚本必须执行数据脱敏步骤。
长期来看,真正降低 AI 数据泄露风险的做法是减少敏感数据“进入模型上下文”的概率,而不是只依赖某个安全组件。业务设计上也要提供开关,让用户在不需要 AI 辅助时可以关闭相关功能,避免默认情况下所有用户数据都被送到模型侧。
6. 上线前需要核对的安全清单和常见坑位
6.1 开发环境与生产环境的显著差异
开发环境中看起来很正常的代码,进入生产环境后可能变成泄露源。区别常常来自日志量、权限边界和真实数据。需要注意的地方包括:
| 观察点 | 学习/开发环境 | 生产环境 |
|---|---|---|
| API Key | 可以使用个人账号 | 使用受限权限的项目专用 Key |
| 测试数据 | 使用虚构样例 | 禁止直接复制真实用户数据 |
| 日志 | 可以打印完整响应便于调试 | 禁止打印完整 Prompt |
| 向量库 | 本地索引即可 | 需要加密、隔离、备份策略 |
| 数据保留 | 不需要严格策略 | 必须有 TTL 和删除机制 |
如果不区分这两类环境,很可能在联调时把真实用户数据导入开发索引,最终出现权限漏洞。
6.2 五个与主题强相关的常见坑
第一,日志里记录完整 messages。很多人认为 OpenAI SDK 调用失败后,打印完整请求更容易排查问题,实际上这是把用户输入复制到了日志平台。推荐只打印 request_id、hash、错误类型和截断信息。
第二,只做正则黑名单,不识别语义敏感内容。用户输入“我们技术经理住徐汇区”不包含手机号,但地名和职位组合起来可能定位到具体的人。需要引入 NER 识别、业务字典和上下文规则,不能只依赖简单 regex。
第三,在上下文读取阶段一次性查询整行。为了生成回复,从用户表里取出所有字段并拼进 Prompt,这是最常见的隐性泄露。应构建只允许读取必要字段的数据访问层,而不是让业务代码随意查询完整数据行。
第四,把向量库当成“另一个日志系统”。为了 RAG 效果更好,把原始对话和用户资料一起写入向量库,却没有任何字段级权限。向量库的主要价值是语义检索,不能因此牺牲数据访问边界。写入前应脱敏,检索结果应按用户维度做好过滤。
第五,在系统提示词里拼接内部密钥、数据库连接串、内部服务名。这些内容即使不直接展示给用户,也可能出现在模型日志、异常信息或错误响应中。系统提示词同样应遵循最小化原则,不要把本可以通过检索接口获取的信息硬编码进去。
6.3 面向 LLM 项目的上线安全核对清单
这里整理一份可以复制到发布文档中的清单。每一项都应该有明确 owner 和检查结果。
- 数据分级表已确认,L3/L4 数据是否有禁止发送标记。
- 调用大模型 API 的代码位于网关层或专门 service,而不是散落在页面控制器。
- Prompt 构造前已经对用户输入执行 PII 检测。
- 日志配置中没有保存完整 Prompt 的字段。
- 每条 LLM 请求都有 request_id,并关联到业务会话。
- OpenAI API Key 只存在服务端环境变量或密钥管理中,没有硬编码。
- API Key 具备最小权限,并有定期轮换机制。
- RAG 索引构建前执行了脱敏和字段白名单。
- 输出侧对模型返回内容执行了敏感信息过滤。
- 事件响应联系人已经确定,知晓服务商控制台、日志平台和密钥管理入口的位置。
AI 数据安全并不是某一次代码重构或某个安全工具能解决的问题。OpenAI 这类大模型 API 让应用的数据边界从自己的服务器扩展到了外部服务,开发者能做的,是在自己的代码路径里把数据分级、字段最小化、日志审计和事件响应当成与功能同等重要的需求去建设。后续项目如果在接入 RAG、Agent 或更多第三方工具时遇到新风险,仍然可以沿用这套判断框架:先识别数据会经过哪些复制点,再把敏感数据从这些复制点里移除。