系列「企业级 AI Agent 实现拆解」第十二篇。上一篇讲了多租户隔离,这篇看审计日志怎么设计。
为什么普通日志不够
Agent 平台做企业客户,合规是硬需求。客户会问:
- "这个 AI 上周帮某员工执行了什么操作?"
- "哪个用户审批了那次高危工具调用?"
用 slog.Info(...) 回答不了这些问题。普通日志可以改、可以删、可以被日志轮转覆盖,没有完整性保证。合规场景需要的是不可篡改的、结构化的、按时间可查询的审计链。
我们把审计日志设计成独立的 BC(限界上下文),核心约束是 append-only:写进去的记录永远不能修改,只能归档。
你可以把审计日志想象成银行的监控录像:录了就不能剪、不能改,出了事调出来看,平时按月份归档存到仓库里。
Entry 设计:一旦创建不允许修改
审计日志的基本单位是 Entry(一条审计记录)。代码里是这样定义的:
go
// domain/model/entry.go
type Entry struct {
id EntryID
tenantID string
actorID string // 谁做的:user_id / api_key_id / "system"
actorType string // 身份类型:user / api_key / system / ops
category Category // 分类:auth / session / tool / data / ops / billing / security
action string // 做了什么:"session.create" / "tool.invoke"
severity Severity // 严重等级:info / warn / error / alert
target string // 对谁做的:session_id / doc_id / user_id
ipAddr string // 请求来源 IP
userAgent string // 请求来源浏览器/客户端
metadata map[string]any // 扩展信息:{"latency_ms": 120, "old_value": "..."}
occurred time.Time
}
所有字段都是私有的(小写开头),没有任何修改方法(Setter)。构造只能通过工厂函数 NewEntry():
go
func NewEntry(in EntryInput) (*Entry, error) {
if in.Action == "" { return nil, errors.New("action required") }
if in.TenantID == "" { return nil, errors.New("tenant_id required") }
// ...
return &Entry{id: EntryID(idgen.NewString()), ...}, nil
}
连 Metadata() 的返回值都是浅拷贝,防止调用方拿到 map 引用后在外部偷偷改:
go
func (e *Entry) Metadata() map[string]any {
out := make(map[string]any, len(e.metadata))
for k, v := range e.metadata { out[k] = v }
return out
}
就像刻了字的石碑:刻上去的字不能擦掉重写,要读上面的字可以拍照(拷贝),但原件永远不变。
7 个分类,4 个严重等级
每条审计记录都有一个分类标签,告诉系统"这是哪类事件":
| 分类 | 典型事件 |
|---|---|
auth |
登录、登出、token 签发、权限变更 |
session |
会话创建、中断、恢复 |
tool |
工具调用(高频,有独立的分区表) |
data |
KB 写入、Memory 修改、数据导出 |
ops |
冒名顶替、强制下线、改配额 |
billing |
套餐变更、充值、退款 |
security |
可疑登录、越权访问、prompt injection |
严重等级从低到高:
| 等级 | 含义 | 举例 |
|---|---|---|
info |
常规操作 | 用户创建了一个会话 |
warn |
异常但不紧急 | 工具调用失败 |
error |
影响业务的失败 | 支付处理失败 |
alert |
安全/合规事件 | 检测到越权访问,触发实时告警 |
AsyncSink:高频写入不阻塞业务
工具调用是高频事件。一次 ReAct 循环可能有十几次工具调用,如果每次都同步写审计,主流程等待数据库,延迟直接翻倍。
DeepFlux 用 AsyncSink 解决这个问题:
- 业务调
Submit()→ 写入 channel → 立即返回,不等数据库 - 后台 goroutine 每隔 2 秒 或凑满 200 条,批量写入数据库
- channel 满时降级为同步写------宁可慢,不能丢
go
// infrastructure/buffer/async_sink.go
func (s *AsyncSink) Submit(e *model.Entry) {
select {
case s.buf <- e:
// 正常:写入 channel,立即返回
default:
// channel 满了 → 直接同步写数据库,不丢日志
slog.Warn("audit buffer full, falling back to sync write")
_ = s.repo.Append(context.Background(), e)
}
}
还有两道保护机制文章不容易看出来,但代码里都有:
- 批量失败后逐条重试:如果 200 条一次性写入失败,会退回逐条 Append,尽量多保存
- 关闭时排空 channel :服务关闭时,
Close()会先把 channel 里缓冲的所有记录都写完再退出
你可以把 AsyncSink 想象成快递揽收站:平时快递员把包裹丢进暂存箱(channel)就走了,不等包裹上车。货车定期来批量运走(每 2 秒或满 200 件)。但如果暂存箱满了,快递员就不放箱子了,直接把包裹亲手交给货车司机(同步写),确保不丢件。
月分区:工具调用审计的专用表
系统中有两张审计相关的表,各管各的:
| 表名 | 用途 | 特点 |
|---|---|---|
audit_logs |
通用审计 | 记录所有类型的事件(登录、会话、计费等) |
tool_audit |
工具调用专用 | 记录每次工具调用的详细参数、结果、风险等级、HITL 决策 |
tool_audit 因为频率极高(每次 Agent 调用工具都写一条),数据量增长快,所以做了 PostgreSQL 月分区:
sql
-- deploy/sql/010_tool_audit_partition.sql
CREATE TABLE tool_audit (
id uuid,
session_id uuid NOT NULL,
user_id uuid NOT NULL,
tenant_id uuid NOT NULL,
tool_name text NOT NULL,
input_json jsonb NOT NULL, -- 工具输入参数
output_json jsonb, -- 工具输出结果
error_msg text, -- 失败时的错误信息
duration_ms int, -- 执行耗时(毫秒)
risk_level text DEFAULT 'low', -- low / medium / high
hitl_required boolean DEFAULT false,
decision text DEFAULT 'auto',-- auto / approve / modify / reject / timeout
approved_by uuid, -- 审批人(HITL 批准时记录 user_id)
created_at timestamptz DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
risk_level 和 decision 两列跟前面讲的 HITL(人工审批)直接相关:risk_level='high' 的工具会触发 HITL,审批结果记在 decision 和 approved_by 里------审计日志和人工审批在这里闭环。
每月一个物理分区(如 tool_audit_2026_06),查询时 PostgreSQL 自动只扫描对应月份的分区,不用扫全表。
一个专门的 Cron 任务(audit-partition)每天凌晨 2 点运行,确保"当前月 + 未来 3 个月"的分区都提前建好,避免月底新月没分区导致写入失败。
就像图书馆按年份分柜存杂志:2026 年的杂志放 A 柜,2027 年的放 B 柜。查的时候直接去对应的柜子找,不用翻遍整个图书馆。
归档:老数据迁到对象存储
审计数据不能无限保留在数据库里------成本太高。一个 Cron 任务(audit-retention)每天凌晨 3 点运行,把超过保留期的老数据迁走:
tool_audit 老数据 → 按月导出为 NDJSON → 上传到对象存储 → 删除数据库中的记录
几个关键细节:
- 保留期按租户配置 :存在
tenants.quota的retention_days字段,默认 90 天 - 导出格式是 NDJSON(每行一条 JSON 记录)------简单直接,不需要额外依赖
- 上传走 S3 协议:私有化部署用 Garage(S3 兼容对象存储),云上可以换成任何 S3 兼容存储
- 存储路径 :
audit-archive/<tenant_id>/<YYYY-MM>.ndjson - 当前走 DELETE:同一月份内可能还有其他租户的数据没过期,所以不能直接 DROP 分区,只能按租户删除。真正的 O(1) DROP PARTITION 在 roadmap 中
归档就像把去年的监控录像从硬盘搬到磁带库:硬盘(数据库)只保留最近几个月的,老录像(NDJSON 文件)存到磁带库(对象存储),需要时还能调出来。
其他 BC 怎么写审计
其他限界上下文(agent、kb、memory 等)写审计,只需一行调用:
go
// application/command/record.go
func (h *RecordHandler) Handle(ctx context.Context, in model.EntryInput) error {
e, err := model.NewEntry(in) // 构造不可变 Entry
if err != nil {
return err
}
h.sink.Submit(e) // 异步提交,不等落库
return nil
}
调用方传入一个 EntryInput 结构体:
go
model.EntryInput{
TenantID: tenantID,
ActorID: userID,
Category: model.CatTool,
Action: "tool.invoke",
Target: toolName,
Severity: model.SevInfo,
Metadata: map[string]any{"latency_ms": latency},
}
调用方不知道背后有 AsyncSink、有月分区、有归档------这些复杂性全部封装在 audit BC 内部。从 DDD 的角度,这是标准的 command 模式 :RecordHandler 是一个应用层用例,负责编排"构造不可变 Entry → 提交到异步 Sink"这个流程。
值得一提的是,audit BC 自身不产生领域事件------它是事件链的终点(sink),不是起点。所以它不走 outbox pattern(前面文章提到的 D6 规则在这里是个合理例外)。
小结
审计日志设计的核心原则:
- append-only 是类型约束 :
Entry没有 Setter,Metadata()返回拷贝,从代码层面保证不可篡改 - AsyncSink 异步批量写:主流程不等落库,channel 满时降级同步写,批量失败后逐条重试,关闭时排空 channel------不丢日志
- 月分区 + 归档 :工具调用审计走
tool_audit月分区表,老数据按租户保留期导出为 NDJSON 到对象存储,在线数据保持高效查询 - 两张表各司其职 :
audit_logs(通用审计)+tool_audit(工具调用专用,有月分区),高频和低频分开处理
下一篇:可观测性 ------ 一条 Agent 请求的完整链路追踪