拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring AI Alibaba + Milvus + Vue 3 实战:搭建完整 AI 聊天应用链路

Spring AI Alibaba + Milvus + Vue 3 实战:搭建完整 AI 聊天应用链路

简介:面向Java后端与Vue前端开发者的AI聊天应用完整工程资源,基于Spring AI Alibaba、Milvus向量数据库和Vue 3构建,支持DeepSeek、SiliconFlow、Gemini、阿里云百炼等多模型接入,并集成RAG检索增强生成,适合需要搭建智能对话系统或为现有应用添加知识库问答能力的开发者参考。资源共2000个文件,以JavaScript、JSON、Markdown、Java、Vue、TypeScript等为主,涵盖前后端源码、环境配置、依赖清单与说明文档,压缩包约15.77MB,结构清晰便于按模块查阅。目前已吸引162人学习下载。内容包含技术栈说明、快速开始步骤、API接口定义、配置细节、前端特性、RAG功能实现、项目结构解析、新增模型方法以及常见问题排查思路,从部署到二次开发均有覆盖。参照此工程可快速复刻具备多模型切换与向量检索增强的聊天应用,减少环境搭建和接口联调成本,是学习Spring AI生态与RAG落地的实用素材。

1. 这就是一条完整的 AI 应用链路:Spring AI Alibaba + Milvus + Vue 3

先说结论:这个标题不是把三个开源组件拼在一起做个 Demo,而是一条完整的 AI 应用落地链路。Spring AI Alibaba 负责把应用和各个大模型对接起来,提供统一的 ChatClient 接口和对话管理;Milvus 负责给应用外挂“长期记忆”和“私有知识库”,解决大模型上下文窗口有限、无法感知私域数据的问题;Vue 3 负责把这一切变成用户能直接用的聊天界面。三者各管一段,组合起来就是一个能让用户连续对话、并且能基于你自有文档回答问题的 AI 聊天应用,而不是那种问一句忘一句的玩具。

这套组合现在被频繁提起,是因为它回答的是 AI 应用开发里最实际的两个问题:后端怎么稳定地对接大模型,前端怎么把流式响应渲染得像 ChatGPT 一样顺滑。对于做 Java 后端、又不想写 Python 微服务的团队,Spring AI Alibaba 是当前最顺手的切入点;对于需要语义检索、要管百万级向量的场景,Milvus 是比 Chroma 和 Qdrant 更偏生产环境的选项。本文会把整条链路拆开,从后端服务端、向量数据库、前端接入到部署排查,一步步讲清楚怎么跑通、参数怎么调、哪里最容易翻车。

2. Spring AI Alibaba 接入层:打通大模型与业务代码的桥梁

2.1 为什么要用 Spring AI Alibaba 而不是直接调 HTTP 接口

直接用 HTTP 调用大模型接口当然能跑通,但一旦进入真实项目,这件事会迅速变得难维护。你至少会碰到:多个模型之间要切换测试、系统提示词要统一管理、对话历史要能被框架自动整理、流式输出要一行行解析。自己做这些不是不行,但是每换一家模型厂商就要重写一遍,维护成本极高。

Spring AI Alibaba 解决的是“模型接入和切换的标准化”问题。它借鉴了 Spring 生态一贯的思路:把差异封装在框架里,给业务代码暴露统一接口。你只需要配置不同的模型供应商参数,业务代码里始终面对同一个 ChatClient 对象。这个设计和 Spring Boot 的自动装配高度契合,Java 开发团队上手成本明显低于引入一堆 Python SDK。

我在实际项目中用它做对话服务,最大的体感是:团队里不同人负责不同模型通道时,代码风格终于统一了。之前有人用 OkHttp 自己拼 JSON、有人用官方 SDK,代码评审时各看各的;切到 Spring AI Alibaba 之后,大家都对着同一个 ChatClient 写,业务逻辑不再被厂商 SDK 的差异打断。

2.2 从零搭建后端服务:最小可用的 Spring Boot 工程

先建工程。常见的做法是在 start.spring.io 选好 Spring Boot 3.x 和 Java 17 及以上的基础依赖,然后手动引入 Spring AI Alibaba 的 BOM 和对应模块。有一点要提前明确:Spring AI 的模块名沿用了 Spring 经典的 starter 命名风格,大模型相关模块叫 spring-ai-alibaba-starter,用起来和 Spring Data、Spring Security 的体验一致。

