Tiana随笔

Agent 可观测性:它到底在干嘛

· Agent 实践

标签:#Agent #Linux

Agent 可观测性:它到底在干嘛

Agent 出错最难排查的地方是“中间发生了什么没人知道”。日志、追踪、回放三件套,把黑盒变成可以调试的系统。

去年我们上线了一个客服 Agent,老板问了一句特别朴素的话:"它现在到底在干嘛?"我当时愣了三秒,传统的服务我闭着眼都能画出调用链、甩出 Prometheus 面板,但面对这个会自己决定调哪个工具、自己决定要不要再想一步的 Agent,我居然答不上来。它不在任何一条固定链路上,它每一步在"推理",而推理这个动作在传统观测体系里是没有位置的。

这篇文章想系统讲清楚:Agent 为什么比普通服务难观测,我们应该从哪几个层次去追踪它,哪些指标真正值得盯,日志和 trace 的字段应该怎么设计,以及怎么靠回放把"看见了"变成"改好了"。我不打算堆术语,而是把我自己在几个项目里摸索出来的、能直接落地的做法讲出来。文中的代码都是我实际在用的简化版,你可以直接抄进项目。

先说结论:Agent 的观测不是给现有 APM 加个埋点就完事,它要求我们把"思维过程"也变成一等公民的数据。下面逐步展开,从"为什么难"一路讲到"怎么闭环改进"。

为什么 Agent 比传统服务更难观测

传统服务是"请求进来、走固定代码路径、请求出去",可观测性建立在三个假设上:路径可枚举、行为是确定的、每一步都有清晰输入输出。Agent 把这三条全打破了,而且破得相当彻底。

第一,路径不可枚举。一个 Agent 可能为了回答一个问题,先查数据库、发现不够、再去搜网页、再调用计算器、最后总结。下一次问法稍变,它选的工具顺序可能完全不同。你没法预先画好一张调用图,因为它每次都在现场"即兴编排"。这意味着传统的"服务依赖拓扑"在 Agent 这里基本失效,你看到的是一团每次都不同的临时网络。

第二,行为非确定。即便输入完全相同,模型这次可能一步到位,下次可能绕了三个弯。传统监控里"P99 延迟"有意义,是因为慢有慢的确定原因;Agent 的慢可能是它自己"想多了",原因藏在自然语言里,不在堆栈里。同一个问题,模型心情(其实是采样和上下文)不同,步数能差三倍,这让"正常值"本身变成了一个分布而非一个点。

第三,工具是自选的。Agent 调用外部工具时,工具本身的成功失败、参数对错、副作用大小,都不在模型的控制范围内,却会左右最终结果。你要观测的不是一个函数,而是一个"会拉外援的决策体"。工具挂了,模型可能默默编一个答案,监控面板一片祥和,用户却拿到了幻觉,这是 Agent 特有的"静默失败"。

这三点叠加,导致最常见的尴尬:告警响了,你打开面板,发现延迟高,但不知道是高在"模型慢"还是"卡在某个工具"还是"它自己兜圈子"。没有分层,你连定位入口都没有。所以观测 Agent 的第一要务,是把"思维过程"也埋进数据里,而不能只盯进出。

Tracing 的三个层次

我的做法是把 trace 切成三层,从粗到细,任何一次 Agent 运行都能映射到这三层上。这套分层是我们踩了两个月"只见延迟不知原因"的坑后定下来的。

最外层是请求层(Request):一次用户触发到最终响应的完整生命周期。它回答"这次任务花了多久、成了没、花了多少钱"。这一层和传统 trace 最像,是给老板和 oncall 看的,也是告警的主要数据源。

中间层是推理步层(Step):Agent 每做一次"思考-决策"算一步。它记录每一步它打算干什么、为什么、选了哪个工具、这一步的输入输出摘要。这一层是 Agent 独有的,是诊断"它为什么绕路"的关键。没有这层,你只知道慢,不知道为什么慢。

最内层是工具调用层(Tool):每一步实际发起的工具调用,记录参数、返回、耗时、错误。这一层和传统链路追踪重合度最高,但要把它挂到具体的推理步下面,否则你看到一堆工具调用却不知道是哪个决策引发的。挂对了,你就能从一次卡死反推出"是第三步选了 search_web 然后工具返回空,所以它重试了五次"。

