Go LLM 应用自定义埋点最佳实践

更新时间:
复制 MD 格式

接入 ARMS 应用监控以后,探针会对常见的 AI 框架进行自动埋点,无需修改代码即可采集调用链信息。如果需要在调用链中体现业务方法的执行情况,可以引入 loongsuite-go/util-genai 与 OpenTelemetry Go SDK,在业务代码中增加自定义埋点。本文介绍如何通过 util-genai 与 OpenTelemetry Go SDK 实现自定义埋点及自定义 Attribute。

支持的 AI 组件与框架

ARMS 探针支持的 AI 组件与框架,请参见:

前提条件

  • 已经成功接入 ARMS 应用监控。如果 Go 应用已手动安装 Golang Agent,探针会自动覆盖常见 LLM SDK 调用;本文的手动埋点可与自动埋点混合使用、互不冲突。

  • 如果使用了 Go 探针接入,则不需要自己上报。如果只是使用 SDK,需要获取数据上报的接入点信息:登录云监控 2.0 控制台,选择目标工作空间,在左侧导航栏单击接入中心。在服务端应用区域单击 OpenTelemetry 卡片,单击 LicenseKey 右侧的点击获取,获取 OTEL_EXPORTER_OTLP_HEADERS 和 OTEL_EXPORTER_OTLP_ENDPOINT,用于数据上报。

  • 本地 Go 版本 >=1.24.0(util-genai 的 go.mod 声明)。

引入依赖

util-genai 目前随主仓库 github.com/alibaba/loongsuite-go一起发布,模块路径为 github.com/alibaba/loongsuite-go/util-genai。在项目里执行:

go get github.com/alibaba/loongsuite-go/util-genai@latest
go get go.opentelemetry.io/otel@v1.40.0
go get go.opentelemetry.io/otel/sdk@v1.40.0

util-genai 只依赖 OpenTelemetry API(otel / otel/trace / otel/metric / otel/log),不引入 SDK;TracerProvider、MeterProvider、LoggerProvider、Exporter、Resource 由调用方自行搭建。0.1.0 版本已实现 OpenTelemetry GenAI Semantic Conventions 的核心属性、metric、event 与 span 命名规则,可与 ARMS 探针自动埋点混用。更多信息请参见仓库根 README。

埋点能力

通过 util-genai 与 OpenTelemetry Go SDK 主要可以实现以下操作:

  • 创建 GenAI 语义的 Span(LLM、Agent、Tool、Embedding、Retrieve、Rerank)。

  • 通过 OpenTelemetry SDK 埋点生成自定义业务 Span。

  • 为 Span 增加自定义 Attributes。

  • 获取当前 Trace 上下文并读取 TraceID / SpanID。

  • 自动记录 GenAI 相关 Metrics(耗时、Token 用量、首包耗时等)。

  • 通过 OTel Logs API 发送 GenAI 事件(gen_ai.client.inference.operation.details 等)。

  • 通过 CompletionHook 将大 payload(prompt/response)异步卸载到外部存储(content-addressed SHA-256 去重)。

名词介绍

  • Span:一次请求中的具体操作,比如一次 LLM 调用或一次工具执行。

  • SpanContext:一次请求追踪的上下文,包含 TraceID、SpanID 等信息。

  • Attribute:Span 的附加属性字段,用于记录关键信息,如模型名称、Token 用量等。

  • Handler:util-genai 提供的 TelemetryHandler,用于创建符合 GenAI 语义规范的 Span 与 Metric。

  • Invocation:每类操作对应的数据模型(如 LLMInvocation、ExecuteToolInvocation),承载 Request / Response 与 Token 等字段。

util-genai 支持的 Span 类型如下表。相比 Python 版,Go 版当前未直接提供 Entry / ReAct Step / Memory 的 Invocation 结构体,但通过 LoongSuite 扩展属性 gen_ai.span.kind(取值 ENTRY / STEP / MEMORY 等)加 OpenTelemetry SDK 手工创建,仍可等价落数。

Span 类型

操作名

创建方式

说明

Entry

enter

OTel SDK + gen_ai.span.kind=ENTRY

应用入口,携带 session_id / user_id / 应用完整互动信息

Agent

invoke_agent {name}

handler.StartInvokeAgent

Agent 调用,可累计 Token 用量

Tool

execute_tool {name}

handler.StartExecuteTool

工具/函数执行

Step

react

OTel SDK + gen_ai.span.kind=STEP

ReAct 单轮迭代标识

LLM

chat {model}

handler.StartLLM(或探针自动)

大模型对话

Embedding

embeddings {model}

handler.StartEmbedding

向量嵌入

Retriever

retrieval {data_source}

handler.StartRetrieve

检索(RAG)

Reranker

rerank_documents

