Agent 服务的可观测性与链路追踪

本文解决 Agent 上线后黑盒不可诊断的问题:讲清 Agent 与单次 LLM 调用在多步循环、工具调用上的可观测差异,给出 OpenTelemetry GenAI 语义约定的 span 层级与属性清单,并落地 Langfuse 与 OpenLLMetry 接入代码。文中给出每会话步数、工具成功率、P95 延迟等指标口径与阈值,并列出把 span 打爆、敏感内容入库、异步丢 trace 等坑。

一个能跑的 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.invokeagent.sessionagent.step.index、agent.step.total
模型层llm.chatagent.invokegen_ai.request.model、gen_ai.usage.input_tokens
工具层tool.executeagent.invoketool.name、tool.call.id、tool.status

关键属性清单

属性名类型示例值说明
gen_ai.systemstringopenai、anthropic供应商标识
gen_ai.request.modelstringgpt-4o-2024-08-06请求时指定的模型
gen_ai.response.modelstringgpt-4o-2024-08-06实际服务的模型快照
gen_ai.usage.input_tokensint2841输入 token 数
gen_ai.usage.output_tokensint412输出 token 数
gen_ai.request.temperaturedouble0.2采样温度
gen_ai.request.max_tokensint2048输出上限
gen_ai.response.finish_reasonsstring[]["stop"]、["tool_calls"]结束原因
tool.namestringsearch_docs工具名
tool.call.idstringcall_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-sdk1.27.0手动埋点基座与 1.26 兼容
opentelemetry-exporter-otlp-proto-http1.27.0OTLP 上报用 4318 端口
traceloop-sdk0.35.0自动埋点依赖 opentelemetry-instrumentation
langfuse2.53.0trace 后端与评测Python SDK
otel-collector0.109.0采样与转发部署为 DaemonSet
postgresql16.4Langfuse 存储生产建议加 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_bucketP95 超过 25 且持续 10 分钟25 步检查提示是否被改动
工具失败计数器 tool_calls_total按 tool.name 分组,失败率大于 3%3%检查外部依赖鉴权
端到端变慢直方图 agent_session_duration_secondsP95 大于 20 秒20 秒拆模型与工具耗时
截断率上升计数器 agent_terminated_total截断占比大于 2%2%调整 max_steps 或提示
token 膨胀直方图 llm_input_tokens_bucketP95 大于 80k80k检查上下文裁剪
成本超支计数器 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 属性上,最后接尾部采样。指标口径一旦定下来,就不要随意改,否则历史曲线会断裂,趋势判断随之失效。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 语义缓存与 Prompt 缓存
  2. 结构化输出与函数调用
  3. 多智能体编排与工作流引擎