比RAG更灵活?用SpringAI函数调用实现地铁查询系统:Qwen2.5+实时API对接指南

当我们在构建一个需要与大模型深度集成的业务系统时,常常会面临一个核心选择:是让模型“记住”所有知识,还是教会它“调用”外部服务?对于静态的、历史的知识库,RAG(检索增强生成)技术无疑是强大的,它将文档切片、向量化后存入数据库,让模型在回答时能够“翻阅”这些资料。然而,当问题转向实时、动态的业务数据时,比如查询此刻的地铁拥挤度、下一班列车的到站时间,或者最新的票价政策,RAG就显得有些力不从心了。它的知识库是“冻结”的,更新需要重新走一遍ETL流程,无法应对瞬息万变的信息。

这时,函数调用(Function Calling) 的价值就凸显出来了。它让大模型从一个“博闻强识的学者”,转变为一个“懂得使用工具的专家”。模型本身不需要记住所有地铁线路的细节,它只需要理解用户的意图,并精准地调用我们预先定义好的业务API,获取最新的数据,再组织成自然语言回复给用户。这种模式,对于需要与实时业务系统交互的场景,提供了前所未有的灵活性和准确性。

本文将以一个地铁线路实时查询系统为具体案例,带你深入SpringAI的函数调用世界。我们将使用通义千问Qwen2.5作为本地大模型,一步步构建一个能够理解用户自然语言查询、动态调用模拟API、并返回结构化结果的智能助手。我们将重点剖析如何利用SpringAI的@Description注解实现意图识别与参数提取,对比RAG与函数调用的适用场景差异,并分享在模型选择、数据构造和集成调试中的关键要点与避坑指南。

1. 理解核心差异:RAG与函数调用的场景抉择

在深入代码之前,我们必须厘清RAG和函数调用各自最适合的战场。这并非一个“谁更好”的问题,而是“谁更合适”的选择。

RAG(检索增强生成) 的核心思想是知识内化。它通过一个离线的ETL(提取、转换、加载)流程,将你的私有文档(如PDF、Word、数据库表结构文档)转换成向量,存入向量数据库。当用户提问时,系统从向量库中检索出最相关的文档片段,作为“上下文”附加到用户的原始问题中,再一并提交给大模型。模型基于这份“参考资料”生成答案。

注意:RAG的优势在于处理非结构化、海量的静态知识。例如,查询公司内部的历史项目文档、产品规格说明书、法律法规条文等。它的知识库一旦建立,查询成本低,且能处理非常复杂的语义检索。

然而,RAG的短板也很明显:

  • 数据实时性差:知识库更新滞后,需要定期重新运行ETL流程。
  • 无法执行操作:它只能“告诉”你知识,不能“替”你执行任何操作,比如下单、查询账户余额、获取实时天气。
  • 存在幻觉风险:如果检索到的上下文不相关或不足,模型可能基于错误信息“编造”答案。

相比之下,函数调用的核心是动作执行。我们预先定义好一系列工具函数(Tools),每个函数都有清晰的描述和参数格式。大模型的工作是理解用户请求,判断是否需要调用某个函数,如果需要,则严格按照我们定义的JSON Schema格式,提取出调用该函数所需的参数。我们的业务系统接收到这些参数后,执行真正的逻辑(如调用第三方API、查询数据库、执行计算),并将结果返回给模型,由模型最终组织成对用户的回复。

让我们用一个表格来直观对比:

特性维度 RAG (检索增强生成) Function Calling (函数调用)
数据源 静态文档、历史数据 动态API、实时数据库、可执行服务
核心能力 知识检索与基于上下文的生成 意图识别、参数提取与动作执行
数据新鲜度 依赖ETL更新周期,延迟高 实时或近实时
适用场景 智能客服知识库、文档问答、历史数据分析 实时信息查询(天气、交通、股价)、业务操作(预订、支付、控制)、数据计算
系统复杂度 需要维护向量数据库和Embedding模型 需要定义清晰的函数接口和稳定的后端服务
响应流程 用户 -> 检索上下文 -> 大模型 -> 答案 用户 -> 大模型(识别意图&提取参数)-> 业务系统 -> 大模型(组织答案)-> 用户

