1. 当Agent开始"装死",我们才意识到它缺了点什么
你有没有遇到过这种情况:让AI coding agent帮你重构一个模块,它信誓旦旦地说"已完成修改",你一看diff,改是改了,但把隔壁两个文件的import全删了。或者更隐蔽的——它跑了一个测试命令,返回码是0,但实际测试根本没执行,因为路径写错了,shell静默失败了。你问它怎么回事,它说"测试通过,任务完成"。
这不是模型能力问题,这是可观测性缺失的问题。Agent在"执行"和"验证"之间有一道巨大的裂缝:它能发出动作,但看不见动作的真实后果。就像一个蒙着眼睛修水管的人,拧了扳手,听见水声停了,就以为修好了——实际上可能是总阀被邻居关了。
这篇东西不是要讲什么高深理论,而是把我自己在给coding agent搭可观测层和自愈机制时踩过的坑、试过的方案、以及几个真正能跑起来的开源思路整理出来。适合已经在用Claude Code、Cursor、Aider或者自己搭agent loop的人看,也适合想从零做一个带反馈闭环的agent系统的开发者。核心就一件事:怎么让agent不仅"能干活",还能"知道自己干得对不对",以及"干错了怎么自己拐回来"。
2. 可观测不是加日志,是给Agent建一套"体感系统"
2.1 为什么传统日志对Agent几乎没用
大多数人第一反应是"加日志嘛"。我一开始也这么干,在agent每一步后面打一行print(f"[DEBUG] step {i}: {action}")。跑了两天就放弃了——日志文件几万行,出了错根本不知道从哪看起。更致命的是,日志是给人看的,不是给agent自己看的。Agent需要的是结构化的、可判断的、能直接触发决策的信号,而不是一堆文本。
这里有个认知转变很关键:可观测性的服务对象有两个——你和agent本身。给人看的部分是事后复盘用的trace;给agent看的部分是实时决策用的feedback signal。这两者数据结构完全不同。前者可以是非结构化的span树,后者必须是紧凑的、带语义标签的、能直接喂回prompt的状态对象。
我后来把agent的每一步抽象成一个StepRecord,大概长这样:
@dataclass class StepRecord: step_id: int action_type: str # "edit_file" | "run_cmd" | "read_file" | "think" action_payload: dict expected_outcome: str # agent自己声明的预期 actual_outcome: dict # 实际观测到的结果 exit_code: int | None stdout_digest: str # 截断+摘要后的输出 stderr_digest: str side_effects: list # 文件变更、进程状态等 timestamp: float duration_ms: int关键字段是expected_outcome和actual_outcome的对比。Agent在执行前必须声明"我预期会发生什么",执行后系统填充"实际发生了什么",两者的差异就是可观测性的核心信号。没有这个对比,日志就只是流水账。
2.2 三个必须观测的维度:文件系统、进程、语义
我试过很多观测点,最后收敛到三个维度,缺一不可。
文件系统维度是最容易被忽略的。Agent改文件,你不能只看它说改了什么,要看文件系统实际发生了什么。我用的是watchdog库做inotify监听,配合git的diff --stat做快照对比。每次agent声称"修改了X文件",系统自动跑一次git diff --name-only,如果agent说的和实际改的对不上,立刻标记为phantom_edit异常。这个机制帮我抓到过好几次agent"幻觉编辑"——它以为改了,实际因为路径错误写到了别的地方。
进程维度主要针对命令执行。subprocess.run的returncode只是最粗的信号。我额外采集:进程实际运行时长(对比预期)、CPU/内存峰值(判断是否真的在干活)、子进程树(判断有没有fork出意外的东西)。有一次agent跑pytest,returncode是0,但进程只跑了0.3秒——正常测试套件至少要跑十几秒。一查,它跑的是pytest --collect-only,根本没执行测试。如果只看returncode,这个错误永远发现不了。
语义维度是最难但最有价值的。Agent的输出文本里藏着它的"自我认知"。我用一个轻量级的规则+小模型混合方案做语义抽取:从agent的回复里提取"我完成了X""我验证了Y""Z应该没问题"这类断言,然后逐条去和实际观测结果对账。对不上的断言就是过度自信信号,直接触发自愈流程。这个方案不需要大模型,我用正则加一个几百M的小分类模型就够了,延迟控制在50ms以内。
2.3 用OpenTelemetry给Agent搭trace骨架
如果你不想自己造轮子,OpenTelemetry是目前最成熟的选择。它的span模型天然适合agent的嵌套执行结构:一个task是一个root span,每个step是一个child span,step内部的工具调用是grandchild span。
我实际接入的方式是给agent的每个action包一层OTel decorator:
from opentelemetry import trace tracer = trace.get_tracer("agent.core") def traced_action(action_type): def decorator(fn): def wrapper(self, *args, **kwargs): with tracer.start_as_current_span(f"action.{action_type}") as span: span.set_attribute("action.type", action_type) span.set_attribute("action.input", str(args)[:500]) try: result = fn(self, *args, **kwargs) span.set_attribute("action.status", "ok") span.set_attribute("action.output_digest", str(result)[:500]) return result except Exception as e: span.set_attribute("action.status", "error") span.record_exception(e) raise return wrapper return decorator好处是你可以用Jaeger或者Grafana Tempo直接看整个agent的执行树,哪个step耗时异常、哪个step报错、step之间的依赖关系一目了然。更重要的是,OTel的span context可以跨进程传播——如果你的agent会调用子agent或者外部工具,trace能串起来。
但OTel有个坑:默认的batch exporter会丢数据。Agent执行快的时候,span还没flush进程就结束了。我改成SimpleSpanProcessor加同步导出,虽然性能差一点,但数据完整性有保障。生产环境可以用BatchSpanProcessor配合force_flush()在关键节点手动刷。
2.4 一个反直觉的经验:观测粒度要"粗到能决策,细到能定位"
我一开始把观测做到极细,每个函数调用都打点。结果agent的prompt里塞满了观测数据,反而干扰了它的判断。后来我定了一个原则:喂给agent的观测信号,粒度必须是"能直接触发一个决策"的。比如"文件X的第42行被修改"太细,"文件X的修改引入了语法错误"刚好,"文件X的修改导致测试Y失败"就是最佳粒度。
而给人看的trace可以细到函数级,因为人可以做关联分析。这个区分很重要,很多agent可观测方案失败就是因为没区分这两个消费者。
3. 自愈不是重试,是让Agent学会"认错"和"换路"
3.1 重试为什么经常越试越糟
最简单的自愈就是失败重试。我试过,效果很差。Agent执行失败后重试,往往用同样的方式再撞一次墙。因为它不知道自己为什么失败——错误信息在stderr里,但它可能根本没读,或者读了没理解。
更糟的是重试污染。Agent第一次改文件改错了,重试时在错误的基础上继续改,越改越乱。我遇到过一次,agent重试了7次,把一个200行的文件改成了800行,全是重复代码和注释掉的旧逻辑。
所以自愈的第一步不是"重试",是**"冻结现场+归因"**。失败后立刻做三件事:保存当前文件系统快照(git stash或者copy)、提取错误信号、让agent基于错误信号重新规划。这三步做完再决定是重试、回滚还是换方案。
3.2 基于"预期-实际"差异的自愈触发条件
自愈不能瞎触发,要有明确的触发条件。我总结了几类高价值的触发信号:
| 信号类型 | 具体表现 | 自愈动作 |
|---|---|---|
| 编辑幻觉 | agent声称改了A,实际改了B或没改 | 回滚+重新定位目标文件 |
| 静默失败 | returncode=0但输出为空/异常短 | 重新执行+加verbose参数 |
| 断言冲突 | agent说"测试通过"但实际有fail | 强制读取测试输出+重新判断 |
| 循环检测 | 连续3步action高度相似 | 中断+换策略提示 |
| 副作用溢出 | 修改了预期外的文件 | 回滚溢出部分+告警 |
这些信号里,循环检测最容易被忽略但最重要。Agent陷入循环时,它自己往往意识不到。我的做法是维护一个最近10步的action embedding,用余弦相似度检测。如果连续3步相似度超过0.9,就强制注入一条系统消息:"你似乎在重复之前的操作,请换一种思路,或者说明你卡在哪里。"
3.3 回滚策略:git stash不是万能的
说到回滚,很多人第一反应是git stash。但agent执行过程中可能产生未tracked的新文件,git stash默认不处理这些。我用的是组合方案:
# 执行前 git add -A && git stash push -m "agent-checkpoint-$(date +%s)" # 或者更彻底的方式 cp -r workspace workspace.checkpoint.$(date +%s)git stash的问题是它会改变工作区状态,如果agent正在依赖某些未提交的改动,stash后可能直接跑不起来。我后来改用影子目录快照:每次agent执行前,用rsync把workspace同步到一个.checkpoints/目录,回滚时反向同步。这个方案对agent完全透明,不影响它的执行环境。
代价是磁盘占用。我的策略是保留最近5个checkpoint,更早的自动清理。对于大仓库,可以用rsync --link-dest做硬链接快照,几乎不占额外空间。
3.4 让Agent自己写"失败复盘"再重试
这是我觉得最有效的一招:失败后不让agent直接重试,而是强制它先写一段复盘。复盘必须包含三个要素:失败的直接原因、根本原因、下一步的不同做法。
Prompt大概是这样:
上一步执行失败。在重试之前,请先完成以下分析: 1. 实际发生了什么?(基于提供的stderr和文件状态) 2. 你之前的预期是什么?差异在哪里? 3. 根本原因是什么?(不要停留在表面) 4. 下一步你会采取什么不同的做法?为什么这次会成功? 只有完成分析后才能执行下一步。这个机制的效果超出预期。Agent在写复盘的过程中,经常自己就发现了问题——比如"我假设文件用的是Python 3.10语法,但实际环境是3.8"。而且复盘文本会进入context,后续步骤会参考它,避免重复犯错。
我统计过,加了复盘机制后,同一任务的agent步数平均减少了23%,因为减少了无效重试。
4. 几个能直接抄的开源方案与组合拳
4.1 LangFuse:给Agent做trace和eval的性价比之选
LangFuse是我目前用得最多的开源可观测方案。它本来是给LLM应用做trace的,但它的span模型和score机制非常适合agent场景。
核心用法是给每个agent step打一个span,然后用它的score API给step打分:
from langfuse import Langfuse langfuse = Langfuse() trace = langfuse.trace(name="coding_task", input=task_description) span = trace.span(name="step_edit_file", input=edit_payload) # ... 执行 ... span.end(output=actual_result) # 用规则或小模型打分 langfuse.score( trace_id=trace.id, name="edit_correctness", value=1.0 if expected == actual else 0.0, comment="文件修改与预期一致" )好处是它自带Web UI,能看到每个task的完整执行树和分数分布。我每周会看一次score的分布,如果edit_correctness的均值下降,说明agent的编辑能力在退化(可能是prompt改动或者模型更新导致的)。
LangFuse的坑:自托管版本的clickhouse依赖比较重,小机器跑不动。如果只是个人用,可以用它的cloud免费版,或者退而求其次用SQLite做本地存储。
4.2 OpenTelemetry + Grafana:适合已有可观测栈的团队
如果你的团队已经在用Grafana,那直接上OTel是最顺的。Agent的trace数据进Tempo,metrics进Prometheus,日志进Loki,然后在Grafana里做统一面板。
我搭过一个面板,核心指标就四个:step成功率、平均step耗时、自愈触发率、自愈成功率。这四个指标能覆盖80%的agent健康度判断。比如自愈触发率突然飙升,说明agent遇到了系统性问题;自愈成功率下降,说明自愈策略需要调整。
OTel的语义约定(semantic conventions)里没有agent相关的标准,我建议自定义一套attribute命名规范,比如agent.step.type、agent.step.status、agent.selfheal.trigger。团队内统一就行,不用等标准。
4.3 用inotify+git hooks做轻量级文件观测
如果你不想引入重型依赖,watchdog+git的组合能覆盖大部分文件观测需求。我写过一个不到200行的FileObserver,核心逻辑:
from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class AgentFileWatcher(FileSystemEventHandler): def __init__(self, expected_changes): self.expected = set(expected_changes) self.actual = set() def on_modified(self, event): if not event.is_directory: self.actual.add(event.src_path) def reconcile(self): phantom = self.expected - self.actual unexpected = self.actual - self.expected return {"phantom": phantom, "unexpected": unexpected}Agent执行前声明它要改哪些文件,执行后用reconcile()对账。phantom是它说改了但没改的,unexpected是它没说要改但改了的。两个集合都为空才算干净。
这个方案的好处是零外部依赖,坏处是inotify在容器环境里可能不工作(取决于挂载方式)。容器里可以用polling模式,watchdog支持PollingObserver,代价是延迟高一点。
4.4 自愈策略的编排:用状态机而不是if-else
自愈逻辑如果写成if-else,很快就会变成意大利面。我用的是一个简单的状态机:
NORMAL -> (检测到异常) -> DIAGNOSING -> (归因完成) -> ROLLING_BACK -> RETRYING -> NORMAL -> (无法归因) -> ESCALATING -> HUMAN_INTERVENTION每个状态有明确的进入条件、执行动作、退出条件。用Python的transitions库或者自己写一个几十行的状态机都行。关键是状态转换必须可观测——每次转换都打一个span,这样你能看到agent在自愈流程里卡在哪一步。
我踩过的坑:自愈不能无限循环。我设了一个max_selfheal_attempts=3,超过就升级到人工。有一次agent在一个自愈循环里转了3次都没出来,第4次我强制中断,发现它在反复回滚和重试同一个错误。如果没有这个上限,它能转到天荒地老。
5. 踩坑实录:那些让我半夜爬起来改代码的瞬间
5.1 观测数据污染了Agent的Context
早期我把完整的stderr塞进agent的context,想着"信息越多越好"。结果agent被一堆warning和deprecation信息干扰,反而忽略了真正的error。更糟的是,有些命令的stderr有几千行,直接把context窗口撑爆了。
后来我加了一个输出摘要层:stderr先过一遍规则过滤(去掉warning、deprecation、progress bar),再截断到关键部分(error前后各20行),最后才喂给agent。这个摘要层用正则就能实现,不需要模型。
经验:喂给agent的观测数据,信噪比比信息量重要得多。宁可少给,不可给杂。
5.2 自愈触发了但Agent不知道
有一次我加了自愈逻辑,检测到异常后自动回滚了文件。但agent不知道发生了回滚,它以为自己的修改还在,下一步基于错误的假设继续执行。结果就是回滚了又改回去,改回去又回滚,死循环。
修复方案很简单但容易忘:任何自愈动作都必须显式通知agent。回滚后注入一条系统消息:"检测到异常,已回滚到上一个检查点。你之前的修改已撤销,请基于当前状态重新规划。"这条消息必须进入agent的context,否则它就是在盲跑。
5.3 循环检测的误报
我用action embedding做循环检测,阈值设0.9。结果发现agent在正常执行时也会被误判——比如它连续读三个不同的文件,action都是read_file,embedding相似度很高,但其实是在做正常的信息收集。
修复方案是把action的payload也纳入相似度计算。read_file("a.py")和read_file("b.py")的payload不同,相似度就降下来了。另外我加了一个白名单:read_file和think这类只读操作不参与循环检测,只有edit_file和run_cmd这类有副作用的操作才检测。
5.4 快照回滚把Agent的"记忆"也回滚了
这是个隐蔽的坑。我用影子目录做文件快照,回滚时文件恢复了,但agent的对话历史里还记着"我已经改了X文件"。文件回滚了,记忆没回滚,两者不一致。
解决方案是回滚时同步回滚对话历史。具体做法是给每个checkpoint关联一个对话历史的index,回滚时把对话历史截断到对应位置。这样agent的"记忆"和"文件状态"始终一致。
这个机制实现起来有点绕,但它是自愈系统可靠性的基石。不一致的状态比不恢复更危险。
5.5 性能开销:观测不能拖慢Agent
全量观测的开销比我想的大。每个step都做文件快照、跑diff、算embedding,一个step额外增加200-500ms。对于需要几十步的任务,累积起来就是十几秒的额外延迟。
优化思路是分级观测:不是每个step都做全量观测。think类step只记时间戳,read_file只记文件路径,只有edit_file和run_cmd才做全量快照和对账。这样开销降到了可接受的范围。
另外快照用rsync --link-dest做增量,第一次全量,后续只存变化的部分,磁盘和IO开销都大幅下降。
6. 把可观测和自愈串成一个闭环
单独做可观测或者单独做自愈,价值都有限。真正的价值在于闭环:观测信号触发自愈,自愈结果反馈到观测,观测数据又用于优化自愈策略。
我现在的架构大概是:每个step产生StepRecord,StepRecord进入一个SignalExtractor提取异常信号,信号触发SelfHealOrchestrator执行自愈,自愈的每一步又产生新的StepRecord。整个链路用OTel串起来,在Grafana里能看到完整的闭环流转。
这个闭环跑顺之后,最直观的变化是agent的"装死率"大幅下降。以前它经常说"完成了"但实际没完成,现在这种情况会被phantom_edit检测抓到,触发自愈,要么真的完成,要么明确报告失败。对我来说,一个能诚实说"我失败了"的agent,比一个假装成功的agent有价值得多。
如果你现在正在搭agent系统,我的建议是先做观测,再做自愈。观测是自愈的前提,没有可靠的观测信号,自愈就是瞎猜。而观测里,优先做"预期-实际"对账,这是投入产出比最高的一环。至于工具选型,LangFuse适合快速起步,OTel适合长期建设,inotify+git适合轻量场景,按你的实际情况选就行,不用追求一步到位。