区分应用日志与审计日志的定位,提出审计日志应记录模型版本、prompt版本、输入贡献、人工介入、用户告知内容和访问记录,而非原始用户文本。
审计跟踪的存在,是为了回答一个在数月之后、由一个当时不在场的人提出的问题:这个系统在某年某月某日为这个人做了什么,做决策的依据是什么。应用日志回答的是另一个问题——什么东西出了问题——而且是为不同的存储周期设计的。两者不应该是同一条管道。
本文是工程设计指南,而非法律意见。没有任何日志设计能保证某个具体的审查者会满意;一个好的设计能保证的是:记录存在、记录完整、且可以证明没有被篡改。
日志需要回答的问题
先写下问题,再设计 schema,因为问题会完全决定 schema。对于一个包含模型的系统,常见的问题是:做了什么决策、由哪个模型版本和哪个提示词版本决定、哪些输入参与了决策、是否有人员参与以及他们做了什么、用户收到了什么回复、以及自那之后谁查看过这条记录。
注意,"重建模型输出的确切内容"在列表中,但"重建用户的原始文本"不在。这个区别就是整个设计的核心。一份存储了每条提示词的审计跟踪,是你的最敏感数据的第二个、更长期、更多人可以读取的副本——为了合规目的而构建,却制造了一个合规问题。
字段,以及需要排除的字段
{
"seq": 8814219,
"ts": "2026-08-03T09:14:02.881Z", // UTC, monotonic seq
"event": "decision.issued",
"subject_ref": "sub_9f3a...", // pseudonymous, resolvable
"actor": "svc:[email protected]",
"decision": "route:manual-review",
"confidence": 0.71,
"model_id": "vendor/model-name@2026-05-11",
"prompt_sha": "b91c4e...", // hash of the template
"prompt_ver": "git:7f2c9d1",
"params": { "temperature": 0, "max_tokens": 400 },
"input_sha": "3ad0f1...", // hash, NOT the text
"input_ref": "conv_4471#msg_12", // pointer to the store
"retrieved": ["doc_882@v3", "doc_119@v7"], // ids and versions
"output_sha": "c7e2b8...",
"human": { "reviewed": true,
"by": "usr_331",
"action": "accepted",
"at": "2026-08-03T09:20:44Z" },
"disclosed": "ai-assisted-notice-v2",
"prev": "0f5b...", // previous entry hash
"hash": "a19d..." // this entry hash
}
需要复制的模式是 input_sha 加上 input_ref。哈希值证明了对话存储今天持有的内容就是当初决策所依据的内容;引用则指明了在哪里找到它。如果数据主体行使删除权,文本消失而审计条目保持完整、仍然可验证——它只是记录了它哈希过的输入已不再可用。这个属性让审计跟踪和删除义务得以共存,而且很难事后补救。
永远不要放入:提示词或补全文本、检索到的文档内容、直接标识符、凭证或 token、以及任何你不放心让全体审计读者看到的东西。这个读者群体比工程团队更大,而且还在扩大。
让它防篡改
只追加是一条策略;哈希链让它可验证。每个条目承诺其前驱,因此篡改或删除任何条目都会破坏其后所有的哈希:
import { createHash } from "node:crypto";
const GENESIS = "0".repeat(64);
// Canonical form matters: both writer and verifier must serialise
// identically, so sort keys and fix number formatting once, centrally.
function canonical(entry) {
return JSON.stringify(entry, Object.keys(entry).sort());
}
function seal(prevHash, entry) {
const body = canonical({ ...entry, prev: prevHash });
const hash = createHash("sha256")
.update(prevHash)
.update("\n")
.update(body)
.digest("hex");
return { ...entry, prev: prevHash, hash };
}
function verify(entries) {
let prev = GENESIS;
for (const e of entries) {
if (e.prev !== prev) return { ok: false, at: e.seq, why: "chain break" };
const { hash, ...rest } = e;
const body = canonical({ ...rest, prev });
const expect = createHash("sha256")
.update(prev).update("\n").update(body).digest("hex");
if (expect !== hash) return { ok: false, at: e.seq, why: "entry altered" };
prev = hash;
}
return { ok: true, head: prev };
}
链式结构能检测篡改;不能阻止篡改,因为能重写日志的人也能重新计算链。有两个补充手段可以低成本地弥合这个差距。将头部哈希发布到日志运营者无法重写的地方——在另一个系统里每日发一条记录、发送给第三方、或者写入带有强制保留锁的存储——这样任何重写都必须同时改动运营者控制范围之外的记录。用应用不持有的密钥对头部签名。
定期运行 verify并在失败时告警。一份没人去跑的完整性检查就只是一段注释。
存储、时钟和保留
只写存储。对象存储配对象锁或不可变性策略,或者一个只能插入和查询(应用凭证没有任何删除权限)的只追加表。无论选择哪种方式,应用都必须无法删除。
用序列号而非时间戳来排序。时钟会漂移。用单调序列来保证顺序,用时间戳来表示含义,两者都要记录。
始终使用 UTC。跨越本地时间夏令时转换的日志会包含一个发生两次的小时,这正是那种会让一份本来很好的记录丧失公信力的细节。
保留期是一个需要决策的事项。长到足以满足义务,不再更长,并通过生命周期策略而非主观意愿来强制执行。因为条目中存储的是哈希和引用而非内容,在这里长期保留的代价比在对话存储上长期保留要低得多。
让价值归零的四个错误
和应用日志写入同一个系统。不同的生命周期、不同的访问群体、不同的完整性要求。共享管道意味着审计跟踪继承了两者中最弱的属性。
只记录模型名称而不记录版本。"用了标准模型"不是"为什么三月会那样做"的答案。锁定标识符并记录提供商返回的确切字符串,而不是你请求的那个。
没有记录人工步骤。如果有人审查并接受了,这通常是记录中最重要的 fact,却是最常被留在后续无人会关联的工单系统里的东西。
审计日志本身没有访问日志。读取一条关于人的记录本身就是值得记录的事件,而在内部事件之后第一个问题就是谁看了。
有些字段必须来自你和模型之间的任何组件——服务请求的确切模型、背后的提供商、以及你可以回溯的请求标识符。Multigrid 在每个响应中返回解析后的模型和请求 ID;无论你用什么,都要确认它能做到这一点,因为在请求时无法获取的字段事后是无法重建的。
擦除权与已训练模型
SOC 2 for an AI Product
Financial Services and AI: Regulatory Constraints