尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

自研Agent可观测性工具:让大模型推理与工具调用全程可追踪

自研Agent可观测性工具:让大模型推理与工具调用全程可追踪 1. 从“黑盒”到“白盒”Agent-Reach 要解决什么问题最近一年我一直在做基于大模型的 Agent 应用代码越写越多但心里越来越没底Agent 到底是怎么“思考”的为什么用户的同一句话昨天返回正常今天就走进了死胡同工具调用的顺序偶尔会乱模型偶发“幻觉”导致流程中断这些在传统软件开发里几乎不存在的问题到了 Agent 场景里全成了日常。我试过很多调试方法。先是在代码里到处塞 print后来换成了日志框架再后来又用了一些通用 APM 工具。老实说都有点用但都不够用。因为 Agent 的执行路径是动态的、非线性的一个复杂任务会让模型反复推理、多次调用工具、不断修正计划。你对着终端里几千行日志翻来翻去根本看不出哪一步是模型自己决定的哪一步是工具调用失败连带的哪一段推理消耗了大量 Token。这不是简单加日志能解决的。后来我动手做了一个轻量级可观测性工具名字叫Agent-Reach。它做的事情一句话能讲清楚把 Agent 内部每一次推理、每一个工具调用、每一次状态变化都变成可检索、可回放、可对比的追踪记录让开发者像调试普通接口一样调试 Agent。这篇文章我会完整拆解这个项目的设计思路、核心实现、实际接入流程以及我在真实业务里踩过的一堆坑。如果你正在做 AI Agent、智能客服机器人、自动化工作流这类应用又恰好被“模型不可解释”折磨过那这篇文章应该能给你一些实用的参考。不管你是刚接触 Agent 开发还是已经上线过好几套系统下面这些内容都尽量做到既有原理又有实操。2. 整体架构与设计取舍2.1 三件套Trace、Span、Event 的数据结构Agent-Reach 的核心数据模型参考了分布式链路追踪的思路但没有照抄 OpenTelemetry 那套完整协议而是做了一套更贴合 Agent 场景的简化模型由三层组成。第一层是Trace对应一次完整的请求。比如用户问了一句“帮我查一下明天的航班顺便把酒店也订了”从请求进入 Agent 开始到最终回复返回给用户整个过程就是一个 Trace。Trace 有全局唯一的 ID用来串联所有数据。第二层是Span对应一次原子的执行单元。Agent 在回答上面那个问题的时候可能会先调用航班查询工具再调用酒店预订工具中间还可能穿插模型的多轮推理。每一次工具调用是一个独立的 Span每一轮 LLM 调用也是一个 SpanSpan 之间有父子关系形成一颗执行树。第三层是Event对应 Span 内部更细粒度的事件。比如一次 LLM Span 里包含了输入 Token 拼装、模型返回、Token 消耗统计、延迟等事件。一次工具调用 Span 里包含了参数序列化、HTTP 请求发出、响应解析等事件。这个三层结构看起来简单但它是整棵树的根。只要你把数据塞进这个结构后面所有功能——回放、对比、告警、搜索——都有了统一的数据基础。我在设计的时候也想过直接用 OpenTelemetry 的 Span 协议后来放弃了因为 Agent 特有的字段太多比如推理文本、工具输入输出、模型名称、Token 用量塞进 OTEL 的 attributes 里会变得很别扭不如定义一个自己的瘦协议来得干净。2.2 为什么不用现成的 APM市面上有不少可观测性产品比如 DataDog、SkyWalking、Jaeger也有专门做 LLM 可观测的 LangSmith、Langfuse。我在动手之前认真评估过一圈最后决定自己写主要基于三个考量。第一通用 APM 不识别 Agent 语义。在 Jaeger 里你能看到一个耗时很长的 Span但不知道这个 Span 是模型在推理、还是工具在等待响应。你只能看到“serviceA 调用了 serviceB”这种粗粒度调用关系信息量不够。第二SaaS 型 LLM 可观测工具不适合私有化场景。有些项目的数据是不能出内网的金融、医疗、企业内部知识库这类场景尤其敏感。Langfuse 虽然可以自托管但它的部署成本在业务刚起步的时候有点重而且数据模型的灵活性对我这个场景不够友好。第三我也想要一个深度定制非常自由的底座。因为 Agent 的执行流程更新频繁今天可能用的是 ReAct 模式明天可能换成了 Plan-and-Execute 模式后天可能加了记忆模块。我不能让可观测性系统反过来限制业务代码的演进速度所以数据接口必须足够灵活。如果你问我什么时候应该用现成方案我的答案是如果你的 Agent 逻辑相对简单团队没有基础设施开发能力直接用 LangSmith 这类产品肯定比自研快得多。但如果你和我一样业务有定制执行引擎数据又要求私有化那自研一套轻量级 Agent 可观测系统的性价比反而更高。2.3 关键设计指标Agent-Reach 在设计阶段定下了几个硬指标后面所有实现都是围绕它们展开的。接入成本要低业务代码里加监控不应该超过 10 行改动最好能做到 3 行以内。数据延迟要小从 Agent 执行到数据可查询控制在 2 秒以内方便调试的时候实时看。存储容量要够单条 Span 最大支持 1MB 的 Event 数据因为工具调用的输入输出可能很大比如一次检索返回几十条文本。检索要快按 Trace ID 精确查询在 100ms 以内按时间范围加标签组合查询在 500ms 以内。不影响主业务采集端不能阻塞 Agent 执行写入失败不能影响主流程监控模块崩溃不能拖垮业务进程。这五点说起来简单实际做起来每一条都要花不少心思。尤其是最后一条“不影响主业务”我最初采用的是同步上报的方式结果在 Agent 高并发场景下出现了明显的业务延迟后来才改成异步批量上报加丢数据的兜底策略。这个后面在实操章节会详细展开。3. 核心实现细节与实操要点3.1 Tracing 的埋点方式Agent-Reach 提供了一套轻量级的 SDK核心是一个全局的 Tracer 对象。业务代码里怎么用非常简单用 Python 举例from agentreach import tracer with tracer.start_trace(user_idu_123456) as trace: with trace.span(llm.react_think, modelgpt-4o): result llm_chat(messages) trace.log_event(token_usage, usage) with trace.span(tool.flight_search, tool_namesearch_flights): data search_flights(departurePEK, arrivalSHA, date2025-06-01) trace.log_event(tool_output, data[:1000])这段代码展示的是埋点的基本形态。start_trace开启一个新的 Trace所有在这个上下文里创建的 Span 都会自动挂在同一个 Trace 下面。span作为上下文管理器进入时自动记录开始时间退出时自动记录耗时和状态。log_event则是在 Span 内部补充自定义事件。这里的关键设计是上下文传播。我的实现里用了contextvars来维护一个全局的上下文栈这样即使在异步代码或者多线程回调里SDK 也能正确地把 Span 关联到对应的 Trace 上。你不需要手动把 tracer 传来传去——只要确保start_trace创建了上下文后面不管代码怎么飞Span 都能准确落位。实测下来这套 implicit context 方案在 asyncio 场景下非常稳。但如果你的代码里有裸线程就要注意了——contextvars在线程之间是不会自动传播的必须在开线程的时候手动把上下文带过去。我在开发早期踩过这个坑现象是子线程里的工具调用日志落到了别的 Trace 上排查了很久才发现是上下文没传。3.2 自动埋点与手工埋点的取舍上面那种手工埋点的方式已经够简单了但很多场景下你甚至不需要手写。Agent-Reach 还支持一种更省事的玩法通过 monkey-patch 自动给常用工具函数、LLM 调用库加上埋点。比如你用的工具是通过requests调用的外部 API可以在启动时执行from agentreach.integrations import init_requests_patch init_requests_patch()这个函数会在requests.Session.request的外面包一层代理自动创建 Span 并记录 URL、方法、状态码、耗时。对于使用openai库的调用也有类似的自动补丁会自动抓取 model 名称、输入输出 Token 数、返回内容摘要。自动埋点和手工埋点的取舍本质上是在“省事”和“可控”之间找平衡。我的实际经验是这样的留作两层第一层对框架级调用HTTP SDK、OpenAI SDK、数据库客户端做自动埋点因为这些都是通用组件做一层适配就能覆盖所有业务。第二层对业务级调用特定的 Agent 节点、Tool 逻辑、知识库检索做手工埋点因为这些部分变化频繁、语义丰富手工埋点可以带上业务字段。如果只做自动埋点日志里会有很多“看到这里耗时异常但不知道为什么”的情况如果全做手工埋点埋点代码又散落在业务各处后期维护头疼。分层处理之后基本上每一个关键执行步骤都在监控范围内而且接入成本可控。3.3 依赖树与循环检测Agent 执行路径是动态的这意味着产生的 Span 结构不能是预设好的而要在运行时动态构建。每次创建一个子 SpanSDK 会把当前最顶层的 Span 当作父 Span然后新 Span 挂上去。这样自然形成一棵树。树本身不难建难的是树可能出现异常形态。比如某些 Agent 实现会有递归调用——Agent A 在完成任务的过程中认为需要用户提供更多信息发起了一个子 Agent BB 又调用 A 去查询资料结果 A 在查询资料过程中又需要启动 B如果没有打断机制这个调用链可以无限嵌套下去日志也会无限膨胀最终把存储写爆。我在 Agent-Reach 里加了一个循环检测机制在创建 Span 时会从当前 Trace 的路径上查找是否已经存在相同类型的 Span。如果检测到同一个tool_nameagent_node组合在一条路径上出现了超过预设阈值默认 5 次就会主动标记这段执行链路为“疑似死循环”并上报告警事件。这个设计来自一次真实的线上事故。当时我负责的一个自动化客服 Agent 在遇到某个特定话术时会反复调用“查询订单状态”工具然后因为响应格式不合预期又重新触发查询整整循环了 40 多分钟磨掉了大概 10 万 Token。系统里全是同一个模式的日志。要是没有阈值检测这个问题光靠人眼几乎是发现不了的。4. 实操过程与核心环节实现4.1 环境准备Agent-Reach 的完整方案包含一个数据采集 SDK支持 Python 和 Node.js、一个数据接收服务基于 FastAPI 构建、一个存储层用的 Elasticsearch也可以换成 ClickHouse以及一个前端控制台基于 React 编写。本地开发环境准备这些就够了Python 3.10 用于 SDK 和数据接收服务Node.js 18 用于前端控制台Docker 用于运行 Elasticsearch一个可用的 Agent 项目我这边是以 LangChain 为基础构建的客服 Agent先说 Elasticsearch 的选择。我用它主要是因为团队里已有的基础设施就是 ELK复用起来方便。但如果你想更轻量用 SQLite 也能跑起来只是查询能力和并发能力会弱不少。如果数据量在每日百万级 Span 以上建议直接上 ClickHouse写入性能比 ES 好一个档次。4.2 数据接收服务的关键实现数据接收服务是一个简单的 HTTP 服务SDK 会通过批量上报的方式把 Span 数据 POST 到这个接口。核心逻辑我直接贴出来from fastapi import FastAPI, Request from agentreach.storage import SpanStorage app FastAPI() storage SpanStorage() app.post(/v1/traces) async def ingest_traces(request: Request): payload await request.json() try: await storage.batch_save(payload[spans]) return {ok: True} except Exception as exc: # 写入失败时返回错误SDK 会在本地做降级处理 return {ok: False, error: str(exc)}, 500这里有一个容易被忽略的点接收接口本身的处理速度不能成为瓶颈。我在压测的时候发现SDK 端做批量上报时是一次 POST 带 100~200 条 Span单次请求体可能有几百 KB。如果服务端逐条解析写入在高并发下 CPU 会迅速打满。后来我把服务端改成了“先落盘、再入库”的模式。先把原始数据写到本地磁盘或 Kafka 队列再由一个后台消费者批量写入 Elasticsearch。这样即使 ES 短暂不可用数据也丢不了ES 恢复后积压的队列会自动消化。这个设计牺牲了一点实时性从 1 秒内变成 3~5 秒但换来了极高的吞吐稳定性。在实际生产中这比“快一点但偶尔丢数据”重要得多。4.3 前端回放与链路可视化数据接进来之后光有 REST API 还不够开发者的核心体验在可视化。Agent-Reach 前端做了两种视图。第一种是Trace 列表页。按时间倒序展示所有 Trace每一行有用户 ID、Trace 耗时、Token 消耗、工具调用次数、状态成功/失败/异常。加上筛选器之后你可以快速定位“最近 5 分钟所有失败的搜索工具调用”或者“Token 消耗超过 10 万的 Trace”。第二种是Trace 详情页。这是整个项目里最见价值的部分页面左侧是 Span 树状图右侧是对应 Span 的事件列表。你点一下“tool.flight_search”这个 Span就能看到这次调用的完整参数、API 地址、响应片段、重试次数、耗时。这个回放体验在 Debug 时简直救命。以前我排查一个“用户说已收到订单确认短信但系统没记录”的问题要在日志里大海捞针。现在打开 Agent-Reach按用户 ID 搜出当天的 Trace一眼就看到那次订单创建工具调用其实返回了超时错误但 Agent 把错误解读成了成功后面还给用户回复了“已办理完成”。这属于典型的模型幻觉被工具异常触发没有可观测性系统的话这个 Bug 我可能永远都定位不到。4.4 完整接入流程演示我拿一个最小可运行的 Agent 示例来演示完整接入流程。假设你有一个很简单的客服机器人只做两件事查订单状态、查物流进度。接入 Agent-Reach 总共就四步。第一步安装 SDKpip install agentreach第二步初始化 SDK 并指向接收服务import agentreach agentreach.init(endpointhttp://localhost:8000/v1/traces)第三步在 Agent 主入口创建 Tracedef handle_message(user_id, text): with agentreach.start_trace(user_iduser_id, input_texttext): agent_node create_agent() response agent_node.run(text) return response第四步在关键工具函数内部添加 Spandef search_order(order_id): with agentreach.span(tool.search_order) as span: try: result call_order_api(order_id) span.log_event(result, result) return result except Exception as exc: span.set_status(error, errorstr(exc)) raise整个接入改动不超过 20 行而且全部限制在入口层和工具层业务逻辑的大头不受影响。我后面在实际项目里接入的时候大约花了半个小时搞定全部埋点精力主要花在决定“哪些函数需要手工埋点、哪些靠自动埋点覆盖”上。注意初始化代码要放在进程启动的早期执行确保所有被 patch 的库都还没有加载。如果你在 import 之后再调用init_requests_patch()requests 已经被引用的部分可能不会被正确 patch导致自动埋点失效。5. 常见问题与排查技巧实录5.1 高并发下的线程安全问题SDK 内部有一个内存缓冲队列Span 先写入队列再由后台线程批量上报。最早我使用了一个普通的list来当队列结果在高并发下出现了数据互相覆盖的问题——两个线程同时写入的时候列表内部的索引发生了错位导致 Span 数据串了。最终的解决方案是用queue.Queue来做生产者和消费者模型再加批量提取的逻辑import queue _buffer queue.Queue(maxsize10000) def _buffer_worker(): while True: # 每 2 秒或每满 200 条就批量发送一次 batch [] deadline time.time() 2 while time.time() deadline and len(batch) 200: try: batch.append(_buffer.get(timeout0.2)) except queue.Empty: break if batch: send_batch(batch)Queue自身是线程安全的多线程写入不会有竞争问题后台 worker 批量取数据的逻辑也很自然。这个改动上线后再也没有出现数据错乱的情况。你也可以用一个线程池来做批处理但注意控制并发数别把接收服务打挂了。5.2 发送失败不能拖垮主流程这是我踩过最惨的一个坑。有一次我开发的 Agent 服务在一个并发较高的线上环境上线Agent-Reach 上报服务因为机器磁盘满了导致一直返回 500结果 SDK 的发送线程持续重试积累了大量内存占用最后 OOM 把整个业务进程杀掉了。那一次事故让我彻底想明白了一个原则可观测性系统不能成为业务可用性的一部分它的失败应该被业务完全忽略。修复之后SDK 的上报逻辑变得严谨很多上报失败只记录错误计数不无限重试超过 3 次连续失败后丢弃当前批次并每隔 30 秒尝试一次“探活”请求服务恢复后自动重连内存缓冲有上限默认 10000 条满了之后丢弃最旧的数据保证业务进程不因监控数据积压而异常。有同事问过我丢弃数据会不会导致问题排查漏掉关键信息我的回答是两害相权取其轻。Agent 可观测性系统丢一点数据最多少看几条 Trace业务进程挂了可就是全线不可用。做基础设施一定要把边界划清楚。5.3 模型 Token 统计对不上的排查另一个高频问题是 Token 用量统计和模型厂商后台的对不上。Agent-Reach 在 LLM 回传的 usage 字段里读取 total_tokens但很多时候 Agent 框架还有缓存命中、上下文裁剪等操作这些不会反映在单次模型的 usage 里。比如你用 LangChain 的 ConversationBufferWindowMemory 机制它每次会截掉一些历史消息。你看到的那次调用里模型计算的 Token 可能不是完整上下文而是裁剪后的。这时候如果只把每次 response 里的 usage 相加得出来的总量就会比模型厂商后台的统计偏少。要在自己的可观测系统里做表记需要在发送请求前对 messages 做一个预估计算。我自己写了一个简单的估算函数按字符数粗略估算 Token 数虽然不精确但能帮助判断大体数量级偏差点在哪个环节。5.4 问题排查技巧速查表现象常见原因排查方法Trace 列表空白SDK 上报地址配置错误检查init(endpoint)地址接收服务日志确认有无 POST 请求Span 树全都在一个 Trace 下上下文传播失效检查异步任务或线程中是否脱离了 contextvars 上下文工具调用耗时异常长外部 API 慢在对应 Span 内检索 HTTP 事件看哪个阶段耗时占比高Token 统计偏少缓存命中或上下文裁剪对比请求前消息长度和模型 usage定位裁剪逻辑某个 Trace 不回显上报时内存缓冲区被丢弃查看 SDK 日志中是否有 drop 计数必要时调大 buffered 上限数据入库延迟大ES 索引刷写策略过慢调整 ES 的 refresh_interval或在接收服务端加批量写入逻辑这个表格我经常拿来给团队新人看大部分问题都可以对照自查。尤其是上下文传播失灵那一条我在 code review 的时候反复提醒所有新写的异步任务或者线程代码都得评估会不会导致可观测性上下文丢失。只要这一条把控住数据质量问题能少一半。6. 从数据到改进用 Agent-Reach 反哺 Agent 质量Agent-Reach 做到了数据可视化和快速检索之后我发现自己对 Agent 的改进方法也改变了。以前优化 Agent 靠猜靠感觉——总觉得某一步可能不太好但说不清哪里不好。现在有了完整的执行轨迹优化的方向变得非常明确。举个实际的例子。我通过 Trace 统计发现客服 Agent 在处理“退款申请”这个意图时平均需要调用 5 次工具耗时 12 秒Token 消耗约 8000。而基本每次失败的案例都有一个共同特征Agent 先查询了订单详情然后查询了售后政策再查询了用户历史退款记录最后才尝试提交退款申请。看起来好像每一步都合理但失败原因是第二次查询售后政策时返回的内容太多超过了模型上下文窗口导致后续生成混乱。没有可观测数据之前这个问题藏得非常深。你只会看到“退款功能不太好用”这个表象完全不知道问题出在中间那一步。有了 Trace你能精确看到是哪次工具调用返回的内容过大进而决定是否需要对工具输出做摘要或截断。优化后同样的任务平均耗时从 12 秒降到 5 秒成功率明显改善。这就是我做 Agent-Reach 真正想实现的目标不只是让开发者能看到数据而是让数据真正指导 Agent 的演进方向。每一次工具调用的失败、每一次 Token 浪费、每一次模型行为异常都能成为改进系统的依据而不是事后翻日志的死角。7. 个人踩坑总结与后续扩展想法如果让我总结做 Agent-Reach 的这段时间印象最深的是几个原则可观测性系统必须明确和业务系统的边界数据模型要足够灵活但要尽早定型接入门槛一定要低到“顺手”的程度高并发下的稳定性必须从第一版就放在优先位置。有个细节我想特别提醒大家给可观测性系统做 API 设计时字段命名一定要想清楚。我最初给事件字段起的名字太随意比如“outline_reason”“think_temp”这种只有当时自己能看懂的命名后来系统用大了同事们看这些字段就像看天书。最终不得不在文档系统里用“字段命名对照表”补救。早期设计数据 schema 时多花一点时间命名后面会省掉大量沟通成本。Agent-Reach 目前已经在我这边好几个 Agent 项目上正常工作了。如果你也想给自己的 Agent 增加可观测性最简单的方法就是参考我上面提到的三层数据结构先用一个简单的 Web 服务接收 JSON 日志再用 React 写个简单的回放界面把第一条 Trace 跑通。只要把数据管道打通了后面所有的优化都会变得轻松很多。最后分享一个自己很喜欢的小扩展Agent-Reach 可以对接告警系统。我后来加了规则引擎支持“工具调用失败率超过 10% 时发送 Webhook 告警”、“Token 消耗超过预设值提醒”等规则。因为数据都已经在手里了加规则只是写一段简单的条件判断。有了这个之后很多线上问题在用户感知之前就收到了通知处理起来从容了不少。
返回列表