handler.StartRerank

重排序(LoongSuite 扩展)

Memory

memory {operation}

OTel SDK + gen_ai.span.kind=MEMORY

记忆读写

下面分步介绍各类 Span 的埋点写法,每一步给出独立的代码片段。完整可运行示例请参见本文附录部分。

1. 获取 Handler 和 Tracer

通过 utilgenai.GetTelemetryHandler() 获取 util-genai 单例 Handler,通过 otel.Tracer("...") 获取 OpenTelemetry SDK 的 Tracer。两者分别用于创建 GenAI 语义 Span 和自定义业务 Span。若需要绑定自建的 TracerProvider / MeterProvider,用 Option 传入即可;不传则回落到 otel.GetTracerProvider() / otel.GetMeterProvider()

import (
    "go.opentelemetry.io/otel"
    utilgenai "github.com/alibaba/loongsuite-go/util-genai"
)

// 单例,进程内复用同一 tracer/meter 实例
handler := utilgenai.GetTelemetryHandler()

// 若需要指定自建的 TracerProvider / MeterProvider / LoggerProvider
// handler := utilgenai.NewTelemetryHandler(
//     utilgenai.WithTracerProvider(tp),
//     utilgenai.WithMeterProvider(mp),
//     utilgenai.WithLoggerProvider(lp),       // 用于 GenAI 事件发送(OTel Logs API)
//     utilgenai.WithCompletionHook(hook),     // 用于大 payload 异步卸载
// )

tracer := otel.Tracer("techcontent-agent")

Handler 生命周期方法采用 Start* / Stop* / Fail* 显式三段式:

ctx = handler.StartLLM(ctx, invocation)
var callErr error
defer func() {
    if callErr != nil {
        handler.FailLLM(invocation, &utilgenai.Error{
            Message: callErr.Error(),
            Type:    "APIError",
        })
    } else {
        handler.StopLLM(invocation)
    }
}()

2. 创建 Entry Span

在请求入口处创建 Entry Span,携带 session_id、user_id,通过属性写入用户输入与最终输出。Go 版目前没有专门的 EntryInvocation 类型,通过 OTel SDK 直接创建普通 Span 并写入 LoongSuite 扩展属性 gen_ai.span.kind=ENTRY 即可被 ARMS 控制台识别为入口。

ctx, entrySpan := tracer.Start(ctx, "enter",
    trace.WithSpanKind(trace.SpanKindServer),
    trace.WithAttributes(
        attribute.String("gen_ai.span.kind", "ENTRY"),
        attribute.String("gen_ai.session.id", sessionID),
        attribute.String("gen_ai.user.id", userID),
        attribute.String("gen_ai.operation.name", "enter"),
    ),
)
defer entrySpan.End()

// 记录用户输入
inputMsgs := []utilgenai.InputMessage{
    {Role: "user", Parts: []utilgenai.MessagePart{utilgenai.Text{Content: req.Topic}}},
}
entrySpan.SetAttributes(
    attribute.String("gen_ai.input.messages", utilgenai.InputMessagesToJSON(inputMsgs)),
)

// 流式响应完成后,把聚合内容写为 output.messages
outputMsgs := []utilgenai.OutputMessage{
    {
        Role:         "assistant",
        Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: aggregated}},
        FinishReason: utilgenai.FinishReasonStop,
    },
}
entrySpan.SetAttributes(
    attribute.String("gen_ai.output.messages", utilgenai.OutputMessagesToJSON(outputMsgs)),
)

在控制台中即可看到该次请求的完整输入与最终输出。gen_ai.session.idgen_ai.user.id 会随 Context 向下传播,只要下游子 Span 在同一 Context 内创建,就能在会话与用户维度进行分析。

3. 创建 Agent Span

通过 StartInvokeAgent 创建 Agent Span,记录 Agent 名称、模型和描述信息。Agent Span 是整个调用链的根 GenAI Span,所有后续的 ReAct Step、LLM 调用与 Tool 调用都作为它的子 Span。

Go 版 0.1.0 阶段尚未内置 Python 0.6.1 那种基于 OpenTelemetry Baggage 的 gen_ai.agent.name 自动透传,如需在下游子 Span 中读取 Agent 名称,请自行在业务代码中透传(例如通过 Context 或 LLMInvocation.Attributes)。

agentInv := utilgenai.NewInvokeAgentInvocation()
agentInv.Provider = "dashscope"
agentInv.AgentName = "TechContentAgent"
agentInv.AgentDescription = "技术内容生成助手"
agentInv.RequestModel = "qwen-plus"
agentInv.ConversationID = sessionID

var (
    totalInputTokens  int
    totalOutputTokens int
)

