D08.拒绝「笨拙」的 AI 对话!用 SpringAI + 自定义协议实现真正的智能交互
约 2725 字大约 9 分钟
想象一下这样的场景:用户问「北京天气怎么样」,AI 不是回复一段文字,而是直接弹出一个精美的天气卡片;用户说「考考我 Spring」,AI 抛出一道选择题,用户点击选项后还能看到正确答案和解析;用户想看销售数据,AI 直接画出一张柱状图——这才是真正的智能助手,而不是一个更聪明的搜索引擎。
你是否受够了 AI 永远只会返回一大段文本?让我们来点不一样的。

为什么你的 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对话 协议:统一前后端交互规范
架构设计

业务流程

核心实现:让大模型学会「调用工具」
第一步:定义工具集
使用 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}}
优势:
- 实时性:文本逐字返回,体验流畅
- 可中断:前端可以随时终止连接
- 结构化:文本、工具调用、UI 卡片混合返回
- 可恢复:sequence 支持断点续传
完整事件类型

| 事件 | 用途 |
|---|---|
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;
}
}
效果展示
天气查询

知识问答

数据对比

配置与运行
依赖
<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:8080 即可体验。
进阶思考
- 多轮对话:如何让大模型记住上下文,连续调用多个工具?
- 工具编排:复杂场景下,如何实现工具链(工具A的结果作为工具B的输入)?
- 流式恢复:网络中断后,如何从断点继续?
- 安全加固:工具权限如何控制,防止大模型「越权」?
这些问题有兴趣的小伙伴可以尝试一下
源码地址:
- GitHub: https://github.com/liuyueyi/spring-ai-demo/tree/master/app-projects/D07-ai-self-protocol-chat
系列文章:
零基础入门:
- LLM 应用开发是什么:零基础也可以读懂的科普文(极简版)
- 大模型应用开发系列教程:序-为什么你“会用 LLM”,但做不出复杂应用?
- 大模型应用开发系列教程:第一章 LLM到底在做什么?
- 大模型应用开发系列教程:第二章 模型不是重点,参数才是你真正的控制面板
- 大模型应用开发系列教程:第三章 为什么我的Prompt表现很糟?
- 大模型应用开发系列教程:第四章 Prompt 的工程化结构设计
- 大模型应用开发系列教程:第五章 从 Prompt 到 Prompt 模板与工程治理
- 大模型应用开发系列教程:第六章 上下文窗口的真实边界
- 大模型应用开发系列教程:第七章 从 “堆上下文” 到 “管理上下文”
- 大模型应用开发系列教程:第八章 记忆策略的工程化选择
- 大模型应用开发系列教程:第九章 上下文工程在企业知识库助手中的落地
实战
- 实战 | 两百行实现一个自然语言地址提取智能体
- 实战 | 基于SpringAI与大模型的零配置发票智能提取架构
- 实战 | 零基础搭建知识库问答机器人:基于SpringAI+RAG的完整实现
- 告别传统AI开发!SpringAI Agent + Skills重新定义智能应用
- Spring AI中的多轮对话艺术:让大模型主动提问获取明确需求
- 实战 | 我用SpringAI造了个「微信红包封面设计师」
作者:一灰
项目地址:https://github.com/liuyueyi/spring-ai-demo
最后更新:2026-03-07
标签: #SpringAI #流式响应 #智能对话 #前后端分离 #结构化数据
欢迎关注我获取更多 AI 开发实战技巧! 🚀
Loading...