本文核心解决三个问题:怎么让 AI 记住上下文、怎么实现打字机效果的流式响应、怎么让 AI 调用外部系统获取实时数据。面向有 Spring Boot 基础的 Java 后端开发者。


一、ChatMemory:让 AI 记住对话

1.1 为什么需要手动管理对话记忆

大模型本质上是无状态的——每次 API 调用都是独立的,模型不会自动记住上一轮说了什么。这意味着如果我们不做任何处理,第二轮对话时模型完全不知道第一轮聊了啥。

在上一篇文章中提到过,API 的输入是一个消息列表(System、User、Assistant),多轮对话的"记忆"实际上是靠我们把历史消息全部塞进去实现的。模型并没有真正"记住",它只是每次都把完整的聊天记录重新读一遍。

1.2 手动管理:ConcurrentHashMap 方案

最直接的思路——自己维护一个 Map,key 是会话 ID,value 是历史消息列表:

@RestController
@RequestMapping("/manual-chat")
public class ManualChatController {

    private final ChatClient chatClient;
    // 手动维护每个会话的历史(演示用,生产不推荐)
    private final Map<String, List<Message>> sessions = new ConcurrentHashMap<>();

    public ManualChatController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @PostMapping
    public String chat(@RequestBody ChatRequest request) {
        // 获取或创建该会话的历史
        List<Message> history = sessions.computeIfAbsent(request.conversationId(), id -> {
            List<Message> list = new ArrayList<>();
            list.add(new SystemMessage("你是一个 Java 技术助手"));
            return list;
        });

        // 追加用户消息
        history.add(new UserMessage(request.message()));

        // 带完整历史调用模型
        String reply = chatClient.prompt()
                .messages(history)
                .call()
                .content();

        // 把模型回复也追加进历史
        history.add(new AssistantMessage(reply));

        return reply;
    }

    record ChatRequest(String conversationId, String message) {
    }
}

这段代码功能上是能跑的,但问题很明显:

问题 说明
内存无限增长 聊得越多历史越长,没有淘汰机制
重启即丢失 存在 JVM 堆内存里,服务重启全没了
Token 超限 历史太长超过上下文窗口,直接报错
多实例不共享 集群部署时各节点数据不一致

1.3 Spring AI ChatMemory:优雅方案

Spring AI 提供了 ChatMemory 抽象和 MessageChatMemoryAdvisor,帮我们把记忆管理从业务代码中剥离出来:

@RestController
@RequestMapping("/memory-chat")
public class MemoryChatController {

    private final ChatClient chatClient;
    // 单独持有 chatMemory 实例,以便在每次请求时按 conversationId 构建 Advisor
    private final MessageWindowChatMemory chatMemory;

    public MemoryChatController(ChatClient.Builder builder) {
        this.chatMemory = MessageWindowChatMemory.builder().maxMessages(10).build();
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手")
                .build();
    }

    @GetMapping
    public String chat(
            @RequestParam String message,
            @RequestParam(defaultValue = "default") String conversationId) {

        return chatClient.prompt()
                .user(message)
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(conversationId)
                        .build())
                .call()
                .content();
    }
}

对比手动方案,代码量少了一半,而且关键改进在于:

  • MessageWindowChatMemory:自动维护历史消息,maxMessages(10) 限制最多保留 10 条,超出的旧消息自动丢弃——既省 Token 又不会撑爆上下文窗口。
  • MessageChatMemoryAdvisor:作为 Advisor(拦截器),在每次请求前自动注入历史消息,请求后自动保存新消息。业务代码完全不用关心记忆管理。
  • conversationId:支持多会话隔离,不同用户、不同对话互不干扰。

maxMessages 怎么设? 没有固定答案,取决于业务场景。简单问答 5-10 条够了;复杂多轮任务可以设 20-30 条。设太大会浪费 Token(费用涨),设太小模型容易"失忆"。建议从 10 条开始,根据实际效果调整。


