☰
Agent可观测性实战:用事件流与Trace揪出静默失败
2026/10/27 17:09:50 网站建设 项目流程

做 Agent 应用的同学,应该都有过这种经历:你精心调好的 Agent,在真实环境里跑着跑着,突然给你返回一个看起来特别正常、但实际完全跑偏的结果。没有异常,没有报错,日志干干净净,它就像沉默着完成了一场错误。我一开始把这类问题统统归咎于“幻觉”,直到后来认真去追踪 Agent 的每一个中间状态才发现,幻觉只是表象,真正的问题藏在那些被省略的决策过程里。

ANOLISA AgentSight 这个项目,专门去对付这种“沉默”。Sight 这个词的意思很直白:给 Agent 的行为装上视野,让每一次工具调用的真实返回、每一次上下文被压缩时丢掉的内容、每一次模型明明读到了错误信息却继续顺畅输出的过程,全部显式暴露出来。这套东西本质上是一个面向 Agent 运行态的可观测性平台,适合正在做 Agent 应用开发、或者已经把 LangChain、Dify、CrewAI 这类框架接入生产的工程同学参考。

下面我把这套观测体系的设计思路、核心实现、接入方式和实战排查心得完整写出来。

1. 先聊聊 Agent 的“沉默”到底是怎么回事

1.1 传统程序会报错,Agent 只会“安静地错”

传统程序员解决问题的路径很固定:出问题,看日志,找异常栈,定位修复。这套方法论在 Agent 面前基本失效。Agent 是概率系统,同样的输入每次的结果都可能不一样,它最可怕的地方在于:错不等于报错。服务没有崩溃,HTTP 状态码正常,CPU 也正常,甚至连用户都收不到任何错误提示,但答案就是错的。

实际运营中我遇到过几类高频“沉默”场景:

  • 静默失败:工具调用抛了异常,Agent 不跟用户说明,而是当作无事发生继续推理。
  • 静默成功:工具返回了数据,但返回格式跟 Agent 预期不一致。Agent 不纠正,而是基于残缺信息编一个看起来合理的答案。
  • 上下文截断:上下文被压缩或超出窗口,Agent 根本看不到关键信息,却照样输出流畅的结论。
  • 循环徘徊:Agent 反复调用同一个工具,消耗大量 token,最后给出一个和第一次调用时几乎一样的答案。

举个真实例子。一个客服 Agent 被问到“退款什么时候到账”,它确实调用了订单查询工具,但工具返回的 JSON 字段跟 Agent 预期的不一致。Agent 没有终止流程,也没有报错,而是根据里面仅有的一个字段编了一句“您的退款将在三个工作日内到账”。这句话在客服场景里看起来太正常了,但实际的退款流程根本没跑通。这种问题不会出现在任何监控面板上,因为系统层面没有任何异常。

1.2 对话日志救不了你:丢的是“为什么”

早期我用的也是传统思路:把 system、user、assistant 的消息全部打日志,出问题就翻对话记录。后来发现这条路根本走不通,原因有三个。

对话日志只记录“说了什么”,不记录“看到了什么”。Agent 做决策真正依赖的是工具返回的原始 JSON、检索到的上下文片段、被压缩器裁剪掉的历史消息。这些在日志里要么不存在,要么已经被拼成了一段普通字符串。

上下文被裁剪的过程不可见。LLM 上下文窗口有限,Agent 几乎一定会做压缩。压缩后哪些内容被丢掉了,日志里看不到,出了问题就只能靠猜。我遇到过一种情况:问题根因明明是被丢弃的某条历史消息,但对话日志里根本没有那一层记录,排查了整整两天。

日志没有结构化时序。你无法回答“这次运行总共花了多少 token”“工具执行耗时多少毫秒”“是哪一次工具调用触发了幻觉”。多个 Agent 并发运行时,日志交织在一起,连还原一条完整链路都困难。传统日志关注的是“系统发生了什么”,而观测 Agent 要回答的是“模型和工具之间到底发生了什么”。前者是行为记录,后者是决策透视,完全两码事。

2. AgentSight 的设计取舍:为什么是“事件流 + Trace”

2.1 设计目标:把隐式行为变成显式证据

