基于 Spring AI 的 DeepSeek 集成实战(第二版)
本文为“基于 Spring AI 的 DeepSeek 集成实战”系列博客的第二版,聚焦于用户体验的两大提升:
流式显示——让长文本响应边生成边可见,降低等待感;
Markdown 渲染——以易读的富文本格式呈现 AI 返回的内容,而非原始 Markdown 代码。
目标读者为已有第一版接入基础、希望完善前后端交互、提升可用性的 Spring 开发者。
为什么要流式显示
降低用户焦虑:长文本生成往往需要数秒甚至更久,用户若一动不动等待,体验欠佳。
实时反馈检查:早期增量能让用户判断模型方向是否符合预期,及时中断或调整提示。
带宽节省:浏览器无需等完整内容才渲染,节省首屏加载时间。
通过流式接口,后端将模型输出分段推送;前端收到一小段就立即渲染,给用户“滚动直播”般的生成体验。
为什么要 Markdown 展示
可读性强:标题、列表、代码块、引用块,都能清晰区分层次和语法元素。
格式统一:与 GitHub、文档平台保持一致,降低阅读成本。
功能丰富:支持表格、任务列表、强调等多种富文本效果。
若将 AI 原始 Markdown 以纯文本显示,诸如 # 标题
、`````` 代码块标记,都会干扰阅读。把文本先转换成 HTML 并安全渲染,即可获得最佳效果。
我是怎么实现的
后端:WebFlux + SSE 流式接口
依赖引入
<!-- Spring WebFlux -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Spring AI Starter -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>服务层(AiService)
public class AiService {
private final StreamingChatModel model;
public AiService(StreamingChatModel model) { this.model = model; }
/** 流式生成返回 Flux<String> */
public Flux<String> streamGenerate(String msg) {
return model.stream(new UserMessage(msg));
}
}控制层(AiController)
"/api/ai") (
public class AiController {
private final AiService svc;
public AiController(AiService svc) { this.svc = svc; }
path="/stream-chat", produces=MediaType.TEXT_EVENT_STREAM_VALUE) (
public Flux<ServerSentEvent<String>> streamChat( String message) {
return svc.streamGenerate(message)
.map(chunk -> ServerSentEvent.builder(chunk).build());
}
}
使用 Reactor 的
Flux
管道将每次模型“增量输出”作为一条 SSE 事件推送。前端可通过标准的
EventSource
订阅TEXT_EVENT_STREAM
。
前端:EventSource + Markdown 渲染
EventSource 订阅 在用户输入后,先渲染 用户气泡,再插入一个空 Bot 占位气泡,然后:
const es = new EventSource(`/api/ai/stream-chat?message=${encodeURIComponent(msg)}`);
let buffer = '';
es.onmessage = e => {
buffer += e.data;
botBubble.innerHTML = renderMarkdown(buffer);
scrollToBottom();
};
es.onerror = () => {
es.close();
saveMessage('bot', buffer);
enableInput();
};预处理 + GFM 渲染
function renderMarkdown(raw) {
// 1. 自动在 # 与文字之间补空格
const fixed = raw.replace(/^(#{1,6})([^\s#])/gm, '$1 $2');
// 2. marked 配置
marked.setOptions({ gfm: true, headerIds: false });
// 3. 安全渲染
return DOMPurify.sanitize(marked.parse(fixed));
}
正则补空格:兼容模型直接输出的
#一级标题
→# 一级标题
GFM 模式:支持标题、列表、任务列表、表格等规范特性
DOMPurify:防止任何恶意 HTML 注入
额外优化与注意事项
连接管理:
EventSource
会自动重试,前端需在适当时机es.close()
避免多次重连。断点续传:可在 SSE 中加入
id
字段,实现断线后从最后一行继续。UI 动效:为占位气泡和代码块添加微交互动效,提升细节体验。
错误提示
- 微信
- 赶快加我聊天吧
- 赶快加我聊天吧