简介:基于 Spring AI Alibaba、Milvus 与 Vue 3 打造的 AI 聊天应用工程包,面向 Java 后端与 Vue 前端开发者,适用于复现 DeepSeek、SiliconFlow、Gemini、阿里云百炼等多模型接入及 RAG 检索增强对话的开发场景。压缩包共 2000 个文件,大小 15.77MB,以 js 前端逻辑、java 后端服务、vue 组件、ts 类型、json/yml 配置为主,并附 md 文档和 esbuild 工具,目录结构便于快速定位;已有 162 人学习下载。内容覆盖技术栈说明、环境要求、数据库启动、配置 API Keys、构建运行与访问应用、API 接口、前端特性、RAG 功能说明、项目结构、添加新模型方法及故障排除,既适合开发者参照搭建 AI 聊天项目,也能帮助使用者理解部署、维护与功能使用。
1. 一套能跑的AI聊天应用:Spring AI Alibaba、Milvus与Vue 3的三角结构
Spring AI Alibaba、Milvus、Vue 3这三件套拼一个AI聊天应用,听起来像是把模型接入、向量库、前端框架各选一个热门组件拼起来。但我把这个项目完整拆过一遍之后,结论有点反直觉:真正卡住你的不是大模型API调用,而是流式响应怎么传、Milvus索引维度怎么对齐、以及前端SSE解析怎么不崩。这套资源的价值在于,它把「模型能对话」升级成了「应用能记忆」——每轮对话都写入Milvus,下一次提问先检索相关历史再交给模型,解决了普通聊天机器人一问就忘的毛病。适合谁?正在做课程设计、企业内部AI助手,或者想把技术demo推成能内测的完整应用的人。
2. 后端接入Spring AI Alibaba:依赖、配置与第一个流式对话接口
2.1 依赖引入与项目骨架
我拿到这套项目的第一件事,是先把后端骨架立起来。Spring AI Alibaba 本质上是在 Spring AI 之上封了一层阿里云 DashScope 的适配,所以你的项目里只需要一个 starter 依赖就能获得 ChatClient、嵌入模型这些核心能力。
<dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <!-- 版本号打开 Maven 中央仓库搜 spring-ai-alibaba-starter,取当前最新 release --> </dependency> <dependency> <groupId>io.milvus</groupId> <artifactId>milvus-sdk-java</artifactId> <!-- 版本以 milvus-sdk-java 官方最新稳定版为准 --> </dependency>这里有个值得注意的点:Spring AI Alibaba 的 starter 会把 Spring AI 的核心抽象一起带进来,所以你在代码里写的ChatClient、ChatModel、EmbeddingModel这些接口,从 API 层面看和社区版 Spring AI 几乎没有差别。好处是你以后想换成别的模型供应商,改配置就可以,业务代码不用动。Milvus SDK 是独立引入的,它负责向量库的写入和检索,和模型调用是两个通道。
2.2 配置文件:模型路由与 Key 管理
接下来是配置。这套项目用的是通义千问系列模型,所以配置项集中在 DashScope 相关前缀下。
spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.7 max-tokens: 2048 milvus: # 这套项目里 Milvus 的客户端是手动实例化的 # 配置项只做记录,不参与自动装配 uri: http://localhost:19530DASHSCOPE_API_KEY我用环境变量注入,不会把 Key 硬编码进配置文件,这是我在这个项目里比较坚持的习惯。api-key是 DashScope 控制台创建的应用 Key。模型名我建议先用qwen-plus跑通全链路,它响应速度和上下文长度都比较均衡;如果你要做复杂推理再切qwen-max。
有个细节:Spring AI Alibaba 某些版本对配置前缀的解析有差异,如果你升级 starter 后发现chat.options.model没生效,模型始终返回默认值,可以先检查一下是不是走了spring.ai.model.chat这种新前缀。遇到这种情况不用慌,在ChatModel的 Bean 定义里手动指定模型名是最快的绕法。
2.3 核心接口:从同步调用到流式返回
依赖和配置就绪后,核心代码就三段:写一个 Controller,注入 ChatClient,然后决定同步还是流式。我先给你看同步版本,这是最容易理解的。
@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @PostMapping("/sync") public Map<String, String> syncChat(@RequestBody ChatRequest request) { String reply = chatClient.prompt() .user(request.message()) .call() .content(); return Map.of("reply", reply); } }ChatClient.Builder是 Spring AI 提供的构建器,通过构造器注入比@Autowired更利于测试。prompt().user()组装的是用户消息,call().content()是阻塞式获取完整回复。这个接口适合调试,但实际聊天应用里你不会想等模型把话全说完才让用户看到内容,所以重点要放在流式实现上。
2.4 流式接口:Flux 与 SSE 的配合
流式场景我用的是stream().content(),返回类型是Flux<String>,Spring MVC 会自动把它按 SSE 格式输出。这一层不需要你手写 SSE 协议,但两个细节决定前端能不能正常解析。
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamChat(@RequestBody ChatRequest request) { return chatClient.prompt() .user(request.message()) .stream() .content(); }第一,produces必须显式声明成TEXT_EVENT_STREAM_VALUE,不写的话某些版本会兜底成 JSON,前端拿到的就是一段被包装过的完整字符串。第二,Flux<String>里每个元素会对应 SSE 的一个data:事件,所以后端不需要也不应该自己拼接data:前缀。这里我踩过坑:一旦你在响应里也手动加了data:,前端按标准 SSE 解析时就会收到双重的data:前缀,解析器直接懵。后文避坑章节我会展开讲前端怎么配。
另外提一句,如果你要在这个接口里同时做鉴权和会话校验,建议用 Spring 的 HandlerInterceptor 做预检,不要在 Controller 里用 ThreadLocal 配合 Flux 异步拼装上下文。Flux 是异步流式执行,ThreadLocal 在流结束前可能已经失效,这是异步编程里的经典坑。
3. Milvus作为长期记忆层:Collection设计、向量写入与TopK检索
3.1 为什么要选Milvus,而不是Chroma或Qdrant
搭建这个聊天应用的记忆层之前,我对几个向量数据库做了个粗略对比,结论是 Milvus 适合中小团队起步、又希望未来数据量上去后不迁移的场景。Chroma 胜在轻量,适合原型验证;Qdrant 的过滤查询做得好,但你需要自己维护服务,且社区版能力有上限;Milvus 用 Docker 就能起一套 standalone 服务,自带数据持久化和类型完善的索引策略。
| 维度 | Milvus | Chroma | Qdrant |
|---|---|---|---|
| 部署方式 | Docker / 分布式中转 | 嵌入式直接调 | 自建服务 |
| 数据容量扩展 | 可横向扩到分布式 | 单机为主 | 单机到小集群 |
| 索引类型支持 | HNSW / IVF / DiskANN | HNSW | HNSW |
| 过滤查询性能 | 中等偏上 | 较弱 | 强 |
| 适合本场景程度 | 高 | 原型期 | 中型数据量 |
聊天记忆这种场景的特点是:写入频繁、单条数据小(几百到几千字)、读取时对最近相关内容敏感。Milvus 的 HNSW 索引在 100 万级向量下依然能保持毫秒级召回,对一个内测阶段的聊天应用来说余量很足。而且 Milvus 的 collection 支持标量字段和向量字段共存,我可以把会话 ID、角色、时间戳作为标量字段存进去,检索时用表达式先过滤再向量搜索,这是 Chroma 那类纯向量库不太方便做的事。
3.2 Collection 设计:字段、类型与索引参数
Milvus 的 Collection 相当于传统数据库的表,字段结构在创建时定死。这个聊天应用里我建议建一个叫chat_memory的集合,核心字段如下。
try (MilvusClientV2 client = new MilvusClientV2( MilvusServiceClientConfig.builder() .uri("http://localhost:19530") .build())) { CreateCollectionReq.CollectionSchema schema = CreateCollectionReq.CollectionSchema.builder() .collectionName("chat_memory") .addField(FieldSchema.builder() .name("id").dataType(DataType.VarChar).maxLength(64).isPrimaryKey(true).build()) .addField(FieldSchema.builder() .name("session_id").dataType(DataType.VarChar).maxLength(64).build()) .addField(FieldSchema.builder() .name("role").dataType(DataType.VarChar).maxLength(16).build()) .addField(FieldSchema.builder() .name("content").dataType(DataType.VarChar).maxLength(8192).build()) .addField(FieldSchema.builder() .name("embedding").dataType(DataType.FloatVector).dimension(1024).build()) .addField(FieldSchema.builder() .name("create_time").dataType(DataType.Int64).build()) .build(); RegisterIndexReq registerReq = RegisterIndexReq.builder() .collectionName("chat_memory") .indexType(IndexType.HNSW) .metricType(MetricType.COSINE) .fieldName("embedding") .indexName("idx_embedding") .extraParam("{\"M\":16,\"efConstruction\":128}") .build(); client.registerIndex(registerReq); }id为主键,用 UUID 字符串,长度 64 够用。role存 user 或 assistant,方便以后按角色追溯。content是原始文本,最多 8192 字符——如果你要用超长上下文模型,可以放宽到 16384,但注意 VarChar 长度必须小于等于 65535,这是 Milvus 的硬约束。embedding的dimension必须和你的嵌入模型返回的向量长度一致,我用的是 1024 维,如果你换模型,这个数字要跟着变,否则写入就会报错。
索引参数里M表示 HNSW 图每个节点的最大连接数,16 是召回率和内存的折中点;efConstruction控制建图时候选集大小,128 在亿级数据量下能保证不错的建图质量。COSINE相似度度量适合文本向量,IP适合未经归一化的向量,这里别选错。
3.3 写入流程:对话落库与向量化
每次对话结束后,把这轮消息连同它的向量一起写入 Milvus,是记忆层能工作的前提。写入前的关键一步是调用嵌入模型把文本变成向量数组。
EmbeddingModel embeddingModel; // 由 Spring AI Alibaba 自动装配 MilvusClientV2 milvusClient; public void saveMessage(String sessionId, String role, String content) { // 1. 生成向量 float[] vector = embeddingModel.embed(content).stream() .mapToDouble(Embedding::getValue) .collect(() -> new float[1024], (arr, d) -> arr[0] = (float) d, ...); // 简化写法:直接取首个嵌入结果 List<Embedding> embeddings = embeddingModel.embed(content); float[] embedding = embeddings.get(0).getValue(); // 2. 组装插入数据 JSONObject row = new JSONObject(); row.put("id", UUID.randomUUID().toString()); row.put("session_id", sessionId); row.put("role", role); row.put("content", content); row.put("embedding", embedding); row.put("create_time", System.currentTimeMillis()); milvusClient.insert(InsertReq.builder() .collectionName("chat_memory") .data(List.of(row)) .build()); }注意嵌入模型返回的是List<Embedding>,单条文本通常只有第一个元素有值。写入是异步还是同步,取决于你的接口要不要等待落库完成;我一般在对话接口返回后异步执行写入,避免用户感受到额外延迟。这里有一个实践细节:先落文本、后补向量,还是同一批写入?我建议同批写入,因为 Milvus 的 upsert 覆盖逻辑要求主键一致,若是分开写,其中一个失败就会留下只有向量没有文本的僵尸记录。
3.4 检索流程:把历史记忆捞回Prompt
检索是记忆层的读路径。用户发起新问题时,先用同样嵌入模型把问题向量化,再去chat_memory搜索最相近的几条历史,最后把这些历史拼进 Prompt 的下文里。
public String buildContext(String sessionId, String userQuestion) { float[] queryVector = embeddingModel.embed(userQuestion).get(0).getValue(); SearchReq searchReq = SearchReq.builder() .collectionName("chat_memory") .data(List.of(queryVector)) .topK(3) .filter("session_id == \"" + sessionId + "\"") .outputFields(List.of("role", "content")) .build(); List<List<SearchResp.SearchResult>> results = milvusClient.search(searchReq); StringBuilder context = new StringBuilder(); for (SearchResp.SearchResult result : results.get(0)) { JSONObject entity = result.getEntity(); context.append(entity.getString("role")) .append(": ") .append(entity.getString("content")) .append("\n"); } return context.toString(); }topK取 3 是我反复调出来的平衡值:太少会漏掉关键上下文,太多会把过时信息也塞进 Prompt,既稀释注意力又浪费 token。filter用标量字段做预过滤,只搜同一个会话的历史,避免把其他用户对同一问题的回答也拉进来——这是聊天记忆场景里容易被忽略的隐私边界。把检索结果拼成role: content的文本块,作为 system 消息的前缀发给模型,模型就有了「我记得你之前说过什么」的效果。
4. Vue 3前端:SSE流式接收、Markdown渲染与会话状态管理
4.1 前端项目结构与依赖选择
前端部分我用了 Vite 工程的 Vue 3 单页应用。依赖项就三样:vue-router做会话页面路由,pinia做消息状态管理,marked加highlight.js做 Markdown 和代码高亮渲染。聊天界面不需要引入重型 UI 组件库,自己写消息列表的样式反而更容易控制流式渲染时的滚动行为。
npm create vite@latest chat-front -- --template vue cd chat-front npm install pinia marked highlight.js项目结构上,我建议把 SSE 逻辑抽到src/api/chat.js,把流式解析的细节封装成一个postChatStream()函数,组件里只关心回调。这样有三个好处:组件层不碰EventSource那套协议,单元测试可以单独测解析逻辑,以后要换成 WebSocket 传输也只改这一个文件。
4.2 fetch流式读取:核心解析逻辑
SSE 在浏览器里有两个实现路径。EventSource只支持 GET 请求,传不了自定义头和请求体,对话场景基本用不上;所以我用fetch配ReadableStream手动读流。
export async function postChatStream(messages, onDelta, signal) { const resp = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages }), signal }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 按 SSE 事件边界切割:每个事件以换行分隔 let eventEnd; while ((eventEnd = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, eventEnd); buffer = buffer.slice(eventEnd + 2); const dataLine = rawEvent .split('\n') .find(line => line.startsWith('data:')); if (dataLine) { const payload = dataLine.slice(5).trim(); if (payload === '[DONE]') return; onDelta(payload); } } } }这段代码的核心在于buffer缓冲区的设计。流式传输中,一个事件可能被 TCP 拆成两个 chunk 到达,也可能两个事件挤在一个 chunk 里,所以必须攒到\n\n才算一个完整事件。TextDecoder带{ stream: true }告诉解码器「后面可能还有数据」,这样多字节中文在跨 chunk 被切半时,解码器会等到下一个 chunk 再补全,而不是直接输出替换符。
data:前缀后面才是真正的模型输出,按行过滤startsWith('data:')是为了跳过 SSE 的注释行和空行。后端如果输出 OpenAI 风格的结束标记[DONE],前端在这里提前 return,消息列表就能准确进入完成态。
4.3 Markdown渲染:流式增量不能整体重绘
聊天应用必然要支持代码块和列表,但流式渲染有个性能陷阱:每收到一个 delta 就把整段 Markdown 重新 parse 一遍,消息一长就会卡。
import { marked } from 'marked'; import hljs from 'highlight.js'; marked.setOptions({ highlight(code, lang) { if (lang && hljs.getLanguage(lang)) { return hljs.highlight(code, { language: lang }).value; } return hljs.highlightAuto(code).value; } }); // 组件内增量更新 function handleDelta(payload) { currentText.value += payload; renderedHtml.value = marked.parse(currentText.value); }这里我用了最简单但有效的策略:文本增量累积,HTML 全量重渲染。消息长度一般几百到几千字,marked 的 parse 耗时在几毫秒级别,完全够用。真正要注意的是highlight回调必须提前配好,否则代码块在流式渲染过程中会先显示成纯文本,闪烁一下再变成高亮样式,观感很廉价。
如果消息超过 1 万字,再考虑每 20 帧节流一次渲染,或者用虚拟滚动只渲染可视区域。这个阈值我是在压测时发现的:超过 1 万字的 Markdown 渲染会让 UI 线程掉帧到 30fps 以下。
4.4 Pinia状态管理:会话与消息的清晰分层
消息状态我用 Pinia 管理,核心是一个messages数组和一个streaming标志位。数组元素结构固定为{ id, role, content, status },其中status标记是 complete 还是 streaming。流式过程中,增量内容直接写进当前 assistant 消息的content字段,Vue 3 的响应式系统会自动驱动视图更新。
export const useChatStore = defineStore('chat', { state: () => ({ sessionId: null, messages: [], streaming: false }), actions: { async sendMessage(text) { this.streaming = true; this.messages.push({ id: crypto.randomUUID(), role: 'user', content: text, status: 'done' }); const assistantId = crypto.randomUUID(); this.messages.push({ id: assistantId, role: 'assistant', content: '', status: 'streaming' }); await postChatStream(text, (delta) => { const msg = this.messages.find(m => m.id === assistantId); if (msg) msg.content += delta; }); const msg = this.messages.find(m => m.id === assistantId); if (msg) msg.status = 'done'; this.streaming = false; } } });streaming标志位不只是给按钮用,它还控制输入框的禁用以防止用户在模型回复过程中连发多条消息——不控制的话,多条流同时写同一个 assistant 消息,状态直接乱掉。crypto.randomUUID()在浏览器端生成消息 ID 足够唯一,不需要联调后端。会话的持久化我没做,刷新页面就清空消息数组;要落地的话,把sessionId存到 localStorage,刷新后按会话 ID 从 Milvus 拉历史重建消息列表,这正好和我们后端的检索查询闭环。
5. 联调避坑:Windows安装Milvus、SSE解析与维度对齐的五条血泪记录
5.1 Milvus容器反复重启,19530端口连不上
现象:Windows 上通过 Docker Desktop 启动 milvus standalone 后,容器日志显示 etcd 或 MinIO 报错退出,docker ps看到容器一直处于 restarting,程序连localhost:19530直接超时。 原因:Milvus standalone 服务对内存的隐性要求很高,etcd 和 MinIO 同时驻留时,WSL2 默认分配的内存在 8GB 以下的机器上很容易触发 OOM,容器被杀掉后又自动重启。 解决:在项目根目录的.wslconfig里调大内存上限到 10GB,同时用docker logs milvus-etcd确认日志里没有failed to allocate memory字样。我一般还会去掉 Docker Desktop 的「基于文件压缩镜像」选项,这个选项在某些版本下会显著加大 CPU 占用。除此之外,Milvus 也提供了不用 Docker 的纯本地模式milvus-lite,适合只需要跑测试的场景,但别指望它支撑你后续的大数据量验证,正式环境还是要回到 standalone。
5.2 Flux返回在前端整体到达,没有流式效果
现象:后端的 stream 接口在浏览器 Network 面板里能看到响应分块,但前端等了几秒一次性拿到完整文本,和同步接口体验一样。 原因:@PostMapping上没写produces = TEXT_EVENT_STREAM_VALUE,Spring 把 Flux 序列化成了application/json数组,前端 fetch 读完整个 body 才能处理。 解决:给接口显式声明produces = MediaType.TEXT_EVENT_STREAM_VALUE。另外,如果你在生产环境用了 Nginx 反代,记得关掉proxy_buffering或者设置X-Accel-Buffering: no响应头,否则 Nginx 会把后端发来的 SSE 小包攒成一个大响应再送出。排查这类问题,先看 Network 面板响应头的Content-Type是不是text/event-stream,再确认 Nginx 配置,两步就能定位。
5.3 前端用JSON.parse解析SSE报错
现象:前端收到的流式内容是 OpenAI 风格的data: {"text":"你好"},你直接对每个 chunk 执行JSON.parse,第一次能解析,第二次就报Unexpected end of JSON input。 原因:SSE 事件是流式到达的,一个 JSON 对象很可能被拆成两个网络 chunk,你在事件边界未形成前就解析了不完整的 JSON。 解决:回到第 4 章的buffer方案,先把收到的字节攒在缓冲区,按\n\n找到完整事件边界,再对事件内的data:行做解析。记住一个原则:SSE 的组装单位是事件,不是网络包;凡是基于data的解析都必须在事件边界上执行。我在一次联调中看到有人把buffer.indexOf('\n\n')写成了buffer.length > 0,整个流式逻辑直接退化成逐字符乱蹦,所以边界判断一定要严格。
5.4 中文乱码与半个字符的替换符
现象:前端流式打印的消息里出现�这样的替换字符,而且它总是出现在句尾,下一段增量会补一个正常字回来。 原因:UTF-8 编码下,一个中文字符占 3 字节。后端 SSE 包比较小时,一个完整中文可能被拆到两个 chunk 里,TextDecoder.decode如果没给stream: true,会在每个 chunk 结尾自作主张把半个字符解码成替换符。 解决:new TextDecoder('utf-8', { stream: true })是关键,stream模式会保留不完整的字节序列等下一个 chunk 到达再解码。我在代码里索性默认加上fatal: false,保证即便遇到异常字节也不会让整个渲染线程挂掉。另一个辅助手段是在后端把 SSE 的发送缓冲调成 1024 字节以上,减少分包频率,但不能依赖它,因为 TCP 分包不由应用层完全控制。
5.5 Milvus报查询维度不一致,插入直接失败
现象:调用milvusClient.search时报错query vector dimension mismatch,检查代码发现嵌入模型返回的是 1024 维数组,但建 Collection 时dimension(1536)。 原因:通义千问的text-embedding-v3默认输出 1024 维,而你可能之前在别的项目里用过 OpenAI 的 1536 维模型,照着老代码的配置填了 dimension。 解决:两个层面的办法。第一,在建 Collection 前先写一行校验脚本,把嵌入模型的输出长度打印出来,System.out.println(embedding.length),确保和 schema 的dimension一致。第二,在所有嵌入配置里显式声明文本向量化模型text-embedding-v3,不要依赖框架默认。我后来在项目里加了一个启动时自检组件,加载模型后自动把维度写到配置中心,Milvus 建表和后面查询都用同一个来源,这个错就再也没出现过。
6. 进阶:让记忆层同时服务RAG知识库与NL2SQL
聊天应用跑通之后,Milvus 的价值还可以放大一大截:同一个 Collection 稍作改造,就能变成私域知识库的检索底座。做法是把知识文档按 500~800 个字符切块,每一块转成向量写入一个新的knowledge_baseCollection,用户提问时先在这个 Collection 里做 TopK 检索,命中段落拼进 system prompt。这样对话时的chat_memory管「记性」,knowledge_base管「知识」,两边互不干扰。
NL2SQL 是 Spring AI Alibaba 场景里很实用的一条延伸路。核心思路是给模型一个带表结构信息的 system prompt,让它把用户的中文问题转成 SQL,再把 SQL 拿到数据库执行,最后把查询结果以自然语言回答。
system: 你是数据库助手。下方是表结构: CREATE TABLE orders ( id INT, user_id INT, amount DECIMAL(10,2), created_at DATETIME ); 只输出可执行的 PostgreSQL SQL,不要输出任何解释。 user: 上个月每笔超过500元的订单共多少单? assistant: SELECT COUNT(*) FROM orders WHERE created_at >= '2025-01-01' AND created_at < '2025-02-01' AND amount > 500;上一段借助了 Prompt 约束实现的 NL2SQL 最容易踩的坑是模型会生成多个候选 SQL 或者附带注释。我在落地时会在解析侧做一层收口:只提取输出里以SELECT开头的完整语句,其余内容直接丢弃。这块如果要用项目自带的调用配置实现类似效果,Prompt 同样是最可控的切入方式,之后再配合校验器做二次确认,能挡住大部分异常的 SQL 生成结果。
在我后来反复搭这类应用时,发现凡是能稳定运行的方案,都在关键路径上加了类似「启动时校验维度」「事件边界切割」「Content-Type 显式声明」的防御动作。从那以后,我每次联调聊天应用,都会强制走一遍后端流式接口测试、前端缓冲区单测、Milvus 数据落库与召回比对这三步,再放人进来用。希望这篇拆解能帮你把中间这些坑绕过去,少走几趟弯路,顺利把整套链路跑通。
本文还有配套的精品资源,点击获取