Week 4 Day 23:Telemetry + Metrics - 让Agent系统透明可观测

💡 没有可观测性的系统就是黑盒:线上出问题只能靠猜。今天把Agent变成玻璃盒——每一次LLM调用、每一个tool执行都能追踪。

第一部分:问题驱动

🤔 问题1:为什么日志不够,还需要metrics和traces?

引导问题:

  1. 用户说"Agent变慢了",你从日志里怎么定位?
  2. 一个Agent请求会调LLM 3次、搜索2次、写DB 1次。日志怎么关联它们?
  3. 想知道"本月OpenAI费用多少",grep日志?

答案揭示:三大支柱各司其职

支柱 回答什么问题 例子
Logs 发生了什么(离散事件) "user 123 sent message"
Metrics 系统现在怎么样(聚合数值) p99延迟2s, QPS 50, error rate 0.5%
Traces 一次请求经历了什么(因果链) req-abc → LLM(200ms) → search(50ms) → LLM(300ms)

三者缺一不可:

  • 只有Logs:搜不动大量数据
  • 只有Metrics:知道慢但不知道为什么
  • 只有Traces:不知道整体趋势

🤔 问题2:为什么选OpenTelemetry?

引导问题:

  1. 2018年前:New Relic/Datadog/Zipkin各有各的SDK,换方案要重写代码
  2. 你想今天用Jaeger,明年换成Datadog,代码能不变吗?

答案:OTEL = 标准化API + Vendor-neutral

应用代码 → OTEL SDK (统一API) → OTLP协议 → [Jaeger | Datadog | Tempo | ...]

你代码里只写tracer.Start(),要换后端改config就行。

OTEL三大组件:

  • Tracer:打trace
  • Meter:记录metrics
  • Logger:结构化日志(较新)

🤔 问题3:一个Agent请求应该有多少span?

引导问题:

  1. 请求进来→拉历史→embedding→vector search→LLM→tool→LLM→返回,要追踪哪几步?
  2. span太细会影响性能,太粗又定位不到问题?

答案揭示:

推荐span粒度:

agent.handle_request (root span, total latency) ├── db.fetch_history (Postgres query) ├── llm.embedding (OpenAI embedding) ├── vector.search (Qdrant) ├── llm.chat (iter 1) (第1次LLM调用) │ └── tokens, cost ├── tool.search (tool call) │ └── http.request ├── llm.chat (iter 2) └── db.save_message

原则:每个外部调用 + 每个关键决策点 = 一个span


第二部分:动手实现

✅ 版本1:初始化OTEL

// internal/telemetry/tracer.go
package telemetry

import (
    "context"
    "fmt"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func InitTracer(ctx context.Context, serviceName, jaegerEndpoint string) (func(context.Context) error, error) {
    // 1. Exporter:把span发给Jaeger(走OTLP协议)
    exp, err := otlptracegrpc.New(ctx,
        otlptracegrpc.WithEndpoint(jaegerEndpoint),
        otlptracegrpc.WithInsecure(),
    )
    if err != nil {
        return nil, fmt.Errorf("otlp exporter: %w", err)
    }

    // 2. Resource:标识这个服务
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName(serviceName),
            semconv.ServiceVersion("0.1.0"),
            attribute.String("environment", "dev"),
        ),
    )
    if err != nil {
        return nil, err
    }

    // 3. Provider:管理tracer
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exp),
        sdktrace.WithResource(res),
        sdktrace.WithSampler(sdktrace.AlwaysSample()), // 开发环境全采样
    )

    otel.SetTracerProvider(tp)
    otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
        propagation.TraceContext{},
        propagation.Baggage{},
    ))

    return tp.Shutdown, nil
}

main.go集成:

func main() {
    ctx := context.Background()

    shutdown, err := telemetry.InitTracer(ctx, "agent-api", "jaeger:4317")
    if err != nil {
        log.Fatalf("init tracer: %v", err)
    }
    defer shutdown(ctx)

    // ... 启动server
}

生产注意:

  • AlwaysSample()会打爆后端,生产用TraceIDRatioBased(0.01)(1%采样)
  • WithBatcher会攒批发送,减少网络开销

✅ 版本2:给Agent核心路径打span

// internal/agent/agent.go
package agent