对于我们的地铁查询系统,需求非常明确:用户问的是当前某个站点的线路信息。这些信息可能来自官方的实时API,或者一个动态更新的数据库。显然,函数调用是更自然、更高效的选择。我们不需要把全市所有地铁站点的信息都向量化存储,只需要教会模型:“当用户问‘XX站有什么线路’时,你就调用getSubwayStationDetails这个函数,并把站名参数传给我”。

2. 环境搭建与模型选择:Ollama与Qwen2.5

工欲善其事,必先利其器。我们选择在本地通过Ollama来运行大模型,这保证了数据的私密性和调用的低延迟。SpringAI对Ollama提供了开箱即用的支持。

首先,确保你的开发环境已经安装并运行了Ollama。然后,我们需要选择一个支持工具调用(Tool Calling) 功能的模型。并非所有模型都具备此能力。通义千问Qwen2.5系列模型在此方面表现优异,且对中文支持友好。

# 拉取Qwen2.5最新版本的模型(以7B参数为例,可根据机器配置选择)
ollama pull qwen2.5:7b

拉取完成后,你可以通过命令行测试一下模型的基础对话和工具调用理解能力。接下来,我们创建一个Spring Boot项目。在pom.xml中引入必要的依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
    <version>0.8.1</version> <!-- 请使用当前最新稳定版 -->
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

application.yml中配置Ollama连接和默认使用的模型:

spring:
  ai:
    ollama:
      base-url: http://localhost:11434 # Ollama默认服务地址
      chat:
        options:
          model: qwen2.5:7b # 指定使用我们拉取的模型

至此,基础环境就搭建完毕了。你可以写一个简单的@RestController来测试模型是否能正常对话,确保网络和配置无误。

3. 核心实现:使用@Description定义地铁查询函数

这是整个系统的灵魂所在。我们将利用SpringAI提供的@Description注解,以一种声明式的方式,向大模型“介绍”我们的业务函数。

我们计划实现一个函数:getSubwayStationDetails。它的作用是“根据地铁站名称,查询该站点经过的所有地铁线路信息”。我们需要明确告诉模型三件事:

  1. 函数是干什么的:用@Description描述。
  2. 函数需要什么:定义一个请求记录(Record),其字段就是所需的参数。
  3. 函数返回什么:定义一个响应记录(Record),包含返回的数据结构。

下面是一个完整的工具类配置:

import org.springframework.ai.model.function.FunctionCallback;
import org.springframework.ai.model.function.FunctionCallbackWrapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Description;
import java.util.List;

@Configuration
public class SubwayToolConfiguration {

    /**
     * 定义请求参数结构。
     * 大模型会尝试从用户输入中提取`stationName`字段。
     */
    public record SubwayStationDetailsRequest(String stationName) {}

    /**
     * 定义响应结果结构。
     * 包含站名、线路总数和线路名称列表。
     */
    public record SubwayStationDetails(
        String stationName,
        Integer totalSubwayLine,
        List<String> subwayLineNameList
    ) {}

    /**
     * 核心:注册一个名为“getSubwayStationDetails”的函数回调。
     * @Description 注解中的文本至关重要,它直接指导大模型何时调用此函数。
     */
    @Bean
    @Description("根据提供的地铁站名称,获取该站点所有经过的地铁线路信息。例如,用户问'天府广场有哪些地铁线?',你就应调用此函数。")
    public FunctionCallback getSubwayStationDetailsFunction() {
        return FunctionCallbackWrapper.builder(new SubwayStationDetailsService()) // 绑定实际服务类
                .withName("getSubwayStationDetails") // 函数唯一标识名
                .withDescription("查询指定地铁站的线路详情")
                .withResponseConverter((response) -> response) // 响应转换器,这里直接返回
                .build();
    }

