1. 半年迭代三次之后,我才看清这五个“假性解决”的坑
去年下半年到今年年初,我带着一个小团队连续做了三个 AI Agent 应用,从最开始的对话式助手,到后面接入工具调用的任务型 Agent,再到带记忆和规划能力的多步执行系统。中间经历过一次大重构,两次推倒重来,踩的坑多到可以写一本小册子。但真正让我难受的,不是那些一眼就能看出来的 bug,而是那些“看起来已经解决了”的问题——测试通过、演示流畅、上线当天也没炸,结果跑了一两周之后,各种诡异现象开始冒出来。
这篇文章不讲概念科普,也不讲“AI Agent 是什么”这种入门内容。我想聊的是五个具体的坑,它们都有一个共同特征:你在开发阶段会觉得自己已经处理好了,但到了真实场景里,问题会以完全不同的面貌重新出现。这五个坑分别涉及 ReAct 循环的终止判断、Function Calling 的参数校验、MCP 工具接入的边界、LangChain4j 的记忆管理,以及并发场景下的状态污染。每一个我都会讲清楚:当时怎么想的、怎么做的、为什么觉得解决了、后来怎么发现没解决、最后怎么真正解决的。
如果你正在用 LangChain4j、Spring AI 或者自己手写 ReAct Agent,或者正在接入 MCP 协议做工具编排,这篇文章里的经验应该能帮你省掉至少两到三周的排查时间。如果你刚开始搭建 AI Agent,也可以把这些坑当成设计阶段的检查清单,提前避开。
2. ReAct 循环的终止条件:为什么“最大轮次”根本不够用
2.1 最初的设计:设一个 maxIterations 就以为万事大吉
刚开始写 ReAct Agent 的时候,我的逻辑很直接:让模型在“思考—行动—观察”的循环里跑,设一个最大轮次,比如 10 轮,超过就强制停止并返回当前结果。这个方案在 demo 阶段表现很好,因为 demo 里的任务通常两三步就结束了,根本碰不到上限。
但上线之后,我遇到了几种情况。第一种是模型在某个步骤上反复调用同一个工具,每次返回的结果略有不同,它就继续调,直到把 10 轮耗尽。第二种是模型在“思考”阶段陷入了自我怀疑,不断生成“让我再确认一下”之类的文本,但迟迟不输出最终答案。第三种更隐蔽:模型确实在 10 轮内给出了答案,但这个答案是它在第 3 轮就应该给出的,后面 7 轮全是在做无效的重复验证。
这三种情况的共同点是:maxIterations 这个硬性上限只能保证循环一定会停,但完全不能保证循环停得合理。它就像一个只管关门不管质量的保安,时间到了就赶人,至于里面的人有没有办完事,它不关心。
2.2 真正的问题:缺少“进展检测”机制
后来我意识到,ReAct 循环的核心问题不是“什么时候停”,而是“怎么判断当前这轮有没有产生新信息”。如果连续两轮的行动和观察结果高度相似,那就说明 Agent 在原地打转,继续跑下去只是浪费 token 和时间。
我最终的方案是引入一个简单的进展评分机制。具体做法是:每一轮结束后,把当前轮的“行动+观察”与上一轮做对比,计算一个相似度分数。如果相似度超过阈值(我用的 0.85),就标记为“无进展轮次”。连续两轮无进展,就强制终止,并把当前已收集到的信息整理成最终回答。
这个相似度计算不需要很复杂,我用的是简单的文本重叠率加上关键实体匹配。比如上一轮调用了“查询订单”工具,返回了订单号 A123,这一轮又调用了同一个工具,返回的订单号还是 A123,那基本可以判定为重复。如果返回的是 A124,那说明有进展,继续跑。
注意:这个阈值不要设得太低,否则正常的重试也会被误判为无进展。我试过 0.7,结果模型在正常的多步推理中被频繁打断。0.85 到 0.9 之间是比较稳妥的范围。
2.3 另一个隐蔽问题:模型自己“假装”给出了最终答案
还有一种情况让我头疼了很久。模型在第 5 轮的时候输出了一个看起来像最终答案的文本,格式也对,内容也像那么回事。但如果你仔细看,会发现它其实还没有拿到关键数据,只是根据已有信息“编”了一个合理的回答。这种情况在 ReAct 框架里特别容易发生,因为模型被训练成倾向于给出完整回答,而不是说“我还没查完”。
我的应对方式是在系统提示词里明确要求:只有在所有必要信息都已通过工具调用获取之后,才能输出最终答案。同时,在解析模型输出时,增加一个校验步骤:如果模型输出了最终答案,但当前轮次还没有任何工具调用记录,或者关键字段缺失,就拒绝这个答案,强制模型继续执行。
这个校验逻辑听起来简单,但实际写起来需要你对业务字段有清晰的定义。比如一个订单查询 Agent,关键字段就是订单号、状态、金额。如果模型给出的答案里缺少这三个中的任何一个,就不算完成。
2.4 实测下来的效果和遗留问题
引入进展检测和答案校验之后,无效循环的比例从原来的 30% 左右降到了 5% 以下。Token 消耗也明显下降,因为大部分无效轮次被提前终止了。
但也不是没有代价。进展检测本身需要额外的计算,虽然不重,但在高并发场景下会累积成可观的延迟。另外,有些任务确实需要多轮重复调用同一个工具,比如轮询某个状态直到它变化。这种情况下,进展检测会误判。我的解决办法是允许在工具定义里标记“可轮询”属性,对于这类工具,进展检测的阈值放宽或者暂时关闭。
3. Function Calling 的参数校验:模型给的参数永远比你想的更“灵活”
3.1 你以为定义了 schema 就安全了
用 Function Calling 的时候,大多数人都会定义 JSON Schema,声明每个参数的类型、必填项、枚举值。然后你就觉得模型会老老实实按 schema 来。我一开始也是这么想的,直到我发现模型会给出这些玩意儿:
- 日期格式写成 “2024年1月1日” 而不是 “2024-01-01”
- 枚举值明明定义了三个选项,它给你返回第四个
- 必填参数倒是给了,但值是空字符串
- 数字类型的参数,它给你返回字符串 “123”
这些问题在开发阶段很难发现,因为你的测试用例通常是你自己写的,模型在那些用例上表现正常。但真实用户的输入千奇百怪,模型在解析用户意图时会产生各种偏差。
3.2 三层校验:schema 校验只是第一层
我后来把参数校验分成了三层。第一层是 JSON Schema 校验,用标准的校验库(比如 Java 里的 networknt/json-schema-validator)做类型和格式检查。这一层能拦住大部分明显的格式错误。
第二层是业务规则校验。比如日期不能是过去的时间、金额不能为负数、枚举值必须在业务允许的范围内。这一层需要你自己写代码,因为 schema 表达不了这些约束。
第三层是语义校验。这一层最容易被忽略,但也最重要。比如模型调用“发送邮件”工具,参数里收件人、主题、正文都有,格式也对,但收件人地址是一个不存在的域名。这种问题 schema 和业务规则都拦不住,只能在实际执行前做一次轻量级的语义检查,比如域名是否存在、用户是否有权限等。
// 以 LangChain4j 为例,工具方法内部做二次校验 public String sendEmail(String to, String subject, String body) { if (!isValidEmailDomain(to)) { return "错误:收件人域名无效,请确认后重试"; } if (subject == null || subject.trim().isEmpty()) { return "错误:邮件主题不能为空"; } // 实际发送逻辑 return emailService.send(to, subject, body); }提示:工具方法返回错误信息时,不要抛异常,而是返回一个结构化的错误描述。这样模型能理解发生了什么,并在下一轮尝试修正。抛异常会导致整个 Agent 流程中断,模型没有机会自我修复。
3.3 参数修正:让模型自己改,比你自己改更靠谱
当校验失败时,有两种处理方式。一种是你在代码里自动修正,比如把 “2024年1月1日” 转成 “2024-01-01”。另一种是把错误信息返回给模型,让它重新生成参数。
我两种都用过,实测下来,对于格式类问题,自动修正效率更高;对于语义类问题,让模型自己改更靠谱。因为格式转换是确定性的,你写一次代码就能覆盖所有类似情况。但语义问题往往需要结合上下文,模型比你更清楚用户到底想要什么。
比如用户说“帮我订明天下午的会议室”,模型第一次调用时把日期算错了。你把错误信息返回给它,它看到“日期不能是过去时间”之后,通常能自己修正。但如果你在代码里硬编码一个日期修正逻辑,反而可能改错。
3.4 一个容易被忽略的细节:参数默认值
有些工具的参数是可选的,模型可能不传。这时候你的代码需要有合理的默认值。但默认值不能随便设,否则会导致意料之外的行为。比如一个“查询订单”工具,如果不传订单号,默认查最近一条,那模型可能会在用户想查特定订单时,因为没传订单号而返回了错误的订单。
我的做法是:对于关键参数,即使 schema 里标记为可选,在业务逻辑里也当作必填处理,如果模型没传,就返回错误让它重新生成。对于真正的可选参数,默认值要尽可能保守,比如分页查询的默认页大小设为 10 而不是 100,避免一次拉取过多数据。
4. MCP 工具接入:协议通了不代表工具就能用
4.1 MCP 解决的是“怎么连”,不是“连上之后怎么用”
MCP 协议最近很火,它确实解决了工具接入的标准化问题。以前每接一个工具都要写一套适配代码,现在只要工具实现了 MCP Server,理论上就能直接接入。但我在实际接入过程中发现,协议层面的连通只是第一步,真正麻烦的是工具描述的质量和调用边界的界定。
我接的第一个 MCP 工具是一个浏览器自动化工具。协议握手成功,工具列表也能正常获取,但模型在调用时频繁出错。原因是工具的描述写得太笼统,比如“在页面上执行操作”这种描述,模型根本不知道什么时候该用、什么时候不该用。后来我把描述改成了“当需要点击页面上的按钮或链接时使用,参数包括选择器和等待时间”,调用成功率立刻上去了。
4.2 工具粒度的取舍:太粗和太细都是坑
MCP 工具的设计有一个粒度问题。一个工具如果功能太粗,比如“处理用户请求”,那模型根本不知道怎么传参。如果功能太细,比如“在输入框输入第一个字符”“在输入框输入第二个字符”,那模型会陷入无穷无尽的工具调用。
我摸索出来的原则是:一个工具应该对应一个完整的、有业务意义的操作。比如“填写登录表单”是一个合适的粒度,它内部可以包含多个步骤,但对模型来说就是一个原子操作。而“点击登录按钮”就太细了,因为模型还需要知道怎么填表单。
注意:MCP 工具的命名也很关键。用动词开头,比如 “query_order”、“send_notification”、“create_ticket”,比用名词开头更容易让模型理解。
4.3 工具返回值的格式:结构化比自然语言好
MCP 工具的返回值格式直接影响模型的后续决策。我一开始让工具返回自然语言描述,比如“查询成功,订单状态是已发货”。后来发现,模型在解析这种返回值时经常抓错重点。改成结构化 JSON 之后,模型的理解准确率明显提升。
{ "status": "success", "data": { "order_id": "A123", "order_status": "shipped", "estimated_delivery": "2024-01-05" } }但结构化也有代价,就是 token 消耗会增加。我的折中方案是:对于简单查询,返回精简的结构化数据;对于复杂结果,返回结构化数据加上一句自然语言摘要。这样模型既能准确解析,又能快速理解。
4.4 MCP 工具的并发调用:别让一个慢工具拖垮整个 Agent
MCP 工具调用通常是同步的,如果某个工具响应很慢,整个 Agent 流程就会被阻塞。我在接入一个外部 API 工具时遇到了这个问题,那个 API 平均响应时间 3 秒,高峰期能到 10 秒。模型在等待期间什么也做不了。
后来我做了两件事。一是给每个工具调用设置超时,超过 5 秒就返回超时错误,让模型决定是重试还是换方案。二是对于可以并行的工具调用,改成异步执行。比如模型需要同时查询订单状态和物流信息,这两个调用没有依赖关系,可以并行发起。
但并行调用也有风险,就是模型可能不理解为什么两个结果一起返回了。我的做法是在返回结果里明确标注每个结果的来源,比如 “order_status_result” 和 “logistics_result”,这样模型能对应上。
5. LangChain4j 的记忆管理:短期记忆不是越多越好
5.1 默认的记忆策略在真实场景下会爆
LangChain4j 提供了多种记忆策略,我一开始用的是最简单的“保留最近 N 条消息”。设了 N=20,觉得应该够用。结果在一个多轮对话场景里,用户和 Agent 聊了 30 多轮之后,Agent 开始忘记前面说过的关键信息。原因是 20 条消息里包含了大量的工具调用记录和中间结果,真正有用的对话内容被挤掉了。
后来我改成了按 token 数量限制,而不是按消息条数。因为工具调用的返回结果可能很长,一条消息就能占几百个 token。按 token 限制更合理,我设的是 4000 token 的短期记忆窗口。
5.2 工具调用记录要不要放进记忆
这是一个很容易忽略的问题。工具调用的请求和返回结果,要不要作为记忆的一部分?我试过两种方案。
方案一:全部保留。好处是模型能看到完整的执行历史,方便它做后续决策。坏处是 token 消耗大,而且很多工具返回结果对后续对话没有价值。
方案二:只保留工具调用的摘要。比如“查询了订单 A123,状态是已发货”,而不是完整的 JSON 返回。好处是节省 token,坏处是模型可能丢失一些细节。
我最终采用的是混合方案:对于最近两轮的工具调用,保留完整结果;对于更早的调用,只保留摘要。这样既保证了近期决策的准确性,又控制了 token 消耗。
5.3 长期记忆的写入时机:别等对话结束才存
长期记忆通常是在对话结束后写入向量数据库,下次对话时检索。但我在实践中发现,等对话结束再存有两个问题。一是如果对话很长,结束时的信息可能已经过时了。二是如果对话意外中断,这次对话的记忆就丢了。
我改成了在对话过程中实时写入关键信息。具体做法是:每当模型输出一个包含关键事实的句子(比如用户说“我的订单号是 A123”),就立刻把这条信息写入长期记忆。这样即使对话中断,关键信息也不会丢。
但实时写入也有代价,就是写入频率高,向量数据库的压力大。我的优化是加一个简单的去重逻辑,如果新信息和已有记忆的相似度超过 0.95,就跳过写入。
5.4 记忆检索的精度问题:相似不等于相关
长期记忆检索通常用向量相似度,但相似度高的记忆不一定对当前对话有用。我遇到过好几次,检索出来的记忆和当前话题在语义上很接近,但实际上是不同用户、不同订单的信息。
解决这个问题的关键是给记忆加上元数据过滤。比如每条记忆都带上用户 ID、会话 ID、时间戳。检索时先按元数据过滤,再做向量相似度排序。这样能大幅减少不相关的记忆被召回。
// 伪代码示例:带元数据过滤的记忆检索 List<Memory> memories = memoryStore.search( queryEmbedding, filter("user_id", userId), filter("timestamp", ">", thirtyDaysAgo), limit(5) );6. 并发场景下的状态污染:单用户测试永远发现不了的问题
6.1 一个让我排查了两天的诡异 bug
上线两周后,有用户反馈说 Agent 有时候会“答非所问”,比如问的是订单 A 的状态,返回的却是订单 B 的信息。我一开始以为是模型幻觉,后来发现不是。同一个会话里,连续两次查询不同订单,第二次的结果里混入了第一次的订单号。
排查了两天才找到原因:我在 Agent 的上下文对象里缓存了工具调用的结果,但没有做会话隔离。当两个请求并发执行时,它们共享了同一个上下文对象,导致数据串了。
这个问题在单用户测试时完全发现不了,因为单用户不会并发。只有多个用户同时使用时才会触发。
6.2 状态隔离的三个层次
后来我把状态隔离分成了三个层次。第一层是会话级隔离,每个会话有独立的上下文对象,用会话 ID 做 key。第二层是请求级隔离,同一个会话内的不同请求,如果并发执行,也要有独立的临时状态。第三层是工具级隔离,工具内部的缓存和状态也要做隔离,不能跨请求共享。
第一层和第二层相对好做,用 ThreadLocal 或者 Reactor 的 Context 就能解决。第三层比较麻烦,因为有些工具本身是有状态的,比如一个浏览器自动化工具,它维护了一个浏览器实例。如果多个请求同时操作同一个浏览器实例,就会互相干扰。
我的解决方案是给每个会话分配独立的工具实例,会话结束后再回收。这样虽然资源消耗大一些,但避免了状态污染。
6.3 并发下的模型调用:限流和降级
除了状态污染,并发还会带来另一个问题:模型 API 的限流。当并发请求数超过模型服务的配额时,请求会被拒绝或者排队。我一开始没有做限流,结果高峰期大量请求失败。
后来我加了一个简单的令牌桶限流器,每个用户每秒最多发起 2 个 Agent 请求。超过的请求进入队列等待,或者直接返回“当前请求较多,请稍后重试”。同时,对于非核心功能(比如记忆检索),在高峰期可以降级为跳过,优先保证主流程可用。
提示:限流阈值不要设得太死,要根据实际模型服务的配额来调整。我一开始设得太低,导致正常用户也被限流了。后来改成动态调整,根据模型服务的响应时间自动升降阈值。
6.4 压测怎么做才有效
并发问题不能靠猜,必须压测。我用的工具是 JMeter 加上自定义的 Java 采样器,模拟多个用户同时发起不同类型的 Agent 请求。压测时重点关注三个指标:响应时间、错误率、以及状态一致性。
状态一致性怎么测?我的做法是在压测请求里埋入唯一的标记,比如每个请求带一个唯一的订单号,然后检查返回结果里的订单号是否和请求一致。如果不一致,就说明有状态污染。
第一次压测就发现了问题,并发 50 的时候状态污染率大概 3%。修复之后,并发 200 的情况下状态污染率为 0。这个测试让我对系统的并发能力有了真实的认知,而不是靠感觉。
7. 这半年下来,我对“看起来解决了”这五个字的理解
写完这五个坑,我发现它们有一个共同模式:都是在开发阶段用简化场景验证通过,但在真实场景的复杂性面前暴露出来的。ReAct 循环在简单任务上跑得很好,但遇到重复调用就失控;Function Calling 在标准参数上没问题,但模型总会给你惊喜;MCP 工具接通了不代表能用好;记忆管理在短对话里看不出问题;并发状态污染在单用户测试中根本不会出现。
我现在做 AI Agent 开发,会刻意问自己一个问题:这个方案在什么情况下会失效?如果我想不出来,那说明我还没有真正理解它。另一个习惯是,任何在 demo 阶段“看起来没问题”的东西,我都会在真实数据上跑至少一周再下结论。因为 AI Agent 的问题往往不是逻辑错误,而是概率性的、边界性的,只有足够多的样本才能暴露出来。
如果你也在做类似的事情,我的建议是:不要相信第一次测试通过的结果,不要相信单用户场景的表现,不要相信模型在标准输入下的行为。多问几个“如果”,多跑几轮真实数据,很多坑其实是可以提前发现的。