二、Redis 持久化 ChatMemory

2.1 为什么内存方案不够

上面的 MessageWindowChatMemory 默认把消息存在内存里(InMemoryChatMemoryRepository),开发调试没问题,但到生产环境就不行了:

场景 内存方案的问题
服务重启/发版 所有对话记录丢失
多实例部署 用户请求落到不同实例,记忆不共享
大量并发用户 内存占用不可控

解决方案很自然——把消息存到 Redis 里。Spring AI 的设计也考虑到了这一点,ChatMemory 的底层存储是通过 ChatMemoryRepository 接口抽象的,我们只需要实现这个接口就行。

2.2 实现 RedisChatMemoryRepository

public class RedisChatMemoryRepository implements ChatMemoryRepository {

    private static final String KEY_PREFIX = "chat:memory:";
    private static final int TTL_DAYS = 7;

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    public RedisChatMemoryRepository(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    /**
     * 追加消息并刷新过期时间
     */
    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        String key = KEY_PREFIX + conversationId;
        // 先删除旧数据,再写入完整列表
        redisTemplate.delete(key);
        for (Message message : messages) {
            try {
                MessageRecord record = new MessageRecord(
                        message.getMessageType().name(),
                        message.getText()
                );
                redisTemplate.opsForList().rightPush(key, objectMapper.writeValueAsString(record));
            } catch (Exception e) {
                throw new RuntimeException("存储消息失败", e);
            }
        }
        redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS);
    }

    /**
     * 返回该会话的全部消息,裁剪逻辑由外层 MessageWindowChatMemory 处理
     */
    @Override
    public List<Message> findByConversationId(String conversationId) {
        String key = KEY_PREFIX + conversationId;
        List<String> rawMessages = redisTemplate.opsForList().range(key, 0, -1);
        if (rawMessages == null) return new ArrayList<>();

        List<Message> messages = new ArrayList<>();
        for (String raw : rawMessages) {
            try {
                MessageRecord record = objectMapper.readValue(raw, MessageRecord.class);
                if ("USER".equals(record.role())) {
                    messages.add(new UserMessage(record.content()));
                } else if ("ASSISTANT".equals(record.role())) {
                    messages.add(new AssistantMessage(record.content()));
                }
            } catch (Exception ignored) {
            }
        }
        return messages;
    }

    @Override
    public void deleteByConversationId(String conversationId) {
        redisTemplate.delete(KEY_PREFIX + conversationId);
    }

    @Override
    public List<String> findConversationIds() {
        Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
        if (keys == null) return new ArrayList<>();
        return keys.stream()
                .map(key -> key.substring(KEY_PREFIX.length()))
                .toList();
    }

    record MessageRecord(String role, String content) {
    }
}

几个设计要点:

  • Key 设计chat:memory:{conversationId},前缀统一方便管理和清理。
  • TTL 7 天:对话记录不需要永久保存,自动过期减少 Redis 内存压力。
  • saveAll 全量覆盖:每次保存先删后写,保证和 MessageWindowChatMemory 裁剪后的数据一致。这里要注意,ChatMemoryRepository 是纯存储层,负责读写所有消息;裁剪(只保留最近 N 条)的逻辑由上层的 MessageWindowChatMemory 处理。
  • 序列化:用 Jackson 把消息转成 JSON 字符串存储,方便调试和排查。

2.3 配置类装配

@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemory chatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
        RedisChatMemoryRepository repository = new RedisChatMemoryRepository(redisTemplate, objectMapper);
        // 底层走 Redis 持久化,上层限制最多保留 20 条消息
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(repository)
                .maxMessages(20)
                .build();
    }
}

2.4 使用 Redis 版 ChatMemory

Controller 层的代码和内存版几乎一样,唯一的区别是注入的 ChatMemory 现在底层走 Redis:

@RestController
@RequestMapping("/redis-chat")
public class RedisChatController {

