Spring AI Agent 完整实战:Function Calling + RAG + Memory + SafeGuard 构建机票助手

0 阅读10分钟

Spring AI Agent 完整实战:Function Calling + RAG + Memory 构建机票助手

这是我学习 Spring AI Alibaba 的第七篇记录。 这一篇解决的问题是:前6章的零件怎么组装成一个完整的 Agent

前置知识:建议先读完第六章 RAG 三种架构

本篇速览:前6章我们分别学了 ChatClient、Function Calling、Prompt 工程、Memory、RAG。每一章都是独立的零件。这一章把它们焊到一起——构建一个完整的"票小蜜"机票 Agent,一个接口同时具备航班查询、政策问答、多轮记忆、断点恢复和输入护栏。

最终效果预览

# 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输入护栏第一道关卡,拦截后不进入后续链路
2MessageChatMemoryAdvisor注入历史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 的集成方式不同

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 项目结构
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 依赖配置
<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 推荐的方式:

@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());
        };
    }
}

知识库工具同理:

@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 链组装——核心代码
@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();
    }
}

关键设计决策

  1. SafeGuardAdvisor 放最外层:敏感内容被拦截后,不会存入 Memory,也不会消耗 Token
  2. Tool 不在 defaultToolCallbacks() 中注册:而是在 Controller 的 .toolNames() 中按需激活——为后续"不同接口暴露不同工具集"留口子
  3. System Prompt 包含当前日期:让 LLM 能理解"明天""后天"等相对时间
2.5 Controller——统一入口
@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();
    }
}

一行代码串联所有能力

flightAgent.prompt(q)                              // 用户输入
    .toolNames("searchFlights", "compareFlights",   // 激活 3 个 Tool
               "searchKnowledge")
    .advisors(advisor -> advisor                    // 传入 sessionId
        .param(ChatMemory.CONVERSATION_ID, sessionId))
    .call()                                         // 同步调用
    .content();                                     // 获取文本回答

请求在内部的完整流程: 在这里插入图片描述

三、测试场景——一个对话验证所有能力

启动应用后,用以下对话序列验证:

# 场景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,机制很简单:

// 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 回答中的敏感内容)
固定词表动态更新(需要重启)

生产环境怎么做更完善的护栏

输入护栏:
  SafeGuardAdvisor(简单词表)
  + 调用阿里云内容安全 API(语义级审核)

输出护栏:
  自定义 Advisor 检查 LLM 回答
  + 内容安全 API 二次审核

这些超出本章范围,后续"生产化"章节会详细讨论。

五、FAQ 与踩坑记录

Q1:Tool 注册了但 LLM 不调用

现象:通过 @Bean + @Description 注册了 searchKnowledge,LLM 收到政策问题却不调用。

排查步骤

  1. 检查 .toolNames("searchKnowledge") 是否在请求中激活
  2. 检查 @Description 描述是否清晰——LLM 根据描述判断何时调用
  3. 检查 System Prompt 是否明确说了"政策问题用 searchKnowledge"
  4. 开启 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

核心收获:

  1. Advisor 链的编排顺序:SafeGuard → Memory → Logger,顺序即策略
  2. Tool 的注册与激活@Bean + @Description 注册,.toolNames() 按需激活
  3. Agentic RAG 的集成方式:作为 Tool 而非 Advisor,让 LLM 自主决定
  4. SafeGuardAdvisor:简单有效的输入护栏,但有明确的能力边界

下一章预告

下一篇我们将扩展 Agent 的"大脑"——接入多个 LLM 模型并实现灵活切换

  1. DashScope(通义千问):我们一直在用的模型
  2. DeepSeek 接入:性价比之王
  3. Ollama 本地模型:免费调试、数据不出域
  4. 模型降级策略:主模型不可用时自动切换备用模型

评论区聊聊

  1. 你的 Agent 用了几个 Tool? 除了 RAG 和 Function Calling,还集成了什么能力(发邮件、操作数据库、调第三方 API)?工具多了之后 LLM 判断准确率有没有下降?
  2. Advisor 链你怎么编排的? 除了 SafeGuard 和 Memory,还加了什么自定义 Advisor?顺序踩过坑吗?
  3. 生产环境的护栏怎么做的? 只用 SafeGuardAdvisor 够吗?还是接了内容安全 API?输出侧的护栏怎么处理?


本文代码仓库:[GitHub - flight-agent 模块](完成项目后补充) 系列目录:[Spring AI Alibaba Agent 实战系列] 上一篇:[(六)RAG 三种架构]


如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。