Spring AI Agent 完整实战:Function Calling + RAG + Memory 构建机票助手
这是我学习 Spring AI Alibaba 的第七篇记录。 这一篇解决的问题是:前6章的零件怎么组装成一个完整的 Agent
前置知识:建议先读完第六章 RAG 三种架构
本篇速览 :前6章我们分别学了 ChatClient、Function Calling、Prompt 工程、Memory、RAG。每一章都是独立的零件。这一章把它们焊到一起------构建一个完整的"票小蜜"机票 Agent,一个接口同时具备航班查询、政策问答、多轮记忆、断点恢复和输入护栏。
最终效果预览:
bash
# 1. 航班查询(Function Calling → ch03)
curl "http://localhost:8086/api/agent/chat?q=明天北京到上海的航班&sessionId=test1"
# → 查到 3 个航班:MU5678 ¥520、CA1234 ¥680、HU7890 ¥550
# 2. 对比航班(Memory 记住上文 + compareFlights → ch03)
curl "http://localhost:8086/api/agent/chat?q=帮我对比前两个&sessionId=test1"
# → 航班对比表 + 推荐最便宜 MU5678
# 3. 政策问答(Agentic RAG → ch06)
curl "http://localhost:8086/api/agent/chat?q=经济舱能带多大行李&sessionId=test1"
# → 根据东航政策:托运20公斤,随身55×40×20cm
# 4. 上下文改写(Memory + RAG → ch05+ch06)
curl "http://localhost:8086/api/agent/chat?q=那退票呢&sessionId=test1"
# → Agent 理解"那"指的是经济舱,检索退票政策
# 5. 护栏拦截(SafeGuardAdvisor)
curl "http://localhost:8086/api/agent/chat?q=我要投诉到民航局&sessionId=test1"
# → "您的消息包含敏感内容,无法处理。如需投诉请拨打 12326..."
# 6. 断点恢复(Checkpoint → ch05)
# 关闭终端,重新打开,同一 sessionId
curl "http://localhost:8086/api/agent/chat?q=我之前问的航班是哪几个&sessionId=test1"
# → Agent 记得之前查过北京到上海的航班
理论篇
一、为什么需要"组装"------从零件到整车
1.1 前6章学了什么
前6章像在学驾照科目一到科目四------每科都过了,但还没真正上路。 
每个零件单独都能 Demo,但放到真实业务中会遇到一个核心问题:这些零件怎么装到一起?
1.2 组装的三个工程挑战
挑战一:Advisor 链的编排顺序
Spring AI 的 Advisor 是洋葱模型------请求从外到内穿过每个 Advisor,响应从内到外返回。顺序错了,行为就错了。 
正确顺序:
| 顺序 | Advisor | 职责 | 为什么在这个位置 |
|---|---|---|---|
| 1(最外) | SafeGuardAdvisor | 输入护栏 | 第一道关卡,拦截后不进入后续链路 |
| 2 | MessageChatMemoryAdvisor | 注入历史 | Memory 注入后,LLM 才能看到上下文 |
| 3(最内) | SimpleLoggerAdvisor | 日志 | 记录最终发给 LLM 的完整 Prompt |
挑战二:Tool 的注册与激活方式
Spring AI 1.1.2 提供了三种 Tool 注册方式:
| 方式 | 适用场景 | 本章使用 |
|---|---|---|
@Bean + @Description |
简单 Tool,自动注册到容器 | ✅ searchFlights、compareFlights、searchKnowledge |
FunctionToolCallback.builder() |
需要 ToolContext 等高级功能 | ch03 中使用 |
.toolNames() |
Controller 按需激活已注册的 Tool | ✅ |
关键区别:defaultToolCallbacks() 在 ChatClient 构建时绑定(所有请求都可用),.toolNames() 在每次请求时按需激活。我们选后者,因为不是所有场景都需要所有工具。
挑战三:Agentic RAG vs Two-Step RAG 的集成方式不同
vbnet
Two-Step RAG 集成方式:
→ 作为 Advisor(RetrievalAugmentationAdvisor)
→ 每次请求都自动检索
→ 不需要 LLM 判断
Agentic RAG 集成方式:
→ 作为 Tool(searchKnowledge)
→ LLM 自主判断是否需要检索
→ 与其他 Tool 平级
在完整 Agent 中应该选 Agentic RAG:
✅ LLM 自主判断"这个问题需要查知识库还是查航班"
✅ 不浪费 Token(闲聊时不触发检索)
✅ 与 searchFlights 等工具自然协同
1.3 架构演进------本章给系统新增了什么