下面是一段把三层串起来的简化结构,重点在 parent 把步和工具调用挂成树:

from dataclasses import dataclass, field
from typing import Optional
from uuid import uuid4

@dataclass
class Span:
    id: str = field(default_factory=lambda: uuid4().hex)
    parent: Optional[str] = None
    kind: str = "request"          # request | step | tool
    name: str = ""
    started_at: float = 0.0
    ended_at: float = 0.0
    meta: dict = field(default_factory=dict)

class Tracer:
    def __init__(self):
        self.spans = []

    def start(self, kind, name, parent=None, **meta):
        s = Span(kind=kind, name=name, parent=parent, meta=meta)
        self.spans.append(s)
        return s.id

    def end(self, sid, **meta):
        for s in self.spans:
            if s.id == sid:
                s.meta.update(meta)
                return

有了这棵树,等出问题你就能从请求层一路点进某一步,再看那一步底下挂了哪些工具调用,定位效率和不分层相比是天壤之别。我建议哪怕不上专业 trace 后端,先把这个树形结构用 JSON 落盘,价值已经巨大。

关键指标

指标要从"这次单跑"升华到"长期分布",否则你只看个例看不到趋势。我盯的指标就五个,多了反而噪声,oncall 也记不住。这五个是我从十几个候选里筛出来的,每一个都对应一类真实故障。

  • 任务成功率:最终产出被判定为可用的比例。分母要算上"中途卡死没返回"的,很多团队只算"返回了的",会虚高。我吃过亏,某次接口把异常吞了返回了一段客套话,成功率看着 99%,其实一半是幻觉,后来把"空实质内容"也计入失败才现原形。
  • 步数分布:每任务推理步数的中位数和 P90。步数突然变多,往往意味着提示词退化或外部工具变差,是早期预警。它比延迟更敏感,因为延迟可能被缓存掩盖,步数不会。
  • Token 成本:单次任务的平均输入/输出 token,以及按天的成本曲线。这是 Agent 最容易被忽视的"隐性故障":没人报错,但账单在涨。步数变多会直接反映在这里。
  • 工具错误率:各工具调用的失败占比。某个检索工具错误率悄悄升,模型会开始"编",你从成功率里看不出,得拆到工具层。我按工具名分组统计,哪個工具红了立刻知道。
  • 卡死率:超过最大步数仍无结果、或陷入重复调用的比例。这是 Agent 特有的健康度指标,传统服务没有对应物。它直接衡量"模型有没有开始兜圈子"。

我吃过最大的亏就是只盯成功率。有一次成功率没动,但步数分布的中位数从 4 涨到 9,事后查是某个 API 改了返回格式,模型一直在重试解析。如果当时盯了步数,能早三天发现。指标之间要互相印证,单看一个会骗你。我的日报里这五个指标并排,任何一项连续两天异动就拉群里看一眼,成本低但防住了大部分事故。

结构化日志与 trace 字段设计

很多团队的 Agent 日志还是一堆 print(response),出了事根本没法查。我的原则是:Agent 的每一步都必须落一条结构化 JSON,而不是一行文本。下面是一份我沿用的字段设计,它兼顾了三层 trace 和后续离线分析,也是我们回放集的入库格式。

{
  "trace_id": "a1b2c3",
  "span_id": "s9",
  "parent_span_id": "s8",
  "kind": "step",
  "agent_role": "researcher",
  "model": "gpt-oss-120b",
  "step_index": 3,
  "intent": "需要核实最新季度营收",
  "tool_chosen": "search_web",
  "tool_args": {"query": "company q2 revenue"},
  "tool_result_summary": "返回 3 条结果,最高相关度 0.7",
  "tokens_in": 1820,
  "tokens_out": 240,
  "latency_ms": 3100,
  "status": "ok",
  "error": null,
  "ts": "2026-09-20T14:03:11Z"
}

几个字段是我特意加的,说一下为什么。intent 记录这一步"打算干什么",它让回放时你能读懂模型的逻辑,而不是只看它调了什么。tool_result_summary 只存摘要不存全文,既够诊断又控成本,全量存 token 会爆存储。agent_role 在多 Agent 场景必不可少,否则你分不清是哪路工人干的。status 和 error 分开,失败必须显式,对应我前面强调的"失败可见"。

