做Agent开发有一段时间的人,大概率都经历过同一个场景:demo跑得风生水起,一上真实场景就原形毕露,不是上下文串了,就是工具调用翻车,再看看账单,一次对话烧掉几万token,心都在滴血。Agent项目难,难就难在它不是传统意义上“写完就稳”的程序,而是一个每次执行都可能走出全新路径的系统,这恰恰决定了调试、错误处理和成本优化这三件事必须从第一天就绑在一起考虑。
这篇内容我打算围绕Agent开发中最容易让人失眠的三个问题展开:怎么像调试传统代码一样调试一个连路径都不确定的Agent,怎么设计一套能扛住各种意外状况的错误处理机制,以及怎么在保障体验的前提下把token成本真正压下来。无论你是在做一个客服Agent、一个工具调用Agent,还是类似autonomous agent的复杂项目,这篇文章里的思路、代码片段和排查经验,基本上都能直接搬到你自己的项目里用。
1. Agent调试的本质:从“断点思维”切换到“回放思维”
1.1 为什么传统调试方法在Agent上失灵了
写过几年代码的人,对调试的第一反应往往是断点、单步执行、查看调用栈。这套组合拳在传统程序里几乎无往不利,因为传统程序是确定性的:同样的输入,必然走同样的代码路径,输出同样的结果。到了Agent这里,这套方法论直接失效了。Agent的核心是大模型在每一个决策点都可能根据上下文生成不同的下一步指令,它可能会选择调用A工具,也可能会选择不调用任何工具直接回答;即使两次输入完全一致,模型也可能因为温度参数或者微小的prompt差异,走出一条完全不同的执行路径。
这就带来一个非常直接的尴尬:你在测试环境里复现问题时,可能已经跑不出来线上那条路径了。“这次不出现,下次一定出现”成了Agent调试最让人头疼的事。我最早做Agent项目的时候,试过在Agent的循环代码里打日志、打断点,每次看到的就是一堆run tool、observe result的堆叠,完全看不出来模型内心是怎么想的。更糟的是,一旦你试图把断点加进Agent的核心循环里,时间一长整个运行的节奏就被破坏了,甚至引发超时。后来我彻底放弃了这个思路,转向了“记录一切,事后回放”的方式。
1.2 给Agent装上“黑匣子”:全链路结构化日志
像飞机一样给Agent装上黑匣子,是Agent调试的第一个关键动作。所谓黑匣子,就是把每一次运行的完整轨迹记录下来,包括:用户输入、系统提示词、模型每一次的原始返回、工具调用的参数和结果、每一步之间状态的变化、token消耗、耗时,以及最终输出。光记录还不够,必须以结构化格式落盘,每条日志带一个统一的session_id和step序号,这样才能在事后像放电影一样把整条执行链路重新拉出来看。
我常用的日志结构大概是这样的:
{ "session_id": "7f2a9c4e-1234-4f5e-8b3a-1a2b3c4d5e6f", "step_index": 3, "event_type": "tool_call", "agent_state": "running", "tool_name": "search_knowledge_base", "tool_args": {"query": "退货政策"}, "model_input_tokens": 4520, "model_output_tokens": 210, "latency_ms": 680, "created_at": "2025-03-21T10:24:33.128Z" }注意,这里字段的命名和类型都要尽量稳定,因为后面要靠这些字段做统计分析。事件类型event_type尤其重要,它至少要区分:llm_call(大模型请求)、tool_call(工具调用)、action(决策动作)、error_event(错误事件)、cost_event(成本统计)等几类。有了这套日志,调试Agent的时候就不再是盯着黑盒猜,而是直接检索“哪一个步骤开始跑偏”“第几步开始上下文膨胀”“哪次工具调用返回了异常长文本”,问题一下就聚焦了。实际上,很多成熟的Agent框架,像LangSmith、Langfuse,做的就是这件事,底层原理就是给Agent运行时加layer,把每次调用记录下来。
1.3 回放式调试工具链:像gdb一样看关键帧
日志是做底层材料,想要真正高效定位问题,还得有回放能力。所谓回放,就是能把某次运行里,每个步骤大模型的原始输入输出完整看一遍。我个人的做法是自建一个轻量的调试台:所有Agent运行时的会话数据、消息历史、工具调用详情图都写入数据库,然后通过一个简单的Web界面,输入session_id之后,就能看到这条会话的完整时间线,包括每一步的prompt实际内容、模型的raw response、工具的真实返回,以及每一步的时间消耗。
这一步之所以重要,是因为Agent最常见的错误模式往往不是当场报错,而是“走偏”。比如它本来应该调用线上知识库工具,却在第5步开始自己编答案;又比如某个工具的返回值特别长,到第7步已经把早期指令挤出了上下文窗口。这些情况通过终态看根本不知道问题在哪,只有通过回放每一帧才能发现。
如果你不想从零搭建,也可以用LangSmith、Langfuse或者本地部署的Helicone这一类追踪工具,它们基本都支持session级回放。但我想提醒的是,不管用什么工具,一定要保证“prompt完整入参”这个字段每次都留下来,很多平台为了省存储会裁剪prompt文本,那样调试价值就大打折扣了。
2. 错误处理设计:把“意外”变成“流程的一部分”
2.1 给Agent错误分个类:不是所有错都需要重试
刚开始做Agent的人,收到一个报错第一反应就是加try-catch、加重试。但Agent的错误复杂得多,错误来源大,类型差异化也非常大。我给实际项目归类之后发现,很多时候不加区分地重试,不仅解决不了问题,还会白白烧掉大量token。第一类错误是输入校验类,用户传了一个不符合要求的键值对,工具拒绝执行,这种错误重试多少次都没用;第二类错误是格式错误,模型返回的JSON格式不对,字段缺失、多了嵌套、被截断,这种错误通过“修正提示后让模型重新生成一次”往往有效;第三类是外部依赖异常,比如知识库服务挂了、API返回500,这种错误就需要重试加退避;第四类是内容超长导致的上下文溢出,这种错误重试没有任何意义,必须先做摘要或裁剪。
针对不同错误,我建议做一个分类映射表:错误类型、触发条件、处理策略、预算开销评估。比如输入参数校验错误,直接返回给上层或让模型改正参数,开销最低;格式解析错误,重试一次,但如果连续两次失败就停止并输出兜底回复;外部服务不可用,做指数退避重试,最多三次;上下文溢出,则切换到摘要模式或者对话裁剪流程,然后重试一次。“重试”永远不是唯一的答案,更不是最优答案。
2.2 设计多层防护网:Harness、LLM与工具层的分工
如果说Agent大脑是大模型,那Harness(运行时与调度框架)就是驾驶舱。Harness负责控制整个循环,决定调用哪些工具、什么时候停止、如何处理工具返回结果,而模型本身只负责做决策。这个区分非常重要,直接决定了你的错误处理代码应该写在什么位置。
我在实际项目中,错误处理是严格分层写的:
- 工具层:负责捕获工具自身的异常,如网络超时、参数校验失败、第三方API返回非200;
- Agent调度层(Harness):负责捕获大模型输出格式错误、上下文过长、tool调用循环超限、总执行步数超过阈值;
- 用户交互层:负责在Agent最终失败时,提供一个体面且有用的兜底回复,而不是直接把一堆底层报错抛给用户。
用一段伪代码表示核心循环的错误处理逻辑,大概是这样:
def run_agent(user_input): messages = [system_prompt, user_input] for step in range(max_steps): try: response = llm_client.chat(messages=messages) if response.has_tool_calls(): messages.append(response) tool_results = execute_tool(response.tool_calls) messages.append(format_tool_result(tool_results)) else: return response.final_answer() except ContextWindowExceededError: messages = summarize_early_messages(messages) except OutputParseError as e: if e.retry_count >= 2: return fallback_answer("抱歉,我没能成功处理你的请求,请换个说法再试试。") messages.append(error_feedback_prompt(str(e))) except ToolExecutionError as e: messages.append(tool_error_feedback(e)) return fallback_answer("抱歉,处理超时。")这段代码看起来简单,但实际执行时覆盖了三种最常见的Agent运行错误:上下文超长、解析失败、工具异常。而且每一层都配上了兜底策略和重试上限,避免了无限循环烧钱的情况。
2.3 进程级防护:step限制、token预算与熔断
Agents出问题的时候,最可怕的是“不报错但不停”。模型可能陷入一种循环,不断调用同一个工具,每次返回结果都没能推动任务往前走,token就在这种无效循环里烧掉。我在项目里专门给Agent定义了三个硬性上限:最大执行步数(比如20步)、单次会话最大token消耗(比如5万token)、最长执行时间(比如120秒)。这三个上限一旦被触发,无论Agent当前看起来状态多好,直接强制停止,走兜底回复。
在实际项目里,熔断还可以做得更细。比如专门给某个高成本工具设定调用次数上限,或者当某个工具连续3次返回异常之后,自动将对应工具从可用工具列表中临时下掉。这些都是基于实际过程中最容易烧钱的场景总结出来的。我还喜欢在Harness里加一个“自救模式”:当错误出现时,把报错信息作为一条工具结果发回给模型,让它自己分析错误并决定下一步怎么改,这个小技巧能明显提高复杂任务的容错率。但要注意设置重试上限为2-3次,否则模型可能会在不正确的路径上越走越远。
3. 成本优化:把Token花在刀刃上
3.1 先算账再优化:掌握成本的三个主干
搞成本优化,最忌讳就是不看数据瞎猜。我见过太多人一谈成本就想换便宜模型,结果换完之后效果大打折扣,成本反而因为重试增多而上升。想控制成本,第一件事是建立成本归因。拿到账单之后,你要能说清楚:这个session花了多少钱、大头出在哪个步骤、是模型输出太长、工具返回太大、历史消息堆积严重,还是方案里反复重试。
一次Agent运行的费用,主要由三块构成:Prompt输入token、模型输出token、工具返回并塞入上下文的文本长度。其中“工具返回内容”最容易成为隐形成本黑洞。很多知识库搜索工具默认把Top 10结果全量塞回上下文,每篇几千字,一次工具调用就能吃掉上万token。我曾在一个项目里排查出某次会话前后只用了3步,却因为一个工具返回了全套商品详情,单次上下文就达到3万多个token,而这部分token还没产生任何有效价值。
给每个session和每个工具额外加上一块费用统计字段,比如estimated_cost_usd,这是成本优化的前提。有了它,你可以用一个简单的SQL或者Python脚本每天跑一张表,看看哪类任务、哪个工具、哪个步骤最烧钱。拿到数据之后再去动手改,十有八九能一击即中。
3.2 提示词层优化:压缩上下文比换模型更见效
很多人不知道一件事:模型计费是按token算,系统提示词和工具描述在每一轮迭代中都会重复计费。如果Agent跑10步,那这段超过2000token的系统提示词就相当于付了10次钱。压缩系统提示词、工具描述和历史消息,是成本优化里最直观、最立竿见影的一步。
我常用这三个手段。第一,精简工具描述。很多工具描述写得像文档一样全,动辄几百甚至上千token,实际上模型真正需要知道的只是“这个工具是做什么的、在什么情况下用它、关键参数是什么”。把那些啰嗦的说明和示例砍掉,工具描述从500个token压到150个token并不难,10步循环就是省了3500个token。第二,做历史消息摘要。会话跑久了,早期消息对最终决策的贡献越来越低,却还占着大量上下文。与其一股脑把第1步到第40步的原文全塞进上下文,不如把早期对话每隔几轮做一次摘要,始终保持上下文长度在可控范围内。第三,给模型提供“忽略工具返回内容的指令”。有些工具调用返回了大量原始数据,模型其实只需要其中几个字段,你可以在工具描述里明确写出:“不要将完整原始返回原样重复,仅提取对回答用户问题有用的关键信息”,这能显著减少模型的输出token。
3.3 缓存复用:同一个问题的第二个买单者
使用缓存是Agent成本优化里被严重低估的手段。Agent场景存在大量的重复查询:不同用户问同一个高频问题、同一个session里Agent重复调用同一个外部API、或者在多个并发session里因为相同意图触发了相同的工具调用。我在项目里通常做两层缓存:第一层是精确匹配缓存,用户在短时间内问完全相同的问题,直接返回上一次的答案,不重新调模型;第二层是工具结果缓存,在同一个session内,如果模型反复调用同一个工具且参数完全相同,直接从缓存里读结果返回,不再去真实API获取。
更进阶一点的方案是做语义缓存,也就是将用户问题的embedding向量存起来,当新问题的向量与历史问题的余弦相似度超过某一阈值(比如0.95),直接走缓存答案。这个方案能用便宜的一次embedding调用替代掉一次昂贵的完整LLM调用。不过要谨慎设置阈值,太低了容易答非所问,损害体验。我一般把阈值设定在0.95以上,只对高度相似的问题做语义缓存。缓存部分做得好,在一个客服类Agent项目中,大约可以砍掉20%-30%的LLM调用次数。
3.4 模型分级路由:让贵模型干贵模型的活
另外一种思路更灵活:并非所有任务都需要当前的旗舰大模型。很多Agent项目里的子任务都比较简单,比如“把用户输入分类到几个意图桶里”“抽取一段文本中的关键实体”“判断一个工具返回是否达到预期”,这些任务用便宜的小模型甚至基于规则的分类就能完成。把这类任务从大模型那里面剥离出来,放到一个路由层去分流,成本能直接砍掉一大截。
我自己的做法是:在Harness里加一个轻量的“路由判断器”。用一个小模型(或是一组正则规则)判断当前任务的复杂度,如果它判断这个步骤属于简单子任务,就走小模型处理;判断为复杂任务或核心对话任务,才使用能力最强的模型。举例来说,意图识别、关键词抽取用便宜模型;多轮规划、复杂逻辑推理、解释性长回答用旗舰模型。这样整体效果几乎不变,但单次会话花费平均能下降20%-40%。不过这个方案要额外关注一个小问题:路由器本身也会有判断错误,需要在路由逻辑里留一个降级通道,一旦便宜模型回答质量不达标,就把它重新交还给旗舰模型处理。
4. 常见问题排查实录:一些真实踩坑的复盘
4.1 “Agent execution terminated due to error”背后到底藏着什么
这个报错在Agent开发社区里出现频率极高。因为很多框架默认配置会在执行抛错时直接以这一个笼统的提示结束进程,你看不到任何具体细节。排查它的第一步,就是找出它内部包裹的真正异常是什么。通常的做法是:查看Harness日志、查看完整执行轨迹、查看最后一步模型返回的内容和系统状态。我遇到的最常见原因有三类:一是工具返回了超大JSON或超长文本,导致上下文超出模型窗口上限;二是模型在一次对话中重复调用同一工具,直到步数用尽;三是结构化输出解析失败后重试次数耗尽,走到了异常退出分支。
如果用的是LangGraph、CrewAI或者自研的Agent框架,排查方式大同小异,核心都是找到真正的root cause。我曾经遇到过一个现象极其隐蔽的案例:工具返回的文本是正常的,但里面有一个超大Base64编码图片字段,光是这一项内容就占了1.8万token,每次跑都到第8步触发上限。当时没有工具调用维度的token统计,我根本看不出来问题,后来加了工具返回长度字段才定位到。所以给每个工具返回体增加一个token_length统计项,甚至超过5000token就自动截断并加上提示,是一个非常实用的便宜防坑手段。
4.2 工具调用死循环:Agent为什么会在原地打转
工具调用死循环几乎是Agent开发中发生率最高的问题。表现在外部,就是Agent连续调用同一个工具,参数几乎不变,每轮都没有推动任务进展,直到撞上步数上限。根因一般有两个:一个是工具返回的结果中没有提供有效的新信息,模型反复拿到同一个数据,自然无法做出下一步决策;另一个是模型被绕进了一个“先调用A拿结果,再把结果传给A再调用”的畸形路径里。
应对方案除了我前面提到的设置步数上限和工具调用次数上限之外,还有一个实用的技巧:在每一轮的模型回复里附带强制约束,当模型判断“当前工具已经返回过相同或相似结果”时,必须停止调用工具,转入“诚实回答无法完成”的分支。我还会在工具返回中加入一个“结果指纹”字段,例如对返回内容做哈希,让Harness可以直接比对两次工具结果是否相同。如果连续3次指纹相同,Harness会主动打断循环,给模型的下一轮输入注入提示:“你一直在重复调用该工具,且结果未变化,请停止,分析已有信息并给出回答”。这个小改动在多个项目里帮我把无效循环几乎压到了零。
4.3 上下文里的“幽灵”:历史消息顺序和工具消息污染
上下文问题属于隐蔽性极高的一类,它不会直接报错,但会让Agent变得越来越“笨”。最常见的两个坑:第一是消息顺序错乱,工具调用结果被放在用户消息前面,导致模型把工具结果当成用户输入;第二是工具结果的格式没有统一,有的工具返回是纯文本,有的是markdown,有的是JSON字符串,模型被迫花费大量token去解析不同格式,还容易解析错。
这里有一个关键原则:给所有工具返回数据统一包裹一层结构化的“ToolResult”,并在消息中明确标注当前这条消息来自哪个工具调用。还要注意,在messages序列里,工具结果消息的位置必须紧随对应的assistant工具调用消息之后。很多框架默认会帮你处理这些,但如果你在自研Harness,一定要验证消息序列是否正确。随着会话推进,上下文变得越来越长,历史消息顺序出错的概率也会上升,我建议每隔几步做一次消息序列的自动校验,确保每条assistant工具调用消息下面紧跟对应的tool消息。
4.4 结构化输出时不时解析失败:到底哪里出了问题
Agent场景里,大模型经常被要求输出JSON,用于后续的逻辑判断。即便模型能力很强,结构化输出仍然会偶发失败,表现为JSON截断、多出注释、嵌套了大段文本导致转义错误。这类问题最不好排查,因为它不是必然发生,而是概率性出现。我踩过最经典的坑是:工具返回文本本身是JSON格式,但里面包含了未转义的特殊字符,导致整段输出无法解析。
针对这类问题,我的经验是把所有结构化输出解析都包在一个“解析-修复-重试”的函数里。第一次解析失败时,不直接报错,而是把解析器抛出的异常信息和出错位置的文本片段拼成一个“修复反馈”,作为一条新消息发给模型:“你上一次的输出无法被JSON解析,错误信息如下,请根据错误信息重新生成严格的JSON输出。”这个方法在很大程度上解决了偶发解析失败的问题。另外,在提示词里明确要求模型使用代码块包裹JSON输出,并在解析时先剥掉代码块符号再尝试解析,也能减少不少麻烦。
4.5 排查技巧速查表
我把日常排查中最常用的检查项整理成了一张表,每次Agent出现异常,先从上往下过一遍,通常很快就能找到症结所在。
| 检查项 | 方法 | 典型表现 |
|---|---|---|
| 日志完整度 | 查看session_id是否存在、step是否连续 | 某次运行缺了中间步骤,说明日志未全量收集 |
| 工具返回大小 | 统计每条tool结果的token_length字段 | 某个工具单次返回超5000token,可能就是上下文溢出元凶 |
| 消息序列正确性 | 检查messages数组中assistant调用与tool结果是否配对 | 工具结果出现在用户消息前后,导致模型误解输入 |
| 重试次数统计 | 查看error_event次数与类型 | 同一错误重试超过2次,说明策略需要调整 |
| 成本归因 | 按session统计token之和与消费金额 | 单次会话成本异常升高,速查哪个工具/步骤占比最大 |
| 模型输出格式 | 回放该步骤模型原始返回 | JSON截断、额外注释、nlp异常等导致解析失败 |
5. 上线前后的稳定性与成本压测清单
5.1 准备好测试集:没有回归用例,就别谈上线
Agent项目的回归测试与传统程序很不一样,传统的断言驱动在这里并不完全适用。我给自己的项目准备了一套“场景清单+评判标准”的组合,每个场景包含一组输入和预期行为。比如“用户询问退货政策,Agent必须调用知识库工具,回答里要包含退货时间窗口”“用户问一个知识库外的冷门问题,Agent不得乱编,要明确说明不知道”。
每次改动提示词、调整错误处理逻辑或者切换模型之后,我都会拿这套场景清单跑一遍,观察通过率。因为模型是概率性的,单次通过不能说明问题,我会把每个场景重复跑3-5次,统计通过率。如果核心场景通过率低于90%,基本就意味着这次改动引入的风险太大,不适合上线。
5.2 成本门禁:给预算装一个“刹车”
上线前,我会给每个Agent场景设定一个成本预算值。比如客服Agent对话的预算上限是单次会话0.5元,超出的session会自动进入“保守模式”:不再调用高成本工具、不再尝试重试、直接给出简洁回复或转人工。这个机制看似简单,但它在成本失控和体验之间划出了一条清晰的边界。
监控这块,我用的是一个很轻量的方案:每次Agent运行结束后,把cost字段写入数据库,并定时跑一个统计任务,按小时和按天汇总平均成本、P95成本和超预算会话占比。一旦P95成本连续多小时超过预算,系统自动告警推送给我。这样做的好处是,成本不会成为一个“事后才知道”的问题,而是作为线上运行的实时指标被观测到。
5.3 小技巧:给Agent定义“经济模式”与“全速模式”
我做Agent开发最想分享的一个实用建议是:不要给所有用户、所有场景配同一个模型和同一套策略。可以让Agent根据任务价值自行决定用哪种预算策略。对比较重要的用户请求,比如付费用户、复杂跨多工具的咨询,走“全速模式”,用最强的模型,允许更多工具调用步数和更多token预算;对低价值或者高频同质化的请求,走“经济模式”,用便宜模型、限制工具数量、优先走缓存、降低重试次数。
一套好的策略不是“最好的模型”,而是“最合适的配置组合”。把模型选择、工具权限、步数上限、缓存策略、重试策略这五个维度配置成多档,再根据任务类型动态切换,成本和效果之间往往能找到一个特别舒服的平衡点。
以我个人的经验,Agent项目从“能跑”到“能稳定跑”之间,隔着的是大量细碎的问题排查和策略调优。没有银弹,也没有一个框架能帮你把所有问题都处理干净,真正可靠的方法就是把自己项目的日志做扎实、错误处理做完整、成本监控做精细。每次我以为Agent已经足够稳定的时候,它总能以一种意想不到的方式给我上一课。所以我现在无论多着急赶版本,都会先跑一遍回归场景,把日志和成本指标从头到尾仔细看一遍,再决定要不要放上线。这个过程很枯燥,但确实能让你在深夜少收到几条用户投诉。