跳至主要内容

模型 API 出境:合同与网关两道闸

上个月我把一个内部小工具从境外模型 API 切到境内节点,触发点不是效果,是翻请求日志时看到的一条 payload:用户粘进来的一段聊天记录里带着身份证号、手机号,还有一份没签完的合同全文。我一个字段都没筛,整段塞进了 messages。

那次之后我把所有模型调用收到一个自建网关后面——出境与否由网关判断,而不是由散在各个业务文件里的那句 client.chat.completions.create 各自判断。

先说清楚:这块我不是律师,下面全是工程视角,条文以原文为准。

请求体里出境的到底是什么

大多数人以为“出境的是 prompt”。实际一次调用出境的东西比这多得多:

  • system prompt 本身。这里面往往写着业务规则、字段含义、甚至内部表名和枚举值。
  • 用户输入,以及用户上传附件解析出来的文本。PDF 解析出来是整篇。
  • 工具调用的返回值。RAG 场景下这是检索到的库内容,可能是完整文档而不是片段。
  • 多轮上下文。第二轮请求会把第一轮的全部内容重发一遍,出境的量是累加的,不是每轮只发增量。
  • 向量化请求。这条最容易被漏掉:chat 接口你做了脱敏,embedding 那一步却把原文完整发了出去。
  • 批处理、微调上传的文件。
  • 还有重试。同一条 payload 因为超时重发三四次,出境次数就是三四次。

服务端留存是另一件事。主流厂商的 API 默认不拿你的数据训练,但通常仍会保留一段时间做滥用监控,量级一般在一个月上下,企业版或特定配置可以谈到更短甚至零保留。这些写在条款里,之前我没读过,包括我自己在内的大多数人也没读过。

有一个判断我踩过坑才想明白:不是看服务商在不在境内,而是看谁能访问到数据。某些厂商在境内有节点、有主体,但运维和模型侧仍在境外,这种通常还是按出境处理。别拿“域名是 .cn”当结论。

合同里能拿到什么,拿不到什么

如果你是甲方,一份像样的数据处理协议(DPA)大致能谈到这些:

  • 处理者身份(受托处理 / 独立控制者),这一条决定出了事谁担责。
  • 子处理者清单,以及变更前的通知期。很多 DPA 写的是“变更后公告”,等于没通知。
  • 数据留存期限、删除机制、终止后的删除证明。
  • 明确否认把你的数据用于训练,而不是含糊的“可能用于改进服务”。
  • 安全事件通知时限,通常是几十小时这个量级。
  • 政府调取请求时的通知义务——这条很难拿到,多数厂商会写“法律允许的范围内尽力通知”。
  • 责任限额。这条最现实:数据事件的赔偿上限常常被压在年费的几倍以内。
  • 数据存储地域与跨境传输条款。

拿不到的是议价权。作为一个小客户,DPA 是粘的,改一个字都要走对方法务,最后你只能签或者不签。所以我后来的做法很土:把每一版 DPA 存下来,记下版本号和生效日期。这样至少在某天需要追溯“某条请求是在哪一版条款下发出的”时,我能拿出东西。

合规路径那条,个人信息保护法给了三条:安全评估、认证、标准合同备案。后来出的便利化规定里,非敏感个人信息、年度累计人数不大的场景可以豁免,按我的理解是十万人这个量级,具体数字建议查原文,不同自贸区还有自己的负面清单。走这些流程时会要求做一次个人信息保护影响评估,把出境字段清单写进去。这件事的价值不在备案本身,在于你会发现很多字段你根本说不清为什么要发出去。

网关上的四件事

合同管的是出事之后我能追到谁,网关管的是会不会出事。后者是我能改的部分。

第一,字段白名单,不是黑名单。 黑名单永远漏,因为字段会新增。我的做法是请求体里只允许出现约定好的几个 key,其余一律丢弃并打日志。丢弃这个动作一开始很烦,跑两周就干净了。

第二,假名化,映射表留在本地。

PYTHON28 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import re, hashlib

PATTERNS = {
"id_card": re.compile(r"\b\d{17}[\dXx]\b"),
"phone": re.compile(r"\b1[3-9]\d{9}\b"),
"bank_card": re.compile(r"\b\d{16,19}\b"),
}

class Vault:
"""映射表只在本地内存里,不随请求出境"""
def __init__(self):
self._m = {}

def token(self, kind, value):
h = hashlib.sha256(f"{kind}:{value}".encode()).hexdigest()[:8]
tok = f"<{kind}_{h}>"
self._m[tok] = value
return tok

def restore(self, text):
for tok, val in self._m.items():
text = text.replace(tok, val)
return text

def redact(text, vault):
for kind, pat in PATTERNS.items():
text = pat.sub(lambda m: vault.token(kind, m.group(0)), text)
return text

这里有个真实的坑:正则之间会互相误伤,18 位纯数字的银行卡会被身份证规则先吃掉。我把 id_card 放在前面,靠 dict 的插入顺序保证先替换,替换完就变成 <id_card_xxxx> 了。这种顺序依赖很脆,但比不做好。

第三,日志只存 hash 和 token 数。 网关自己的 access log 里不要再落原始 prompt,落一个 sha256(payload) 和字符数就够了,排查问题时靠时间去关联上游。原始 payload 我单独存加密副本,保留 7 天,只在需要复盘时解。

第四,本地先分流。 正则只能覆盖结构化字段,姓名、地址、自由文本里夹的病史它一点办法没有。所以请求进来先过一个本地的小分类器(我用的是一套关键词规则加一个小模型,阈值得很保守),判断“这段文本里有没有疑似个人信息”。有的话整条路由到本地模型,宁可效果差一点,也不出境。这条分流救回来的量比我预想的多,大概三成请求最后没出去。

还有一层是供应商抽象。provider 做成配置,切走的时间控制在一小时内。这不是为了省钱,是为了某天条款改了、口径变了,我不用改业务代码。

我现在的动作

每周抽 20 条网关日志,解密原始 payload,对比脱敏后的文本,看有没有漏网的。一次十五分钟。这件事比读十页 DPA 有用吗?不一定,但它们解决的不是同一个问题。

下一个要动的是 embedding。现在向量化还是把原文整段发出去,这块我还没想好怎么在不牺牲召回的前提下切到本地——打算先用本地小模型跑一版召回对比,如果掉点不超过五个,就全量切过去。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#数据合规 #模型API #网关

评论