Agent可观测性实战:用事件流与Trace揪出静默失败

发布时间:2026/10/26 14:41:14
Agent可观测性实战:用事件流与Trace揪出静默失败 做 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 tokenAgent 看到的是残稿但它不会跟你说“我看漏了”。这两个埋点就是用来逮这种问题的。3.3 “沉默”检测怎么判断是真卡住还是还在思考我把“沉默”分成两类一类是长时间没有事件产生叫 stall另一类是事件不断但有规律地重复叫 loop。两类检测逻辑不一样。Stall 检测用流式心跳给每个 LLM span 设置一个数据流阈值比如 60 秒内没有新 token 就判定为 stall。阈值要根据模型调整慢模型和快模型的标准完全不同。Loop 检测用重复调用识别维护最近 N 步的工具调用记录计算每个调用的工具名和入参哈希当同一个组合出现超过阈值次数时判定为循环。下面是检测器的一个简化示意# 简化版基于事件流做沉默检测 class AgitationDetector: def __init__(self, stall_after_s60, repeat_limit3): 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_nameNone, args_hashNone): # 记录当前事件时间作为该 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 接入 AgentSight4.1 环境准备与依赖我们实际跑的是一个叠加了 LangChain 的自研 AgentPython 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-collectorSDK 这边我做的兼容面比较宽OpenAI SDK 的包装器、LangChain 的 callback、CrewAI 的 hook 都有对应接入层。接入时不需要改 Agent 内部的业务代码只改初始化部分。4.2 两个接入方式装饰器与回调最省事的方式是用装饰器给 Agent 的入口方法挂根 spanfrom agentsight import AgentSight sight AgentSight( endpointhttp://localhost:4317, service_nameweather-agent, sample_rate1.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, inputprompts) def on_llm_end(self, response, **kwargs): usage response.llm_output.get(token_usage, {}) sight.end_span(self._span, outputresponse.text, usageusage) def on_tool_start(self, serialized, input_str, **kwargs): self._tool_span sight.start_span( tool.call, nameserialized.get(name), inputinput_str, ) def on_tool_end(self, output, **kwargs): sight.end_span(self._tool_span, outputoutput)这个 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 真正最脆弱的路线比任何临时构造的测试用例都可靠。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询