AgentSight 的设计目标很明确:把 Agent 的隐式行为变成显式证据,再让这些证据可以被检索、被回放、被计算。这里我把观测拆成了三层。

行为层记录 Agent 执行了什么:工具调用的名称、入参、原始返回、执行耗时、重试次数,一次不落。决策层记录模型是怎么想的:模型真正面对的输入是什么——注意,是经过压缩、截断、系统提示拼接之后最终送进模型的 messages,而不是用户最原始的那句话——以及模型的原始输出,包括那些被下游解析器丢弃的文本碎片。状态层记录运行时状态:上下文窗口占用率、token 消耗、记忆写入内容、运行时长。

为什么要拆三层?因为 Agent 的 bug 不像传统软件有一个清晰的异常栈,它常常是三层“合谋”的结果。工具返回被截断是行为层的问题,模型基于残缺信息做决策是决策层的问题,而你可能连什么时候截断的都不知道,这就是状态层的问题。只观测任何一层都还原不了全貌。

2.2 用 Trace 串起 Agent 的运行图谱

一开始我也考虑过直接用日志系统改造,但很快放弃了。Agent 的运行结构天然是树状的:一次用户请求就是一条链路,后面每一个 LLM 调用、每一次工具执行、每一条记忆检索都是这条链路上的节点。传统平铺日志只能回答“发生了什么”,Trace 能回答“这个发生是从哪来的、又影响了什么”。

AgentSight 在技术上直接复用了 OpenTelemetry 的 Trace 模型。一次 Agent 请求会创建一个根 span,后续的 LLM 调用、工具执行、记忆读写都作为子 span 挂上去。这样天然能把“最后这个错误答案”和“孕育它的整条链路”关联在一起。同时 OTel 生态里的 exporter、采样策略、上下文传播机制都可以直接拿来用,不用重复造轮子。

还有一点:我要求在 span 上挂的结构化字段必须是 event 而不是 log。Event 是带属性集的,比如工具返回体可以挂成tool.response.raw,上下文截断可以挂成context.truncated.messages。这样后面做查询、做检测、做回放,全部可以进行结构化计算。

2.3 Harness 优先:观测是脚手架,不是补丁

在方案取舍上我有一条很坚定的原则:不要通过修改 prompt 让模型自己输出思考过程,也不要等 Agent 出问题后再靠日志复盘。正确做法是把观测能力做成 Agent 运行时的脚手架,业内叫 Harness。

Harness 层要做三件事。第一是拦截:所有 LLM 调用和工具执行必须经过这一层,通过回调、中间件或装饰器统一接管。第二是记录:自动生成结构化 span 和 event,不依赖 Agent 内部是否记得打日志。第三是干预:沉默检测、数据脱敏、上下文审计都在这一层完成。

为什么强调 Harness 而不是事后补丁?因为观测不能侵入业务逻辑。你如果指望每个 Agent 开发者都自觉埋点,最后一定有人漏掉关键节点。脚手架的方式是:开发新 Agent 的时候把 Harness 当作第一公民,接入观测只是配置问题,而不是额外负担。

3. 核心实现:给 Agent 装上“心电图”

3.1 采集链路与整体架构

AgentSight 的采集链路我按五层来设计:Agent Runtime 到 AgentSight SDK,再到 Collector,再到存储与索引,最后是分析与可视化。

Agent Runtime 就是 Agent 本体,它不关心观测,只产生行为。SDK 层负责无侵入埋点,通过装饰器、CallbackHandler 或者 Wrapper 把运行时行为转成 span 和 event。Collector 层负责缓冲、批处理、脱敏、采样,避免海量 token 文本把内存打爆。存储层里,trace 数据进时序索引,事件流进近线队列,消息原文存到廉价冷存储。分析层负责沉默检测、成本核算、重复调用识别、告警和可视化。

这里最容易犯的错误是:把采集做成同步的。Agent 对时延非常敏感,尤其是流式输出场景,任何阻塞都会让用户感受到“卡住”。AgentSight 的 SDK 内部采用异步队列加批量导出的方式,业务线程只负责往本地队列丢事件,真正网络传输发生在后台,不影响 Agent 正常响应。