    private final ChatClient chatClient;
    private final ChatMemory chatMemory;

    public RedisChatController(ChatClient.Builder builder, ChatMemory chatMemory) {
        this.chatMemory = chatMemory;
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手")
                .build();
    }

    @GetMapping
    public String chat(
            @RequestParam String message,
            @RequestParam(defaultValue = "default") String conversationId) {

        return chatClient.prompt()
                .user(message)
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(conversationId)
                        .build())
                .call()
                .content();
    }
}

实际开发提示:生产环境建议 conversationIduserId + sessionId 的组合,这样既能区分不同用户,又能区分同一用户的不同会话。另外,findConversationIds() 中用了 keys 命令,数据量大时可能阻塞 Redis,生产中可以改用 scan 命令。


三、SSE 流式输出:打字机效果

3.1 为什么需要流式输出

大模型生成一段回答通常需要几秒甚至十几秒。如果用普通的 HTTP 请求,用户点击"发送"后只能干等,直到全部生成完才一次性收到结果——体验很差。

ChatGPT 的做法是流式输出(Streaming)——模型每生成一个词就立刻推送给前端,用户看到的就是"打字机效果",感知上的等待时间大幅缩短。

技术上,Spring AI 用 SSE(Server-Sent Events) 实现,返回类型是 Flux<String>(Project Reactor 的响应式流)。

3.2 基础流式接口

@RestController
@RequestMapping("/api/stream")
public class StreamController {

    private final ChatClient chatClient;

    public StreamController(ChatClient.Builder builder) {
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手,回答详细且有条理。")
                .build();
    }

    /**
     * 流式对话接口
     * produces = TEXT_EVENT_STREAM_VALUE 告诉 Spring 这是 SSE 响应
     */
    @GetMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content();
    }
}

和普通调用的区别就两处:.call() 改成 .stream(),返回类型从 String 改成 Flux<String>。加上 produces = MediaType.TEXT_EVENT_STREAM_VALUE,Spring 会自动处理 SSE 协议。

3.3 流式 + ChatMemory 组合

流式输出和对话记忆可以无缝组合,写法和非流式版本基本一致:

@RestController
@RequestMapping("/api/stream/conversation")
public class StreamConversationController {

    private final ChatClient chatClient;
    private final MessageWindowChatMemory chatMemory;

    public StreamConversationController(ChatClient.Builder builder) {
        this.chatMemory = MessageWindowChatMemory.builder().maxMessages(10).build();
        this.chatClient = builder
                .defaultSystem("你是一个 Java 技术助手")
                .build();
    }

    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(
            @RequestParam String message,
            @RequestParam(defaultValue = "default") String conversationId) {

        return chatClient.prompt()
                .user(message)
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(conversationId)
                        .build())
                .stream()
                .content();
    }
}

MessageChatMemoryAdvisor 在流式模式下同样能正常工作——会在流开始前注入历史消息,流结束后自动保存本轮对话。

3.4 服务端收集完整回复(存库场景)

流式推送给前端的同时,服务端往往需要拿到完整的回复内容——比如存入数据库做审计、统计 Token 用量等。关键技巧是用 doOnNext 逐片段收集,doOnComplete 在流结束时触发保存:

@Slf4j
@RestController
@RequestMapping("/api/stream/save")
public class StreamSaveController {

    private final ChatClient chatClient;

    public StreamSaveController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    /**
     * 流式推送给前端,同时在服务端收集完整回复后存库
     */
    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatAndSave(
            @RequestParam String message,
            @RequestParam(defaultValue = "default") String conversationId) {

        StringBuilder fullResponse = new StringBuilder();

        return chatClient.prompt()
                .user(message)
                .stream()
                .content()
                .doOnNext(fullResponse::append)
                .doOnComplete(() -> saveToDatabase(conversationId, message, fullResponse.toString()));
    }