    /**
     * 实际执行业务逻辑的服务类。
     * 这里使用Mock数据模拟,真实场景应调用外部API或查询数据库。
     */
    public static class SubwayStationDetailsService {
        public SubwayStationDetails apply(SubwayStationDetailsRequest request) {
            String stationName = request.stationName();
            // 模拟数据查询逻辑
            if (stationName.contains("天府广场")) {
                return new SubwayStationDetails(stationName, 2, List.of("地铁1号线", "地铁2号线"));
            } else if (stationName.contains("春熙路")) {
                return new SubwayStationDetails(stationName, 3, List.of("地铁2号线", "地铁3号线", "地铁10号线"));
            } else if (stationName.contains("火车南站")) {
                return new SubwayStationDetails(stationName, 4, List.of("地铁1号线", "地铁7号线", "地铁18号线", "地铁33号线(在建)"));
            } else {
                // 模拟未找到的情况
                return new SubwayStationDetails(stationName, 0, List.of("未找到该站点的线路信息"));
            }
        }
    }
}

关键点解析:

  1. @Description注解:这是与大模型沟通的桥梁。描述要清晰、具体,最好包含示例(如“用户问'天府广场有哪些地铁线?'”)。好的描述能极大提高模型识别意图的准确率。
  2. 请求/响应记录(Record):使用Java Record定义数据结构,简洁明了。SpringAI会自动将其转换为JSON Schema供模型理解。字段名应语义清晰。
  3. FunctionCallbackWrapper:这是SpringAI提供的构建器,用于将我们的普通Java方法包装成模型可以调用的“工具”。withName必须唯一。
  4. Mock服务:在SubwayStationDetailsService中,我们暂时用简单的条件判断返回模拟数据。这是开发初期的常见做法,便于快速验证函数调用链路是否通畅。后续可以轻松替换为真正的HTTP客户端调用或数据库查询。

4. 集成与对话:在ChatClient中调用工具

函数定义好了,接下来就需要在对话中启用它。SpringAI的ChatClient提供了流畅的API来集成工具调用。

我们创建一个ChatService,它负责处理用户的消息,并自动管理工具调用的流程。

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Service
public class SubwayChatService {

    private final ChatClient chatClient;

    // 通过构造器注入ChatClient,SpringAI会自动装配我们定义的FunctionCallback
    public SubwayChatService(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    /**
     * 单次调用,获取完整的ChatResponse,便于调试。
     */
    public ChatResponse chatWithTools(String userMessage) {
        return chatClient.prompt()
                .user(userMessage)
                .call() // 触发对话,模型会决定是否以及如何调用工具
                .chatResponse();
    }

    /**
     * 流式调用,更适合前端展示,可以实时看到模型“思考”和调用工具的过程。
     */
    public Flux<String> chatStreamWithTools(String userMessage) {
        return chatClient.prompt()
                .user(userMessage)
                .stream() // 改为流式调用
                .content();
    }

    /**
     * 更精细的控制:指定系统指令,影响模型行为。
     */
    public String chatWithSystemInstruction(String userMessage) {
        String systemInstruction = """
                你是一个专业的地铁信息查询助手。你的回答应准确、简洁、友好。
                当用户询问地铁站线路时,你必须使用可用的工具(函数)来获取实时信息,不得凭空编造。
                如果工具返回的结果显示未找到信息,请如实告知用户。
                """;

        return chatClient.prompt()
                .system(s -> s.text(systemInstruction)) // 设置系统指令
                .user(userMessage)
                .call()
                .content();
    }
}

然后,我们创建一个简单的REST控制器来暴露接口:

import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

@RestController
@RequestMapping("/api/subway")
public class SubwayQueryController {

    private final SubwayChatService chatService;

    public SubwayQueryController(SubwayChatService chatService) {
        this.chatService = chatService;
    }

    @PostMapping("/query")
    public String query(@RequestBody QueryRequest request) {
        // 调用非流式接口
        return chatService.chatWithSystemInstruction(request.message());
    }

    @GetMapping(value = "/stream", produces = "text/event-stream")
    public Flux<String> streamQuery(@RequestParam String message) {
        // 调用流式接口,适合前端用EventSource连接
        return chatService.chatStreamWithTools(message);
    }

    public record QueryRequest(String message) {}
}

现在,启动你的Spring Boot应用。使用Postman或curl进行测试:

curl -X POST http://localhost:8080/api/subway/query \
  -H "Content-Type: application/json" \
  -d '{"message": "请问天府广场通哪几条地铁?"}'

如果一切正常,你将看到类似以下的响应过程(在日志或流式响应中会更清晰):