3.2 关键埋点:哪些“心跳”必须被记录

我整理了一张埋点清单,基本覆盖了我踩坑过程中所有需要回查的数据:

采集点记录内容解决什么问题
LLM 请求最终注入模型的消息数组、模型名、采样参数、token 用量模型到底看到了什么
LLM 响应原始输出、解析后输出、finish_reason模型实际上说了什么
工具调用工具名、入参 JSON、执行耗时、返回体原始内容工具这一步是否真的成功
注入结果实际进入上下文的返回体内容、是否有截断标记信息是在哪一步丢掉的
上下文压缩压缩前消息数、被丢弃片段摘要、token 变化压缩是否偷走了关键信息
记忆读写检索 query、命中的 chunk ID、写入内容记忆到底读来了什么

有两个点最容易静默丢信息,务必盯紧:一个是工具返回体从“原始内容”到“注入上下文”之间的变化,一个是上下文压缩器丢掉的片段。工具返回的原始 JSON 可能有 3000 token,实际注入上下文时被截成 300 token,Agent 看到的是残稿,但它不会跟你说“我看漏了”。这两个埋点就是用来逮这种问题的。

3.3 “沉默”检测:怎么判断是真卡住还是还在思考

我把“沉默”分成两类:一类是长时间没有事件产生,叫 stall;另一类是事件不断但有规律地重复,叫 loop。两类检测逻辑不一样。

Stall 检测用流式心跳:给每个 LLM span 设置一个数据流阈值,比如 60 秒内没有新 token 就判定为 stall。阈值要根据模型调整,慢模型和快模型的标准完全不同。Loop 检测用重复调用识别:维护最近 N 步的工具调用记录,计算每个调用的工具名和入参哈希,当同一个组合出现超过阈值次数时,判定为循环。

下面是检测器的一个简化示意:

# 简化版:基于事件流做沉默检测 class AgitationDetector: def __init__(self, stall_after_s=60, repeat_limit=3): self.last_activity = {} self.recent_calls = [] self.stall_after_s = stall_after_s self.repeat_limit = repeat_limit def on_event(self, span_id, ts, tool_name=None, args_hash=None): # 记录当前事件时间,作为该 span 最近一次活跃时间 self.last_activity[span_id] = ts if tool_name: self.recent_calls.append((ts, tool_name, args_hash)) # 统计最近 10 步里相同工具+参数哈希出现多少次 same = [c for c in self.recent_calls[-10:] if c[1] == tool_name and c[2] == args_hash] if len(same) >= self.repeat_limit: emit_warning("loop_detected", tool_name, args_hash) def check_stall(self, now): # 遍历所有 span,检查距离最近一次活跃事件是否超过阈值 for span_id, last in self.last_activity.items(): if now - last > self.stall_after_s: emit_warning("stall_detected", span_id, now - last)

这里有个实操心得:检测和自动熔断必须分离。不要一看到 stall 就杀掉 Agent,因为 LLM 单次生成本来就可能很慢,误杀会打断大量正常的长思考。AgentSight 默认的形态是“只标记、高亮、告警”,把干预交给上层策略决定。实际运行下来,这个“先警告再动手”的策略能避免很多误伤。

还有一点:args_hash 计算时要对入参做规范化处理,忽略时间戳、随机 ID 这类每次都会变的字段,否则同一个循环调用会因为参数不同而躲过检测。

3.4 回放与重放:让静默失败可以复现

Agent 问题最头疼的是不可复现,因为 LLM 本身是概率性的。AgentSight 的思路是:既然每次运行的不确定性无法避免,那就把完整的运行轨迹保存下来,让它变成可回放的数据。

回放分两种模式。Mock 重放:从历史 trace 里取出 LLM 响应和工具返回,按原时序重新驱动整个 Agent 管线,适合验证工具调用序列、上下文注入逻辑是否有变化。真实重放:用原 trace 保存的上下文重新请求真实模型,看同样的问题在新版本模型下是否还会犯相同的错。

