审计日志:append-only 的合规链路

系列「企业级 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 解决这个问题:

  1. 业务调 Submit() → 写入 channel → 立即返回,不等数据库
  2. 后台 goroutine 每隔 2 秒 或凑满 200 条,批量写入数据库
  3. 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_leveldecision 两列跟前面讲的 HITL(人工审批)直接相关:risk_level='high' 的工具会触发 HITL,审批结果记在 decisionapproved_by 里------审计日志和人工审批在这里闭环。

每月一个物理分区(如 tool_audit_2026_06),查询时 PostgreSQL 自动只扫描对应月份的分区,不用扫全表。

一个专门的 Cron 任务(audit-partition)每天凌晨 2 点运行,确保"当前月 + 未来 3 个月"的分区都提前建好,避免月底新月没分区导致写入失败。

就像图书馆按年份分柜存杂志:2026 年的杂志放 A 柜,2027 年的放 B 柜。查的时候直接去对应的柜子找,不用翻遍整个图书馆。


归档:老数据迁到对象存储

审计数据不能无限保留在数据库里------成本太高。一个 Cron 任务(audit-retention)每天凌晨 3 点运行,把超过保留期的老数据迁走:

复制代码
tool_audit 老数据 → 按月导出为 NDJSON → 上传到对象存储 → 删除数据库中的记录

几个关键细节:

  • 保留期按租户配置 :存在 tenants.quotaretention_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 规则在这里是个合理例外)。


小结

审计日志设计的核心原则:

  1. append-only 是类型约束Entry 没有 Setter,Metadata() 返回拷贝,从代码层面保证不可篡改
  2. AsyncSink 异步批量写:主流程不等落库,channel 满时降级同步写,批量失败后逐条重试,关闭时排空 channel------不丢日志
  3. 月分区 + 归档 :工具调用审计走 tool_audit 月分区表,老数据按租户保留期导出为 NDJSON 到对象存储,在线数据保持高效查询
  4. 两张表各司其职audit_logs(通用审计)+ tool_audit(工具调用专用,有月分区),高频和低频分开处理

下一篇:可观测性 ------ 一条 Agent 请求的完整链路追踪

相关推荐
岳小哥AI1 小时前
AI不是从天而降,它经历了七十年三起三落:通过图灵测试读懂AI
ai·ai基础
陆业聪1 小时前
WebView性能优化与稳定性治理:预热、复用池与崩溃防护
人工智能·aigc
yurenpai(27届找实习中)1 小时前
Spring AI 实战:从零实现 AI 对话的记忆与历史记录管理(附源码级解析)
java·spring·ai·prompt·word
VIP_CQCRE1 小时前
SeeDance 视频生成 API 集成指南
ai
花花少年1 小时前
Ubuntu系统下安装Claude Code
llm·agent·claude code
SZLSDH8 小时前
可视分析与自主决策之间:数字孪生与AI智能体融合的架构演进路径
ai·数字孪生·数据可视化·智能体
花伤情犹在9 小时前
Mac上 10 分钟快速安装Hermes
macos·ai·agent·hermes
码农阿强10 小时前
技术解析:Claude‑Opus‑4‑8 模型原理 + StartAPI 接入实战
ai·aigc·ai编程