LLM 工具调用链路追踪OpenTelemetry 在智能工作流中的落地实践一、Agent 工作流的黑盒困境当 LLM 调用耗时 3 秒到底是哪一环慢了Agent 工作流通常由多个 LLM 调用和工具调用串联而成。一个典型的搜索→分析→总结流水线可能包含三次模型调用和两次搜索引擎调用。当用户抱怨响应太慢时排查变得极其困难——你面对的不是一个单体接口而是一条多跳调用链。Prompt 写得太长导致 Token 消耗多某个工具调用的 API 超时了重试策略触发了三次才成功——没有链路追踪你只能靠猜。传统的 APM 工具如 New Relic、Datadog在 LLM 场景下有个明显的盲区它们记录的是 HTTP 请求的入站出站时间但不理解 Prompt Token 数、Completion Token 数、模型版本、重试次数这些 LLM 特有的维度。你需要一套专门的 LLM 可观测性方案。sequenceDiagram participant U as 用户 participant W as 工作流引擎 participant L as LLM 服务 participant T1 as 搜索工具 participant T2 as 数据库查询 U-W: 发起任务 W-L: Step 1: 意图识别 (TraceID: abc-1) L--W: 返回意图 Token 统计 W-T1: Step 2: 搜索引擎调用 (TraceID: abc-2) T1--W: 返回搜索结果 耗时 W-L: Step 3: 内容生成 (TraceID: abc-3) Note over L: 重试 2 次才成功 L--W: 返回生成内容 W-T2: Step 4: 数据校验 (TraceID: abc-4) T2--W: 返回校验结果 W--U: 最终响应 完整 Trace Note over W: 每个 Span 关联到同一个 TraceID本文将基于 OpenTelemetry 阐述如何为 LLM 工作流构建完整的链路追踪体系覆盖 Token 维度监控、重试追踪、模型版本审计三个关键场景。二、OpenTelemetry 的 LLM 语义约定为什么标准 HTTP Span 不够用OpenTelemetry 定义了通用的 Span 语义约定——http.method、http.status_code、db.statement等。但这些不足以描述 LLM 调用的特征。我们需要扩展自定义属性属性名类型含义llm.modelstring模型名称如 gpt-4ollm.prompt_tokensint64输入 Token 数llm.completion_tokensint64输出 Token 数llm.temperaturefloat64采样温度llm.retry_countint64本次调用的重试次数llm.tool_namestring调用的工具名称llm.workflow_stepstring在工作流中的步骤序号这些自定义属性注册到 Span 上后Jaeger 或 Grafana Tempo 的查询界面可以按llm.model分组统计耗时分布按llm.prompt_tokens筛选高 Token 消耗的调用。更重要的是 Trace 传播。OpenTelemetry 的 Context Propagation 机制可以自动将 TraceID 从 HTTP 请求头中提取和注入。在 LLM 工作流中每一步调用LLM、工具都应该携带同一个 TraceID通过 W3C Trace Context 标准在服务间传递。三、代码实现LLM 调用的 OpenTelemetry 包装器package telemetry import ( context encoding/json fmt time go.opentelemetry.io/otel go.opentelemetry.io/otel/attribute go.opentelemetry.io/otel/codes go.opentelemetry.io/otel/trace ) // LLMCallInput 描述一次 LLM 调用的输入参数 type LLMCallInput struct { Model string Prompt string Temperature float64 MaxTokens int StepName string } // LLMCallOutput 描述一次 LLM 调用的输出结果 type LLMCallOutput struct { Completion string PromptTokens int CompletionTokens int TotalTokens int Model string FinishReason string } // TracedLLMCall 在 OpenTelemetry Span 中包裹 LLM 调用 func TracedLLMCall( ctx context.Context, input LLMCallInput, callFn func(context.Context, LLMCallInput) (LLMCallOutput, error), ) (LLMCallOutput, error) { tracer : otel.Tracer(llm-workflow) // 创建 Span自动继承父 Context 的 TraceID ctx, span : tracer.Start(ctx, fmt.Sprintf(llm.%s, input.StepName), trace.WithAttributes( attribute.String(llm.model, input.Model), attribute.Float64(llm.temperature, input.Temperature), attribute.Int(llm.max_tokens, input.MaxTokens), attribute.String(llm.workflow_step, input.StepName), attribute.Int(llm.prompt_length, len(input.Prompt)), ), ) defer span.End() start : time.Now() var retryCount int // 带重试的调用 var output LLMCallOutput var err error for retryCount 1; retryCount 3; retryCount { output, err callFn(ctx, input) if err nil { break } // 记录重试 span.AddEvent(llm.retry, trace.WithAttributes( attribute.Int(retry_attempt, retryCount), attribute.String(error, err.Error()), )) time.Sleep(time.Duration(retryCount*500) * time.Millisecond) } // 记录 Token 统计数据 span.SetAttributes( attribute.Int(llm.prompt_tokens, output.PromptTokens), attribute.Int(llm.completion_tokens, output.CompletionTokens), attribute.Int(llm.total_tokens, output.TotalTokens), attribute.Int(llm.retry_count, retryCount-1), attribute.Int64(llm.duration_ms, time.Since(start).Milliseconds()), attribute.String(llm.finish_reason, output.FinishReason), ) if err ! nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return LLMCallOutput{}, fmt.Errorf(llm call %s failed after %d retries: %w, input.StepName, retryCount-1, err) } span.SetStatus(codes.Ok, LLM call completed) return output, nil } // TracedToolCall 为工具调用创建 Span func TracedToolCall( ctx context.Context, toolName string, input interface{}, callFn func(context.Context) (interface{}, error), ) (interface{}, error) { tracer : otel.Tracer(llm-workflow) inputJSON, _ : json.Marshal(input) ctx, span : tracer.Start(ctx, fmt.Sprintf(tool.%s, toolName), trace.WithAttributes( attribute.String(llm.tool_name, toolName), attribute.String(llm.tool_input, string(inputJSON)), ), ) defer span.End() start : time.Now() output, err : callFn(ctx) elapsed : time.Since(start) span.SetAttributes( attribute.Int64(tool.duration_ms, elapsed.Milliseconds()), ) if err ! nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return nil, fmt.Errorf(tool %s failed: %w, toolName, err) } span.SetStatus(codes.Ok, tool call completed) return output, nil }使用时每个 LLM 调用和工具调用都用TracedLLMCall和TracedToolCall包裹。所有 Span 自动关联到同一个 TraceID在 Jaeger 中呈现完整的调用树。Token 消耗按模型分组统计重试次数单独记录为 Span Event排查问题一目了然。四、权衡与适配可观测性的成本边界引入 OpenTelemetry 不是零成本的。性能开销每个 Span 的创建和属性设置约 0.1-0.3ms。对于单次 LLM 调用通常 1-10 秒这个开销可以忽略。但如果你做的是毫秒级的批量推理Span 开销可能占 10% 以上。建议使用概率采样Probability Sampling只采集 10%-30% 的请求。存储成本Traces 数据量大。一个 10 步工作流的单次执行就可能产生 20 个 Span。如果每秒处理 100 个这样的请求Traces 存储每天可增长数十 GB。务必设置 TTL如 7 天对超过保留期的数据自动清理。复杂度OpenTelemetry 的 SDK 和 Collector 部署都有学习成本。对于单人项目或早期 MVP从日志抓起就够了。等到工作流超过 3 个步骤再考虑引入分布式追踪。精确定位少即是多。不是每个函数都要包一层 Span专注于 LLM 调用、工具调用、数据库查询这些跨服务边界的关键节点。五、总结LLM 工作流的可观测性核心指标就三个每个步骤的耗时、Token 消耗、重试次数。用 OpenTelemetry 的自定义属性 Span Event 可以精确捕捉这些维度。落地路径先在代码中封装TracedLLMCall和TracedToolCall两个工具函数所有 LLM 调用和工具调用统一使用然后部署 Jaeger 或 Grafana Tempo 接收 Trace 数据最后建立 Dashboard按模型、步骤、错误类型分组展示。哪一步慢、哪一步 Token 消耗大、哪一步频繁重试——数据说话不再靠猜。
LLM 工具调用链路追踪:OpenTelemetry 在智能工作流中的落地实践
LLM 工具调用链路追踪OpenTelemetry 在智能工作流中的落地实践一、Agent 工作流的黑盒困境当 LLM 调用耗时 3 秒到底是哪一环慢了Agent 工作流通常由多个 LLM 调用和工具调用串联而成。一个典型的搜索→分析→总结流水线可能包含三次模型调用和两次搜索引擎调用。当用户抱怨响应太慢时排查变得极其困难——你面对的不是一个单体接口而是一条多跳调用链。Prompt 写得太长导致 Token 消耗多某个工具调用的 API 超时了重试策略触发了三次才成功——没有链路追踪你只能靠猜。传统的 APM 工具如 New Relic、Datadog在 LLM 场景下有个明显的盲区它们记录的是 HTTP 请求的入站出站时间但不理解 Prompt Token 数、Completion Token 数、模型版本、重试次数这些 LLM 特有的维度。你需要一套专门的 LLM 可观测性方案。sequenceDiagram participant U as 用户 participant W as 工作流引擎 participant L as LLM 服务 participant T1 as 搜索工具 participant T2 as 数据库查询 U-W: 发起任务 W-L: Step 1: 意图识别 (TraceID: abc-1) L--W: 返回意图 Token 统计 W-T1: Step 2: 搜索引擎调用 (TraceID: abc-2) T1--W: 返回搜索结果 耗时 W-L: Step 3: 内容生成 (TraceID: abc-3) Note over L: 重试 2 次才成功 L--W: 返回生成内容 W-T2: Step 4: 数据校验 (TraceID: abc-4) T2--W: 返回校验结果 W--U: 最终响应 完整 Trace Note over W: 每个 Span 关联到同一个 TraceID本文将基于 OpenTelemetry 阐述如何为 LLM 工作流构建完整的链路追踪体系覆盖 Token 维度监控、重试追踪、模型版本审计三个关键场景。二、OpenTelemetry 的 LLM 语义约定为什么标准 HTTP Span 不够用OpenTelemetry 定义了通用的 Span 语义约定——http.method、http.status_code、db.statement等。但这些不足以描述 LLM 调用的特征。我们需要扩展自定义属性属性名类型含义llm.modelstring模型名称如 gpt-4ollm.prompt_tokensint64输入 Token 数llm.completion_tokensint64输出 Token 数llm.temperaturefloat64采样温度llm.retry_countint64本次调用的重试次数llm.tool_namestring调用的工具名称llm.workflow_stepstring在工作流中的步骤序号这些自定义属性注册到 Span 上后Jaeger 或 Grafana Tempo 的查询界面可以按llm.model分组统计耗时分布按llm.prompt_tokens筛选高 Token 消耗的调用。更重要的是 Trace 传播。OpenTelemetry 的 Context Propagation 机制可以自动将 TraceID 从 HTTP 请求头中提取和注入。在 LLM 工作流中每一步调用LLM、工具都应该携带同一个 TraceID通过 W3C Trace Context 标准在服务间传递。三、代码实现LLM 调用的 OpenTelemetry 包装器package telemetry import ( context encoding/json fmt time go.opentelemetry.io/otel go.opentelemetry.io/otel/attribute go.opentelemetry.io/otel/codes go.opentelemetry.io/otel/trace ) // LLMCallInput 描述一次 LLM 调用的输入参数 type LLMCallInput struct { Model string Prompt string Temperature float64 MaxTokens int StepName string } // LLMCallOutput 描述一次 LLM 调用的输出结果 type LLMCallOutput struct { Completion string PromptTokens int CompletionTokens int TotalTokens int Model string FinishReason string } // TracedLLMCall 在 OpenTelemetry Span 中包裹 LLM 调用 func TracedLLMCall( ctx context.Context, input LLMCallInput, callFn func(context.Context, LLMCallInput) (LLMCallOutput, error), ) (LLMCallOutput, error) { tracer : otel.Tracer(llm-workflow) // 创建 Span自动继承父 Context 的 TraceID ctx, span : tracer.Start(ctx, fmt.Sprintf(llm.%s, input.StepName), trace.WithAttributes( attribute.String(llm.model, input.Model), attribute.Float64(llm.temperature, input.Temperature), attribute.Int(llm.max_tokens, input.MaxTokens), attribute.String(llm.workflow_step, input.StepName), attribute.Int(llm.prompt_length, len(input.Prompt)), ), ) defer span.End() start : time.Now() var retryCount int // 带重试的调用 var output LLMCallOutput var err error for retryCount 1; retryCount 3; retryCount { output, err callFn(ctx, input) if err nil { break } // 记录重试 span.AddEvent(llm.retry, trace.WithAttributes( attribute.Int(retry_attempt, retryCount), attribute.String(error, err.Error()), )) time.Sleep(time.Duration(retryCount*500) * time.Millisecond) } // 记录 Token 统计数据 span.SetAttributes( attribute.Int(llm.prompt_tokens, output.PromptTokens), attribute.Int(llm.completion_tokens, output.CompletionTokens), attribute.Int(llm.total_tokens, output.TotalTokens), attribute.Int(llm.retry_count, retryCount-1), attribute.Int64(llm.duration_ms, time.Since(start).Milliseconds()), attribute.String(llm.finish_reason, output.FinishReason), ) if err ! nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return LLMCallOutput{}, fmt.Errorf(llm call %s failed after %d retries: %w, input.StepName, retryCount-1, err) } span.SetStatus(codes.Ok, LLM call completed) return output, nil } // TracedToolCall 为工具调用创建 Span func TracedToolCall( ctx context.Context, toolName string, input interface{}, callFn func(context.Context) (interface{}, error), ) (interface{}, error) { tracer : otel.Tracer(llm-workflow) inputJSON, _ : json.Marshal(input) ctx, span : tracer.Start(ctx, fmt.Sprintf(tool.%s, toolName), trace.WithAttributes( attribute.String(llm.tool_name, toolName), attribute.String(llm.tool_input, string(inputJSON)), ), ) defer span.End() start : time.Now() output, err : callFn(ctx) elapsed : time.Since(start) span.SetAttributes( attribute.Int64(tool.duration_ms, elapsed.Milliseconds()), ) if err ! nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) return nil, fmt.Errorf(tool %s failed: %w, toolName, err) } span.SetStatus(codes.Ok, tool call completed) return output, nil }使用时每个 LLM 调用和工具调用都用TracedLLMCall和TracedToolCall包裹。所有 Span 自动关联到同一个 TraceID在 Jaeger 中呈现完整的调用树。Token 消耗按模型分组统计重试次数单独记录为 Span Event排查问题一目了然。四、权衡与适配可观测性的成本边界引入 OpenTelemetry 不是零成本的。性能开销每个 Span 的创建和属性设置约 0.1-0.3ms。对于单次 LLM 调用通常 1-10 秒这个开销可以忽略。但如果你做的是毫秒级的批量推理Span 开销可能占 10% 以上。建议使用概率采样Probability Sampling只采集 10%-30% 的请求。存储成本Traces 数据量大。一个 10 步工作流的单次执行就可能产生 20 个 Span。如果每秒处理 100 个这样的请求Traces 存储每天可增长数十 GB。务必设置 TTL如 7 天对超过保留期的数据自动清理。复杂度OpenTelemetry 的 SDK 和 Collector 部署都有学习成本。对于单人项目或早期 MVP从日志抓起就够了。等到工作流超过 3 个步骤再考虑引入分布式追踪。精确定位少即是多。不是每个函数都要包一层 Span专注于 LLM 调用、工具调用、数据库查询这些跨服务边界的关键节点。五、总结LLM 工作流的可观测性核心指标就三个每个步骤的耗时、Token 消耗、重试次数。用 OpenTelemetry 的自定义属性 Span Event 可以精确捕捉这些维度。落地路径先在代码中封装TracedLLMCall和TracedToolCall两个工具函数所有 LLM 调用和工具调用统一使用然后部署 Jaeger 或 Grafana Tempo 接收 Trace 数据最后建立 Dashboard按模型、步骤、错误类型分组展示。哪一步慢、哪一步 Token 消耗大、哪一步频繁重试——数据说话不再靠猜。