苦猿的大模型日记 · Day44 · Agent 的可观测性与调试-帮普通人把AI学进简历系列
前言:90% 的错误不会报错
先给你一个数字,我最近一年调试 Agent 攒下来的经验:传统代码,90% 的错误会通过异常、堆栈告诉你;Agent 恰恰相反,90% 的错误是"没报错,但它做错了"。
这不是夸张。
普通代码写错了,它会抛异常。TypeError、KeyError、堆栈、行号,明明白白告诉你"你死在第 37 行"。你能复现、能单步、能断点。
Agent 写错了,它不抛异常。它只是默默做了一件错的事------把价格算错了、把订单状态编造出来了、把用户带沟里了。全流程零报错,日志干干净净,唯一的异常信号是"结果不对"。
上周我就碰到这么一档子事。
我搭了个客服 Agent,接了订单查询工具。用户问"我这个订单到哪了",Agent 回了一长串:
"您的订单已从上海发出,预计明天下午三点送达,配送员是张师傅。"
说得有鼻子有眼。但我一查------那单三天前就签收了。它全程没调工具,凭空编的。
最气人的是:整个流程零报错。没有异常,没有警告,连日志都记满了"看起来一切正常"的信息。
后来我把那个 Agent 的日志翻了个底朝天,想搞清楚它为什么编。结果发现:日志里根本没记它调了哪几个工具、每步想了什么------我当初只记了"最终回答"。一个连"它到底有没有调工具"都查不出来的系统,谈何定位。
那一刻我意识到,我这一年调试 Agent 踩的所有坑,本质上都指向同一个问题:我们习惯了代码世界的确定性,而 Agent 活在概率世界里。 代码要么对要么错,Agent 是"大概率对、小概率错、错了也不告诉你"。
这就是 Agent 调试和普通代码调试最根本的差别------它坏掉的方式,是"不可见"的。
这篇文章解决两件事:先让循环里每一步都看得见 ,再让一次运行看得懂、能定位。最后给你一张故障图谱和一个能直接跑的最小可观测体系。

PART 01:为什么调普通代码那套,调不了 Agent
先说个扎心的结论:调试 Agent 不是调试代码逻辑,是调试决策过程。 两者的调试工具,几乎完全不同。
1.1 传统调试三件套,在 Agent 上全部失效
普通代码调试靠三样东西:报错、堆栈、断点。这三样,在 Agent 上一个个失效。
报错失效。 Agent 没有"业务错误"这个概念。它只有"对话继续"。你觉得它"答错了",它觉得自己"又完成了一轮"。报错机制天然就缺失。
堆栈失效。 普通代码的调用栈是"谁调了谁",一层层清清楚楚。Agent 的"调用栈"是一次次 LLM 调用,中间状态是对话历史,不是内存变量。你没法"回退到上一层",因为上一层是一段话,不是一个作用域。
断点失效。 你没法断在一个"思维"上。更麻烦的是------同样的输入,两次走的路径可能完全不同。因为模型采样有随机性,同一个问题,第一次它先查工具再回答,第二次它可能直接凭记忆编。普通代码是确定性的,Agent 不是。
1.2 你要 debug 的对象,从"代码"变成了"对话历史"
这四样失效之后,你要面对一个更本质的问题:
你真正要排查的,从"哪一行代码写错了",变成了"哪一步决策想岔了"。
每一步决策,由三样东西组成:模型看到了什么(输入)、模型想了什么(thought)、模型做了什么(action + observation)。
普通代码出 bug,你盯着变量看;Agent 出 bug,你得盯着这个序列看。
这里要特别强调一件事:Agent 的每一步决策,都不是"必然"的。 模型是在概率分布里采样的------它"大概率"会走对的路,但也有"小概率"在某一步想岔。这个"想岔"不像普通代码那样有确定性的原因(比如"这个变量为 null 了"),而可能只是那一刻采样偏了、或者上下文里某个角落有误导信息。
这就是为什么 Agent 的 bug 常常不可复现 。你重跑一遍,它可能又对了。这不是"薛定谔的 bug",而是你还没看到影响它决策的那部分输入------trace 里没记录的东西,就是 bug 藏身的地方。
但这里有个致命前提------你得先有这份"决策序列"的记录。
我见过太多人(包括当初的我),第一次调 Agent 时,加的全是这种日志:
print("调用工具...")
print("工具返回:", result)
只记"发生了什么",不记"模型当时看到了什么、为什么这么决定"。结果就是------日志有了,出问题时照样两眼一抹黑。因为你记下的,恰恰是出问题最少的那部分。
关键认知:Agent 的 bug 往往不在"它做了什么",而在"它看到了什么"。

