《AI Agent 工程实战》15.3 审计与合规:不可篡改审计日志

本节讲清审计日志与普通日志的区别:普通日志可丢可采样,审计日志必须完整、必须能证明没被改过。用追加式加哈希链实现审计日志,每条记录带前一条的哈希,改任何一条都会让链断掉。真跑:写 5 条记录、校验链完整、篡改中间一条再校验,打印「第 N 条校验失败」,并给出审计字段表与保留策略表。

15.3 审计与合规:不可篡改审计日志

15.1 拦住了越权,15.2 拦住了脏输出。但安全系统还有一条底线:当事情真的发生时,你必须能还原出「谁、在什么时候、做了什么」。这不是给运维看的调试信息,而是给审计和合规看的证据。一条删不掉的记录,比一百句「我们的 Agent 很安全」都有说服力。

本节把 ResearchCopilot 推进到「可审计」:实现一份追加式 + 哈希链的审计日志,每条记录带上一条的哈希,任何一处改动都会让链断掉。我们真跑一遍——写 5 条记录、校验链完整、篡改中间一条、再校验,看它怎么把「第 N 条被改了」指出来。最后给出审计字段表与保留策略。

15.3.1 审计日志不是普通日志

很多人把审计日志当成「日志的一种」,随手 logging.info 记一条就算数。这是个误区。审计日志与普通日志的目标完全不同:

维度普通日志审计日志
目标排障、观测追责、合规、取证
完整性可丢、可采样必须完整,一条不能少
可篡改无所谓必须能证明没被改过
保留期几天到几周数月到数年(按法规)
格式人读优先机器可校验 + 人可读
写入时机异步、可丢同步、先落盘再继续

一句话:普通日志丢了可以重跑,审计日志丢了就是证据链断裂。所以审计日志的写入必须比普通日志更「重」——同步写、落盘后才继续、写失败要阻断业务。这与 3.3 的幂等、4.3 的审批记录是一脉相承的:关键状态先持久化,再继续。

15.3.2 追加式 + 哈希链

「不可篡改」听起来很难,其实一个经典结构就能做到大部分:追加式(append-only)+ 哈希链(hash chain)。

思路很简单:每条记录都存下上一条记录的哈希,再把自己(含前哈希)的哈希也算出来存进去。这样:

  • 想改第 3 条 → 它的内容变了 → 它的 record_hash 对不上 → 校验时立刻发现。
  • 想连哈希一起改 → 第 4 条的 prev_hash 指向的是第 3 条原来的哈希 → 第 4 条又对不上了。
  • 想改一整条链 → 得把后面每一条的哈希都重算,还得让链的「锚」(比如定期外发的根哈希)也一致。

用一个最小实现说明。先定义记录结构和哈希函数:

import hashlib, json
from dataclasses import dataclass, asdict

GENESIS = "0" * 64   # 创世哈希:链的起点

@dataclass
class AuditRecord:
    seq: int
    ts: str
    actor: str
    workspace: str
    run_id: str
    tool: str
    args_digest: str
    status: str
    approver: str | None
    prev_hash: str
    record_hash: str = ""

def _body(rec: AuditRecord) -> dict:
    d = asdict(rec)
    d.pop("record_hash")          # 哈希本身不参与计算
    return d

def _hash(obj: dict) -> str:
    blob = json.dumps(obj, sort_keys=True, ensure_ascii=False).encode("utf-8")
    return hashlib.sha256(blob).hexdigest()

def digest_args(args: dict) -> str:
    return _hash(args)[:16]       # 只留摘要,不落原始参数

两个细节。① sort_keys=True:字典的键序要稳定,否则同样的内容会算出不同的哈希。② args_digest 只存摘要:工具的原始参数可能含敏感信息(15.2),审计只存一个哈希,既能证明「当时是这个参数」,又不把明文留在日志里。

追加和校验:

def seal(rec: AuditRecord) -> AuditRecord:
    rec.record_hash = _hash(_body(rec))
    return rec

def append(records: list[AuditRecord], **kw) -> AuditRecord:
    prev = records[-1].record_hash if records else GENESIS
    rec = AuditRecord(seq=len(records) + 1, prev_hash=prev, **kw)
    records.append(seal(rec))
    return rec

def verify(records: list[AuditRecord]) -> str:
    prev = GENESIS
    for i, r in enumerate(records):
        if r.prev_hash != prev:
            return f"第 {i + 1} 条校验失败:prev_hash 与上一条 record_hash 不符"
        if _hash(_body(r)) != r.record_hash:
            return f"第 {i + 1} 条校验失败:record_hash 与内容不符(内容被改动)"
        prev = r.record_hash
    return f"链完整:{len(records)} 条记录全部校验通过"

