一次评分异常的排查在 ELK 里翻了两个小时,问题就出在三个服务各打各的日志。从那以后我明白,日志不是写给自己看的,是写给未来半小时后的自己看的。平台从 Day 1 就定下四件事:统一格式、关键字段必填、TraceID 串联、敏感信息脱敏。下面把这份规范完整沉淀下来。
一、为什么日志规范至关重要
不规范的日志,出的事基本都是这几类:
- 多个微服务写各自的格式,ELK 聚合后字段全乱;
- 出问题时不知道是”用户调用”还是”评分员”还是”自动重试”;
- 日志里直接打印了 API Key,爬到就完蛋;
- 排查一个慢请求,要在几十 GB 日志里翻 trace。
规范的日志能把”定位问题”从小时级压到分钟级。我们落地时没有一步到位,而是按三步推进:
- 先定字段标准和级别约定,所有人按同一份规范写;
- 再统一框架与输出格式,保证日志在 ELK 里长一个样;
- 最后接日志平台与告警,让日志真正被用起来。
这套顺序的核心是先把字段定死再谈工具,顺序反了大概率要返工。
二、日志框架选型
框架选型上我们没纠结太久,团队熟悉 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-TraceIdHeader; - 异步任务(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/实例,超过就是”什么都在打”。