在企业级 AI 项目里,最容易被低估的问题不是大模型本身选型,而是 Agent 怎么把模型、工具、记忆和外部系统稳定地组织在一起。SpringAI 2.0 正好把这套链路重新梳理了一遍:多模型接入、Tools 函数调用、MCP 协议、多层记忆、Skills 技能封装,最终都可以汇入同一条 ChatClient 调用链。本文用一个智能航空项目作为主线,完整跑一遍从工程初始化、多模型配置、工具开发、MCP 接入、记忆分层、Skills 封装到 Agent 编排的 AI 开发全链路,并给出高频问题排查和上线检查清单。
1. 先理解 SpringAI 2.0 里 Agent、模型、工具、MCP、记忆、Skills 是怎么配合的
1.1 AI Agent 解决的是“一次性问答”到“多步任务执行”的跨越
普通大模型问答是“用户提问,模型直接返回文本”。它的问题在于:模型只知道训练数据里的知识,不知道你系统里的航班数据、会员里程、退改签政策,更不可能主动去查数据库或调用外部接口。
AI Agent 把问答过程变成“感知、决策、执行、反馈”的循环:
- 用户输入问题。
- 模型将问题拆解为可执行的子步骤,并判断哪些信息自己不知道。
- 模型从已注册的 Tools、MCP Server 中选择合适的工具。
- 工具执行并返回结构化结果。
- 模型结合工具结果继续推理,生成最终回答。
在智能航空项目中,典型问题是“下周三从杭州到深圳最早到达的航班是哪一班,落地后天气如何,我的金卡里程够不够升舱”。这类问题靠单次提示词无法回答,必须依赖 Agent 编排多个工具。
1.2 六个核心组件在智能航空项目中的分工
标题中这六个关键词不是并列的技术点,而是一条链路中的不同角色。
| 组件 | 作用 | 智能航空项目中的对应实现 |
|---|---|---|
| ChatModel | 负责语言理解和生成 | DeepSeek、GPT、通义等模型的统一抽象 |
| Tools | 让模型能调用进程内方法 | 航班查询、天气查询、里程查询的 Java 方法 |
| MCP | 通过标准协议接入外部系统 | 航班动态服务、机场流量服务等 MCP Server |
| Memory | 保存短期会话与长期用户画像 | 会话记忆、会员偏好、工具执行历史 |
| Skills | 把一组工具和提示词封装成可复用技能 | 航班行程规划 Skill、退改签咨询 Skill |
| Agent | 编排以上组件,完成多步任务 | 接收用户问题、调用工具、回填结果、生成答案 |
直观理解:ChatModel 是大脑,Tools 是手,MCP 是让手可以够到外部服务,Memory 是记事本,Skills 是把固定套路变成标准动作,Agent 是总调度。
1.3 为什么企业项目选择 SpringAI 而不是自研编排层
如果只是调用一次大模型接口,用 RestTemplate 或 WebClient 请求一次 HTTP 很简单。但进入 Agent 场景后,要处理的问题明显变多:
- 多模型接入和切换,不同供应商的鉴权、base-url、模型名规则不一致。
- Function Calling 的参数 Schema 生成。
- 工具注册、返回结果解析、循环调用控制。
- 流式输出。
- 会话记忆的注入和裁剪。
- 结构化管理 MCP 连接。
SpringAI 的优点是把这些能力统一到聊天客户端链路里。开发时只需要关注业务方法本身,剩下的参数映射、工具回填、记忆注入由框架处理。
需要注意的是,SpringAI 版本迭代速度较快,1.x 到 2.0 在部分 API 上会有调整。下面的示例代码按 SpringAI 2.0 的常用 API 编写,落地时如果发现编译不过,优先对照当前官方文档的迁移说明。
2. 多模型接入:先搭一个能跑的 SpringAI 工程再说
2.1 企业为什么要“多模型”而不是只买一家
在真实项目中,没有哪个模型能同时满足成本、效果、延迟、合规的所有要求。
一个典型的航空业务平台可能是这样的:
- 高频简单咨询使用 deepseek-chat 或 qwen-plus,成本低,响应快。
- 复杂行程规划和多工具编排使用综合能力更强的模型。
- 涉及敏感数据的场景走私有化部署模型,数据不出内网。
多模型接入的核心价值不是“炫技”,而是让同一套 Agent 代码可以按业务场景路由到不同模型。模型供应商出现故障或限额时,也能快速切换。
2.2 初始化工程与依赖
先创建一个 Spring Boot 3.x 工程,Java 版本建议 17 或 21。下面是最小化的 Maven 依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.3</version> <relativePath/> </parent> <properties> <java.version>17</java.version> <spring-ai.version>2.0.0</spring-ai.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-qwen</artifactId> </dependency> </dependencies>这里刻意把 OpenAI 和通义的 Starter 都引进来,是因为 DeepSeek 等国产模型大多兼容 OpenAI 协议,可以直接通过 OpenAI 抽象接入,而通义、百炼等国内服务又有独立的 Starter。
如果原始材料没有给出明确版本,落地前务必先确认 SpringAI BOM 与 Spring Boot 版本的兼容关系,否则后面所有配置都会受影响。
2.3 多模型配置示例
在application.yml中先配置一个默认模型。这里用 DeepSeek 作为默认,因为它兼容 OpenAI 协议,配置成本最低:
spring: application: name: airline-agent ai: openai: base-url: ${DEEPSEEK_BASE_URL:https://api.deepseek.com/v1} api-key: ${DEEPSEEK_API_KEY:} chat: options: model: deepseek-chat temperature: 0.7如果希望项目中同时保留多套模型,建议在代码中创建多个 ChatModel Bean。例如:
@Configuration public class ModelConfig { @Bean public ChatModel deepSeekChatModel(@Value("${deepseek.api-key}") String apiKey) { OpenAiApi api = OpenAiApi.builder() .baseUrl("https://api.deepseek.com/v1") .apiKey(apiKey) .build(); return OpenAiChatModel.builder() .openAiApi(api) .build(); } @Bean public ChatModel qwenChatModel(@Value("${qwen.api-key}") String apiKey) { QwenApi api = QwenApi.builder() .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .apiKey(apiKey) .build(); return QwenChatModel.builder() .qwenApi(api) .build(); } }需要提醒的是,OpenAiApi、QwenApi 的 Builder 方法名在不同版本中可能略有差异。不要照抄示例,而是要理解“每个模型供应商通过 Authentication、Base URL、Model Name 三个要素区分”。
| 供应商 | Base URL | 常用模型名 | 适用场景 |
|---|---|---|---|
| OpenAI | https://api.openai.com/v1 | gpt-4o-mini、gpt-4o | 综合问答、复杂工具编排 |
| DeepSeek | https://api.deepseek.com/v1 | deepseek-chat、deepseek-reasoner | 性价比高,中文理解好 |
| 通义千问 | https://dashscope.aliyuncs.com/compatible-mode/v1 | qwen-plus、qwen-max | 国内合规、备案场景 |
| 本地 Ollama | http://localhost:11434/v1 | qwen2.5、llama3 | 内网私有化、数据不出域 |
2.4 用 ChatClient 做最小对话验证
SpringAI 的核心客户端是 ChatClient,它把 prompt、tools、advisors、call 封装在一个流畅 API 中。
@Configuration public class ChatClientConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder.defaultSystem("你是航空客服助手,请基于工具返回的真实数据回答,不要编造航班信息。") .build(); } }@RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient = chatClient; } @PostMapping("/chat") public String chat(@RequestBody String message) { return chatClient.prompt() .user(message) .call() .content(); } }启动应用后,用 curl 验证:
curl -X POST http://localhost:8080/chat \ -H "Content-Type: application/json" \ -d '北京到上海有哪些航班'这个环节不追求复杂,只要确认模型能正常返回内容即可。如果调用 DeepSeek 时出现 content 为空的问题,可以先跳到第 8 章查看排查思路,这里先继续搭建完整链路。
3. 给 Agent 装上手:用 Tools 实现航班、天气、里程查询
3.1 @Tool 工具的基本工作要求
让模型调用工具,核心是让模型知道“什么时候调哪个工具、传什么参数”。Spring AI 中可以用@Tool注解标记一个方法,框架会扫描方法签名,自动生成 Function Calling 所需的参数 Schema。
@Component public class FlightTool { @Tool(description = "查询指定出发城市、到达城市、日期对应的航班列表") public List<FlightInfo> queryFlight(String fromCity, String toCity, String date) { // 这里应替换为真实航班服务调用 return List.of( new FlightInfo("CA1234", fromCity, toCity, date, "09:00", "12:00", 1800.00), new FlightInfo("ZH5678", fromCity, toCity, date, "10:30", "13:30", 2500.00) ); } }方法参数要尽量简单、语义清晰,最好使用字符串、数字、日期等基础类型。返回对象建议用轻量 record,避免返回大对象导致上下文膨胀。
public record FlightInfo( String flightNo, String fromCity, String toCity, String date, String departureTime, String arrivalTime, double price ) {}3.2 实现三个核心工具
继续补上天气查询工具和里程查询工具:
@Component public class WeatherTool { @Tool(description = "查询指定城市在指定日期的天气情况,返回温度和天气现象") public String queryWeather(String city, String date) { // 实际项目中调用气象服务 return "{\"city\":\"" + city + "\",\"date\":\"" + date + "\",\"weather\":\"晴\",\"temp\":28}"; } }@Component public class MileageTool { @Tool(description = "根据会员ID查询会员等级和可用里程数") public MemberMileage queryMileage(String memberId) { return new MemberMileage(memberId, "金卡", 12580); } }这三个工具覆盖了智能航空项目最常用的三种能力:核心业务查询、外部环境信息、会员资产信息。
3.3 把工具挂到 ChatClient
Spring AI 通过ToolCallbacks.from()把工具对象转换成模型可识别的工具回调。
@Component public class AgentRunner { private final ChatClient chatClient; public AgentRunner(ChatClient.Builder builder, FlightTool flightTool, WeatherTool weatherTool, MileageTool mileageTool) { this.chatClient = builder .defaultTools(ToolCallbacks.from(flightTool, weatherTool, mileageTool)) .build(); } }当用户问“明天北京到深圳的航班有哪些,到深圳后气温多少”时,实际调用链路是:
- 模型分析问题,发现需要航班和天气信息。
- 框架根据参数 Schema 生成一个或多个 function call。
- 框架调用对应 Java 方法。
- 工具结果回填给模型。
- 模型基于真实数据输出最终回答。
3.4 工具返回结果的设计规范
工具返回内容不是给人看的,是给模型看的。设计错误会导致模型回答错误或反复调用:
- 返回 JSON 形式的结构化数据,不要返回整篇营销文案。
- 查询不到数据时,返回明确的空结构,例如
{"code":404,"message":"无航班"},不要抛异常。 - 城市名要做归一化,避免“杭州”和“杭州市”匹配不到。
- 日期格式统一为
yyyy-MM-dd,并在方法内做好解析校验。
一个常见问题是工具方法里抛异常后,模型拿不到有效结果,只能凭记忆猜测回答。推荐把业务异常转成结构化返回,框架层面再统一处理真正的系统异常。
注意:工具描述不要写成功能列表,而要写清楚“在什么场景、为了回答什么问题而使用”。描述越具体,模型选错工具的概率越低。
4. MCP 接入:外部数据通过统一协议进入 Agent 链路
4.1 为什么已经有 Tools 还要 MCP
Tools 解决的是进程内方法调用,MCP 解决的是跨系统工具互通。
在没有 MCP 之前,每个 Agent 平台接入一个外部系统,都要重新写一遍鉴权、参数协议、错误处理。MCP(Model Context Protocol)把“模型调用外部工具”变成了标准化协议:外部系统暴露一个 MCP Server,Agent 作为 MCP Client 订阅并调用其中的工具。
在智能航空项目中,本地 Tools 可以查自有数据库里的航班和里程,但航司动态、机场流量、最新延误信息往往来自外部系统。直接把全部系统接入 Agent 代码不现实,通过 MCP Server 暴露需要授权的接口,是更稳妥的方式。
4.2 在 SpringAI 2.0 里接入 MCP Server
首先引入 MCP Client Starter:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-client</artifactId> </dependency>在配置中声明 MCP Server 连接:
spring: ai: mcp: client: sse: connections: flight-status: url: http://localhost:8081/sse type: sse配置完成后,Spring 容器会自动提供一个 MCP 工具提供方,将远程 MCP Server 暴露的工具当作普通 ToolCallback 使用。
@Component public class McpToolRegistrar { private final ToolCallbackProvider mcpTools; public McpToolRegistrar(ToolCallbackProvider mcpTools) { this.mcpTools = mcpTools; } public ToolCallback[] getMcpTools() { return mcpTools.getToolCallbacks(); } }在组装 ChatClient 时,把 MCP 工具和本地 Tools 合并:
this.chatClient = builder .defaultTools(ToolCallbacks.from(flightTool, weatherTool, mileageTool)) .defaultTools(mcpTools.getToolCallbacks()) .build();4.3 实现一个航班动态 MCP Server(简化)
MCP Server 也可以基于 SpringAI 搭建。下面是一个独立应用的简化结构:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-mcp-server</artifactId> </dependency>spring: ai: mcp: server: sse: path: /sse服务端定义一个工具方法:
@Component public class FlightStatusService { @Tool(description = "查询航班号的实时状态,包括计划起飞时间、延误情况、登机口") public String queryFlightStatus(String flightNo, String date) { // 真实项目中调用机场或航司动态数据服务 return "{\"flightNo\":\"" + flightNo + "\",\"status\":\"延误\",\"delayMinutes\":35,\"gate\":\"B12\"}"; } }这样,主 Agent 应用只需要知道 MCP Server 的地址,不需要引入航司 SDK,也不需要知道对方系统内部实现。
4.4 MCP 与本地 Tools 的冲突与优先级
MCP 工具和本地工具同时存在时,容易出现两个问题:
- 工具名冲突。本地有
queryFlight,MCP Server 也暴露queryFlight,模型会收到两个同名工具,行为不可控。 - 权限边界模糊。MCP 是外部调用,每次调用都应该有审计记录。
推荐做法:
- 给工具命名加前缀区分,例如本地工具叫
local_flight_query,MCP 工具叫mcp_flight_status。 - 在 MCP Client 层打印调用日志,记录工具名、参数、响应耗时。
- 上线前用一份“工具清单”核对所有注册到 ChatClient 的工具,避免测试环境带上生产 MCP 配置。
| 维度 | 自建 Tools | MCP Server |
|---|---|---|
| 部署位置 | 与 Agent 同进程 | 独立服务或第三方服务 |
| 开发成本 | 直接写 Java 方法 | 需要实现 MCP 协议 |
| 复用范围 | 仅当前应用 | 可被多个 Agent 平台复用 |
| 鉴权方式 | 方法内自行处理 | 协议层配置鉴权 |
| 适用场景 | 数据库、内部服务 | 跨系统、跨团队、外部服务 |
5. 多层记忆:让 Agent 不再是“一次性聊天机器人”
5.1 Agent 记忆为什么需要分层
把所有对话历史塞进提示词是最简单的做法,但进入生产环境后会迅速遇到三个问题:
- Token 成本高,每一轮请求都在重复发送全部历史记录。
- 无关历史会干扰模型判断,工具调用准确率下降。
- 用户会员等级、常飞机场这类长期信息不应该存在短期对话里。
所以记忆要分层管理。在智能航空项目中,推荐分成三层:
| 记忆层 | 存储方式 | 生命周期 | 典型内容 |
|---|---|---|---|
| 会话短期记忆 | 内存或 Redis | 一次会话 | 当前问题、刚查询过的航班、工具结果 |
| 业务长期记忆 | MySQL、Redis | 长期 | 用户会员等级、常飞城市、舱位偏好 |
| 工具执行记忆 | 日志、审计表 | 按审计周期 | 哪次查询、哪个工具、返回了什么 |
5.2 会话级短期记忆
SpringAI 提供了 ChatMemory 和 MessageChatMemoryAdvisor 来管理短期记忆。
@Configuration public class MemoryConfig { @Bean public ChatMemory chatMemory() { return new InMemoryChatMemory(); } }@Component public class MemoryChatClient { private final ChatClient.Builder builder; private final ChatMemory chatMemory; public MemoryChatClient(ChatClient.Builder builder, ChatMemory chatMemory) { this.builder = builder; this.chatMemory = chatMemory; } public ChatClient build(String conversationId, int windowSize) { return builder .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, conversationId, windowSize)) .build(); } }使用conversationId区分不同用户会话。这样用户在同一个会话中追问“那最早的航班呢”,模型能够理解“那”指的是上一轮查询结果。
生产环境不建议直接使用 InMemoryChatMemory,因为它无法跨实例共享。如果服务多节点部署,要替换为 Redis 实现,否则不同请求打到不同实例会丢失上下文。
5.3 长期业务记忆:向量库加用户画像
长期记忆更适合保存“用户长期不变或变化缓慢”的信息。会员等级、常飞机场、舱位偏好可以统一维护到一张用户画像表,例如:
CREATE TABLE user_profile ( user_id VARCHAR(64) PRIMARY KEY, member_level VARCHAR(16), frequent_city VARCHAR(64), preferred_seat VARCHAR(16), mileage_balance DECIMAL(12,2), updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );如果希望模型在回答时自动理解用户偏好,还可以把历史行为向量化存入向量数据库。对话开始时,Agent 根据用户 ID 先在向量库中检索最相关的画像片段,只把相关片段注入提示词,而不是把整张表塞给模型。
5.4 记忆注入 Agent 的正确姿势
注入记忆的方式有两种:
- 通过 Advisor,在每次请求前后自动加工消息。
- 通过工具,让模型按需查询用户画像。
推荐“用户画像用工具、短期对话用 Advisor”。原因是画像信息未必每条都要出现在当前问题中,让模型按需调用更省 Token,也更符合 Agent 的决策逻辑。
@Component public class UserProfileTool { @Tool(description = "查询用户的会员等级、常飞城市、偏好座位和可用里程") public UserProfile getUserProfile(String userId) { // 查询 user_profile 表 return new UserProfile(userId, "金卡", "杭州", "靠窗", 12580); } }注意:不要直接把长期用户画像拼进 System Prompt。这样每个请求都会带着完整画像,既浪费 Token,又会干扰模型聚焦当前问题。
5.5 记忆清理与 Token 控制
短期记忆不是越长越好。MessageChatMemoryAdvisor 的 windowSize 参数会控制保留最近几轮消息。实际项目中,建议:
- 单次记忆窗口控制在 10 到 20 轮。
- 工具返回结果特别长时,只保留摘要,不保留完整 JSON。
- 按小时做 Redis Key 过期,避免垃圾会话长期占用内存。
6. Skills:把“会做一件事”的能力封装成可复用技能
6.1 技能、工具、提示词的边界
在实际开发中,经常有人分不清这三个概念。
- 工具是一个方法,回答“我能做哪个动作”。
- 提示词是一段说明,回答“你该怎么表达”。
- 技能是一组能力包,回答“我能在什么场景下完成什么任务”,它可能包含多个工具、一段系统提示词、参数校验规则和输出格式要求。
以航空项目为例:
queryFlight是工具,负责查航班。queryWeather是工具,负责查天气。- “航班行程规划”是技能,它组合了航班查询、天气查询、里程查询三个工具,并规定“先查航班、再查天气、最后算里程”的执行顺序和输出格式。
Agent Skill 和 MCP 的区别在于:MCP 是传输和协议层,解决“工具如何在系统之间暴露”;Skill 是业务能力层,解决“如何把多个工具组装成一个用户可理解的业务流程”。
6.2 用 Spring 组件实现一个航空行程规划 Skill
先定义一个自定义注解,用于标记技能类:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) public @interface AgentSkill { String name(); String description(); }再实现技能类,注意它不是给模型直接调用的方法,而是给调度层拼接能力包的源头:
@Component @AgentSkill( name = "itinerary_planning", description = "当用户需要规划一次完整行程时使用,能够查询航班、查询落地天气、估算里程升舱" ) public class ItineraryPlanningSkill { private final FlightTool flightTool; private final WeatherTool weatherTool; private final MileageTool mileageTool; public ItineraryPlanningSkill(FlightTool flightTool, WeatherTool weatherTool, MileageTool mileageTool) { this.flightTool = flightTool; this.weatherTool = weatherTool; this.mileageTool = mileageTool; } public String execute(String fromCity, String toCity, String date, String memberId) { var flights = flightTool.queryFlight(fromCity, toCity, date); var weather = weatherTool.queryWeather(toCity, date); var mileage = mileageTool.queryMileage(memberId); return buildPlan(flights, weather, mileage); } private String buildPlan(Object flights, Object weather, Object mileage) { return "航班计划:" + flights + ",天气:" + weather + ",里程:" + mileage; } }6.3 Skill 注册与动态启用
为了让 Agent 知道有哪些技能可用,需要把技能描述注入 System Prompt。
@Component public class SkillRegistry { private final ApplicationContext applicationContext; public SkillRegistry(ApplicationContext applicationContext) { this.applicationContext = applicationContext; } public String buildSkillPrompt() { Map<String, Object> beans = applicationContext.getBeansWithAnnotation(AgentSkill.class); StringBuilder sb = new StringBuilder("以下技能可按需使用:\n"); beans.values().forEach(bean -> { AgentSkill skill = bean.getClass().getAnnotation(AgentSkill.class); sb.append("- ").append(skill.name()) .append(":").append(skill.description()).append("\n"); }); return sb.toString(); } }在构建 ChatClient 时,把技能描述拼接进系统提示词:
ChatClient chatClient = builder .defaultSystem(systemPrompt + "\n" + skillRegistry.buildSkillPrompt()) .build();这样 Agent 看到“用户要规划行程”时,会优先考虑该技能涉及的工具链。
6.4 Skills 与 Tools、MCP 的编排顺序
技能不是越多越好。技能描述越多,System Prompt 越长,模型注意力越分散。建议遵循以下规则:
- 单个动作、无状态、返回固定结构的用 Tool。
- 跨系统、跨团队、需要协议对接的用 MCP。
- 多步骤、固定业务链路、有明确输出格式的用 Skill。
- 如果不能描述清楚技能触发条件和边界,就不要注册成 Skill,宁可让模型逐个调用工具。
在智能航空项目中,比较适合封装成 Skill 的场景包括:行程规划、退改签咨询、里程升舱估算、延误改签建议。这些任务都涉及多个工具和固定规则。
7. 完整 Agent 编排:从“一句话问题”到“多步多工具回答”
7.1 一个真实的智能航空用户问题
假设用户输入:
“我是金卡会员,会员 ID 是 U10001,帮我查一下下周三从杭州到深圳最早到达的航班,到了深圳天气怎么样,顺便看看我的里程够不够升舱。”
这个问题的执行链路是:
- 识别日期“下周三”,计算具体日期。
- 调用航班查询工具,获取杭州到深圳的航班列表。
- 根据“最早到达”条件筛选到达时间最小的航班。
- 调用天气工具,查询深圳当天天气。
- 调用会员画像工具,读取用户等级和里程余额。
- 根据里程余额和航班信息估算升舱需求。
- 汇总输出完整回答。
这就是 Agent 相比简单 prompt 的本质差异:模型负责拆解,工具负责执行,Agent 负责串起来。
7.2 AgentService 核心编排代码
下面是一个最小可运行的 AgentService 结构。关键点是“把工具和记忆统一注册到 ChatClient,由模型自动决策调用顺序”。
@Service public class AgentService { private final ChatClient chatClient; public AgentService(ChatClient.Builder builder, ChatMemory chatMemory, FlightTool flightTool, WeatherTool weatherTool, MileageTool mileageTool, SkillRegistry skillRegistry) { this.chatClient = builder .defaultSystem(loadSystemPrompt(skillRegistry)) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory, "airline_conversation", 15)) .defaultTools(ToolCallbacks.from(flightTool, weatherTool, mileageTool)) .build(); } public String handle(SessionRequest request) { return chatClient.prompt() .user(request.question()) .advisors(advisor -> advisor.param("conversationId", request.conversationId())) .call() .content(); } private String loadSystemPrompt(SkillRegistry skillRegistry) { return """ 你是一个航空业务助手。请严格基于工具返回的数据回答。 查询航班时,如果用户没有指定日期,先询问日期。 如果用户要规划完整行程,使用相关技能链路。 """ + skillRegistry.buildSkillPrompt(); } }SessionRequest 是一个简单 record,包含用户问题、会话 ID、用户 ID 等字段:
public record SessionRequest(String conversationId, String userId, String question) {}7.3 运行验证与预期输出
启动应用,调用接口:
curl -X POST http://localhost:8080/agent \ -H "Content-Type: application/json" \ -d '{"conversationId":"conv-001","userId":"U10001","question":"帮我查下周三杭州到深圳最早到达的航班,到了深圳天气怎么样,我的里程够不够升舱"}'预期输出大致为:
根据查询结果,下周三杭州到深圳最早到达的是 CA1234,09:00 起飞,12:00 到达。 深圳当天天气晴,28 摄氏度。 您的当前里程为 12580,金卡会员。根据航班价格 1800 元,升舱至商务舱需要 12000 里程,您当前的里程足够完成本次升舱。验证重点是:
- 模型是否产生了多个工具调用。
- 第一个工具结果是否被第二个工具判断使用。
- 最终回答是否包含具体航班号、时间、天气、里程结论,而不是含糊的“根据查询”。
7.4 学习环境与生产环境的差异
同一个工程在本地能跑通,不代表生产环境可以直接部署。
| 维度 | 学习环境 | 生产环境 |
|---|---|---|
| 模型 Key | 个人开发 Key | 独立账号、按部门配额 |
| 记忆存储 | 内存 | Redis 或数据库实现 |
| MCP 地址 | localhost | 内网服务发现或网关地址 |
| 日志 | System.out | 结构化日志、TraceId 串联 |
| 工具权限 | 全部放开 | 按角色和租户限制 |
| 超时与重试 | 默认 | 配置超时、熔断、降级 |
| 审计 | 不关注 | 记录每次工具调用的入参出参 |
| 成本控制 | 不关注 | 按接口、用户、模型维度统计 Token |
8. 高频问题排查与生产落地清单
8.1 SpringAI 连接 DeepSeek 不输出 content
这个问题的现象是请求返回 200,但 content 为空,或者流式输出只输出空字符串。
排查顺序如下:
- 先确认 base-url 是否正确。DeepSeek 兼容 OpenAI 的接口路径通常是
https://api.deepseek.com/v1,不是https://api.deepseek.com。 - 确认模型名是否合法。普通对话用
deepseek-chat,深度推理用deepseek-reasoner,命名不存在时会返回错误。 - 抓取原始响应体,看是否是流式格式问题。部分 OpenAI 兼容服务在非流式请求下返回字段与 OpenAI 不完全一致。
- 检查是否携带了服务端不支持的参数。某些模型不支持自定义
function_call或额外参数时,可能忽略生成内容。 - 用官方 SDK 先做一次最小请求,排除 SpringAI 侧配置问题。
curl https://api.deepseek.com/v1/chat/completions \ -H "Authorization: Bearer $DEEPSEEK_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"deepseek-chat","messages":[{"role":"user","content":"你好"}]}'如果官方接口正常,就说明问题出在 SpringAI 的 base-url、模型名或参数配置上;如果官方接口也不返回 content,那要检查模型账号和配额。
8.2 MCP Server 握手超时或 Agent 执行不响应
现象是 ChatClient 调用一直没有结果,日志出现 MCP 连接超时,或者提示 agent execution provider did not respond in time。
排查要点:
- 先确认 MCP Server 进程是否启动,SSE 地址是否能直接访问。
- 再确认协议类型。SSE 走的是
text/event-stream,普通 HTTP 接口地址不能直接混用。 - 检查 MCP Server 的工具名和参数 Schema 是否与主应用预期一致,Schema 不匹配时模型生成的参数可能无法被正确解析。
- 检查是否有多个 MCP 连接串行执行,导致整体超时。生产环境要考虑并行调用或缩短超时时间。
| 现象 | 可能原因 | 检查方式 | 解决建议 |
|---|---|---|---|
| MCP 连接超时 | Server 未启动或地址错误 | 浏览器访问 SSE 地址 | 启动服务,核对地址 |
| 工具调用无响应 | 协议类型配置错误 | 查看连接日志 | 改为正确的 sse 或 http 传输 |
| 模型生成参数校验失败 | 参数 Schema 不匹配 | 打印工具注册清单 | 对齐参数名和类型 |
| Agent 整体超时 | 多工具串行执行耗时过长 | 查看各工具耗时日志 | 并行化处理或优化模型推理轮次 |
8.3 工具调用无法触发
模型不调用工具,或者一直调用同一个工具,通常是以下原因:
tools()没有传入,工具没有注册到 ChatClient。- 工具描述不清晰,模型不知道在什么场景下使用。
- 模型自身不支持 function calling,或在某些参数下关闭了工具调用。
- 工具返回结果格式太复杂,模型无法从结果中提取关键信息。
检查方法是在代码里打印最终注册的工具名和描述,先确认工具真的挂载成功。然后再用一个极简请求验证单个工具是否会被触发。
8.4 生产落地前检查清单
发布到生产环境前,建议逐项确认以下内容:
- 所有模型 Key 已从配置文件迁移到环境变量或密钥管理服务。
- 已确认多模型切换逻辑,并验证任一模型故障时能快速降级。
- 每个工具方法都做了超时控制,避免外部服务拖垮 Agent 主链路。
- 工具返回结果有日志,包括入参、出参、