Spring AI日志审计:合规要求下的AI调用日志体系建设
Spring AI日志审计:合规要求下的AI调用日志体系建设
适读人群:在金融、医疗、政务等合规敏感领域做AI项目的Java工程师 阅读时长:约16分钟 文章价值:从零搭建符合合规要求的AI调用日志审计体系,附完整代码实现
被监管部门问住的那一刻
小李在一家互联网金融公司做后端架构,今年年初他们上线了一套AI智能风控系统——用LLM分析用户的贷款申请材料,给出风险评估建议。
上线三个月,一切正常。直到监管部门来例行检查,问了一个问题:
"你们的AI系统对用户做了哪些判断?这些判断的依据是什么?能给我们看三个月前某个具体用户的完整AI交互记录吗?"
小李当场懵了。他们的系统有日志,但只有接口级别的request/response,AI调用的prompt、模型版本、token消耗、思考过程——完全没有。
这下麻烦大了。监管要求AI系统必须"可解释、可追溯、可审计"。补记录是来不及的,他们差点因此被叫停业务。
这篇文章,就是为了让你不要在这个地方翻车。
合规AI日志需要记录什么
不同行业的合规要求不同,但AI调用日志有几个通用维度必须覆盖:
核心字段说明:
| 字段分类 | 必须记录 | 建议记录 | 合规场景 |
|---|---|---|---|
| 身份 | 用户ID、操作时间 | IP、设备指纹 | 责任归属 |
| 请求 | 完整prompt、模型名 | prompt版本hash | 行为追溯 |
| 响应 | 完整响应、token数 | 安全评分 | 内容合规 |
| 异常 | 错误码、错误信息 | 重试次数 | 故障分析 |
整体架构设计
为什么用ClickHouse而不是MySQL?AI审计日志有两个特点:写多读少、单条记录很大(prompt可能几千字)。ClickHouse的列存储对这类场景压缩率极高,查询也快。
审计日志实体设计
@Data
@Builder
@TableName("ai_audit_log")
public class AiAuditLog {
/**
* 日志唯一ID(UUIDv7,带时间序)
*/
@TableId(type = IdType.INPUT)
private String logId;
/**
* 业务追踪ID(跨服务关联)
*/
private String traceId;
/**
* 会话ID(多轮对话归组)
*/
private String sessionId;
/**
* 用户标识
*/
private String userId;
/**
* 业务模块(如:LOAN_REVIEW, CUSTOMER_SERVICE)
*/
private String bizModule;
/**
* 模型提供商(openai/anthropic/zhipu)
*/
private String provider;
/**
* 模型名称
*/
private String modelName;
/**
* 系统提示词(MD5 + 完整内容)
*/
private String systemPromptHash;
private String systemPrompt;
/**
* 用户输入(JSON格式,含多轮历史)
*/
private String userMessages;
/**
* AI响应内容
*/
private String responseContent;
/**
* Function调用记录
*/
private String functionCalls;
/**
* Token使用量
*/
private Integer promptTokens;
private Integer completionTokens;
private Integer totalTokens;
/**
* 请求延迟(毫秒)
*/
private Long latencyMs;
/**
* 是否成功
*/
private Boolean success;
/**
* 错误信息(失败时)
*/
private String errorMessage;
/**
* 敏感内容标记
*/
private Boolean sensitiveFlag;
private String sensitiveKeywords;
/**
* 请求时间
*/
private LocalDateTime requestTime;
/**
* 客户端IP
*/
private String clientIp;
/**
* 数据保留标记(合规要求保留N年)
*/
private LocalDate retainUntil;
}核心:Spring AI Advisor实现审计拦截
Spring AI 1.0的Advisor机制是做审计的最佳切入点,不侵入业务代码:
@Component
@Slf4j
public class AuditAdvisor implements CallAroundAdvisor {
private final AuditLogAsyncWriter auditLogWriter;
private final SensitiveWordDetector sensitiveDetector;
public AuditAdvisor(AuditLogAsyncWriter auditLogWriter,
SensitiveWordDetector sensitiveDetector) {
this.auditLogWriter = auditLogWriter;
this.sensitiveDetector = sensitiveDetector;
}
@Override
public String getName() {
return "AuditAdvisor";
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 最高优先级,最外层拦截
}
@Override
public AdvisedResponse aroundCall(AdvisedRequest advisedRequest,
CallAroundAdvisorChain chain) {
long startTime = System.currentTimeMillis();
String logId = generateLogId();
// 提取审计上下文
AuditContext auditContext = extractAuditContext(advisedRequest);
auditContext.setLogId(logId);
auditContext.setStartTime(startTime);
AdvisedResponse response = null;
Exception error = null;
try {
// 执行实际调用
response = chain.nextAroundCall(advisedRequest);
return response;
} catch (Exception e) {
error = e;
throw e;
} finally {
// 无论成功失败,异步写入审计日志
long latency = System.currentTimeMillis() - startTime;
writeAuditLog(auditContext, response, error, latency);
}
}
private AuditContext extractAuditContext(AdvisedRequest request) {
// 从Spring Security上下文获取用户信息
String userId = getCurrentUserId();
String traceId = MDC.get("traceId");
// 提取prompt内容
String systemPrompt = request.systemText();
List<Message> messages = request.messages();
// 敏感词检测
String userInput = extractUserInput(messages);
SensitiveDetectResult detectResult = sensitiveDetector.detect(userInput);
return AuditContext.builder()
.userId(userId)
.traceId(traceId)
.systemPrompt(systemPrompt)
.systemPromptHash(DigestUtils.md5DigestAsHex(
systemPrompt != null ? systemPrompt.getBytes() : new byte[0]))
.userMessages(toJson(messages))
.sensitiveFlag(detectResult.isHit())
.sensitiveKeywords(String.join(",", detectResult.getKeywords()))
.clientIp(getClientIp())
.build();
}
private void writeAuditLog(AuditContext context,
AdvisedResponse response,
Exception error,
long latencyMs) {
try {
AiAuditLog auditLog = AiAuditLog.builder()
.logId(context.getLogId())
.traceId(context.getTraceId())
.userId(context.getUserId())
.systemPrompt(context.getSystemPrompt())
.systemPromptHash(context.getSystemPromptHash())
.userMessages(context.getUserMessages())
.sensitiveFlag(context.getSensitiveFlag())
.sensitiveKeywords(context.getSensitiveKeywords())
.clientIp(context.getClientIp())
.latencyMs(latencyMs)
.requestTime(LocalDateTime.now().minus(latencyMs, ChronoUnit.MILLIS))
.retainUntil(LocalDate.now().plusYears(5)) // 保留5年
.build();
if (response != null && response.response() != null) {
// 成功响应
var chatResponse = response.response();
var result = chatResponse.getResult();
auditLog.setSuccess(true);
auditLog.setResponseContent(result.getOutput().getContent());
var usage = chatResponse.getMetadata().getUsage();
if (usage != null) {
auditLog.setPromptTokens((int) usage.getPromptTokens());
auditLog.setCompletionTokens((int) usage.getGenerationTokens());
auditLog.setTotalTokens((int) usage.getTotalTokens());
}
auditLog.setModelName(chatResponse.getMetadata().getModel());
} else if (error != null) {
// 失败情况
auditLog.setSuccess(false);
auditLog.setErrorMessage(error.getMessage());
}
// 异步写入,不阻塞业务
auditLogWriter.writeAsync(auditLog);
} catch (Exception e) {
// 审计日志写入失败不应影响业务,但要报警
log.error("审计日志写入失败!logId={}", context.getLogId(), e);
// TODO: 发送告警
}
}
private String generateLogId() {
// 使用时间戳前缀,方便按时间分区查询
return System.currentTimeMillis() + "-" + UUID.randomUUID().toString().replace("-", "");
}
private String getCurrentUserId() {
try {
return SecurityContextHolder.getContext()
.getAuthentication()
.getName();
} catch (Exception e) {
return "anonymous";
}
}
}异步写入模块
审计日志必须异步写,不能让日志IO拖慢AI响应:
@Component
@Slf4j
public class AuditLogAsyncWriter {
private final BlockingQueue<AiAuditLog> logQueue;
private final AuditLogRepository repository;
private final ExecutorService executorService;
public AuditLogAsyncWriter(AuditLogRepository repository) {
this.repository = repository;
this.logQueue = new LinkedBlockingQueue<>(10000); // 缓冲队列
this.executorService = Executors.newFixedThreadPool(
2,
new ThreadFactoryBuilder().setNameFormat("audit-writer-%d").build()
);
// 启动后台消费线程
startBatchWriter();
}
public void writeAsync(AiAuditLog log) {
boolean offered = logQueue.offer(log);
if (!offered) {
// 队列满了,降级为同步写入并告警
log.warn("审计日志队列已满,降级同步写入: logId={}", log.getLogId());
writeDirect(log);
}
}
private void startBatchWriter() {
executorService.submit(() -> {
List<AiAuditLog> batch = new ArrayList<>(100);
while (true) {
try {
// 每500ms或积累100条,批量写入一次
AiAuditLog log = logQueue.poll(500, TimeUnit.MILLISECONDS);
if (log != null) {
batch.add(log);
logQueue.drainTo(batch, 99); // 最多再取99条
}
if (!batch.isEmpty()) {
writeBatch(batch);
batch.clear();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
log.error("批量写入审计日志失败", e);
batch.clear();
}
}
});
}
private void writeBatch(List<AiAuditLog> logs) {
try {
repository.saveBatch(logs);
log.debug("批量写入审计日志: count={}", logs.size());
} catch (Exception e) {
log.error("批量写入失败,尝试逐条写入", e);
logs.forEach(this::writeDirect);
}
}
private void writeDirect(AiAuditLog log) {
try {
repository.save(log);
} catch (Exception e) {
log.error("审计日志写入失败,日志丢失: logId={}", log.getLogId(), e);
// 最后兜底:写到本地文件
writeToLocalFile(log);
}
}
}在业务代码中注入审计
@Service
@Slf4j
public class LoanReviewService {
private final ChatClient chatClient;
public LoanReviewService(ChatClient.Builder builder,
AuditAdvisor auditAdvisor,
@Value("${loan.review.system-prompt}") String systemPrompt) {
this.chatClient = builder
.defaultSystem(systemPrompt)
.defaultAdvisors(
auditAdvisor, // 注入审计拦截器
new SimpleLoggerAdvisor()
)
.build();
}
public LoanReviewResult reviewApplication(LoanApplication application) {
// 构建审计上下文(通过MDC传递业务信息)
MDC.put("bizModule", "LOAN_REVIEW");
MDC.put("applicationId", application.getId());
try {
String prompt = buildReviewPrompt(application);
String aiAnalysis = chatClient.prompt()
.user(prompt)
.advisors(spec -> spec
.param("userId", application.getApplicantId())
.param("sessionId", application.getId())
)
.call()
.content();
return parseReviewResult(aiAnalysis);
} finally {
MDC.remove("bizModule");
MDC.remove("applicationId");
}
}
private String buildReviewPrompt(LoanApplication application) {
return String.format("""
请对以下贷款申请进行风险评估:
申请人信息:
- 年龄:%d岁
- 月收入:%d元
- 负债率:%.1f%%
- 信用评分:%d
申请金额:%d元
申请期限:%d个月
请从以下维度给出评估:
1. 还款能力(1-10分)
2. 信用风险(低/中/高)
3. 综合建议(通过/拒绝/人工复核)
4. 主要风险点
""",
application.getAge(),
application.getMonthlyIncome(),
application.getDebtRatio(),
application.getCreditScore(),
application.getAmount(),
application.getTerm()
);
}
}审计查询API
监管要求随时能查,得有一个好用的查询接口:
@RestController
@RequestMapping("/audit")
@PreAuthorize("hasRole('AUDIT_ADMIN')")
@Slf4j
public class AuditQueryController {
private final AuditLogQueryService queryService;
@GetMapping("/logs")
public Page<AuditLogVO> queryLogs(
@RequestParam(required = false) String userId,
@RequestParam(required = false) String bizModule,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
AuditQueryCondition condition = AuditQueryCondition.builder()
.userId(userId)
.bizModule(bizModule)
.startDate(startDate.atStartOfDay())
.endDate(endDate.plusDays(1).atStartOfDay())
.page(page)
.size(size)
.build();
Page<AuditLogVO> result = queryService.query(condition);
// 记录查询行为(审计的审计)
log.info("审计日志被查询: operator={}, condition={}",
getCurrentOperator(), condition);
return result;
}
@GetMapping("/logs/{logId}")
public AuditLogDetailVO getLogDetail(@PathVariable String logId) {
return queryService.getDetail(logId);
}
@GetMapping("/stats/token-usage")
public TokenUsageStats getTokenUsage(
@RequestParam String bizModule,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end) {
return queryService.getTokenUsageStats(bizModule, start, end);
}
@GetMapping("/sensitive-alerts")
public Page<AuditLogVO> getSensitiveAlerts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return queryService.querySensitiveLogs(page, size);
}
}数据保留与脱敏
合规日志还有两个关键问题:数据保留多久?敏感信息怎么处理?
@Component
@Slf4j
public class AuditDataLifecycleManager {
private final AuditLogRepository repository;
/**
* 每天凌晨2点清理过期数据
*/
@Scheduled(cron = "0 0 2 * * ?")
public void cleanExpiredLogs() {
int deleted = repository.deleteExpiredLogs(LocalDate.now());
log.info("清理过期审计日志: count={}", deleted);
}
/**
* 脱敏处理:对超过保留期的日志做字段脱敏
* (合规要求:保留元数据,但可脱敏内容)
*/
@Scheduled(cron = "0 0 3 * * ?")
public void anonymizeSensitiveLogs() {
LocalDateTime threshold = LocalDateTime.now().minusYears(2);
int anonymized = repository.anonymizeOldLogs(threshold);
log.info("脱敏历史审计日志: count={}", anonymized);
}
}脱敏工具类:
public class AuditDataAnonymizer {
/**
* 对Prompt中的敏感信息做脱敏
* 保留结构,替换具体数值
*/
public static String anonymizePrompt(String prompt) {
if (prompt == null) return null;
// 手机号脱敏:138****1234
prompt = prompt.replaceAll("1[3-9]\\d{9}", m ->
m.substring(0, 3) + "****" + m.substring(7));
// 身份证脱敏
prompt = prompt.replaceAll("\\d{17}[\\dXx]", "[ID_REDACTED]");
// 银行卡号脱敏
prompt = prompt.replaceAll("\\d{16,19}", "[CARD_REDACTED]");
return prompt;
}
}踩坑总结
在金融项目里落地AI审计,踩过这些坑:
| 问题 | 坑点描述 | 解决方案 |
|---|---|---|
| 日志太大 | 每条日志几KB,MySQL撑不住 | 改用ClickHouse,压缩率10:1 |
| 影响性能 | 同步写日志拖慢响应200ms | 异步批量写,延迟降到5ms以内 |
| 日志丢失 | 应用崩溃时队列中的日志丢了 | Redis做WAL,先写Redis再异步写DB |
| 查询太慢 | 3个月日志,查某用户慢10秒 | ClickHouse按user_id+日期分区 |
| 合规审查 | 监管要看"原始未脱敏"记录 | 分两表:原始表加密存储、查询表脱敏 |
小结
合规不是负担,是护城河。
当你的AI系统能清晰回答"这个用户的贷款被拒,AI给出了什么理由、基于什么信息",这本身就是一个很高的技术门槛。
做AI审计的核心三点:
- 拦截要早:用Advisor在最外层统一拦截,业务代码零改动
- 写入要异步:日志不能拖慢业务,队列+批量写是标配
- 查询要快:ClickHouse比MySQL更适合这类场景
小李后来重新建了这套审计体系,再次面对监管检查,从容地调出了任意时间段任意用户的完整AI交互记录。监管官员都说这套系统做得"比较规范"。
