1. 为什么说 AI Agent 的可观测性是一道“硬骨头”
这两年搞 AI Agent 开发的人越来越多了,如果你问一个真正在线上跑过 Agent 服务的工程师,什么是最让人头疼的环节,十有八九不是模型选型,也不是 Prompt 调优——而是“这玩意儿到底在干什么,我完全看不到”。
传统软件开发里的可观测性已经够折腾人了,但至少逻辑可控、变量可查、栈信息可回溯。AI Agent 完全不是这么回事。它的运行逻辑本质上是循环推理加工具调用的组合:大模型感知环境、生成下一动作、调用外部工具、解析返回结构、再思考再行动。这个循环里每一步都可能出问题,而且很多问题还是概率性的——同一个输入,今天跑通明天就挂,GPT 这次调用正常下次就开始胡说八道。你没法靠“复现”来排查问题,只能靠完整的观测链路来还原现场。
我之前在团队里推进 Agent 项目时就吃过这个亏。前期上线时做的是最基础的日志打印,结果一遇到线上用户反馈“Agent 回答得很奇怪”,我打开日志一看,好家伙,只有一行行孤立的 LLM 调用记录和工具返回结果,根本看不出它为什么要这么调用、为什么选了这个工具、中间经历了多少次失败的推理。完全是两眼一抹黑,排查一个看起来不复杂的问题花了我将近四个小时。
那次之后我悟出一个道理:Agent 的可观测性和传统可观测性有着本质区别。我们不是只关心“系统健康度”和“请求成功率”,而是要回答几个更深层的问题:
- Agent 当前的运行处于哪个阶段,是思考中还是调工具中
- 模型每一步的输入输出是什么,Prompt 到底传进去了什么
- 工具调用的参数拼接是否正确,返回信息有没有被模型正确理解
- 整个过程耗时多长,哪个环节是瓶颈
- 用户的最终体验和内部动作之间是怎么关联的
这几件事,没一个是传统监控面板能直接告诉你的。所以这篇内容我就来好好拆一下,AI Agent 的可观测性到底应该怎么做,从底层原理到落地实操,把我踩过的坑和沉淀的方法一次讲清楚。
2. 先搞懂 Agent 的运行机制,才能知道该观测什么
2.1 Agent 的循环推理结构
要想做好观测,第一步是理解你的 Agent 到底是怎么“思考”的。目前主流的 Agent 架构基本都遵循一个模式:模型与环境交互,通过工具调用完成任务。简化来看就是一个循环:
- 接收用户输入,系统 Prompt 与任务上下文组装完成
- 大模型根据当前状态推理,决定下一步动作
- 如果动作是调用工具,就生成结构化参数,执行工具调用
- 工具返回结果,拼接到消息历史中
- 模型拿到新上下文,继续推理,直到生成最终回复或达到最大轮次
这个循环本身就是可观测性的天然抓手。每一个循环单元都产生三类关键信息:模型请求的完整快照(含 Prompt)、工具的入参与出参、以及模型对工具结果的解读方式。这三类信息就是 Agent 可观测性的原始素材。
很多开发者刚上手 Agent 时,观测粒度停留在“HTTP 请求层面”。FastAPI 写个接口,LLM 调一次,日志里记下耗时和 token 数就完事。这在单轮问答场景下够用,但放到真正的 Agent 场景中远远不够——一个复杂任务可能需要循环五轮、调用三个不同工具,每轮之间还有信息依赖关系。你看到的“一次请求”背后是多次内部循环,观测不到循环级别就等同于瞎。
理解这个差异是做好 Agent 可观测性的第一步。我的经验是:直接放弃“请求/响应”的思维模型,改用“轨迹(Trace)”的思维模型。一次用户请求就是一条工作轨迹,轨迹里包含若干个推理步骤和工具执行片段,每个片段都有自己的父子关系、时间戳和内容快照。后面讲的 Tracing 系统,本质上就是把这种思维模型变成了可运行的工程方案。
2.2 哪些关键节点必须被记录
搞清楚了循环结构,接下来要明确“在哪些位置埋点”。我总结了一个算是我个人经验的清单,覆盖了 Agent 运行的所有关键信息节点:
节点一:初始输入快照。包括原始用户输入、系统 Prompt、模型参数(temperature、top_p、max_tokens)、模型版本、会话上下文长度。注意上下文长度这个信息非常重要,Agent 跑多轮之后上下文膨胀,往往就是从这个节点开始出问题的。
节点二:每一轮模型推理的完整 Prompt。这块很多人会偷懒只记录输入输出的摘要。我强烈不建议这么做。Agent 出问题一半以上的原因就是 Prompt 组装错误——工具描述没传进去、历史消息重复、系统指令被用户输入覆盖。没有完整 Prompt 快照,这些问题根本没法定位。
节点三:模型的原始响应。不仅仅是最终文本,还有工具调用的结构化参数。如果你用 OpenAI 的 function calling,这里就要记录完整的 function_call 参数 JSON。我用 LangChain 的时候,这块直接序列化 AIMessage 对象,连同函数调用参数一起入库。
节点四:工具执行详情。工具名称、入参 JSON、出参结构、执行耗时、错误信息(如果有)。这里要注意工具返回结果的内容长度。有些工具比如搜索引擎返回几千字的网页摘要,记录时一定要截断或者摘要化存 JSON,不然后续分析时存储开销会非常大。
节点五:单轮循环的耗时拆解。模型推理耗时与工具执行耗时分开记。同样是耗时数据,分开了才能看出瓶颈到底在自然语言理解上还是外部服务依赖上。
节点六:Agent 的终止状态。循环是因为什么结束的?是模型判断任务完成、还是超过最大轮次被强制中断、还是异常退出?这个信息很多日志系统不记录,但它往往是用户投诉“Agent 没解决问题”的第一线索。
这六个节点串起来,就是一条完整的“Agent 运行 DNA”。把这份 DNA 记录下来,想复盘任何一次劣质回答,几乎都能找到根因。
3. 可观测性的三大支柱在 Agent 场景下的变形
3.1 Tracing:用“轨迹思维”替代“请求思维”
传统后端可观测性的三大支柱是 Metrics(指标)、Logging(日志)、Tracing(链路追踪)。到了 Agent 场景,这三个支柱仍然有效,但内涵和实现方式发生了显著变化。
先说 Tracing。传统 Tracing 解决的是分布式系统中一个请求经过多个服务的调用链追踪问题,核心结构是“Span 树”。Agent 场景同样适用这种结构,但 Span 的语义需要做定制扩展。
我在实践中把 Agent 的 Span 分成四类:
- AgentSpan:代表整个 Agent 会话的生命周期,是最顶层的根 Span
- LLMSpan:一次大模型调用的全过程,内部记录完整 Prompt、响应、token 统计
- ToolSpan:一次工具调用的执行过程,记录工具名、入参、出参、执行状态
- ChainSpan:如果你用的是 LangChain,那 Chain 作为编排节点也需要有自己的 Span 类型,用来标记一个完整的处理管道
这种分类方式对应了 Agent 运行的不同阶段,每一类 Span 都有自己的属性集。比如 LLMSpan 里存的是 provider、model_name、prompt_tokens、completion_tokens;ToolSpan 里存的是 tool_name、tool_input、tool_output_status。
关键点在于 Span 之间的关联。Agent 的循环结构决定了 LLM 调用和工具调用是交替嵌套的——第一个 LLMSpan 结束后跟着一个 ToolSpan,ToolSpan 结束后继续嵌套下一个 LLMSpan。这种嵌套关系必须忠实反映出循环的时序结构,只有这样才能还原“模型先思考、再调工具、再基于结果思考”的完整链路。
实现上,OpenTelemetry 的 Span 机制天然支持这种嵌套。你在 Agent 主流程里开启 AgentSpan,每次循环迭代里开启子 Span,模型调用再往下开一层。跑完整个 Agent 流程,你就得到一棵完整的 Span 树了。
3.2 Metrics:别只盯着 Token 数和延迟
Metrics 这块是很多团队容易走偏的地方。常规监控平台给出的指标,比如请求量、QPS、平均延迟、Token 消耗,对 Agent 来说是“必要但不充分”的。这些问题当然要监控,但只是 Agent 健康度的冰山一角。
我认为 Agent 场景下真正有效的 Metrics 应该包含下面这些维度:
轮次分布(Turn Count):一次任务完成需要多少个推理循环。这个指标的分布形态非常能说明问题——如果大量用户的请求都在最大轮次附近被截断,说明 Agent 的规划能力有问题,或者工具返回的信息不足以让模型做出决策。
工具成功率:这个必须分工具监测。有些 Agent 应用了一个搜索工具,但搜索结果经常被模型误解为无效返回,这类问题在平均指标里看不出来,一定要单拆。
上下文使用率:输入 token 占总上下文窗口的比例。这是个非常有价值的预警指标。当使用率超过 80%,模型输出质量通常会有断层式下降,但系统本身不会有任何报错。
工具参数解析失败次数:模型生成了工具调用请求但参数不合法导致解析失败,这种事件虽然不会让请求直接挂掉(不少框架会自动重试或者让模型自我纠正),但会对用户体验产生负面影响,统计起来也很有价值。
Agent 的 Metrics 体系设计时,有一个容易被忽视的心得:要区分“技术指标”和“业务指标”。技术指标是模型延迟、token 数、工具响应时间——直接反映系统状态;业务指标是任务完成率、平均轮次、用户修正请求的次数——反映 Agent 的实际效果。这两类指标收集方式不同,分析时也必须一起看,才能完整还原 Agent 的表现。
3.3 Logging:从“异常日志”转向“全量记录”
说到 Logging,Agent 场景和传统后端有个很大的差异。传统后端的日志系统重在检测异常——错误日志、警告日志、超时日志。你要有一行 Error 才算需要关注的日志。但 Agent 的运行过程中,大量所谓的“问题”根本不会产生传统意义上的错误。
举个典型的例子:模型在工具调用时选错了搜索关键词,导致返回了不相关内容,最后 Agent 基于错误内容生成了错误回答。这个过程中没有任何异常,工具调用成功了,模型也没有报错,但你得到的结果是错的。
从这个例子能看出,Agent 场景下的日志系统不能只做“异常采集”,必须做“全量语义记录”。模型的每个输入输出、每个工具调用的参数和返回值、每轮的组装消息历史,都应该持久化存储。这给存储带来压力,但这是 Agent 调试不可回避的代价。
我在实际项目里的做法是分两级存储。热数据存在 ClickHouse 或 Elasticsearch 中,保留最近 30 天,支持在线检索排查。冷数据定期转储到对象存储里,只保留核心字段,用于后续的数据分析、模型评测、Prompt 迭代。这样做下来,存储成本可控,排查效率也有保障。
有个细节值得一提:日志记录时的上下文拼装顺序一定要保持与运行时一致。我遇到过因为日志记录时重新组装消息历史,顺序错乱,结果调试时看到的信息和实际运行信息不一致,白白浪费了一上午排查那个根本不存在的问题。
4. 落地实操:用 OpenTelemetry 和 Langfuse 搭建一套可用的观测体系
4.1 工具选型:为什么我最终选了 OTel + Langfuse 的组合
铺垫了这么多理论,现在说说怎么落地。工具生态方面,目前业界已经有一些专门做 Agent/LLM 可观测性的方案,比如 Langfuse、LangSmith、W&B Weave、Phoenix 等。各有各的侧重点,但对我来说,工程化落地最顺的还是 OpenTelemetry 和 Langfuse 的组合。
给出我的选型心路,方便你参考:
单用 OpenTelemetry 的话,它能提供完整的语义约定和传输管道,但要把原始的 Trace 数据重构成“Agent 友好”的可视化界面,开发成本高,我团队也没必要为了画个界面专门投入开发资源。
单用 Langfuse 这类商业/开源平台,界面和 Agent 概念的结合非常紧密——可以直接看到模型的 Prompt、响应、Token 消耗、成本分析,但如果你不止一个服务做观测,想统一纳入整个微服务链路的追踪体系里,就会比较封闭。
所以现在的方案是双轨制:Opentelemetry 负责链路数据的采集、传输和标准存储,Langfuse 做 Agent 业务语义层的可视化和分析。OTel SDK 集成到 Agent 服务中,生成标准 Trace 数据;Langfuse 提供 Python/JS SDK,专门记录 LLM 调用相关的语义数据,两者在我的架构里是互补关系。
技术上还有一个点值得提:OpenTelemetry GenAI 语义约定。这是 OpenTelemetry 社区正在推进的一个标准,把大模型请求、响应、Token 统计等字段标准化,避免各家自己造轮子。虽然还没有完全成熟,但方向已经比较明确。你如果从零开始建设,直接参考这套语义约定来设计属性字段,未来会少走很多弯路。
4.2 最小化落地配置:从零接入 Agent 服务
下面用一段实际配置给各位做个演示,场景基于一个典型的 LangChain Agent 服务:
from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource resource = Resource.create( attributes={ "service.name": "order-agent", "service.version": "1.2.0", "deployment.environment": "prod" } ) provider = TracerProvider(resource=resource) provider.add_span_processor( BatchSpanProcessor( OTLPSpanExporter(endpoint="http://otel-collector:4318/v1/traces") ) ) trace.set_tracer_provider(provider) tracer = trace.get_tracer(__name__)设置完成后,在每个关键的 Agent 运行节点手动埋点。这一步很关键,尤其是用 LangChain 内置的 callback 记录工具调用时,要同时在 OTel 的 Span 上打上对应的属性:
from opentelemetry import trace import json tracer = trace.get_tracer("agent.tracer") with tracer.start_as_current_span("agent.run") as root_span: root_span.set_attribute("user_query", user_query) root_span.set_attribute("session_id", session_id) with tracer.start_as_current_span("llm.reasoning") as llm_span: llm_span.set_attribute("model_name", "gpt-4o") llm_span.set_attribute("prompt_tokens", response.usage.prompt_tokens) llm_span.set_attribute("completion_tokens", response.usage.completion_tokens) llm_span.set_attribute("model_response", response.content) with tracer.start_as_current_span("tool.execute") as tool_span: tool_span.set_attribute("tool_name", "es_search") tool_span.set_attribute("tool_input", json.dumps(tool_input, ensure_ascii=False)) tool_span.set_attribute("tool_output", json.dumps(tool_output, ensure_ascii=False)[:2000])这里有几个容易踩的坑,提前说明。
第一,不要在整个 Agent 运行过程里只开一个 Span。很多初学者图省事,把整个 Agent 的逻辑包在一个大 Span 里,结果 OpenTelemetry 的 UI 上只看到一个耗时 10 秒的大块,完全无法定位瓶颈。
第二,Tool 的输出记录必须做截断。有些工具返回内容轻松超过几千甚至上万字符,直接全量打进 Span attribute 里,可能导致 exporter 的 payload 超限,丢失整条链路数据。我的做法是默认截断到 2000 字符,必要时在数据库里存全量。
第三,Span Attribute 的值类型有限制。OpenTelemetry 的 attribute 只支持字符串、布尔、数字、数组这些基础类型,结构化的 JSON 必须序列化成字符串再存。调试的时候注意别把复杂对象直接塞进去。
4.3 更细粒度的观测:在 LangChain 回调中注入自定义指标
LangChain 是目前用的最多的 Agent 编排框架,它的回调系统做了一层薄封装。你可以在回调里挂上自定义事件,把需要观测的内部过程记录下来。下面是一个实际用过的回调注入例子:
from langchain_core.callbacks import BaseCallbackHandler from opentelemetry import trace class AgentObservabilityHandler(BaseCallbackHandler): def __init__(self): self.tracer = trace.get_tracer("agent.chain") def on_llm_start(self, serialized, prompts, **kwargs): with self.tracer.start_as_current_span("llm.start") as span: span.set_attribute("prompt", prompts[0][:1000]) def on_llm_end(self, response, **kwargs): with self.tracer.start_as_current_span("llm.end") as span: span.set_attribute("llm_output", response.generations[0][0].text[:2000]) if response.llm_output: span.set_attribute("token_usage", response.llm_output.get("token_usage", {})) def on_tool_start(self, serialized, input_str, **kwargs): with self.tracer.start_as_current_span("tool.start") as span: span.set_attribute("tool_name", serialized.get("name")) span.set_attribute("tool_input", input_str[:2000]) def on_tool_end(self, output, **kwargs): with self.tracer.start_as_current_span("tool.end") as span: span.set_attribute("tool_output", str(output)[:2000])这套回调的价值在于不需要改动 Agent 的主体逻辑代码——只是把它挂到链上。LangChain 提供了相应的 handler 注册机制,代码侵入非常小。而且能单独把 LLM 调用和工具调用摘出来做精细记录,底层逻辑清晰。
注册方式很简单:
agent = create_agent(...) agent.run(user_input, config={"callbacks": [AgentObservabilityHandler()]})需要注意一点,回调示例中的 Span 是独立的,它不会自动与最外层 Agent Span 形成父子关系。要想串起来,需要你在初始化这个 handler 时就把它放在同一个 Trace 上下文里,最简单的方法是把外层根 Span 的 context 传进 handler 构造函数,然后用trace.use_span(context)重新绑定。
5. 深度场景拆解:从一次线上事故看观测数据的价值
5.1 事故现场还原
理论讲得再多,不如来一个真实案例。
去年我们上线了一个企业知识库问答 Agent,底层接的是内部文档搜索引擎加向量数据库。某天用户反馈量突然变大,说“回答质量明显变差,经常给出答非所问的结论”。因为不是系统报错,常规监控面板上看不到异常——CPU 正常、内存正常、接口 P99 延迟也没有明显波动。
换了没做过 Agent 观测的团队,这种问题几乎没法查。但因为我们当时已经把 OTel Trace 接到 Agent 上了,直接把出问题的会话 ID 拎出来,看这条 Trace 的 Span 树。
结果一目了然。问题出在向量检索环节:向量库近期新导入了一批新的文档,但是 Embedding 用的模型版本不一致,导致新文档的向量分布与旧文档完全不同。结果检索时,用户问题相关的旧文档排在了很后面,排在前面的全是语义不相干的新文档,Agent 基于错误检索结果生成了回答,自然答非所问。
整个排查过程不到十五分钟。没有完整的链路追踪,这个问题光靠日志和指标至少得折腾大半天,还不一定查得到。
5.2 事故背后的观测数据拆解
来复盘一下,到底哪些观测数据关键起了作用:
关键一:LLMSpan 中的完整 Prompt 快照。我们直接看到了模型在推理时拿到的检索结果是哪几段文档,发现这几段文档和用户问题之间几乎没有语义重叠。这一步把问题范围锁定在检索环节。
关键二:ToolSpan 中的检索参数。进一步检查时发现向量检索工具的入参里,query字段是正确的,但collection_name参数指向了那个混入不一致文档的新集合。
关键三:工具返回的 score 分布。排在前面的文档相似度分数普遍在 0.55 左右,属于低置信度匹配。结合这一点基本可以判断向量索引本身的检索质量出了问题。
关键四:历史轨迹对比。把几个质量正常的会话和质量差的会话拉出来对比,发现正常会话的第一轮检索结果里相似度分数通常在 0.8 以上。这个对比直接确定了问题的严重程度。
这四个观测维度,传统监控里一个都没有。模型调用耗时监控、接口错误率监控在整场事故里的价值趋近于零。
这里额外补充一个经验:Agent 的 Trace 数据一定要存够一定的量再做分析。单看一两个 Trace 看不出规律,但把同类型问题的 Trace 拉开做横向对比,规律往往就很清晰了。所以数据存储的成本不是白花的,它是排查问题的基础设施。
6. 常见问题与排查技巧实录
Agent 可观测性建设过程中,踩坑几乎是必然的。这里把我碰到过的问题按频率排个序,给各位做个直接可查的速查表:
| 问题现象 | 根本原因 | 排查思路 | 预防措施 |
|---|---|---|---|
| Trace 数据断链,子 Span 找不到父 Span | 异步代码里没有正确传递 Trace 上下文 | 检查异步任务启动时是否用context.attach恢复 Trace 上下文 | 封装统一的异步任务入口,强制传入 parent_span_context |
| Span 数量爆炸,存储成本飙升 | 没有对工具循环做合并或采样 | 查看单条 Trace 的 Span 分布,定位高重复度 Span | 对工具执行 Span 开启采样策略,相同工具同类参数只记录摘要 |
| Langfuse 界面看不到 OTel 的自定义 Span | 两套 SDK 各自为政,没有打通 | 检查 Langfuse SDK 初始化时的 trace 关联配置 | 在代码层为 Langfuse 生成trace_id与 OTel 的统一关联字段 |
| 排查问题时发现 Prompt 记录不完整 | 只记录了最终 Prompt,没有记录中间组装过程 | 检查 LLMSpan 上下文的组装代码 | 在每个 LLM 调用点打印完整的 messages 列表 |
| Agent 卡在工具循环中,Trace 看起来一切正常 | 模型对工具结果产生了错误解读,反复重试 | 查看同一轮循环的 LLM 输出,看它是否误解了工具返回的错误提示 | 在系统 Prompt 中加入更明确的工具返回格式指导 |
另外有一个很实用的技巧想单独分享:给每个 Agent 会话生成一个全局唯一的session_id,让它在日志、Trace、数据库记录、前端反馈工单之间通用。
当时我们在前端接入了用户反馈按钮,提交问题时自动携带session_id。用户说“这个回答有问题”时,后台直接输入这个 ID 就能拉出完整的 Agent Trace。这个设计让“用户主观反馈”变成可追溯的客观数据,对产品迭代和模型优化带来了极大的帮助。
还有个经验是埋点的标准统一问题。团队大了以后,每个人写的 Agent 分支都给自己加属性,字段命名混乱,最后查询时非常痛苦。我们后来干脆把埋点属性定义整理成一份团队文档,统一了字段规范,如llm.model_name、tool.execution_status、agent.turn_index等,代码评审时也检查埋点是否符合规范。这个管理上的小动作,带来的协作效率提升是一本万利的。
7. 实践经验之外的思考与建议
Agent 可观测性这个领域还在快速发展中,标准也在逐步统一。从我个人的实践体会看,有几个方向特别值得持续关注。
第一是评测驱动的可观测。目前很多团队的观测数据只是“事后诸葛亮”——问题发生了才去看。真正先进的做法是把观测数据用于高频的回归评测:每个用例跑完后自动分析 Trace,检测是否存在工具调用异常、推理轮次过多、上下文超限等情况,如果出现则自动标记为失败并推送告警。这等于把可观测性从被动排障工具升级为质量守护系统。
第二是多模态 Agent 的观测。现在 Agent 不只是处理文本了,还会看截图、听语音、操作浏览器界面。这些非文本输入输出在观测系统里怎么结构化、怎么存储、怎么检索,都是新的挑战。我们团队已经开始在内部试点把模型生成的中间视觉状态也纳入 Trace 记录,但目前还没有形成成熟方案。
第三是成本可观测性。Agent 的成本和传统 API 完全不同——一个任务可能调用模型五次、工具十次,每个环节都有成本。把成本数据纳入可观测体系,按用户、按会话、按功能模块拆解,是预算控制和定价决策的重要依据。我见过不少 Agent 项目上线两个月才发现成本远超预估,就是因为没建立 Token 粒度的成本观测。
最后再分享一个我在实际项目里反复用到的技巧:给 Agent 的每个主要路径分支打上“可解释标签”。比如用户请求是查询类任务,标签就是intent.query;如果中间触发了多轮工具调用,就用path.toolchain标记。这些标签不直接反映异常,但在大流量分析、用户行为画像、Agent 规划策略评估时,它们提供的切片维度远比你想象的丰富。
Agent 应用的质量保障,说到底就是那句话:看不到的东西就无法改进。一套完整的观测体系,不只是在出问题时帮你排查,它更大的价值在于让你每天都在和真实、高质量的运行数据打交道,靠数据说话,而不是靠运气和感觉迭代产品。希望大家都能把这块基础能力早日补上,别等线上出了问题才想起要做可观测性。