import (
    "context"

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

var tracer = otel.Tracer("agent")

func (a *Agent) HandleRequest(ctx context.Context, req Request) (Response, error) {
    // Root span
    ctx, span := tracer.Start(ctx, "agent.handle_request",
        trace.WithAttributes(
            attribute.String("user.id", req.UserID),
            attribute.String("conversation.id", req.ConvID),
            attribute.Int("message.length", len(req.Message)),
        ),
    )
    defer span.End()

    // 1. 拉历史
    history, err := a.fetchHistory(ctx, req.ConvID)
    if err != nil {
        span.SetStatus(codes.Error, "fetch history failed")
        span.RecordError(err)
        return Response{}, err
    }

    // 2. Loop
    for iter := 0; iter < a.maxIter; iter++ {
        resp, err := a.callLLM(ctx, history, iter)
        if err != nil {
            span.RecordError(err)
            return Response{}, err
        }

        if resp.ToolCall == nil {
            span.SetAttributes(attribute.Int("iterations", iter+1))
            return Response{Answer: resp.Content}, nil
        }

        toolResult, err := a.executeTool(ctx, resp.ToolCall)
        if err != nil {
            span.RecordError(err)
            return Response{}, err
        }

        history = append(history, resp.Message, toolResult.Message)
    }

    span.SetStatus(codes.Error, "max iterations reached")
    return Response{}, ErrMaxIter
}

func (a *Agent) callLLM(ctx context.Context, history []Message, iter int) (LLMResponse, error) {
    ctx, span := tracer.Start(ctx, "llm.chat",
        trace.WithAttributes(
            attribute.String("llm.model", a.model),
            attribute.Int("llm.iteration", iter),
            attribute.Int("llm.history.length", len(history)),
        ),
    )
    defer span.End()

    resp, err := a.llm.Chat(ctx, history)
    if err != nil {
        span.RecordError(err)
        return LLMResponse{}, err
    }

    // 记录token使用(成本分析用)
    span.SetAttributes(
        attribute.Int("llm.tokens.input", resp.Usage.InputTokens),
        attribute.Int("llm.tokens.output", resp.Usage.OutputTokens),
        attribute.Float64("llm.cost_usd", estimateCost(a.model, resp.Usage)),
    )

    return resp, nil
}

func (a *Agent) executeTool(ctx context.Context, call *ToolCall) (ToolResult, error) {
    ctx, span := tracer.Start(ctx, "tool.execute",
        trace.WithAttributes(
            attribute.String("tool.name", call.Name),
        ),
    )
    defer span.End()

    result, err := a.tools.Execute(ctx, call)
    if err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, "tool failed")
        return ToolResult{}, err
    }

    span.SetAttributes(
        attribute.Bool("tool.success", true),
        attribute.Int("tool.result.length", len(result.Content)),
    )
    return result, nil
}

关键模式:

  • 每个外部调用打span
  • 入参/出参用SetAttributes记录(注意不要记录敏感信息)
  • 错误用RecordError + SetStatus(codes.Error, ...)
  • defer span.End() 保证无论如何都关闭

✅ 版本3:HTTP Middleware自动打span

// internal/middleware/tracing.go
package middleware

import (
    "net/http"

    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

// 用官方middleware,所有HTTP请求自动有span
func Tracing(handler http.Handler) http.Handler {
    return otelhttp.NewHandler(handler, "http.server")
}
// main.go
router.Use(middleware.Tracing)

每个HTTP请求自动生成root span,包含method、path、status code。


✅ 版本4:Metrics(Prometheus风格)

// internal/telemetry/metrics.go
package telemetry

import (
    "context"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/metric"
)

var meter = otel.Meter("agent")

type Metrics struct {
    RequestCounter    metric.Int64Counter
    LLMLatency        metric.Float64Histogram
    ToolLatency       metric.Float64Histogram
    TokenUsage        metric.Int64Counter
    ErrorCounter      metric.Int64Counter
}

func NewMetrics() (*Metrics, error) {
    reqCounter, err := meter.Int64Counter(
        "agent.requests.total",
        metric.WithDescription("Total agent requests"),
    )
    if err != nil {
        return nil, err
    }

    llmLatency, err := meter.Float64Histogram(
        "agent.llm.latency",
        metric.WithDescription("LLM call latency in ms"),
        metric.WithUnit("ms"),
    )
    if err != nil {
        return nil, err
    }

    toolLatency, err := meter.Float64Histogram(
        "agent.tool.latency",
        metric.WithDescription("Tool execution latency in ms"),
        metric.WithUnit("ms"),
    )
    if err != nil {
        return nil, err
    }

    tokenUsage, err := meter.Int64Counter(
        "agent.tokens.total",
        metric.WithDescription("Total tokens used"),
    )
    if err != nil {
        return nil, err
    }

    errCounter, err := meter.Int64Counter(
        "agent.errors.total",
        metric.WithDescription("Total errors"),
    )
    if err != nil {
        return nil, err
    }

    return &Metrics{
        RequestCounter: reqCounter,
        LLMLatency:     llmLatency,
        ToolLatency:    toolLatency,
        TokenUsage:     tokenUsage,
        ErrorCounter:   errCounter,
    }, nil
}

使用:

func (a *Agent) callLLM(ctx context.Context, history []Message) (LLMResponse, error) {
    start := time.Now()
    defer func() {
        a.metrics.LLMLatency.Record(ctx, float64(time.Since(start).Milliseconds()),
            metric.WithAttributes(attribute.String("model", a.model)),
        )
    }()

    resp, err := a.llm.Chat(ctx, history)
    if err != nil {
        a.metrics.ErrorCounter.Add(ctx, 1,
            metric.WithAttributes(attribute.String("stage", "llm")),
        )
        return LLMResponse{}, err
    }

    a.metrics.TokenUsage.Add(ctx, int64(resp.Usage.TotalTokens),
        metric.WithAttributes(
            attribute.String("model", a.model),
            attribute.String("type", "total"),
        ),
    )

    return resp, nil
}

✅ 版本5:docker-compose加jaeger + prometheus + grafana

# 加到docker-compose.yml
  jaeger:
    image: jaegertracing/all-in-one:1.54
    ports:
      - "16686:16686"  # UI
      - "4317:4317"    # OTLP gRPC
    environment:
      - COLLECTOR_OTLP_ENABLED=true

  prometheus:
    image: prom/prometheus:v2.50.0
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana:10.4.0
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana_data:/var/lib/grafana
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'agent-api'
    static_configs:
      - targets: ['api:8080']
    metrics_path: '/metrics'

API暴露/metrics端点(用otelprom桥接)。


第三部分:关键概念

Context Propagation

核心机制:trace信息通过context.Context在函数间传递。

// ❌ 错误:开新span没传ctx
func (a *Agent) callLLM() {
    ctx, span := tracer.Start(context.Background(), "llm.chat")
    // 这个span和父span无关联!
}

// ✅ 正确:传递ctx
func (a *Agent) callLLM(ctx context.Context) {
    ctx, span := tracer.Start(ctx, "llm.chat")
    // span会自动挂到ctx里的parent span下
}

跨服务传递:HTTP header里的traceparent带trace ID,OTEL的propagator自动读写。

Sampling策略

// 开发:全采样
sdktrace.AlwaysSample()

// 生产:概率采样
sdktrace.TraceIDRatioBased(0.01)  // 1%

// 生产:父级决定(推荐)
sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.01))