verify 从头到尾走一遍:先比 prev_hash 是否等于上一条的 record_hash,再重算本条哈希看是否对得上。任何一处不一致,都会定位到具体是第几条。

15.3.3 记什么:审计字段

字段设计要回答「谁、何时、何地、对什么、做了什么、结果如何、谁批的」:

字段含义例子
seq单调序号3
ts服务端时间戳(ISO 8601)2026-10-07T14:02:11+08:00
actor发起者u_bob
workspace租户ws_globex
run_id哪次运行run_42
tool调了哪个工具delete_run
args_digest参数摘要(非明文)a1b2c3…
status结果状态approved / ok / denied
approver审批人(危险动作)u_bob
prev_hash / record_hash链指针…

注意几个口径:时间戳用服务端时钟,不信客户端(4.3 讲过审批的 expires_at 同理);status 区分「审批」和「执行」两条——delete_run 会先落一条 approved(审批通过),执行后再落一条 ok,这样审计能还原「批了但没执行成功」这种中间态;approver 只对危险动作有值,只读操作留空。

15.3.4 真跑:写入、校验、篡改、再校验

写入 5 条记录(覆盖只读、写入、危险三种),校验链,然后篡改第 3 条再校验。真跑输出(Python 3.14.6):

>>> 1) 写入 5 条审计记录并校验链

#1 search     ok        prev=00000000 hash=7fa8934e
#2 sql_exec   ok        prev=7fa8934e hash=be0dc0c8
#3 delete_run approved  prev=be0dc0c8 hash=b7ddec6a
#4 delete_run ok        prev=b7ddec6a hash=228096cc
#5 digest     ok        prev=228096cc hash=20bdb41e

校验: 链完整:5 条记录全部校验通过

链是完整的:每条 prev 都等于上一条的 hash,从创世哈希 00000000 起头,一路扣到第 5 条。

现在模拟一次篡改——把第 3 条的 status 从 approved 偷偷改成 ok(假装这次删除没经过审批),再校验:

>>> 2) 篡改第 3 条(把 status 从 approved 改成 ok),再校验

校验: 第 3 条校验失败:record_hash 与内容不符(内容被改动)

篡改被精确定位:第 3 条的 record_hash 是按原始内容算的,内容一改,重算的哈希就对不上,校验立刻报「第 3 条」。

如果攻击者更聪明——改完内容顺手把第 3 条的 record_hash 也重算一遍,想掩盖呢?再校验:

>>> 3) 攻击者重算第 3 条的 record_hash 以掩盖改动,再校验

校验: 第 4 条校验失败:prev_hash 与上一条 record_hash 不符

攻击者补上了第 3 条的哈希,但第 4 条里存的是第 3 条原来的哈希——第 4 条的 prev_hash 现在指向一个已经不存在的老哈希,于是第 4 条暴露了。这就是哈希链的威力:改一条要连着改后面所有条,而后面每一条的哈希又都被下一条锚住。要彻底掩盖,只能重算整条链——那就必须连链的「外部锚」也一起改,而外部锚(比如定期把当前根哈希发到别处、写进只读存储)正是防这一手的。

这两次篡改演示了哈希链的两层防御:朴素改动被本条的 record_hash 抓住(第 3 条);补算哈希的改动被下一条的 prev_hash 抓住(第 4 条)。两次报的「第 N 条」不同,但都精确定位了断链的位置。实际取证时,只要 verify 返回的不是「链完整」,就说明这份日志已经不能作为完整证据使用——哪怕只差一条,整条链的可信度都要打问号。

15.3.5 保留期与「按用户删除」的张力

审计日志有个绕不开的矛盾:合规要求「长期保留」,隐私法规要求「用户可删除」。11.3 讲过按用户删除记忆数据,现在审计日志要保留,两者怎么共存?

答案是把**「事实」和「内容」分开**:

保留什么删除什么
谁、何时、哪个 run、调了哪个工具工具参数里的原文(用户消息、检索内容)
结果状态、审批人答案全文、引用块的原文
参数摘要(哈希)参数明文

也就是说:审计日志记「发生了这件事」,不记「这件事的原文」。用户要求删除时,把消息表、记忆表、答案表里属于他的内容删掉(11.3),但审计记录留下——它只含 actor / run_id / tool / 状态这些事实,不含内容,删不删都不涉及隐私。这样既满足「可追责」,又不违背「可删除」。

一条底线:审计日志本身绝不能提供「修改」接口。任何「更正一条审计记录」的需求,正确做法都是再追加一条更正记录,而不是改原来那条——append-only 是它的生命线,一旦开了口子,哈希链就形同虚设。

