上个月有一次,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 下次不会重蹈覆辙