D08.拒绝「笨拙」的 AI 对话!用 SpringAI + 自定义协议实现真正的智能交互

一灰灰blogSpringAISpringSpringAI约 2725 字大约 9 分钟

想象一下这样的场景:用户问「北京天气怎么样」,AI 不是回复一段文字,而是直接弹出一个精美的天气卡片;用户说「考考我 Spring」,AI 抛出一道选择题,用户点击选项后还能看到正确答案和解析;用户想看销售数据,AI 直接画出一张柱状图——这才是真正的智能助手,而不是一个更聪明的搜索引擎。

你是否受够了 AI 永远只会返回一大段文本?让我们来点不一样的。

D08-1.webp
D08-1.webp

为什么你的 AI 对话「不够智能」

大多数 AI 对话应用长这样:

用户:上海天气怎么样?
AI:上海今天天气晴朗,气温 22-28 度,东南风 3-4 级...

但 富文本AI对话 风格的 AI 应该长这样:

用户:上海天气怎么样?
AI:[弹出天气卡片,温度、湿度、AQI、穿衣建议一应俱全]

核心差异在于:结构化响应 vs 文本堆砌

本文将带你实现后者。

核心技术方案

技术栈一览

  • Spring Boot 3.x + Spring AI:AI 能力基石
  • OpenAI GPT-4o:提供 Function Calling 能力
  • NDJSON 流式传输:实现实时增量响应
  • 自定义 富文本AI对话 协议:统一前后端交互规范

架构设计

D08-2.webp
D08-2.webp

业务流程

D08-4.webp
D08-4.webp

核心实现:让大模型学会「调用工具」

第一步:定义工具集

使用 Spring AI 的 @Tool 注解声明可调用的函数:

@Component
public class ChatTools {

    /**
     * 天气查询工具
     * 关键点:用 @ToolResponseType 声明返回类型为「卡片」
     */
    @Tool(description = "查询指定城市的天气信息")
    @ToolResponseType("card")  // 👈 前端渲染成天气卡片
    public WeatherCard queryWeather(
            @ToolParam(description = "城市名称") String city,
            ToolContext toolContext) {
        
        // 实际项目中调用真实天气 API
        return WeatherCard.builder()
                .city(city)
                .condition("晴")
                .temperature(25)
                .humidity(60)
                .aqi(45)
                .windDirection("东南风")
                .windLevel("3级")
                .dressAdvice("建议穿短袖")
                .tips("天气晴朗,适合户外活动")
                .build();
    }

    /**
     * 知识问答工具
     * 返回类型声明为「问答卡片」
     */
    @Tool(description = "创建知识问答题目")
    @ToolResponseType("quiz")  // 👈 前端渲染成选项卡片
    public QuizCard createQuiz(@ToolParam(description = "问题主题") String topic) {
        return QuizCard.builder()
                .question("Spring AI 的核心接口是?")
                .options(List.of(
                    QuizCard.Option.builder().key("A").value("ChatClient").build(),
                    QuizCard.Option.builder().key("B").value("Flux").build()
                ))
                .correctAnswer("A")
                .explanation("ChatClient 是 Spring AI 的核心接口")
                .build();
    }

    /**
     * 数据对比工具
     * 返回类型声明为「柱状图」
     */
    @Tool(description = "对比多个数据项并以柱状图展示")
    @ToolResponseType("chart")  // 👈 前端渲染成柱状图
    public BarChartCard compareData(
            @ToolParam(description = "图表标题") String title,
            @ToolParam(description = "X轴分类") List<String> categories,
            @ToolParam(description = "数据") Map<String, List<Integer>> data) {
        // 返回柱状图数据结构
        return BarChartCard.builder()
                .title(title)
                .xAxis(categories)
                .datasets(datasets)
                .build();
    }

    /**
     * 获取所有工具回调(供 Spring AI 使用)
     */
    public List<ToolCallback> getTools() {
        return List.of(MethodToolCallbackProvider.builder()
                .toolObjects(this)
                .build()
                .getToolCallbacks());
    }
}

第二步:实现智能路由服务

核心思路:让大模型自己决定什么时候调用工具、调用哪个工具

@Service
public class AgentChatService {

    private final ChatClient chatClient;
    private final List<ToolCallback> tools;

    public AgentChatService(ChatModel chatModel, ChatTools 富文本AI对话Tools) {
        this.tools = 富文本AI对话Tools.getTools();
        this.chatClient = ChatClient.builder(chatModel).build();
    }