工具调用层的日志则多带 tool_args 和完整返回码,便于复现。request 层则额外带 user_id、session_id、final_status、total_cost_usd,方便做用户维度和成本核算。把这三层的 schema 统一进一个 Pipeline,查询时用 kind 过滤即可。我特别提醒:时间戳用 RFC3339 带时区,别用本地时间,跨时区排障会疯。

回放与离线评估

光有线上 trace 还不够,因为线上你不敢随便重跑。回放(Replay)的思路是:把每次运行的输入、中间每一步、最终输出都存下来,之后用新模型或新提示词离线重跑同一批输入,对比新旧产出差异。这是 Agent 迭代最稳的杠杆,也是我把"观测"和"改进"连起来的桥。

我维护了一个"黄金集":约两百条有标准答案或至少有人工评判的真实任务。每次改提示词或换模型,先在这两百条上跑一遍回放,看成功率和步数分布有没有变差,再决定是否上线。下面是一段极简的回放驱动,真实项目里 judge 会换成 LLM 评审或人工标注:

def replay(dataset, agent_factory):
    scores = []
    for case in dataset:
        agent = agent_factory()
        out = agent.run(case["input"])
        ok = judge(case["expected"], out)     # judge 可为人审或规则
        scores.append((case["id"], ok, agent.steps))
    return scores

def judge(expected, got):
    # 真实场景用 LLM 评审或人工标注,这里用包含判断示意
    return expected in got

离线评估还帮我发现过一类线上看不到的问题:模型换版本后,成功率没降,但"绕路率"高了,同样能答,但步数变多、成本变贵。这种"隐性退化"只有回放能抓,线上指标会被"反正答对了"掩盖。所以我现在把步数和成本也纳入回放对比,不只看对错。黄金集的维护成本不高,但每次改动的信心来源全靠它,值得长期投入。

回放还有个妙用:把线上卡死的样本摘出来,单独构成一个"卡死集",专门用来验证"我的修复有没有让这类输入不再卡死"。这是最贴近真实的回归测试,比 synthetic 用例管用得多。

从日志到改进的闭环

观测的终点不是"能看",而是"能改"。我跑的闭环是:线上 trace 持续收集 → 每周聚出异常模式(哪类任务步数异常高、哪个工具错误集中)→ 把典型案例补进黄金集 → 改提示词或加校验 → 回放验证 → 上线。这个环转起来,Agent 才会越跑越稳,否则观测只是花钱买心安。

举一个真实例子:trace 里反复出现 tool_chosen=search_web 然后 status=empty,聚完后发现是用户问的是"我们内部数据",模型却去搜公网。改进是在系统提示里加一句"涉及内部指标优先用 internal_db 工具",回放后这类空搜下降明显,步数中位数也回落。这个改进完全来自日志,不是拍脑袋。如果没有结构化字段,我只会看到"搜了很多次",永远不会想到是"搜错了地方"。

我还把"卡死"样本单独建了个集子。凡是在线上超过最大步数的运行,自动归档,每周人工看一遍它们卡在哪。几次下来,我发现大部分卡死都源于"工具返回格式和模型预期不一致",于是统一在工具层加了输出规范化,卡死率直接掉了一截。日志不分析就是废纸,分析不闭环就是空谈。我给团队定的硬指标是:每周至少要有一条"来自 trace 的改进"被合进代码,逼迫大家真的用数据驱动,而不是埋了点就完事。

落地的最低成本方案

如果你刚起步,不必一上来就自建整套。我建议的最小可用集:第一,给每次运行一个 trace_id 并贯穿全程;第二,每一步落一条 JSON 到文件或日志系统;第三,先用我列的那五个指标做最粗的日报。等体量上来,再接真正的 trace 后端和回放集。不要追求完美埋点,先把"每一步发生了什么"记下来,价值已经巨大。

