日志与可观测性:Agent 的黑盒怎么透明化

上个月有一次,daily-report-agent 生成了一份日报,写着"本周修复了 3 个 Bug"。但实际只修了 1 个。

我翻代码、查 Git 记录、检查 Prompt,折腾了一个小时。最后发现是 Agent 在分析某个仓库时,把「最近 7 天」当成了「本周」,而那个仓库恰好有老的未合并的修复------Agent 把它也算进去了。

Agent 是一个黑盒------输入进去,输出出来,中间的推理过程完全不可见。

如果是传统程序出错,你加个断点就能看到变量值。Agent 不行------你不知道它在"想"什么。

这篇给你一套方案:用日志 + OpenTelemetry Trace 把 Agent 的每一步透明化。


三个层次的可观测性

层次 回答的问题 工具
日志 发生了什么? slog / zap
指标 是不是正常? Prometheus + Grafana
追踪 每一步做了什么? OpenTelemetry

上一篇文章讲了指标(Prometheus)。这篇重点讲日志和追踪------让你能回答"Agent 为什么给出了这个结果"。


第一层:结构化日志------记录 Agent 的"思考"

Go 1.21 内置的 slog 足够好用了。不引入第三方依赖,结构清晰:

go 复制代码
// internal/middleware/logging.go
package middleware

import (
    "context"
    "log/slog"
    "time"

    "agent-project/internal/agent"
)

// LoggingMiddleware 详细日志中间件
type LoggingMiddleware struct {
    next   agent.Client
    logger *slog.Logger
    name   string // Agent 名称,区分多个 Agent 实例
}

func Logging(next agent.Client, name string) agent.Client {
    return &LoggingMiddleware{
        next:   next,
        logger: slog.Default().With("agent", name),
        name:   name,
    }
}

func (lm *LoggingMiddleware) Chat(
    ctx context.Context, messages []agent.Message,
) (*agent.Response, error) {
    start := time.Now()

    // 记录输入(只记录角色和长度,不记录完整内容以免日志爆炸)
    inputSummary := make([]map[string]interface{}, len(messages))
    for i, m := range messages {
        inputSummary[i] = map[string]interface{}{
            "role":    m.Role,
            "len":     len(m.Content),
            "preview": truncate(m.Content, 100),
        }
    }

    lm.logger.InfoContext(ctx, "Agent 开始调用 LLM",
        "messages_count", len(messages),
        "input_summary", inputSummary,
    )

    resp, err := lm.next.Chat(ctx, messages)
    elapsed := time.Since(start)

    if err != nil {
        lm.logger.ErrorContext(ctx, "Agent LLM 调用失败",
            "elapsed_ms", elapsed.Milliseconds(),
            "error", err.Error(),
        )
        return nil, err
    }

    // 记录 Tool 调用
    if resp.ToolCall != nil {
        lm.logger.InfoContext(ctx, "Agent 决定调用 Tool",
            "tool_name", resp.ToolCall.Name,
            "tool_input", truncate(resp.ToolCall.Input, 200),
            "elapsed_ms", elapsed.Milliseconds(),
            "tokens_in", resp.Usage.InputTokens,
            "tokens_out", resp.Usage.OutputTokens,
        )
    } else {
        lm.logger.InfoContext(ctx, "Agent 返回最终答案",
            "output_preview", truncate(resp.Text, 200),
            "elapsed_ms", elapsed.Milliseconds(),
            "tokens_in", resp.Usage.InputTokens,
            "tokens_out", resp.Usage.OutputTokens,
        )
    }

    return resp, nil
}

func truncate(s string, maxLen int) string {
    runes := []rune(s)
    if len(runes) <= maxLen {
        return s
    }
    return string(runes[:maxLen]) + "..."
}

第二层:Agent 思考日志------记录每一轮迭代

光记录 LLM 调用不够。Agent 的核心是"思考→行动→观察→思考"循环。每一轮迭代都要记录:

go 复制代码
// internal/agent/agent.go 中添加
type IterationLog struct {
    Round      int      `json:"round"`
    Thought    string   `json:"thought"`     // Agent 的"思考"内容
    Action     string   `json:"action"`      // 要执行的 Tool
    ActionInput string  `json:"action_input"` // Tool 的参数
    Observation string  `json:"observation"` // Tool 的返回结果
    TokensIn   int      `json:"tokens_in"`
    TokensOut  int      `json:"tokens_out"`
    DurationMs int64    `json:"duration_ms"`
}

func (a *Agent) RunWithTrace(ctx context.Context, task string) (*Result, error) {
    var iterations []IterationLog

    for i := 0; i < a.maxIterations; i++ {
        iterStart := time.Now()
        iterLog := IterationLog{Round: i + 1}

        resp, err := a.client.Chat(ctx, messages)
        if err != nil {
            a.logger.Error("Agent 迭代失败",
                "round", i+1,
                "error", err,
            )
            return nil, err
        }

        iterLog.Thought = resp.Text
        iterLog.TokensIn = resp.Usage.InputTokens
        iterLog.TokensOut = resp.Usage.OutputTokens

        if resp.ToolCall != nil {
            iterLog.Action = resp.ToolCall.Name
            iterLog.ActionInput = resp.ToolCall.Input

            toolResult, err := a.executeTool(ctx, resp.ToolCall)
            if err != nil {
                iterLog.Observation = fmt.Sprintf("Tool 错误: %v", err)
            } else {
                iterLog.Observation = toolResult
            }
        }

        iterLog.DurationMs = time.Since(iterStart).Milliseconds()
        iterations = append(iterations, iterLog)

        a.logger.Info("Agent 迭代完成",
            "round", iterLog.Round,
            "action", iterLog.Action,
            "tokens_in", iterLog.TokensIn,
            "tokens_out", iterLog.TokensOut,
            "duration_ms", iterLog.DurationMs,
        )

        // 最终答案
        if resp.ToolCall == nil {
            return &Result{
                Answer:     resp.Text,
                Iterations: iterations,
                TotalTokens: sumTokens(iterations),
            }, nil
        }
    }

    return nil, fmt.Errorf("超过最大迭代次数")
}

