先说结论:Spring AI + MCP 这套组合,是我最近半年用下来觉得最“稳”的 Java 侧 Agent 落地方式。它解决的核心问题不是“把模型接进来”,而是“怎么让模型安全、可控、可扩展地调用真实业务能力”。如果你正在 Java 技术栈里做 AI Agent、智能助手、流程自动化这类项目,这篇内容值得你从头到尾看一遍。
MCP(Model Context Protocol)本身是对外开放的协议标准,Spring AI 把它原生接进了 Spring Boot 生态,意味着你不需要自己解析 function calling、不需要自己维护工具注册表、更不需要为每个模型厂商单独写一套适配层。配合 Spring Boot 的自动配置和 Bean 体系,基本可以做到“加一个工具=加一个类”,这对想要构建标准化、可扩展 Agent 系统的团队来说,价值非常直接。
这篇我会从架构设计、环境配置、核心代码、踩坑记录四个层面展开,全程以真实项目“某内部智能助手”为例。项目不大,但麻雀虽小五脏俱全:模型接入、工具调用、上下文管理、对话记忆、问题排查都覆盖到了。
1. 整体设计与思路拆解
1.1 Agent 落地难,难的根本不是“大模型”
很多团队做 Agent 的第一个误区是:把大模型当成核心工作量。真做下来你会发现,模型能力再强,没有一套规矩的工具调用机制,Agent 就是个“能聊天但啥也干不了”的花瓶。
实际项目里,Agent 要让模型去做事,比如查库存、发审批、查工单、算价格,必须打通三层:
- 模型层:决定“什么时候该调用哪个工具”
- 工具层:把业务系统的能力暴露给模型,可插拔、可复用
- 协调层:管理上下文、控制对话历史、记录工具调用结果
如果这三层全用自家代码硬撸,第一个项目能跑,第二个项目就得返工。工具一多,模型经常“不知道该调哪个”;换了模型供应商,原来的 function calling 格式全部作废;再想支持第三方工具,又得写一套新连接器。这套路我太熟了,痛过才知道要提前用标准协议。
1.2 MCP 像什么?像 AI 世界的万能插头
MCP 这个协议,核心思路一句话:把工具、数据资源、提示词都定义成标准化的“能力端点”,模型客户端通过统一协议发现和调用它们。
用生活类比最直观:你的手机需要一个充电头,但市面上的充电协议五花八门。MCP 就是那个“USB-C 接口”——从此以后,企业系统不用关心接的是哪家模型,模型也不用关心你内部是什么技术栈。只要双方实现了 MCP 协议,就能直接协作。
协议里最常用的三种能力:
| 能力类型 | 作用 | 典型场景 |
|---|---|---|
| Tools | 可执行的工具,模型主动调用 | 查天气、查库存、发送审批 |
| Resources | 可读取的数据资源 | 读取内部知识库、读取配置信息 |
| Prompts | 可复用的提示词模板 | 团队按统一规范复用提示词 |
这套抽象对 Agent 系统的最大意义是:工具层和模型层彻底解耦。协议在中间,业务系统只管实现服务端,模型接入只管做客户端,两边互不绑架。
1.3 Spring AI 是 Java 生态里最舒服的“接线层”
Java 做 AI 应用,很早以前是很别扭的。要么调 SDK 裸写,要么包一层 HTTP 请求自己封装,日志、重试、注入全都要手动处理。Spring AI 出现后,最大的贡献是把“模型交互”纳入了 Spring 的编程模型。
用过 Spring Boot 的人上手 Spring AI 几乎没有成本:
- Starter 依赖一键引入
- 自动配置帮你把 ChatModel、ChatClient 这些东西全部准备好
- 配置走 application.yml,换模型厂商基本只改配置不改代码
@Tool注解把业务方法和模型工具直接绑定
再加上 Spring Boot 对 MCP 的原生支持,这一套组合下来,构建 Agent 的体验甚至比很多脚本语言框架还要顺畅。
2. 架构与标准化设计
2.1 分层架构:接入层、协调层、工具层
我在“某内部智能助手”项目里用到的分层结构,经过几轮优化后基本定型成这个形式,你可以直接参考:
- 接入层:负责和模型厂商通信,统一封装 ChatModel 和 ChatClient。业务代码不直接碰模型 SDK。
- 协调层:Agent 核心逻辑,负责工具发现、调用决策、上下文管理、对话记忆。这是整个系统的“大脑”。
- 工具层:所有业务能力都以 MCP 工具形式暴露。工具内部再调用底层业务服务或第三方接口。
这个分层最大的好处是职责单一。协调层不知道“查库存”具体怎么实现,它只知道有一个工具叫query_stock,参数是商品 ID,返回是库存数据。工具层也不知道模型是什么品牌,它只负责把自己的能力注册成标准协议端点。
2.2 为什么不用裸 function calling?
可能有人问:我用 OpenAI 的 function calling 或者各家 SDK 的原生工具调用,不是挺好吗?为什么非要套 MCP?
直说我的观点:小 Demo 用原生 function calling 没问题,但生产级 Agent 系统有几个绕不开的坎:
第一,供应商绑定。原生 function calling 的参数结构、返回格式、错误处理都是跟着厂商走的。今天用甲厂商,明天换乙厂商,代码审计一遍。MCP 是中立协议,两边都适配,换料不换锅。
第二,工具治理是硬需求。工具数量超过十个,就要面临工具清单从哪里来、权限怎么控、调用日志怎么记、哪个工具耗时过高怎么监控。MCP 把工具生命周期管理收敛到统一层面,这套治理逻辑才有地方放。
第三,生态互通。MCP 出来以后,行业里已经有不少现成的 Server 实现,数据库、文件系统、开发工具、常用办公套件都能找到协议实现。这意味着你花一次学习成本,后续可以接入大量现成能力。
当然,MCP 也不是银弹。它的学习曲线不算陡,但如果你只是临时写一个脚本调一下模型,确实没必要上 MCP。什么时候该用?当你确定要把它做成一个长期维护、不断加功能的系统时,就用。
2.3 可扩展性到底从哪里来
“可扩展”这个词很容易被说成口号。在我看来,可扩展性要落地到三个具体的地方:
- 协议扩展:新能力通过 MCP Server 暴露,Agent 端零代码接入。你给我一个 MCP Server 地址,我就能把新工具接到现有系统里。
- 配置扩展:模型切换、工具连接、系统提示词都通过配置驱动,不写死在代码里。环境隔离、灰度发布、多租户差异化配置都能轻松支持。
- 代码扩展:用 Spring 的 Bean 机制管理工具注册,新增工具就是新增一个带有
@Tool方法的@Component。依赖注入自动把业务服务送进来,工具类内部代码依然遵循 Spring 最佳实践。
这个设计带来的感觉是:Agent 系统的增长方式从“改代码”变成了“挂载能力”。两者完全不是一回事。
3. 环境准备与核心配置
3.1 依赖引入,能用 Starter 就别手撸
项目基于 Spring Boot 3.x,构建工具用的 Maven。Spring AI 相关的核心依赖就三个:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> <version>1.0.0</version> </dependency>依赖版本这个事多说一句:Spring AI 迭代速度很快,1.0 之前的 API 变动相当频繁。建议以你实际引入时的最新稳定版本为准,尽量不要用 SNAPSHOT 版本跑生产。踩过这种坑的人应该懂我为什么特别强调这一句。
Starter 依赖的好处是把底层 SDK、自动配置、默认参数全都打包进来了。你不需要自己构建 MCP Client 实例,也不需要手动配置模型提供商连接,写个配置就好。
3.2 模型接入配置
我这里用的是 OpenAI 兼容接口,便于切换。如果你的模型厂商支持 OpenAI 兼容协议,配置几乎一模一样:
spring: ai: openai: base-url: https://api.example.com/v1 api-key: ${LLM_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.2temperature: 0.2是我在 Agent 场景里比较偏好的值。Agent 需要执行任务,输出要尽量稳定、可控、贴近指令,不需要太多创造性。如果你做的是文案生成、创意类对话,这个值可以再调高。曾经把一个工具调用类的 Agent 温度调到了 0.8,结果模型经常“自由发挥”,把参数名都改得乱七八糟,查了好久才发现是温度惹的祸。
3.3 MCP Server 端配置:把业务能力变成协议端点
我强烈建议你一开始就把工具定义为 MCP Server 模式,而不是仅仅依赖内存里的 ToolCallback。原因很简单:MCP Server 可以被外部复用。同一个工具,既能服务当前 Agent,未来也能被其他系统调用。
先看配置:
spring: ai: mcp: server: enabled: true name: internal-assistant-server version: 1.0.0再看一个具体的工具类。项目里第一个接入的是“内部工单查询”能力:
@Component public class TicketTools { private final TicketService ticketService; public TicketTools(TicketService ticketService) { this.ticketService = ticketService; } @Tool(description = "根据工单ID查询内部工单详情,返回工单状态、处理人、最后更新时间") public String queryTicketDetail(String ticketId) { TicketDO detail = ticketService.queryById(ticketId); if (detail == null) { return "工单不存在: " + ticketId; } return "工单[" + ticketId + "]状态=" + detail.getStatus() + ",处理人=" + detail.getOwner() + ",最后更新时间=" + detail.getGmtModified(); } }这里的关键点在@Tool注解的description。很多人不重视这个字段,觉得就是写给人看的注释。实际不是,模型的工具选择决策完全依赖这个描述。描述写得模糊,模型就不知道该在什么时候用这个工具。见过团队把描述写成“查询工单”,模型就经常在用户询问“我的报销到哪一步了”时错误地调用它。把描述改成“根据工单ID查询内部工单详情,返回工单状态、处理人、最后更新时间”之后,误调用率显著下降。
3.4 MCP Client 端配置:Agent 如何发现工具
Agent 端要做的,就是配置 MCP Client 并连接 Server。我用的是 Stdio 模式,也就是本地进程通信,一个进程对应一个 Server:
spring: ai: mcp: client: enabled: true name: internal-assistant-client version: 1.0.0 connections: - type: stdio name: internal-assistant-server command: java args: - -jar - /opt/mcp-servers/internal-assistant-server.jar这种模式适合和自家服务部署在一起的工具。如果你的工具跑在独立服务上,MCP 还支持 HTTP 模式(streamable HTTP),配置一个 URL 就能远程调用。我当时先跑了 Stdio,等工具 Server 多了以后准备切到 HTTP 统一管理,但是单机演示和内部验证用 Stdio 更简单直接。
MCP Client 启动后会自动发现 Server 暴露的工具清单,这些工具会并入 Spring AI 的 ToolCallback 体系。也就是说,Agent 调用一个 MCP 工具和调用一个本地函数,对模型来说没有区别,统一走协议封装。
3.5 工具注册成功怎么验证
有一个很直接的验证方法:启动项目后看日志。Spring AI 的 MCP 自动配置启动时,会打印工具列表。如果你配置正确,控制台会列出类似以下内容:
Registered MCP tool: queryTicketDetail(String ticketId)看到这个输出,说明 Server 端和 Client 端握手成功、工具已注册。这一步没通过,后面 Agent 再怎么调都不会触发工具。
4. 实战:构建一个标准化、可扩展的 Agent 系统
4.1 需求场景设定
先交代一下项目背景:某公司内部需要一个智能助手,能帮助员工查询工单、查看公告、提交简单的审批请求。所有数据都来自内部系统,不直接暴露数据库,也不允许模型自由发挥去捏造数据。
这个场景非常适合 Agent + MCP。员工用自然语言提问,Agent 决定调用哪个工具,工具返回真实数据,模型再组织语言回复。
4.2 配置 ChatClient 与工具回调
Spring AI 1.0 后的标准做法是用ChatClient作为交互入口。先创建配置类:
@Configuration public class AgentConfig { @Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem("你是某公司内部智能助手。请根据用户问题判断是否需要调用工具。" + "如果工具返回的结果不足以回答问题,明确告知用户,不要编造数据。") .build(); } }注意这里我加了一个明确的系统提示词:不要编造数据。模型有时候会为了“完成对话”而强行输出不存在的工单信息,这在企业内部场景是不可接受的。系统提示词里写清楚底线,比在业务代码里做各种校验都更治本。
接下来,在调用处把工具带有选择性地注入:
@Service public class AssistantService { private final ChatClient chatClient; private final List<ToolCallback> toolCallbacks; public AssistantService(ChatClient chatClient, List<ToolCallback> toolCallbacks) { this.chatClient = chatClient; this.toolCallbacks = toolCallbacks; } public String ask(String userMessage) { return chatClient.prompt() .user(userMessage) .tools(toolCallbacks) .call() .content(); } }这里把List<ToolCallback>注入进来,Spring 会自动收集所有 MCP 工具。想要控制某个 Agent 只能用某些工具,就在构建tools(...)时按名称过滤,非常灵活。
4.3 Agent 是怎么一步步“思考”和调用工具的
CallResponseSpec内部接入 Tool Calling 机制后,整个调用流程是这样的:
- 用户输入“帮我查一下工单 TK20250201 到哪一步了”
- 模型收到消息,判断需要调用工具
- 模型生成一个结构化的工具调用请求,包含工具名
queryTicketDetail和参数"TK20250201" - Spring AI 框架本地执行
queryTicketDetail方法,拿到返回结果 - 工具结果作为一条消息回传给模型
- 模型基于工具结果生成最终回答:“这个工单当前状态是处理中,处理人是张三,最后更新时间为今天上午 10:24”
这个多轮工具调用循环是 Agent 的核心引擎。你不必自己写循环代码,框架帮你做了。但你必须理解它,这样才能在出现问题时快速定位是哪一环出了岔子。
实战中我发现,工具调用失败的常见原因往往不是框架有 Bug,而是模型选择的参数不合法。比如用户说“查一下昨天的工单”,模型如果把这个请求转换成queryTicketDetail(ticketId="昨天的工单"),那你得让工具在参数校验不过时返回一个友好的错误信息,模型看到错误信息后会尝试修正自己的调用。这也是工具方法内部要做防御的原因。
4.4 上下文管理:对话记忆与 token 预算
单轮问答很容易,难的是多轮对话。Agent 必须记住用户之前说过什么。Spring AI 提供了ChatMemory和MessageChatMemoryAdvisor,直接解决这个问题。
先定义一个内存型记忆组件:
@Bean ChatMemory chatMemory() { return MessageWindowChatMemory.builder() .maxMessages(20) .build(); }然后在构建 ChatClient 时挂上记忆顾问:
@Bean ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultSystem("你是某公司内部智能助手。请根据用户问题判断是否需要调用工具。" + "如果工具返回的结果不足以回答问题,明确告知用户,不要编造数据。") .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build(); }.maxMessages(20)的意思是只保留最近 20 条消息。这个“20”不是随便写的。Agent 每轮对话不仅消耗 token,还消耗模型的注意力资源。历史太长,模型会“记不清”当前这轮问题的重点。我调过多个 Agent 项目,内部总结的经验是:短任务型 Agent 保持 10~20 条历史最佳;长度超过 50 条,不仅成本翻倍,回答质量也会肉眼可见地下降。
MessageWindowChatMemory 是滑动窗口策略,实现简单、效果可控。再复杂一点的场景可以考虑摘要压缩,把旧消息交给模型生成摘要再塞回上下文。但对多数内部助手来说,滑动窗口足够了。
4.5 系统提示词设计的几个心得
系统提示词对 Agent 行为的影响远超很多人的认知。在我这个项目里,经过多轮调优最终稳定下来的一段提示词,核心要素是角色、边界、调用策略:
你是某公司内部智能助手。 - 你的信息来源只有内部系统工具,不要编造数据。 - 用户问题涉及工单、公告、审批时,先调用对应工具获取真实数据。 - 工具返回数据为空时,如实告知用户,不猜测结果。 - 回答保持简洁,直接给出结论和关键信息。提示词里明确写“先调用对应工具”这一点很重要。模型默认情况下是“懒惰”的,能直接回答的问题它不喜欢绕路调工具。除非系统提示词明确要求,否则它常常宁愿“猜一个答案”。对于 Agent 应用,这就是致命的。
5. 常见问题与排查技巧实录
5.1 工具一直在,但模型就是不调用
这是我被问过最多的问题,没有之一。排查思路按顺序走:
第一步,看注册日志,确认工具确实在 ToolCallback 列表里。有些情况下工具类没有被 Spring 扫描到,日志里根本没有这一行。
第二步,看description。描述模糊、或者没有提到任何触发关键词,模型就不知道该用。把用户可能的口语表达写进去是最实用的做法。比如“查一下昨天的工单”,“工单”这个词必须出现在描述里。
第三步,看用户的提问和工具参数是否匹配。模型只有在“当前上下文足够支撑参数填充”时才倾向调用。如果你问的是“我的工单到哪了”但工具要求传ticketId,模型不知道 ID 是什么,就会放弃调用。这种情况下,可以在工具描述里补充说明“如果用户未提供工单ID,引导用户提供”。
第四步,确认是否在同一轮内可以调用多个工具。部分模型对“单轮多工具调用”的支持不好,会一次只调一个,然后停下来等结果。这不是框架问题,是模型策略问题,换个对工具调用支持更强的模型即可解决。
5.2 工具返回的数据格式影响回答质量
工具方法返回的是字符串,用大段文本拼接,模型确实也能读,但不如结构化格式好。建议工具返回值尽量用 JSON 字符串形式。比如天气工具返回{"city":"北京","temperature":5,"condition":"晴"},模型直接从 JSON 提取关键信息,组织自然语言回答,比拼接的文本要准确得多。
我自己的工具方法统一用一个简单的格式:如果数据正常,返回 JSON 字符串;如果异常,返回{"error":"查询失败: ..."}。模型能识别 error 字段,在最终回答里会主动传达失败原因。这对用户体验是很大的提升。
5.3 上下文越来越长,成本飙升
滑动窗口能限制“对话历史”长度,但工具调用产生的 token 一样要花钱。如果你的 Agent 频繁调用工具,一次工具调用循环可能消耗几百 token,多轮就是几千。
经验做法是:
- 工具结果尽量精简,只回传关键字段。不需要把整个数据库记录都塞给模型。
- 对工具返回结果做截断,超过 500 字符的只保留前半部分和后半部分,中间用省略号代替。
- 对话之间及时清理无用的历史,比如用户已经完成了一次咨询,就不需要把上一轮的完整工具调用过程一直留在上下文里。
5.4 Spring AI 自动配置冲突
这个坑比较隐蔽。Maven 里同时引入了多个 Spring AI 模块时,偶尔会出现自动配置冲突,表现是 MCP Client 连接失败、工具注册不生效。排查手段:
- 检查依赖树,
mvn dependency:tree查看是否有重复版本的 Spring AI 模块 - 检查
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports里的自动配置类是否被手动排除 - 确认没有引入两个不同版本号的
spring-ai-core
由于 Spring AI 是较新的框架,社区资料不像老牌框架那么多。遇到诡异问题,第一反应应该是“查版本、查依赖树”,而不是怀疑代码写错了。
5.5 问题排查速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 日志无工具注册 | Server/Client 配置未生效 | 检查 MCP 自动配置、依赖引入 |
| 模型不调用工具 | description 不明确 | 重写描述,加入触发同义词 |
| 调用工具但参数错误 | 上下文缺关键信息 | 引导用户补全参数或工具内做容错 |
| 回答质量波动大 | temperature 过高 | Agent 任务调到 0.2 以下 |
| 老问题经常复现 | 上下文被污染 | 检查 ChatMemory 窗口设置 |
| 成本上涨明显 | 工具结果太长 | 截断返回 JSON,只保留关键字段 |
6. 扩展方向与实用建议
6.1 把外部公共工具接进来
内测稳定以后,我们开始接更多现成的 MCP Server。GitHub 上能找到不少开源实现,比如数据库查询、时间日期、文件读取、HTTP 请求等通用工具。接任何一个工具,本质上都是加一个依赖、加一段连接配置,Agent 核心代码不需要改动。这就是 MCP 标准的实际红利。
当然,接外部工具要注意权限问题。MCP Server 暴露的是真实能力,权限控制必须做在 Server 端。不要让一个“查公网 IP”的工具在有内网权限的 Agent 里随意使用。
6.2 多 Agent 拆分的思路
工具数量到了 30+ 以后,单 Agent 的决策压力会变大。模型面对三十个工具的时候,调用准确率明显低于面对五个工具。
此时可以考虑纵向拆分:一个“路由 Agent”先判断用户意图属于哪个领域,再把请求转发给对应领域的子 Agent。子 Agent 只挂自己领域的少量工具。这个架构能显著提升工具调用的准确率。Spring AI 目前提供了 ChatClient 级别的工具过滤机制,你可以轻松实现“按 Agent 过滤工具”的逻辑。
我用下来觉得判断的临界点大概在 20 个工具附近。工具少于这个数,单 Agent 更简单;超过这个数,拆多 Agent 收益更大。
6.3 别忽略可观测性
Agent 系统最让人头疼的就是“黑盒”。用户问一个问题,触发了几次工具调用,每次结果是什么,最终回答基于哪些数据——这些过程全透明地记录下来,才方便复盘和排查。
建议至少记录三类日志:
- 用户原始输入和最终回答
- 每一次工具调用的工具名、入参、出参、耗时
- 模型返回的中间推理内容(如果厂商 API 支持的话)
Spring AI 有 Advisor 机制,可以在调用链路上做切面和日志。我建议从第一天就加上这个能力,不要等出了问题再补。等到上线后再补日志,你连当时发生了什么都不知道。
6.4 最后一个小技巧
没有兜底规则,Agent 系统永远不够格上生产。
我经历过一次印象特别深的 incident。当时用户问了一个不在任何工具能力范围内的问题,模型牛头不对马嘴地调用了一个工具,把耗时的接口命中了一次,结果返回的数据完全无关。用户看到的回答是“已为您处理”,实际上什么都没发生。
从那以后,我在系统提示词和工具返回两个层面都做了兜底:一是明确要求“没有合适工具时,如实告诉用户目前无法处理”;二是工具内对无效请求直接返回{"error":"参数异常"}。两层兜底叠起来,再也没有出现过“假装处理成功”的尴尬场面。
这个经验是那些漂亮架构图里不会写出来的东西,但每一套能稳定运行的生产级 Agent 系统,背后都离不开这种对失败场景的执着。做 Agent 不是做 demo,能稳住的系统才是真功夫。