    private void saveToDatabase(String conversationId, String question, String answer) {
        // 替换为实际的数据库操作
        log.info("保存对话 conversationId={}, answer长度={}", conversationId, answer.length());
    }
}

注意doOnNextdoOnComplete 是 Reactor 的操作符,不会影响流的内容——前端依然逐片段收到数据,保存逻辑在服务端"旁路"执行。

3.5 错误处理与超时控制

流式调用比普通调用多了一个问题——流中途出错怎么办?比如模型响应超时、网络抖动等。如果不处理,前端会突然断开连接,用户体验很差:

@RestController
@RequestMapping("/api/stream/safe")
public class StreamSafeController {

    private static final Logger log = LoggerFactory.getLogger(StreamSafeController.class);
    private final ChatClient chatClient;

    public StreamSafeController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .stream()
                .content()
                // 超时控制:30 秒内没有新数据就触发 TimeoutException
                .timeout(Duration.ofSeconds(30))
                // 超时时推送提示后结束流
                .onErrorResume(TimeoutException.class,
                        e -> Flux.just("[响应超时,请重试]"))
                // 其他异常统一处理
                .onErrorResume(e -> {
                    log.error("流式调用出错: {}", e.getMessage());
                    return Flux.just("[抱歉,生成过程中出现错误,请稍后重试]");
                });
    }
}

这里用到三个 Reactor 操作符:

操作符 作用
timeout(Duration) 两个数据片段之间超过指定时间就抛 TimeoutException
onErrorResume(异常类, fallback) 捕获特定异常,返回一个友好的提示消息
onErrorResume(fallback) 兜底处理所有其他异常

生产建议:超时时间根据模型响应速度设置,国内调用海外模型建议 60 秒以上;如果是本地部署的模型,15-30 秒通常够了。另外,前端也要做好 SSE 断线重连机制。


四、Function Calling:让 AI 有手有脚

4.1 为什么需要 Function Calling

大模型的知识有截止日期,也没法直接查数据库、调 API。当用户问"北京今天天气怎么样"或"帮我查一下订单状态"时,模型靠自己的知识是回答不了的。

Function Calling(也叫 Tool Calling / Tool Use)解决的就是这个问题:让模型在推理过程中主动调用开发者提供的函数,获取实时数据或执行外部操作,再基于结果生成最终回答。

4.2 工作流程

一次 Function Calling 的完整流程:

① 用户提问:"北京今天天气怎么样?"
② 模型分析意图,发现需要调用 getWeather 工具,参数 city="北京"
③ 框架自动执行 getWeather("北京"),拿到结果
④ 结果返回给模型
⑤ 模型基于工具返回的真实数据,生成自然语言回答

关键点:模型不是直接执行函数,而是生成"我需要调用 xxx 工具,参数是 yyy"的结构化指令,由框架(Spring AI)在应用侧执行真正的函数调用。模型扮演的角色更像是"调度员"。

Function Calling 流程

4.3 @Tool 和 @ToolParam 注解

Spring AI 通过注解来声明工具,非常直观:

  • @Tool(description = "..."):标记方法为 AI 可调用的工具,description 是给模型看的,描述这个工具做什么用。
  • @ToolParam(description = "..."):标注参数的含义,帮助模型正确传参。

4.4 基础示例:天气查询工具

先看工具类的定义:

@Component
public class WeatherTools {

    @Tool(description = "获取指定城市的当前天气。返回温度、天气状况、风力等信息。")
    public String getWeather(
            @ToolParam(description = "城市名称,例如:北京、上海、广州") String city) {

        // 这里调用真实的天气 API,演示用假数据
        return String.format("""
                城市:%s
                温度:25°C
                天气:晴
                风力:北风3级
                湿度:45%%
                更新时间:%s
                """, city, java.time.LocalDateTime.now());
    }

