AI 对话应用接口开发:同步、SSE 流式与智能体前端对接
AI 对话应用接口开发,对比同步接口与 SSE 流式输出实现方式。通过 Spring Boot 整合 TravelApp 与智能体 BaseAgent,支持 Flux 及 SseEmitter 流式响应。涵盖前端 Vue3 项目生成、跨域配置及工具提示词优化,实现从后端流式处理到前端实时展示的全流程对接。

AI 对话应用接口开发,对比同步接口与 SSE 流式输出实现方式。通过 Spring Boot 整合 TravelApp 与智能体 BaseAgent,支持 Flux 及 SseEmitter 流式响应。涵盖前端 Vue3 项目生成、跨域配置及工具提示词优化,实现从后端流式处理到前端实时展示的全流程对接。

我们平时开发的大多数都是同步接口,也就是待后端处理完再返回。但是对于 AI 应用,特别是响应时间较长的应用,可能会让用户失去耐心等待,因此推荐使用 SSE 技术实现实时流式输出,类似打字机效果,大幅度提升用户体验。
接下来我们先开发 AI 同步接口,对比学习。首先我们编写一个与"科泰旅游大师"对话的接口,使用常规同步的方式获得对话结果。
在 controller 包中新建 ChatWithAIController,如下图所示:

编写同步接口:
@RestController
@RequestMapping("/ai")
public class ChatWithAIController {
// 注入 TravelApp 实例
@Resource
private TravelApp travelApp;
/*
* 前端可以通过此方法获得一个 ID
*/
@GetMapping("/chat/new")
public String newChat(){
return UUID.randomUUID().toString();
}
// 与 AI 聊天(同步)
@GetMapping("/chat/travel/sync")
public String chat(String chatId,String message){
return travelApp.chat(chatId,message);
}
}
通过/chat/new 接口,让前端利用 UUID 生成会话 ID,避免 A 用户能够通过猜测会话 ID 获取用户 B 的信息。
同步接口代码也非常简单,直接使用注入的 TravelApp 实例对用户的问题进行回复。
启动 Spring Boot 项目,浏览器打开 API 文档页面测试。
浏览器打开 http://localhost:8080/api/doc.html

获取到 ChatId 后测试/api/ai/chat/travel/sync 时会出现很长的时间进度提示。