    /**
     * 流式聊天入口
     * 返回 NDJSON 事件流
     */
    public Flux<NdjsonEvent> streamChat(AgentChatRequest request) {
        String sessionId = getOrCreateSessionId(request.getSessionId());
        
        // 1. session.start - 会话开始
        Flux<NdjsonEvent> startEvent = Flux.just(
            NdjsonEventBuilder.createSessionStartEvent(sessionId, ...)
        );

        // 2. 处理消息(核心:Function Calling)
        ToolCallRecord toolCallInfo = new ToolCallRecord();
        Flux<NdjsonEvent> messageEvents = processWithFunctionCalling(
            request, sessionId, toolCallInfo
        );

        // 3. 工具执行结果 → UI 卡片
        Flux<NdjsonEvent> uiEvent = Flux.defer(() -> {
            if (toolCallInfo.name != null) {
                // 根据 @ToolResponseType 注解生成对应 UI
                return Flux.just(NdjsonEventBuilder.createUiEvent(
                    messageId, toolCallInfo.name, toolCallInfo.data, ...
                ));
            }
            return Flux.empty();
        });

        // 4. status.waiting_user - 等待下一轮用户输入
        Flux<NdjsonEvent> waitingEvent = ...

        return startEvent
            .concatWith(messageEvents)
            .concatWith(completeEvent)
            .concatWith(uiEvent)
            .concatWith(waitingEvent);
    }

    /**
     * 核心:使用 Function Calling 处理消息
     * 
     * 大模型会根据对话内容自主决定:
     * - 是否需要调用工具
     * - 调用哪个工具
     * - 传递什么参数
     */
    private Flux<NdjsonEvent> processWithFunctionCalling(...) {
        // 关键:禁用自动执行,由我们手动控制
        ToolCallingChatOptions options = ToolCallingChatOptions.builder()
            .internalToolExecutionEnabled(false)
            .build();

        return chatClient.prompt(new Prompt(msg, options))
            .toolCallbacks(tools)  // 注入工具集
            .stream()
            .chatResponse()
            .doOnNext(content -> {
                var output = content.getResult().getOutput();
                if (!CollectionUtils.isEmpty(output.getToolCalls())) {
                    // 👇 大模型决定调用工具!
                    var toolRsp = executeTools(output, toolContext);
                    toolCallInfo.name = toolRsp.getKey();   // 工具名
                    toolCallInfo.data = toolRsp.getValue(); // 执行结果
                }
            })
            .map(s -> NdjsonEventBuilder.createMessageDeltaEvent(...));
    }

    /**
     * 执行工具并返回结果
     * 
     * 关键:从 @ToolResponseType 注解读取响应类型
     * 告诉前端应该渲染成什么 UI
     */
    private KeyValue executeTools(AssistantMessage message, ToolContext ctx) {
        var toolCall = message.getToolCalls().get(0);
        
        for (ToolCallback tool : tools) {
            if (tool.getToolDefinition().name().equals(toolCall.name())) {
                // 执行工具
                var result = tool.call(toolCall.arguments(), ctx);
                
                // 👇 关键:从注解获取响应类型
                String responseType = getResponseTypeFromAnnotation(toolCall.name());
                
                // 返回:响应类型 + 数据
                return KeyValue.of(responseType, result);
            }
        }
        throw new RuntimeException("未找到工具:" + toolCall.name());
    }
}

第三步:事件流构建器

NDJSON 事件是整个协议的核心:

public class NdjsonEventBuilder {

    /**
     * 根据工具类型生成对应的 UI 事件
     */
    public static NdjsonEvent createUiEvent(String messageId, String toolType, 
            Object data, ...) {
        return switch (toolType) {
            case "weather", "card" -> createUiCardEvent(...);       // 天气卡片
            case "quiz" -> createUiOptionsEvent(...);               // 问答卡片
            case "chart" -> createUiChartBarEvent(...);             // 柱状图
            default -> createUiCardEvent(...);
        };
    }

    /**
     * ui.options 事件 - 知识问答
     */
    public static NdjsonEvent createUiOptionsEvent(String messageId, QuizCard quiz, ...) {
        return NdjsonEvent.builder()
            .type("ui.options")
            .payload(Map.of(
                "messageId", messageId,
                "data", Map.of(
                    "question", quiz.getQuestion(),
                    "options", quiz.getOptions().stream()
                        .map(o -> Map.of(
                            "key", o.getKey(),
                            "value", o.getValue(),
                            "label", o.getKey() + ". " + o.getValue()
                        )).toList()
                )
            ))
            .build();
    }

    /**
     * ui.chart_bar 事件 - 柱状图
     */
    public static NdjsonEvent createUiChartBarEvent(String messageId, BarChartCard chart, ...) {
        return NdjsonEvent.builder()
            .type("ui.chart_bar")
            .payload(Map.of(
                "messageId", messageId,
                "data", chart  // 直接序列化整个对象
            ))
            .build();
    }
}

协议设计:NDJSON 事件流

为什么选择 NDJSON?

传统 REST 是这样:

{"text": "这是回答...", "toolCalls": [...]}