ctx = handler.StartInvokeAgent(ctx, agentInv)
defer func() {
    if r := recover(); r != nil {
        handler.FailInvokeAgent(agentInv, &utilgenai.Error{
            Message: fmt.Sprintf("agent panic: %v", r),
            Type:    "RuntimeError",
        })
        panic(r)
    }
}()

// ... Agent 核心逻辑(ReAct 循环,见第 4 步) ...

agentInv.InputTokens = &totalInputTokens
agentInv.OutputTokens = &totalOutputTokens
handler.StopInvokeAgent(agentInv)

Agent 执行完成后,把累积的 totalInputTokens 和 totalOutputTokens 写入 InputTokens / OutputTokens 字段,StopInvokeAgent 会将其落到 gen_ai.usage.input_tokens / gen_ai.usage.output_tokens 属性,同时驱动 gen_ai.client.token.usage 直方图 record,实现 Agent 级别的 Token 汇总统计。

4. 创建 ReAct Step Span

在每一轮 ReAct 推理迭代时创建 Step Span,传入当前轮次 round。迭代结束时通过 finish_reason 标记:需要继续迭代为 continue,最终回答为 stop。Go 版当前没有 ReactStepInvocation,用 OTel SDK 直接创建 Span 并写入 gen_ai.span.kind=STEP

ctx, stepSpan := tracer.Start(ctx, "react",
    trace.WithSpanKind(trace.SpanKindInternal),
    trace.WithAttributes(
        attribute.String("gen_ai.span.kind", "STEP"),
        attribute.String("gen_ai.operation.name", "react"),
        attribute.Int("gen_ai.step.round", iteration+1),
    ),
)

finishReason := "continue"
func() {
    defer stepSpan.End()

    resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
        Model:    "qwen-plus",
        Messages: messages,
        Tools:    toolDefs,
    })
    if err != nil {
        stepSpan.RecordError(err)
        stepSpan.SetStatus(codes.Error, err.Error())
        return
    }
    // ... 处理响应 ...
    if noMoreToolCalls {
        finishReason = "stop"
    }
    stepSpan.SetAttributes(attribute.String("gen_ai.response.finish_reason", finishReason))
}()

Step Span 生命周期内的 LLM 调用如果由 ARMS 探针自动埋点,无需手动创建;如果直接使用 OpenAI SDK 无插桩,则可用第 6 步的 LLMInvocation 手动埋点。

5. 创建 Tool Span

当模型返回工具调用时,为每个 tool_call 创建 Tool Span,记录工具名称、调用 ID、入参和返回结果。

toolInv := utilgenai.NewExecuteToolInvocation(toolCall.Function.Name)
toolInv.ToolCallID = toolCall.ID
toolInv.ToolType = "function"

// 入参:util-genai 序列化时会自动 JSON.stringify,写入 gen_ai.tool.call.arguments
_ = json.Unmarshal([]byte(toolCall.Function.Arguments), &toolInv.Input)

ctx = handler.StartExecuteTool(ctx, toolInv)

result, err := dispatchTool(toolCall.Function.Name, toolCall.Function.Arguments)
if err != nil {
    handler.FailExecuteTool(toolInv, &utilgenai.Error{
        Message: err.Error(),
        Type:    "ToolError",
    })
    return err
}

toolInv.Output = result // 落到 gen_ai.tool.call.result
handler.StopExecuteTool(toolInv)

工具入参 gen_ai.tool.call.arguments 与返回 gen_ai.tool.call.result 属于 experimental 内容属性,需要开启 OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimentalOTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY(或其他包含 SPAN 的值)后才会写入 Span。

6. 创建 ASR/TTS 类型的 LLM Span

对于 ASR、TTS 这类音频模型调用,如果需要补充音频时长、识别耗时、首包耗时、字符数等业务属性,可以创建一个特殊的 LLM Span 承载:ASR 使用 OperationName="transcribe",TTS 使用 OperationName="synthesize_speech"。标准 Token 字段仅在模型接口真实返回 Token 时写入;没有返回 Token 的接口,不要按音频时长、字符数或字节数推算 gen_ai.usage.*

Go 版通过 LLMInvocation.Attributes 挂载自定义键值(支持 string / int / int64 / float64 / bool / []string;其它类型会被静默丢弃)。

// ASR:语音识别输出文本
asrInv := utilgenai.NewLLMInvocation("paraformer-v2")
asrInv.Provider = "dashscope"
asrInv.OperationName = "transcribe"
asrInv.OutputType = utilgenai.OutputTypeText

asrInv.InputMessages = []utilgenai.InputMessage{
    {Role: "user", Parts: []utilgenai.MessagePart{
        utilgenai.Uri{MimeType: "audio/wav", Modality: "audio", URI: audioURL},
    }},
}
asrInv.Attributes = map[string]any{
    "gen_ai.asr.channel":      "dashscope_transcription_async",
    "gen_ai.asr.input.source": "url",
}