原则:错误请求一定要采样,正常请求抽样。

关键Metrics要监控什么

业务层: - agent.requests.total (by status) # 总请求数 - agent.tokens.total (by model) # token消耗 - agent.iterations.histogram # 循环次数分布 性能层: - agent.llm.latency (p50, p95, p99) - agent.tool.latency (p50, p95, p99) by tool_name - agent.end_to_end.latency 错误层: - agent.errors.total (by type) - agent.timeouts.total 资源层(自动有): - go_memstats_alloc_bytes - go_goroutines - http_server_requests_duration

Grafana Dashboard基础面板

1. 请求量:sum(rate(agent_requests_total[5m])) 2. 错误率:sum(rate(agent_errors_total[5m])) / sum(rate(agent_requests_total[5m])) 3. p99延迟:histogram_quantile(0.99, rate(agent_llm_latency_bucket[5m])) 4. Token消耗:sum(rate(agent_tokens_total[5m])) by (model) 5. 成本预估:token量 × 单价

第四部分:自测清单

  • Logs/Metrics/Traces各解决什么问题?
  • span的parent-child关系怎么建立?
  • 为什么生产环境不能AlwaysSample?
  • HTTP请求之间怎么传递trace?
  • Counter和Histogram的区别?

第五部分:作业

任务1:接入OTEL

  • Init tracer,发往Jaeger
  • /chat handler打root span
  • LLM调用、tool调用各自打span

任务2:关键metrics

  • 实现5个核心metrics
  • 暴露/metrics端点
  • Prometheus能抓到数据

任务3:Jaeger验证

  • 发一个请求到/chat
  • Jaeger UI(localhost:16686)能看到完整trace
  • 确认span有正确的parent-child

任务4:Grafana Dashboard

  • 创建4个基础面板(QPS、错误率、p99、token消耗)
  • 截图保存

第六部分:常见问题解答

Q: span太多会不会影响性能?

A: 每个span约几微秒开销,主要瓶颈在exporter网络IO。用BatchProcessor攒批 + 低采样率即可。

Q: 怎么debug没有trace的情况?

A:

  1. 检查InitTracer是否成功
  2. 检查otel.SetTracerProvider(tp)是否调用
  3. 看Jaeger collector日志
  4. stdouttrace exporter打到stdout

Q: 敏感信息能打进span吗?

A: 绝对不要!user_id、conversation_id可以;用户消息内容、API key、PII数据不能。用ScrubDatahook过滤。

Q: Prometheus和OTEL Metrics什么关系?

A: OTEL Metrics是标准,可以导出为Prometheus格式。现代做法:代码用OTEL API,backend用Prometheus。

Q: p99延迟突然高怎么排查?