把「删除请求」和「审计记录」两条线并排看,边界很清楚:

  • 用户要求删除 → 删内容(消息、记忆、答案),保留事实(谁、何时、哪个 run、调了什么工具)。
  • 合规要求留存 → 保留事实,但事实里不含用户内容,所以不冲突。
  • 数据主体查询(谁看过我的数据)→ 用 workspace + actor 反查审计记录即可回答,无需读内容。

15.3.6 保留策略与真实环境

不同类别的记录,保留期可以不同:

记录类别保留期依据
只读操作(search / get_run)90 天排障为主,合规压力小
写入操作(create_run / update_note)1 年变更追溯
危险操作(delete_run / send_email)3–7 年合规、取证
审批记录(approved / denied)与危险操作同与动作绑定

15.3.7 落在哪里:与可观测性的分工

审计日志不能和普通日志混在一个地方,因为两者的生命周期和访问权限完全不同。第 13 章用 OTel 把 trace / metric / log 送进可观测性系统,那是排障视角;审计日志是合规视角,应当单独落一处:

普通日志 / trace审计日志
落点OTel collector → 日志系统专用 audit_records 表 / WORM 存储
访问运维可读可删只读角色可查,无 UPDATE / DELETE 权限
采样可采样全量
关联trace_id 串联run_id / workspace 串联

落进 Postgres(第 5 章)时,一个关键做法是权限层面禁掉修改:建表后只给应用一个「只允许 INSERT」的数据库角色,UPDATE 和 DELETE 权限一律不授予。这样即使应用代码有漏洞,数据库这一层也改不动已落盘的记录——把不可篡改的约束下沉到存储层,而不是只靠应用自觉。第 5 章的迁移里加一张 audit_records 表,record_hash 建唯一索引,写入用 INSERT(不是 UPSERT),就已经把结构立住了:

CREATE TABLE audit_records (
    seq         BIGSERIAL PRIMARY KEY,
    ts          TIMESTAMPTZ NOT NULL,
    actor       TEXT NOT NULL,
    workspace   TEXT NOT NULL,
    run_id      TEXT NOT NULL,
    tool        TEXT NOT NULL,
    args_digest TEXT NOT NULL,
    status      TEXT NOT NULL,
    approver    TEXT,
    prev_hash   TEXT NOT NULL,
    record_hash TEXT NOT NULL UNIQUE
);
REVOKE UPDATE, DELETE ON audit_records FROM app_role;  -- 应用角色只能 INSERT

record_hash 的 UNIQUE 约束顺带防了「重复插入同一条」;REVOKE 则从数据库权限层堵死了改写。这两条加起来,让「不可篡改」不只写在代码注释里,而是由数据库强制。

再往上,可以用数据库触发器或外部锚定期把当前链的根哈希外发,让「重算整条链」这条路也走不通。本节的纯 Python 实现是它的最小内核,落库只是把同一个结构搬进持久存储。

真实环境里还有几件事本节的简化实现没做,但要提一句:

  • 外部锚:定期把当前链的根哈希写进一个只读存储(对象存储的 WORM、另一套系统),让「重算整条链」也掩盖不了。
  • 签名而非纯哈希:哈希链防的是「悄悄改」,如果连写入者都不可信,需要给每条记录加数字签名,用私钥签、公钥验。
  • 异地多副本:单机 append-only 文件仍可能被物理覆盖,生产上要同步到多副本。

真实模型下会怎样:审计日志与模型无关——不管背后是假模型还是真实 Claude,记录的内容和结构一样。但 Agent 场景让审计多了一层价值:模型的决策链本身也是证据。一次越权删除,审计要能还原「模型被哪篇文档注入、在第几步发出调用、护栏为什么没拦住」。所以本节的结构里,tool + args_digest 之外,真实系统还会追加一条决策摘要(当时上下文的哈希),把「模型看到什么」也锚进链里。这样,一次事故的完整复盘——从注入来源到最终动作——全都可被独立校验,而不是靠当事人口述。

到此第 15 章闭环:15.1 守入口、15.2 守出口、15.3 留下不可篡改的凭证。ResearchCopilot 现在不只是一个「能用」的 Agent,而是一个出了事说得清、追得回、赖不掉的 Agent——这是它能进入合规场景的前提。下一章离开安全,回到工程:把 Python 与 Node 双栈服务装进精简镜像,用 compose 编排起来。

阅读导航:上一节:15.2 输出侧:敏感信息、越狱与幻觉引用校验 · 下一节:16.1 TS 落点三:Python + Node 双栈镜像瘦身 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距