ctx = handler.StartLLM(ctx, asrInv)
// 调用真实 ASR SDK
asrResp, asrErr := dashscopeASR(ctx, audioURL)
if asrErr != nil {
    handler.FailLLM(asrInv, &utilgenai.Error{Message: asrErr.Error(), Type: "ASRError"})
    return asrErr
}

asrInv.ResponseID = asrResp.TaskID
asrInv.OutputMessages = []utilgenai.OutputMessage{
    {Role: "assistant",
     Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: asrResp.Transcript}},
     FinishReason: utilgenai.FinishReasonStop},
}
if v := asrResp.Usage.InputTokens; v > 0 {
    asrInv.InputTokens = &v
}
if v := asrResp.Usage.OutputTokens; v > 0 {
    asrInv.OutputTokens = &v
}
asrInv.Attributes["gen_ai.asr.success"] = true
asrInv.Attributes["gen_ai.asr.status"] = "SUCCEEDED"
asrInv.Attributes["gen_ai.asr.audio.duration"] = asrResp.Usage.Duration
asrInv.Attributes["gen_ai.asr.call.wall_time"] = asrCallWallTime
asrInv.Attributes["gen_ai.asr.provider.processing.duration"] = asrProviderProcessingTime
handler.StopLLM(asrInv)
// TTS:文本合成音频
ttsInv := utilgenai.NewLLMInvocation("qwen-tts")
ttsInv.Provider = "dashscope"
ttsInv.OperationName = "synthesize_speech"
ttsInv.OutputType = utilgenai.OutputTypeSpeech

ttsInv.InputMessages = []utilgenai.InputMessage{
    {Role: "user", Parts: []utilgenai.MessagePart{utilgenai.Text{Content: ttsText}}},
}
ttsInv.Attributes = map[string]any{
    "gen_ai.tts.channel":           "qwen_tts_http",
    "gen_ai.tts.voice":             "Cherry",
    "gen_ai.tts.input.text_length": len(ttsText),
}

ctx = handler.StartLLM(ctx, ttsInv)
ttsResp, ttsErr := qwenTTS(ctx, ttsText)
if ttsErr != nil {
    handler.FailLLM(ttsInv, &utilgenai.Error{Message: ttsErr.Error(), Type: "TTSError"})
    return ttsErr
}

ttsInv.ResponseID = ttsResp.RequestID
if v := ttsResp.Usage.InputTokens; v > 0 {
    ttsInv.InputTokens = &v
}
if v := ttsResp.Usage.OutputTokens; v > 0 {
    ttsInv.OutputTokens = &v
}
ttsInv.OutputMessages = []utilgenai.OutputMessage{
    {Role: "assistant",
     Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: fmt.Sprintf("audio generated: %s", ttsResp.AudioID)}},
     FinishReason: utilgenai.FinishReasonStop},
}
ttsInv.Attributes["gen_ai.tts.success"] = true
ttsInv.Attributes["gen_ai.tts.status"] = "SUCCEEDED"
ttsInv.Attributes["gen_ai.tts.audio.duration"] = ttsResp.AudioDuration
ttsInv.Attributes["gen_ai.tts.call.wall_time"] = ttsCallWallTime
ttsInv.Attributes["gen_ai.tts.first_audio.duration"] = firstAudioDelay
handler.StopLLM(ttsInv)

ASR/TTS 常用字段建议按用途区分:gen_ai.asr.audio.duration 表示输入音频时长,gen_ai.asr.provider.processing.duration 表示服务端识别耗时;gen_ai.tts.audio.duration 表示合成后的音频时长,gen_ai.tts.first_audio.duration 表示流式合成首包耗时。端到端调用耗时可使用 Span Duration,也可以额外写入 gen_ai.asr.call.wall_timegen_ai.tts.call.wall_time

7. GenAI 事件发送与内容卸载

事件发送(Events)

util-genai 支持通过 OTel Logs API 发送 GenAI 事件。当 StopLLM / StopInvokeAgent 被调用时,如果同时满足以下条件,Handler 会自动向 LoggerProvider 发送一条事件(event name 为 gen_ai.client.inference.operation.detailsgen_ai.client.agent.invoke.operation.details):

  • 环境变量 OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental 已设置。

  • 环境变量 OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT=true 已设置。

  • 创建 Handler 时通过 WithLoggerProvider(lp) 注入了 LoggerProvider。

消息内容仅在 OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT 包含 EVENT(即 EVENT_ONLY 或 SPAN_AND_EVENT)时才落入事件体。

