从六个关键审计问题出发,推导出一种只能追加、不可篡改的 AI 调用日志 Schema,覆盖渲染后的完整 Prompt、模型版本、影响文档、人类操作等全链路。
一个从安全问题出发设计的审计日志记录的是方便记录的内容。一个从将要提出的问题出发反向设计的审计日志记录的是必须记录的内容。本文从六个问题出发,推导出回答这些问题的schema,使其以数据库用户无法撤销的方式成为仅可追加(append-only)的,并对之后每个人都会附加的防篡改机制的局限性保持坦诚。
这些是在安全评审、客户尽职调查问卷或事件调查中会出现的问题。每一个都驱动至少一列的设计。
谁导致了这个输出? 哪个认证主体(authenticated principal),代表谁,从哪个应用发出。
具体给模型发送了什么? 不是模板——而是渲染后的 prompt,包括检索到的上下文和工具定义。
哪个模型,哪个版本? 包括 provider 和 deployment,因为"同一个模型"在两个不同的 provider 那里并不是同一个模型。
哪些文档影响了它? 检索是输入的一部分,而一个引用了用户本不应该看到的文档的回答,正是审计通常要追问的事情。
人类对此做了什么? 接受、编辑、拒绝、升级。对于任何有人工介入的系统,这是监管者最先问的问题。
你能证明这条记录没有被篡改吗? 这是最难的问题,也是答案最需要诚实的一个。
-- One row per model invocation. Immutable by construction.
CREATE TABLE runs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT clock_timestamp(),
-- Q1: who
tenant_id uuid NOT NULL,
actor_type text NOT NULL, -- 'user' | 'service' | 'schedule'
actor_id text NOT NULL,
on_behalf_of uuid, -- the end user, when an agent acted
application text NOT NULL,
request_id text NOT NULL, -- joins to llm_requests telemetry
-- Q2: what was sent. Hash always; body subject to retention policy.
prompt_sha256 bytea NOT NULL,
prompt_body jsonb, -- nullable: erasable, see below
prompt_template text NOT NULL, -- 'answer-with-context@v7'
tool_schema_sha256 bytea,
-- Q3: which model
provider text NOT NULL,
model text NOT NULL,
model_version text, -- provider's version string, verbatim
params jsonb NOT NULL, -- temperature, top_p, max_tokens, seed
-- the output
output_sha256 bytea NOT NULL,
output_body jsonb,
finish_reason text NOT NULL,
input_tokens int NOT NULL,
output_tokens int NOT NULL,
-- Q6: tamper evidence
prev_hash bytea NOT NULL,
row_hash bytea NOT NULL
);
-- Q4: which documents influenced it. One row per retrieved chunk.
CREATE TABLE run_retrievals (
run_id bigint NOT NULL REFERENCES runs(id),
rank int NOT NULL,
chunk_id bigint NOT NULL,
document_id uuid NOT NULL,
score real NOT NULL,
index_version text NOT NULL,
PRIMARY KEY (run_id, rank)
);
-- Q5: what a human did next. Also append-only; a change of mind is a
-- new row, never an update.
CREATE TABLE run_dispositions (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
run_id bigint NOT NULL REFERENCES runs(id),
occurred_at timestamptz NOT NULL DEFAULT clock_timestamp(),
actor_id text NOT NULL,
action text NOT NULL, -- 'accepted' | 'edited' | 'rejected' | 'escalated'
edited_sha256 bytea,
note text
);
两个设计要点需要说明。prompt_sha256 是 NOT NULL 而 prompt_body 是 nullable:哈希是记录本身,内容是便利层,这种分离使得擦除成为可能而不破坏审计追踪。而 run_retrievals 在 chunk id 之外还存储了 index_version,因为一年后 chunk id 本身毫无意义——如果索引被重建了——版本号告诉你这个回答是在哪个语料库状态下产生的,并连接到在重建索引后仍然存在的 documents 表的版本列。
"我们从不更新那张表"只是一个约定。两套机制使其成为一项属性,你两个都需要,因为它们的失败模式不同。
-- 1. Privileges. The application literally cannot issue the statement.
REVOKE UPDATE, DELETE, TRUNCATE ON runs, run_retrievals, run_dispositions
FROM app_user;
GRANT INSERT, SELECT ON runs, run_retrievals, run_dispositions TO app_user;
-- 2. A trigger, which also binds roles that were granted more later,
-- including the table owner.
CREATE OR REPLACE FUNCTION deny_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'table % is append-only (attempted %)',
TG_TABLE_NAME, TG_OP
USING ERRCODE = 'restrict_violation';
END $$;
CREATE TRIGGER runs_append_only
BEFORE UPDATE OR DELETE ON runs
FOR EACH ROW EXECUTE FUNCTION deny_mutation();
对局限性要坦诚:超级用户可以删除触发器、恢复权限并重写任何行,数据库内部没有任何东西能阻止这一点。这两套机制真正提供的是:没有意外、没有 ORM 级联、没有粗心的迁移、没有被入侵的应用凭证能篡改日志。这是大部分风险,值得拥有,即使它不是全部。
其余的需要把证据放到数据库外部,这是下一节的内容,以及约束谁持有超级用户权限,这是组织控制而非 schema 控制。
每一行的哈希覆盖其自身内容和上一行的哈希,因此篡改任何一行会使其后所有哈希失效。
row_hash = sha256( prev_hash
|| canonical_json(id, occurred_at, tenant_id, actor_id,
prompt_sha256, model, model_version,
params, output_sha256) )
canonical_json: keys sorted, no insignificant whitespace, timestamps
in UTC to microsecond precision, numbers in a fixed representation.
Two implementations that disagree about any of these produce
different hashes for identical data, which makes the chain
unverifiable — pin the canonicalisation in a test.
-- Verification is a single window function over the table.
SELECT id,
row_hash = sha256(
lag(row_hash) OVER (ORDER BY id) ||
convert_to(canonical_payload, 'UTF8')
) AS ok
FROM runs
ORDER BY id;
-- Any false, or any gap in id, is the finding.
现在是通常被遗漏的部分。完全存储在受保护数据库内部的哈希链,对能够写入该数据库的攻击者什么也证明不了。任何能篡改一行的人都可以重新计算其后所有的哈希。这个链能检测意外损坏和部分篡改;它检测不了有能力的内部人,而向审计师描述它好像能检测到,就是那种在审查时会出问题的说法。
当链的头部被发布在数据库管理员无法重写的地方时,它才成为真正的证据。按成本由低到高排序:
移出单机。 每小时将当前头部哈希和行数写入启用了对象锁的对象存储桶,或写入具有不可变保留期的日志服务。成本低廉,且将未检测到的篡改限制在一小时内。
签名。 用存储在 HSM 或 KMS 中、数据库角色无法使用的密钥对头部签名。现在伪造链需要攻破两个系统。
发送给第三方。 时间戳认证机构、客户自己的系统或公共账本。最强,但运营开销也最大,只有在威胁模型中的对手是你自己时才值得。
选一个并写下来。"仅可追加加哈希链"没有外部锚点是一句听起来像控制而实际上不是的话。监管者已接受的通用设计在监管者会接受的审计日志中有讨论。
这两项义务确实存在冲突。你的审计策略说记录是不可变的;数据主体有删除其个人数据的权利。两者都是真实的,哪个也不会让步。
解决方案是将记录与内容分离,上面的 schema 已经这样做了。用每个主题的密钥加密 prompt_body 和 output_body;收到删除请求时,销毁密钥。该行保留,其哈希仍然可以验证,链是完整的,时间戳、模型版本和 token 计数仍然可以审计——而个人数据则不可恢复。
CREATE TABLE subject_keys (
subject_id uuid PRIMARY KEY,
wrapped_key bytea, -- wrapped by your KMS
destroyed_at timestamptz -- set on erasure; wrapped_key nulled
);
-- Erasure: one statement, and every ciphertext for that subject
-- becomes permanently undecryptable.
UPDATE subject_keys
SET wrapped_key = NULL, destroyed_at = now()
WHERE subject_id = $1;
两个必须与之一同记录的注意事项。在删除之前进行的备份仍然包含密钥,因此保证只有在这些备份过期时才完全生效——在你的删除程序中声明保留期,因为那是诚实的答案,也是监管指导通常期望看到的。而你留在明文中的审计列必须确实不包含个人数据:如果 actor_id 是一个电子邮件地址,你没有解决问题,只是移动了问题。GDPR 和 AI API 涵盖了周围的义务。
-- Q1/Q2/Q3: everything about one output.
SELECT r.occurred_at, r.actor_type, r.actor_id, r.on_behalf_of,
r.provider, r.model, r.model_version, r.params,
r.prompt_template, encode(r.prompt_sha256, 'hex') AS prompt_hash
FROM runs r WHERE r.request_id = $1;
-- Q4: which documents influenced it, in rank order.
SELECT rr.rank, rr.document_id, rr.chunk_id, rr.score, rr.index_version
FROM run_retrievals rr
JOIN runs r ON r.id = rr.run_id
WHERE r.request_id = $1
ORDER BY rr.rank;
-- Q4 inverted, which is the query an incident actually needs:
-- every answer that was ever influenced by this document.
SELECT r.occurred_at, r.tenant_id, r.actor_id, r.request_id
FROM run_retrievals rr
JOIN runs r ON r.id = rr.run_id
WHERE rr.document_id = $1
ORDER BY r.occurred_at DESC;
-- Q5: human review outcomes for a model version, over a period.
SELECT d.action, count(*)
FROM run_dispositions d
JOIN runs r ON r.id = d.run_id
WHERE r.model_version = $1 AND r.occurred_at >= $2
GROUP BY d.action;
反向检索查询是证明整个设计合理性的查询。当一个文档被发现是错误的,或者被不应该看到它的人看到了,问题就是这个文档污染了哪些回答——而这个问题除非你在当时记录了检索,否则无法回答。事后什么也重建不了。
这个 schema 刻意不做的一件事是记录所有内容。一个不加区分地增长的审计日志会变成一张没人查询的表,一个保留责任,而且——因为内容包含了用户输入的任何内容——一个没有商业目的的大量个人数据集中地。三条规则使其保持适度。
始终哈希,选择性存储内容。 哈希小而永久,足以证明给定的 prompt 产生了给定的输出。只为你有明确理由的请求类别存储内容——受监管的决策、人工审查流程、客户合同要求的任何内容——并对它们设置比行本身更短的保留期。
不记录 secrets,且要检查而不是假设备。 渲染后的 prompt 可能包含用户粘贴的 API 密钥,工具定义可能包含连接字符串,检索到的块可能包含语料库中的任何内容。在捕获时进行脱敏,这是 PII 脱敏覆盖的范围,并把审计日志当作一个持有生产 secrets 的系统,直到被证明不是。
将这个表与你的遥测数据分开。 它们有不同的保留规则、不同的访问控制和不同的查询模式,合并它们意味着最严格的规则支配两者——所以你三十天的指标表继承了七年的保留义务。存储 AI 调用的遥测数据是另一半,通过 request_id 连接,不做其他任何连接。
保留期本身是一个需要与承担这项义务的人共同做出的决定,连同其理由一起写下来,并通过分区删除而不是意图来执行。没有到期日的审计日志不比有记录在案的七年窗口的更合规;它更不合规,因为它持有的数据超过了你能解释为什么要保留的时间点。
request_id 列是这个日志与你的网关遥测数据的连接点;如果你通过 Multigrid 路由,每个请求记录已经携带了 provider、解析后的模型和 token 计数,因此审计行可以引用它而不是复制那些必须保持一致的字段。
存储来自 AI 调用的遥测数据
多租户检索的行级安全性
触达向量索引的删除