1. 为什么“模型不行”往往是排查方向跑偏了
做 Agent 开发的人,大概都经历过这种场景:线上告警突然炸了,任务成功率从 95% 掉到 60%,第一反应是“模型又抽风了”,于是换模型、调 temperature、加 few-shot,折腾半天发现成功率纹丝不动。最后翻日志才发现,真正的问题出在一个 HTTP 超时上,或者某个工具返回了空结果导致后续链路全崩。
我踩过太多次这种坑了。Agent 系统和传统的单次 LLM 调用最大的区别在于:它是一条多步骤、多组件、有状态的执行链。模型只是链条上的一环,前面有意图解析、工具选择,后面有工具执行、结果解析、状态更新、重试决策。任何一个环节出问题,最终表现都可能是“任务失败”,但失败的原因可能跟模型半毛钱关系都没有。
这篇文章想聊的核心就一件事:怎么从一堆粗粒度的错误码出发,一步步还原出完整的失败链,精准定位到真正出问题的那个环节。关键词里的terminalBlocker是我自己在项目里用的一个概念——指的是那些“一旦出现就会阻断整个 terminal 执行”的致命错误,它和普通的可重试错误需要完全不同的处理策略。
适合谁看?如果你正在做 Agent 开发、Agent 框架搭建,或者负责 Agent 项目的线上稳定性,这篇文章里的排查思路和实操方法应该能直接拿去用。如果你还在学习 Agent 开发阶段,也可以提前了解一下真实生产环境里会碰到什么问题,避免以后走弯路。
2. 先搞清楚 Agent 的失败到底分几层
2.1 从“一个错误码”到“一条失败链”的认知转变
大部分 Agent 框架在出错时,抛给上层的往往就是一个笼统的错误码,比如AGENT_EXECUTION_FAILED、TOOL_CALL_ERROR、MODEL_REQUEST_FAILED。这种粗粒度错误码的问题在于:它只告诉你“失败了”,但没告诉你“在哪一步失败”“为什么失败”“失败后影响了什么”。
我举个实际例子。之前有个任务,Agent 需要先查数据库拿到用户 ID,再调用外部 API 获取订单信息,最后生成摘要。线上报错是MODEL_REQUEST_FAILED,看起来是模型请求失败。但实际排查下来是这样的:
- 数据库查询返回了空结果(因为用户 ID 格式变了)
- Agent 拿到空结果后,仍然继续调用外部 API,传了个空参数
- 外部 API 返回 422,Agent 把 422 的响应体直接塞给了模型
- 模型因为输入格式异常,请求被服务商拒绝,返回了错误
最终暴露出来的错误码是模型请求失败,但根因在第一步的数据库查询。如果只看错误码,你永远找不到真正的问题。
所以第一步认知转变就是:不要盯着错误码看,要盯着失败链看。错误码是终点,失败链是路径。
2.2 失败链的三个层次:输入层、执行层、输出层
我把 Agent 的失败链拆成三个层次,这个分类方式在我自己的项目里用了很久,排查效率提升很明显。
输入层指的是任务进入 Agent 之前的环节。包括用户输入的解析、上下文组装、历史记忆的检索、系统提示词的拼接。这一层出问题,往往表现为“Agent 理解错了任务”或者“Agent 拿到了错误的上下文”。常见错误码有CONTEXT_BUILD_FAILED、MEMORY_RETRIEVAL_ERROR、PROMPT_TOO_LONG。
执行层是 Agent 的核心循环,包括模型推理、工具选择、工具调用、结果解析、状态更新。这一层是失败最集中的地方,也是错误码最模糊的地方。TOOL_CALL_ERROR可能意味着工具本身报错、参数不对、超时、返回格式不符合预期,甚至工具根本不存在。
输出层是任务完成后的环节,包括结果格式化、最终校验、写回存储。这一层出问题相对少见,但一旦出问题往往很隐蔽,比如结果格式对了但内容错了,或者写回时因为并发冲突失败。
排查心法:先定位失败发生在哪一层,再往下钻。不要一上来就查模型,先确认输入层和执行层的前置步骤是否正常。
2.3 terminalBlocker:那些必须立即中断的致命错误
在失败链里,有一类错误需要特别对待,我管它叫terminalBlocker。这类错误的特征是:一旦出现,继续执行下去只会产生更多错误,必须立即中断整个 terminal。
典型的 terminalBlocker 包括:
- 认证失败(API Key 过期、权限不足)
- 资源耗尽(内存溢出、磁盘满、连接池耗尽)
- 依赖服务不可用(数据库宕机、核心外部 API 持续 5xx)
- 状态不一致(Agent 内部状态与外部存储状态冲突)
和 terminalBlocker 相对的是可重试错误,比如网络抖动、临时限流、单次工具超时。这两类错误的处理策略完全不同:terminalBlocker 要快速失败并告警,可重试错误要退避重试。
我见过很多项目把这两类错误混在一起处理,结果就是:要么该中断的时候在傻等重试,要么该重试的时候直接失败了。区分 terminalBlocker 是失败链排查里非常关键的一步。
3. 错误码体系怎么设计才能支撑排查
3.1 粗错误码的问题:信息量太少,排查全靠猜
大部分框架默认的错误码体系是这样的:
class AgentError(Exception): pass class ToolCallError(AgentError): pass class ModelRequestError(AgentError): pass这种设计的问题在于,ToolCallError可能对应十几种不同的实际情况:工具不存在、参数校验失败、工具执行超时、工具返回了非预期格式、工具内部抛异常。你拿到这个错误码,除了知道“工具调用出问题了”,什么也做不了。
更麻烦的是,当错误在多层之间传递时,原始信息很容易丢失。比如工具内部抛了一个KeyError,被工具包装层捕获后转成了ToolCallError,再被 Agent 循环捕获后转成了AgentExecutionError,最后到告警系统里只剩一个AGENT_EXECUTION_FAILED。原始的那个KeyError早就不知道去哪了。
3.2 分层错误码设计:让每个错误都能定位到具体环节
我的做法是设计一套分层错误码,格式是LAYER_COMPONENT_REASON。举几个例子:
| 错误码 | 含义 | 是否 terminalBlocker |
|---|---|---|
INPUT_CONTEXT_TOO_LONG | 上下文超长 | 是 |
INPUT_MEMORY_EMPTY | 记忆检索为空 | 否 |
EXEC_MODEL_TIMEOUT | 模型请求超时 | 否 |
EXEC_MODEL_RATE_LIMIT | 模型限流 | 否 |
EXEC_TOOL_NOT_FOUND | 工具不存在 | 是 |
EXEC_TOOL_PARAM_INVALID | 工具参数校验失败 | 否 |
EXEC_TOOL_TIMEOUT | 工具执行超时 | 否 |
EXEC_TOOL_RESPONSE_MALFORMED | 工具返回格式异常 | 否 |
EXEC_STATE_CONFLICT | 状态冲突 | 是 |
OUTPUT_FORMAT_INVALID | 输出格式校验失败 | 否 |
OUTPUT_WRITE_CONFLICT | 写回冲突 | 否 |
这套错误码的好处是:看到错误码就知道去哪一层找问题,不用再猜。而且terminalBlocker标记明确,处理策略可以直接根据这个标记来决定。
3.3 错误上下文:光有错误码不够,还得带上现场信息
错误码只是索引,真正排查靠的是错误上下文。我在每个错误抛出时都会附带一个结构化的 context,包含:
trace_id:全链路追踪 IDstep_index:当前执行到第几步component:出错的组件名input_snapshot:出错时的输入快照(脱敏后)state_snapshot:出错时的 Agent 状态快照retry_count:当前重试次数elapsed_ms:从任务开始到出错的耗时
这些信息在排查时非常关键。比如step_index能告诉你失败发生在第几步,input_snapshot能让你复现问题,state_snapshot能让你看到 Agent 当时的内部状态。
实操心得:
input_snapshot一定要脱敏,但不要截断得太狠。我一般保留前 2000 字符和后 500 字符,中间用省略号。截断太狠会导致复现时信息不足。
4. 从错误码还原失败链的完整实操
4.1 第一步:建立 trace 级别的日志聚合
排查失败链的前提是,你能看到一条完整的 trace。如果日志是散的,每个组件各打各的,那排查起来就是大海捞针。
我的做法是在任务入口生成一个trace_id,然后通过 context 传递到所有组件。每个组件打日志时都带上trace_id和step_index。这样在日志系统里按trace_id一搜,整条链就出来了。
import uuid import logging from contextvars import ContextVar trace_id_var = ContextVar("trace_id", default=None) step_index_var = ContextVar("step_index", default=0) def init_trace(): trace_id = str(uuid.uuid4()) trace_id_var.set(trace_id) return trace_id def log_with_context(level, component, message, **extra): logger.log( level, f"[{trace_id_var.get()}][step={step_index_var.get()}][{component}] {message}", extra=extra )关键是step_index要随着 Agent 循环递增。这样你就能看到失败发生在第几步,以及前面几步分别做了什么。
4.2 第二步:用错误码定位到失败层次
拿到 trace 之后,先看错误码,定位到失败层次。比如错误码是EXEC_TOOL_TIMEOUT,那问题在执行层的工具调用环节。然后看step_index,确认是第几步出的问题。
这时候不要急着看代码,先看日志。看这一步的输入是什么、工具是什么、超时设置是多少、实际耗时是多少。很多时候看到输入就能猜到问题。
我遇到过一个案例:错误码是EXEC_TOOL_TIMEOUT,工具是search_api,超时设置是 5 秒。看输入发现查询词是一个超长的字符串(用户粘贴了一整篇文章)。搜索 API 处理这种超长查询时特别慢,5 秒根本不够。问题不在工具,也不在超时设置,而在输入没有做长度限制。
4.3 第三步:回溯前置步骤,找到真正的根因
定位到失败层次之后,还要往前回溯。因为失败层次往往不是根因所在。上面那个例子,根因在输入层没有做长度校验。
回溯的方法是:从失败步骤开始,往前逐步检查每一步的输出是否符合预期。具体来说:
- 检查失败步骤的输入是否合法
- 如果不合法,检查上一步的输出为什么会产生这种输入
- 重复这个过程,直到找到第一个产生异常输出的步骤
这个回溯过程可以用一个简单的脚本辅助:
def trace_back(trace_id, failed_step): steps = load_steps(trace_id) for i in range(failed_step, -1, -1): step = steps[i] print(f"Step {i}: {step.component}") print(f" Input: {step.input_snapshot[:200]}") print(f" Output: {step.output_snapshot[:200]}") print(f" Status: {step.status}") if not validate_output(step): print(f" >>> 异常输出,根因可能在此") break4.4 第四步:区分 terminalBlocker 和可重试错误
回溯到根因之后,要判断这个根因属于哪类错误。如果是 terminalBlocker,那处理策略是立即中断并告警;如果是可重试错误,那要看重试策略是否合理。
这里有个容易踩的坑:有些错误看起来是可重试的,但实际上是 terminalBlocker。比如EXEC_TOOL_PARAM_INVALID,如果参数是 Agent 自己生成的,那重试可能有用(下次生成不同的参数);但如果参数是从上游传递下来的,那重试多少次都一样,应该立即中断。
判断标准是:这个错误是否可能通过重试改变结果。如果错误的输入是固定的,重试不会改变输入,那就不是可重试错误。
4.5 第五步:修复并验证失败链是否闭环
找到根因并修复之后,要验证失败链是否闭环。具体来说:
- 用相同的输入重新跑一遍,确认不再失败
- 构造类似的边界输入,确认修复是通用的而不是特例
- 检查修复是否引入了新的失败点
我一般会写一个回归测试集,把每次排查出来的失败案例都加进去。这样下次改动时能快速发现是否引入了回归。
5. 几个真实案例的失败链还原过程
5.1 案例一:模型请求失败,根因在数据库查询
现象:任务成功率下降,错误码集中在EXEC_MODEL_REQUEST_FAILED。
排查过程:
先看 trace,发现失败都发生在第 3 步(模型推理)。看输入快照,发现模型输入里包含了一个空的结果集。往前回溯,第 2 步是数据库查询,输出是空数组。再往前,第 1 步是参数解析,输出的用户 ID 格式是user_123,但数据库里存的是123。
根因:参数解析环节没有做 ID 格式转换,导致数据库查询返回空,空结果被传给模型,模型因为输入异常而请求失败。
修复:在参数解析环节加上 ID 格式标准化。同时在数据库查询后加一个空结果检查,如果为空则直接中断并返回明确错误,不再继续往下传。
经验:模型请求失败往往不是模型的问题,而是输入的问题。排查时先看模型输入是否合法,再看输入是怎么产生的。
5.2 案例二:工具调用超时,根因在连接池耗尽
现象:多个任务同时报EXEC_TOOL_TIMEOUT,但工具本身响应正常。
排查过程:
看 trace,发现超时的工具都是同一个外部 API。单独测试这个 API,响应时间正常。看并发量,发现失败集中在高峰期。检查连接池配置,发现最大连接数是 10,但高峰期并发请求有 50 多个。
根因:连接池太小,高峰期请求排队,排队时间超过了工具超时设置。
修复:调大连接池,同时给工具超时设置加上动态调整逻辑(高峰期自动放宽)。
经验:工具超时不一定是工具慢,也可能是排队。排查时要看并发量和资源池配置。
5.3 案例三:状态冲突导致的 terminalBlocker
现象:任务执行到一半突然中断,错误码EXEC_STATE_CONFLICT。
排查过程:
看 trace,发现中断发生在状态更新环节。看状态快照,发现 Agent 内部状态显示任务已完成,但外部存储显示任务还在进行中。往前回溯,发现上一步的工具调用实际上已经成功,但返回时因为网络问题丢失了响应,Agent 认为工具调用失败并重试了,重试时工具又执行了一次,导致状态被更新了两次。
根因:工具调用没有做幂等处理,重试导致状态被重复更新。
修复:给工具调用加上幂等键,重试时使用相同的幂等键,工具端根据幂等键去重。
经验:状态冲突往往是重试导致的。排查时要特别关注重试逻辑,确认重试是否幂等。
6. 排查工具链和自动化建议
6.1 日志聚合与 trace 查询
日志聚合是排查的基础。我一般用 ELK 或者 Loki 做日志聚合,按trace_id查询。关键是日志格式要统一,所有组件都用相同的结构化日志格式。
{ "timestamp": "2024-01-15T10:30:00Z", "trace_id": "abc-123", "step_index": 3, "component": "tool_executor", "level": "ERROR", "error_code": "EXEC_TOOL_TIMEOUT", "message": "Tool search_api timed out after 5000ms", "context": { "tool_name": "search_api", "timeout_ms": 5000, "elapsed_ms": 5123, "input_snapshot": "..." } }6.2 失败链可视化
光看日志还不够直观,我做了个简单的失败链可视化工具,把一条 trace 的所有步骤按顺序展示出来,每个步骤显示组件、状态、耗时、输入输出摘要。失败的步骤标红,异常输出的步骤标黄。这样一眼就能看出问题在哪。
这个工具不复杂,核心就是一个前端页面加一个后端接口。后端按trace_id查日志,解析成步骤列表返回;前端渲染成时间线。
6.3 自动化根因分析
对于常见的失败模式,可以做一些自动化根因分析。比如:
- 如果错误码是
EXEC_MODEL_REQUEST_FAILED且输入快照为空,自动提示“检查上游是否返回空结果” - 如果错误码是
EXEC_TOOL_TIMEOUT且并发量超过阈值,自动提示“检查连接池配置” - 如果错误码是
EXEC_STATE_CONFLICT且重试次数大于 0,自动提示“检查重试幂等性”
这些规则不复杂,但能大幅缩短排查时间。
7. 一些踩坑经验和注意事项
7.1 不要过早下结论
我见过太多人(包括我自己早期)看到错误码就下结论。MODEL_REQUEST_FAILED就说是模型问题,TOOL_CALL_ERROR就说是工具问题。实际上错误码只是入口,真正的根因往往在别的地方。
7.2 保留足够的现场信息
排查失败链最怕的就是信息不足。日志打太少,复现不了;日志打太多,又影响性能。我的经验是:关键步骤的输入输出一定要打,中间过程的细节可以按需打。关键步骤指的是模型调用、工具调用、状态更新这三个环节。
7.3 区分“错误”和“失败”
错误是组件层面的异常,失败是任务层面的结果。一个任务可能经历了多个错误但最终成功了(比如重试后成功),也可能没有任何错误但最终失败了(比如逻辑错误)。排查时要区分这两个概念,不要混为一谈。
7.4 定期回顾失败案例
我每个月会花半天时间回顾这个月的失败案例,看看有没有新的失败模式,有没有可以自动化的排查步骤。这个习惯帮我积累了不少排查经验,也让我对系统的薄弱环节越来越清楚。
7.5 常见问题速查表
| 现象 | 可能根因 | 排查方向 |
|---|---|---|
| 模型请求失败 | 输入格式异常 | 检查模型输入快照 |
| 工具调用超时 | 连接池耗尽/工具慢 | 检查并发量和连接池配置 |
| 状态冲突 | 重试不幂等 | 检查重试逻辑和幂等键 |
| 任务成功率突降 | 上游数据变化 | 检查上游输入是否变化 |
| 特定任务总是失败 | 边界输入未处理 | 检查该任务的输入特征 |
| 高峰期失败率上升 | 资源不足 | 检查资源池和限流配置 |
8. 从失败链到系统改进的闭环
排查失败链的最终目的不是修一个 bug,而是让系统变得更健壮。我一般会从三个层面做改进。
第一层是防御。在容易出问题的环节加上校验和兜底。比如输入层加长度校验和格式校验,执行层加超时和重试上限,输出层加格式校验。
第二层是观测。让失败链更容易被看到。完善错误码体系,补充错误上下文,做好 trace 聚合。这一层的投入产出比很高,因为排查效率的提升是持续的。
第三层是自愈。对于已知的失败模式,尝试自动恢复。比如连接池耗尽时自动扩容,限流时自动退避,状态冲突时自动对账。这一层难度最大,但收益也最大。
我自己的项目从第一层做到第三层花了大概半年时间,任务成功率从 85% 提升到了 98% 以上。最关键的不是某一项技术,而是建立了“排查-修复-防御-观测-自愈”的闭环。
最后分享一个我自己的习惯:每次排查完一个失败链,我都会写一个简短的复盘,记录现象、排查过程、根因、修复方案。这些复盘积累下来,就成了团队内部的排查手册。新同学遇到类似问题时,翻手册就能找到方向,不用从头摸索。这个习惯看起来笨,但长期来看非常值。