export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
export OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT=true
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=EVENT_ONLY
import (
    logapi "go.opentelemetry.io/otel/log"
    sdklog "go.opentelemetry.io/otel/sdk/log"
    "go.opentelemetry.io/otel/exporters/stdout/stdoutlog"
)

logExporter, _ := stdoutlog.New()
lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(
    sdklog.NewBatchProcessor(logExporter),
))
defer lp.Shutdown(ctx)

handler := utilgenai.NewTelemetryHandler(
    utilgenai.WithTracerProvider(tp),
    utilgenai.WithLoggerProvider(lp),
)

之后使用 Handler 的 StartLLM / StopLLM 与 StartInvokeAgent / StopInvokeAgent 照常调用即可,事件会自动发送,无需额外代码。

内容卸载(CompletionHook)

当 LLM 的 prompt/response 体积较大时(如数十 KB 的多轮对话上下文),直接写入 Span Attribute 可能导致 Span 过大。util-genai 提供了 CompletionHook 接口,在 StopLLM 时自动将大 payload 异步卸载到外部存储(文件系统或自定义后端),仅在 Span 中写入一个 content-addressed 引用 URI(gen_ai.input.messages_ref / gen_ai.output.messages_ref 等)。

内置的 UploadCompletionHook 使用 SHA-256 计算文件名实现去重(同一 payload 不重复写),支持 JSON 和 JSONL 两种序列化格式,内部通过 worker pool + bounded FIFO set 实现异步并发上传。

# 配置外部存储路径与格式
export OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH=/data/genai-traces
export OTEL_INSTRUMENTATION_GENAI_UPLOAD_FORMAT=json
export OTEL_INSTRUMENTATION_GENAI_UPLOAD_MAX_QUEUE_SIZE=128
// 使用内置 FS Uploader(也可以实现自定义 Uploader 接口写入 OSS 等)
hook := utilgenai.NewUploadCompletionHook(
    utilgenai.WithUploader(utilgenai.NewFSUploader("/data/genai-traces")),
    utilgenai.WithUploadFormat(utilgenai.UploadFormatJSON),
    utilgenai.WithUploadQueueSize(128),
)

handler := utilgenai.NewTelemetryHandler(
    utilgenai.WithTracerProvider(tp),
    utilgenai.WithCompletionHook(hook),
)
defer handler.Shutdown(ctx) // 优雅 drain 队列中的 pending 上传

当 StopLLM 完成后,Handler 内部调用 offloadLLMContent 将 InputMessages、OutputMessages、SystemInstruction、ToolDefinitions 序列化并提交给 CompletionHook。Hook 计算 SHA-256,写入文件系统(如 /data/genai-traces/<sha256>.json),并在 Span 上写入引用属性 gen_ai.input.messages_ref=sha256://<hash>

自定义 Uploader 接口:

type Uploader interface {
    Upload(ctx context.Context, ref string, data []byte) error
}

可以实现该接口将 payload 写入 OSS、S3 或其他对象存储。

Reasoning 消息类型

util-genai 新增了 Reasoning 消息片段类型,用于承载模型的推理/思考过程(如 Chain-of-Thought)。在 OutputMessage.Parts 中使用:

invocation.OutputMessages = []utilgenai.OutputMessage{{
    Role: "assistant",
    Parts: []utilgenai.MessagePart{
        utilgenai.Reasoning{Content: "让我分析这个问题...首先..."},
        utilgenai.Text{Content: "最终回答是..."},
    },
    FinishReason: utilgenai.FinishReasonStop,
}}
// Reasoning token 可通过 ReasoningOutputTokens 单独统计
reasoningTokens := 150
invocation.ReasoningOutputTokens = &reasoningTokens

对应的 Span 属性为 gen_ai.usage.reasoning.output_tokens,在控制台中可区分模型的推理 Token 与正式输出 Token。

查看监控详情

  1. 登录云监控 2.0 控制台,选择目标工作空间,在左侧导航栏选择所有功能 > AI 应用可观测。

  2. 在 AI 应用列表页面单击应用名称即可查看详细的应用监控数据。

埋点效果展示

Entry Span 详情

Entry Span 详情面板中,关键属性包括 gen_ai.session.id(会话唯一标识)和 gen_ai.user.id(用户标识),在函数入口处设置后会自动透传到下游子 Span(前提是下游 Span 使用同一 Context 派生)。同时可查看 gen_ai.input.messages(用户输入的完整消息内容)和 gen_ai.output.messages(模型最终输出的完整消息内容)。

Agent Span 详情

Agent Span 面板展示该 Agent 的名称与描述,以及 Agent 级别的 Token 用量汇总。示例中:gen_ai.agent.name=TechContentAgentgen_ai.provider.name=dashscopegen_ai.request.model=qwen-plusgen_ai.usage.input_tokens=3982gen_ai.usage.output_tokens=884