我特别想提醒:别用自然语言日志糊弄自己。logger.info(f"模型返回: {resp}") 这种在排查时几乎没用,因为你不记得上次长什么样。结构化、字段化、可聚合,才是 Agent 观测和传统日志的分水岭。这一步偷懒,后面所有指标和回放都建不起来。当年我就是先用 print 上线,出了问题全员对着一屏花里胡哨的文本猜,才痛下决心切 JSON 的。

还有一个容易忽略的点:采样与隐私。全量落盘 token 很贵,我按"核心用户全采、长尾采样"的策略,既控成本又保覆盖。涉及用户 PII 的字段在入库前脱敏,intent 这种可能含用户输入的字段只存摘要。观测是为了掌控系统,不是把用户隐私也一并收集进来,这条合规线别碰。

常见误区

最后列几个我见别人(也见过当初的自己)反复踩的误区,照着避开能省很多时间。

  • 误区一:把 Agent 当普通服务加埋点就够。错,缺了推理步层,你永远不知道它为什么慢。
  • 误区二:只盯成功率。错,步数、成本、卡死率才是早期信号,成功率是最滞后的指标。
  • 误区三:日志用文本不结构化。错,不可聚合的日志在排障时等于零。
  • 误区四:没有回放就直接上线改动。错,Agent 改动的回归风险极高,黄金集是底线。
  • 误区五:追求全自动无人值守。错,Agent 系统需要人定期读 trace 并改进,观测的价值在于"人能介入"。

这些误区每一个我都交过学费,写出来希望你别重来。

多 Agent 场景下的观测差异

前面讲的都是单 Agent,多 Agent 系统观测要再多一层:worker 之间的消息流。除了 request/step/tool 三层,我还加了一层"消息层(Message)",记录主管派给工人的指令原文、工人回给主管的产物摘要。为什么要单独记?因为多 Agent 的故障常发生在"传递"而非"执行":主管理解偏了、工人没接住,日志里两边都"正常完成",但结果错了。

这一层的关键是记"原文"而非"我的转述"。我早期犯过懒,只记了"已派发任务 A",事后复盘根本看不出主管到底怎么描述的 A。后来改成记完整指令文本,一次复盘就定位到主管把"只需列标题"理解成了"需写全文",工人老老实实写了三千字,浪费且污染下游。多 Agent 观测,传递环节不可省。

告警怎么设才不吵

指标有了,告警是下一步,但 Agent 指标的告警比传统服务难设,因为它天然波动大。步数今天 5 明天 7,算不算异动?如果阈值卡太死,天天误报;卡太松,等发现时已故障半天。我的做法是"看分布偏移而非单点越界":用过去两周的同指标分布做基线,只有连续偏离基线超过两个标准差且持续上升才告警,单点抖动忽略。

另一个经验:告警要带"该看哪"的线索。我的告警消息里直接附上出问题的 trace_id 和最可能的根因假设(比如"步数偏高,疑似工具 X 错误率上升"),oncall 点开就能直奔现场,而不是再从零排查。告警的价值不在"通知你出事了",而在"帮你省掉定位的前十分钟"。

采样策略与成本权衡

全量落盘每一步的 token 很贵,尤其 Agent 的中间思考往往又长又重复。我的策略是分层采样:request 层和 tool 层全量(关键且体积小),step 层的 intent 全量但 tool_result 只存摘要,完整中间文本按"核心用户全采、长尾采样一成"保留。这样既控成本,又保了覆盖和诊断能力。

我还按错误加权采样:正常运行采样一成,但任何 status != ok 或卡死的运行 100% 全量留档。故障样本本就稀少且最该研究,不该被采样丢掉。这套策略把存储成本压在预算内,同时黄金集和卡死集的材料从不短缺。观测也要讲 ROI,无脑全量既贵又让真正该看的数据淹没在噪声里。

用 trace 做团队知识沉淀

trace 除了排障,还是最好的团队教材。我把每周最经典的几条(卡死的、绕路的、优雅一步到位的)存进一个"案例库",新人 onboard 时先读二十条,比看文档快十倍建立"Agent 会怎么跑"的直觉。比起抽象讲"提示词要写清边界",一条真实的"因为没写清导致工人重复劳动"的 trace 更有说服力。