  1. 模型理解问题,识别出需要调用getSubwayStationDetails工具。
  2. 模型生成一个格式严格的JSON请求,如 {"stationName": "天府广场"}
  3. SpringAI框架截获此请求,调用我们定义的SubwayStationDetailsService.apply方法。
  4. 服务返回Mock数据:{"stationName":"天府广场", "totalSubwayLine":2, "subwayLineNameList":["地铁1号线","地铁2号线"]}
  5. 模型收到工具执行结果,组织成最终的自然语言回复:“天府广场站共有2条地铁线路经过,分别是地铁1号线和地铁2号线。”

5. 避坑要点与进阶优化

在实际开发中,你可能会遇到一些挑战。以下是一些关键要点和优化建议:

1. 模型选择与提示工程

  • 工具调用支持:务必确认你使用的模型版本支持工具调用。Qwen2.5、GPT-4、Claude等主流模型都支持。
  • 系统指令优化system指令是引导模型行为的有力工具。明确的指令可以减少模型“偷懒”(不调用工具直接回答)或错误调用的情况。例如,强调“必须使用工具查询实时信息”。
  • 描述的精炼@Description和工具withDescription的文本要互补。前者告诉模型“什么时候用”,后者告诉模型“这是什么工具”。两者结合效果更好。

2. 从Mock到真实API的对接 Mock只是第一步。真实场景中,你需要调用地铁官方API或内部数据服务。

import org.springframework.web.client.RestTemplate;
import org.springframework.beans.factory.annotation.Value;

@Service
public class RealSubwayService {
    private final RestTemplate restTemplate;
    @Value("${subway.api.base-url}") private String apiBaseUrl;

    public SubwayStationDetails findRealStationDetails(String stationName) {
        // 1. 可能需要对站名进行清洗或编码
        String cleanedName = cleanStationName(stationName);
        // 2. 调用外部API
        String url = String.format("%s/stations/%s/lines", apiBaseUrl, cleanedName);
        ResponseEntity<ApiResponse> response = restTemplate.getForEntity(url, ApiResponse.class);
        // 3. 将API响应映射到我们的SubwayStationDetails记录
        return mapToDetails(stationName, response.getBody());
    }
    // ... 其他辅助方法
}

3. 错误处理与降级策略

  • 网络超时与重试:调用外部API必须设置合理的超时和重试机制。
  • 参数提取失败:模型可能无法从用户模糊的表述中提取出准确的stationName。你需要设计降级策略,例如,让模型进行澄清追问(“您想查询哪个地铁站呢?”),这可以通过在函数调用链中处理null或非法参数来实现。
  • 工具调用结果为空:当工具返回“未找到”时,模型应如何回复?这需要在系统指令或后续的提示中加以引导。

4. 复杂参数与多工具协作 一个复杂的查询可能涉及多个参数或多个工具。例如,“从天府广场坐地铁去火车南站怎么换乘最快?”。这可能需要:

  • 单个函数,复杂参数:定义一个calculateRoute函数,参数为startStationendStation
  • 多个函数协作:先调用getStationDetails获取两个站点的线路,再调用另一个findTransferPlan函数计算换乘方案。SpringAI支持在单次对话中依次调用多个工具。

5. 可观测性与调试 函数调用的过程比普通对话复杂。务必加强日志记录。

  • 记录模型生成的原始工具调用请求JSON。
  • 记录工具执行的结果。
  • 记录模型的最终回复。这有助于在出现问题时,精准定位是模型意图识别错误、参数提取错误,还是后端服务逻辑错误。

函数调用技术为我们打开了一扇新的大门,让大模型能够安全、可控地与真实世界进行交互。它弥补了RAG在实时性和操作性上的不足,尤其适合构建需要查询动态数据或触发具体业务流程的AI应用。通过SpringAI简洁的注解和API,我们可以快速将这种能力集成到Java生态系统中。从地铁查询出发,这套模式可以轻松扩展到智能家居控制、电商订单查询、金融数据播报等无数场景。关键在于清晰地定义工具、编写准确的描述,并构建可靠的后端服务。剩下的,就交给大模型去理解和调度吧。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