Tool Span 详情

Tool Span 展示工具调用的详细信息。以 execute_tool generate_seo_keywords 为例,右侧详情面板显示工具名称(gen_ai.tool.name=generate_seo_keywords)、工具类型(gen_ai.tool.type=function)、工具调用入参(gen_ai.tool.call.arguments)以及工具返回结果(gen_ai.tool.call.result),便于排查工具调用的输入输出是否符合预期。

LLM Span 详情

LLM Span 可以由 ARMS 探针对已支持的 Go 组件(如 sashabaranov/go-openai、dashscope 等)自动采集,也可以通过本文示例中的 LLMInvocation 手动创建。自动采集适用于常见文本对话模型调用;对于 ASR、TTS 等暂未由探针自动覆盖或需要补充业务属性的模型调用,可以手动创建 LLM Span 并写入 gen_ai.asr.* / gen_ai.tts.* 等自定义属性。

LLM Span 详情面板展示应用名、接口名、IP、开始与结束时间、SpanID、ParentSpanID、状态码等基本信息。附加信息选项卡可查看该次调用的 response 输出内容、自定义 Attribute 和 Token 用量。

自定义 Span 详情

自定义 Span(如 duplicate_tool_detection、response_loop_detection)需要在控制台调用链视图切换到全部视图才能看到;面板中可查看写入的 gen_ai.loop_detection.* 属性,用于分析业务侧循环、重试等异常行为。

Metrics 一览

TelemetryHandler 在 Stop* / Fail* 时会自动 record 以下直方图(单位与 spec 一致),可用于 CMS 2.0 / Prometheus 侧的聚合分析。

Metric

单位

含义

gen_ai.client.operation.duration

s

LLM / Embedding 单次调用端到端耗时

gen_ai.client.token.usage

{token}

输入/输出 Token 用量(gen_ai.token.type=input/output 区分)

gen_ai.client.operation.time_to_first_chunk

s

流式首包耗时(需写入 LLMInvocation.TimeToFirstChunk)

gen_ai.invoke_agent.duration

s

Agent 调用总耗时

gen_ai.execute_tool.duration

s

工具调用总耗时

gen_ai.workflow.duration

s

Workflow 调用总耗时(预留,尚无对应 Handler 方法)

要开启消息内容落 Span(否则 gen_ai.input.messages / gen_ai.output.messages / gen_ai.tool.call.arguments / gen_ai.tool.call.result 不会写入):

export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY

完整环境变量列表:

环境变量

取值

效果

OTEL_SEMCONV_STABILITY_OPT_IN

gen_ai_latest_experimental

开启 experimental 语义(否则消息内容一律不落属性)

OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT

NO_CONTENT / SPAN_ONLY / EVENT_ONLY / SPAN_AND_EVENT

控制是否把 input/output message JSON 写入 Span 或 Event

OTEL_INSTRUMENTATION_GENAI_EMIT_EVENT

true / false

是否通过 OTel Logs API 发送 GenAI 事件

OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH

fsspec URI 或本地路径

内容卸载目标路径

OTEL_INSTRUMENTATION_GENAI_UPLOAD_FORMAT

json / jsonl

卸载文件序列化格式

OTEL_INSTRUMENTATION_GENAI_UPLOAD_MAX_QUEUE_SIZE

int

异步上传 worker 队列大小

附录:完整示例代码

初始化 TracerProvider → 建立 Entry Span → 启动 Agent Span → 运行 ReAct 循环(含 Step / LLM / Tool Span) → 记录 Token 用量并结束 Agent Span。示例基于 sashabaranov/go-openai,可无缝替换为 DashScope 兼容接入,更多代码参考 genai-demo

main.go

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "log"
    "os"
    "strings"
    "time"

    utilgenai "github.com/alibaba/loongsuite-go/util-genai"
    openai "github.com/sashabaranov/go-openai"
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
    "go.opentelemetry.io/otel/trace"
)

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
    exporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
    if err != nil {
        return nil, err
    }
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceNameKey.String("techcontent-agent"),
            semconv.ServiceVersionKey.String("0.1.0"),
            // 关键 Resource:不要通过 Span Attribute 写
            attribute.String("acs.arms.service.feature", "genai_app"),
            attribute.String("gen_ai.instrumentation.sdk.name", "loongsuite-genai-utils"),
        ),
    )
    if err != nil {
        return nil, err
    }
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
    )
    otel.SetTracerProvider(tp)
    return tp, nil
}