A:

  1. Grafana确认是全部慢还是少数慢
  2. Jaeger过滤慢trace(>2s)
  3. 看span breakdown,定位哪一步慢
  4. 查那段时间的logs

配套算法题

题1:Unique Paths (Medium)

题目: m×n 网格,从左上角到右下角,每次只能向右或向下走一格,共有多少种不同路径?

思路: 经典二维 DP。dp[i][j] 表示到达格子 (i,j) 的路径数,转移方程 dp[i][j] = dp[i-1][j] + dp[i][j-1],首行首列全为 1(只能直走)。

package main

import "fmt"

func uniquePaths(m, n int) int {
    // dp[i][j] = 到达 (i,j) 的路径数
    dp := make([][]int, m)
    for i := range dp {
        dp[i] = make([]int, n)
        dp[i][0] = 1 // 第一列只能从上往下
    }
    for j := 0; j < n; j++ {
        dp[0][j] = 1 // 第一行只能从左往右
    }
    for i := 1; i < m; i++ {
        for j := 1; j < n; j++ {
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
        }
    }
    return dp[m-1][n-1]
}

func main() {
    fmt.Println(uniquePaths(3, 7)) // 28
    fmt.Println(uniquePaths(3, 2)) // 3
}

复杂度: 时间 O(m×n),空间 O(m×n);滚动数组可优化到 O(n)。

变体/面试追问:

  1. 如果网格中有障碍物(值为 1),路径不能经过,怎么处理?(遇到障碍格 dp[i][j]=0 即可)
  2. 如何用数学组合公式 C(m+n-2, m-1) 在 O(min(m,n)) 时间求解?

题2:Maximum Product Subarray (Medium)

题目: 给一个整数数组 nums,找连续子数组的最大乘积,返回该乘积。

思路: 由于负数×负数会变正,不能只维护最大值。需要同时维护当前最大值 maxP 和当前最小值 minP。遇到负数时先交换两者,再分别更新。

package main

import "fmt"

func maxProduct(nums []int) int {
    if len(nums) == 0 {
        return 0
    }
    maxP, minP, result := nums[0], nums[0], nums[0]
    for i := 1; i < len(nums); i++ {
        n := nums[i]
        if n < 0 {
            // 负数乘以最小值可能得到最大值,先交换
            maxP, minP = minP, maxP
        }
        // 要么以 n 重新开始,要么延续之前的乘积
        if maxP*n > n {
            maxP = maxP * n
        } else {
            maxP = n
        }
        if minP*n < n {
            minP = minP * n
        } else {
            minP = n
        }
        if maxP > result {
            result = maxP
        }
    }
    return result
}

func main() {
    fmt.Println(maxProduct([]int{2, 3, -2, 4}))    // 6
    fmt.Println(maxProduct([]int{-2, 0, -1}))       // 0
    fmt.Println(maxProduct([]int{-2, 3, -4}))       // 24
}

复杂度: 时间 O(n),空间 O(1)。

变体/面试追问:

  1. 如果允许删除一个元素,最大乘积怎么求?(前缀积×后缀积,枚举删除位置)
  2. 数组里有 0 的情况下,maxP/minP 的边界如何保证正确性?(0 会重置乘积链,代码里 max(n, ...) 自然处理)

题3:Climbing Stairs (Easy)

题目: 爬 n 级台阶,每次可以爬 1 或 2 级,有多少种不同的爬法?

思路: 到第 n 级只能从第 n-1 级跨 1 步或从第 n-2 级跨 2 步到达,因此 f(n) = f(n-1) + f(n-2)——斐波那契变体。用滚动变量做到 O(1) 空间。

package main

import "fmt"

func climbStairs(n int) int {
    if n <= 2 {
        return n
    }
    // a = f(i-2), b = f(i-1)
    a, b := 1, 2
    for i := 3; i <= n; i++ {
        a, b = b, a+b
    }
    return b
}

func main() {
    fmt.Println(climbStairs(2))  // 2
    fmt.Println(climbStairs(3))  // 3
    fmt.Println(climbStairs(10)) // 89
}

复杂度: 时间 O(n),空间 O(1)。

变体/面试追问:

  1. 如果每次可以跨 1、2 或 3 步呢?(f(n) = f(n-1) + f(n-2) + f(n-3),同理滚动三个变量)
  2. 矩阵快速幂能将时间压缩到 O(log n),面试中如何描述思路?

下一步:Day 24 预告

明天我们会(难度⚠️):

  1. 构建自动化eval runner CLI
  2. 基于Day 12的dataset跑回归测试
  3. 阈值判断pass/fail
  4. GitHub Actions CI集成:PR触发eval、结果回写comment

准备问题:

  • 为什么手动测试不够,需要自动eval?
  • Eval应该放在CI哪个阶段?
  • pass/fail的阈值怎么定?