小结
调普通代码,是找哪一行写错了;调 Agent,是找它在哪一步想错了。前者看代码,后者看"决策历史"------而决策历史,要靠你先记录下来。
PART 02:先解决"看得见"------给循环插桩,记录每一步
好,既然 Agent 的调试对象是"决策序列",那第一步就很明确了:把每一次决策过程,原原本本记录下来。
这就是插桩(instrumentation)。原理极简,一句话:在 ReAct 循环的四个关键点,各插一个记录动作。
2.1 四个插桩点
一个标准的 ReAct 循环长这样(30 行核心,做过 Agent 的都熟):
def run_agent(task: str, tools: dict, model, max_steps=20):
history = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": task}]
for step in range(max_steps):
# ① 调模型前:记下完整输入
response = model(history)
# ② 调模型后:记下原始输出
thought, action = parse(response)
# ③ 调工具前:记下动作
if action is None:
return thought # Final Answer
result = tools[action["name"]](**action["args"])
# ④ 调工具后:记下返回
history.append({"role": "assistant", "content": response})
history.append({"role": "tool", "content": str(result)})
return "达到最大步数,未完成"
就是上面这个最朴素的东西。要在哪儿插桩?四个点,刚好对应一次决策的完整生命周期:
| 插桩点 | 时机 | 记什么 |
|---|---|---|
| ① | 每次调模型前 | 当时的完整输入(system + 全部对话历史) |
| ② | 每次调模型后 | 模型的原始输出(一字不改,含 thought 和 action) |
| ③ | 每次调工具前 | 动作:工具名 + 入参 |
| ④ | 每次调工具后 | 返回:工具结果(超长截断) |
插桩后的循环,长这样:
def run_agent_observable(task, tools, model, max_steps=20):
history = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": task}]
trace = [] # 新增:一次运行的所有记录
for step in range(max_steps):
# ① 调模型前:记下完整输入
trace.append({"step": step, "type": "input",
"history": history.copy()})
response = model(history)
# ② 调模型后:记下原始输出
trace.append({"step": step, "type": "thought",
"raw": response})
thought, action = parse(response)
if action is None:
return thought, trace # Final Answer,连带 trace 一起返回
# ③ 调工具前:记下动作
trace.append({"step": step, "type": "action",
"name": action["name"], "args": action["args"]})
result = tools[action["name"]](**action["args"])
# ④ 调工具后:记下返回
trace.append({"step": step, "type": "observation",
"result": str(result)[:500]}) # 超长截断
history.append({"role": "assistant", "content": response})
history.append({"role": "tool", "content": str(result)})
return "达到最大步数,未完成", trace
2.2 这几个插桩点,为什么缺一不可
① 和 ② 是灵魂。 ③ ④ 是辅助。为什么?
因为 Agent 的 bug,80% 出在"模型当时看到的东西"和"模型当时的想法"上------
- 上下文被污染了,你得看 ① 才知道"哦,它看到了不该看到的东西"
- 模型把工具返回的"失败"当"成功"继续编,你得看 ② 的 thought 才知道"它为什么会觉得成功了"
- 工具调用格式错了(JSON 被截断),你得看 ② 的原始输出才知道"模型输出的是半截 JSON"
只记③④(工具名、结果),不记①②(输入、想法),就等于只记了案发地点,没记案发经过。
2.3 一个必须避开的坑:只记"结论"不记"过程"
很多人(包括踩过坑的我)插桩时会想:"反正最后有 Final Answer,中间过程记那么多干嘛?"
错。 中间过程恰恰是定位的关键。
我举个真实例子。有一次客服 Agent 把用户问的"怎么退运费险"答成了"退货运费"------俩完全不同的东西。最终回答看着挺正常,但用户反馈不对。
看最终回答,根本看不出问题。但一看中间 trace 就明白了:第 3 步的 thought 里,模型把"运费险"误解成了"运费",后面每一步都在错误的路上越走越远。问题出在第 3 步的"想法"上,而那个想法只存在于中间过程里。
小结
看得见,是调试 Agent 的第一步。怎么看得见?把循环四个点全插上桩------输入、想法、动作、返回,一样都不能少。