保存下来的 trace 也顺带解决了一个评测数据来源的问题。传统做法是手工构造测试用例,但 Agent 部署到生产后,真实流量的复杂度和多样性远不是手工用例能比的。把异常运行 trace 整理成“输入 + 期望工具调用序列”的形式,就直接变成了一个自动生成的 Agent 评测集。后面做模型升级或者 prompt 调整,先拿这组剧本回归一遍,比任何手工构造都贴近真实场景。

4. 实操记录:把一个 LangChain Agent 接入 AgentSight

4.1 环境准备与依赖

我们实际跑的是一个叠加了 LangChain 的自研 Agent,Python 3.10。接入 AgentSight 之前需要先准备好几个基础依赖:

pip install agentsight pip install opentelemetry-sdk opentelemetry-exporter-otlp pip install langchain-core # 如果你用 LangChain

另外需要本地起一个 OpenTelemetry Collector 作为接收端,AgentSight SDK 会通过 OTLP 协议把 trace 数据发到 Collector,再由 Collector 做缓冲和转发。用 Docker 起一个最简版本就行:

docker run -p 4317:4317 otel/opentelemetry-collector

SDK 这边我做的兼容面比较宽:OpenAI SDK 的包装器、LangChain 的 callback、CrewAI 的 hook 都有对应接入层。接入时不需要改 Agent 内部的业务代码,只改初始化部分。

4.2 两个接入方式:装饰器与回调

最省事的方式是用装饰器给 Agent 的入口方法挂根 span:

from agentsight import AgentSight sight = AgentSight( endpoint="http://localhost:4317", service_name="weather-agent", sample_rate=1.0, ) @sight.instrument() # 自动给 run 方法挂根 span class WeatherAgent: def __init__(self, llm, tools): self.llm = llm self.tools = tools def run(self, user_query: str) -> str: result = self.llm.invoke(user_query) return result.content

如果用的是 LangChain,更推荐注册一个全局 callback,这样不用动 Agent 内部结构:

from langchain_core.callbacks import BaseCallbackHandler class AgentSightCallback(BaseCallbackHandler): def on_llm_start(self, serialized, prompts, **kwargs): self._span = sight.start_span("llm.call", input=prompts) def on_llm_end(self, response, **kwargs): usage = response.llm_output.get("token_usage", {}) sight.end_span(self._span, output=response.text, usage=usage) def on_tool_start(self, serialized, input_str, **kwargs): self._tool_span = sight.start_span( "tool.call", name=serialized.get("name"), input=input_str, ) def on_tool_end(self, output, **kwargs): sight.end_span(self._tool_span, output=output)

这个 Handler 注册进 Chain 之后,工具调用和 LLM 调用都会自动变成 span。为什么在回调层做而不是在工具函数内部做?因为回调层能看到完整的父子关系——哪个 LLM 调用触发了哪个工具调用。在函数内部打日志只能看到局部,没法建立链路。

插一句:LangChain 不同版本的回调字段不太一样,on_llm_start的签名、response.text的取值路径在你本地环境里可能需要微调,以实际版本为准。

4.3 一个真实案例:工具被截断后,Agent 编了个天气

下面复盘一个真实定位过程。场景是客服 Agent 接了一个“查天气”的工具,用户问:“明天适合爬山吗?”

Agent 的回答是:“明天天气晴朗,气温 24 摄氏度,非常适合爬山。”听起来很顺畅,没有任何报错。但我在 AgentSight 面板里点开这次 trace 之后,问题一目了然:

  • 工具调用 span 显示只执行了 18 毫秒,返回体原始 JSON 有约 400 token。
  • 但注入结果 span 显示,这 400 token 被截断到了 60 token,截断位置正好在“降水概率”和“风力”字段中间。
  • LLM 请求 span 里实际注入的上下文,只有“晴”和“最高气温 26 摄氏度”两个残缺片段。
  • 模型看到的信息里没有降水信息,于是基于残稿编了一个“24 摄氏度、适合爬山”的结论,而真实天气是午后有雷阵雨。

根因不是 prompt 写得不好,也不是模型不够聪明,而是上下文注入那一步丢了一个关键字段。修复方式也很简单:调大注入 token 上限,同时在工具返回时加结构化字段校验,如果关键字段缺失就触发一次重新调用。改完后用同一个请求重放,工具调用序列里多了一次“补查降水”的操作,输出变成了“午后有雷阵雨,不建议爬山”。

