3、结构化日志:JSON日志格式、日志字段设计、上下文日志、日志聚合

聊到Agent的可观测性,日志是最基础的手段。但说实话,很多人对日志的理解还停留在「printf大法」的阶段。你想想看,一个Agent系统跑起来,每秒可能产生上千条日志,如果全是散乱的文本,你怎么查问题?

我个人习惯,从一开始就要求团队使用结构化日志。说白了,就是把日志从「给人看」变成「给机器和人都能看」。今天我们就来聊聊这个。

为什么非得是JSON?

我见过不少项目,日志长这样:

2024-01-15 10:30:45 INFO User login success, user_id=12345, ip=192.168.1.1

这种格式,人眼扫一眼还行。但你要用ELK或者Splunk去聚合、过滤、统计,就麻烦了。你得写正则表达式去解析,而且不同开发者的日志格式还不一样,解析规则得维护一堆。

JSON格式的好处,说白了就是自描述。每个字段都有名字,有类型,解析器直接就能用。

{
  "timestamp": "2024-01-15T10:30:45.123Z",
  "level": "INFO",
  "logger": "com.agent.auth",
  "message": "User login success",
  "user_id": 12345,
  "ip": "192.168.1.1",
  "duration_ms": 45,
  "trace_id": "abc-123-def"
}

嗯,这里要注意:JSON日志虽然好,但别把整个请求体都塞进去。我曾经见过有人把用户密码也写进了日志,那真是灾难。敏感信息一定要脱敏。

日志字段设计:少即是多

字段不是越多越好。我建议你遵循一个原则:通用字段 + 业务字段

通用字段,每个日志都必须有:

字段名 类型 说明
timestamp string (ISO 8601) 日志产生时间,带时区
level string DEBUG/INFO/WARN/ERROR
logger string 产生日志的模块或类名
message string 人类可读的描述
trace_id string 用于链路追踪
span_id string 当前操作ID
service string 服务名称
instance string 实例ID或IP

业务字段,根据场景灵活加。比如Agent调用LLM时,我会加这些:

{
  "llm_model": "gpt-4",
  "prompt_tokens": 150,
  "completion_tokens": 80,
  "latency_ms": 2300,
  "retry_count": 0,
  "tool_call": "search_web"
}
我的小技巧: 字段名统一用下划线命名法(snake_case),别混用驼峰。团队里统一风格,后面查日志时能省很多事。

上下文日志:让日志「认得」你是谁

这是结构化日志最强大的地方。想象一下,你的Agent在处理一个用户请求,中间调了3个工具,每个工具又调了2次LLM。如果日志里没有上下文信息,你根本不知道哪条日志属于哪个请求。

我建议的做法是:在请求入口处生成一个trace_id,然后通过线程上下文或协程上下文传递下去

在Python里,我常用contextvars或者logging.LoggerAdapter来实现:

import logging
import uuid
from contextvars import ContextVar

trace_id_var: ContextVar[str] = ContextVar('trace_id', default='')

class ContextLogger(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        extra = kwargs.get('extra', {})
        extra['trace_id'] = trace_id_var.get()
        kwargs['extra'] = extra
        return msg, kwargs

# 使用示例
logger = ContextLogger(logging.getLogger(__name__), {})
trace_id_var.set(str(uuid.uuid4()))
logger.info("开始处理用户请求", extra={"user_id": 123})

这样,所有日志都会自动带上trace_id。你在日志系统里一搜trace_id,整个请求的生命周期就全出来了。

避坑指南: 我曾经在异步代码里吃过亏。用 threading.local 存上下文,结果协程切换时上下文丢了。后来改用 contextvars 才解决。异步环境下,一定要用语言原生的上下文传递机制。

日志聚合:从海量数据中捞针

日志写好了,怎么用?单个日志没意义,聚合起来才有价值。

我常用的聚合模式有几种:

  • 错误聚合: 按error类型和message分组,统计出现频率。这样能快速发现高频错误。
  • 延迟聚合: 按接口或工具名分组,计算P50/P95/P99延迟。Agent调LLM的延迟波动很大,这个指标很关键。
  • 调用链聚合: 按trace_id聚合,还原一次请求的完整路径。哪个环节慢了,一目了然。

举个例子,我想看Agent调用搜索工具的成功率:

{
  "query": "search_web",
  "total_calls": 1200,
  "success_count": 1150,
  "fail_count": 50,
  "avg_latency_ms": 450,
  "p99_latency_ms": 1200,
  "error_types": {
    "timeout": 30,
    "rate_limit": 15,
    "invalid_query": 5
  }
}

这种聚合数据,直接扔到Grafana面板上,团队每天扫一眼就知道系统健康度。

一张图看懂结构化日志体系

下面这张图,是我自己总结的结构化日志核心逻辑。从日志产生到最终可视化,整个链路是这样的:

结构化日志体系流程图 Agent 应用 产生结构化日志 日志收集器 Filebeat / Fluentd 存储与索引 Elasticsearch / Loki UI Grafana 聚合分析层 错误聚合 / 延迟聚合 / 调用链 告警与通知 P99延迟 > 5s 触发告警 上下文传递 trace_id / span_id 从日志产生 → 收集 → 存储 → 聚合 → 告警,形成完整闭环

你看,从Agent产生日志,到最终在Grafana上看到聚合指标,中间每一步都有讲究。但核心就一句话:让日志变得可查询、可聚合、可告警

总结一下我的经验:

  • JSON格式是标配,别再用纯文本了
  • 字段设计要克制,通用字段统一,业务字段按需加
  • 上下文日志靠trace_id串联,异步环境用contextvars
  • 聚合分析要围绕「错误、延迟、调用链」三个维度展开

嗯,结构化日志这块,说白了就是「磨刀不误砍柴工」。前期多花点心思设计好,后面排查问题能省下十倍的时间。我自己经历过太多次「日志一堆,但啥也查不出来」的痛苦,所以现在对日志质量特别较真。


专注资料整理