SSE 是 HTML5 定义的基于 HTTP 的单向实时数据推送技术,客户端通过 EventSource API 发起长连接,服务器以文本流持续推送事件,适配实时通知、行情更新等场景。
要让接口支持流式输出,首先我们的 TravelApp 也不能使用同步的 call()+chatResponse() 方法返回 String 类型的全部内容。
需要使用到 Flux 响应式对象进行异步操作。在 TravelApp 类增加一个 StreamChat 方法,(SpringAI 系列的文章都是围绕一个项目写没有的内容在前面几期)
/*
* 与 AI 大模型流式对话接口
* @param chatId 会话 ID
* @param message 用户输入
* @return AI 模型返回结果
*/
public Flux<String> streamChat(String chatId, String message){
FunctionCallback[] mcpTools = toolCallbackProvider.getToolCallbacks();
Flux<String> result = chatClient.prompt()
.user(message)
.advisors(spec -> spec
.param(CHAT_MEMORY_CONVERSATION_ID_KEY,chatId)
.param(CHAT_MEMORY_RETRIEVE_SIZE_KEY,10)
)
.tools(aiTools)
.tools(McpToolCallbackProxy.proxyAll(mcpTools))
.toolContext(Map.of("chatId",chatId))
.stream().content();
// log.info("chatResponse: {}",chatResponse);
return result;
}
实现流式接口一共有三种方式。
修改 ChatWithAIController,增加方法 chatWithTravelAppSEE_Flux:
/*
* SSE 实现方式一:使用 Flux
* 必须设置 produces 为 MediaType.TEXT_EVENT_STREAM_VALUE
*/
@GetMapping(value = "/chat/travel/sse/flux",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatWithTravelAppSEE_Flux(String chatId, String message){
return travelApp.streamChat(chatId,message);
}
这种方式可省略 MediaType 的设置,但是要对 String 进行转化。
/*
* SSE 实现方式二:使用 ServerSentEvent 泛型
*/
@GetMapping("/chat/travel/sse/generic")
public Flux<ServerSentEvent<String>> chatWithTravelAppSEE_Generic(String chatId, String message){
return travelApp.streamChat(chatId,message)
.map(result -> ServerSentEvent.builder(result).build());
}
// 实现方式三使用 SseEmitter
@GetMapping("/chat/travel/sse/emitter")
public SseEmitter chatWithTravelAppSEE_Emitter(String chatId, String message){
// 创建 SseEmitter 对象,设置超时时间为 5 分钟
SseEmitter emitter = new SseEmitter(300000L);
// 订阅流
travelApp.streamChat(chatId,message).subscribe(
result -> {
try {
// 发送数据
emitter.send(result);
} catch (Exception e) {
emitter.completeWithError(e);
}
},
// 错误处理
error -> emitter.completeWithError(error),
() -> emitter.complete()
);
return emitter;
}
那么首先类似于 TravelApp 增加流式对话 StreamChat(), 我们也需要改造智能体 BaseAgent 增加支持流式运行的 streamRun() 方法:
/*
* 增加流式输出的运行方法
* @param userPrompt 用户输入
* @return SseEmitter
*/
public SseEmitter streamRun(String userPrompt){
SseEmitter emitter = new SseEmitter(300000L);
//使用异步线程处理,避免线程阻塞
CompletableFuture.runAsync(() -> {
try{
if (this.state != AgentState.IDLE){
emitter.send("当前 Agent 正在运行中,请勿重复运行!");
emitter.complete();
return;
}
if (StrUtil.isBlank(userPrompt)){
emitter.send("请输入用户提示词!");
emitter.complete();
return;
}
// 添加用户输入
chatMemory.add(chatId,new UserMessage(userPrompt));
this.state = AgentState.RUNNING;
try{
for (int i = 0; i < maxSteps && state != AgentState.FINISHED; i++) {
currentStep = i + 1;
log.info("开始执行第{}步...",currentStep);
String result = "Step " + currentStep + ": " + step();
emitter.send(result);
}
if(currentStep >= maxSteps){
state = AgentState.FINISHED;
emitter.send("已执行最大步骤数,任务结束!,最大步数为"+maxSteps);
}
// 正常完成
emitter.complete();
} (IOException e) {
state = AgentState.ERROR;
{
emitter.send();
emitter.complete();
} (IOException ex) {
(ex);
}
} {
cleanResources();
}
} (IOException e) {
emitter.completeWithError(e);
});
emitter.onTimeout(() -> {
.state = AgentState.ERROR;
.cleanResources();
log.warn(, chatId);
});
emitter.onCompletion(() -> {
(state == AgentState.RUNNING){
.state = AgentState.FINISHED;
}
.cleanResources();
log.debug(, chatId);
});
emitter;
}
上述代码看上去很复杂,但是大部分都是在原有 run 方法上改造,将结果信息交给 SseEmitter 通过 send 方法推送。
有了异步运行的方法,我们就可以添加智能体接口了,修改 ChatWithAIController,调用 ktTravelManus 智能体的接口。
@Resource
private ToolCallback[] toolCallbacks;
@Resource
private ChatModel dashscopeChatModel;
/*
* 流式调用智能体
* @param message
* @return
*/
@GetMapping("/chat/manus/sse")
public SseEmitter chatWithManus(String message){
TravelManus travelManus = new TravelManus(toolCallbacks,dashscopeChatModel);
travelManus.setMaxSteps(20);
return travelManus.streamRun(message);
}
为了保证每次请求都是一个新的 TravelManus 实例,我们通过自己 new 来创建,那么构造函数必要的工具列表 aiTools 和 dashscopeChatModel 对象就需要注入进来。 当然我们也可以直接注入 TravelManus,然后 Controller 添加@Scope 注解值为 request,这样每次请求都会创建一个新的控制器,由于 KtTravelManus 的@Scope 是 prototype,也会注入一个新的 KtTravelManus 实例。
使用 API 测试工具测试。
重启 Spring Boot 项目,在 API 测试工具中新增如下接口测试。
接口链接:http://localhost:8080/api/ai/chat/manus/sse
参考参数:message=我想在长沙旅游三天预算 3000,帮我规划旅程,并安排附近公里的酒店,并生成 PDF

由于我们这个项目不需要复杂的前端页面,我们可以使用 AI 编程工具来快速生成前端代码,极大提高开发效率。
(写这个项目需要具备前端的一些基础知识) 在终端输入 npm create vite@latest 使用 vite 去创建一个最新的 vue 版本的项目。

参考提示词
你是一位专业的前端开发,请帮我根据下列信息来生成对应的前端项目代码。#需求 1、提供主页:可以切换不同应用 (考虑页面样式美观,操作流畅) 2、页面 1:AI 旅游大师应用。页面风格为聊天室,仿 Deepseek 等 AI 聊天界面,首次运行创建新会话,每次创建新会话调用后端 newChat 接口获得新的聊天 ID,老的会话可以通过列表保留 (仅界面支持,后端暂不支持返回会话列表)。聊天室内通过 SSE 的方式调用 chatwithTravelAppsSE Flux 接口,实时显示对话内容。 3、页面 2:AI 旅游智能体应用。页面风格同页面 1,但不保留历史会话,调用 chathithManus 接口,实时显示对话内容,按 Step x:的格式分割气泡展示步骤 (x 为步骤数字值) ###技术选型 1.Vue3+ ElementPlus 布局 2.Axios 请求库
后端接口信息
接口地址前缀:http://localhost:8080/api
SpringBoot 后端接口代码
与 AI 聊天的控制器 @RestController @RequestMapping("/ai") public class ChatWithAIController { // 注入 TravelApp 实例 @Resource private TravelApp travelApp;
@Resource private ToolCallback[] aiTools; @Resource private ChatModel dashscopeChatModel; /* * 流式调用智能体 * @param message * @return */ @GetMapping("/chat/manus/sse") public SseEmitter chatWithManus(String message){ TravelManus travelManus = new TravelManus(aiTools,dashscopeChatModel); travelManus.setMaxSteps(20); return travelManus.streamRun(message); } /* * 前端可以通过此方法获得一个 ID */ @GetMapping("/chat/new") public String newChat(){ return UUID.randomUUID().toString(); } /* * SSE 实现方式一:使用 Flux * 必须设置 produces 为 MediaType.TEXT_EVENT_STREAM_VALUE */ @GetMapping(value = "/chat/travel/sse/flux",produces = MediaType.) <> (){ travelApp.(chatId,message); }
然后把提示词发送给 AI 后他会根据我们提示词生成一个页面。


页面如果觉得不好看可以按照想法去修改。
但是在我们运行过程中会有一个错误返回,会话创建失败,然后我们端口控制台可以看到是一个跨域错误。

在我们后端项目的 config 包中新建 CorsConfig。
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowCredentials(true)
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("*");
}
}
配置完成后我们再次重启后端项目,测试是否可以正常运行了。
如下图所示,会话创建成功,并且给出了正确的回复。

由于工具的使用由 AI 决定,如果它胡乱使用就可能会出现一些异常,例如原本有图片搜索工具,它却选择去使用'网页爬取工具'去爬取百度图片搜索结果,导致读取的页面数据得到大量乱码 (可能百度做了技术防护)。
另外还有一个问题,就是我们仅仅告知了最大执行步骤,并未告知大模型目前在第几步,大模型由于上下文限制,可能会忘记自己现在做到第几步了,我们也可以在提示词中再次提示它。
我们修改一下 KtTravelManus 智能体的 NEXT_STEP_PROMPT 提示词,把这两个问题一起解决一些,看下情况是否能够改善。
根据用户需求,主动选择最合适的工具或工具组合。对于复杂的任务,你可以将问题分解开来,然后分步骤使用不同的工具来解决它。你应该尽量在 %d 个步骤内完成任务,否则任务将可能提前结束,当前已经是第 %d 步。在使用每种工具之后,要清晰地说明执行结果,并提出下一步的建议。如果您想在任何时刻终止这种交互,请使用'终止'工具 / 函数调用重要的选择工具规则:搜索和爬取 1、图片搜索只能使用 searchImage 工具,不允许通过 searchWeb 和 scrapeWebContent 工具 2、如果需要搜索信息,请使用 searchWeb 工具,不允许使用 scrapeWebContent 工具 3、如果需要爬取信息,请使用 scrapeWebContent 工具,不允许使用 searchWeb 工具 4、如果需要地理位置信息,请使用高德地图相关工具 5、如果需要图文并茂的 PDF,先下载图片,并使用 Markdown 格式引用相对路径的图片地址,再传递此 Markdown 内容,使用 generatePdf 工具生成 6、如果用户要求提供文件,在最终响应时提供生成文件的下载链接(A 标签)
如果想要给智能体用所有工具,那么我们就需要合并本地工具和 MCP 工具提供给 KtTravelManus 的构造函数。
修改 ChatWithAIController。

// 合并工具
private ToolCallback[] mergeAiTools() {
// 注意到进行代理,避免 MCP 工具出现 ToolContext 问题
FunctionCallback[] mcpFunctionCallbacks = McpToolCallbackProxy.proxyAll(toolCallbackProvider.getToolCallbacks());
// 转化为 ToolCallBack 数组
ToolCallback[] mcpToolCallbacks = Arrays.stream(mcpFunctionCallbacks)
.map(tool -> (ToolCallback)tool).toArray(ToolCallback[]::new);
// 合并本地工具和 MCP 工具
ToolCallback[] allTools = ArrayUtil.addAll(aiTools,mcpToolCallbacks);
return allTools;
}
因为我们只发送了步骤信息,最终结果信息并没有发送,所以我们需要改造 BaseAgent,在最终完成状态时,发送最后一次的 AI 消息。
修改 BaseAgent 代码。

对应代码
// 完成之前获取最后一次有效的 AI 响应并发送
if(state == AgentState.FINISHED){
String finalAiResponse = getLastValidAiResponse();
// 发送最终响应给前端
if(StrUtil.isNotBlank(finalAiResponse)){
emitter.send("\n=== 智能体最终响应 ===");
emitter.send(finalAiResponse);
emitter.send("\n=== 智能体最终响应结束 ===");
}
}
/**
* @return 最后一次 AI 的有效响应文本,如果没有则返回空字符串
*/
protected String getLastValidAiResponse() {
// 获取所有消息
List<Message> messageList = chatMemory.get(this.chatId, Integer.MAX_VALUE);
// 如果消息为空,则最后的响应也为空
if (messageList == null || messageList.isEmpty()) {
return "";
}
// 从后往前遍历消息列表,查找最后一个助手消息
for (int i = messageList.size() - 1; i >= 0; i--) {
Message message = messageList.get(i);
// 获取最后一条助手消息
if (message instanceof AssistantMessage) {
AssistantMessage assistantMessage = (AssistantMessage) message;
String content = assistantMessage.getText();
// 返回非空的助手消息内容
if (StrUtil.isNotBlank(content)) {
log.info("Final response: {}", content);
return content;
}
}
}
return "智能体未返回最终响应";
}
注意后端数据发送逻辑

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online