我们还用它做复盘:每次线上事故,第一动作不是开骂,而是拉出那条 trace 从头走到尾,所有人看着同一份事实讨论。这避免了"我觉得它应该那样"的扯皮,把复盘变成了对齐认知的过程。观测数据一旦变成公共事实源,团队的沟通成本明显下降,吵架也少了,因为大家争论的不再是记忆,而是同一条记录。

观测系统的反模式清单

除了前面说的误区,再补几条工程层面的反模式,都是我见过的真事。其一,把 trace 只存内存不落盘,一重启全没,事故时反而最该看的数据丢了。其二,span 不挂 parent,三层结构塌成一层,等于没分层。其三,指标口径偷偷改,这周成功率九十五、下周九十八,其实是判定变松了,趋势完全失真。其四,回放集半年不更新,模型早换了,跑出来的对比毫无意义。

这些反模式共性是"短期省事、长期失信"。观测系统的信用一旦崩,大家就不再信面板,回到拍脑袋,前面所有投入归零。所以我对观测系统的要求是:落盘可靠、结构稳定、口径冻结、集子常新。四点守住,它才是一贯可信的事实源,而不是一个漂亮但没人信的摆设。

观测与安全的接口

顺带一提,观测和安全是连着的。Agent 调用外部工具的参数、返回,都是潜在注入面,而 trace 正好是你审计"模型有没有被诱导调了不该调的工具"的证据源。我曾靠 trace 发现一次提示注入:某条用户消息诱导模型连续调用了内部删除接口,虽然被权限层拦了,但 trace 里清清楚楚记着那串异常的工具调用序列。从那以后,我把"异常工具调用序列"也纳入了告警。

观测不只是看健康,也是在看清安全边界有没有被蹭。一个没有 trace 的 Agent,就像没有门禁日志的机房,你永远不知道谁动过什么。把工具调用层记全、记准,既是排障所需,也是安全审计所需,这两件事在 Agent 系统里本就是一回事。

给老板的那句话怎么说

回到开头老板的问题。现在我会这么答:"它这周成功率九十七,步数中位四,成本比上周降了一成;周三那次变慢是因为检索工具 X 抖动,我已经加了降级;卡死率零点三,在阈值内。"这段话之所以能脱口而出,不是因为我盯着它,而是因为三层 trace 和五个指标把"它在干嘛"翻译成了人话。

观测的终极价值,是让一个黑盒重新变得可对话。当老板、当 oncall、当你自己都能用同一套数据讨论 Agent,它才真正从"神秘地有用"变成"可控地有用"。对一个生产系统,这是上线的前提,不是上线后的装饰;对个人博客,它让你对自己的工具始终心里有数。

观测数据的留存与合规

trace 里可能含用户输入、含 PII,留存要克制。我的原则是"默认短留、按需长留":原始 request 层只留七天,异常和卡死样本永久归档,step 层的意图摘要留三十天。这样既满足排障和复盘,又不长期囤积敏感数据。涉及欧盟用户时,我把可识别字段在入库前哈希化,user_id 不存明文,只保留聚合分析所需的匿名标识。

合规不是事后补丁,是观测系统设计时就要定的。我见过团队先猛记、等法务找上门再删,结果删不干净还留了风险。把脱敏和留存期限写进采集层的代码,比事后清理可靠得多。Agent 系统收集的是"模型在和用户对话中产生的数据",性质上就是用户数据,这点别侥幸。观测越细,责任越重,这是做 Agent 系统的人躲不开的伴生义务。

结语

Agent 可观测性的核心,是把"它在想什么"也变成数据。传统监控盯的是确定性的链路,而 Agent 的不确定性恰恰藏在推理步里,所以 tracing 必须有"推理步"这一层,指标必须包含步数和卡死率这种 Agent 特有项,日志必须结构化到能回放。当我把这三件事做齐,老板那句"它到底在干嘛"我才终于答得上来,而且不只是答得上来,还能指着曲线说"昨天它在这里绕了路,我今天改了提示词,已经好了"。

观测不是为了炫技,是为了让一个会自己决策的系统,重新回到人能理解和掌控的范围内。对一个个人技术博客、对一个生产客服系统,这句话都成立。先把 trace_id 和 JSON 日志加上,你已经走完了最难的第一步。

相关文章