一个Agent用了四十多秒才返回"查询失败",传统APM往往只能告诉你入口HTTP请求很慢。真正的问题可能藏在十几层调用里:第一次模型推理选错工具,搜索接口超时后被重试,第二次模型又带入过长历史,最后输出Token触发上限。若模型、工具和重试各写各的日志,排障只能凭时间戳拼图。
另一个常见问题是成本失控。账单显示某模型一天使用了大量Token,却无法回答"哪个Agent、哪条业务路径、哪种工具失败后消耗最多"。团队于是把完整prompt和response全部写入日志,短期能查问题,随后又遇到隐私泄露、索引费用和访问权限失控。可观测性不是记录得越多越好,而是在统一语义下记录恰当的边界。
OpenTelemetry的GenAI语义约定为模型推理、Agent调用、工具执行、Token用量和错误提供共同名称。它不绑定某一家模型,也不要求某一种后端。应用产生trace、metric和可选event,经OTLP进入Collector,再由Collector采样、脱敏和导出到现有观测平台。截至2026年10月,GenAI语义约定仍在快速演进,官方已把它迁移到独立的semantic-conventions-genai仓库,因此生产代码需要固定版本,并把自定义字段与标准字段分开管理。
1. 先定义要回答的问题
在安装SDK之前,先列出观测系统必须回答的工程问题。一次用户请求经历了哪些模型调用和工具调用?哪个步骤占用最长时间?自动重试发生了几次?输入与输出Token分别是多少?错误属于模型限流、网络超时、工具业务失败还是本地解析失败?同一个Agent版本上线后,延迟和失败率是否回归?
这些问题决定信号选择。Trace描述一次请求的因果链与时间边界;Metric观察大量请求的分布和趋势;Log记录离散诊断信息;Event适合带独立时间点的状态变化。把所有信息都塞进span attribute会制造巨大span,把每次模型调用只做成日志又失去父子关系。
一个实用的span树通常以invoke_agent为根,在下面创建每次模型推理span与execute_tool span。模型返回tool call并不意味着工具已经执行;"模型选择工具"和"应用真正执行工具"是两个不同事实。工具失败后再次推理,也应成为新的兄弟span,而不是覆盖第一次调用。
#mermaid-svg-5JdcF2A5JI78odM8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5JdcF2A5JI78odM8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5JdcF2A5JI78odM8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5JdcF2A5JI78odM8 .error-icon{fill:#552222;}#mermaid-svg-5JdcF2A5JI78odM8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5JdcF2A5JI78odM8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5JdcF2A5JI78odM8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5JdcF2A5JI78odM8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5JdcF2A5JI78odM8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5JdcF2A5JI78odM8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5JdcF2A5JI78odM8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5JdcF2A5JI78odM8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5JdcF2A5JI78odM8 .marker.cross{stroke:#333333;}#mermaid-svg-5JdcF2A5JI78odM8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5JdcF2A5JI78odM8 p{margin:0;}#mermaid-svg-5JdcF2A5JI78odM8 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5JdcF2A5JI78odM8 .cluster-label text{fill:#333;}#mermaid-svg-5JdcF2A5JI78odM8 .cluster-label span{color:#333;}#mermaid-svg-5JdcF2A5JI78odM8 .cluster-label span p{background-color:transparent;}#mermaid-svg-5JdcF2A5JI78odM8 .label text,#mermaid-svg-5JdcF2A5JI78odM8 span{fill:#333;color:#333;}#mermaid-svg-5JdcF2A5JI78odM8 .node rect,#mermaid-svg-5JdcF2A5JI78odM8 .node circle,#mermaid-svg-5JdcF2A5JI78odM8 .node ellipse,#mermaid-svg-5JdcF2A5JI78odM8 .node polygon,#mermaid-svg-5JdcF2A5JI78odM8 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5JdcF2A5JI78odM8 .rough-node .label text,#mermaid-svg-5JdcF2A5JI78odM8 .node .label text,#mermaid-svg-5JdcF2A5JI78odM8 .image-shape .label,#mermaid-svg-5JdcF2A5JI78odM8 .icon-shape .label{text-anchor:middle;}#mermaid-svg-5JdcF2A5JI78odM8 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5JdcF2A5JI78odM8 .rough-node .label,#mermaid-svg-5JdcF2A5JI78odM8 .node .label,#mermaid-svg-5JdcF2A5JI78odM8 .image-shape .label,#mermaid-svg-5JdcF2A5JI78odM8 .icon-shape .label{text-align:center;}#mermaid-svg-5JdcF2A5JI78odM8 .node.clickable{cursor:pointer;}#mermaid-svg-5JdcF2A5JI78odM8 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5JdcF2A5JI78odM8 .arrowheadPath{fill:#333333;}#mermaid-svg-5JdcF2A5JI78odM8 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5JdcF2A5JI78odM8 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5JdcF2A5JI78odM8 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5JdcF2A5JI78odM8 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5JdcF2A5JI78odM8 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5JdcF2A5JI78odM8 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5JdcF2A5JI78odM8 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5JdcF2A5JI78odM8 .cluster text{fill:#333;}#mermaid-svg-5JdcF2A5JI78odM8 .cluster span{color:#333;}#mermaid-svg-5JdcF2A5JI78odM8 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5JdcF2A5JI78odM8 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5JdcF2A5JI78odM8 rect.text{fill:none;stroke-width:0;}#mermaid-svg-5JdcF2A5JI78odM8 .icon-shape,#mermaid-svg-5JdcF2A5JI78odM8 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5JdcF2A5JI78odM8 .icon-shape p,#mermaid-svg-5JdcF2A5JI78odM8 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5JdcF2A5JI78odM8 .icon-shape .label rect,#mermaid-svg-5JdcF2A5JI78odM8 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5JdcF2A5JI78odM8 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5JdcF2A5JI78odM8 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5JdcF2A5JI78odM8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 返回tool call
结果
聚合
Token
Token
OTLP
HTTP请求span
invoke_agent support-bot
chat model-A
execute_tool search_orders
订单服务HTTP/DB spans
chat model-A
最终回答
调用时长与工具次数Metrics
OpenTelemetry Collector
Trace/Metric/Log后端
不要把"Agent思考"虚构成无法验证的内部步骤。可观测性应记录应用真正观察到的操作:调用模型、接收tool call、执行函数、访问数据库、重试与返回。若框架有明确的plan阶段,可以按约定记录;若只是从自然语言中猜测"它大概在规划",不应创建看似精确的span。
2. GenAI语义约定的关键边界
模型推理span表示调用生成式AI模型或服务并获得响应,通常使用CLIENT kind;同进程模型可以使用INTERNAL。通用命名建议是{gen_ai.operation.name} {gen_ai.request.model},例如chat model-a。关键字段包括gen_ai.operation.name、gen_ai.provider.name、请求模型、响应模型、输入/输出Token和finish reasons。具体字段要求会随约定版本演进,应以固定版本文档为准。
本地Agent执行用invoke_agent,span名在名称可用时为invoke_agent {gen_ai.agent.name},kind为INTERNAL。它覆盖一次Agent调用,而不是整个用户会话。多轮对话可以通过会话或对话标识关联,但不要让一个span跨越数小时等待用户输入;长span既难采样,也模糊了真正的活跃耗时。
工具执行使用gen_ai.operation.name=execute_tool,span名为execute_tool {gen_ai.tool.name},kind为INTERNAL。gen_ai.tool.call.id可把模型输出的工具调用与执行关联起来。工具内部若发HTTP或数据库请求,已有自动插桩会在该span下继续产生子span。这样慢点究竟在Agent编排、工具包装器还是下游服务,一眼可以分开。
工具参数和结果属于Opt-In内容,可能包含口令、查询语句、个人信息和大段文档。默认只记录工具名、类型、调用ID、结果状态和大小等元数据。只有在明确的调试环境、经过字段级脱敏并有严格访问控制时,才短期捕获内容。官方2026年文章也强调,GenAI内容默认不捕获,完整prompt、tool schema、arguments与results需要显式开启。
错误遵循OpenTelemetry通用错误约定。操作因错误结束时设置错误状态并记录error.type;若模型正常返回"工具业务拒绝",则不一定是span异常。异常、业务失败和模型finish reason是三个维度,不能用一个success=false抹平。
3. 最小可运行的Python插桩
安装API、SDK和OTLP exporter即可手工插桩。库作者通常只依赖API,由宿主应用决定SDK与导出;业务应用则初始化TracerProvider和Exporter。开发环境可先使用console exporter看清span结构,生产环境通过OTLP发给Collector,不要让每个服务直接绑定特定厂商SDK。
text
opentelemetry-api
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-http
下面的例子构造一个最小Agent:先调用模型决定是否查天气,执行工具后再次调用模型。模型函数通过依赖注入模拟,代码本身不依赖某家API。关键点是span边界围绕真实操作,Token只在模型响应可用时记录,工具异常由上下文自动关联到当前span。
python
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
def configure_tracing() -> trace.Tracer:
provider = TracerProvider(resource=Resource.create({
"service.name": "weather-agent",
"service.version": "1.0.0",
"deployment.environment.name": "development",
}))
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
return trace.get_tracer("example.weather-agent", "1.0.0")
def call_model(
tracer: trace.Tracer,
messages: list[dict[str, str]],
request: Callable[[list[dict[str, str]]], dict[str, Any]],
) -> dict[str, Any]:
configured_model = "demo-model"
with tracer.start_as_current_span("chat demo-model", kind=trace.SpanKind.CLIENT) as span:
span.set_attribute("gen_ai.operation.name", "chat")
span.set_attribute("gen_ai.provider.name", "example")
span.set_attribute("gen_ai.request.model", configured_model)
try:
response = request(messages)
except TimeoutError:
span.set_attribute("error.type", "timeout")
span.set_status(trace.Status(trace.StatusCode.ERROR, "model timeout"))
raise
usage = response.get("usage", {})
if isinstance(usage.get("input_tokens"), int):
span.set_attribute("gen_ai.usage.input_tokens", usage["input_tokens"])
if isinstance(usage.get("output_tokens"), int):
span.set_attribute("gen_ai.usage.output_tokens", usage["output_tokens"])
if isinstance(response.get("model"), str):
span.set_attribute("gen_ai.response.model", response["model"])
return response
def execute_tool(
tracer: trace.Tracer,
name: str,
call_id: str,
invoke: Callable[[], str],
) -> str:
with tracer.start_as_current_span(f"execute_tool {name}") as span:
span.set_attribute("gen_ai.operation.name", "execute_tool")
span.set_attribute("gen_ai.tool.name", name)
span.set_attribute("gen_ai.tool.type", "function")
span.set_attribute("gen_ai.tool.call.id", call_id)
try:
return invoke()
except Exception as exc:
span.set_attribute("error.type", type(exc).__name__)
span.set_status(trace.Status(trace.StatusCode.ERROR, str(exc)))
raise
def run_agent(
tracer: trace.Tracer,
question: str,
model: Callable[[list[dict[str, str]]], dict[str, Any]],
weather: Callable[[str], str],
) -> str:
with tracer.start_as_current_span("invoke_agent weather-assistant") as span:
span.set_attribute("gen_ai.operation.name", "invoke_agent")
span.set_attribute("gen_ai.agent.name", "weather-assistant")
span.set_attribute("gen_ai.agent.version", "1.0.0")
messages = [{"role": "user", "content": question}]
first = call_model(tracer, messages, model)
tool_call = first.get("tool_call")
if not isinstance(tool_call, dict):
return str(first["text"])
result = execute_tool(
tracer, "get_weather", str(tool_call["id"]),
lambda: weather(str(tool_call["city"])),
)
messages.append({"role": "tool", "content": result})
return str(call_model(tracer, messages, model)["text"])
if __name__ == "__main__":
tracer = configure_tracing()
calls = iter([
{"model": "demo-model-1", "usage": {"input_tokens": 9, "output_tokens": 4},
"tool_call": {"id": "call-1", "city": "上海"}},
{"model": "demo-model-1", "usage": {"input_tokens": 18, "output_tokens": 8},
"text": "上海今天多云。"},
])
answer = run_agent(tracer, "上海天气如何?", lambda _: next(calls), lambda _: "多云")
assert answer == "上海今天多云。"
这段示例没有记录prompt与tool arguments,只记录低敏感元数据。Token字段来自provider响应,而不是用字符串长度估算。不同模型的Tokenizer不同,客户端估算可以用于预算预警,却不应冒充账单事实。若provider未返回usage,应让字段缺失,并用"usage缺失率"监控插桩质量。
还要避免重复插桩。若模型SDK已经自动产生符合语义约定的span,业务层不要再包一个同含义的chat span,否则每次调用会出现两份时长和Token。业务层保留invoke_agent,让SDK负责模型CLIENT span;工具若已有MCP插桩,也应明确哪一层拥有execute_tool语义。
4. Trace Context如何跨过工具与队列
同步HTTP工具通常由OpenTelemetry HTTP自动插桩注入traceparent和tracestate,下游提取后生成子span。MCP 2026版也明确记录了W3C Trace Context在_meta中的键名。无论走哪种传输,传播的是上下文,不是把trace ID手工拼进日志字符串。
异步队列更容易断链。生产者发送任务时注入上下文,消费者提取并创建CONSUMER或内部span;任务可能排队很久,排队时间与执行时间要区分。对于数小时后台任务,可以用span link关联创建请求,而不是让入口HTTP span一直保持开启。
不要接受外部任意baggage并直接复制到metric label。baggage会跨服务传播,若放入用户输入、邮箱或会话ID,不仅泄露信息,还会制造高基数。只允许少量经过审核的键,并在信任边界过滤。
一次Agent调用可能并行执行多个工具,trace天然能表示这些分支。不要依赖span展示顺序判断因果,父子关系和links才是可靠结构。工具结果回到模型时,可以通过tool call ID关联,但这个ID也不宜作为metric维度。
5. Metrics:从单次证据走向总体趋势
Trace适合回答"这一单为什么慢",Metric适合回答"过去一小时是否普遍变慢"。官方GenAI约定包含模型客户端操作时长、Token使用、Agent调用时长、Agent工具调用次数以及工具执行时长等指标。具体名称与属性仍可能演进,必须与采用的语义版本一致。
直方图比平均值更有用。模型延迟常呈长尾,平均数会掩盖少量极慢请求;Token也需要观察分布,而不是只看总和。按gen_ai.request.model、provider、operation和低基数Agent名称切分通常有意义,按用户ID、prompt hash、tool call ID或错误消息切分会造成时序爆炸。
工具调用次数是Agent循环失控的直接信号。一次调用从通常一两个工具突然变为数十个,可能是模型重复选择、工具结果格式不稳定或终止条件失效。只看最终成功率会漏掉这种"成功但昂贵"的退化。相应告警应同时考虑样本量,低流量窗口中的单次异常不宜触发大规模事故响应。
成本可以由Token乘以价格表估算,但价格不是OpenTelemetry语义的一部分,且会随供应商、缓存Token和套餐变化。更稳妥的是保留标准Token metric,在报表层按带生效日期的价格维表计算,明确区分"观测用估算"和供应商最终账单。
下面建立三类低基数指标,并用一个上下文管理器记录工具时长。它展示的是手工插桩方式;若SDK或框架已经发出同名标准指标,应复用而不是重复计数。
python
from __future__ import annotations
import time
from contextlib import contextmanager
from collections.abc import Iterator
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader
reader = PeriodicExportingMetricReader(ConsoleMetricExporter(), export_interval_millis=5000)
metrics.set_meter_provider(MeterProvider(metric_readers=[reader]))
meter = metrics.get_meter("example.genai-metrics", "1.0.0")
model_duration = meter.create_histogram(
"gen_ai.client.operation.duration", unit="s",
description="GenAI client operation duration",
)
token_usage = meter.create_histogram(
"gen_ai.client.token.usage", unit="{token}",
description="Number of input or output tokens used",
)
tool_duration = meter.create_histogram(
"gen_ai.execute_tool.duration", unit="s",
description="Tool execution duration",
)
def record_model_call(model: str, elapsed: float, input_tokens: int, output_tokens: int) -> None:
common = {"gen_ai.operation.name": "chat", "gen_ai.request.model": model,
"gen_ai.provider.name": "example"}
model_duration.record(elapsed, common)
token_usage.record(input_tokens, common | {"gen_ai.token.type": "input"})
token_usage.record(output_tokens, common | {"gen_ai.token.type": "output"})
@contextmanager
def measure_tool(name: str) -> Iterator[None]:
started = time.perf_counter()
error_type = ""
try:
yield
except Exception as exc:
error_type = type(exc).__name__
raise
finally:
attrs = {"gen_ai.tool.name": name}
if error_type:
attrs["error.type"] = error_type
tool_duration.record(time.perf_counter() - started, attrs)
if __name__ == "__main__":
record_model_call("demo-model", 0.25, 12, 5)
with measure_tool("get_weather"):
result = "多云"
assert result == "多云"
生产指标通常由Collector或后端聚合。客户端不要把完整URL、原始异常文本和动态模型响应ID当属性。若确实需要按租户排查,优先在trace里用受控、可访问隔离的租户标识,而不是给每条metric加tenant ID。
6. 内容捕获、脱敏与采样
GenAI可观测性的最大诱惑是记录内容,因为内容最能解释模型行为;最大风险也来自内容,因为prompt可能包含个人信息、商业秘密、访问令牌、检索文档和工具结果。默认关闭内容捕获是一条合理基线。元数据已经能解决大量延迟、错误和成本问题。
需要内容调试时,可以采用四层控制。第一,按环境关闭生产默认捕获;第二,只对特定trace或故障类型采样;第三,依据结构化字段脱敏,而不是用一条正则处理所有文本;第四,把内容与普通trace分开存储,使用更严格权限和更短保留期。访问内容本身也要审计。
"先全量采集,再在Collector删除"并不总是安全,因为敏感内容已经进入进程内队列、网络和Collector。能在插桩点不生成,就不要生成。若框架自动捕获内容,要明确关闭开关,并用测试检查导出的OTLP,而不是只看配置文件。
采样也影响因果链。头部采样在请求开始时不知道后面会不会失败,尾部采样可以在Collector看到完整trace后保留错误、极慢和高Token调用。尾部采样需要更多缓冲资源,因此应设置等待窗口、内存上限和降级策略。无论哪种采样,都不能把metric建立在"只保留错误trace"的样本上,否则成功率和Token总量会严重偏差。
7. 错误、重试与取消应该怎样记录
模型SDK的自动重试通常属于一次逻辑操作,语义约定建议span覆盖包含所有自动重试的完整时长。若能观察每次尝试,可以添加受控event或子span,但必须避免把一次调用的Token重复计入。业务层显式重试则往往是多个独立模型调用,应分别建span,并用父Agent span关联。
限流错误要记录稳定的error.type,不要把包含request ID和动态文本的异常消息用作metric维度。HTTP 429、provider限流类型和本地预算拒绝应可区分。工具返回isError或领域拒绝时,根据其是否属于操作异常决定span状态,同时在业务结果字段中保留可查询类别。
用户取消流式响应时,span应在取消实际发生后结束,不能继续等后台生成而显示"成功"。如果provider仍计费,usage可能晚于取消返回;观测系统要允许Token缺失或延迟补充,不能编造零值。零表示确实使用了零个Token,缺失表示未知,两者业务含义不同。
超时要分层:HTTP入口截止时间、Agent总预算、模型单次超时、工具超时各自独立。子操作不能拥有比父预算更长的无限超时。trace中记录真实结束点后,开发者才能判断是模型慢,还是上层过早取消。
8. Collector是治理中心,不只是转发器
所有服务直连厂商后端会让采样、脱敏、重试和凭据散落。Collector提供统一OTLP入口,可以批处理、内存限流、资源属性补全、敏感字段删除、尾部采样和多后端导出。应用只负责产生正确语义,环境负责决定数据去向。
部署Collector时要防止观测系统反过来拖垮业务。SDK导出使用批处理和有界队列,Collector设置memory limiter与batch processor,出口故障时允许丢弃低价值遥测而不是阻塞用户请求。可观测性失败不应改变工具执行结果,但丢弃率本身要有内部遥测。
资源属性记录稳定的服务身份,例如service.name、版本与部署环境。不要把agent名称只放在service.name里,因为一个服务可能运行多个Agent版本;也不要把每个会话当成服务实例。资源描述"谁在运行",span描述"做了什么"。
语义版本升级应经过转换和回归。字段重命名时,Collector可在过渡期把旧厂商字段规范化为gen_ai.*,但要标明来源,避免同一调用同时存在两套互相矛盾的Token。2026年的OpenTelemetry Demo就加入了GenAI normalizer processor,用来把生态遥测转换到官方语义约定,这说明规范统一往往需要现实的适配层。
9. 从Dashboard到SLO
第一张Dashboard不要追求华丽,先包含四块:Agent端到端时长与错误率、模型调用时长与Token分布、工具时长与失败类型、每次Agent调用的工具次数。再按环境、服务版本、Agent名称、模型和工具等低基数维度过滤。
告警围绕用户可感知结果建立。入口请求错误率升高但模型和工具正常,可能是编排或输出解析问题;模型延迟正常而Agent总时长上升,可能是工具循环;输出Token突然下降同时finish reason变化,可能是上限配置错误。单看某一个vendor指标很难得出这些结论,trace树和标准metric需要配合。
SLO应把"成功"定义清楚。HTTP 200不代表Agent完成任务,模型自然语言道歉也不等于工具成功。可以在业务层记录最终结果类别,并通过trace关联到失败步骤。质量正确性还需要离线评测与在线反馈,可观测性只能说明系统如何运行,不能仅凭延迟和无异常证明答案正确。
发布验证可以对比Agent版本的p50/p95时长、每调用工具次数、Token分布、错误类别和usage缺失率。任何数字都应来自自己的流量基线,不能照抄别人的阈值。低流量服务应使用更长窗口或事件审查,避免百分位不稳定。
10. 常见故障与排查路径
如果所有span都是孤岛,先检查入口是否提取trace context、异步任务是否注入与提取、反向代理是否保留标准头。不要手工覆盖trace ID。跨信任域接收外部上下文时,还要验证格式并控制baggage。
如果一个模型调用出现两条几乎相同的span,检查是否同时启用了自动插桩和业务包装。确定语义所有者:SDK负责模型CLIENT span,应用负责Agent和自定义工具,HTTP库负责网络子span。少一层比重复一层更容易理解。
如果Token总量与账单相差很大,检查usage是否覆盖流式完成、缓存Token、重试与失败调用,字段单位是否一致,采样是否误用于聚合。账单仍是财务事实,OpenTelemetry数据用于工程归因,两者应定期对账而不是强行视为完全相等。
如果观测平台费用暴涨,先查看span体积和属性基数。完整prompt、工具结果、向量、动态错误消息和用户ID都是高风险项。删除无用内容通常比调高后端配额更有效。再检查Agent循环是否真的增加了调用数量,费用上涨也可能是业务故障的症状。
如果错误span很多但用户无感,检查业务拒绝是否被错误标记为异常;如果用户失败但trace全绿,检查代码是否吞掉异常、只在返回文本中表达失败。错误语义要与实际操作边界一致,不能为了Dashboard好看把失败标成成功。
11. 把在线观测与离线评测连接起来
延迟低、没有异常、Token少,只能证明系统运行得顺,不证明答案正确。Agent可能快速调用了错误工具,也可能用很少Token自信地给出错误结论。因此线上trace与离线评测需要共享可关联但低敏感的标识,例如Agent版本、Prompt模板版本、工具集合版本和评测数据集版本。
评测任务调用真实Agent时,同样创建invoke_agent根span,但资源属性标记为evaluation环境,避免与生产SLO混合。每条样本的期望结果留在评测存储,不必放进span;trace只记录不可逆样本ID、执行路径和结果类别。失败样本可通过trace定位究竟是检索、工具、模型还是解析器造成,再把根因转成回归用例。
发布前可以比较候选版本与基线版本:任务通过率属于评测系统,端到端时长、工具次数和Token分布来自OpenTelemetry。两组数据联合才能识别"质量略升但成本翻倍"或"延迟下降但工具选择退化"。不要把一个综合分数掩盖权衡,应分别设定质量门槛与资源预算。
线上用户反馈也可形成受控event。点赞、人工纠错或升级客服是一个时间点事件,携带结果类别和当前trace关联信息即可,不应复制整段对话。反馈可能晚于Agent调用,使用link或业务关联ID连接,不要让原span一直开着等待几天后的评价。
发现生产失败后,回放必须先脱敏并隔离副作用。不能为了复现把原始工具调用直接重放到支付、邮件或删除接口。为工具提供只读模拟器或录制的响应,再比较Agent路径。trace是诊断证据,不是获得用户授权重新执行操作的凭证。
12. Schema治理与插桩契约测试
GenAI语义约定独立演进,字段稳定性并不完全一致。团队应建立一份采用清单:语义约定提交或发布版本、SDK版本、自动插桩包、Collector转换规则以及Dashboard查询。升级任意一层时运行契约测试,检查实际导出的OTLP,而不是只靠类型检查。
契约测试可以启动内存或测试Exporter,执行一次确定性Agent:一次模型调用、一次工具调用、再一次模型调用。断言存在一个invoke_agent、两个推理span和一个execute_tool,父子关系正确;Token字段只出现在模型span;工具名和调用ID存在;prompt、令牌和工具结果未被默认导出。这个小场景比大量mock测试更能发现重复插桩与字段漂移。
自定义字段使用组织命名空间,并记录何时可以删除。若标准后来提供相同含义字段,先双写一段短期窗口并验证查询,再迁移Dashboard,最后删除自定义字段。不要把自定义属性直接命名为尚未标准化的gen_ai.*,否则未来官方字段同名但语义不同,数据将无法区分。
Collector中的转换也要版本化。处理器把旧字段映射到新字段时,遇到目标字段已存在应定义优先级并计数冲突,不能静默覆盖。转换前后的样本OTLP作为golden文件保存,升级Collector后对比。观测管线本身需要发布流程,因为一条错误转换规则能让所有服务的成本报表同时失真。
属性基数可以设置自动守卫:Collector统计每个键的估计唯一值,超过预算时删除、哈希或降级到日志,并告警给插桩所有者。守卫是最后防线,不应替代代码评审。模型响应ID、会话ID和用户ID几乎总是不适合作为metric attribute,即使某次压测中基数看起来不高。
13. 安全事件中的可观测性
Agent安全事故往往表现为正常API调用:模型因提示注入选择了高风险工具,工具也用合法凭据执行。仅靠异常率无法发现。观测模型应记录经过归类的工具风险级别、授权决策结果、是否经过人工确认和策略版本,但不要记录密钥或原始敏感参数。
例如邮件发送工具的span可以显示工具名、风险类别、策略允许或拒绝以及审批模式。若同一Agent版本突然大量调用高风险工具,即使全部成功,也值得调查。策略拒绝不是系统异常,却是安全指标;它可以作为event或独立计数器,不能为了降低"错误率"而完全丢弃。
Prompt Injection文本本身不宜全量进入trace。可以记录检测器版本、规则类别和决策,并把需要人工分析的少量内容写入隔离证据库。证据库使用独立密钥、短保留期和严格访问审批。普通研发看得到"因为外部文档指令被拒绝",不一定需要看到文档中的个人信息。
trace context也可能被滥用。外部调用方可以伪造traceparent尝试与内部trace关联,或通过baggage传播恶意高基数字段。入口应使用标准解析器,按信任策略决定继承、重建或link外部上下文,并过滤baggage白名单。trace ID不是认证令牌,知道它不能获得对应数据的访问权。
审计日志和调试trace用途不同。涉及谁在何时批准了什么操作的记录,应进入防篡改、保留期明确的审计系统;trace可以引用审计事件ID,却不应被当成唯一审计账本。采样、过期和后端故障都可能让trace不完整,而合规审计通常要求更强保证。
14. 上线边界与演进策略
OpenTelemetry不会自动修复慢模型、错误工具或Prompt Injection。它让这些问题可定位,并为容量、预算和安全审计提供证据。内容捕获更不是安全防护;若工具权限过大,trace只能记录越权已经发生。授权、沙箱、输入验证和人工确认仍必须在执行路径上。
GenAI语义约定处于Development的部分可能发生不兼容变化。固定依赖和约定版本,维护一小层集中映射,契约测试检查导出的span名、kind和关键属性。不要把属性名散落在数百个业务函数里,也不要为了"未来所有provider"设计庞大抽象;先覆盖实际使用的Agent、模型和工具边界。
最小上线顺序是:先贯通入口、Agent、模型与工具trace;再加入低基数延迟和Token metric;然后配置Collector批处理、脱敏与采样;最后才在受控场景启用内容。每一步都验证OTLP实际输出和后端查询,而不是只验证代码没有报错。
总结
GenAI系统难以排障,不是因为没有日志,而是因为模型、工具、重试和Token缺少共同因果结构。OpenTelemetry用invoke_agent、模型推理和execute_tool等语义把真实操作连成span树,再用标准metric观察延迟、Token和工具次数的总体分布。
可靠方案要坚持四条边界:span围绕真实操作而非想象中的思考;内容默认不采集,必要时按字段脱敏和短期采样;metric只使用低基数维度;语义约定固定版本并通过Collector集中治理。这样当Agent再次"慢、贵、错"时,团队看到的不再是一句失败文本,而是一条可以定位、量化和回归验证的执行链。
参考资料
- OpenTelemetry:Inside the LLM Call,GenAI可观测性实践
- OpenTelemetry GenAI语义约定仓库
- GenAI Client Spans语义约定
- GenAI Agent与Framework Spans语义约定
- GenAI Metrics语义约定
- OpenTelemetry Python手工插桩
- OpenTelemetry Python文档
- OpenTelemetry Collector配置文档
- OpenTelemetry通用错误记录约定
- W3C Trace Context
- OpenTelemetry:AI Agent Observability
- OpenTelemetry Demo 3.0与GenAI normalizer
- OpenTelemetry语义约定总览