PART 03:再解决"看得懂"------结构化 trace 与重放
插桩解决了"看得见",但裸日志还是不好用。你看着 20 行的 trace,还是得一行行找。要把日志升级成结构化 trace------让机器能聚合、让你能一眼定位。
3.1 把每步日志写成 JSONL
最简单的做法:一行一个事件,JSON 格式,存成文件。这就是业界说的 trace。
{"run_id": "run_7f3a", "step": 0, "type": "input", "tokens": 1200, "history_len": 2}
{"run_id": "run_7f3a", "step": 0, "type": "thought", "raw": "用户问订单状态,我需要调用查询工具", "tokens": 150}
{"run_id": "run_7f3a", "step": 0, "type": "action", "name": "query_order", "args": {"order_id": "10086"}}
{"run_id": "run_7f3a", "step": 0, "type": "observation", "result": "{\"status\": \"signed\", ...}", "latency_ms": 320}
{"run_id": "run_7f3a", "step": 1, "type": "input", "tokens": 2100, "history_len": 4}
...
写起来就是:
import json
def log_event(f, event: dict):
f.write(json.dumps(event, ensure_ascii=False) + "\n")
3.2 三个关键设计
第一,给每个 trace 一个 run_id。 一次完整任务一个 id,所有事件共享。这样你才能"按一次运行聚合",把散落的事件串成一条线。没有 run_id,20 步就是 20 个孤立的碎片,看不出前后因果。
这也是为什么一定要先想清楚"一次运行"的边界。一个用户点一次"帮我查订单",这是一次运行;但如果 Agent 内部还派生了子任务(比如先查订单、再算价格、再写报表),每个子任务最好有自己的 run_id,同时挂一个共同的父 run_id。否则日志一混,你又分不清哪段是哪段的。
第二,记录每一步消耗的 token 和步数。 这是"绕圈"最灵敏的信号------正常任务 5 步,某次跑了 30 步,token 从 2 千涨到 1 万 2。步数的异常增长,就是 Agent 在绕圈的体检报告。
# 按 run_id 聚合,看步数和 token
import json, collections
stats = collections.Counter()
with open("trace.jsonl") as f:
for line in f:
ev = json.loads(line)
stats[ev["run_id"]] += 1
if ev["type"] == "input":
stats[f"{ev['run_id']}_tokens"] += ev["tokens"]
for run_id, n in stats.items():
if isinstance(n, int) and not str(run_id).endswith("_tokens"):
print(f"{run_id}: {n} 步, {stats[f'{run_id}_tokens']} tokens")
第三,完整保存一次运行的上下文,让它"可重放"。 这是最有价值的一个设计。
重放(replay)的意思是:把当时模型看到的完整上下文存下来,出问题时能原样复现"它当时到底看到了什么"。
很多 Agent 框架自带重放,原理都一样:存输入,不存推导。你存下第 3 步的完整 history,就能知道"它为什么在这一步想岔"。
注意,重放和"重跑"是两回事。重跑是再让模型跑一遍 ------因为采样有随机性,它可能又对了,你什么也学不到。重放是把当时已经发生的事情原样端出来看------输入没变、想法没变、输出没变,你看到的就是"案发现场"本身。这也是为什么重放比重跑值钱得多。
3.3 一个实战技巧:多存"看到",少存"做了"
这是我在生产里摔过跟头才悟出来的:trace 里多存"模型当时看到的完整上下文",不只存"它做了什么"。
原因还是那句话------Agent 的 bug 往往在"它看到的东西",不在"它做的事"。
比如上下文污染:第 5 步 history 里混进了别人的订单信息,模型就答错了。你只看"它做了什么"(答错了),永远找不到根因;你重放"它看到了什么"(第 5 步 history 里有脏数据),一秒定位。
3.4 生产手段轻提一句
说到这里,肯定有人要问:这些不都有现成工具吗?
有。 LangSmith、Langfuse、AgentOps 这些都是现成的 tracing 平台,能省很多造轮子的功夫。如果你在团队里,直接上现成的。
但原理就是上面这套:记录 + 结构化 + 可聚合。自己先能造出最小的一版,再用现成的,你才知道那些工具在帮你做什么、哪些字段值得看。
小结
看得懂,是调试 Agent 的第二步。怎么看得懂?结构化、加 run_id、记 token 步数、能重放------让一次运行变成一份可以反复翻的"病例档案"。

