我们在做一个医疗健康领域的 AI 智能体平台:管理台 + 多租户权限 + 若干业务模块 + 智能体桥接。 智能体不是只会聊天——它会代表用户去读数据、生成内容、执行操作,而它碰的数据属于敏感个人信息。
平台上线前要过等保,监管的要求很朴素:AI 做过的每件事,都要能查。 但"能查"这三个字拆开,是三个完全不同的问题:怎么留痕、留什么、给谁看。 这篇讲我们怎么答这三问,以及中间踩到的两个坑。
一、Agent 的审计,和普通系统审计有三点不一样
普通业务系统的审计相对单纯:一次 HTTP 请求进来,一个用户在做事,落一条记录就完了。
Agent 插进来之后,有三件事变了:
① 一次对话可能触发多次工具调用。 用户只发了一句"帮我查一下某个对象的记录",背后可能是:意图分类 → 查数据 → 生成回答。"用户说了什么"和"系统做了什么"不再是同一个粒度。
② 决策发生在模型里,不在代码里。 传统系统里"为什么走了这个分支"能靠代码追溯;Agent 里同一个输入两次可能走不同分支。审计记录是唯一的事后依据。
③ 它碰的数据更敏感。 模型要用的上下文里天然含有 PII。而审计日志本身也是数据——如果审计把整段上下文都记下来,那审计日志自己就变成了一个 PII 泄漏面。
第 ③ 条是我们一开始没想透的地方,后面会讲它怎么炸的。
二、一次请求横跨两个系统,所以审计得有两份
我们的架构里,请求路径是这么走的(权限那条链路见本系列第 1 篇):
用户(企微 / 管理台)
│
▼
业务平台(NestJS) ← 身份、数据范围、审计表都在这
│ 签名转发
▼
智能体引擎(独立进程) ← 意图分类、工具调用、生成
│
▼
模型 API
两个系统各有各的审计,而且形态完全不同:
| 业务平台侧 | 智能体引擎侧 | |
|---|---|---|
| 存储 | PostgreSQL 表 AuditEvent |
按日切割的 NDJSON 文件 |
| 内容 | 谁、对什么资源、做了什么、结果 | 哪次请求、哪个租户、哪个模型、烧了多少 token |
| 查询 | 管理台可按条件检索、导出 CSV | 运维按天捞文件 |
| 权限 | 需要 audit:view 权限点 |
文件系统权限 0600 |
为什么不做成一份?因为两侧的读写特性完全不同。
业务侧的审计要能按租户、按人、按时间检索,要能导出给测评机构——这是关系型数据库擅长的事。 智能体侧是高频追加、几乎不查,而且量比业务侧大一个量级——按日切文件、定期清理比塞进主库省事得多,也不会让审计写入拖慢业务查询。
代价:想还原"一次对话完整的因果链",得两边对起来看。所以我们在两侧都塞了同一个字段——request_id。
三、留什么:字段是从"要能回答什么问题"倒推的
我们没有先想"该记哪些字段",而是先列出出事之后要能回答的问题,再倒推字段。
智能体侧最终落盘的每一条长这样:
{
"timestamp": "2026-08-12T02:41:07.512Z",
"tenant_code": "t-xxx",
"tenant_id": "3f1e...",
"user_id": "u-1029",
"action": "chat",
"source_ip": "10.x.x.x",
"request_id": "req-8b2c...",
"patient_id_hash": "9a4f...",
"model_name": "…",
"token_consumed": 2871,
"result": "success"
}
每个字段都在回答一个具体问题:
| 字段 | 回答什么问题 |
|---|---|
tenant_code / tenant_id |
这是哪个租户的事?(多租户下不写这条,日志就是一团) |
user_id |
谁发起的? |
action |
做的是哪一类操作?(不是内容,是类别) |
request_id |
这一次操作和业务侧那一条哪条记录是同一件事? |
patient_id_hash |
涉及哪个服务对象?——注意是哈希,不是明文 |
model_name + token_consumed |
用了哪个模型、花了多少? |
result / error |
成了还是没成?失败的话原因是什么? |
三个刻意为之的设计:
① request_id 是两侧唯一的缝合点。 没有它,业务侧的"张三被查了 3 次"和智能体侧的"3 次模型调用"对不上。
② patient_id_hash 而不是 patient_id。 审计要能回答"涉及哪个服务对象",但不需要知道他是谁。哈希保留了聚合能力(同一个人被查了几次、被谁查的),去掉了可直接识别的信息。
③ action 记的是类别,不是内容。 类别是有限集合,能统计、能告警;内容是无限的,也是危险的。
action 的命名约定
我们用了点分隔的两段式,前缀归域:
auth.login auth.login.failed auth.change_password
auth.refresh auth.logout
content.generate content.publish
…(业务域的动作同理,前缀按模块划分)
好处很实际:想加一条"凌晨的登录失败超过 N 次就告警",不需要改任何业务代码,用 auth.login.failed 前缀查就行。
四、不留什么:三条禁令,和一条进 CI 的测试
这是整篇里我认为最值得抄的一段。
坑:审计日志自己变成了泄漏面
我们最初的写法很随意——出问题的时候想在日志里看上下文,于是就有了这种代码:
// ❌ 我们最初就是这么写的
this.logger.log(`派发失败 ${JSON.stringify(record)}`);
await this.audit.record(user, {
action: 'record.update',
resourceType: 'record',
metadata: { idCard: record.idCard, phone: record.phone }, // ❌
});
功能上没问题,日志还很"好用"。问题是:这个业务对象里带着姓名、身份证、电话、住址。
而审计表和日志文件的生命周期比业务数据长——业务数据可能按机构政策清理了,审计日志还要留着备查 180 天。等于我们把最敏感的数据,复制到了保护级别最低、保留时间最长的那个地方。
解法:把禁令写成扫描全仓的测试
写进文档没用——文档会被忘。我们把它变成了 CI 里一条会失败的测试:
// audit-metadata-hygiene.spec.ts
const FORBIDDEN = [
/metadata:\s*\{[^}]*idCard:\s*[a-zA-Z_][\w.]*/s, // 元数据里带身份证字段
/metadata:\s*[a-zA-Z_][\w.]*Record\b/, // 元数据里塞整个业务对象
/Logger\.[a-z]+\([^)]*JSON\.stringify\(\s*(record|user|payload)/i, // 日志整对象 dump
];
it('does not find obvious full-object PII dumps in metadata/Logger calls', () => {
const hits = [];
for (const file of walk(ROOT)) {
const text = readFileSync(file, 'utf8');
for (const re of FORBIDDEN) if (re.test(text)) hits.push(`${file} ~ ${re}`);
}
expect(hits).toEqual([]);
});
它不智能,就是正则扫源码。但它有三个别的方法没有的好处:
- 在 code review 之前就拦住了,不依赖 reviewer 记不记得这条约定
- 只写"明显的坏模式",不追求完备——宁可漏报,不可误报,否则没人会认真看它失败
- 新人提交代码时自然就学会了,比看文档有效
第二条禁令:模型上下文不落盘
智能体侧的审计记录里,没有 prompt,也没有 scopedContext。
这不是疏忽,是明令禁止的——因为上下文里必然含 PII,而且体积是审计记录本身的几十倍。要追溯"模型看到了什么",去看业务侧那几条工具调用的记录就够了,不需要把原文抄一遍。
同样是用测试锁住的:
it('appends one NDJSON line without prompt fields', () => {
// ...
expect(row).not.toHaveProperty('prompt');
expect(row).not.toHaveProperty('scopedContext');
});
注意这个断言的形式:不是"检查值里有没有敏感词",而是"这几个字段根本不该存在"。前者永远追不上数据的花样,后者是一条结构性的边界。
第三条:派发前先剥
审计之外,还有一条治本的:出域的载荷先剥一遍。
智能体拿数据是靠一个受限的查询接口,调用时带着范围声明:
{ tenant, sub, scopeType, deptIds, piiLevel: 'masked' }
piiLevel: 'masked' 意味着这一路返回的身份证、电话、住址是脱敏的。审计和脱敏在这里合流了——
审计回答"谁问了什么",脱敏回答"返回的东西里有什么"。两件事都得有,缺一个都不成立。
光有审计、没有脱敏,等于完整记录了"谁看走了全部明文";光有脱敏、没有审计,等于没人知道发生过什么。
五、给谁看:按租户隔离,一个权限点,能导出
审计记录本身也是敏感数据,访问控制不能比业务数据松。
我们的做法:
GET /admin/audit → 按当前用户的 tenantId 过滤,需要 audit:view 权限
GET /admin/audit/export → 导出 CSV,同样需要 audit:view
三个决定:
① 强制租户隔离。 查询条件的 tenantId 从登录态里取,客户端传什么都不认——和业务数据用的是同一套数据范围机制(见本系列第 2 篇)。
② 独立权限点 audit:view,不给管理员默认带上。 谁能看业务数据,和谁能看"谁看过业务数据",是两个不同的授权。
③ 导出功能是必须的,不是锦上添花。 等保测评、监管检查要的是能带走的材料,不是"你登录系统看一眼"。没有导出,审计就等于没有。
保留期:按天切文件 + 启动时清理
智能体侧的日志是 NDJSON 按 UTC 日期切文件,默认保留 180 天:
openclaw-audit-2026-08-12.ndjson
清理逻辑放在服务启动时跑一遍,不是定时任务——少一个调度组件要维护,而且重启本来就会发生,清理一定会被触及。
文件权限 0600、目录 0700,只对运行账户可读。审计日志不该被同机器上的其他进程顺手读到。
六、真实踩到的另一个坑:登录失败没有审计
这个是做权限加固时顺带发现的,值得单独说。
我们的审计写入接口长这样:
record(user: AuthenticatedUser, input: AuditInput)
要求传一个完整的登录用户对象。这在绝大多数场景下没问题——直到要记录"登录失败"。
登录失败时,你手上根本没有 AuthenticatedUser:可能是密码错(有用户名、没通过验证),可能是账号不存在(什么都没有),可能是租户都没识别出来。
结果就是:最需要审计的那一类事件,恰好写不进去。
修法是拆出第二个入口,把上下文降级成可选:
recordAuthEvent(input: {
tenantId: string;
actorId?: string | null; // 允许为空
action: string;
result: 'success' | 'failure';
// ...
})
现在 auth.login / auth.login.failed / auth.logout / auth.refresh / auth.change_password 全部落审计。
这个坑的通用形状是:审计接口的设计如果隐含了"必须有一个合法用户"这个前提,那它一定会在"用户不合法"的场景下失效——而那恰恰是最该被记录的场景。
七、沉淀成四条
一、审计的字段要从"事后要回答什么问题"倒推,不要从"现在有什么变量"正推。
后面这种写法会得到一个能记录很多、但关键时刻答不上话的日志。
二、审计日志自己也是敏感数据。
它比业务数据活得更久、保护级别往往更低。"审计里不许出现什么"要和"审计里要记什么"一样明确,而且要用 CI 而不是文档来保证。
三、能用结构性断言,就别用内容匹配。
expect(row).not.toHaveProperty('prompt') 比"检查值里有没有敏感词"可靠得多。前者是一条边界,后者是一场永远追不上的军备竞赛。
四、审计和脱敏是一条链路的两端,不能只做一半。
只审计不脱敏,等于完整记录了泄漏过程;只脱敏不审计,等于没人知道发生过什么。
一句话总结
让 AI 干活不难,难的是事后能说清楚它干了什么、看了什么、以及——它不该看到什么。
而真正把这件事做扎实的,往往不是那些漂亮的日志格式,而是几条能进 CI 的禁令。
作者:阿良 · 苏州畅达软件 —— 这是「AI Agent 落地实践」系列第 3 篇。第 1 篇讲权限隔离(PDP/PEP 分离与 fail-closed),第 2 篇讲多租户数据隔离的两层模型。有类似场景欢迎交流。