第三层:OpenTelemetry Trace------可视化调用链

日志能告诉你"发生了什么",Trace 能告诉你"整个过程长什么样"。一次 Agent 调用可能涉及:

  • 多轮 LLM 调用
  • 多次 Tool 执行
  • 外部 API 调用(搜索、数据库查询等)

用 OpenTelemetry 给每一步打上 Span:

go 复制代码
// internal/middleware/tracing.go
package middleware

import (
    "context"
    "fmt"

    "agent-project/internal/agent"
    "agent-project/internal/llm"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/codes"
    "go.opentelemetry.io/otel/trace"
)

const tracerName = "agent-project"

// TracedLLMClient 给 LLM 调用加 Trace
type TracedLLMClient struct {
    next   agent.Client
    tracer trace.Tracer
}

func NewTracedLLMClient(next agent.Client) agent.Client {
    return &TracedLLMClient{
        next:   next,
        tracer: otel.Tracer(tracerName),
    }
}

func (tc *TracedLLMClient) Chat(
    ctx context.Context, messages []agent.Message,
) (*agent.Response, error) {
    // 创建 Span
    ctx, span := tc.tracer.Start(ctx, "llm.chat",
        trace.WithAttributes(
            attribute.Int("messages.count", len(messages)),
        ),
    )
    defer span.End()

    resp, err := tc.next.Chat(ctx, messages)

    if err != nil {
        span.SetStatus(codes.Error, err.Error())
        span.RecordError(err)
        return nil, err
    }

    // 记录关键属性
    span.SetAttributes(
        attribute.Int("tokens.input", resp.Usage.InputTokens),
        attribute.Int("tokens.output", resp.Usage.OutputTokens),
        attribute.String("model", resp.Model),
        attribute.String("stop_reason", resp.StopReason),
    )

    // 如果有 Tool 调用,记录
    if resp.ToolCall != nil {
        span.SetAttributes(
            attribute.String("tool.name", resp.ToolCall.Name),
            attribute.String("tool.input", truncateAttr(resp.ToolCall.Input, 500)),
        )
    }

    return resp, err
}

func truncateAttr(s string, maxLen int) string {
    if len(s) <= maxLen {
        return s
    }
    return s[:maxLen] + "..."
}

初始化 OpenTelemetry(在 main.go 里):

go 复制代码
import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/sdk/trace"
    "go.opentelemetry.io/otel/sdk/resource"
    semconv "go.opentelemetry.io/otel/semconv/v1.21.0"
)

func initTracer() (*trace.TracerProvider, error) {
    exporter, err := otlptracegrpc.New(context.Background(),
        otlptracegrpc.WithEndpoint("localhost:4317"),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    tp := trace.NewTracerProvider(
        trace.WithBatcher(exporter),
        trace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceName("daily-report-agent"),
        )),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

在 Grafana 里看到的 Trace 效果:

复制代码
Agent.Run (2.3s)
  ├── llm.chat (0.8s) --- "分析提交记录"
  │   └── response: ToolCall → git_log
  ├── tool.execute: git_log (0.3s)
  │   └── result: 7 commits found
  ├── llm.chat (0.6s) --- "生成日报摘要"
  │   └── response: ToolCall → generate_report
  ├── tool.execute: generate_report (0.2s)
  │   └── result: markdown document
  └── llm.chat (0.4s) --- "最终审阅"
      └── response: final answer

每一步花了多少钱、花了多少 Token、调了什么 Tool------全部可视。


生产排障实战:一个真实案例

有一天 Grafana 显示 Agent 的 LLM 调用次数突然翻倍:

复制代码
正常:每天 30 次调用,¥0.36
异常:每天 60 次调用,¥0.72

打开 Grafana Trace 一看,每个任务都在第 3 轮和第 4 轮之间反复循环------Agent 调了一个 Tool,返回结果后不满意,又调了一次同一个 Tool。

原因是那天仓库里的 Commit 格式不规范,Agent 解析失败后重试,但重试方法有 Bug------它没把上一次失败的上下文传进去。

没有 Trace,这个问题可能要花一天排查。有 Trace,10 分钟定位,5 分钟修复。

Bug fix 只有一行:

go 复制代码
// 修复前
messages = messages[:len(messages)-1] // 丢弃了失败反馈

// 修复后
messages = append(messages, Message{
    Role: "tool",
    Content: toolResult,
    ToolCallID: resp.ToolCall.ID,
}) // 保留失败反馈,Agent 下次不会重蹈覆辙