PART 04:常见故障图谱------从日志到定位的 checklist
看得见、看得懂之后,还差最后一块:一张故障地图------告诉你"这种症状,去看哪个字段"。
我把这一年调 Agent 遇到的高频故障,归纳成五类,每类给你"一眼定位"的字段。
4.1 故障一:死循环 / 绕圈
症状:同一工具被反复调用,任务迟迟不结束。
定位字段:看 step 数(异常大)+ 看 action 序列(反复出现同一个工具名)。
# 检测绕圈:同 run 内,同一工具连续/高频出现
from collections import Counter
seq = [ev["name"] for ev in events if ev["type"] == "action"]
counts = Counter(seq)
worst = counts.most_common(1)[0]
if worst[1] > 3:
print(f"⚠️ 绕圈嫌疑:工具 {worst[0]} 被调了 {worst[1]} 次")
这是最简单、最好定位的一类。看到绕圈,基本就是两种原因:工具返回了模型消化不了的结果,或者模型在反复试同一条死路。
4.2 故障二:幻觉性成功
症状:工具返回失败/空,但模型当作成功继续,然后编出一堆"正常"的回答。
定位字段:看 observation(工具真实返回)+ 看后续 thought(是否在"编")。
我那个编造"张师傅配送"的客服 Agent,就是典型。看 trace:第 1 步调了 query_order,返回的是空;第 2 步的 thought 是"用户想查订单状态,我直接告诉他"------模型把"工具没查到"当成了"工具查完了",然后开始编。
这类最坑 ,因为最终回答看起来完全正常。唯一的破绽,就是 observation 和 thought 之间的"逻辑断裂"------工具没给信息,thought 却说"我知道了"。
再补一个更隐蔽的变体:工具真的返回了,但返回的是错误信息,模型没看出来。 比如订单查询工具返回了 {"status": "error", "msg": "订单号不存在"},模型却把它当成"查到了订单"继续编。这种你只看 thought 是看不出来的,必须同时对照 observation 里工具的真实返回 。所以前面才强调:插桩时 ④(工具返回)一定要原样记,别只记"成功/失败"两个状态------模型犯错的素材,往往就在你没记全的那部分返回里。
4.3 故障三:上下文污染
症状:模型答非所问,但每一步看起来都"正常"。
定位字段:看每一步的 input(history 里混进了什么)。
客服 Agent 最常见的脏数据:把别的用户的对话、过期的系统消息、甚至上一单的 JSON 全塞进了 history。模型被带偏,答得越来越离谱。
一眼定位法:重放第 N 步的完整 history,找"这一步的输入里,多出了什么不该有的东西"。
上下文污染有个特别容易漏的源头:工具返回本身 。如果你的工具把一坨和当前任务无关的数据塞进了 observation(比如查订单时顺手带回了用户的历史账单),模型会把这坨东西也当成"上下文"消化掉,然后被带偏。这类污染,光看 input 的 history 还看不出来,得把 ④ 的工具返回也纳入检查------脏东西常常是从工具返回那里溜进来的。
4.4 故障四:工具调用格式错误
症状:模型想调工具,但动作解析失败------或者解析出来是错的。
定位字段:看 thought 的原始输出(raw)。
最典型的是:模型输出的 action JSON 被截断(token 到上限了),后半截丢了,解析失败。或者模型在 JSON 前后多输出了一行字("好的,我来查询:{"name": ...}"),parse 函数直接抛错。
这类最好修:看 raw,一眼就懂。修法通常也不在 Agent 逻辑里,而在 prompt 或者解析器的容错上。
4.5 故障五:目标漂移
症状:跑着跑着,忘了最初的目标,开始做无关的事。
定位字段:看每一步的 thought,对照最初的任务。
这是 Day43 讲规划时提过的坑,在可观测性里正好有对应:trace 里能看到模型一步步偏离原目标的过程。 第 1 步还在查订单,第 5 步开始"顺便"查物流公司,第 8 步开始研究"物流公司的股价"。
一眼定位法:把每一步的 thought 抽出来列成一行,目标漂移一眼可见。
4.6 故障图谱小结

| 故障 | 一眼定位字段 | 常见根因 |
|---|---|---|
| 死循环/绕圈 | step 数 + action 序列 | 工具返回模型消化不了 / 反复试死路 |
| 幻觉性成功 | observation + 后续 thought | 工具失败被当成功,开始编 |
| 上下文污染 | 每步 input 的 history | 脏数据混进对话历史 |
| 工具格式错误 | thought 的原始输出 raw | JSON 截断 / 多输出一行字 |
| 目标漂移 | 每步 thought 对照初始任务 | 模型注意力被中间结果带偏 |
小结
故障地图的意义在于:不用一行行读 trace,看症状 → 找字段 → 定位根因,三步走完。 排障从"大海捞针"变成"对表查病"。
PART 05:自建一个最小可观测体系
前面四步,可以串成一个完整的体系。这篇最后,给一个能直接跑的最小实现------循环 + JSONL 记录 + 统计 + 绕圈告警,60 行内搞定。

