• 欢迎关注我的微信公众号“ Falost ” 右边扫描关注 --->>

Agent 可观测性实战:日志、Trace、Debug,生产环境不裸奔

AI / 大模型 神棍 110℃ 0评论

Agent 可观测性实战:日志、Trace、Debug,生产环境不裸奔

写在前面的实话

上篇我拆了 Agent 记忆系统的三层架构——短期记忆靠 Checkpointer、长期记忆用向量库、知识图谱上 Neo4j。写完在本地跑起来之后,我发现一个更头疼的问题:Agent 出了 bug 怎么查?

传统程序出 bug,你打个断点、看下堆栈、复现一下,八九不离十。但 Agent 不一样——同样的 user query,两次调用 LLM 可能给出完全不同的回复。这周花了不少时间调试一个生产环境的 Agent 错误:用户问「帮我查一下上周五订单号 OD20260710 的物流信息」,Agent 调了三个工具,最后一步参数拼错了,返回空结果。用户说「你们的 AI 是智障吗」。

我蹲在终端前,面对几千行日志,脑壳疼。

这篇文章就记录一下我这段时间趟出来的 Agent 可观测性方案——从最基础的日志结构到完整的 Trace 链路和 Debug 工作流。

一、Agent 日志为什么比普通程序难写

先看一个对比。传统后端服务,请求进来 —> 处理 —> 返回,日志是线性的,一条请求一条链路,清晰得很。但 Agent 不一样:

维度 传统后端 Agent 系统
执行路径 确定(代码决定) 不确定(LLM 决定调什么工具)
输出结果 确定(相同输入相同输出) 不确定(温度 > 0 每次不同)
错误来源 代码异常 / 网络 / DB 代码 + LLM 幻觉 + 工具返回 + Prompt 设计
复现难度 低(固定的请求 + 数据即可) 高(依赖 LLM 采样、上下文窗口、Token 顺序)
调试手段 断点 / 堆栈 / 日志 需要 Trace + LLM 调用记录 + Tool 调用记录

一句话总结:Agent 的不确定性使得传统「打日志 — 复现 — 修 bug」的闭环断掉了。你必须换一套思路。

二、我的日志结构:每条记录都要回答五个问题

我从第一天搭 Agent 就踩了一个坑:日志打得太糙。只记了「用户说了什么」和「Agent 回了什么」,中间发生了什么完全不知道。

后来我查了一些实践资料,总结了一个五问日志模板——每条 Agent 日志必须能回答:

  1. 谁调用了谁? — Session ID、User ID、Graph ID
  2. LLM 收到了什么? — 完整 Prompt(含 System Prompt + 历史 + Tool Schema)
  3. LLM 回复了什么? — Raw Completion 文本
  4. Agent 决定做什么? — 调了哪个 Tool、参数是什么
  5. Tool 返回了什么? — Tool 执行结果、状态码、耗时

我写了一个简单的结构化日志函数:

import json, logging, time, uuid
from datetime import datetime

class AgentLogger:
    def __init__(self, session_id=None):
        self.session_id = session_id or str(uuid.uuid4())
        self.logger = logging.getLogger(f"agent.{self.session_id}")
        self.steps = []

    def log_llm_call(self, prompt, completion, model, latency_ms):
        entry = {
            "event": "llm_call",
            "session_id": self.session_id,
            "timestamp": datetime.utcnow().isoformat(),
            "model": model,
            "latency_ms": latency_ms,
            "prompt_preview": prompt[:500],
            "completion": completion,
        }
        self.logger.info(json.dumps(entry, ensure_ascii=False))
        self.steps.append(entry)

    def log_tool_call(self, tool_name, arguments, result, latency_ms):
        entry = {
            "event": "tool_call",
            "session_id": self.session_id,
            "timestamp": datetime.utcnow().isoformat(),
            "tool": tool_name,
            "arguments": arguments,
            "result_preview": str(result)[:300],
            "latency_ms": latency_ms,
        }
        self.logger.info(json.dumps(entry, ensure_ascii=False))
        self.steps.append(entry)

每条日志都是一行 JSON,后面用 jq 或者 ELK 都能解析。生产上我直接用 python-json-logger 这个库打结构化日志,省得自己拼字符串。

三、Trace 链路:把每一步串起来

结构化日志解决了「每步发生了什么」的问题,但还有一个更棘手的:怎么把三步前的 LLM 调用和最终的错误答案串起来?

传统的 trace(比如 OpenTelemetry)用 Span 和 Trace ID 搞定。我直接复用这套标准——每个 Agent Session 就是一个 Trace,每次 LLM 调用和 Tool 调用都是一个 Span。

我用的是 LangSmith——LangChain 官方的可观测性平台,对 LangGraph 天然支持。但如果你没用 LangChain 生态,也可以用 OpenTelemetry 的 Python SDK 手动打 Span。

核心代码示例(用 OpenTelemetry 手动 trace):

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor

# 初始化 Tracer
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("agent-inference")