    @Tool(description = "获取未来几天的天气预报")
    public String getWeatherForecast(
            @ToolParam(description = "城市名称") String city,
            @ToolParam(description = "预报天数,1-7天") int days) {

        StringBuilder sb = new StringBuilder();
        sb.append(city).append(" 未来 ").append(days).append(" 天天气预报:\n");
        String[] weathers = {"晴", "多云", "小雨", "阴", "大风"};
        for (int i = 1; i <= days; i++) {
            sb.append(String.format("第%d天:%s,20-%d°C\n", i, weathers[i % weathers.length], 20 + i));
        }
        return sb.toString();
    }
}

再看 Controller 怎么用:

@RestController
@RequestMapping("/api/weather")
public class WeatherChatController {

    private final ChatClient chatClient;
    private final WeatherTools weatherTools;

    public WeatherChatController(ChatClient.Builder builder, WeatherTools weatherTools) {
        this.weatherTools = weatherTools;
        this.chatClient = builder
                .defaultSystem("你是一个天气助手,帮用户查询天气信息。不要编造天气数据,只根据工具返回的信息回答。")
                .build();
    }

    @GetMapping
    public String chat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                // 注册工具,可以传多个
                .tools(weatherTools)
                .call()
                .content();
    }
}

只需要一行 .tools(weatherTools) 就把工具注册进去了。当用户问天气相关的问题时,模型会自动判断该调哪个方法、传什么参数;如果用户问的跟天气无关,模型就不会调用工具,直接正常回答。

description 写好很重要。模型靠 description 判断什么时候该调这个工具、怎么传参。描述不清楚,模型要么不调用,要么传错参数。建议写清楚:这个工具做什么、参数是什么格式、返回什么内容。


五、Function Calling 进阶

5.1 数据库驱动的工具:JPA + Tool

真实业务中,工具往往要查数据库。Spring AI 的 Tool 就是普通的 Spring Bean,可以正常注入 Repository:

@Component
public class OrderQueryTools {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public OrderQueryTools(OrderRepository orderRepository,
                           ProductRepository productRepository) {
        this.orderRepository = orderRepository;
        this.productRepository = productRepository;
    }

    @Tool(description = "根据订单号查询订单状态和物流信息")
    public String getOrderStatus(
            @ToolParam(description = "订单号,格式如:ORD001") String orderId) {

        Order order = orderRepository.findById(orderId).orElse(null);
        if (order == null) {
            return "未找到订单号为 " + orderId + " 的订单";
        }
        return String.format("""
                订单号:%s
                状态:%s
                金额:¥%.2f
                创建时间:%s
                预计到达:%s
                物流单号:%s
                """,
                order.getId(),
                order.getStatus().getDisplayName(),
                order.getTotalAmount(),
                order.getCreatedAt(),
                order.getEstimatedDelivery() != null ? order.getEstimatedDelivery() : "暂无",
                order.getTrackingNumber() != null ? order.getTrackingNumber() : "暂无");
    }

    @Tool(description = "查询用户的历史订单列表,返回最近的订单记录")
    public String getUserOrders(
            @ToolParam(description = "用户ID") Long userId,
            @ToolParam(description = "查询条数,默认5条,最多20条") int limit) {

        int safeLimit = Math.min(limit, 20);
        List<Order> orders = orderRepository.findByUserIdOrderByCreatedAtDesc(
                userId, PageRequest.of(0, safeLimit));

        if (orders.isEmpty()) {
            return "该用户暂无历史订单";
        }

        StringBuilder sb = new StringBuilder("最近 " + orders.size() + " 条订单:\n");
        for (Order order : orders) {
            sb.append(String.format("- %s (%s) ¥%.2f - %s\n",
                    order.getId(),
                    order.getCreatedAt().toLocalDate(),
                    order.getTotalAmount(),
                    order.getStatus().getDisplayName()));
        }
        return sb.toString();
    }

