前言
在我们开发带有 AI 问答能力的"任务分发与管理系统"时,遇到了一个典型的企业级架构难题: 前端是 Vue3,主业务后端是 Java (Spring Boot)。如果让前端直接调用大模型 API,不仅极度不安全(API Key 泄露),而且无法结合系统内的 MySQL 业务数据和复杂的项目权限隔离。
经过技术选型,我们敲定了 Vue3 + Java + Python (RAG中间层) 的三层架构。 本文将毫无保留地分享这套架构中最核心、最容易踩坑的"打字机流式对话(SSE)全链路代码",带你从零打通全栈大模型开发!
一、 全栈链路架构图解:谁在干什么?
在这套架构中,数据的流向就像一场完美的接力赛:
-
Vue3 (前端 UI): 发起 POST 请求提问,并在接收响应时解析 SSE(Server-Sent Events)流,实现类似 ChatGPT 的逐字打印效果。
-
Java (主控网关): 拦截前端请求,校验 User Token 和项目权限。鉴权通过后,作为透明代理(Proxy),向 Python 服务发起请求,并将接收到的流式数据异步推送给前端。
-
Python (AI 引擎): 接收 Java 的内部调用,将问题向量化,检索 Chroma 本地向量库,组装 Prompt 给大模型(如 DeepSeek/GPT),最后将大模型生成的文字**流式(Yield)**返回给 Java。
接下来,我们直接上干货,按从底层到前端的顺序逐一拆解代码!
二、 底层 AI 引擎:Python (FastAPI) 侧代码
Python 侧的任务最纯粹:只管 AI 检索和对话,不碰业务权限。我们使用 FastAPI + LangChain。
依赖安装:
Bash
pip install fastapi uvicorn langchain langchain-openai chromadb
核心代码 (main.py):
Python
python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
import uvicorn
app = FastAPI()
# 1. 初始化模型与向量库
api_key = "your_api_key_here"
embeddings = OpenAIEmbeddings(openai_api_key=api_key, model="text-embedding-3-small")
llm = ChatOpenAI(openai_api_key=api_key, model="gpt-3.5-turbo", streaming=True) # 必须开启流式
vector_store = Chroma(
collection_name="project_knowledge",
embedding_function=embeddings,
persist_directory="./chroma_db"
)
class ChatRequest(BaseModel):
project_id: str
query: str
# 2. RAG 流式问答接口
@app.post("/api/chat/stream")
async def chat_stream(req: ChatRequest):
# 【核心1】按项目 ID 隔离检索,防止数据越权
retriever = vector_store.as_retriever(
search_kwargs={"k": 3, "filter": {"project_id": req.project_id}}
)
template = """你是一个专业的项目助手。请根据以下【背景知识】回答问题:
【背景知识】\n{context}\n\n【用户问题】\n{question}"""
prompt = ChatPromptTemplate.from_template(template)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt | llm | StrOutputParser()
)
async def generate_stream():
async for chunk in rag_chain.astream(req.query):
# 【核心2】必须严格遵守 SSE 协议格式:以 data: 开头,\n\n 结尾
yield f"data: {chunk}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate_stream(), media_type="text/event-stream")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
三、 业务大管家:Java (Spring Boot) 侧代码
Java 侧最大的难点在于如何优雅地代理 Python 返回的流式数据,并且不阻塞 Tomcat 线程 。 强烈推荐使用 Spring WebFlux 的 WebClient 发起内部请求,并用 SseEmitter 将流推给前端。
依赖引入 (pom.xml):
XML
java
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
核心代理控制器 (ChatController.java):
Java
java
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import reactor.core.publisher.Flux;
import java.io.IOException;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/chat")
public class ChatController {
// 配置 WebClient 指向 Python 服务的地址
private final WebClient webClient = WebClient.create("http://localhost:8000");
@PostMapping(value = "/ask", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter askQuestion(@RequestBody Map<String, String> request) {
// 1. 鉴权与业务逻辑处理 (此处省略 Token 校验逻辑)
String userId = "user_123";
String projectId = request.get("projectId"); // 从前端传来的项目ID
String query = request.get("query");
// 2. 创建 SseEmitter (设置超时时间为 0 或极大值,防止大模型思考过长导致断开)
SseEmitter emitter = new SseEmitter(120000L); // 2分钟超时
// 异步回调处理
emitter.onCompletion(() -> System.out.println("SSE 流传输完成"));
emitter.onTimeout(emitter::complete);
emitter.onError(e -> emitter.completeWithError(e));
// 3. 构建发给 Python 的请求体
Map<String, String> pythonPayload = Map.of(
"project_id", projectId,
"query", query
);
// 4. 调用 Python 服务并代理流
webClient.post()
.uri("/api/chat/stream")
.bodyValue(pythonPayload)
.accept(MediaType.TEXT_EVENT_STREAM)
.retrieve()
.bodyToFlux(String.class) // 将 Python 返回的流映射为 Flux 响应式流
.subscribe(
// onNext: 接收到 Python 的一个字,立刻推给 Vue 前端
chunk -> {
try {
// 注意:Spring 的 SseEmitter 会自动补全 data: 前缀
// 如果 Python 已经加了,前端可能会收到 data: data: xxx
// 所以此处直接原样发送 chunk 即可
emitter.send(chunk);
} catch (IOException e) {
emitter.completeWithError(e);
}
},
// onError: 发生错误
error -> emitter.completeWithError(error),
// onComplete: 传输结束
() -> {
try {
emitter.send("[DONE]");
emitter.complete();
} catch (IOException e) {
emitter.complete();
}
}
);
// 立即返回 emitter 给前端,建立保持连接
return emitter;
}
}
四、 颜值担当:Vue3 侧打字机效果代码
在前端,处理 POST 请求的 SSE 流用原生的 fetch 写起来有些复杂(涉及 TextDecoder 和 Uint8Array 的切割)。 生产环境强烈推荐使用微软开源的 @microsoft/fetch-event-source 库。
安装依赖:
Bash
npm install @microsoft/fetch-event-source
Vue3 组件核心代码 (Chat.vue):
代码段
javascript
<template>
<div class="chat-container">
<div class="message-box">
<div v-for="(msg, index) in messages" :key="index" :class="['message', msg.role]">
<span>{{ msg.content }}</span>
</div>
<div v-if="isTyping" class="loading">AI 正在思考中...</div>
</div>
<div class="input-area">
<input
v-model="userInput"
@keyup.enter="sendMessage"
placeholder="向当前项目的知识库提问..."
:disabled="isTyping"
/>
<button @click="sendMessage" :disabled="isTyping">发送</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue';
import { fetchEventSource } from '@microsoft/fetch-event-source';
// 对话列表
const messages = reactive([
{ role: 'assistant', content: '你好!我是项目 AI 助手,你想了解什么规范?' }
]);
const userInput = ref('');
const isTyping = ref(false);
const projectId = "P-1001"; // 当前所在项目的 ID
const sendMessage = async () => {
if (!userInput.value.trim() || isTyping.value) return;
const query = userInput.value;
// 1. 用户消息上屏
messages.push({ role: 'user', content: query });
userInput.value = '';
isTyping.value = true;
// 2. 准备一个空的 AI 回复对象,占位
const aiMessageIndex = messages.push({ role: 'assistant', content: '' }) - 1;
// 3. 发起流式请求
const ctrl = new AbortController(); // 用于中断请求
try {
await fetchEventSource('http://localhost:8080/api/v1/chat/ask', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer YOUR_TOKEN_HERE' // Java 鉴权使用
},
body: JSON.stringify({
projectId: projectId,
query: query
}),
signal: ctrl.signal, // 绑定中断控制器
onmessage(ev) {
// 当接收到后端推来的数据
if (ev.data === '[DONE]') {
console.log('数据接收完毕');
isTyping.value = false;
ctrl.abort(); // 关闭连接
return;
}
// 将接收到的单字拼接到当前消息中,Vue 的响应式会自动更新视图实现打字机效果
// 这里的 ev.data 就是大模型吐出的字
messages[aiMessageIndex].content += ev.data;
},
onerror(err) {
console.error('连接出错', err);
isTyping.value = false;
messages[aiMessageIndex].content += '\n\n[网络异常,连接中断]';
ctrl.abort();
throw err; // 抛出错误以停止重试
}
});
} catch (error) {
isTyping.value = false;
}
};
</script>
<style scoped>
/* 样式省略,自行美化即可 */
.user { color: blue; text-align: right; }
.assistant { color: green; text-align: left; }
</style>
五、 全栈打通踩坑指南(防脱发必看)
根据我们的实战经验,这套代码在本地跑通常很顺利,但一部署到服务器就会遇到各种流断开的问题。请务必核对以下三点:
-
Nginx 代理导致流式失效: 如果你的 Java 服务部署在 Nginx 后面,Nginx 默认会"积攒"一定量的数据才返回给前端,导致打字机效果变成"便秘式"的一大段大段出字。 解决: 在 Nginx 配置的
location模块中加入proxy_buffering off;。 -
Spring Boot 超时断开: 默认 Tomcat 对异步请求有超时时间(通常是 30 秒)。大模型如果遇到复杂问题,可能思考 10 秒才出第一个字。务必在
new SseEmitter(120000L)传入一个较大的毫秒数。 -
数据跨项目污染(核心红线): 一定要检查前端传入的
projectId是否真的是当前用户的。必须在 Java 端查询该用户是否真有权限查看这个projectId,再传给 Python,绝对不能盲目透传,否则会导致极其严重的越权数据泄露。
结语
基于 Vue3 (视图渲染) + Java (鉴权路由) + Python (AI 引擎) 的微服务架构,既保证了现有企业级 Java 系统的稳定性,又完美融入了 Python 繁荣的大模型生态。
这套 SSE 流式链路不仅仅能用于知识库对话,稍加改造就可以用于"AI 自动写周报"、"AI 分析 Excel 并流式输出结论"等各类高级业务场景。
如果有任何疑问,欢迎在评论区留言讨论!如果这篇文章对你有帮助,千万别忘了点个收藏和关注哦!