本章没有引入新的 Spring AI API,而是解决一个工程问题:怎么把已有的零件正确地组装到一起。新增的 SafeGuardAdvisor 是唯一的新组件(★ 标记)。
实战篇
二、动手编码------一步步组装完整 Agent
2.1 项目结构
bash
flight-agent/
├── pom.xml
├── src/main/java/com/ai/course/flightagent/
│ ├── FlightAgentApplication.java
│ ├── config/
│ │ ├── AgentConfig.java # 核心:Advisor 链 + ChatClient 组装
│ │ └── KnowledgeBaseInitializer.java # 启动时加载知识库
│ ├── controller/
│ │ └── AgentController.java # 统一入口
│ ├── model/
│ │ └── FlightInfo.java
│ └── tool/
│ ├── FlightTools.java # 航班工具(ch03)
│ ├── KnowledgeTools.java # 知识库工具(ch06)
│ └── MockFlightService.java # 模拟数据
└── src/main/resources/
├── application.yml
└── docs/airline-policy.txt # 航空公司政策文档
2.2 依赖配置
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<!-- RAG 模块 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-rag</artifactId>
</dependency>
</dependencies>
2.3 Tool 注册------@Bean + @Description
航班工具和知识库工具都通过 @Bean + @Description 注册,这是 Spring AI 1.1.2 推荐的方式:
java
@Configuration
public class FlightTools {
public record FlightQuery(
@JsonProperty(required = true)
@JsonPropertyDescription("出发城市名称,如'北京'、'上海'")
String from,
@JsonProperty(required = true)
@JsonPropertyDescription("目的城市名称")
String to,
@JsonProperty(required = true)
@JsonPropertyDescription("出发日期,格式 yyyy-MM-dd")
String date
) {}
@Bean
@Description("根据出发城市、目的城市和日期查询可用航班。")
public Function<FlightQuery, String> searchFlights(MockFlightService service) {
return query -> {
List<FlightInfo> flights = service.search(query.from(), query.to());
if (flights.isEmpty()) return "未找到航班";
// 格式化输出(代码见 flight-agent 模块)
return formatFlights(flights, query.date());
};
}
}
知识库工具同理:
java
@Configuration
public class KnowledgeTools {
@Bean
@Description("在航空公司知识库中搜索政策信息,包括退改签、行李、儿童票等。")
public Function<KnowledgeQuery, String> searchKnowledge(VectorStore vectorStore) {
return query -> {
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query.question())
.topK(5)
.similarityThreshold(0.5)
.build());
if (results.isEmpty()) return "知识库中未找到相关信息";
// 格式化输出
return formatResults(results);
};
}
}
为什么不用
FunctionToolCallback.builder()? 这两种方式功能等价,但@Bean + @Description更符合 Spring 的编程习惯,且自动注册到 IoC 容器,不需要手动传给 ChatClient。只有在需要ToolContext等高级功能时才需要FunctionToolCallback。
2.4 Advisor 链组装------核心代码
java
@Configuration
public class AgentConfig {
@Bean
public SafeGuardAdvisor safeGuardAdvisor() {
return SafeGuardAdvisor.builder()
.sensitiveWords(List.of("投诉到民航局", "炸弹", "劫机"))
.failureResponse("您的消息包含敏感内容,无法处理。"
+ "如需投诉请拨打 12326 民航服务质量监督热线。")
.build();
}
@Bean("flightAgent")
public ChatClient flightAgent(ChatClient.Builder builder,
ChatMemory chatMemory,
SafeGuardAdvisor safeGuardAdvisor) {
return builder
.defaultSystem("""
你是东方航空的智能客服「票小蜜」。
你拥有以下能力:
1. searchFlights --- 查询实时航班
2. compareFlights --- 对比航班
3. searchKnowledge --- 查询航空公司政策
对话规则:
1. 查航班需要:出发城市、目的城市、出发日期
2. 记住用户之前的信息,不要重复追问
3. 政策问题用 searchKnowledge,航班问题用 searchFlights
4. 闲聊直接回答,不调用工具
当前日期:%s
""".formatted(LocalDate.now()))
.defaultAdvisors(
safeGuardAdvisor, // 1. 输入护栏
MessageChatMemoryAdvisor.builder(chatMemory).build(), // 2. 对话记忆
new SimpleLoggerAdvisor() // 3. 日志
)
.build();
}
}
关键设计决策:
- SafeGuardAdvisor 放最外层:敏感内容被拦截后,不会存入 Memory,也不会消耗 Token
- Tool 不在
defaultToolCallbacks()中注册 :而是在 Controller 的.toolNames()中按需激活------为后续"不同接口暴露不同工具集"留口子 - System Prompt 包含当前日期:让 LLM 能理解"明天""后天"等相对时间
2.5 Controller------统一入口
java
@RestController
@RequestMapping("/api/agent")
public class AgentController {
private final ChatClient flightAgent;
public AgentController(@Qualifier("flightAgent") ChatClient flightAgent) {
this.flightAgent = flightAgent;
}
@GetMapping("/chat")
public String chat(@RequestParam String q,
@RequestParam(defaultValue = "default") String sessionId) {
return flightAgent.prompt(q)
.toolNames("searchFlights", "compareFlights", "searchKnowledge")
.advisors(advisor -> advisor
.param(ChatMemory.CONVERSATION_ID, sessionId))
.call()
.content();
}
}
一行代码串联所有能力:
java
flightAgent.prompt(q) // 用户输入
.toolNames("searchFlights", "compareFlights", // 激活 3 个 Tool
"searchKnowledge")
.advisors(advisor -> advisor // 传入 sessionId
.param(ChatMemory.CONVERSATION_ID, sessionId))
.call() // 同步调用
.content(); // 获取文本回答
请求在内部的完整流程: 
三、测试场景------一个对话验证所有能力
启动应用后,用以下对话序列验证:
bash
# 场景1:航班查询(Function Calling)
curl "http://localhost:8086/api/agent/chat?q=明天北京到上海的航班&sessionId=demo"
# 预期:LLM 调用 searchFlights,返回 3 个航班
# 场景2:航班对比(Memory + compareFlights)
curl "http://localhost:8086/api/agent/chat?q=帮我对比一下前两个&sessionId=demo"
# 预期:LLM 从 Memory 中找到航班号 MU5678 和 CA1234,调用 compareFlights
# 场景3:政策问答(Agentic RAG)
curl "http://localhost:8086/api/agent/chat?q=经济舱能免费托运多少行李&sessionId=demo"
# 预期:LLM 判断是政策问题 → 调用 searchKnowledge → 基于检索结果回答
# 场景4:上下文推理(Memory + RAG)
curl "http://localhost:8086/api/agent/chat?q=那退票呢&sessionId=demo"
# 预期:LLM 结合 Memory 理解"那"指经济舱 → searchKnowledge("经济舱退票")
# 场景5:闲聊(不调用任何 Tool)
curl "http://localhost:8086/api/agent/chat?q=你好&sessionId=demo"
# 预期:直接回答,不调用任何工具
# 场景6:护栏拦截(SafeGuardAdvisor)
curl "http://localhost:8086/api/agent/chat?q=我要投诉到民航局&sessionId=demo"
# 预期:SafeGuardAdvisor 拦截,返回投诉热线
# 场景7:断点恢复(Checkpoint)
# 关闭终端,重新打开
curl "http://localhost:8086/api/agent/chat?q=我之前查的是哪条航线&sessionId=demo"
# 预期:Agent 从 Memory 中恢复上下文,回答"北京到上海"
7 个场景,1 个接口,1 个 sessionId------这就是完整 Agent 的价值。前6章的每个能力都在这里发挥作用。
四、SafeGuardAdvisor------输入护栏详解
SafeGuardAdvisor 是 Spring AI 1.1.2 内置的输入过滤 Advisor,机制很简单:
java
// SafeGuardAdvisor 核心源码(简化)
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
// 检查用户输入是否包含敏感词
if (sensitiveWords.stream().anyMatch(w -> request.prompt().getContents().contains(w))) {
return createFailureResponse(request); // 直接返回,不继续链路
}
return chain.nextCall(request); // 放行,继续后续 Advisor
}
SafeGuardAdvisor 的局限:
| 能做 | 不能做 |
|---|---|
| 精确匹配敏感词 | 模糊匹配、语义理解 |
| 拦截输入 | 过滤输出(LLM 回答中的敏感内容) |
| 固定词表 | 动态更新(需要重启) |
生产环境怎么做更完善的护栏:
markdown
输入护栏:
SafeGuardAdvisor(简单词表)
+ 调用阿里云内容安全 API(语义级审核)
输出护栏:
自定义 Advisor 检查 LLM 回答
+ 内容安全 API 二次审核
这些超出本章范围,后续"生产化"章节会详细讨论。
五、FAQ 与踩坑记录
Q1:Tool 注册了但 LLM 不调用
现象 :通过 @Bean + @Description 注册了 searchKnowledge,LLM 收到政策问题却不调用。
排查步骤:
- 检查
.toolNames("searchKnowledge")是否在请求中激活 - 检查
@Description描述是否清晰------LLM 根据描述判断何时调用 - 检查 System Prompt 是否明确说了"政策问题用 searchKnowledge"
- 开启
SimpleLoggerAdvisor,查看实际发给 LLM 的 tools 定义
根因 :通常是 @Description 写得太模糊,LLM 无法判断该工具的适用场景。
Q2:Memory 记住了敏感内容
现象:用户发送敏感内容被拦截后,下一轮对话 Memory 仍然回放了敏感内容。
原因:SafeGuardAdvisor 的顺序不对------放在了 MessageChatMemoryAdvisor 之后。
解决:确保 SafeGuardAdvisor 在 Advisor 链最外层(最先执行),敏感请求在进入 Memory 之前就被拦截。
Q3:同一个 sessionId 的多个用户互相看到对话
现象:两个用户用同一个 sessionId,能看到对方的对话历史。
原因:sessionId 是 Memory 的隔离键,相同 sessionId 共享 Memory。
解决 :sessionId 应该包含用户标识,如 userId-sessionId 格式。或者在 Controller 层做权限校验。
本章小结
本章没有引入新的 Spring AI API,而是解决了一个工程问题:怎么把 ChatClient、Function Calling、RAG、Memory、SafeGuard 这些零件正确组装成一个完整 Agent。
核心收获:
- Advisor 链的编排顺序:SafeGuard → Memory → Logger,顺序即策略
- Tool 的注册与激活 :
@Bean + @Description注册,.toolNames()按需激活 - Agentic RAG 的集成方式:作为 Tool 而非 Advisor,让 LLM 自主决定
- SafeGuardAdvisor:简单有效的输入护栏,但有明确的能力边界
下一章预告
下一篇我们将扩展 Agent 的"大脑"------接入多个 LLM 模型并实现灵活切换:
- DashScope(通义千问):我们一直在用的模型
- DeepSeek 接入:性价比之王
- Ollama 本地模型:免费调试、数据不出域
- 模型降级策略:主模型不可用时自动切换备用模型
评论区聊聊
- 你的 Agent 用了几个 Tool? 除了 RAG 和 Function Calling,还集成了什么能力(发邮件、操作数据库、调第三方 API)?工具多了之后 LLM 判断准确率有没有下降?
- Advisor 链你怎么编排的? 除了 SafeGuard 和 Memory,还加了什么自定义 Advisor?顺序踩过坑吗?
- 生产环境的护栏怎么做的? 只用 SafeGuardAdvisor 够吗?还是接了内容安全 API?输出侧的护栏怎么处理?
本文代码仓库 :[GitHub - flight-agent 模块](完成项目后补充) 系列目录 :[Spring AI Alibaba Agent 实战系列] 上一篇:[(六)RAG 三种架构]
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。