5.1 最小可观测框架
import json
class ObservableAgent:
def __init__(self, model, tools, log_path="trace.jsonl"):
self.model = model
self.tools = tools
self.log_file = open(log_path, "a", encoding="utf-8")
def _log(self, run_id, step, etype, **fields):
ev = {"run_id": run_id, "step": step, "type": etype, **fields}
self.log_file.write(json.dumps(ev, ensure_ascii=False) + "\n")
self.log_file.flush()
def run(self, task, max_steps=20):
run_id = f"run_{id(self)}_{len(open('trace.jsonl','r').readlines())}" # 简化版 run_id
history = [{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": task}]
for step in range(max_steps):
self._log(run_id, step, "input", tokens=0, history_len=len(history))
response = self.model(history)
self._log(run_id, step, "thought", raw=response)
thought, action = parse(response)
if action is None:
return thought
self._log(run_id, step, "action", name=action["name"], args=action["args"])
result = self.tools[action["name"]](**action["args"])
self._log(run_id, step, "observation", result=str(result)[:500])
history += [{"role": "assistant", "content": response},
{"role": "tool", "content": str(result)}]
return "达到最大步数,未完成"
配一个绕圈告警:
from collections import Counter
def detect_loop(run_id, log_path="trace.jsonl"):
actions = []
with open(log_path, encoding="utf-8") as f:
for line in f:
ev = json.loads(line)
if ev["run_id"] == run_id and ev["type"] == "action":
actions.append(ev["name"])
counts = Counter(actions)
top, n = counts.most_common(1)[0]
if n >= 3:
print(f"⚠️ [{run_id}] 绕圈告警: 工具 {top} 调用 {n} 次")
就这么点东西,已经覆盖了前面讲的大部分能力:每个决策过程有记录、可重放、可统计、能告警。
5.2 落地顺序:别一上来就上重型平台
很多人一听"可观测性",第一反应是接 LangSmith、上 APM。别急。
落地顺序应该从轻到重:
- 先有日志------循环里四点点埋上,能看就行
- 再结构化------JSONL、加 run_id、加 token 统计
- 再聚合------按 run_id 看步数、看 token、看工具调用频次
- 再加告警------绕圈、步数超限、token 异常,阈值触发
先自己造出最小的一版跑起来,再用现成平台,你会比直接上平台的人更懂那些字段在说什么。
我见过太多团队,上来就接最重的 tracing 平台,然后三个月后还在问"这个字段是什么意思"------因为他们从来没自己记录过一遍,不知道哪些字段是关键的、哪些是噪音。自己先手搓一遍,哪怕只有一天,之后再上平台,你一眼就能看出平台缺了什么、哪些视图对你有用。
另外提醒一句:别为了"可观测"而牺牲"可理解"。 有些平台埋点埋得又多又细,页面上一万个字段,反而比裸日志更难用。好的可观测体系的标准只有一个------出问题时,你能在十分钟内定位到"哪一步想岔了"。这个标准达不到,再多字段都是噪音。
5.3 两个坑
坑一:过度埋点。 什么都记,日志膨胀得飞快,出问题时反而不容易定位。关键点埋点(四个插桩点 + 少量统计)就够了。
坑二:完整上下文都存,token 成本翻倍。 每次输入都存完整 history,存储成本确实涨。权衡做法:默认只存"关键事件"(thought/action/observation + 每步 token),出问题时再临时开启"完整上下文重放"。
小结
可观测性的落地顺序,永远是"先有、再优、后自动化"。先让循环看得见,再让看得懂,最后让机器帮你盯------到这一步,你的 Agent 才敢上生产。
结尾:看得见,才敢信
回到开头那个编造"张师傅配送"的客服 Agent。
它现在还在跑,但已经不是黑盒了。每一步看了什么、想了什么、做了什么、工具回了什么,全部有记录。用户再反馈"答得不对",我翻 trace,三分钟定位到第几步想岔了。
Agent 从"能跑"到"敢上线",中间隔着的就是可观测性。
普通代码,错了会喊;Agent,错了不喊。所以你得替它把眼睛装上------装上之前,它是个黑盒;装上之后,它才是一台你能信任的机器。
普通代码的调试,是找哪一行写错了;Agent 的调试,是找它在哪一步想错了。你看不见 Agent 在想什么,就永远不知道它错在哪。
互动时间:你跑 Agent 的时候,最常遇到的是哪种"没报错但做错了"?是编造答案、绕圈,还是上下文污染?评论区聊聊,我下篇挑几个典型的拆一拆。
下一篇预告 :Agent 能跑、能调、能被看见了,接下来就是最带劲的一步------让多个 Agent 协作干一件大事。分工怎么分、消息怎么传、两个 Agent 打起来怎么办,下篇聊 多 Agent 编排实战。
--- END ---
苦猿 · 帮普通人把 AI 学进简历