日志规范设计方法详解(AI 大模型评测平台的可观测性基础)

一次评分异常的排查在 ELK 里翻了两个小时,问题就出在三个服务各打各的日志。从那以后我明白,日志不是写给自己看的,是写给未来半小时后的自己看的。平台从 Day 1 就定下四件事:统一格式、关键字段必填、TraceID 串联、敏感信息脱敏。下面把这份规范完整沉淀下来。

一、为什么日志规范至关重要

不规范的日志,出的事基本都是这几类:

  • 多个微服务写各自的格式,ELK 聚合后字段全乱;
  • 出问题时不知道是”用户调用”还是”评分员”还是”自动重试”;
  • 日志里直接打印了 API Key,爬到就完蛋;
  • 排查一个慢请求,要在几十 GB 日志里翻 trace。

规范的日志能把”定位问题”从小时级压到分钟级。我们落地时没有一步到位,而是按三步推进:

  1. 先定字段标准和级别约定,所有人按同一份规范写;
  2. 再统一框架与输出格式,保证日志在 ELK 里长一个样;
  3. 最后接日志平台与告警,让日志真正被用起来。

这套顺序的核心是先把字段定死再谈工具,顺序反了大概率要返工。

二、日志框架选型

框架选型上我们没纠结太久,团队熟悉 Java 生态,Spring Boot 默认带 Logback,就把它定为日志实现,SLF4J 作抽象层,Log4j2 留作备选——不是它不好,而是不需要同时维护两套配置。

框架 优点 平台选型
Logback Spring Boot 默认,配置灵活 ✅ 选
Log4j2 性能更好,异步 Logger 备选
SLF4J 抽象层 ✅ 配合 Logback

选型定了之后,全部配置收敛在 logback-spring.xml 里,dev 环境打控制台,prod 环境打文件加 Kafka,环境差异不散落在业务代码里。

三、日志级别规范

级别规范是最容易被忽视的部分。很多团队 DEBUG 打成习惯,线上也开着 DEBUG,日志量直接翻十倍。我们定了明确的分工:ERROR 一定是影响业务的事,WARN 是可恢复的预期告警,INFO 只打业务关键节点。

级别 用途 平台示例
ERROR 系统异常、影响业务的错误 LLM 调用失败, 数据库连接断开
WARN 可恢复异常、预期内的告警 评分分歧大, 限流触发, token 即将过期
INFO 业务关键节点 用户登录, 评测提交, 批次完成
DEBUG 调试信息(生产默认关) 请求参数, LLM 原始响应
TRACE 最详细信息(仅开发) 每条 chunk

执行上有一条铁律:生产环境的 DEBUG 只能通过配置中心动态打开,不能改代码重新发版,否则没人敢碰这个开关。INFO 默认开启,但量要控制,后面会讲到我们怎么把一天的量压到 1-2 GB。

四、统一字段格式

字段统一是日志规范的地基。我们要求每条日志必须是结构化的,靠 MDC 把 traceId、userId、model、scene 这些上下文自动带进去,业务代码只管打业务信息:

MDC.put("traceId", TraceContext.getTraceId());
MDC.put("userId", String.valueOf(currentUser.getId()));
MDC.put("model", "gpt-4o");
MDC.put("scene", "code-review");
log.info("LLM 调用开始 prompt_len={}", prompt.length());

MDC 里放好字段,logback-spring.xml 的 pattern 里用 %X{traceId} 引用,输出就是下面这种行:

<pattern>
  %d{yyyy-MM-dd HH:mm:ss.SSS} | %-5level | [%X{traceId}] | 
  userId=%X{userId} model=%X{model} scene=%X{scene} | 
  %logger{36} - %msg%n
</pattern>

实际打出来长这样,每一行都自带上下文:

2026-08-20 14:23:11.245 | INFO  | [a1b2c3d4] | userId=10086 model=gpt-4o scene=code-review | 
c.e.s.LlmService - LLM 调用开始 prompt_len=512

ELK 里按 traceId 一筛就是一条完整链路,不用再猜这行日志属于谁。

五、TraceID 串联全链路

单服务日志再规范,跨服务的调用链还是会断。平台一天几十万次 LLM 调用,一次评测要串起 HTTP 入口、MQ、Worker、模型服务、数据库、WebSocket 六个环节,没有 TraceID 就是大海捞针。我们选了 Micrometer Tracing + Brave/Zipkin 这套组合,好处是能跟着框架自动埋点,不用手写透传:

  • 入口(HTTP)生成 traceId 写入 MDC;
  • 通过 Feign/RestTemplate 调用下游时透传 X-B3-TraceId Header;
  • 异步任务(MQ 消费)从消息 header 取 traceId 续上;
  • LLM 调用也带上 traceId 出参(部分 provider 支持 user 字段)。

串起来之后,一次评测的日志长这样——同一个 traceId 贯穿所有环节:

