一个能跑的 Agent 和一个能运维的 Agent 之间,差的是可观测性。普通 LLM 调用出问题,你翻一条请求日志就能定位;Agent 出问题,你面对的是几十个 span、若干次工具调用、一轮又一轮的循环,以及"这次为什么多绕了三步"这种无法从单条日志回答的问题。本文把 Agent 可观测性拆成三块:差异认知、语义约定、接入落地,最后落到采样与成本。
为什么 Agent 需要独立的可观测体系
从单次调用到多步循环
普通 LLM 调用的观测模型是"一次请求对应一次响应",天然是一层的。Agent 的观测模型是"一次用户请求触发一棵执行树",树的形状在运行时才确定,取决于模型的每一次决策。这带来一个根本差异:你无法预先定义好要采集什么,只能按统一的语义约定去采集,让树自己长出来。
三个结构性差异
| 维度 | 普通 LLM 调用 | Agent 服务 |
|---|---|---|
| 调用次数 | 每次请求 1 次模型调用 | 每次会话 3 到 50 次不等 |
| 控制流 | 线性,无分支 | 循环加分支,含提前终止 |
| 工具调用 | 无 | 每次工具调用是一个独立 span |
| 输出确定性 | 采样温度决定 | 采样温度加工具返回加循环共同决定 |
| 失败模式 | 超时、限流、内容拦截 | 上述全部,外加死循环、工具报错、上下文溢出 |
| 延迟归因 | 单一 P95 即可 | 必须拆到模型时间与工具时间 |
多步与循环带来的归因难题
一次会话的端到端延迟是 8.2 秒,这个数字本身没有信息量。你要回答的是:模型生成占了 5.1 秒,工具执行占了 2.6 秒,排队与网络占了 0.5 秒;其中工具里有一次数据库查询重试了两次。没有 span 层级,这些答案都不存在。
更麻烦的是循环。Agent 可能在"检索、评估、再检索"之间来回,也可能陷入"调用同一个工具、拿到同样的错误、再调用一次"的死循环。可观测性要能同时回答两个问题:这次循环是合理的多轮推理,还是病态的重复?前者要看每一步的输入是否在变化,后者要看工具返回是否收敛。
工具调用是新的故障面
工具是 Agent 与外部世界的接口,也是绝大多数线上事故的发生地。工具超时、鉴权失效、参数拼错、返回结构变更,都会以"模型输出很奇怪"的表象暴露出来。如果没有为每次工具调用单独打 span 并记录入参与返回摘要,你只能看到模型在胡言乱语,而看不到根因。
非确定性让对比失去基线
同一个输入两次运行,步数可能从 6 步变成 11 步。这意味着你不能用"这次和上次是否一致"来判断健康,只能用量化分布:步数的 P50 与 P95、工具成功率的滑动窗口、token 消耗的分位数。关于多模型与多版本共存下的对比口径,可参考 模型网关与多模型路由 。
OpenTelemetry GenAI 语义约定
为什么统一到 OpenTelemetry
自研埋点在早期很快,但当你接入第二个模型供应商、第三个向量库、第四个 Agent 框架时,字段名就会失控。OpenTelemetry 的 GenAI 语义约定把模型调用、token 用量、工具调用这些概念标准化成固定属性名,让后端无论用 Langfuse、Phoenix 还是自建 Jaeger 都能解析同一份数据。
span 层级模型
推荐的四层结构如下表。层级的价值在于:任何一层的异常都能沿父链回溯,而不需要在日志里做字符串关联。
| 层级 | span 名称 | 父 span | 关键属性 |
|---|---|---|---|
| 会话层 | agent.session | 无(root) | session.id、user.id、agent.name |
| 编排层 | agent.invoke | agent.session | agent.step.index、agent.step.total |
| 模型层 | llm.chat | agent.invoke | gen_ai.request.model、gen_ai.usage.input_tokens |
| 工具层 | tool.execute | agent.invoke | tool.name、tool.call.id、tool.status |
关键属性清单
| 属性名 | 类型 | 示例值 | 说明 |
|---|---|---|---|
gen_ai.system | string | openai、anthropic | 供应商标识 |
gen_ai.request.model | string | gpt-4o-2024-08-06 | 请求时指定的模型 |
gen_ai.response.model | string | gpt-4o-2024-08-06 | 实际服务的模型快照 |
gen_ai.usage.input_tokens | int | 2841 | 输入 token 数 |
gen_ai.usage.output_tokens | int | 412 | 输出 token 数 |
gen_ai.request.temperature | double | 0.2 | 采样温度 |
gen_ai.request.max_tokens | int | 2048 | 输出上限 |
gen_ai.response.finish_reasons | string[] | ["stop"]、["tool_calls"] | 结束原因 |
tool.name | string | search_docs | 工具名 |
tool.call.id | string | call_abc123 | 与模型返回的 id 对齐 |
注意 gen_ai.request.model 与 gen_ai.response.model 的区别。前者是你要的,后者是实际给的。当供应商在服务端做版本别名替换时,只有后者能解释质量漂移。
span 命名与基数控制
span 名称必须是低基数的。正确做法是 llm.chat 加 gen_ai.request.model 属性;错误做法是把模型名拼进 span 名称变成 llm.chat.gpt-4o-2024-08-06,这会让后端的 span 名称数量随模型版本无限增长,直接拖垮聚合查询。同理,tool.execute 不要拼工具名,工具名放属性。
接入实践
方案一 手动埋点加 OpenTelemetry
手动埋点的好处是字段完全可控,适合已有 OpenTelemetry 基础设施的团队。
"""agent_tracing.py 手动埋点,OpenTelemetry SDK 1.27.0 加 GenAI 语义约定"""
from contextlib import contextmanager
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.sdk.resources import Resource
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
resource = Resource.create({
"service.name": "agent-runtime",
"service.version": "2.4.0",
"deployment.environment": "prod",
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces"),
max_queue_size=8192,
schedule_delay_millis=2000,
)
)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("agent.runtime", "2.4.0")
@contextmanager
def llm_span(model: str, temperature: float, max_tokens: int):
"""包住一次模型调用,退出时自动写入 token 用量"""
with tracer.start_as_current_span("llm.chat") as span:
span.set_attribute("gen_ai.system", "openai")
span.set_attribute("gen_ai.request.model", model)
span.set_attribute("gen_ai.request.temperature", temperature)
span.set_attribute("gen_ai.request.max_tokens", max_tokens)
try:
yield span
except Exception as exc:
span.set_attribute("error.type", type(exc).__name__)
span.record_exception(exc)
raise
@contextmanager
def tool_span(name: str, call_id: str):
"""包住一次工具调用,记录耗时与结果状态"""
with tracer.start_as_current_span("tool.execute") as span:
span.set_attribute("tool.name", name)
span.set_attribute("tool.call.id", call_id)
try:
yield span
span.set_attribute("tool.status", "ok")
except Exception as exc:
span.set_attribute("tool.status", "error")
span.record_exception(exc)
raise
def run_agent(session_id: str, user_input: str, max_steps: int = 12):
with tracer.start_as_current_span("agent.session") as session:
session.set_attribute("session.id", session_id)
for step in range(max_steps):
with tracer.start_as_current_span("agent.invoke") as step_span:
step_span.set_attribute("agent.step.index", step)
with llm_span("gpt-4o-2024-08-06", 0.2, 2048) as llm:
# 实际调用替换为你的 SDK 调用
resp = call_model(user_input)
llm.set_attribute("gen_ai.usage.input_tokens", resp.usage.input_tokens)
llm.set_attribute("gen_ai.usage.output_tokens", resp.usage.output_tokens)
llm.set_attribute("gen_ai.response.finish_reasons", [resp.finish_reason])
if resp.finish_reason != "tool_calls":
return resp.content
for tc in resp.tool_calls:
with tool_span(tc.name, tc.id) as tool:
tool.set_attribute("tool.args.size", len(str(tc.arguments)))
dispatch_tool(tc)
session.set_attribute("agent.terminated", "max_steps_exceeded")
return None
关键点是 agent.terminated 这个属性。它让"被 max_steps 截断的会话"成为一个可聚合的指标,而不是消失在返回的 None 里。
方案二 OpenLLMetry 自动埋点
如果不想改业务代码,OpenLLMetry 的 traceloop-sdk 可以在导入时对主流 SDK 做 monkey patch。
"""auto_tracing.py OpenLLMetry 自动埋点,traceloop-sdk 0.35.0"""
from traceloop.sdk import Traceloop
from traceloop.sdk.decorators import workflow, task, agent, tool
Traceloop.init(
app_name="agent-runtime",
api_endpoint="http://otel-collector:4318",
disable_batch=False,
resource_attributes={"deployment.environment": "prod"},
)
@tool(name="search_docs")
def search_docs(query: str, top_k: int = 5) -> list[dict]:
return vector_store.search(query, top_k=top_k)
@task(name="plan")
def plan(goal: str) -> list[str]:
return call_model(goal).content
@workflow(name="research_agent")
def research_agent(question: str) -> str:
steps = plan(question)
for step in steps:
search_docs(step, top_k=5)
return call_model(question).content
装饰器的语义是:@workflow 对应 agent.session,@agent 与 @task 对应 agent.invoke,@tool 对应 tool.execute。自动埋点覆盖了模型调用的 token 统计,但业务语义属性(比如租户 id、实验分组)仍然要手动补。
版本与依赖矩阵
| 组件 | 版本 | 用途 | 备注 |
|---|---|---|---|
opentelemetry-sdk | 1.27.0 | 手动埋点基座 | 与 1.26 兼容 |
opentelemetry-exporter-otlp-proto-http | 1.27.0 | OTLP 上报 | 用 4318 端口 |
traceloop-sdk | 0.35.0 | 自动埋点 | 依赖 opentelemetry-instrumentation |
langfuse | 2.53.0 | trace 后端与评测 | Python SDK |
otel-collector | 0.109.0 | 采样与转发 | 部署为 DaemonSet |
postgresql | 16.4 | Langfuse 存储 | 生产建议加 ClickHouse |
Langfuse 从 2.x 开始原生支持 OTLP 摄入,因此可以把它当作 OpenTelemetry 后端,而不是绑定它的 SDK。关于向量检索侧的可观测细节,见 RAG 工程化 。
Langfuse 装饰器接入
如果团队希望快速拿到可读性强的 trace 面板,直接用 Langfuse SDK 是更短路径。
"""langfuse_tracing.py Langfuse SDK 2.53.0"""
from langfuse.decorators import observe, langfuse_context
from langfuse import Langfuse
langfuse = Langfuse(
public_key="pk-lf-xxx",
secret_key="sk-lf-xxx",
host="https://langfuse.internal",
release="2.4.0",
flush_at=64,
flush_interval=1.0,
)
@observe(as_type="generation", name="llm.chat")
def call_model(prompt: str, model: str = "gpt-4o-2024-08-06"):
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0.2,
)
langfuse_context.update_current_observation(
model=model,
usage={
"input": resp.usage.prompt_tokens,
"output": resp.usage.completion_tokens,
"unit": "TOKENS",
},
)
return resp.choices[0].message.content
@observe(name="agent.session")
def run_agent(session_id: str, question: str):
langfuse_context.update_current_trace(
session_id=session_id,
user_id="u_1024",
tags=["prod", "research"],
metadata={"agent.version": "2.4.0"},
)
return call_model(question)
update_current_trace 里的 session_id 是把多次请求聚合成一条会话线的关键。没有它,你只能看到一堆孤立 trace,无法回答"这个用户的第 3 轮追问为什么失败了"。
关键指标与看板
指标口径与阈值
| 指标 | 计算口径 | 健康区间 | 告警阈值 |
|---|---|---|---|
| 每会话步数 P50 | 会话内 agent.invoke 计数中位数 | 3 到 8 | 大于 12 |
| 每会话步数 P95 | 同上,95 分位 | 小于 15 | 大于 25 |
| 工具成功率 | tool.status=ok 除以总数 | 大于 99% | 小于 97% |
| 端到端 P95 延迟 | agent.session 时长 95 分位 | 小于 12 秒 | 大于 20 秒 |
| 模型时间占比 | llm.chat 总时长除以端到端 | 0.5 到 0.8 | 大于 0.9 |
| token 每会话 | input 加 output 之和 | 小于 40k | 大于 80k |
| 重试率 | 含重试标记的 span 占比 | 小于 2% | 大于 5% |
| 截断率 | agent.terminated 非空占比 | 小于 0.5% | 大于 2% |
每会话步数分布为什么比均值重要
均值会被长尾抹平。一个健康的 Agent,步数分布应该是右偏的:大量会话在 3 到 5 步结束,少量复杂问题到 10 步。如果分布出现双峰,通常意味着存在两类截然不同的流量,比如"闲聊"和"深度检索"混在同一条链路里,这时候应该分流而不是继续调参。
工具成功率要按工具拆
全局工具成功率 98% 可能掩盖了某个工具 70% 的失败率。按 tool.name 分组后,你往往能立刻发现是某个外部 API 的鉴权过期,或者某个检索工具在特定查询上稳定超时。
端到端延迟的三段拆解
把端到端延迟拆成排队、模型、工具三段。经验上,模型占 50% 到 80%,工具占 15% 到 40%,排队占不到 5%。如果工具占比超过 50%,优化方向是工具侧缓存与超时收紧;如果模型占比超过 90%,方向是提示瘦身与上下文裁剪。关于 token 消耗与成本的进一步核算,见 推理成本核算与 FinOps 。
token 分布与失败重试
token 分布要看输入侧。输入 token 的 P95 突然抬升,通常意味着对话历史没有被正确裁剪,或者检索注入的文档变多了。重试要单独打点:区分"模型返回格式错误后的自动重试"与"网络超时后的重试",前者是提示工程问题,后者是基础设施问题。
trace 采样策略与成本控制
头部采样与尾部采样
| 策略 | 决策时机 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| 头部采样 | trace 开始前 | 实现简单,开销恒定 | 会丢掉罕见错误 | 高流量、低成本诉求 |
| 尾部采样 | trace 结束后 | 可按错误与延迟保真 | 需 collector 缓存全量 span | 排障优先 |
| 分层采样 | 按会话分层 | 关键用户全采 | 需要用户分层能力 | 多租户 SaaS |
推荐组合:头部按 10% 基础采样,collector 侧开启尾部采样策略,对错误 trace、延迟超过 20 秒的 trace、以及标记了 debug=true 的 trace 强制全采。这样在成本可控的前提下保住排障样本。
采样率与成本的量化
假设每会话平均 14 个 span,每 span 平均 1.2 KB,日活会话 5 万。全量采集是 5 万乘 14 乘 1.2 KB 约 840 MB 每天,一年约 300 GB 原始数据,加上索引通常放大 2 到 3 倍。按对象存储每 GB 每月 0.023 美元估算,存储成本不高,但索引与查询成本会随基数上升而显著增长。
采样器实现
"""sampler.py 头部采样加错误保真"""
from opentelemetry.sdk.trace.sampling import Sampler, SamplingResult, Decision
from opentelemetry.trace import SpanKind
from opentelemetry import trace
class AdaptiveSampler(Sampler):
"""基础采样率 10%,错误与慢请求强制保留"""
def __init__(self, base_ratio: float = 0.1, slow_threshold_ms: float = 20000):
self.base_ratio = base_ratio
self.slow_threshold_ms = slow_threshold_ms
self._counter = 0
def should_sample(self, parent_context, trace_id, name, kind=SpanKind.INTERNAL,
attributes=None, links=None, trace_state=None):
attributes = attributes or {}
# 显式调试标记:全采
if attributes.get("debug") is True:
return SamplingResult(Decision.RECORD_AND_SAMPLE)
# 确定性哈希采样:同一 trace_id 结果稳定,父子一致
self._counter += 1
keep = (trace_id % 1000) < int(self.base_ratio * 1000)
decision = Decision.RECORD_AND_SAMPLE if keep else Decision.DROP
return SamplingResult(decision)
def get_description(self) -> str:
return f"AdaptiveSampler(base={self.base_ratio})"
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter(...)))
provider._sampler = AdaptiveSampler(base_ratio=0.1)
要点是用 trace_id 做确定性哈希,而不是用随机数。随机数会导致同一个 trace 的父 span 被采样、子 span 被丢弃,后端里出现断链。同时,采样决策必须发生在根 span,子 span 继承父决策。
采样对成本的真实影响
采样率从 100% 降到 10%,存储成本降到约十分之一,但排障能力不是线性下降,因为错误 trace 被强制保留。真正的风险是"低概率但高影响"的问题被基础采样漏掉,例如千分之一的死循环。缓解方式是给这类问题单独打一个计数器指标,让指标负责发现异常,trace 负责解释异常。
从 trace 到告警
为什么 trace 不能直接告警
trace 是采样的、逐条的、高基数的,用它直接做告警会同时踩三个坑:采样导致计数不准,逐条导致告警风暴,高基数导致查询超时。正确的分工是:指标负责发现,trace 负责解释。也就是说,告警规则建立在指标上,触发后附上几条代表性 trace 的 id 作为入口。
告警规则设计
| 告警名 | 数据源 | 表达式要点 | 阈值 | 处置动作 |
|---|---|---|---|---|
| 步数异常 | 直方图 agent_steps_bucket | P95 超过 25 且持续 10 分钟 | 25 步 | 检查提示是否被改动 |
| 工具失败 | 计数器 tool_calls_total | 按 tool.name 分组,失败率大于 3% | 3% | 检查外部依赖鉴权 |
| 端到端变慢 | 直方图 agent_session_duration_seconds | P95 大于 20 秒 | 20 秒 | 拆模型与工具耗时 |
| 截断率上升 | 计数器 agent_terminated_total | 截断占比大于 2% | 2% | 调整 max_steps 或提示 |
| token 膨胀 | 直方图 llm_input_tokens_bucket | P95 大于 80k | 80k | 检查上下文裁剪 |
| 成本超支 | 计数器 llm_cost_usd_total | 小时环比上升大于 40% | 40% | 检查路由与缓存 |
指标与 trace 的联动
每一条告警都应该能一键跳到 trace。实现方式是让指标带上 trace_id 作为 exemplar(OpenTelemetry 与 Prometheus 原生支持 exemplar),或者在日志里输出 trace_id 并在告警通知中附带最近 N 条超阈值的 trace id。没有这条通路,值班同学拿到告警后仍然要手动去 trace 后端大海捞针。
一个可用的查询示例
下面这段 SQL 假设 trace 已经落在 ClickHouse 里,用于找出"步数多且工具失败多"的会话,作为告警后的第一层筛选。
-- 找出过去 1 小时内最可疑的 20 个会话
SELECT
session_id,
countIf(span_name = 'agent.invoke') AS steps,
countIf(span_name = 'tool.execute') AS tool_calls,
countIf(span_name = 'tool.execute' AND status = 'error') AS tool_errors,
round(sum(input_tokens + output_tokens) / 1000, 1) AS ktokens,
round(max(duration_ms) / 1000, 2) AS session_seconds
FROM otel_spans
WHERE timestamp >= now() - INTERVAL 1 HOUR
AND service_name = 'agent-runtime'
GROUP BY session_id
HAVING steps >= 12 OR tool_errors >= 3
ORDER BY tool_errors DESC, steps DESC
LIMIT 20;
配套的索引建议是 (service_name, timestamp) 与 (session_id),前者支撑时间范围扫描,后者支撑按会话回溯整条链路。若日均 span 量超过 5 亿,把 otel_spans 按天分区,并把 attributes 列设为 Map(String, String) 而不是宽表,避免属性膨胀导致的写放大。
看板分层
一块好的 Agent 看板应该分三层:最上层是四个北极星数字(成功率、P95 延迟、每会话成本、截断率),中间层是分布图(步数分布、token 分布、工具耗时分布),最下层是明细入口(按会话、按租户、按版本的下钻)。三层看板的价值在于:值班时只看第一层,优化时看第二层,排障时进第三层。把这三层混在一块看板上,是团队里最常见的反模式。
常见坑清单
把 span 打爆导致采样失真
最常见的是在循环里为每次 token 生成、每次流式 chunk 都打一个 span。一个 20 步的会话可能产生上千个 span,采样器按 trace 计数时看起来正常,但后端存储与查询被拖垮,最终不得不把采样率压到 1%,反而丢掉了关键样本。规则是:一个逻辑操作一个 span,流式输出用 span event 而不是子 span。
敏感内容入库
把完整的用户输入、模型输出、工具返回原样写进 trace,等于把 PII 复制到了一个新的、通常权限更松的存储里。正确做法是默认只存长度、哈希与截断后的前 200 字符,敏感字段用白名单过滤,并给 trace 后端配置与业务库同等级别的访问审计。
异步上下文丢失 trace
Agent 大量使用 asyncio 与后台任务。如果在新任务里直接调用而不是复制 context,trace 会断成两截。
async def bad():
asyncio.create_task(handle_tool()) # 子任务里的 span 变成新的 root,链路断成两截
import contextvars
from opentelemetry import context as otel_context
async def good():
ctx = contextvars.copy_context()
asyncio.create_task(asyncio.to_thread(ctx.run, handle_tool))
同类问题还出现在线程池、Celery worker、以及基于回调的流式 SDK 中。
其余高频问题
- 只在成功路径打 span,异常路径直接抛出,导致错误 trace 完全没有工具入参,无法复现
- span 名称拼进模型名或工具名,导致基数爆炸与聚合查询超时
- 时间戳用了本地时间而不是 UTC,跨时区聚合出现负延迟
- 忘记设置
service.version,模型升级与提示改动后无法按版本对比指标 - 采样决策放在子 span,父采样子丢,链路断裂
- 把 trace 当唯一数据源,不做独立的计数器指标,采样一降就失去发现能力
- 工具入参记录为完整 JSON 对象,属性值超过后端单属性 2 KB 上限被静默截断
小结
Agent 可观测性的核心不是"多打日志",而是建立一棵可聚合、可回溯、成本可控的执行树。三条主线:统一到 OpenTelemetry GenAI 语义约定,让不同供应商与后端说同一种语言;按 agent.session、agent.invoke、llm.chat、tool.execute 四层建 span,并把租户、实验分组、版本作为属性而非名称;用头部加尾部混合采样,在成本与排障能力之间取平衡。落地顺序建议是先用 Langfuse 装饰器拿到可读面板,再把关键字段迁移到 OpenTelemetry 属性上,最后接尾部采样。指标口径一旦定下来,就不要随意改,否则历史曲线会断裂,趋势判断随之失效。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。