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"
}
上下文日志:让日志「认得」你是谁
这是结构化日志最强大的地方。想象一下,你的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,整个请求的生命周期就全出来了。
日志聚合:从海量数据中捞针
日志写好了,怎么用?单个日志没意义,聚合起来才有价值。
我常用的聚合模式有几种:
- 错误聚合: 按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产生日志,到最终在Grafana上看到聚合指标,中间每一步都有讲究。但核心就一句话:让日志变得可查询、可聚合、可告警。
总结一下我的经验:
- JSON格式是标配,别再用纯文本了
- 字段设计要克制,通用字段统一,业务字段按需加
- 上下文日志靠trace_id串联,异步环境用contextvars
- 聚合分析要围绕「错误、延迟、调用链」三个维度展开
嗯,结构化日志这块,说白了就是「磨刀不误砍柴工」。前期多花点心思设计好,后面排查问题能省下十倍的时间。我自己经历过太多次「日志一堆,但啥也查不出来」的痛苦,所以现在对日志质量特别较真。