    @Tool(description = "搜索商品信息,根据关键词查找商品名称和库存")
    public String searchProducts(
            @ToolParam(description = "搜索关键词,如商品名称") String keyword,
            @ToolParam(description = "最大返回数量,默认5个") int maxResults) {

        List<Product> products = productRepository.searchByKeyword(
                keyword, PageRequest.of(0, Math.min(maxResults, 10)));

        if (products.isEmpty()) {
            return "没有找到与 \"" + keyword + "\" 相关的商品";
        }

        StringBuilder sb = new StringBuilder();
        for (Product product : products) {
            sb.append(String.format("- %s:¥%.2f,库存%d件,评分%.1f\n",
                    product.getName(),
                    product.getPrice(),
                    product.getStock(),
                    product.getRating()));
        }
        return sb.toString();
    }
}

注意 getUserOrders 中的 int safeLimit = Math.min(limit, 20)searchProducts 中的 Math.min(maxResults, 10)——模型传过来的参数不可信,必须在工具内部做校验和限制。模型可能传一个很大的数字,如果不限制,一次查几万条出来既浪费资源又可能超出上下文窗口。

5.2 全局注册 vs 按请求注册

注册工具有两种方式:

// 方式一:按请求注册 —— 每次调用指定
chatClient.prompt()
        .user(message)
        .tools(weatherTools)     // 只在这次请求中可用
        .call().content();

// 方式二:全局注册 —— 构建 ChatClient 时指定
this.chatClient = builder
        .defaultTools(orderQueryTools)  // 每次调用都带这个工具
        .build();
方式 适用场景
按请求注册 .tools() 不同接口需要不同工具集合
全局注册 .defaultTools() 某个 ChatClient 始终需要某些工具(如客服场景)

下面是一个全局注册的客服示例,同时集成了 ChatMemory:

@RestController
@RequestMapping("/api/customer-service")
public class CustomerServiceController {

    private final ChatClient chatClient;
    private final MessageWindowChatMemory chatMemory;

    public CustomerServiceController(
            ChatClient.Builder builder,
            OrderQueryTools orderQueryTools) {

        this.chatMemory = MessageWindowChatMemory.builder().maxMessages(10).build();
        this.chatClient = builder
                .defaultSystem("""
                        你是一个电商平台的智能客服助手。

                        你可以:
                        - 查询订单状态和物流
                        - 查询用户历史订单
                        - 搜索商品信息

                        规则:
                        - 只回答与订单、商品相关的问题
                        - 需要查询时直接调用工具,不要编造数据
                        - 对用户友好耐心
                        """)
                .defaultTools(orderQueryTools)  // 全局注册工具,每次调用都带
                .build();
    }

    @PostMapping
    public String chat(@RequestBody CustomerServiceRequest request) {
        return chatClient.prompt()
                .user(request.message())
                .advisors(MessageChatMemoryAdvisor.builder(chatMemory)
                        .conversationId(request.userId().toString())
                        .build())
                .call()
                .content();
    }

    record CustomerServiceRequest(Long userId, String message) {
    }
}

这个例子把前面学的东西全串起来了:ChatMemory 负责记忆、Function Calling 负责查数据、System Prompt 负责约束行为

5.3 Void 工具:有副作用的操作

并非所有工具都需要返回数据。发邮件、发短信、设置提醒——这些操作执行完就行了,不需要返回结果给模型:

@Component
public class NotificationTools {

    private static final Logger log = LoggerFactory.getLogger(NotificationTools.class);

    private final JavaMailSender mailSender;
    private final Map<String, String> reminderStore = new ConcurrentHashMap<>();

    public NotificationTools(JavaMailSender mailSender) {
        this.mailSender = mailSender;
    }

    /**
     * 无返回值工具:执行完模型就知道操作已完成
     */
    @Tool(description = "发送邮件通知给指定邮箱")
    public void sendEmail(
            @ToolParam(description = "收件人邮箱") String email,
            @ToolParam(description = "邮件主题") String subject,
            @ToolParam(description = "邮件正文") String body) {

        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(email);
        message.setSubject(subject);
        message.setText(body);
        mailSender.send(message);
        log.info("邮件已发送至 {}", email);
        // void 返回,模型收到 tool result 后会自动继续生成回复
    }