func main() {
    ctx := context.Background()
    tp, err := initTracer(ctx)
    if err != nil {
        log.Fatalf("init tracer: %v", err)
    }
    defer tp.Shutdown(ctx)

    apiKey := os.Getenv("DASHSCOPE_API_KEY")
    if apiKey == "" {
        log.Fatal("DASHSCOPE_API_KEY is required")
    }

    cfg := openai.DefaultConfig(apiKey)
    cfg.BaseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
    client := openai.NewClientWithConfig(cfg)

    handler := utilgenai.NewTelemetryHandler(utilgenai.WithTracerProvider(tp))
    tracer := otel.Tracer("techcontent-agent")

    if err := runAgent(ctx, client, handler, tracer, "CMS 2.0 AI 告警降噪"); err != nil {
        log.Fatalf("run agent: %v", err)
    }
}

func runAgent(
    ctx context.Context,
    client *openai.Client,
    handler *utilgenai.TelemetryHandler,
    tracer trace.Tracer,
    topic string,
) error {
    // ---------- Entry Span ----------
    sessionID := fmt.Sprintf("sess-%d", time.Now().UnixNano())
    ctx, entrySpan := tracer.Start(ctx, "enter",
        trace.WithSpanKind(trace.SpanKindServer),
        trace.WithAttributes(
            attribute.String("gen_ai.span.kind", "ENTRY"),
            attribute.String("gen_ai.operation.name", "enter"),
            attribute.String("gen_ai.session.id", sessionID),
            attribute.String("gen_ai.user.id", "anonymous"),
        ),
    )
    defer entrySpan.End()

    inputMsgs := []utilgenai.InputMessage{
        {Role: "user", Parts: []utilgenai.MessagePart{utilgenai.Text{Content: topic}}},
    }
    entrySpan.SetAttributes(
        attribute.String("gen_ai.input.messages", utilgenai.InputMessagesToJSON(inputMsgs)),
    )

    // ---------- Agent Span ----------
    agentInv := utilgenai.NewInvokeAgentInvocation()
    agentInv.Provider = "dashscope"
    agentInv.AgentName = "TechContentAgent"
    agentInv.AgentDescription = "技术内容生成助手"
    agentInv.RequestModel = "qwen-plus"
    agentInv.ConversationID = sessionID

    ctx = handler.StartInvokeAgent(ctx, agentInv)

    messages := []openai.ChatCompletionMessage{
        {Role: openai.ChatMessageRoleSystem, Content: "你是一名资深云原生技术编辑,善用工具收集素材后再撰稿。"},
        {Role: openai.ChatMessageRoleUser, Content: topic},
    }

    var (
        totalIn, totalOut int
        finalContent     string
        toolCounter      = map[string]int{}
        prevContent      string
    )

    // ---------- ReAct 循环 ----------
    for round := 0; round < 5; round++ {
        checkDuplicateTools(ctx, tracer, toolCounter, &messages)

        stepCtx, stepSpan := tracer.Start(ctx, "react",
            trace.WithSpanKind(trace.SpanKindInternal),
            trace.WithAttributes(
                attribute.String("gen_ai.span.kind", "STEP"),
                attribute.String("gen_ai.operation.name", "react"),
                attribute.Int("gen_ai.step.round", round+1),
            ),
        )

        // 手工 LLM Span(若已启用 ARMS Go 自动埋点可省略)
        llmInv := utilgenai.NewLLMInvocation("qwen-plus")
        llmInv.Provider = "dashscope"
        llmInv.OperationName = utilgenai.OperationChat
        llmInv.ConversationID = sessionID
        llmInv.InputMessages = openAIToUtilMessages(messages)

        stepCtx = handler.StartLLM(stepCtx, llmInv)
        resp, err := client.CreateChatCompletion(stepCtx, openai.ChatCompletionRequest{
            Model:    "qwen-plus",
            Messages: messages,
            Tools:    toolDefinitions(),
        })
        if err != nil {
            handler.FailLLM(llmInv, &utilgenai.Error{Message: err.Error(), Type: "APIError"})
            stepSpan.RecordError(err)
            stepSpan.End()
            handler.FailInvokeAgent(agentInv, &utilgenai.Error{Message: err.Error(), Type: "APIError"})
            return err
        }

        choice := resp.Choices[0]
        llmInv.ResponseID = resp.ID
        llmInv.ResponseModelName = resp.Model
        pt := resp.Usage.PromptTokens
        ct := resp.Usage.CompletionTokens
        llmInv.InputTokens = &pt
        llmInv.OutputTokens = &ct
        totalIn += pt
        totalOut += ct

        llmInv.OutputMessages = []utilgenai.OutputMessage{{
            Role:         "assistant",
            Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: choice.Message.Content}},
            FinishReason: utilgenai.FinishReason(choice.FinishReason),
        }}
        handler.StopLLM(llmInv)

        messages = append(messages, choice.Message)

        // ---------- Tool 循环 ----------
        for _, tc := range choice.Message.ToolCalls {
            toolCounter[tc.Function.Name]++
            toolInv := utilgenai.NewExecuteToolInvocation(tc.Function.Name)
            toolInv.ToolCallID = tc.ID
            toolInv.ToolType = "function"

            _ = json.Unmarshal([]byte(tc.Function.Arguments), &toolInv.Input)

            stepCtx = handler.StartExecuteTool(stepCtx, toolInv)
            result, err := dispatchTool(tc.Function.Name, tc.Function.Arguments)
            if err != nil {
                handler.FailExecuteTool(toolInv, &utilgenai.Error{Message: err.Error(), Type: "ToolError"})
                stepSpan.End()
                handler.FailInvokeAgent(agentInv, &utilgenai.Error{Message: err.Error(), Type: "ToolError"})
                return err
            }
            toolInv.Output = result
            handler.StopExecuteTool(toolInv)

            messages = append(messages, openai.ChatCompletionMessage{
                Role:       openai.ChatMessageRoleTool,
                Content:    result,
                ToolCallID: tc.ID,
                Name:       tc.Function.Name,
            })
        }

        // ReAct 结束判断
        finishReason := "continue"
        if len(choice.Message.ToolCalls) == 0 {
            finishReason = "stop"
            finalContent = choice.Message.Content
            stepSpan.SetAttributes(attribute.String("gen_ai.response.finish_reason", finishReason))
            stepSpan.End()

            if checkResponseLoop(ctx, tracer, finalContent, prevContent) {
                agentInv.Attributes = map[string]any{
                    "gen_ai.response.finish_reason": "loop_detected",
                }
                break
            }
            break
        }
        stepSpan.SetAttributes(attribute.String("gen_ai.response.finish_reason", finishReason))
        stepSpan.End()
        prevContent = choice.Message.Content
    }

    // 汇总 Token 用量并结束 Agent Span
    agentInv.InputTokens = &totalIn
    agentInv.OutputTokens = &totalOut
    handler.StopInvokeAgent(agentInv)

    // Entry 输出
    outputMsgs := []utilgenai.OutputMessage{{
        Role:         "assistant",
        Parts:        []utilgenai.MessagePart{utilgenai.Text{Content: finalContent}},
        FinishReason: utilgenai.FinishReasonStop,
    }}
    entrySpan.SetAttributes(
        attribute.String("gen_ai.output.messages", utilgenai.OutputMessagesToJSON(outputMsgs)),
    )

    fmt.Println(strings.Repeat("-", 60))
    fmt.Println(finalContent)
    return nil
}

