一、引言:Agent 的可观测性,为什么比微服务难十倍
先说一个几乎每个团队都遇到过的场景:
客服 Agent 上线第三天,运营在工作群里甩来一张截图——用户问"我的订单为什么还没发货",Agent 一本正经地回答了半小时前另一个用户的物流单号。
你打开服务器,只有一行print(response)。没有 session id,没有 prompt 快照,不知道它检索了什么、调了哪个工具、在第几轮循环里把上下文搞混了。
传统微服务出问题,你可以顺着 trace_id 从网关一路查到数据库;而 Agent 的问题有三个特殊之处:
- 执行路径非确定:同样的输入,因为 temperature、上下文长度、工具返回内容不同,会走出完全不同的链路。没有追踪,你根本复现不了。
- 一次请求内部是多轮循环:一个"回答用户问题"的动作,背后可能是 6 次 LLM 调用 + 4 次工具调用 + 2 次向量检索,成本、延迟、错误散落在每一环。
- 失败往往不抛异常:模型不会报错,它只是"答得不对"。传统的 5xx 告警对语义错误完全失明。
所以结论很明确:Agent 的监控不能靠日志,必须靠 Trace + Span 的执行链路模型。日志告诉你"发生了什么",链路告诉你"在哪一步出的错,代价是多少"。
再往深一层看,传统微服务的可观测性建立在三个稳定假设上:接口边界清晰、错误码规范、请求-响应一一对应。而 Agent 把这三个假设全部打破了。它的"接口"是一段自然语言提示词,"错误码"变成了模型的一段解释,"请求-响应"变成了内部几十次 LLM 与工具调用的循环。这就是为什么很多团队把 APM 接进来之后,看到的只有一堆没有意义的 HTTP 调用,真正出问题的语义层完全黑盒。
还有一个容易被忽略的点:Agent 的成本是"过程成本"而非"结果成本"。微服务一次请求消耗多少 CPU 基本固定,而 Agent 一次任务可能因为多循环了两轮、多检索了三次,token 成本翻五倍。如果不在链路里记录每一环的 token 消耗,你月底看账单时根本不知道钱花在了哪个用户、哪个工具、哪次重试上。
因此,一个可用的 Agent 监控系统,至少要回答四个问题:这次任务走了哪条路径?每一步花了多少钱、多少时间?在哪一步语义开始跑偏?出问题时能不能完整复现?下面我们从原理到代码,一步步把系统搭出来。
二、核心原理:把 Agent 拆成一棵可观测的调用树
2.1 Trace 与 Span 的语义映射
借用 OpenTelemetry 的模型,做一次语义映射:
| 概念 | Agent 中的对应物 |
|---|---|
| Trace | 一次用户会话(或一次 Agent 任务) |
| Root Span | agent.run |
| Child Span | agent.turn(循环的一轮) |
| Leaf Span | llm.chat/tool.call/retriever.search/memory.read |
| Span Attributes | model、token 数、TTFT、工具名、参数哈希、检索命中数 |
| Span Events | 重试、降级、护栏拦截、人工接管 |
关键点在于父子关系即因果链。当一个 root span 下挂着 8 个agent.turn、其中第 5 轮的tool.call状态是 ERROR,而第 6 轮之后模型开始胡言乱语——这个因果链一眼就能看出来,纯日志做不到。
这里需要特别强调:Span 的父子关系不是"时间先后",而是"逻辑嵌套"。比如检索和 LLM 调用可能并发发生,但它们的 parent 都应该是同一个agent.turn。如果只按时间戳排序,你会误以为检索发生在 LLM 之后,从而得出错误的因果结论。所以在埋点时,必须用显式的上下文传递来建立父子关系,而不是靠日志时间戳猜。
另外,推荐在 Span Attributes 里统一使用 OpenTelemetry 的 GenAI 语义约定,例如gen_ai.system、gen_ai.request.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens。这样不同团队、不同框架的埋点可以对齐,后期换可视化工具也不用改数据格式。
2.2 四个必看的黄金指标
传统的 Latency / Traffic / Errors / Saturation 对 Agent 不够用,需要扩展成:
- 成本:每次任务、每个用户、每个模型的 token 花费,必须能归因到具体 span。
- 延迟:一定要区分TTFT(首 token 时间)和总耗时。流式场景下,用户感知的是 TTFT,而 SLA 考核的是总耗时。
- 可靠性:工具调用失败率、JSON 解析失败率、循环轮数超限率。
- 质量:这是 Agent 特有的——通过用户点踩、LLM-as-Judge 离线评测、关键字段规则校验来打分,并把这个分数写回 trace。
成本指标尤其要细:输入 token 和输出 token 要分开,因为输出 token 通常贵好几倍;缓存命中 token 要单独标记,否则你会误判优化效果;推理模型的 reasoning token 如果计费,也要单独记录。延迟方面,除了 TTFT 和总耗时,还建议记录"工具调用耗时占比",因为很多 Agent 慢不是因为模型慢,而是因为某个外部 API 卡了 10 秒。
质量指标最难,但也最有价值。一个可落地的做法是:在线收集用户反馈(点赞/点踩/重新生成),离线用 LLM-as-Judge 对抽样 trace 打分,再把分数写回 trace 的属性里。这样你就可以在 Grafana 里画出"质量分数随版本变化"的曲线,而不是等用户投诉才知道新 prompt 改坏了。
2.3 存储选型
链路数据写多读少、字段半结构化、需要按时间范围聚合——ClickHouse 是性价比最高的选择;小团队直接用 Postgres 的 JSONB 也够跑几十万 trace。可视化层,Jaeger 看单条链路,Grafana 看聚合指标,两者配合。
如果日增 trace 在百万级以上,ClickHouse 的优势会非常明显:列式存储压缩比高,按时间分区后查询快,物化视图可以预聚合成本和错误率。如果只是内部工具、日增几千条,Postgres 加 GIN 索引完全够用,运维成本更低。关键是要把 trace 和 span 分表存储,span 表按trace_id和start_time建索引,避免查一条链路时全表扫描。
2.4 采样与隐私:别把敏感信息写进 Span
生产环境不可能全量存储每一条 trace,尤其是高并发场景。推荐尾部采样:先在内存中保留完整链路,等 root span 结束后根据规则决定是否落盘。规则可以包括:错误链路 100% 保留、超过成本阈值的保留、随机采样 10%、特定用户或租户全量保留。这样既控制存储成本,又不会漏掉关键故障。
隐私方面,prompt 和工具参数里经常包含手机号、地址、订单号。落盘前必须做脱敏:对敏感字段做哈希或掩码,只保留前几位用于排查;完整原文可以加密后存对象存储,设置更短的 TTL。千万不要为了调试方便把用户原文直接写进 ClickHouse,一旦泄露就是严重事故。
2.5 与现有框架的关系
很多团队用了 LangChain、Dify 这类框架,但框架自带回调往往埋点不全。常见问题是:只记录了 LLM 调用,没记录工具内部耗时;或者回调是同步的,高并发下阻塞主流程。更稳的做法是自己实现一个几十行的 Tracer,用contextvars管理嵌套,天然支持异步和并发。框架回调可以作为补充,但不能作为唯一数据源。
三、实战:手把手搭建
整体架构分四层:
采集层(SDK/装饰器) → 传输层(异步队列 + OTLP) → 存储层(ClickHouse/OTel Collector) → 展示层(Jaeger + Grafana)3.1 采集层:一个无框架依赖的极简 Tracer
很多团队用了 LangChain、Dify 这类框架,但框架自带回调往往埋点不全。更稳的做法是自己实现一个几十行的 Tracer,用contextvars管理嵌套,天然支持异步和并发。
importcontextvars,time,uuidfromdataclassesimportdataclass,field _trace_id=contextvars.ContextVar("trace_id",default=None)_span_stack=contextvars.ContextVar("span_stack",default=())@dataclassclassSpan:span_id:strtrace_id:strparent_id:str|Nonename:strstart:floatend:float|None=Noneattrs:dict=field(default_factory=dict)status:str="OK"class_SpanCtx:def__init__(self,tracer,name,attrs):self.tracer,self.name,self.attrs=tracer,name,attrsdef__enter__(self):stack=_span_stack.get()tid=_trace_id.get()orstr(uuid.uuid4())span=Span(str(uuid.uuid4())[:8],tid,stack[-1].span_idifstackelseNone,self.name,time.time(),attrs=dict(self.attrs))self.span,self._tt=span,_trace_id.set(tid)self._st=_span_stack.set(stack+(span,))returnspandef__exit__(self,exc_type,exc,tb):self.span.end=time.time()ifexc_type:# 异常自动染色,不吞异常self.span.status="ERROR"self.span.attrs["error.type"]=exc_type.__name__ self.span.attrs["error.msg"]=str(exc)[:500]_span_stack.reset(self._st)_trace_id.reset(self._tt)self.tracer.sink(self.span)# 交给异步队列,不阻塞主链路returnFalseclassTracer:def__init__(self,sink):self.sink=sinkdefspan(self,name,**attrs):return_SpanCtx(self,name,attrs)注意三个设计取舍:span_id 只取 8 位(够用且省存储)、异常不吞(return False)、sink 异步(后面讲)。
为什么用contextvars而不是全局变量?因为 Agent 服务通常是异步并发处理多个请求,全局栈会串数据。contextvars在 asyncio 任务和线程池中都能正确隔离,每个请求有独立的_trace_id和_span_stack。另外,__exit__里先 reset 再 sink,保证即使 sink 抛异常也不会污染上下文。
3.2 在 Agent Loop 里埋点
importhashlibdefrun_agent(tracer,user_input,tools,max_turns=8):withtracer.span("agent.run",input_len=len(user_input))asroot:messages=[{"role":"user","content":user_input}]forturninrange(max_turns):withtracer.span("agent.turn",turn=turn)asts:withtracer.span("llm.chat",model=MODEL)ass:resp=call_llm(messages,stream=True)s.attrs.update({"gen_ai.usage.input_tokens":resp["usage"]["prompt_tokens"],"gen_ai.usage.output_tokens":resp["usage"]["completion_tokens"],"llm.ttft_ms":resp["ttft_ms"],# 首 token 单独记"llm.prompt_hash":hashlib.sha256(str(messages).encode()).hexdigest()[:16],})msg=resp["message"]messages.append(msg)ifnotmsg.get("tool_calls"):ts.attrs["stop_reason"]="final_answer"root.attrs["answer_preview"]=msg["content"][:200]returnmsg["content"]forcallinmsg["tool_calls"]:withtracer.span("tool.call",tool=call["name"])ast:t.attrs["args_hash"]=hashlib.sha256(str(call["args"]).encode()).hexdigest()[:16]t.attrs["args_preview"]=str(call["args"])[:200]try:result=tools[call["name"]](**call["args"])t.attrs["result_len"]=len(str(result))t.attrs["result_preview"]=str(result)[:200]exceptExceptionase:t.attrs["error"]=str(e)[:200]raisemessages.append({"role":"tool","tool_call_id":call["id"],"content":str(result),})root.attrs["stop_reason"]="max_turns_exceeded"root.status="ERROR"return"抱歉,我暂时无法完成这个任务。"这段代码有几个关键点值得展开。第一,llm.prompt_hash记录的是整个 messages 列表的哈希,不是单条用户输入。这样当同一用户重复提问时,你能区分是上下文不同导致回答不同,还是模型本身不稳定。第二,llm.ttft_ms必须在流式响应第一个 chunk 到达时记录,不能等整个响应结束再算。第三,工具调用里args_preview只保留前 200 字符,既方便排查参数格式错误,又避免存储爆炸。第四,max_turns_exceeded被标记为 ERROR 并写入 root span,这样告警系统可以直接统计"循环超限率"。
还有一个容易踩的坑:工具调用失败后直接raise,会让整个 Agent 任务中断。更优雅的做法是捕获异常后把错误信息作为 tool message 塞回给模型,让模型自己决定是否重试其他工具。但无论哪种策略,都必须把异常记录到 span 里,否则你永远不知道模型为什么突然改变策略。
3.3 传输层:异步队列与批量写入
采集层产生的 Span 不能同步写数据库,否则一次 LLM 调用 2 秒,写库再花 50 毫秒,高并发下直接拖垮服务。标准做法是:Tracer 的sink只负责把 Span 放进一个无界或有界队列,后台线程/协程批量消费并写入存储。
importqueue,threading,json,timeclassAsyncSink:def__init__(self,writer,batch_size=200,flush_interval=1.0):self.q=queue.Queue(maxsize=10000)self.writer=writer self.batch_size=batch_size self.flush_interval=flush_interval self._stop=threading.Event()self._t=threading.Thread(target=self._loop,daemon=True)self._t.start()def__call__(self,span):try:self.q.put_nowait(span)exceptqueue.Full:# 队列满时丢弃低优先级 span,保证主链路不阻塞passdef_loop(self):batch=[]last=time.time()whilenotself._stop.is_set():try:span=self.q.get(timeout=0.2)batch.append(span)exceptqueue.Empty:passiflen(batch)>=self.batch_sizeor(time.time()-last)>self.flush_interval:ifbatch:self.writer(batch)batch.clear()last=time.time()defclose(self):self._stop.set()self._t.join(timeout=5)# 把剩余数据刷完whilenotself.q.empty():self.writer([self.q.get()])队列满时选择丢弃而不是阻塞,这是一个重要的取舍:监控数据可以丢,业务请求不能卡。如果确实不能丢,可以落本地磁盘做 WAL,但这会显著增加复杂度。对于大多数团队,有界队列 + 丢弃低优先级 span 已经足够。
3.4 存储层:ClickHouse 表设计与查询
ClickHouse 里建议建两张表:traces存根 span 的汇总信息,spans存所有 span 明细。traces表按trace_id去重,方便快速列出最近失败任务;spans表按start_time分区,按trace_id排序,方便按链路查询。
CREATETABLEspans(trace_id String,span_id String,parent_id Nullable(String),name LowCardinality(String),start_time DateTime64(3),duration_ms UInt32,statusLowCardinality(String),attrs String,-- JSONINDEXidx_trace trace_idTYPEbloom_filter GRANULARITY4)ENGINE=MergeTree()PARTITIONBYtoDate(start_time)ORDERBY(trace_id,start_time)TTL toDate(start_time)+INTERVAL30DAY;查询一条坏链路的典型 SQL:
SELECTname,duration_ms,status,attrsFROMspansWHEREtrace_id='abc123'ORDERBYstart_time;如果要统计"过去一小时工具调用失败率":
SELECTJSONExtractString(attrs,'tool')AStool_name,countIf(status='ERROR')/count()ASerror_rateFROMspansWHEREname='tool.call'ANDstart_time>now()-INTERVAL1HOURGROUPBYtool_nameORDERBYerror_rateDESC;对于成本聚合,可以在spans表上建物化视图,按小时预聚合每个模型的 token 消耗。这样 Grafana 查询时不用扫描明细,响应时间从秒级降到毫秒级。
3.5 展示层:Jaeger + Grafana 的分工
Jaeger 适合看单条链路:输入 trace_id,展开调用树,每个 span 的耗时、状态、属性一目了然。排查"第 5 轮工具调用失败导致模型胡言乱语"这类问题时,Jaeger 的瀑布图是最直观的。
Grafana 适合看聚合趋势:成本曲线、P95 延迟、工具失败率、循环轮数分布、质量分数变化。建议在 Grafana 里做一个"Agent 健康度"看板,包含四个区域:成本、延迟、可靠性、质量。每个区域放 3-5 个关键面板,颜色阈值设置好,一眼就能看出异常。
如果团队已经有 OTel Collector,可以让 Tracer 直接输出 OTLP 格式,Collector 负责扇出到 ClickHouse 和 Jaeger。这样采集层不用关心后端存储,后期换存储也不用改代码。
3.6 告警与质量监控:别等用户投诉
传统告警基于错误率,但 Agent 的语义错误不抛异常。需要补充几类告警:
- 循环轮数超限率:
stop_reason = max_turns_exceeded的比例超过 1% 就告警,通常意味着工具设计有问题或提示词需要优化。 - 成本突增:按租户/用户统计每小时 token 消耗,超过历史均值 3 倍触发告警,防止恶意刷量或死循环。
- 工具失败率:某个工具连续 5 分钟失败率超过 20% 告警,可能是外部 API 挂了。
- 质量分数下降:LLM-as-Judge 分数周环比下降 10% 告警,通常对应 prompt 或模型版本变更。
- TTFT 劣化:P95 首 token 时间超过 3 秒告警,用户体验直接受损。
告警一定要带 trace_id 链接,点击直接跳到 Jaeger 查看现场。否则开发收到告警后还要手动翻日志,排查效率极低。
四、常见问题 FAQ
Q1:不改造业务代码,能不能监控 Agent?
可以,但埋点会缺失关键信息。比如框架回调通常拿不到工具内部耗时和检索命中数。建议至少自己包一层 LLM 客户端和工具注册器,核心链路手动埋点。
Q2:采样会不会漏掉关键错误?
用尾部采样,错误链路 100% 保留,慢链路按比例保留,正常链路随机采样。这样既控制成本又不漏故障。
Q3:prompt 里有用户隐私,怎么存?
落盘前脱敏:手机号、地址、订单号做哈希或掩码;完整原文加密存对象存储,TTL 设短。Span 里只存 hash 和预览。
Q4:流式响应的 TTFT 怎么测?
在 LLM 客户端收到第一个 chunk 时记录时间戳,减去请求开始时间。不要用总耗时代替,否则无法定位流式卡顿。
Q5:多 Agent 协作怎么追踪?
跨进程传递trace_id和parent_span_id,通过消息队列的 header 或 HTTP header 透传。每个子 Agent 创建新的 root span 时,parent_id 指向调用方的 span。
Q6:成本怎么归因到用户?
在 root span 上记录user_id和tenant_id,每个 LLM span 记录 token 数,聚合时按用户分组。注意输出 token 和输入 token 单价不同,要分开计算。
Q7:评估指标怎么回写 trace?
异步任务消费 trace 数据,调用 LLM-as-Judge 打分,再用trace_id更新 traces 表的quality_score字段。ClickHouse 支持ALTER TABLE UPDATE,但频繁更新性能差,建议另建质量表按 trace_id 关联。
五、踩坑与优化建议
坑 1:同步写日志阻塞主链路。
早期用print或同步写文件,QPS 一高就卡。解决:异步队列 + 批量写入。
坑 2:span 嵌套过深,上下文丢失。
手动传 parent_id 容易漏,尤其是异步并发场景。解决:用contextvars自动管理栈。
坑 3:只记总耗时,不记 TTFT。
用户抱怨"慢",你看总耗时 5 秒,以为是模型慢,实际是首 token 等了 4 秒。解决:TTFT 和总耗时分开记录。
坑 4:不记 prompt 快照,无法复现。
模型回答错了,但你没存当时的上下文,只能靠猜。解决:存 prompt_hash 和前 200 字符预览,需要完整复现时用 hash 去对象存储取。
坑 5:工具参数未脱敏。
把用户地址、身份证号直接写进 span。解决:敏感字段哈希,预览截断。
优化建议:
- 批量写入,每批 200-500 条,减少 I/O。
- 用列式存储,按时间分区,冷数据降级到对象存储。
- 成本聚合用物化视图,避免实时扫描明细。
- 采样策略动态调整,高峰期降低采样率。
- 给 span 属性设白名单,避免 JSON 无限膨胀。
六、总结
Agent 监控的核心不是"记日志",而是"还原执行链路"。把一次任务拆成 Trace + Span 的调用树,在每一环记录成本、延迟、可靠性和质量,再把这些数据写回链路,你才能在模型"答得不对"时快速定位是哪一步出了问题。
一个最小可用的 Agent 监控系统只需要四件事:一个基于contextvars的 Tracer、一个异步 Sink、一张 ClickHouse 表、一个 Jaeger + Grafana 看板。
更多硬核网安与AI工具包,请扫码获取完整源码!
代码量不大,但能把你从"只有一行 print"的黑暗时代里救出来。先跑起来,再逐步补充采样、脱敏、质量评估和告警规则。监控不是一次性工程,而是随着 Agent 迭代不断完善的反馈闭环。