[a1b2c3d4] Controller 接收请求
[a1b2c3d4] MQ 生产任务
[a1b2c3d4] Worker 消费任务
[a1b2c3d4] LLM 调用 (model=gpt-4o, 12s)
[a1b2c3d4] 评分 (judge=claude, 8s)
[a1b2c3d4] DB 写入
[a1b2c3d4] WebSocket 推送给客户端

看到这条链,”为什么这次评分这么低”就从猜谜变成了”哪一段慢”的直线查找。

六、敏感信息脱敏

日志规范里最不能让步的是敏感信息脱敏。API Key、用户邮箱、答案原文一旦进日志,等于把钥匙挂在门口。我们做了三层防护,一层管格式、一层管实体、一层管调用点,缺一不可。

1. Logback Pattern 脱敏

<conversionRule conversionWord="masked" 
                converterClass="com.eval.platform.log.MaskConverter"/>

MaskConverter 用正则匹配 sk-[A-Za-z0-9]{20,} 替换为 sk-***,兜住所有日志输出路径。

2. 注解脱敏

@Sensitive(type = SensitiveType.API_KEY)
private String apiKey;

@Sensitive(type = SensitiveType.EMAIL)
private String userEmail;

toString() / JSON.toJSONString() 时自动替换,实体里躺着的敏感字段不用每次手写过滤。

3. 字段过滤

log.info("请求参数 {}", SensitiveFilter.mask(req));

业务代码在调用点显式过滤,适合那些结构不固定、不好用注解覆盖的场景。三层叠加的效果是:日志文件即使被拖走,攻击者也拿不到可用密钥,只能看到 sk-***。

七、业务日志与异常日志分离

业务日志和异常日志混在一个文件里,告警和检索都会互相干扰。我们把它们拆到不同 appender,业务日志进 ELK 给产品运营看,错误日志单独接告警 webhook:

<appender name="BUSINESS" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>/var/log/eval/business.log</file>
    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
        <level>INFO</level>
    </filter>
</appender>
<appender name="ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>/var/log/eval/error.log</file>
    <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
        <level>ERROR</level>
    </filter>
</appender>

这样值班同学只需要盯 error 流,产品同学按业务日志做分析,两类日志互不污染,告警噪音也小了很多。

八、LLM 调用的特殊日志

LLM 调用是平台最贵的操作,每次调用的成本、耗时、状态都得留下来。我们给每次调用打一条结构化日志,落独立 topic:

log.info("LLM_CALL model={} input_tokens={} output_tokens={} " +
         "fee={} duration_ms={} status={} traceId={}",
    model, in, out, fee, duration, "ok", traceId);

这些数据进 ClickHouse 后,按模型、按用户、按场景统计成本与延迟都是一条 SQL 的事,给采购谈判和容量规划提供了硬数据,也帮我们发现了”某个模型单次调用费比预期高”的异常。

九、踩过的坑

规范落地过程里踩的坑,比顺风顺水的部分更有参考价值:

  • MDC 线程切换丢失:异步任务或 @Async 线程拿不到主线程 MDC,要在切线程前 MDC.getContextMap() 序列化,任务里 MDC.setContextMap() 恢复。
  • traceId 不透传到 MQ:消息生产者写到 header,消费者读不到。平台用 MessagePostProcessor 注入,listener 用 @Header 读取。
  • 日志太大爆磁盘:INFO 级别 + 完整堆栈 + 大对象 toString 一天能打几十 GB。用 RollingFileAppender 按天切 + 保留 7 天。
  • 异步 Logger 丢日志:Log4j2 异步 + 默认 100% 丢失策略,进程崩时未刷盘日志丢。配置 DiscardingOldestPolicy + sync flush。
  • JSON 序列化卡顿:log.info("req={}", JsonUtil.toJson(req)) 在大对象上耗时 50ms+,要限制只打”必要字段”。

这些坑的共同点是对异步链路不够警惕——线程切换、MQ 透传、异步刷盘,每一处都在悄悄断开日志的上下文。

十、日志保留与查询

日志不是存得越久越好,存储成本与查询价值要平衡。平台按类型定了不同的保留期:

类型 保留期 存储
业务日志 30 天 ELK
错误日志 90 天 ELK + S3 冷备份
LLM 调用日志 180 天 ClickHouse
审计日志(敏感操作) 1 年 MySQL + 归档

平台给运营提供 Kibana 看板,给开发提供 LogQL 模板,各查各的、互不干扰。到这里,一份可落地的日志规范就完整了——从级别、格式、链路到脱敏、分离、保留,每一环都有明确规则。

常见问题(FAQ)

Q1:traceId 用 UUID 还是雪花 ID?

都行。平台用 16 字节的 ULID(时间序+随机),方便按时间排序。

Q2:MDC 怎么在 WebSocket 里传递?

握手时把 traceId 存进 WebSocket session attribute,OnMessage 里 MDC.put 后再处理业务。

Q3:怎么避免日志打太多冗余信息?

只打”业务节点”和”异常”,不重复打”中间步骤”。INFO 一天控制在 1-2 GB/实例,超过就是”什么都在打”。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部