2024 年我做了一个 AI Agent:给它一个用户的问题,它自己决定该查订单、查物流还是查退款,一步步调用我写好的几个工具,最后把答案组织出来回给用户。怎么让它"靠谱"?这件事我压根没多想。第一版我做得很顺手:一个循环,每一轮把对话历史发给大模型,模型说要调哪个工具我就调哪个,把结果塞回对话,再让它接着想,直到它给出最终答案。就完事了。我在公司内部演示了一遍——真不错:问"我的订单到哪了",它准确地查了物流、答得有模有样。我心里挺踏实:"Agent 嘛,不就是个调用工具的循环?哪天出 bug,打个断点、看下日志、复现一下不就完了?"可等这 Agent 真正上线、面对成千上万条五花八门的真实问题,一串问题冒了出来。第一种最先把我打懵:同一个用户问同一个问题,它这次答得好好的,下次却调错了工具、给了个驴唇不对马嘴的答案,我想"复现"一下那个 bug,反复跑了十几遍,它每次走的路都不一样,那个错误就是不出现。第二种最难缠:它会陷入死循环——同一个工具来回调用十几次,自己跟自己绕,直到撞上步数上限才停。第三种最头疼:它会一本正经地胡说——工具明明返回了空结果,它却凭空编了一个数字回给用户。第四种最莫名其妙:错误明明显形在最后一步的答案上,可我盯着最后一步怎么看都正常,问题压根不在那。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"调试 AI Agent,跟调试普通程序一样——打断点、看日志、复现一下就行了"。这句话把 AI Agent 当成了一段"输入确定、输出也确定、逻辑全写在我代码里"的普通程序。可它根本不是这么回事。我脑子里,这个 Agent 就是一段普通代码:它的逻辑写在我的 for 循环里,输入一样、走的路就一样,出了 bug 我打个断点单步跟一遍就能抓到。可 AI Agent 这种东西,在三个根本的地方,跟普通程序完全不一样,而这三个不一样,恰好把"打断点、复现、看日志"这套经典调试法整个废掉了。第一,它是非确定性的:Agent 每一步的决策,来自大模型的一次采样,同样的输入,模型这次可能决定查物流、下次决定查订单,所以你根本没法靠"再跑一遍"来复现一个 bug——bug 是随机出现的。第二,它的"逻辑"根本不在我的代码里:我的 for 循环只是个空壳,真正决定"下一步干什么"的逻辑,在大模型那一次次的决策里,而模型的决策由我喂给它的那段上下文(prompt + 历史 + 工具结果)驱动。所以 bug 几乎从来不在我某一行代码上,而在"我喂给模型的东西"上。第三,它是一个多步骤的循环:Agent 是"想一下→调个工具→看结果→再想一下"这样滚动十几步,而第 8 步给出的错误答案,病根往往在第 3 步——第 3 步某个工具返回了一份有点不对劲的数据,模型被它带偏,后面几步将错就错,错误一路滚到第 8 步才显形。我盯着第 8 步查,当然什么也查不出来。所以,调试 AI Agent,真正的调试单元,既不是我的某一行代码,也不是某一次孤立的大模型调用,而是这个 Agent 一次完整运行的全过程——它走的每一步、每一步喂给模型的完整上下文、模型的原始输出、调用了哪个工具、工具返回了什么——这一整条记录,叫做一次 trace。你不能"复现"一个 Agent 的 bug,你只能在它出 bug 的那一次,把整条 trace 完完整整地记下来,事后回放它、一步步检查,找出"决策到底是在第几步第一次走偏的"。真正把 AI Agent 调试做扎实,核心不是"打断点、看日志、复现一下",而是认清 AI Agent 是一个非确定性的、逻辑在模型里的、多步骤的决策循环,它的最小调试单元是一次完整的 trace;你要做的是完整记录每一次运行的 trace、用回放代替复现、沿 trace 定位决策第一次走偏的那一步,再把走偏的原因分清是 prompt bug、工具 bug 还是模型决策 bug,分别去治。这篇文章就把 AI Agent 调试这个坑梳理一遍:为什么打断点看日志调不了 Agent、Agent 的最小调试单元为什么是一次完整 trace、怎么把 trace 完整记录下来、怎么用回放代替复现并定位走偏的决策点、prompt 与工具与模型决策三类 bug 怎么分开治,以及一些把 Agent 调试做扎实要避开的工程坑。
问题背景
这个坑普遍,是因为绝大多数后端和前端工程师的调试直觉,都是被"确定性程序"训练出来的——同样的输入必然走同样的路,出了错打断点单步跟一遍就行。这套直觉对了几十年,可一旦对象换成 AI Agent,它从根上就失效了。它错得隐蔽,是因为演示阶段几乎永远是"对"的:你拿几个想好的问题去演示,模型大概率走在它最熟悉的那条主路上,Agent 表现得聪明又稳定,你根本意识不到它的非确定性会带来什么。它只在真实流量、海量五花八门的问题灌进来之后才集中爆发,而那时你想调试,却发现经典调试法一样都用不上。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:同一个问题这次对下次错、复现不出来;Agent 陷入工具反复调用的死循环;工具返回空它却编造答案;错误显形在最后一步、病根却在中间某步。
- 错误认知一:以为同样输入会走同样的路,可以靠"再跑一遍"复现 bug。真相是每一步决策来自模型采样,Agent 是非确定性的,bug 随机出现。
- 错误认知二:以为 Agent 的逻辑写在自己的代码里、打断点能跟到。真相是逻辑在模型的决策里,由喂给它的上下文驱动,bug 在上下文不在代码行。
- 错误认知三:以为错误发生在哪一步、病根就在哪一步。真相是 Agent 是多步循环,错误会从早期某步一路滚动放大到后面才显形。
- 真相:Agent 调试的最小单元是一次完整 trace,要靠记录、回放、定位走偏决策点,再分 prompt、工具、模型三类 bug 来治。
一、为什么打断点、看日志调不了 AI Agent
先把第一版那个 Agent 摆出来。它的思路就是字面意思:一个循环,模型决定调什么工具,我就调什么。
# 第一版:一个最朴素的 AI Agent —— 一个"思考→调用工具→再思考"的循环
def run_agent(user_question):
messages = [{"role": "user", "content": user_question}]
for _ in range(20): # 最多循环 20 步
# 让模型基于当前对话,决定下一步:要么调一个工具,要么直接回答
resp = llm.chat(messages, tools=TOOLS)
if resp.tool_call:
# 模型决定调用某个工具,我们执行它,把结果塞回对话
result = run_tool(resp.tool_call.name, resp.tool_call.args)
messages.append({"role": "tool", "content": result})
else:
return resp.content # 模型给出最终答案,结束
return "抱歉,我没能完成这个任务"
这段代码逻辑上挑不出错,演示时也跑得好好的。可一旦它在线上出了个怪答案,我拿出看家本领去调试,会发现三板斧全部砍空。第一斧,打断点单步跟:我在 llm.chat 那行打个断点,跟下去——然后呢?断点跟进去是一次网络请求,返回一个我无法预测的决策,我能"单步"的只有我那个空壳 for 循环,真正的逻辑在模型那头,断点根本跟不进去。第二斧,复现:我把那个出错的问题原样再发一遍,想让 bug 重现——可模型这次采样出了另一条路,答得好好的,bug 死活不出现。第三斧,看日志:于是我加 print。
# 第二版:加一堆 print,以为这样就能调试了(还是没用)
def run_agent(user_question):
messages = [{"role": "user", "content": user_question}]
for i in range(20):
resp = llm.chat(messages, tools=TOOLS)
print(f"第 {i} 步,模型返回:{resp}") # 打印一下模型干了啥
if resp.tool_call:
result = run_tool(resp.tool_call.name, resp.tool_call.args)
print(f" 调用工具 {resp.tool_call.name},结果:{result}")
messages.append({"role": "tool", "content": result})
else:
return resp.content
return "抱歉,我没能完成这个任务"
# 问题:print 出来的是"这一次"的运行,下次再跑路径就变了,对不上号
print 加完我才发现,它依然没用。它打出来的,是"这一次"运行的轨迹;可下一次运行,模型走的路就变了,这次的日志和下次的现象对不上。我攒了一堆日志,却没有一堆能对得上号的日志。把两种调试对象摆在一起,差别一目了然:
普通程序的调试 vs AI Agent 的调试
普通程序: 输入 X ──必然──→ 路径 P ──必然──→ 输出 Y
出错了 → 输入同样的 X,必然重走路径 P → 断点单步抓得到
AI Agent: 输入 X ──采样──→ 路径 P1 / P2 / P3 …… ──→ 输出 Y1 / Y2 ……
出错了 → 输入同样的 X,这次走的是 P2,可 bug 在 P1 上,复现不了
断点只能停在你那个空壳循环里,真正的决策在模型那头
这里要建立的第一个、也是最重要的认知是:在你动手调试一个东西之前,你必须先判断清楚,它到底是不是一个"确定性系统"——因为"打断点、复现、看日志"这套你用得最熟的调试法,它能成立,有一个你从来没意识到的隐藏前提:被调试的对象是确定性的,同样的输入必然导致同样的执行路径和同样的输出。正是这个前提,让"复现"成为可能(再给一遍输入就能重走错误路径),让"断点"有意义(执行路径固定,你能一步步跟),让"日志"可信(这次的日志能解释下次的现象)。AI Agent 把这个前提彻底打破了:它每一步的决策都来自大模型的一次采样,采样天生带随机性,于是同样的输入会长出许许多多条不同的执行路径。前提一旦不成立,建立在它之上的整套调试法就跟着一起垮:你复现不了,因为下一次跑的根本是另一条路;你的断点形同虚设,因为你能停下的只是那个不含真正逻辑的空壳循环;你的日志支离破碎,因为每一份日志只属于它自己那一次独一无二的运行。所以,面对 AI Agent,正确的第一反应不是"我的断点怎么不管用了",而是"我得换一套为非确定性系统设计的调试法"。而那套调试法的基石,就是下一节要讲的——既然你没法让 bug 重演,那就在它唯一一次出现时,把现场完完整整地录下来。
二、AI Agent 的"最小调试单元"是一次完整的 trace
既然没法复现,调试 Agent 的整个思路就得倒过来:不去追求"让 bug 再现一次",而是确保 bug 第一次出现时,现场被完整地保存下来。要保存现场,先得想清楚:Agent 运行的"现场",到底由什么构成?
一次 Agent 运行,是一连串步骤滚动起来的。每一个步骤,本质上是一次完整的"决策事件",它包含四样东西,缺一不可:一,这一步喂给模型的完整上下文(系统提示、对话历史、上一步的工具结果——模型就是看着这些做决策的);二,模型这一步吐出来的原始输出(它的思考过程和决定);三,模型决定调用的工具和传的参数;四,那个工具实际返回的结果。把这四样按步骤串起来,就是一次完整的 trace。这才是调试 Agent 的最小单元——不是一行代码,也不是一次孤立的模型调用,而是这一整条带着上下文的决策链。
# 一次 Agent 运行的"最小调试单元":把每一步结构化地记下来
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class TraceStep:
index: int # 这是第几步
prompt_messages: list # 这一步喂给模型的完整上下文(快照)
model_output: str # 模型这一步的原始输出
tool_name: Optional[str] = None # 模型决定调用的工具(没有就是 None)
tool_args: Optional[dict] = None # 调用工具时传的参数
tool_result: Optional[str] = None # 工具返回的结果
@dataclass
class Trace:
question: str # 这次运行的用户问题
steps: list = field(default_factory=list) # 一步一步累积的 TraceStep
final_answer: Optional[str] = None # 最终答案
这个结构里,最容易被忽略、却最关键的是 prompt_messages——这一步喂给模型的完整上下文快照。很多人记 trace 只记"模型说了什么、调了什么工具",偏偏漏掉"模型是看着什么说出这番话的"。可模型的决策完全由它的输入决定,你不存下那一刻的输入,事后就永远没法回答"它为什么会做这个决策"。
这里要建立的认知是:调试任何一个复杂系统,你都得先找到它正确的"调试粒度"——粒度选错了,你要么淹死在细节里,要么根本看不见问题。第一版用 print 调试,它的粒度其实是"单次模型调用"——它孤立地打印某一次 llm.chat 的返回,把每一次调用当成一个互不相干的点。可 Agent 的 bug,本质上是"决策链上的传导"问题:第 3 步一个不起眼的偏差,经过第 4、5、6 步的放大,最后在第 8 步爆发成一个离谱的答案。你用"单次调用"这个粒度去看,看到的只是一串孤立的点,你看不到点和点之间的那根因果链条,而 bug 恰恰活在那根链条上。trace 这个调试单元高明就高明在,它的粒度正好对准了 Agent 出问题的方式:它不是一个个孤立的点,而是一条完整的、带着上下文和因果的链——它记下了每一步是"看着什么"做出"什么决策"、又导致了"什么后果"。有了这条链,"偏差是怎么从第 3 步传导到第 8 步的"才第一次变得可见。所以,选择调试单元有一条通用的法则:你的调试单元,必须和这个系统"产生 bug 的方式"相匹配。普通函数的 bug 产生于单行代码,所以调试单元是代码行;Agent 的 bug 产生于决策链的传导,所以调试单元必须是那条完整的链——也就是 trace。找对了调试单元,问题才会在正确的尺度上向你显形。
三、把 trace 完整记录下来:每一步都要可回看
想清楚了 trace 由什么构成,记录它就是一件具体的工程活:把第一版那个空壳循环改造一下,让它每滚动一步,就往 Trace 里追加一个 TraceStep,并且不管这次运行成功还是失败,最后都把整条 trace 落盘存好。
# 带 trace 记录的 Agent 循环:每一步都完整地存进 Trace
def run_agent_traced(user_question):
trace = Trace(question=user_question)
messages = [{"role": "user", "content": user_question}]
for i in range(20):
resp = llm.chat(messages, tools=TOOLS)
step = TraceStep(
index=i,
prompt_messages=list(messages), # 关键:深拷贝,存"这一步当下"的上下文快照
model_output=resp.raw_text,
)
if resp.tool_call:
step.tool_name = resp.tool_call.name
step.tool_args = resp.tool_call.args
step.tool_result = run_tool(resp.tool_call.name, resp.tool_call.args)
messages.append({"role": "tool", "content": step.tool_result})
trace.steps.append(step)
else:
trace.steps.append(step)
trace.final_answer = resp.content
save_trace(trace) # 不管成功失败,trace 都落盘
return resp.content, trace
save_trace(trace) # 撞上步数上限,同样落盘
return "抱歉,我没能完成这个任务", trace
这里有两个细节决定了 trace 有没有用。第一个是 prompt_messages=list(messages) 那一行的 list(...):它做了一次拷贝,存下的是"这一步当下"的上下文快照。如果你直接存 messages 这个引用,循环后面还在不停往 messages 里追加东西,等你事后去看,每一步存的都成了"最终的完整对话",根本不是它当时看到的样子。第二个是"不管成功失败都落盘":Agent 的 bug 大多不会抛异常——它不会崩,它只是"答错了",从程序角度看一切正常。所以你不能只在出异常时存 trace,必须每一次运行都无条件存下来,出了问题才有据可查。
一次出了问题的运行,记录下来的 trace,长这样:
一次出问题的 Agent 运行,记录下来的 trace:
步骤 0 模型输出:我需要先查这个用户的订单 → 调用 query_orders(user_id=88)
工具结果:[] (空列表 —— 这个用户没有订单)
步骤 1 模型输出:我再查一下退款记录 → 调用 query_refunds(user_id=88)
工具结果:{"error": "user_id 必须是字符串"}
步骤 2 模型输出:查询失败了,我换个参数 → 调用 query_refunds(user_id="88")
工具结果:[{"id": 7, "amount": 200}]
步骤 3 模型输出:这个用户退款过 200 元,我直接告诉他能退 200
最终答案:您可以退款 200 元
↑ 但用户问的根本不是退款,是"我的订单到哪了"
盯着这条 trace 看,那个怪答案的来龙去脉一下就清楚了:步骤 1 工具因为参数类型报了错,步骤 2 模型自己纠正了参数、拿到了退款数据,可步骤 3 它把"退款记录"错当成了用户问题的答案——而用户从头到尾问的是订单物流。没有这条 trace,你对着最终那句"您可以退款 200 元"永远猜不到中间发生过这些。
这里要建立的认知是:面对一个你无法控制其行为、又无法让它重演的系统,你能做的、也是最该做的一件事,就是为它装上一台"不间断的记录仪"。这件事的思路,和调试技巧本身关系不大,而是一种更底层的工程世界观:当一个系统的故障是偶发的、不可复现的,那么"故障发生的那一刻"就是你唯一的、稍纵即逝的取证窗口——你要么在那一刻把现场录全,要么就永远失去了它。这正是为什么飞机上要装黑匣子:没人能让一次空难"复现"一遍给你看,所以航空业根本不指望复现,它转而做一件更可靠的事——让飞机在每一次飞行中,都不间断地记录下所有关键参数,这样无论哪一次出了事,调查员都能拿到那一次的完整记录去回溯。给 Agent 记 trace,做的是一模一样的事。而这里有两个容易被忽略、却致命的纪律。一是"录全":黑匣子要是只录了高度、漏了速度,关键时刻照样断不了案。trace 也一样,尤其要录下每一步的输入上下文快照——漏了它,你就只知道模型做了什么,永远不知道它为什么这么做。二是"无条件录":Agent 答错时通常不会崩溃、不会抛异常,它会安安静静地给出一个错误答案。所以你绝不能只在异常时记录,必须每一次运行都录——因为你事先根本不知道哪一次会出事。一个偶发故障的系统,它的可调试性,几乎完全取决于你有没有在故障发生之前,就为它老老实实地装好了那台一直在转的记录仪。
四、复现的替代品:trace 回放与决策点定位
有了完整的 trace,就能补上调试链条里缺失的那一环——"复现"。准确说,是用一个更靠谱的东西替代复现。复现的本质诉求,是"让我能反复地、稳定地观察那个出错的过程"。重新跑 Agent 满足不了这个诉求,因为它每次都变。但回放一条已经录好的 trace 可以:trace 是死的、固定的,你想看几遍看几遍,每一遍都一模一样。
# 复现的替代品:把磁盘上的 trace 读回来,离线重放和检查
import json
def load_trace(path):
with open(path, encoding="utf-8") as f:
data = json.load(f)
return Trace(**data)
def replay(trace):
# 回放不是"再跑一遍 Agent"(那样路径又变了)
# 而是把已经凝固在 trace 里的每一步,原封不动地摆出来逐步检查
for step in trace.steps:
print(f"=== 第 {step.index} 步 ===")
print(f"喂给模型的上下文(末条):{step.prompt_messages[-1]}")
print(f"模型的决定:{step.model_output}")
if step.tool_name:
print(f"调了工具 {step.tool_name},参数 {step.tool_args}")
print(f"工具返回:{step.tool_result}")
注意 replay 和"重跑 Agent"是两件完全不同的事:重跑是再次发起那些不确定的模型调用,路径又会变;回放只是把已经凝固在 trace 里的每一步,原封不动地摆出来给你看。回放是确定性的,这正是它能用来调试的原因。
能稳定回放之后,定位 bug 就有了着落。关键的一步,是放弃"盯着错误结果查"的本能,转而去 trace 里找决策第一次走偏的那一步:
沿着 trace 从头走,第一个"决策不合理"的步骤,就是 bug 的真正起点——它之后的所有步骤,大概率只是在忠实地、将错就错地处理一个本就被带偏的局面。把这个查找过程写成代码:
# 定位"决策第一次走偏的那一步":沿着 trace 一步步往下查
def find_first_bad_step(trace, is_step_ok):
# is_step_ok 是你写的一个判断函数:给它一步,它说这步合不合理
for step in trace.steps:
if not is_step_ok(step):
return step # 第一个不合理的步骤,就是 bug 的起点
return None # 每一步看着都对,问题可能在最终答案的措辞
# 一个具体的判断:第 1 步那个 query_refunds 报错,就是这里第一次走偏
def is_step_ok(step):
if step.tool_result and "error" in str(step.tool_result):
return False # 工具报错了却没被妥善处理,这步不 OK
return True
对前面那条 trace 跑一下:步骤 0 合理,步骤 1 工具报错了——这就是决策第一次走偏的地方。bug 的起点是步骤 1,不是那个显形在步骤 3 的怪答案。
这里要建立的认知是:调试一个多步骤系统,最关键、也最反直觉的一个动作,是把你的注意力从"错误显形的地方"挪到"错误起源的地方"——这两个地方,往往隔着好几步。人的本能是"哪里报错看哪里":答案错了,就死盯着生成答案的最后一步。可在 Agent 这种链式系统里,这个本能几乎总是把你带到错误的地方。因为最后一步常常是无辜的——它只是在忠实地处理一个早就被前面某一步带偏了的局面,你把它单独拎出来看,它的行为甚至是"合理"的(模型确实拿到了退款数据,它确实据此回答了),你在那儿能查出来的东西约等于零。真正的病根,在决策第一次偏离正轨的那个点。所以正确的方法,是沿着这条因果链从头往后扫,找到第一个"这步决策不该这么做"的地方——在它之前,一切正常;在它之后,系统进入了错误的轨道。这个"找第一个出错的环节"的思路,价值极大:它把你从"对着一个被层层放大、面目全非的最终症状发愁",拉回到"诊断一个还很小、很局部、离病根最近的初始偏差"。一个症状在传导链上走得越远,它就被搅得越复杂、越难懂;而那个最初的偏差,往往简单得多、也清楚得多。调试链式系统的功夫,八成都在这一个"回到偏差起点"的动作上。
五、三类 bug 要分开治:prompt bug、工具 bug、模型决策 bug
定位到"决策第一次走偏的那一步"之后,还不能立刻动手改。因为"这一步走偏了"只是现象,走偏的原因可以截然不同,而原因不同,药方也完全不同。把决策点的 bug 分成三类,是对症下药的前提。
第一类,prompt bug:你喂给模型的上下文本身就有问题——少了关键信息、混进了误导性内容、或者指令写得含糊。模型是看着一份残缺或错误的输入做决策的,它做错很正常。这类 bug,病根在你拼装上下文的代码里,跟模型、工具都无关。
第二类,工具 bug:你喂给模型的上下文没问题,但模型调用的工具出了错——工具崩了、返回了乱码、返回了格式不对的数据(就像前面 trace 里 query_refunds 因为参数类型报错那样)。模型基于一份错误的工具结果继续往下走,后面自然全错。这类 bug,病根在工具的实现或它的接口约定里。
第三类,模型决策 bug:上下文是全的、对的,工具结果也是对的,模型就是做了个错误的判断——该调工具 A 它调了工具 B,或者像前面那样,把退款数据硬塞给一个问物流的用户。这才是真正"模型自己的错"。这类 bug 治法和前两类完全不同:你改不动模型,只能通过改 prompt(给更明确的指令、加示例)、换更强的模型、或加一道决策校验来兜底。
把这套判断沉淀成代码,就是一个对 trace 步骤做断言的回归测试:
# 把"这一步该怎样才算对"沉淀成断言,变成可回归的测试
def assert_step(step):
# 工具 bug:工具被调用了,却没拿回结果或拿回了异常
if step.tool_name and step.tool_result is None:
raise AssertionError(f"第 {step.index} 步:工具 {step.tool_name} 没有返回结果")
if step.tool_result and "error" in str(step.tool_result):
raise AssertionError(f"第 {step.index} 步:工具报错未被处理 {step.tool_result}")
# 模型决策 bug:有可用结果,模型却做了离谱的下一步
if step.tool_result == "[]" and "退款 200" in step.model_output:
raise AssertionError(f"第 {step.index} 步:工具明明返回空,模型却编了个数字")
# 把出过问题的 trace 收集成一个回归集,每次改完 prompt 都重放断言一遍
def run_regression(trace_dir):
for path in list_traces(trace_dir):
trace = load_trace(path)
for step in trace.steps:
assert_step(step)
每修一个 bug,就把那条出过问题的 trace 连同对应的断言,收进一个回归集。以后每次改 prompt、换模型、调工具,都把整个回归集重放断言一遍——这样你才知道,这次的改动是修好了一个老问题,还是顺手又带崩了三个。
这里要建立的认知是:当一个系统由多个性质不同的部件协作而成时,"定位到出问题的环节"只完成了调试的一半,另一半、也是更容易被跳过的一半,是"判断清楚问题出在这个环节的哪一种性质上"。一个 Agent 的决策点,看似是一个点,其实是三种东西的交汇:你写的上下文拼装逻辑、你接的外部工具、以及你调用的那个模型。同一个"决策走偏"的现象,这三种东西里任何一个出问题都能导致。如果你不区分,只是笼统地说"这一步错了",你的修复就只能靠猜——而这三类 bug 的药方是互相冲突的:prompt bug 要你去改拼装上下文的代码,工具 bug 要你去改工具实现,模型决策 bug 你压根改不了代码、只能从 prompt 和模型选型上想办法。猜错了类别,你就会在错误的地方使劲:工具返回了乱码,你却一遍遍去打磨 prompt 的措辞,改到天荒地老也没用,因为你修的根本不是生病的那个部件。所以面对一个多部件协作的系统,调试的纪律是分两步走:先定位是"哪一个环节",再定性是"这个环节的哪一类毛病"。定位告诉你去哪儿,定性告诉你用哪种药。少了定性这一步,你的修复就退化成了在三类病因之间碰运气,而碰运气,从来不是工程。
六、工程里那些 AI Agent 调试的坑
调试的主体方法对了,落地时还有几个工程坑反复咬人。第一个,Agent 必须有硬性的"安全阀"。Agent 会陷入死循环——同一个工具来回调,自己绕自己。你不能指望模型自己醒悟,必须从外面给它装上步数、时间、成本三道上限,任何一道被突破就强制中止。
# 给 Agent 的循环装上"安全阀":步数、时间、成本任意一个超了就强制停
import time
class AgentBudget:
def __init__(self, max_steps=20, max_seconds=60, max_tokens=50000):
self.max_steps = max_steps
self.deadline = time.time() + max_seconds
self.max_tokens = max_tokens
self.used_tokens = 0
def check(self, step_index):
if step_index >= self.max_steps:
raise AgentStop("步数超出上限,可能陷入了死循环")
if time.time() > self.deadline:
raise AgentStop("运行超时,强制中止")
if self.used_tokens > self.max_tokens:
raise AgentStop("Token 消耗超预算,强制中止")
第二个,trace 要记工具的输入,不是只记输出。很多人记 trace 只存工具返回了什么,漏了调用工具时传的参数。可工具 bug 里有一大半,是模型传了个错参数导致的(就像前面把 user_id 传成了数字)——不记参数,你就分不清是工具本身坏了,还是模型把它用错了。第三个,trace 里的敏感信息要脱敏。trace 会原样录下用户问题、工具返回的数据,里面常带着手机号、订单号、住址这类隐私。trace 要落盘、要给人看,落盘前必须把这些字段脱敏,否则一份 trace 就是一次数据泄露。第四个,别忽略 trace 本身的成本。完整 trace 会很大(每一步都存了完整上下文快照),全量长期保存,存储会爆。合理的做法是:出错的 trace 长期留、采样保留一部分正常的 trace、其余的定期清理。第五个,回归集要持续养。一个只在你修 bug 当天用过一次的回归集,等于没有。每修一个新 bug 就往里加一条,每次改动都重放一遍,它才会随着时间越来越值钱。把这些都接进监控,你才有数据驱动地去调:
AI Agent 上线后要盯死的几个核心指标:
avg_steps_per_run 平均每次运行的步数,突然变长往往是死循环的前兆
loop_abort_rate 撞上步数上限被强制中止的比例,高了说明 Agent 常卡死
tool_error_rate 工具调用的报错率,按工具分开看,定位是哪个工具拖后腿
wrong_tool_rate 调错工具的比例,靠回归集里的断言来统计
trace_capture_rate 成功落盘的 trace 占比,这是你出事后能不能取证的命根子
cost_per_run 每次运行的平均 token 成本,Agent 多绕几步成本就翻倍
这里要建立的认知是:把这一节的坑串起来看,会浮现一个关于 AI Agent 的总体判断——它是一个你"无法完全控制、也无法完全信任"的部件,这个属性,决定了你对待它的工程姿态,必须和对待一段你自己写的、行为确定的代码截然不同。对自己写的确定性代码,你可以信任它:它会按你写的逻辑走,不会突然自作主张。可 Agent 不会,它随时可能陷入死循环、可能调错工具、可能把一份隐私数据原样吐进日志、可能为了一个简单问题绕上十几步烧掉一大笔 token。这一节所有的坑和对策,根子都是同一句话:你不能信任这个部件会"自己表现良好",你必须从它外面,用工程手段把它围起来。给它装步数和成本的安全阀,是不信任它会自己停下;在 trace 落盘前做脱敏,是不信任它不会吐露隐私;持续养一个回归集,是不信任它今天对了明天还对。说到底,围绕 AI Agent 做工程,核心心态不是"我写好了它就会乖乖工作",而是"我假设它随时会以我想不到的方式出问题,所以我要在它周围预先建好一圈护栏、记录仪和报警器"。一个不可控部件能不能被放心地用在生产里,从来不取决于这个部件本身有多聪明,而取决于你在它外面那套约束、观测、兜底的工程,做得有多扎实。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| AI Agent | 一个想一步调工具看结果再想的多步决策循环 | 非确定性 逻辑在模型里而非你的代码里 |
| trace | 一次 Agent 运行的完整记录 每步含上下文与决策 | Agent 调试的最小单元 不是代码行 |
| 非确定性 | 同样输入模型采样出不同执行路径 | 导致 bug 无法靠重跑来复现 |
| 上下文快照 | 记 trace 时存这一步喂给模型的完整上下文 | 漏了它就永远不知道模型为何这么决策 |
| 回放 replay | 把已录好的 trace 原样摆出逐步检查 | 确定性 是复现的可靠替代品 |
| 决策走偏点 | trace 里第一个不合理的步骤 | bug 的真正起点 而非错误显形处 |
| prompt bug | 喂给模型的上下文残缺或带误导 | 病根在你拼装上下文的代码 |
| 工具 bug | 工具崩溃或返回错误格式的数据 | 病根在工具实现或接口约定 |
| 模型决策 bug | 上下文工具都对 模型自己判断错 | 改不动模型 靠 prompt 与选型兜底 |
| 安全阀 budget | 步数时间成本三道硬上限 | 防 Agent 死循环烧光预算 |
避坑清单
- 别用打断点、复现、看日志去调 Agent。它是非确定性系统,这套经典调试法整套失效。
- 把每次 Agent 运行的完整 trace 记下来,每步含上下文快照、模型输出、工具调用与结果。
- trace 存上下文要做深拷贝,直接存引用会被后续步骤污染成最终对话,看不到当时的样子。
- 不管成功失败都无条件落盘 trace,Agent 答错通常不抛异常,只在异常时记会漏掉。
- 用回放代替复现,回放一条录好的 trace 是确定性的,重跑 Agent 不是。
- 定位 bug 沿 trace 从头找决策第一次走偏的步,别死盯错误显形的最后一步。
- 把决策点 bug 分成 prompt、工具、模型决策三类,分别对症,别笼统地猜。
- 给 Agent 装步数、时间、成本三道安全阀,任意一道超限就强制中止,防死循环。
- trace 落盘前对手机号、订单号等隐私字段脱敏,一份明文 trace 就是一次数据泄露。
- 把出过问题的 trace 连同断言养成回归集,每次改动都重放,防修一个崩三个。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为调试 AI Agent,跟调试一段普通程序没什么两样,打个断点、复现一下、翻翻日志就能抓到 bug。可 AI Agent 在三个根本的地方和普通程序不一样——它是非确定性的、它的逻辑不在我的代码里而在模型的决策里、它是一个会把早期偏差一路放大的多步循环。这三点合在一起,把"打断点、复现、看日志"这套依赖"确定性"才能成立的调试法,整个废掉了。我没有先看清调试对象的性质,就把用了多年的老办法直接套了上去。
真正把 AI Agent 调试做扎实,工作量不在"写多巧妙的调试工具",而在一次思路的转变:承认你没法让 Agent 的 bug 重演,于是不再追求复现,转而追求记录与回放。一旦接受这一点,该做的事就都浮现出来了——把每一次运行的完整 trace 录下来、用回放代替复现、沿 trace 定位决策第一次走偏的那一步、再把走偏的原因分成 prompt、工具、模型决策三类分别去治。每一步都不复杂,难的是先承认:你调试的不是一段你能完全掌控的代码,而是一个会自己做决策、且每次决策都可能不同的部件。
我后来常拿飞机的黑匣子来想这件事。空难是没法复现的——没有人能让一架飞机照着上次的样子再失事一遍给调查员看。所以航空业根本不在"复现"上下功夫,它做的是另一件事:让每一架飞机在每一次飞行的全过程里,都不间断地记录下高度、速度、姿态、每一个操作……这样无论哪一次飞行出了事,调查员都能拿到那一次的完整记录,从头回放,精确地找出是哪一秒、哪个动作开始出了岔子。给 Agent 记 trace,就是给它装黑匣子;事后回放 trace、找决策第一次走偏的那一步,就是调查员回放记录仪、定位事故起点。第一版的我,是开着一架没装黑匣子的飞机——出了事,除了"它好像不太对",我什么也拿不到。
这类问题最咬人的地方,在于它在演示和测试时几乎永远是"对"的:你拿几个精心想好的问题去演示,模型大概率走在它最熟、最顺的那条主路上,Agent 聪明得无可挑剔,你根本看不见它非确定性的那一面。它只在真实流量涌进来、成千上万个你没预料到的问题把模型逼上各种偏门小路之后,才集中暴露,而那时你想调试,才发现连录都没录、无从查起。所以别等线上用户开始抱怨 Agent"时灵时不灵"才想起 trace:做 AI Agent 的第一天,就该把"完整记录每一次运行的 trace"当成和写 Agent 主循环同等重要的事来设计——它不该是一个"出了问题再补"的事后补丁,而该是你写第一行 Agent 代码时就铺好的地基。把这台记录仪在一开始就装上,你才算真正跳出了那个几乎人人都会踩、却要等到上线才追悔莫及的"用调普通程序的办法去调 Agent"。
—— 别看了 · 2026