Appearance
审计与脱敏:出事能追溯,记录不泄密
1. 本节产出
一套审计系统:每个 Agent 运行的完整轨迹可回放(含每一轮的 Thought/Action/Observation、Token、耗时、审批记录),敏感信息在落库前脱敏,并且能回答「这个操作是谁让 AI 做的」。
2. 前置依赖
3. 为什么 Agent 的审计比普通系统难
普通系统的审计:谁、什么时间、调了哪个接口、参数是什么。够用。
Agent 的审计要回答的问题更难:
| 问题 | 为什么难 |
|---|---|
| AI 为什么做这个操作? | 需要 Thought 和上下文 |
| 是哪个输入导致它这么做的? | 可能是文档内容、工具返回值、用户话术中的任何一处 |
| 审批是谁批的? | 需要关联审批记录 |
| 花了多少钱? | 需要按运行聚合 Token |
核心难点:要能「回放」整个决策过程。 只记录最终动作是不够的,出事时你需要知道「它当时看到了什么、想到了什么」。
另一个矛盾:审计要记录详细内容,但详细内容里往往包含敏感信息(用户的手机号、订单金额、内部系统地址)。记录得越全,泄密风险越大。 这就是脱敏要解决的问题。
4. 核心原理
4.1 审计要记录什么
一次 Agent 运行的审计记录:
├─ 基本信息:runId、租户、用户、目标、起止时间、状态
├─ 每一轮:Thought、Action、工具名、参数、Observation、耗时、Token
├─ 工具调用:每次调用的工具、参数、结果摘要、是否审批
├─ 审批记录:谁批的、何时、理由
├─ 安全事件:越权尝试、编造工具名等
└─ 成本:总 Token、各轮分布、估算金额4.2 脱敏策略
| 信息类型 | 处理方式 |
|---|---|
| 手机号 | 138****1234 |
| 身份证 | 3301**********1234 |
| 银行卡 | 只留后 4 位 |
| 邮箱 | a***@example.com |
| API Key / 密钥 | 完全移除,不记录 |
| 地址 | 保留到区县级 |
| 姓名 | 视合规要求,可保留或脱敏 |
密钥必须完全移除而不是打码:打码后的密钥仍有泄露风险(部分前缀可能可猜)。
4.3 脱敏的时机
关键:脱敏必须在「落库前」,不是「查询时」
记录 → 脱敏 → 落库 ──→ 查询时无需处理
错误做法:记录 → 落库(明文)→ 查询时脱敏
问题:库里是明文的,任何有库权限的人都能看到落库前脱敏是唯一正确的做法。查询时脱敏只是「看起来安全」,实际上数据库里全是明文。
但要注意:脱敏后就无法回放完整的原始过程了。折中方案:
| 方案 | 做法 |
|---|---|
| 全脱敏 | 简单,但排查时信息不足 |
| 分级保留 | 敏感字段脱敏,其余保留;原始内容加密存储 + 严格权限 + 短期保留 |
推荐分级保留:脱敏后的记录用于日常查询,加密的原始记录用于事故调查(且有访问审计)。
4.4 保留期限与合规
审计日志保留期:按行业要求(金融通常 5 年以上)
原始内容(含敏感信息):短期(如 30 天)后自动删除
访问审计记录本身:也要留痕(谁查了审计日志)5. 代码走查
5.1 审计模型
sql
CREATE TABLE agent_audit (
id BIGSERIAL PRIMARY KEY,
run_id VARCHAR(64) NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64),
iteration INT,
thought TEXT, -- 已脱敏
tool_name VARCHAR(100),
tool_args JSONB, -- 已脱敏
observation TEXT, -- 已脱敏
tokens INT,
duration_ms BIGINT,
at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_audit_run ON agent_audit (run_id, iteration);
CREATE INDEX idx_audit_tenant ON agent_audit (tenant_id, at);
-- 审计日志的访问也要留痕
CREATE TABLE audit_access_log (
id BIGSERIAL PRIMARY KEY,
accessor VARCHAR(64),
run_id VARCHAR(64),
reason TEXT,
at TIMESTAMPTZ DEFAULT now()
);5.2 脱敏器
java
// ch03-agent/src/main/java/com/aitech/agent/audit/Masker.java
@Component
public class Masker {
private static final Pattern PHONE =
Pattern.compile("(1[3-9]\\d)\\d{4}(\\d{4})");
private static final Pattern ID_CARD =
Pattern.compile("(\\d{4})\\d{10}(\\w{4})");
private static final Pattern EMAIL =
Pattern.compile("(\\w)[\\w.-]*(@[\\w.-]+)");
private static final Pattern KEY =
Pattern.compile("(sk-|AKID|Bearer\\s+)[A-Za-z0-9_\\-]{8,}");
public String mask(String text) {
if (text == null) return null;
String r = PHONE.matcher(text).replaceAll("$1****$2");
r = ID_CARD.matcher(r).replaceAll("$1**********$2");
r = EMAIL.matcher(r).replaceAll("$1***$2");
r = KEY.matcher(r).replaceAll("[KEY_REMOVED]"); // 完全移除
return r;
}
public Map<String, Object> maskArgs(Map<String, Object> args) {
return args.entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey,
e -> e.getValue() instanceof String s ? mask(s) : e.getValue()));
}
}5.3 审计 Advisor
java
// ch03-agent/src/main/java/com/aitech/agent/audit/AuditAdvisor.java
@Component
public class AuditAdvisor implements CallAroundAdvisor {
private final Masker masker;
private final AuditRepository repo;
@Override
public AdvisedResponse aroundCall(AdvisedRequest req, CallAroundAdvisorChain chain) {
long t0 = System.nanoTime();
AdvisedResponse resp;
try {
resp = chain.nextAroundCall(req);
} catch (Exception e) {
// 失败也要审计——「谁在什么时候发起了一个失败的请求」同样重要
repo.save(AuditRecord.failed(req, masker, e));
throw e;
}
repo.save(AuditRecord.of(req, resp, masker, elapsed(t0)));
return resp;
}
@Override public String getName() { return "audit"; }
@Override public int getOrder() { return 100; } // 最前:拿到原始输入
}失败也要记录是重要细节:只记录成功的调用,会让攻击尝试、异常行为从审计里消失——而这些恰恰是最需要审计的。
5.4 轨迹回放接口
java
// ch03-agent/src/main/java/com/aitech/agent/controller/AuditController.java
@RestController
@RequestMapping("/api/audit")
public class AuditController {
@GetMapping("/{runId}")
@PreAuthorize("hasRole('AUDITOR')")
public List<AuditRecord> trace(@PathVariable String runId,
@RequestParam String reason,
Authentication auth) {
// 访问审计本身也留痕
accessLog.record(auth.getName(), runId, reason);
return repo.findByRunId(runId);
}
/** 回答「这个操作是谁让 AI 做的」 */
@GetMapping("/{runId}/who")
@PreAuthorize("hasRole('AUDITOR')")
public Responsibility responsibility(@PathVariable String runId) {
AgentRun run = runRepo.findById(runId).orElseThrow();
List<ApprovalAudit> approvals = approvalRepo.findByRunId(runId);
return new Responsibility(
run.userId(), // 发起人
run.goal(), // 原始指令
approvals, // 谁批准了哪些操作
run.createdAt(), run.updatedAt());
}
}6. 跑起来
bash
git checkout ch03-12-audit
mvn spring-boot:runbash
# 1. 跑一个含敏感信息的任务
curl -X POST http://localhost:8080/api/agent/run \
-d '{"goal":"查询客户张三(13812345678)的订单并修改状态"}'
# 2. 查看审计轨迹(脱敏后)
curl -H "X-User: auditor" "http://localhost:8080/api/audit/{runId}?reason=排查"
# 期望:手机号显示为 138****5678,完整轨迹可见
# 3. 直接查数据库验证(关键)
psql -c "SELECT thought, tool_args FROM agent_audit LIMIT 5"
# 期望:库中也是脱敏的(证明是落库前脱敏,不是查询时脱敏)
# 4. 责任追溯
curl "http://localhost:8080/api/audit/{runId}/who"
# 期望:发起人、原始指令、审批人、时间
# 5. 访问留痕
psql -c "SELECT * FROM audit_access_log"
# 期望:审计员的访问被记录| 检查项 | 通过标准 |
|---|---|
| 轨迹完整 | 每一轮的 Thought/Action/Observation 都在 |
| 落库即脱敏 | 直接查库也是脱敏的 |
| 密钥移除 | Key 类信息完全不出现 |
| 失败也记录 | 失败的调用同样有审计 |
| 责任可追溯 | 能回答发起人、审批人 |
| 访问留痕 | 查审计的人被记录 |
第三项「直接查库验证」是本节的关键验收:它证明脱敏发生在落库前。
7. 生产避坑
- 脱敏必须在落库前,不能在查询时。查询时脱敏只是展示层的安全,数据库里仍是明文,任何有库权限的人(DBA、运维、备份文件)都能看到完整敏感信息。这是合规检查必查项。
- 失败的调用也要审计。只记录成功调用会让异常行为从审计中消失——而越权尝试、注入攻击恰恰是「失败的调用」。审计的价值在于发现异常,不在于统计成功。
- 访问审计日志本身要留痕并限权。审计日志包含大量业务信息,是内部人员泄露的高风险点。做法:访问需要专门角色、必须填写理由、访问行为本身被记录。
8. 延伸与锚点
- 思考题:超时、重试、熔断、循环检测、权限、审批、审计——这七件事怎么组织成一个可复用的组件?(03B 收官——答案在下一课时)
- 代码锚点:
git checkout ch03-12-audit - 下一课时:03-13 Harness 机制总装
- 对应课件:L03-12 审计与脱敏