这个案例很典型:Agent 的错误答案最终形态是一个幻觉,但幻觉的种子是链路截断种下的。

5. 常见问题与排查技巧实录

5.1 工具明明调用了,为什么结果没生效?

这个问题的出现频率在我的运营数据里排第一。表象是 Agent 调了工具,但最终输出里没有任何工具结果的痕迹。

排查时我会按顺序看三个地方。先看 tool result span 里的“实际注入长度”和原始返回长度是否一致,确认是不是被截断。再看工具的返回体在 Agent 内部的解析函数里是不是被静默 catch 掉了,解析异常没有抛出来,Agent 就当无事发生。最后才看 prompt——这个顺序很关键,我踩过太多次冤枉调 prompt 的坑了。

排查口诀:先看注入,再看解析,最后才看 Prompt。工具 schema 的每一次小改动都可能破坏下游上下文,不要一上来就怀疑提示词。

5.2 token 突然暴涨,多半是原地打转

有一次我们 Agent 的账单异常飙升,从单次 5k token 涨到 50k。AgentSight 的成本核算面板显示,连续五轮都在调用同一个工具的同一个接口,参数哈希值完全一致。

这就是典型 loop。原因很简单:Agent 每次调用后都把结果写进记忆,但写入失败,下一轮它又去重试读取。它以为自己在推进任务,实际在原地打转。

修复方案没有用 prompt 层“不要重复调用”,模型不可靠。我在 Harness 层加了两个硬性防护:一是单次运行步数上限,比如最多 20 步;二是相同工具加相同参数哈希在最近 N 步内重复出现时,自动短路并让 Agent 重新规划。这种问题等账单出来再发现就晚了,必须在 Harness 层主动拦截。

5.3 沙箱重放:把历史 trace 变成回归剧本

现在提一个新问题:Agent 版本迭代怎么保证不回归?靠手工测试用例太薄弱。

我的做法是把每次真实环境里出过问题的 trace 存成“剧本”,在沙箱里重放。沙箱会按原时序把同样的工具返回喂给新版本 Agent,然后对比新老版本的工具调用序列和最终输出。升级模型、改 prompt、改工具 schema 之前,先把历史故障剧本跑一遍。这套剧本的覆盖面是手工用例比不了的,因为它记录的都是真实用户流量里踩出来的坑,而且每次事故都会新增一条剧本,越积越多。

5.4 采集本身的代价:开销、脱敏与“观测者效应”

采集工具本身也可能变成新问题的来源。我遇到过 SDK 同步导出导致 Agent 响应慢了 200ms 的情况,所以强烈建议异步导出,观测层不能阻塞业务。

存储成本也要控制。我实际用下来的经验是:默认 head sampling 采样 10%,l l 事件(stall、loop、工具异常)全量保留。这样日常 trace 量可控,异常场景又不丢失。

脱敏必须在 Harness 层做,邮箱、手机号、API Key 之类的一律在导出副本上正则替换。这里有个隐蔽的坑:脱敏要在导出的副本做,不要改注入模型的上下文。一度为了省事我直接在注入前把邮箱替换成脱敏串,结果 Agent 在对话里开始引用脱敏后的奇怪字符串,把用户都搞懵了。观测层不能修改 Agent 实际看到的输入,这是底线。

回调层还要做异常兜底。某次一个第三方框架的 callback 里抛了异常,直接让主链路挂掉了。观测代码不该带来这种风险,callback 内部任何异常都要捕获并吞掉,只记 warning。

最后再分享一点我的体会

做完这套观测体系之后,我最大的变化不是“能抓到 bug”,而是工作习惯彻底变了。以前 Agent 一出问题,第一反应是调 prompt、换模型,实在不行再怀疑工具写错了。现在第一反应永远是先拉起那次运行的 trace,看工具注入、看上下文压缩、看每一步的 token 消耗曲线。效率和准确率都比以前高了一个量级。

最后再分享一个小技巧:把每一次真实环境里出过问题的 trace 都保存成“复现剧本”,每次发布新版本之前把剧本整体跑一遍。这组剧本才是你 Agent 真正最脆弱的路线,比任何临时构造的测试用例都可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询