tools.go

package main

import (
    "encoding/json"
    "fmt"

    openai "github.com/sashabaranov/go-openai"
)

func toolDefinitions() []openai.Tool {
    return []openai.Tool{
        {
            Type: openai.ToolTypeFunction,
            Function: &openai.FunctionDefinition{
                Name:        "generate_seo_keywords",
                Description: "根据主题生成 SEO 关键词",
                Parameters: map[string]any{
                    "type": "object",
                    "properties": map[string]any{
                        "topic": map[string]any{"type": "string"},
                    },
                    "required": []string{"topic"},
                },
            },
        },
        {
            Type: openai.ToolTypeFunction,
            Function: &openai.FunctionDefinition{
                Name:        "outline_article",
                Description: "生成文章大纲",
                Parameters: map[string]any{
                    "type": "object",
                    "properties": map[string]any{
                        "topic":    map[string]any{"type": "string"},
                        "keywords": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
                    },
                    "required": []string{"topic"},
                },
            },
        },
    }
}

func dispatchTool(name, argJSON string) (string, error) {
    var args map[string]any
    _ = json.Unmarshal([]byte(argJSON), &args)
    switch name {
    case "generate_seo_keywords":
        topic, _ := args["topic"].(string)
        return fmt.Sprintf(`["%s 最佳实践", "%s 案例分析", "%s 落地方案"]`, topic, topic, topic), nil
    case "outline_article":
        topic, _ := args["topic"].(string)
        return fmt.Sprintf(`## %s 大纲\n1. 背景\n2. 技术方案\n3. 效果度量`, topic), nil
    default:
        return "", fmt.Errorf("unknown tool: %s", name)
    }
}

messages.go

package main

import (
    utilgenai "github.com/alibaba/loongsuite-go/util-genai"
    openai "github.com/sashabaranov/go-openai"
)

func openAIToUtilMessages(msgs []openai.ChatCompletionMessage) []utilgenai.InputMessage {
    out := make([]utilgenai.InputMessage, 0, len(msgs))
    for _, m := range msgs {
        out = append(out, utilgenai.InputMessage{
            Role:  m.Role,
            Parts: []utilgenai.MessagePart{utilgenai.Text{Content: m.Content}},
        })
    }
    return out
}