
你的 Agent 链跑了 47 秒花了 $0.87 —— 但钱到底花在哪一步哪个模型调用是瓶颈本文用 Go OpenTelemetry 从零搭建 LLM 调用全链路追踪系统。一、为什么 LLM 应用需要专门的观测体系传统微服务有成熟的观测三板斧Metrics / Traces / Logs但 LLM 应用有三个独特需求是 Prometheus Grafana 的通用模板覆盖不了的需求传统 APM 能解决LLM 专属痛点Token 用量归因❌一次 Agent 链可能触发 20 次 LLM 调用需要知道哪个 prompt 最烧钱延迟拆解❌网络 RTT vs 首 token 延迟 vs 生成延迟 — 需要拆到这三个维度模型对比❌A/B 两个模型跑同一批请求需要按模型维度聚合质量指标工具调用链路部分Agent → LLM → tool_call → LLM → tool_result → LLM → response的多跳追踪本节我们用 Go 实现一个可插拔的 LLM 观测层核心设计原则零侵入— 不改业务代码通过http.RoundTripper拦截所有 LLM HTTP 请求OpenTelemetry 原生— Span 可导出到 Jaeger / Grafana Tempo / Datadog成本实时归因— 每个 Span 带 Token 用量和$成本二、核心架构┌─────────────────────────────────────────────────────┐ │ Your Agent Code │ │ agent.Run(ctx, 帮我分析这个 PR) │ └────────────────────┬────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ ObservableRoundTripper │ │ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │ │ │ 创建 Span │→│ 注入 W3C │→│ http.RoundTrip() │ │ │ │ │ │ TraceCtx │ │ │ │ │ └──────────┘ └───────────┘ └────────┬─────────┘ │ │ │ │ │ ┌──────────┐ ┌───────────┐ ┌───────▼─────────┐ │ │ │ 计算成本 │←│ 解析响应 │←│ OpenAI / Claude │ │ │ │ 设置属性 │ │ Token 用量 │ │ HTTP Response │ │ │ └──────────┘ └───────────┘ └─────────────────┘ │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ OTLP Exporter → Jaeger / Grafana │ │ 每条 Trace: │ │ ├── agent.run (root) │ │ │ ├── llm.call (modelgpt-4o, tokens3200) │ │ │ ├── tool.search_code │ │ │ ├── llm.call (modelgpt-4o-mini, tokens800) │ │ │ └── llm.call (modelgpt-4o, tokens2100) │ └─────────────────────────────────────────────────────┘三、完整实现3.1 依赖go get go.opentelemetry.io/otel go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go get go.opentelemetry.io/otel/sdk/trace go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp3.2 核心类型LLMSpan// llmspan/span.gopackagellmspanimport(contextencoding/jsonionet/httpstringstimego.opentelemetry.io/otelgo.opentelemetry.io/otel/attributego.opentelemetry.io/otel/codesgo.opentelemetry.io/otel/trace)// LLMCallSpan 封装一次 LLM 调用的所有观测数据typeLLMCallSpanstruct{span trace.Span providerstringmodelstringstartAt time.Time}// StartLLMCall 创建 LLM Span提取 provider model 信息funcStartLLMCall(ctx context.Context,req*http.Request)(*LLMCallSpan,context.Context){tracer:otel.Tracer(llm-client)provider,model:parseProviderModel(req)ctx,span:tracer.Start(ctx,llm.call,trace.WithAttributes(attribute.String(llm.provider,provider),attribute.String(llm.model,model),attribute.String(llm.url,req.URL.Hostreq.URL.Path),attribute.String(http.method,req.Method),),)returnLLMCallSpan{span:span,provider:provider,model:model,startAt:time.Now()},ctx}// parseProviderModel 从请求体中解析 model 名称支持 OpenAI / Anthropic / 兼容格式funcparseProviderModel(req*http.Request)(provider,modelstring){// 从 URL 推断 providerhost:req.URL.Hostswitch{casestrings.Contains(host,openai):provideropenaicasestrings.Contains(host,anthropic):provideranthropicdefault:providerhost}// 读取请求体获取 model生产环境建议用 buffer poolbodyBytes,err:io.ReadAll(req.Body)iferr!nil{modelunknownreturn}req.Bodyio.NopCloser(strings.NewReader(string(bodyBytes)))// 恢复 bodyvarbodystruct{Modelstringjson:model}ifjson.Unmarshal(bodyBytes,body)nilbody.Model!{modelbody.Model}else{modelunknown}return}// RecordResponse 从 LLM 响应中提取 Token 用量并写入 Spanfunc(l*LLMCallSpan)RecordResponse(resp*http.Response,respBody[]byte,errerror){deferl.span.End()duration:time.Since(l.startAt)l.span.SetAttributes(attribute.Float64(llm.duration_ms,float64(duration.Milliseconds())))// 记录 HTTP 状态ifresp!nil{l.span.SetAttributes(attribute.Int(http.status_code,resp.StatusCode))}// 处理错误iferr!nil{l.span.RecordError(err)l.span.SetStatus(codes.Error,err.Error())return}ifresp.StatusCode400{l.span.SetStatus(codes.Error,resp.Status)return}// 解析 Token 用量usage:parseUsage(respBody,l.provider)ifusage!nil{l.span.SetAttributes(attribute.Int(llm.usage.prompt_tokens,usage.PromptTokens),attribute.Int(llm.usage.completion_tokens,usage.CompletionTokens),attribute.Int(llm.usage.total_tokens,usage.TotalTokens),attribute.Float64(llm.cost.usd,usage.Cost),)}l.span.SetStatus(codes.Ok,)}// TokenUsage Token 用量 成本typeTokenUsagestruct{PromptTokensintCompletionTokensintTotalTokensintCostfloat64}// parseUsage 解析不同 provider 的 token usage 字段funcparseUsage(body[]byte,providerstring)*TokenUsage{varrespstruct{Usagestruct{PromptTokensintjson:prompt_tokensCompletionTokensintjson:completion_tokensTotalTokensintjson:total_tokens}json:usage}iferr:json.Unmarshal(body,resp);err!nil{returnnil}u:TokenUsage{PromptTokens:resp.Usage.PromptTokens,CompletionTokens:resp.Usage.CompletionTokens,TotalTokens:resp.Usage.TotalTokens,}// 成本计算各模型定价u.CostcalculateCost(u.PromptTokens,u.CompletionTokens,provider)returnu}// calculateCost 按模型定价计算成本USDfunccalculateCost(prompt,completionint,providerstring)float64{// 示例定价2025 Q2生产环境应从配置读取prices:map[string]struct{prompt,completionfloat64}{gpt-4o:{2.50,10.00},gpt-4o-mini:{0.15,0.60},claude-3.5-sonnet:{3.00,15.00},claude-3-haiku:{0.25,1.25},deepseek-chat:{0.14,0.28},}p,ok:prices[provider]if!ok{pprices[gpt-4o-mini]// 默认按低价算}return(float64(prompt)/1e6)*p.prompt(float64(completion)/1e6)*p.completion}3.3 HTTP Transport 拦截层// llmspan/transport.gopackagellmspanimport(bytesionet/http)// ObservableTransport 包装 http.RoundTripper自动为所有 LLM 请求创建 SpantypeObservableTransportstruct{base http.RoundTripper}// NewObservableTransport 创建可观测的 HTTP TransportfuncNewObservableTransport(base http.RoundTripper)*ObservableTransport{ifbasenil{basehttp.DefaultTransport}returnObservableTransport{base:base}}func(t*ObservableTransport)RoundTrip(req*http.Request)(*http.Response,error){lls,ctx:StartLLMCall(req.Context(),req)reqreq.WithContext(ctx)// 调用真正的 HTTP 客户端resp,err:t.base.RoundTrip(req)// 读取响应体获取 token 信息varrespBody[]byteifresp!nilresp.Body!nil{respBody,_io.ReadAll(resp.Body)resp.Body.Close()resp.Bodyio.NopCloser(bytes.NewReader(respBody))}// 记录到 Spanlls.RecordResponse(resp,respBody,err)returnresp,err}3.4 初始化 OTLP Exporter// telemetry/otel.gopackagetelemetryimport(contextfmtosgo.opentelemetry.io/otelgo.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpcgo.opentelemetry.io/otel/propagationgo.opentelemetry.io/otel/sdk/resourcesdktracego.opentelemetry.io/otel/sdk/tracesemconvgo.opentelemetry.io/otel/semconv/v1.21.0)// InitTracer 初始化 OTLP Tracer导出到 Jaeger / Grafana TempofuncInitTracer(ctx context.Context,serviceNamestring)(*sdktrace.TracerProvider,error){endpoint:os.Getenv(OTEL_EXPORTER_OTLP_ENDPOINT)ifendpoint{endpointlocalhost:4317// gRPC}exporter,err:otlptracegrpc.New(ctx,otlptracegrpc.WithEndpoint(endpoint),otlptracegrpc.WithInsecure(),// 开发环境生产用 TLS)iferr!nil{returnnil,fmt.Errorf(create OTLP exporter: %w,err)}tp:sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter),sdktrace.WithResource(resource.NewWithAttributes(semconv.SchemaURL,semconv.ServiceName(serviceName),)),sdktrace.WithSampler(sdktrace.AlwaysSample()),// 生产环境建议用概率采样)otel.SetTracerProvider(tp)otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{},propagation.Baggage{},))returntp,nil}3.5 使用示例// main.gopackagemainimport(contextfmtnet/httptimeyour-project/llmspanyour-project/telemetrygithub.com/sashabaranov/go-openai)funcmain(){ctx:context.Background()// 1. 初始化追踪tp,err:telemetry.InitTracer(ctx,my-agent)iferr!nil{panic(err)}defertp.Shutdown(ctx)// 2. 创建可观测的 OpenAI 客户端obsTransport:llmspan.NewObservableTransport(http.DefaultTransport)httpClient:http.Client{Transport:obsTransport}client:openai.NewClientWithConfig(openai.DefaultConfig(sk-...))// 注入自定义 HTTPClient如果 SDK 支持// 或者直接修改 DefaultConfig 的 HTTPClient// 3. 开始你的 Agent 逻辑tracer:otel.Tracer(agent)ctx,span:tracer.Start(ctx,agent.analyze_pr)deferspan.End()// span.SetAttributes(attribute.String(pr.url, prURL))// ... 调用 LLM每次调用自动生成子 Spanresp,err:client.CreateChatCompletion(ctx,openai.ChatCompletionRequest{Model:gpt-4o,Messages:[]openai.ChatCompletionMessage{{Role:user,Content:Review this code diff...},},})iferr!nil{span.RecordError(err)return}fmt.Printf(Response: %s\nCost: tracked in trace\n,resp.Choices[0].Message.Content)// 完成后在 Jaeger UI 查看完整调用链// http://localhost:16686}四、更进阶构建 Agent 专属仪表盘有了上述 Span 数据你可以在 Grafana 里构建 LLM 专属面板4.1 关键指标 SQLTimestream / ClickHouse / PostgreSQL-- 每个模型的 P50 / P95 / P99 延迟SELECTmodel,quantile(0.50)(duration_ms)asp50,quantile(0.95)(duration_ms)asp95,quantile(0.99)(duration_ms)asp99FROMllm_spansWHEREcreated_atnow()-interval24 hoursGROUPBYmodel;-- 每小时成本趋势SELECTdate_trunc(hour,created_at)ashour,model,sum(cost_usd)astotal_costFROMllm_spansWHEREcreated_atnow()-interval7 daysGROUPBY1,2ORDERBY1;-- 各 Agent 步骤的 Token 消耗分布SELECTspan_name,-- agent.analyze_pr, llm.call, tool.search, etc.sum(prompt_tokens)asprompt,sum(completion_tokens)ascompletion,count(*)ascallsFROMllm_spansWHEREtrace_idIN(SELECTtrace_idFROMllm_spansWHEREspan_nameagent.analyze_pr)GROUPBY1;4.2 自定义 Span 属性加上业务维度// 在业务代码中为 Span 添加业务属性方便按客户/项目聚合span.SetAttributes(attribute.String(business.customer_id,customerID),attribute.String(business.workflow,code_review),attribute.String(llm.quality_score,qualityScore),// 后续评估的结果)这样你就能回答这些问题“昨天 code_review 流程花了多少钱”“哪个客户触发了最多的 LLM 调用”“gpt-4o 在 review 场景的延迟是否比 sonnet 更稳定”五、生产环境 Checklist项目建议采样策略使用TraceIDRatioBased(0.1)— 生产环境 10% 采样可覆盖绝大部分排查场景Span 属性大小不要把完整 prompt / response 写入 Span 属性超过 8KB 会截断。存引用 ID 即可OTLP 端点用 gRPC 而非 HTTP减少连接开销加 retry backoff计费精度按实际 API 返回的 usage 计费不要自己数 tokenTokenizer 差异可达 20%多 Provider抽象parseUsage支持所有 provider 的 response schema写单元测试覆盖六、总结本文实现了一个零侵入的 LLM 调用观测层核心只有三个文件llmspan/span.go— Span 创建 Token 解析 成本计算llmspan/transport.go— HTTP 拦截器自动创建 Spantelemetry/otel.go— OTLP 导出初始化投入约 200 行代码你就拥有了每次 LLM 调用的全链路追踪含 Token 用量和美元成本与 Jaeger / Grafana Tempo 的原生集成按模型、工作流、客户等维度的成本归因这套方案已经在我们的生产环境跑了 3 个月帮助发现了几个隐藏成本泄漏点——比如某个 prompt 模板因为历史遗留的 system message 每次都多烧 3000 tokens。下一步你可以把 Logs 和 Metrics 也接到 OpenTelemetry用otel.SetLogger()otel.SetMeter()实现真正的三合一观测。代码完整可运行依赖见 3.1 节。