    /**
     * 有返回值工具:返回提醒 ID,模型会把结果告诉用户
     */
    @Tool(description = "创建一个日程提醒,返回提醒ID")
    public String createReminder(
            @ToolParam(description = "提醒内容") String content,
            @ToolParam(description = "提醒时间,格式:yyyy-MM-dd HH:mm") String reminderTime) {

        String reminderId = "RMD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
        reminderStore.put(reminderId, reminderTime + " | " + content);
        log.info("创建提醒 [{}]: {} at {}", reminderId, content, reminderTime);
        return "提醒已创建,ID: " + reminderId + ",将于 " + reminderTime + " 提醒你:" + content;
    }
}

Controller 的用法完全一样:

@RestController
@RequestMapping("/api/notify")
public class NotificationController {

    private final ChatClient chatClient;
    private final NotificationTools notificationTools;

    public NotificationController(ChatClient.Builder builder, NotificationTools notificationTools) {
        this.notificationTools = notificationTools;
        this.chatClient = builder
                .defaultSystem("""
                        你是一个助手,可以帮用户发送邮件或创建日程提醒。
                        需要操作时直接调用工具,不要编造结果。
                        操作完成后用自然语言告知用户结果。
                        """)
                .build();
    }

    @GetMapping
    public String chat(@RequestParam String message) {
        return chatClient.prompt()
                .user(message)
                .tools(notificationTools)
                .call()
                .content();
    }
}

用户说"帮我给 zhangsan@example.com 发一封邮件,告诉他明天下午三点开会",模型会自动解析出收件人、主题、正文,调用 sendEmail 工具,然后回复"邮件已发送"之类的自然语言。

5.4 多工具编排

一个 ChatClient 可以同时注册多个工具类。模型会根据用户的意图自动判断调用哪个(甚至哪几个):

// 注册多个工具
chatClient.prompt()
        .user(message)
        .tools(orderQueryTools, notificationTools, weatherTools)
        .call().content();

如果用户说"帮我查一下 ORD001 的订单状态,然后发邮件通知我",模型可能会:
1. 先调用 getOrderStatus("ORD001") 查订单
2. 再调用 sendEmail(...) 发邮件
3. 最后汇总结果回复用户

5.5 安全注意事项

Function Calling 的参数是模型生成的,不是用户直接输入的,但也不能无条件信任:

风险 应对措施
参数越界(查 10000 条数据) 工具内部做 Math.min 限制
SQL 注入 使用 JPA / PreparedStatement,不拼 SQL
越权访问 校验用户 ID 是否有权查看该订单
敏感操作(删除、转账) 需要二次确认机制,不要让模型直接执行

核心原则:工具只是模型的"手",但"手"该不该动、能动多大幅度,必须由应用层把关。


六、写在最后

这篇文章覆盖了 Spring AI 实战中三个最常用的能力,总结一下:

能力 解决的问题 核心 API
ChatMemory 多轮对话记忆 MessageWindowChatMemory + MessageChatMemoryAdvisor
SSE 流式输出 长回答的用户体验 .stream().content() 返回 Flux<String>
Function Calling AI 调用外部系统 @Tool + @ToolParam + .tools()

几个实践建议:

  • ChatMemory 从内存开始,再升级 Redis。开发调试用内存版就够了,上生产再换 Redis 实现,代码改动极小。
  • 流式输出是标配。只要涉及面向用户的对话界面,一定用流式,体验差距是质变级别的。
  • Function Calling 的 description 要花心思写。这是模型判断调用时机和传参方式的唯一依据,写得模糊就等着出 bug。
  • 三者可以自由组合。一个完整的 AI 应用通常三者都要用上:ChatMemory 管记忆、SSE 做输出、Function Calling 接外部系统。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