下面的pom.xml是一个最小可用的配置,覆盖了 Web 服务和 Spring AI Alibaba 的接入:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.5</version> <relativePath/> </parent> <properties> <java.version>17</java.version> <spring-ai-alibaba.version>1.0.0</spring-ai-alibaba.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud.ai</groupId> <artifactId>spring-ai-alibaba-starter</artifactId> <version>${spring-ai-alibaba.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

这里要说明两点。第一,Spring Boot 3.2.x 是当前比较稳妥的基线,Spring AI Alibaba 官方对 Spring Boot 3.2 的适配最成熟,升级到 3.3 或 3.4 也可以,但遇到兼容性问题时第一件事就是检查 Spring Boot 版本和 Spring AI 版本的对应关系。第二,spring-ai-alibaba.version这个版本号不要看网上博客抄,必须以你拉到的实际版本为准,不同版本之间的 API 有差异,尤其是 ChatClient 的构造方式变化较快。

2.3 配置模型供应商:application.yml 里的关键项

Spring AI Alibaba 的核心价值之一是同时支持多种模型供应商的接入,包括通义千问、DeepSeek 等,且可以通过配置切换。以我在项目中常用的一套配置为例:

spring: application: name: ai-chat-service ai: alibaba: # 模型供应商的密钥只在本地开发时放配置文件 # 部署到服务器时必须换成环境变量注入 api-key: ${DASHSCOPE_API_KEY:} base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 chat: options: model: qwen-plus temperature: 0.7 max-tokens: 2048 server: port: 8080

两个点需要展开。temperature控制生成随机性,做聊天助手 0.7 是比较中庸的值;如果做的是知识库问答,我会调到 0.2 以下让回答更稳定、更少编造。max-tokens不是越大越好,2048 在多数聊天场景够用,token 太大不仅费用更高,首字延迟也会明显变慢,因为模型在真正返回第一个字之前要做大量计算。如果你发现用户问长文档时回答被截断,优先的做法是分段检索而不是硬调max-tokens。

api-key配置这里要单独强调:我在交付项目时见过太多人把密钥直接写在application.yml里提交到 Git 仓库。正确做法是配置文件里写${DASHSCOPE_API_KEY:}这种占位符,实际密钥通过环境变量注入。

2.4 写一个立即能跑的 Controller:同步与流式两个版本

核心交互代码分为同步和流式两种。同步版本适合内部调试,用户点完按钮等结果就好;流式版本适合真实聊天界面,文字要一个字一个字往外蹦,体验接近主流 AI 聊天产品。先看一个同步版本的示例:

@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @PostMapping("/sync") public String syncChat(@RequestBody ChatRequest request) { // 调用大模型,等待完整响应返回 return chatClient.call(request.message()); } }

这段代码背后的逻辑是:ChatClient.Builder是 Spring AI Alibaba 自动注入的工厂类,通过它构建的ChatClient实例自动携带了application.yml里的模型配置。chatClient.call()是阻塞调用,方法返回前用户会一直等待,适合没有 UI 的接口联调。

流式版本是实际聊天应用的主路径,代码如下:

@PostMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8") public Flux<String> streamChat(@RequestBody ChatRequest request) { return chatClient.prompt() .user(request.message()) .stream() .content() .map(content -> "data:" + content + "\n\n"); }

这里有两个值得说透的技术点。第一,返回值类型用了Flux<String>,这是 Project Reactor 的响应式类型,表示“随时间持续产生多个字符串”,Spring MVC 的异步方法支持直接返回它,然后由框架把数据一块块写给浏览器。第二,text/event-stream;charset=UTF-8是 SSE(Server-Sent Events)协议的标准响应类型,前端 JS 里的EventSource或者fetch流式读取都靠这个 MIME 类型识别。每一块输出前面加上data:前缀、后面跟上两个换行,这是 SSE 协议规定的消息帧格式,前端解析时按这个格式拆分。

2.5 记忆从哪里来:用 MessageWindow 实现多轮对话

ChatClient的单次调用是无状态的,每条消息都是独立的请求,模型记不住前几轮聊了什么。要做出真正的聊天体验,必须在代码里显式管理历史消息。Spring AI Alibaba 把这个问题封装成了 MessageWindow,用法非常直接:

@PostMapping("/chat-with-memory") public String chatWithMemory(@RequestBody ChatRequest request) { return chatClient.prompt() .user(request.message()) .advisors(MessageWindowChatMemory.builder() .windowSize(10) .build()) .call() .content(); }

这段代码解决的是多轮对话的上下文保持问题。MessageWindowChatMemory会在内存里维护一个滑动窗口,保存最近 10 轮的用户消息和助手回复,每次调用时自动把这些历史消息拼接到 prompt 里一起发给模型。窗口大小 10 是经验值:太小了对话记不住事,太大了既浪费 token 又让模型注意力分散。内存窗口意味着应用重启后聊天记录全部清空,如果要做持久化记忆,需要外接 Redis 或数据库,这正好是后面 Milvus 要承担的角色之一。

3. Milvus 接入:给聊天应用装上长期记忆和知识库

3.1 三种向量数据库怎么选:Milvus、Chroma、Qdrant 的边界

市场上被频繁提到的开源向量数据库主要是 Milvus、Chroma 和 Qdrant,三者的定位差异很大。如果你的数据量在几十万条以内、本机开发用、不想折腾部署,Chroma 是上手成本最低的,pip 装完直接当普通库用。Qdrant 的 Rust 核心性能好,单机部署简单,Docker 起一个容器就够了,适合中小规模生产环境。

Milvus 则是为更大规模设计的分布式向量数据库,它的核心优势在于存算分离架构:存储用对象存储,计算节点可以独立扩容,单机模式下也能通过配置推进到集群模式。在真实的 RAG 项目里,如果向量规模到百万级以上,或者有高并发检索需求,Milvus 的成熟度明显领先。选型的另一个实际考量是运维能力:Milvus 单机版依赖 etcd 和 MinIO 两个组件,光这一点就比 Chroma 重得多,所以如果你只有一台 2C4G 的服务器,我不建议上 Milvus。

3.2 Windows 本机安装 Milvus:Docker Compose 是唯一推荐路径

很多人在 Windows 上折腾 Milvus 源码编译,撞得头破血流。实际上 Milvus 官方根本不支持 Windows 原生安装,正确路径是把 Docker Desktop 装好,然后用 Docker Compose 拉起整套依赖。这里给出一个可用的 compose 配置,涵盖 etcd、MinIO 和 Milvus 三个核心组件:

version: '3.5' services: etcd: image: quay.io/coreos/etcd:v3.5.5 environment: ETCD_AUTO_COMPACTION_MODE: revision ETCD_AUTO_COMPACTION_RETENTION: '1000' ETCD_QUOTA_BACKEND_BYTES: '4294967296' command: etcd -advertise-client-urls=http://127.0.0.1:2379 -listen-client-urls=http://0.0.0.0:2379 --data-dir /etcd volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd minio: image: minio/minio:RELEASE.2023-03-20T03-26-10Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin command: minio server /minio_data --console-address ":9001" ports: - "9000:9000" - "9001:9001" volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data milvus: image: milvusdb/milvus:latest command: ["milvus", "run", "standalone"] environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 ports: - "19530:19530" - "9091:9091" volumes: - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus depends_on: - etcd - minio

这个配置里最容易被忽略的是depends_on和启动顺序问题。Milvus 启动时会连接 etcd 和 MinIO,如果这两个依赖还没就绪,Milvus 会启动失败。但depends_on只保证服务启动顺序,不代表依赖已健康,所以第一次启动失败很常见。解决办法是启动后观察日志,等 etcd 和 MinIO 真正就绪后再执行docker compose up里的 Milvus 容器,或者干脆重启一次 Milvus 服务。

运行起来后,还需要装 Python SDK 来操作 Milvus,这是后续所有数据写入和检索的基础:

pip install pymilvus

pymilvus是 Milvus 的官方 Python 客户端,我们后续创建 collection、写入向量、做相似度检索都通过它完成。如果你更喜欢 RESTful API 风格,Milvus 也提供了一个独立的 Milvus HTTP 客户端,但 Python SDK 功能最全,社区案例也最多。

3.3 设计 Collection:向量维度、距离度量、索引类型怎么定

Milvus 里一个 collection 类似关系数据库中的一张表,表结构决定了检索效果和性能上限。创建一个名为chat_kb的 collection,用来存知识库文档切分后的向量,代码和注释如下:

from pymilvus import ( connections, CollectionSchema, FieldSchema, DataType, Collection ) # 连接到本机 Milvus 服务 connections.connect(host="127.0.0.1", port="19530") # 定义字段:主键、文本内容、来源文件、向量 fields = [ FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True), FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535), FieldSchema(name="source", dtype=DataType.VARCHAR, max_length=512), FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=1536), ] schema = CollectionSchema(fields=fields, description="知识库文档集合") # 创建 collection 并指定索引 collection = Collection(name="chat_kb", schema=schema) index_params = { "index_type": "IVF_FLAT", "metric_type": "COSINE", "params": {"nlist": 128} } collection.create_index(field_name="embedding", index_params=index_params) print("Collection created:", collection.name)

这里有几个关键参数值得展开说明。

dim=1536不是随便写的。它必须和你使用的 Embedding 模型的输出维度严格一致,不一致时 Milvus 会直接报错。用 OpenAI 的text-embedding-3-small或通义千问的text-embedding-v3,输出维度通常是 1536 或 1024,取决于你在 Embedding 接口里的配置和模型的默认设置。

metric_type选择了COSINE而不是欧式距离L2。两者在数学上都可用,但语义检索场景下 COSINE 效果更稳定,因为它只关注向量的方向而非长度,对文本长短差异不那么敏感。如果你的 Embedding 模型本身已经做了 L2 归一化,用L2和COSINE结果等价,此时选L2检索性能更高。

index_type选了IVF_FLAT,它适合数据量在百万到千万级别的中等规模场景。nlist是聚类中心数量,128 是常见值;检索时nprobe(探测的聚类中心数)越大,召回越好但速度越慢。实际调优时一般从nprobe=8起步,根据召回率和延迟反向调整。

3.4 写入与检索:从文档切分到召回

数据写入部分只讲一个最常见的路径:文档切分、向量化、批量插入。完整链路依赖 LangChain 的文档加载和切分能力,代码示例如下:

from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_community.embeddings import DashScopeEmbeddings # 1. 加载文档 loader = TextLoader("knowledge.txt") documents = loader.load() # 2. 切分文档,chunk_size 和 chunk_overlap 是关键参数 text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=100, ) chunks = text_splitter.split_documents(documents) # 3. 向量化并准备插入数据 embeddings = DashScopeEmbeddings(model="text-embedding-v2") for chunk in chunks: vector = embeddings.embed_query(chunk.page_content) # 实际批量插入时,把 vector 和 content 攒成列表一次 insert

chunk_size=500, chunk_overlap=100是文本检索场景里的一组常用初始参数。500 意味着每段文本约 500 个字符,既不会因为太短导致信息不完整,也不会因为太长导致向量表征模糊。chunk_overlap让相邻片段之间有 100 字符的重叠,目的是避免某个关键句子恰好被切断在边界上而完全丢失。

插入数据后,检索是用户提问时的读路径。Milvus 的search接口是核心,它的参数直接影响最终答案质量:

collection.load() search_params = { "metric_type": "COSINE", "params": {"nprobe": 10}, } results = collection.search( data=[query_vector], anns_field="embedding", param=search_params, limit=5, output_fields=["content", "source"], )

limit=5表示每次召回 5 条最相似的文本片段。这个数字不要贪多,LLM 的 prompt 窗口有限,塞进去太多片段既稀释注意力,又浪费 token 费用。output_fields指定返回哪些字段,这里把原文和来源都带回,方便拼进 prompt 时给模型附上引用来源。

3.5 从 Milvus 召回结果到模型回答的组装

召回本身不是目的,让模型基于召回内容回答才是目标。之前很多代码把召回结果直接拼字符串发给大模型,效果不稳定。我实际项目里用的 prompt 模板有以下结构:

String prompt = """ 你是知识库问答助手。严格基于以下资料回答用户问题。 如果资料中没有答案,直接说“根据现有资料无法回答”,不要编造。 资料: {context} 用户问题:{question} """;

这个模板里有三个要点。第一,明确告诉模型“不要编造”,这是抑制 RAG 幻觉最有效的简单手段,实测比不写这句的回答准确率高不少。第二,把召回的片段按相关性顺序排列,最相关的放前面,因为模型对 prompt 前面的内容注意力更强。第三,temperature要设低,建议 0.2 以下,否则模型在压力下更容易自由发挥,脱离给定资料。

我把上述逻辑封装在 Java 的 service 层里,结构大致是:接收用户问题 → 调 Embedding 接口得到查询向量 → 查 Milvus 召回 top5 → 拼 prompt → 调大模型。这是一条经典的 RAG 链路,也是这套 AI 聊天应用和普通聊天框最核心的差异。

4. Vue 3 前端接入:从消息列表到流式渲染的完整实现

4.1 前端项目怎么搭:Pinia 管状态、Axios 管请求

前端的技术栈是 Vue 3 的组合式 API 加 Pinia 状态管理,这是当前比较标准的一套组合。Pinia 负责维护消息列表、当前会话状态,Axios 负责和后端打交道。初始化项目用一个 Vite 脚手架就够了,然后在 store 里定义消息相关的状态。

下面给出消息 store 的核心逻辑:

// stores/chat.js import { defineStore } from 'pinia' export const useChatStore = defineStore('chat', { state: () => ({ messages: [], isStreaming: false }), actions: { pushMessage(role, content) { this.messages.push({ role, content, id: Date.now() }) } } })

这里把消息数组和流式状态都收敛到了统一的位置。isStreaming这个标记在用户连续提问时很重要,可以防止用户在上一轮还没答完时又点发送按钮,造成请求乱序。实际项目中我会再加一个sessionId字段,后端按会话维度管理消息记录,方便后续做历史会话恢复。

4.2 配置 Axios 拦截器处理 SSE 流式响应

调用后端流式接口是前端最关键的编码环节。常规的 Axios 请求拿到的是完整响应体,但 SSE 流要求边读边解析。下面这段代码是前端实现流式对话的推荐写法:

// api/chat.js import axios from 'axios' const apiClient = axios.create({ baseURL: '/api', timeout: 60000 }) export async function streamChat(message, onChunk) { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, body: JSON.stringify({ message }) }) const reader = response.body.getReader() const decoder = new TextDecoder('utf-8') while (true) { const { done, value } = await reader.read() if (done) break const chunk = decoder.decode(value, { stream: true }) onChunk(chunk) } }

为什么要用fetch而不用 Axios 的 response stream?因为 Axios 早期版本对浏览器原生流式读取的支持不完整,而 fetch API 的getReader()方法天生就是为流式场景设计的,能拿到底层字节流然后逐块解码。TextDecoder的{ stream: true }选项解决的是多字节字符被分块切断的问题——如果这里不带这个参数,一个中文汉字可能会被切成两半导致乱码。

后端返回的 SSE 数据格式是data: {"message": "你好"}\n\n这种帧结构,前端onChunk回调里需要先按空行切帧,再去掉data:前缀得到 JSON 字符串,解析后追加到消息列表末尾。

4.3 前端组件拆分:会话列表、消息区、输入框三块

组件层面我把页面拆成三个区域:左侧会话列表、中间消息滚动区、底部输入框。消息滚动区是坑最多的地方,核心代码和注意点如下:

<template> <div ref="messageContainer" class="message-list" @scroll="onScroll"> <div v-for="msg in messages" :key="msg.id" class="message-item"> <div :class="['bubble', msg.role === 'user' ? 'user' : 'assistant']"> {{ msg.content }} </div> </div> <div v-if="isStreaming" class="streaming-cursor"></div> </div> </template> <script setup> import { ref, watch, nextTick } from 'vue' const messageContainer = ref(null) function scrollToBottom() { if (messageContainer.value) { messageContainer.value.scrollTop = messageContainer.value.scrollHeight } } // 学习用户是否正在上翻查看历史 let userScrollingUp = false function onScroll() { const el = messageContainer.value userScrollingUp = el.scrollTop + el.clientHeight < el.scrollHeight - 80 } watch(() => messages.value.length, async () => { await nextTick() if (!userScrollingUp) { scrollToBottom() } }) </script>

这个组件有两个血泪经验。第一,消息列表更新后必须用nextTick等 DOM 真正渲染完再设置scrollTop,否则滚动位置总是停在旧位置。第二,用户正在向上翻看历史内容时,不要强制把视口拉到底部,否则用户阅读行为会被持续打断,体验反而更差。这里做了一个简单的判定:滚动位置离底部超过 80px 就暂停自动滚动。

4.4 停止生成与错误处理:AbortController 的正确用法

用户点击“停止生成”按钮是聊天应用的高频操作。调用方发起 fetch 后,后端如果没主动断开,前端必须有能力中断请求。实现方式是AbortController:

let abortController = null export async function streamChatWithAbort(message, onChunk) { abortController = new AbortController() try { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, body: JSON.stringify({ message }), signal: abortController.signal }) // 后续读取逻辑与之前相同 } catch (error) { if (error.name === 'AbortError') { console.log('用户中止了生成') } else { console.error('请求失败', error) } } } // 停止按钮的处理函数 export function stopGeneration() { if (abortController) { abortController.abort() } }

AbortError 是用户主动取消的正常结果,不是错误,这里要区分处理。另一个实际场景是余额不足或限流,后端返回的可能是 429 状态但 SSE 流正常建立,前端的onChunk里可能收到一条带错误信息的帧。我一般在解析帧时先尝试解析 JSON,判断里面有没有 error 字段,有就直接弹提示。

4.5 渲染效果优化:Markdown 渲染与代码高亮

大模型返回的内容通常是 Markdown 格式,直接把文本渲染成纯文本会让用户以为是 Bug。Vue 3 里常用的方案是markdown-it配合highlight.js做代码高亮,注意一个是 XSS 风险点。下面给出一个封装思路:

// utils/markdown.js import MarkdownIt from 'markdown-it' import hljs from 'highlight.js' const md = new MarkdownIt({ html: false, // 关闭原始 HTML 渲染,防止 XSS linkify: true, highlight(str, lang) { if (lang && hljs.getLanguage(lang)) { return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang, ignoreIllegals: true }).value}</code></pre>` } return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>` } }) export function renderMarkdown(text) { return md.render(text) }

html: false必须关。大模型有可能在回答里携带<script>标签或带onerror属性的图片标签,直接渲染成 HTML 等于把 XSS 攻击面暴露给用户。关闭后 MarkdownIt 会把原始 HTML 转义为文本展示,安全性提升一个量级。为了节省首次加载时间,highlight.js可以用按语言的子集引入,只加载常用的python、javascript、bash、java等,而不是 loading 全量。

5. 全链路联调避坑:从环境问题到数据问题的排查记录

5.1 Milvus 在 Windows Docker Desktop 下容器反复重启

现象:docker compose up后 Milvus 容器启动后几十秒就自动退出,日志最后几行提示连接 etcd 超时。

原因:这是 Windows Docker Desktop 最常见的翻车现场。Milvus 启动时依赖的 etcd 和 MinIO 服务因磁盘 IO 或者镜像拉取未完全就绪,而 Milvus 的启动脚本只做了有限次重试,超时后直接 panic 退出。此外 Docker Desktop 的虚拟磁盘 IO 性能相比 Linux 本机有明显下降,组件启动速度变慢,放大了时序问题。

解决:先docker compose logs etcd确认 etcd 已正常监听 2379 端口,再docker compose logs minio确认 MinIO 就绪,最后单独重启 Milvus:docker compose restart milvus。如果反复出现,在docker-compose.yml的 milvus 配置里增加restart: always策略,并适当增加健康检查间隔。另一个有效做法是把milvusdb/milvus:latest固定为具体的 v2.x 版本标签,latest 标签在组件版本更新后更容易引入未知不兼容问题。

5.2 后端启动报错找不到 ChatClient 类型的 Bean

现象:Spring Boot 启动时抛出No qualifying bean of type 'ChatClient',堆栈里能看到ChatClient.Builder相关字样。

原因:引入的 starter 依赖没有触发自动装配,最常见的是 Spring AI Alibaba 的版本与 Spring Boot 主版本不匹配。Spring AI 的自动装配类通常放在spring.ai.autoconfigure包路径下,Spring Boot 扫描不到时会静默跳过,导致容器里根本没有 ChatClient 的候选 Bean。

解决:核对 pom.xml 里 Spring Boot 父版本和 Spring AI Alibaba BOM 版本的对应关系,把两个版本对齐到官方 release 页面标注的兼容组合。还有一种情况是只引入了spring-ai-alibaba-starter但没有引入 Web 相关的依赖导致自动装配条件不满足,重新加上spring-boot-starter-web后清理 Maven 本地仓库缓存并重新导入。

5.3 流式接口在 Postman 里正常,前端收到乱码

现象:用 Postman 调用/api/chat/stream,返回内容正常;但 Vue 前端里收到的中文内容偶尔出现乱码或丢失半个字符。

原因:这是典型的字符编码和流读取双重问题。后端接口没有显式指定字符集时,SSE 的charset可能默认落到 ISO-8859-1。同时前端TextDecoder没有启用{ stream: true },多字节 UTF-8 字符在从 TCP 分块到达时被截断,前后两块的边界处便产生了乱码。

解决:后端@PostMapping的 produces 属性里写完整text/event-stream;charset=UTF-8,前端TextDecoder解码时传入{ stream: true }选项。这两个位置是必须一起修的,只改一边问题依旧。这里还有个玄学体验:乱码问题在有代理转发的环境里会出现得更频繁,因为反向代理有时会改写内容编码,排查时先绕开 Nginx 直连后端做对比。

5.4 知识库问答总答非所问,且用户查不到已有文档内容

现象:文档已经切分并写入 Milvus,但用户问题涉及的具体内容,模型回答“根据现有资料无法回答”。

原因:这个问题大多是 chunk 切分或召回策略造成的。chunk_size设置过大导致单个片段包含太多无关信息,向量表征被稀释;或者limit召回数量太少,真正相关的内容在排序后被切掉。还有一个容易被忽略的点:用户提问的表述方式与文档原文差异较大时,单路向量检索本身召回率就不够。

解决:先做检索侧调优。用 Milvus 的 Python SDK 直接写一段脚本,对同一问题分别尝试nprobe=4、8、16和limit=3、5、10的组合,观察score值变化。分数普遍低于 0.6 时,考虑换更强的 Embedding 模型,或者改用多路召回策略,比如混合关键词检索和向量检索。另一个实用技巧是把切分方式从固定chunk_size改为按文档章节和段落切分,保留标题结构,召回质量会有明显提升。

5.5 Vue 3 里 fetch 流式请求始终等不到任何数据

现象:前端调用streamChat后,长时间没有任何界面反馈,需要等整个响应结束才一下子全部显示。

原因:这是 SSE 和 fetch 流式读取的一个经典误解。后端没有真正以流式方式返回数据,而是把完整响应拼好后一次性发送;或者后端返回的是application/json而不是text/event-stream,fetch 拿到完整 body 后reader.read()只触发一次。另一个原因是 Nginx 或网关开启了响应缓冲,缓冲满了才把数据交给浏览器,表现为“卡顿后突然全部出现”。

解决:先用 curl 直接模拟请求并观察响应时间:curl -N --no-buffer -X POST http://localhost:8080/api/chat/stream -H "Content-Type: application/json" -d '{"message":"你好"}'。--no-buffer参数要求 curl 边收边打印。如果 curl 也是等待很久才输出,问题在后端或中间链路;如果 curl 正常而前端异常,检查 Nginx 的proxy_buffering配置,关闭缓冲即可。

5.6 多用户同时使用时,各自的 Milvus 数据互相干扰

现象:A 用户上传的文档,B 用户在提问时也能检索到其中内容,甚至回答里引用了别人的数据。

原因:collection 里没有按用户或租户维度隔离数据。之前创建 collection 时没有设计分区键,所有用户的文档向量混在一个 collection 和同一个分区里,检索时自然全局扫描。这个问题在 Demo 阶段不一定暴露,但一旦多用户联调就会立刻出现。

解决:给 collection 增加用户维度隔离字段,用 Milvus 的 partition 功能解决。每个用户对应一个 partition,检索时只搜该用户所在 partition,实现逻辑隔离。代码上需要在插入和检索时都指定partition_names参数。数据量更大的场景下,也可以直接用 Milvus 的标量字段过滤(类似 WHERE 条件)替代 partition,但 partition 的检索性能更好,二者选型的边界在于单个 partition 内数据规模是否仍然可控。

6. 高阶玩法:从检索效果评估到应用上线

6.1 评估召回质量:不靠感觉,用可量化的指标

知识库问答上线前必须做一次召回评估,否则上线后面对的问题不是“用户说不好用”这么简单,而是不知道哪里不好用。我把内部用过的评估方法整理成表:

指标含义达标建议
Recall@5正确答案出现在召回前 5 条中的比例不低于 80%
MRR正确答案在排序列表中的平均倒数排名不低于 0.6
首字延迟用户发送后到收到第一个字符的时间低于 2 秒
完整响应时间整段回答完全展示的时间低于 15 秒

如果 Recall@5 不足,优先调nprobe和chunk_size;MRR 不足说明排序有问题,考虑在 Milvus 检索结果上再接一个 rerank 步骤。常见做法是引入交叉编码器模型,把召回回来的 5 条候选逐条和问题拼接打分,再按分数重排,准确率提升很明显,代价是增加毫秒级延迟。

6.2 多租户场景下的数据隔离策略

单用户 Demo 可以直接把数据和记忆都存在默认 collection 里。但一旦做成 SaaS 产品,数据隔离是第一个要过的关。最简洁的做法是每个用户创建一个独立 collection,collection 名字带上用户 ID 后缀。这样用户数据物理隔离,连“查询时误召回别人数据”的可能性都不存在。

缺点是 collection 数量膨胀后,Milvus 的元数据管理开销变大;而且创建 collection 是个重量级操作,不适合用户在每次会话时反复创建。另一个折中方案是共享 collection,用标量字段(比如user_id)做过滤,写查询时固定带上过滤条件。两种方案我实测的分界线是:用户总量在几百级别用独立 collection,千级以上用共享 collection 加字段过滤,整套链路避免超出 Milvus 的管理规模。

6.3 上生产前的最后检查清单:凭经验圈的六个雷区

上线部署和本地跑通是两个世界,我总结为一张简洁清单,供团队交付前逐条过一遍。

第一,查看spring.ai.alibaba的日志级别是否配到可诊断的程度;第二,确认 Milvus 备份机制是否落地。Milvus 单机版跑在 Docker 里,卷挂载路径必须做定期快照或同步到对象存储,否则服务器磁盘损坏时整个知识库灰飞烟灭。第三,检查 Embedding 接口的限流配置,知识库导入阶段会高频调用,没有重试机制会导致导入中断。第四,确认大模型 API 密钥已从代码仓库彻底移除,Git 历史里有泄露的旧密钥也要轮换。第五,压测一次性并发 50 个用户提问,观察后端进程内存和 Milvus 的 CPU 使用率;Spring AI 的流式接口是长连接类型,需要确认网关的超时配置不会在长回答中途切断。第六,模型回答需要记录审计日志,至少保存用户问题、系统回答、召回片段来源和时间戳,未来做问题回溯时这是唯一的后悔药。

6.4 对话记录落库:把多轮聊天存进关系型数据库

聊天应用上线后一定会碰到数据持久化问题。Milvus 管的是向量检索,对话全文和时间线最好还是交给 MySQL 或 PostgreSQL。我常用的做法是建两张表:session保存会话维度信息,message保存单条消息内容。定时把存量消息批量取出来,做 Embedding 后写入 Milvus,这样既支持了基于历史的智能回顾,又避免每一次对话都实时调用向量化接口增加延迟。

这一步的价值在交付后会被快速看到:用户找历史对话是一条高频路径,没有全文检索时只能靠模糊查询,效果等于没有。把对话记录按天同步到 Milvus,用户就能用自然语言找到“上周三关于数据库选型那段讨论”,体验完全不同。这也是整套系统中 Milvus 承担的第二个角色,不限于原始文档知识库。

这些经验是从一次次翻车里趟出来的。Milvus 的索引参数、Spring AI 的版本兼容、Vue 3 的流式解析,单独拎出来都是简单知识点,但它们之间的配合才是真正的复杂度所在。如果本文能让你少踩一半的坑,目的就达到了。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表