def run_agent_step(user_input, context):
    with tracer.start_as_current_span("agent_step") as span:
        span.set_attribute("session_id", context.session_id)
        span.set_attribute("user_input", user_input)

        # LLM 调用
        with tracer.start_as_current_span("llm_call") as llm_span:
            response = llm.invoke(user_input)
            llm_span.set_attribute("model", model_name)
            llm_span.set_attribute("latency_ms", elapsed_ms)
            llm_span.set_attribute("completion_length", len(response))

        # Tool 调用(如果有)
        if response.get("tool_calls"):
            for tc in response["tool_calls"]:
                with tracer.start_as_current_span(f"tool_{tc['function']['name']}") as tool_span:
                    tool_result = execute_tool(tc)
                    tool_span.set_attribute("result", str(tool_result)[:200])

        span.set_attribute("final_response", final_answer)

这段代码跑起来之后,你能在 Jaeger、Zipkin 或 LangSmith 的可视化面板上看到一棵 Span 树——从用户输入到最终回复,每一层嵌套都看得一清二楚。

我这边的真实效果:之前排查一个「Agent 重复调用同一工具 7 次」的 bug,看了 Trace 面板一秒定位——原来是因为 LLM 返回的 Tool Call 中有一个参数类型错误,工具返回错误后 LLM 又重试,陷入死循环。

四、可复现性:给 Agent 加个「回放」模式

Agent 调试最让我崩溃的是不可复现。同样的 prompt、同样的工具、甚至同样的温度参数,两次跑结果不一样。怎么回放?

我的做法是:记录 LLM 的 seed + temperature + top_p,保存每次调用的完整 completion

对 OpenAI / Anthropic 类的 API,设置 seed 参数:

response = client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
    temperature=0.7,
    seed=42,           # 设种子,相同输入尽量出相同输出
    logprobs=True,     # 记录每个 token 的概率分布,方便分析
)

注意:seed 只能保证 尽力而为的确定性,不是 100%(OpenAI 文档明确说了)。但足够让你调试同一个 bug 时,大概率复现到相似的行为。

我更推荐的方案是 Prompt + Completion 快照——把每次 LLM 调用的完整请求和响应存下来,回放时可以重新跑或者直接看原始输出分析。

import json
from pathlib import Path

class TraceRecorder:
    def __init__(self, save_dir="traces/"):
        self.save_dir = Path(save_dir)
        self.save_dir.mkdir(exist_ok=True)

    def snapshot(self, session_id, step_idx, request, response, metadata=None):
        record = {
            "session_id": session_id,
            "step_idx": step_idx,
            "timestamp": datetime.utcnow().isoformat(),
            "request": {
                "model": request.get("model"),
                "messages": request.get("messages"),
                "temperature": request.get("temperature"),
                "seed": request.get("seed"),
                "tools": request.get("tools"),
            },
            "response": {
                "id": response.id,
                "choices": [
                    {
                        "index": c.index,
                        "finish_reason": c.finish_reason,
                        "message": c.message.model_dump(),
                        "logprobs": c.logprobs.model_dump() if c.logprobs else None,
                    }
                    for c in response.choices
                ],
                "usage": response.usage.model_dump() if response.usage else None,
            },
            "metadata": metadata or {},
        }
        filepath = self.save_dir / f"{session_id}_{step_idx}.json"
        filepath.write_text(json.dumps(record, ensure_ascii=False, indent=2))
        return filepath

这样每个 Agent 决策步骤都是一个 JSON 文件。回放时重新加载该文件,可以精确复现当时的 Prompt 和 LLM 回复。配合上一步的 Trace,你能把整个会话时间线拉出来逐帧播放。

五、生产环境的部署检查清单

上完前面三个方案,我整理了一个上线前的检查清单,分享出来供参考:

# 检查项 说明
1 结构化的请求日志 每步有 Session ID + Timestamp + 事件类型
2 LLM Prompt & Completion 快照 原始请求与回复存文件,便于回放分析
3 Trace 链路(OpenTelemetry / LangSmith) 可视化 Span 树,快速定位问题在哪一步
4 异常自动告警 连续 N 次 Tool 调用失败 / 超时,触发告警
5 LLM 调用耗时监控 P50 / P95 / P99 延迟,异常尖峰预警
6 Token 消耗统计 按 Session / 用户 / 时间段统计成本
7 确定性 Seed 策略 调试环境设固定 seed,生产环境可调随机

小结

Agent 的可观测性不是可有可无的锦上添花——没有它,Agent 就是黑盒,出了问题你只能拍脑袋猜。我的核心经验就三条:结构化日志打底、Trace 链路串起每步决策、快照机制保证可复现。这三板斧用上之后,我调试 Agent 的效率提升了不止一个量级。

下一篇我会分享多 Agent 协作架构的设计实践——多个 Agent 怎么分工、怎么通信、怎么避免互相打架。

参考文献

转载请注明:Falost的小窝 » Agent 可观测性实战:日志、Trace、Debug,生产环境不裸奔

如果你觉得这篇文章不错或者对你有帮助,想请我喝一杯咖啡,可以打赏
喜欢 (0)
发表我的评论
取消评论

表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址