AI Agent 调试完全指南:从一次"Agent 演示时好好的,一上线就胡乱调用工具"看懂 trace 与决策定位

2024 年我做了一个 AI Agent给它几个工具查订单查物流发起退款让它根据用户的问题自己决定调哪个工具怎么调试它出问题这件事我压根没多想第一版我做得很顺手用一个 for 循环把大模型的对话和工具调用串起来模型说要调工具我就调把结果塞回去再问模型直到它给出最终答案演示的时候真不错问一句查我上周的订单它准确地调了查订单工具给出漂亮的回答我心里很笃定调试嘛不就是打断点看日志复现一下可等这 Agent 真正上线被几百个真实用户用一串问题冒了出来第一种最先把我打懵线上用户报错我照着他的问题再问一遍 Agent 表现完全正常复现不出来第二种最难缠Agent 有时会陷入死循环同一个工具反复调几十次第三种最头疼工具明明返回了空结果模型却编造了一个看起来很合理的答案第四种最莫名其妙错误显形在最后一步可我盯着最后一步怎么都看不出问题病根在中间某一步早就埋下了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为调试 AI Agent 跟调试普通程序一样打断点看日志复现一下就行可一个 AI Agent 跟普通代码有三个根本不同它是非确定性的同样的输入两次运行结果可能不一样它的核心逻辑不在你写的代码里而在模型每一步的决策里它是一个多步循环早期一个微小的偏差会被后面每一步不断放大所以调试 AI Agent 的最小单元根本不是一行代码一个断点而是一次完整的 trace从用户输入到最终输出中间模型每一次决策每一次工具调用每一个返回结果的完整链条真正把 AI Agent 调试做扎实核心不是打断点而是认清 Agent 的最小调试单元是一次完整 trace要把每一步决策和工具调用完整记录下来用 trace 回放代替复现把 bug 分成 prompt 问题工具问题模型决策问题三类分开治本文从头梳理为什么打断点看日志调不了 AgentAgent 的最小调试单元为什么是 trace怎么把 trace 完整记录下来复现不出来时怎么用 trace 回放定位决策点三类 bug 怎么分开治以及一些把 Agent 调试做扎实要避开的工程坑

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 死循环烧光预算

避坑清单

  1. 别用打断点、复现、看日志去调 Agent。它是非确定性系统,这套经典调试法整套失效。
  2. 把每次 Agent 运行的完整 trace 记下来,每步含上下文快照、模型输出、工具调用与结果。
  3. trace 存上下文要做深拷贝,直接存引用会被后续步骤污染成最终对话,看不到当时的样子。
  4. 不管成功失败都无条件落盘 trace,Agent 答错通常不抛异常,只在异常时记会漏掉。
  5. 用回放代替复现,回放一条录好的 trace 是确定性的,重跑 Agent 不是。
  6. 定位 bug 沿 trace 从头找决策第一次走偏的步,别死盯错误显形的最后一步。
  7. 把决策点 bug 分成 prompt、工具、模型决策三类,分别对症,别笼统地猜。
  8. 给 Agent 装步数、时间、成本三道安全阀,任意一道超限就强制中止,防死循环。
  9. trace 落盘前对手机号、订单号等隐私字段脱敏,一份明文 trace 就是一次数据泄露。
  10. 把出过问题的 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

HTTP/2 完全指南:从一次"网站首页加载慢得想关掉,优化了文件却还是慢"看懂多路复用与队头阻塞

2026-5-22 18:21:29

技术教程

慢 SQL 优化完全指南:从一次"加了索引 EXPLAIN 也显示用了索引,查询却还是慢"看懂执行计划

2026-5-22 18:40:56

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索