用脱敏与哈希链设计大模型调用日志
文章给出面向 Python 和 TypeScript 的 LLM 日志设计思路:写入或发送前脱敏、异步记录、用哈希链检测篡改,并按区域与保留期限管理存储。内容同时讨论 GDPR 和高风险 AI 场景的日志要求。
AI 深度解读
LLM 日志很容易变成另一份用户数据库:姓名、邮箱、工单内容,甚至健康问题,都可能跟着 prompt 一起留下。作者给出的方案是:先脱敏,再异步写入,用哈希链检测篡改,并为存储位置和删除时间设定规则。
这套 Python、TypeScript 实现值得看,但不能直接当作合规成品。代码把日志可靠性让位给了业务可用性,HMAC 也不能自动消除个人数据属性。理解这些取舍,比复制一个 Logger 更有用。
日志要留下系统行为,尽量少留下用户身份
作者建议按业务用途设置 system_id,例如 cv-screening、support-triage,而不是只记录模型供应商。同一个模型处理营销文案和候选人简历,涉及的数据、风险和审计目的并不相同。
脱敏后仍可以保留模型、时间、token 用量、耗时和结束原因。原文示例记录了 gpt-4o-2024-08-06、847 个输入 token、203 个输出 token、1243 ms 耗时,以及 finish_reason: "stop"。这些信息足以分析调用成本和响应情况,无须同时保存邮箱。
处理个人信息(PII)分两层。正则匹配 email、IBAN、银行卡号、电话和 IP;本地命名实体识别模型(NER)处理姓名。原文使用 spaCy 的 en_core_web_lg,并提到多语言可选 xx_ent_wiki_sm,但没有给出中文识别效果。
正则合并为一次匹配,同一位置优先命中排在前面的分支,因此银行卡号排在电话前面。不过,匹配格式不等于验证有效性,姓名模型也可能漏检。
替换后的标记是否需要跨日志保持一致,取决于排障目的。下面这段代码用位置编号或 HMAC 生成标记:
def token(category: str, value: str, n: int, key: bytes | None) -> str:
if key is None:
return f"[{category.upper()}_REDACTED_{n}]"
digest = hmac.new(key, value.encode(), hashlib.sha256).hexdigest()[:12]
return f"[{category.upper()}_REDACTED_{digest}]"
要看的是 hmac.new(...):普通 SHA-256 可以被已知邮箱列表枚举比对,HMAC 则增加了密钥门槛。密钥应留在自己的基础设施中,不能随日志发送。
但同一邮箱始终生成同一标记,也保留了跨记录关联能力。HMAC 更接近假名化手段,不能仅凭它就认定日志已经匿名。 上下文中的职位、经历或罕见问题,同样可能帮助识别人。
异步日志解决延迟,却没有自动解决丢失
Python 示例让请求线程只向内存队列调用 put_nowait,后台单线程负责脱敏和发送。队列容量为 10_000,满了就丢弃;发送超时为 2 秒,最多尝试 5 次,等待时间使用 min(2**attempt, 30)。
这种设计避免了每次调用都创建线程,也把 NER 开销移出了请求处理流程。但作者所说的「绝不增加延迟」应理解为工程目标:入队、构造元数据仍有开销,文中没有基准测试。后文提到的 200 ms 也是示意场景,不是实测结果。
TypeScript 版本通过 setImmediate 启动消费队列,使用 AbortSignal.timeout(2000),退避公式为 Math.min(2 ** attempt * 1000, 30_000)。这里容易误读:setImmediate 只是推迟执行,同步 scrubAll 仍在 Node 事件循环里运行。脱敏耗时较长时,需要考虑 Worker Threads 或独立进程。
流式响应还有一个更隐蔽的问题:客户端中断后,循环后面的日志代码可能没有机会执行。原文用下面的实现收集用量,并在清理阶段记录已生成内容:
def call_llm_streaming(user_prompt: str):
start = time.perf_counter()
chunks, usage, model, finish_reason = [], None, None, None
stream = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": user_prompt}],
stream=True,
stream_options={"include_usage": True}, # 最后一个数据块携带 token 用量
)
try:
for chunk in stream:
model = chunk.model
if chunk.usage:
usage = chunk.usage
if chunk.choices:
choice = chunk.choices[0]
finish_reason = choice.finish_reason or finish_reason
if choice.delta.content:
chunks.append(choice.delta.content)
yield choice.delta.content
finally:
# 客户端断开且生成器被关闭时,也会执行
stream.close()
logger.log(
user_prompt,
"".join(chunks),
model=model,
prompt_tokens=usage.prompt_tokens if usage else None,
completion_tokens=usage.completion_tokens if usage else None,
latency_ms=round((time.perf_counter() - start) * 1000),
finish_reason=finish_reason or "interrupted",
)
重点看 stream_options={"include_usage": True} 和 finally。前者请求用量统计,后者处理生成器被关闭时的记录;中途退出可能收不到最终用量,因此代码保留 None。应用还需要确保断连会触发生成器关闭,进程退出则无法靠 finally 保证交付。
可靠性上的缺口更直接:队列满、脱敏异常、重试耗尽都会丢日志;HTTP 状态码小于 500 就返回,连 401、429 也不会重试。作者建议生产环境增加磁盘队列,但必须明确脱敏位置,否则将原始队列直接持久化,就违反了「写入前脱敏」。
此外,示例把 user_prompt 原样发给 OpenAI。它实现的是日志出口脱敏,并没有实现模型请求脱敏。
哈希链能检查修改,保存期限仍要单独设计
哈希链的做法很简单:把上一条记录的哈希和当前记录内容一起计算 SHA-256。修改中间记录,后续校验就会失败。
链应在日志接收端计算,每条链只有一个写入者,例如放进数据库事务。多个应用实例各自维护「上一条哈希」,会产生分叉。
但能修改全部记录的人,也能重算整条链。因此作者要求定期把最新哈希保存到独立系统、报告或邮件中。外部锚点提供了另一份可供比较的依据。
保存期限则是另一个问题。原文引用 GDPR Article 5(1)(e) 的保存限制,并引用 EU AI Act Article 12 的自动日志要求,以及 Article 26(6) 中部署者至少保存六个月的要求。六个月针对文中讨论的高风险系统,不能推广成所有 LLM 日志的统一期限,具体适用条件仍需核对。
到期删除还会影响哈希链校验。原文没有解释删除旧记录后如何保存边界哈希、验证剩余记录,这部分需要补设计。
存储位置也不能只看「EU region」。作者担心美国母公司及 CLOUD Act 带来的访问风险,偏向没有美国母公司的欧盟服务商或欧盟自托管;对于 EU–US Data Privacy Framework 的有效性和争议,则属于需要按使用时点核实的法律信息。
原文还要求记录处理活动、落实自动删除,按 Article 28 与模型供应商签订 DPA,并在高风险用例下开展 DPIA。这些工作无法由 Logger 代码代替。
我的判断
最有价值的原则是:脱敏必须发生在日志第一次落盘或外发之前。 先保存原文再清洗,会让原始数据进入备份、索引和访问链路,后续处理更难控制。
但全文没有吞吐量、漏检率、丢失率或方案对照数据,也没有明确交代生产验证规模。作者披露自己正在开发 Sovergate,提供 Python、TypeScript SDK、德国存储和 Article 12 报告,因此应把文章视作带有产品背景的工程经验,而非独立验证结论。
普通排障日志可以接受适度丢弃,强审计需求则需要更明确的交付保证。相比加密原文,脱敏减少了可恢复身份信息,却降低了复现能力;如果只需要监控成本和错误,直接少记文本,往往比复杂脱敏更容易控制。
能拿来做什么
- 在 Node 服务端的 LLM 调用封装里统一记录用途、模型、token、耗时和结束原因,并检查异常日志与 APM 是否绕过脱敏链路。
- 用业务中的中文姓名、混合语言工单、手机号和业务编号评估脱敏效果,再决定是否需要可跨记录关联的 HMAC 标记。
- 为流式接口补上断连清理与日志记录,显式请求用量,并允许中断时用量未知。
- 在引入持久化队列前,明确允许丢失多少日志、原文在哪一步消失,以及到期删除后如何验证哈希链。