接入 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.0util-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.id 与 gen_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_experimental 且 OTEL_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_time 或 gen_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.details 或 gen_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_ONLYimport (
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。
查看监控详情
登录云监控 2.0 控制台,选择目标工作空间,在左侧导航栏选择所有功能 > AI 应用可观测。
在 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=TechContentAgent、gen_ai.provider.name=dashscope、gen_ai.request.model=qwen-plus、gen_ai.usage.input_tokens=3982、gen_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 | 单位 | 含义 |
| s | LLM / Embedding 单次调用端到端耗时 |
| {token} | 输入/输出 Token 用量(gen_ai.token.type=input/output 区分) |
| s | 流式首包耗时(需写入 LLMInvocation.TimeToFirstChunk) |
| s | Agent 调用总耗时 |
| s | 工具调用总耗时 |
| 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完整环境变量列表:
环境变量 | 取值 | 效果 |
| gen_ai_latest_experimental | 开启 experimental 语义(否则消息内容一律不落属性) |
| NO_CONTENT / SPAN_ONLY / EVENT_ONLY / SPAN_AND_EVENT | 控制是否把 input/output message JSON 写入 Span 或 Event |
| true / false | 是否通过 OTel Logs API 发送 GenAI 事件 |
| fsspec URI 或本地路径 | 内容卸载目标路径 |
| json / jsonl | 卸载文件序列化格式 |
| 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
}