我们的方式是这样:

{"type":"session.start","payload":{"sessionId":"abc"},"meta":{"sequence":1}}
{"type":"message.delta","payload":{"delta":"今天"},"meta":{"sequence":2}}
{"type":"message.delta","payload":{"delta":"北京天气"},"meta":{"sequence":3}}
{"type":"ui.card","payload":{"data":{"city":"北京","temperature":25}},"meta":{"sequence":4}}
{"type":"status.waiting_user","payload":{"enabled":true},"meta":{"sequence":5}}

优势

  1. 实时性:文本逐字返回,体验流畅
  2. 可中断:前端可以随时终止连接
  3. 结构化:文本、工具调用、UI 卡片混合返回
  4. 可恢复:sequence 支持断点续传

完整事件类型

D08-3.webp
D08-3.webp
事件用途
session.start会话创建
message.delta文本增量(流式)
message.complete文本发送完成
ui.card信息卡片
ui.options问答/表单
ui.chart_bar柱状图
status.waiting_user等待用户输入
session.end会话结束
error错误

前端实现:事件驱动 UI

// 读取 NDJSON 流
const response = await fetch('/agent/chat', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/x-ndjson'  // 👈 关键:声明接收 NDJSON
    },
    body: JSON.stringify({
        sessionId,
        message: userInput,
        context: { appId: 'my-app' }
    })
});

const reader = response.body.getReader();
let buffer = '';

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    buffer += new TextDecoder().decode(value);
    const lines = buffer.split('\n');
    buffer = lines.pop(); // 不完整的行留到下次
    
    for (const line of lines) {
        if (!line.trim()) continue;
        const event = JSON.parse(line);
        handleEvent(event); // 👈 分发到不同渲染器
    }
}

// 事件处理器
function handleEvent(event) {
    switch (event.type) {
        case 'message.delta':
            appendText(event.payload.delta);
            break;
        case 'ui.card':
            renderWeatherCard(event.payload.data);
            break;
        case 'ui.options':
            renderQuizCard(event.payload.data);
            break;
        case 'ui.chart_bar':
            renderBarChart(event.payload.data);
            break;
    }
}

// 渲染柱状图
function renderBarChart(data) {
    const maxValue = Math.max(...data.datasets.flatMap(d => d.data));
    
    // 生成柱状图 HTML...
    // 支持鼠标悬停显示数值
    // 支持多系列并排显示
}

数据模型:结构化响应

天气卡片

@Data @Builder
public class WeatherCard {
    private String city;           // 城市
    private String condition;     // 晴/多云/雨...
    private Integer temperature;  // 温度
    private Integer humidity;     // 湿度
    private Integer aqi;          // 空气质量
    private String windDirection; // 风向
    private String windLevel;    // 风力
    private String dressAdvice;   // 穿衣建议
    private String tips;          // 温馨提示
}

知识问答卡片

@Data @Builder
public class QuizCard {
    private String question;           // 问题
    private String description;       // 描述
    private List<Option> options;     // 选项
    private String correctAnswer;     // 答案
    private String explanation;       // 解析
    private Difficulty difficulty;    // 难度

    @Data @Builder
    public static class Option {
        private String key;   // A, B, C, D
        private String value;
    }
}

柱状图卡片

@Data @Builder
public class BarChartCard {
    private String title;
    private List<String> xAxis;   // ["Q1", "Q2", "Q3", "Q4"]
    private List<Dataset> datasets;
    private String yAxisLabel;
    private Boolean showLabels;

    @Data @Builder
    public static class Dataset {
        private String label;      // "销售额"
        private List<Integer> data;// [100, 200, 150, 300]
        private String backgroundColor;
    }
}

效果展示

天气查询

D08-5.webp
D08-5.webp

知识问答

D08-6.webp
D08-6.webp

数据对比

D08-7.webp
D08-7.webp

配置与运行

依赖

<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>
</dependencies>

配置

spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        options:
          model: gpt-4o

启动

./mvnw spring-boot:run

访问 http://localhost:8080open in new window 即可体验。

进阶思考

  1. 多轮对话:如何让大模型记住上下文,连续调用多个工具?
  2. 工具编排:复杂场景下,如何实现工具链(工具A的结果作为工具B的输入)?
  3. 流式恢复:网络中断后,如何从断点继续?
  4. 安全加固:工具权限如何控制,防止大模型「越权」?

这些问题有兴趣的小伙伴可以尝试一下


源码地址

系列文章

零基础入门:

实战


作者:一灰
项目地址https://github.com/liuyueyi/spring-ai-demoopen in new window
最后更新:2026-03-07
标签: #SpringAI #流式响应 #智能对话 #前后端分离 #结构化数据


欢迎关注我获取更多 AI 开发实战技巧! 🚀

Loading...