去年年底我从纯 Java 后端转到 AI 应用这条线上,一开始以为最难的是一堆新概念:RAG、MCP、ReAct、Memory......文档看一圈,感觉都能讲两句。
真正上手做项目才发现,最难的不是"不懂概念",而是每个坑我在踩之前都以为自己已经解决了。
这篇不复述概念,只写五个真实踩过的坑,以及踩完之后我理解的正确做法。
坑一:max_steps = 10 让我以为死循环问题解决了
这是我最早的"自信"。给 ReAct 循环设个最大步数,超了就终止,日志里再也不会无限增长了,挺完美。
然后线上出现一个现象:某个深度研究任务,正常应该跑 30 步左右出结果,现在跑到第 10 步就被我杀掉了,用户拿到的是半成品。而另一个真正在打转的任务,因为每步"看起来都在做事",一直撑到第 10 步才被杀,白烧了 10 步的 Token。
我犯的错是把"动作重复"当成了循环的判据。
但这两件事不是一回事:
- 搜索 Agent 正在分页拉数据,每次工具名都一样、参数只差一个
page,这是有进展的;
- 接口第一次返回限流,系统按错误类型退避重试,调用参数完全相同 ,这是合理容错;
- 反过来,Agent 每次都换一个搜索词,参数全不一样,但一条新证据都没拿到 ,这是实打实的空转。
所以单看"做了什么"根本判不准,得同时看"状态变了什么"。
我后来改成四个维度一起看:
| 维度 | 看什么 | 判据 |
|---|---|---|
| 动作 | 生成动作指纹 :子任务 + Agent 名 + 工具名 + 标准化后的参数,取哈希 | 指纹在窗口内反复出现 → 预警 |
| 结果 | 文本比摘要语义相似度,结构化数据比关键字段 | 输出高度相似 → 加重嫌疑 |
| 状态 | 已完成步骤有没有增加、待办有没有缩小、有没有新证据 | 状态没推进才是硬证据 |
| 路径 | 把调用链画成有向图,看 A → B → A |
有环 ≠ 有问题,要回到 A 时看状态变没变 |
参数标准化这步千万别省。参数键顺序变一下、搜索词多一个空格,如果不做标准化,同一件事会被算成全新动作,指纹就废了。
max_steps 最后我保留了,但定位变了 ------它不是循环检测,它是保险丝。防止问题无限放大的兜底,不是诊断手段。而且简单查询和深度研究不能共用一组阈值,得按任务类型分开校准。
坑二:把 MCP 当成了 Function Calling 的替代品
这个坑是我在准备技术分享时被自己问住的。
我当时的理解:MCP 出来了,Function Calling 就过时了,以后工具调用都走 MCP。
然后我问自己:那 MCP 底层靠什么驱动工具的?我答不上来。
真相是 MCP 底层依然靠 Function Calling。
MCP Client 连上 Server 之后,会调 list_tools 把所有工具定义拉下来,转换成模型原生的 Function Calling 格式 再传给模型。模型输出的还是 tool_calls,Client 再把它路由到对应的 Server 执行。
从模型视角看,它完全感知不到 MCP 的存在------它以为自己只是在做普通的 Function Calling。MCP 所有的"魔法"都发生在宿主程序那一层:工具发现、schema 转换、请求路由、结果回传。
所以这两个东西根本不在一个层次:
- Function Calling 管"一次调用长什么样"------像 HTTP 协议,解决传输格式;
- MCP 管"一堆工具怎么被组织、发现、跨项目复用"------像 REST 规范加服务注册发现。
有个推论挺关键:如果模型本身不支持 Function Calling,MCP 就完全用不了,因为这层"翻译"失效了。这也解释了为什么有些推理模型跑不了 MCP。
那 MCP 到底解决了什么?我算过一笔账:团队里 5 个应用,每个接 8 个工具,就是 40 份工具对接代码。GitHub API 改个字段,你要在 5 个地方同步改,漏一个就深夜报警;想从 Claude 迁到 GPT-4,40 份里的调用格式全要重适配。
MCP 的思路是把工具做成独立运行的标准化服务,谁要用就来连。工具只实现一次,Claude Desktop、Cursor、你自己写的 Agent 都能用。
实际配一个确实简单,Claude Desktop 里加几行配置就能自动发现工具,一行对接代码都不用写:
但"配置简单"不等于"这层抽象没有代价" ------坑三就跟着来了。
坑三:工具失败一律 return "调用失败"
我一开始的代码长这样:工具调用抛异常,catch 住,统一返回一句"调用失败"给模型。
看起来挺健壮,实际上是我给 Agent 挖的最大一个坑。
因为模型拿到"调用失败"这四个字,它不知道这次失败值不值得重试 。它最容易的选择是:把上一个动作原样再做一遍。
于是超时、限流、参数错、权限拒绝------四种完全不同性质的失败,在模型眼里长成一个样,全都被当成"再试一次说不定就好了"。
正确做法是按失败性质分流:
| 失败类型 | 处理 |
|---|---|
| 超时 / 限流 | 有界重试 + 退避,这是临时性的 |
| 参数错误 / 权限拒绝 | 直接修正参数或问用户,重试一万次也没用 |
| 一直没有进展 | 禁用当前失败路径,换参数或换工具,必要时从检查点重新规划 |
| 预算耗尽 / 风险越界 | 停止自动执行,把已完成结果、卡点、可选方案交给人工 |
我改成把错误类型、是否可重试、建议动作都结构化返回之后,Agent 的无效重试少了一大截。
坑四:有副作用的工具,我一个都没加幂等键
这个坑最贵,因为它不只是多花 Token。
当时场景是 Agent 帮忙发通知邮件。由于模型对"这件事还没做完"的判断出错,同一封邮件被发了两遍。
问题出在两处叠加:
- 工具明明执行成功了,但我没有把"已完成"和"新证据"写回任务状态,下一轮模型仍然以为这事没做;
- 邮件发送、扣款、建群这类有副作用的工具,我没有加幂等键。
后果就是:重复调用会再一次改变外部系统。这比多烧几千 Token 严重得多------用户收到两封邮件只是尴尬,如果是扣款工具呢?
我现在的规矩:所有有副作用的工具必须带幂等键,且执行结果必须显式写回状态。
坑五:以为用了 LangChain4j 的注解,模型切换就是免费的
我一开始对 LangChain4j 的理解是"LangChain 的 Java 版",觉得它就是把 Python 那套翻译成 Java。后来发现这个定位本身就错了------它不是官方移植版,API、内部实现、发布节奏都独立于 Python LangChain。
它真正有价值的地方是顺着 Java 的习惯设计:ChatModel、EmbeddingModel、EmbeddingStore 做统一抽象,高层用 AI Services------声明一个 Java 接口,运行时给你代理实现,很像我熟悉的 Spring Data JPA 或 Retrofit。
写完接口就能调,Prompt 拼装、消息转换、输出反序列化都不用管了。
但我踩的坑是把"减少胶水代码"理解成了"消灭供应商差异"。
抽象只能覆盖交集 。某个模型支不支持工具调用、原生 JSON Schema、多模态输入、思考内容、特殊采样参数------这些都要单独查能力矩阵。切换供应商之后,Prompt 效果、Token 计算、限流策略、异常类型、评测基线,全都得重新验一遍。
还有一个容易忽略的点:ChatMemory 保留的是模型当前需要的上下文 ,它不等于完整聊天记录。复杂的长事务还得交给业务系统或者专门的编排层。
另外选型时要看模块成熟度------官方到现在仍有带 beta 后缀的模块,Guardrails 和可观测性部分标的是实验性,上生产前得自己权衡。
最后几条我真正沉淀下来的判断
- "看起来解决了"比"没解决"危险 。
max_steps、统一错误、注解一把梭,都给了我虚假的安全感,掩盖了真正的问题。
- 观测状态比观测动作重要。Agent 系统的核心不是"它调了什么工具",而是"任务往成功标准推进了没有"。
- 有副作用的工具是另一个风险量级。Token 是钱的问题,副作用是事故的问题,处理逻辑要分开设计。
- 框架减少的是重复代码,不减少工程责任。权限、数据隔离、评测、运维,框架不替你背。
这半年踩的坑基本都在这些方向上。后面打算把 Agent 的错误处理协议和评测集搭建再系统地整理一篇,如果对你有用,可以先关注一波,发了会第一时间推。