本地大模型问答系统实战:Qwen2.5+vLLM+SpringBoot+Vue3全链路搭建
简介:随着大模型应用加速落地,本地化部署成为企业保护数据隐私、降低API调用成本的关键选择。通过vLLM推理引擎加载Qwen2.5开源模型,可以让模型权重和对话数据完全内网闭环。同时,利用SSE(Server-Sent Events)流式传输技术,实现类似打字机效果的实时响应,显著提升交互体验。从架构设计到代码实现,完整介绍了一个基于Vue3、SpringBoot、FastAPI和vLLM的本地问答系统,涵盖会话管理、流式转发、模型推理及联调踩坑,为构建企业级本地大模型Web应用提供可落地的参考。 最近在给公司搭一套内网智能问答系统,最开始我也想过直接接云端API,但第一轮方案评审就被拍回来了:会话数据不出内网、模型要能配合知识库做二次改造、上线后调用量还不小。这三个条件摆在一起,官方API基本没戏,本地化部署成了必选项。
我基于通义千问开源模型(Qwen2.5-7B-Instruct)搭了一套完整的前后端分离系统,技术栈是 Vue3 + SpringBoot + FastAPI + vLLM,支持 SSE 流式传输,也保留了 RESTful 接口。现在前端交互、后端会话管理、模型推理三层都可以独立扩展。这篇文章把整体架构、每层的关键代码、启动参数、联调踩坑都整理出来,给准备做本地大模型Web应用的兄弟一点参考。
1. 为什么没用云端大模型API:三个绕不过去的现实问题
1.1 业务数据不想经过别人的服务器
内部问答系统的对话内容不是随便一句“你好”,而是带着业务上下文的。比如员工问“3月份华东区的回款异常单有哪些”,这个问题背后包含了客户名、区域、金额敏感信息。如果走云端API,这些内容至少要经过对方服务器一次,哪怕协议上承诺不留存,甲方和合规部门也不会点头。
所以本地部署的第一个意义,不是“技术更高级”,而是让数据闭环在受控环境里。模型权重可以随便放,但对话记录、系统日志、用户信息都必须落在自己的机器上。
1.2 API调用成本会随着用户量线性上涨
内部系统一旦上线,使用频次往往是每天几千甚至上万次。云端API按token计费,长上下文的场景一次可能消耗几千个token,一个月下来账单非常可观。我做过一个粗略估算:500个活跃用户、日均30次问答、平均每次5000 token,用API大概一天烧掉75万token,一个月就是2200万token,按当时市场价格换算,一年的费用足够买一张不错的显卡了。
本地部署正好相反,推理开销主要摊在硬件折旧和电费上,单位成本的边际递减非常明显。虽然前期要花时间搭环境和调优,但跑几个月之后,这笔投入基本能回本。
1.3 对话应用不只是一个模型接口
如果只是调API,那确实简单,但一个可用的聊天应用还要处理历史会话、权限控制、流式推送、日志审计、异常降级。这些逻辑如果全塞在模型层,会让模型服务变得极难维护,后续想换模型、想加RAG、想接企业微信机器人,都会被这团乱麻绊住。
所以我从一开始就决定拆三层:
- 模型推理层:vLLM
- 模型网关层:FastAPI
- 业务后端层:SpringBoot
前端单独用Vue3做交互。每一层可以独立替换,这也是后面所有设计决策的前提。
2. 整体链路设计:RESTful和SSE各负责什么
2.1 各层组件承担的职责
系统里的每一层都不是随便选的:
| 层级 | 技术选型 | 核心职责 |
|---|---|---|
| 交互层 | Vue3 | 渲染消息列表、解析SSE流、发起终止请求 |
| 业务后端 | SpringBoot | 管理会话、存储记录、权限校验、转发SSE |
| 模型网关 | FastAPI | Prompt模板拼装、模型参数聚合、流式转发 |
| 推理引擎 | vLLM | 加载Qwen权重、管理显存、对外提供OpenAI兼容接口 |
Vue3和SpringBoot之间是标准的RESTful接口,用来做历史记录加载、登录态校验、会话创建。真正对话响应的传输走SSE,因为文本生成是流式的,用户等不了全部生成完再看到结果。
2.2 一条完整的问答请求是怎么流转的
用户在浏览器里发出一条消息,前端做四件事:
- 先调用SpringBoot的RESTful接口,创建会话记录、保存用户问题;
- 接着发起一个SSE请求,带上当前会话ID和消息内容;
- SpringBoot收到这个流式请求后,转发给FastAPI网关;
- FastAPI再组装成OpenAI格式的Chat Completion请求,发给vLLM的
/v1/chat/completions接口。
vLLM开始生成后,token会一层层传回:vLLM到FastAPI、FastAPI到SpringBoot、SpringBoot到Vue3,最终用打字机效果渲染到浏览器。
为什么中间隔着SpringBoot和FastAPI两层,不让Vue直接连vLLM?原因很简单:vLLM只关心模型推理,它不关心你是谁、有没有权限、历史上聊过什么;SpringBoot管业务,但不应该被模型调度的细节绑架。FastAPI作为模型网关,可以把提示词模板、采样参数、模型版本切换都收敛在一个地方。
3. 模型层部署:vLLM和FastAPI的完整搭建过程
3.1 环境准备与模型下载
我用的显卡是RTX 4090 24GB,模型是Qwen2.5-7B-Instruct。这套搭配在FP16精度下占用的显存大约16GB左右,留出了足够空间给KV Cache。
如果你用的是Windows,直接装vLLM会比较痛苦,官方对Windows的原生支持不好,优先建议用Docker或者直接上Linux服务器。我的生产环境是Ubuntu 22.04 + Python 3.10 + CUDA 12.1。
模型用ModelScope或HuggingFace下载。国内网络环境下面建议优先用ModelScope,速度稳定:
pip install modelscope
python -c "from modelscope import snapshot_download; snapshot_download('Qwen/Qwen2.5-7B-Instruct', local_dir='/data/models/qwen2.5-7b-instruct')"
下载完成之后,先验证一下权重完整性,再安装vLLM:
pip install vllm
vllm --version
3.2 vLLM启动命令与关键参数分析
模型层命令我调整了很久,最终跑通的启动脚本是:
python -m vllm.entrypoints.openai.api_server \
--model /data/models/qwen2.5-7b-instruct \
--served-model-name qwen2.5-7b-instruct \
--port 8000 \
--gpu-memory-utilization 0.9 \
--max-model-len 8192 \
--dtype float16
这里有几个参数值得单独说明一下:
-
--gpu-memory-utilization 0.9:表示vLLM最多使用90%的显存。不要设成1.0,否则运行中一旦产生额外显存分配,很容易直接OOM。 -
--max-model-len 8192:控制模型上下文的最大长度。如果对话历史太长,超过了这个长度,vLLM会直接报错。对于一般问答场景8192够用,需要长文档分析再调到16384。但这个值越大会占用更多KV Cache显存,要量力而行。 -
--served-model-name:这是对外暴露的模型名,客户端调用时要用这个名字。我习惯改成项目代号,这样以后换基座模型时,只要保持这个名字不变,上层代码完全不用动。 -
--enforce-eager:这个参数值得单独说。
热搜里有人问“vLLM启动时加--enforce-eager有什么影响”,我实测下来的感受是:它会关闭CUDA Graph的捕获推理路径,改用Eager模式执行,代价是推理速度会慢一些,大约有10%-20%的性能损失;但好处是显存占用更低,启动更快,而且能规避某些模型在CUDA Graph捕获阶段导致的OOM问题。
如果你的GPU比较旧、或者模型加载后总在启动时崩掉,加上这个参数往往能救回来。生产环境如果追求极致吞吐,不加它。
3.3 FastAPI网关封装:为什么要包一层
vLLM自带的标准接口是OpenAI兼容格式,理论上我可以直接让SpringBoot调它,但我还是加了一个FastAPI中间层。原因有几个:
- 业务接口要对内网统一,不能让上层关心模型服务的IP和端口;
- 可以在网关层做Prompt模板注入,系统提示词、菜单位、安全限制都收敛在这层;
- 后续如果要加RAG检索,在FastAPI里先检索再组装上下文,比在SpringBoot里做更顺。
我的FastAPI网关只暴露一个POST接口和一个SSE流式接口。
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import httpx
app = FastAPI()
VLLM_URL = "http://localhost:8000/v1/chat/completions"
async def stream_chat(messages: list[dict]):
payload = {
"model": "qwen2.5-7b-instruct",
"messages": messages,
"stream": True,
"temperature": 0.7,
"max_tokens": 2048
}
timeout = httpx.Timeout(300.0)
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream("POST", VLLM_URL, json=payload) as resp:
async for line in resp.aiter_lines():
if line.startswith("data: "):
data = line[6:]
if data.strip() != "[DONE]":
yield f"data: {data}\n\n"
@app.post("/v1/chat")
async def chat_endpoint(conversation: dict):
return StreamingResponse(
stream_chat(conversation["messages"]),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)
这里 X-Accel-Buffering: no 很关键。如果部署时前端通过Nginx反代,Nginx默认会缓冲响应,导致SSE流变成攒一波才发一次,前端就会感觉半天不出字。加了这个头,Nginx会关掉对该请求的缓冲。
3.4 用curl和在线工具验证模型服务
FastAPI启动后,先用curl验证流式输出:
curl -N -X POST http://localhost:8080/v1/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"用一句话介绍你自己"}]}'
-N 参数是curl里的禁用缓冲。如果控制台能像打字机一样一段段输出,说明链路已经通了。
调试阶段我也用过SSE在线测试工具,这类工具的好处是可以直接看到event stream的每个数据块和响应头,方便排查是不是有中间节点把响应吃掉或改成非流式了。
4. 中间层:SpringBoot如何优雅转发SSE流
4.1 为什么选SseEmitter而不是WebFlux
很多教程会告诉你SpringBoot转发SSE要用WebFlux,但我的项目是传统Spring MVC应用,并且已经在用Spring Security做权限控制。为了一个SSE转发引入整个WebFlux栈,意味着要处理两套容器的兼容问题,非常不划算。
Spring MVC原生提供的 SseEmitter 完全够用,它的语义就是为服务端推送设计的。实现思路是:
- 前端请求SpringBoot的一个接口;
- SpringBoot内部用WebClient请求FastAPI;
- 拿到的流式数据逐段通过SseEmitter发送给前端。
4.2 SseEmitter的完整转发代码
在SpringBoot里,我单独建了一个 ChatController :
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter stream(@RequestBody ChatRequest request) {
SseEmitter emitter = new SseEmitter(300000L);
chatService.forwardStream(request, emitter);
return emitter;
}
}
这里设置超时时间为300秒,也就是5分钟。生成式AI的响应时间波动很大,简单问答也许10秒就好,长文档摘要可能要一两分钟,超时时间小于这个范围就会导致流中途断开。
核心的转发逻辑在 ChatService 里,用WebClient异步把数据推到emitter:
public void forwardStream(ChatRequest request, SseEmitter emitter) {
String fastApiUrl = "http://localhost:8080/v1/chat";
WebClient.create().post()
.uri(fastApiUrl)
.bodyValue(request.toFastApiPayload())
.retrieve()
.bodyToFlux(String.class)
.doOnNext(data -> {
try {
emitter.send(data);
} catch (IOException e) {
emitter.completeWithError(e);
}
})
.doOnComplete(emitter::complete)
.doOnError(emitter::completeWithError)
.subscribe();
}
这段代码看起来简单,但有几个容易被忽略的细节:
-
bodyToFlux(String.class)会把响应体转换成一个字符串Flux,每个元素对应一段从FastAPI拿到的数据。这里要注意编码,必须保证上游返回的是UTF-8。 -
emitter.send()被try-catch包裹,因为连接随时可能断开,一旦客户端取消页面,再强制send会抛IOException,此时要手动结束流。 -
subscribe()必须调用。WebClient是响应式API,不订阅就不会真正请求;这里没有用blocking,避免占满Tomcat线程池。
4.3 会话历史如何管理
SSE流只负责新生成的内容,但发给大模型的上下文必须包含历史对话。
我在SpringBoot里设计了一套简单的会话结构:
-
conversationId由前端生成,用UUID; - Redis以
chat:{conversationId}为key,存储最近10轮用户消息和AI回复; - 每次新请求进来,从Redis取出历史,拼上当前消息,再转发给FastAPI。
具体实现时,我会在接口里加一个工具方法:
List<ChatMessage> buildContext(Conversation c, String userInput) {
List<ChatMessage> history = conversationService.getRecentMessages(c.getId(), 10);
history.add(new ChatMessage("user", userInput));
return history;
}
这里要控制历史长度。Qwen2.5-7B的最大上下文是8192到16384 token不等,如果历史消息无限堆积,早晚会撑爆上下文。我建议按“最近10-20条”或者“总token不超过4096”来做滑动窗口截断。
4.4 超时、断连和异常处理
SSE和普通Rest接口最大的区别是“连接生命周期更长”。在SpringBoot里处理不好,会遇到几个经典问题:
- 默认超时时间太短。Spring MVC的
SseEmitter默认超时是30秒,不显式设置的话,模型还没想好第一个词,连接就断了。 - 线程池配置。
SseEmitter配合异步请求时,如果不配置线程池,高并发下容易打满Tomcat默认线程池。我会在配置类里定义单独的ThreadPoolTaskExecutor来处理WebClient回调。 - 前端断开时,后端要及时感知并停止向上游拉数据。否则模型还在继续计算,而通知已经发给空气了,浪费算力。
我在异常处理的回调里加了一条日志和指标埋点,方便观察是哪个环节断了。你至少应该在 doOnError 里记录错误类型,不然线上排查会非常一脸懵。
5. 前端层:Vue3处理流式输出的正确姿势
5.1 为什么不用EventSource
看到SSE第一反应是用 EventSource ,但这个API有一个天然限制:只支持GET请求,且不支持自定义请求头。
我的接口都是POST,而且需要带 Authorization 头做鉴权,所以EventSource基本被排除。替代方案是使用 fetch ,因为fetch的响应体本身是一个 ReadableStream ,完全可以按行读取并解析SSE数据。
5.2 用fetch解析SSE流的Vue3实现
我在Vue3项目里封装了一个 useChat 组合式函数,核心逻辑是:
async function sendMessage(payload: { conversationId: string; content: string }) {
const controller = new AbortController();
abortControllerRef.value = controller;
const resp = await fetch('/api/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!resp.ok || !resp.body) {
throw new Error('request failed');
}
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 });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
const data = trimmed.slice(5).trim();
if (data !== '[DONE]') {
const json = JSON.parse(data);
currentText.value += json.choices[0].delta.content ?? '';
}
}
}
}
}
这段代码有几个关键点:
-
TextDecoder('utf-8')处理中文编码。流式数据中一个中文可能被拆成两个分片,必须用{stream: true}保留未完成的字节,等到下一段到了再合并。 - 用
split('\n')按行切分,每次保留最后一段到buffer,这就是常见的数据拆包问题。 - 每次拿到的是增量token,所以用
+=拼接到当前回复上,前端就出现了打字机效果。
5.3 停止生成:AbortController的正确用法
聊天应用几乎都要提供“停止生成”按钮。实现方式就是把AbortController实例存下来,点击按钮时调用 controller.abort() 。
function stopGenerate() {
abortControllerRef.value?.abort();
isStreaming.value = false;
}
这里要注意,调用 abort() 后, fetch 会抛出一个 AbortError ,在 while 循环里要用 try/catch 捕获,不能让它直接冒泡成未处理的Promise异常。
5.4 消息列表的状态管理
我用了 reactive 来保存会话上下文,没有一上来就上Pinia,因为这个页面的状态相对独立:
const state = reactive({
messages: [{ role: 'user', content: '' }],
isStreaming: false,
currentText: '',
});
加载历史列表时走RESTful接口:
async function loadHistory(conversationId: string) {
const { data } = await axios.get(`/api/chat/history/${conversationId}`);
state.messages = data.messages;
}
这样RESTful管“静态数据”,SSE管“动态数据”,各自职责清晰。如果聊天记录要跨页面共享,再考虑抽到Pinia里也不迟。
6. 端到端联调实测:性能数据和踩坑记录
6.1 先在SSE在线测试工具上验证再连前端
联调时我养成了一个习惯:先不急着打开Vue页面,而是用SSE在线测试工具直接请求SpringBoot接口,把整条后端链路单独验证完毕,再去看前端渲染。
这样做的原因是,如果直接连前端调试,出现问题时很难区分是后端没有流式返回、还是前端解析有误。用SSE在线工具先看:
- 响应头里是否带
Content-Type: text/event-stream; - 数据是否一段一段到达,而不是一次性返回;
- 特殊字符、换行符是否被正确编码。
我用curl测过之后,再用在线工具看响应头,确认没有 Content-Length 以及Nginx没有强制缓冲,才认为后端链路是通畅的。
6.2 单卡4090上Qwen2.5-7B的实际表现
我在本机和测试服务器上分别压过一轮,数据供参考:
| 场景 | 数值 |
|---|---|
| 模型 | Qwen2.5-7B-Instruct |
| 精度 | FP16 |
| 显存占用 | 约16GB |
| 单次请求首token延迟 | 0.7-1.5秒 |
| 平均生成速度 | 35-50 token/s |
| 并发数 | 5个会话同时生成时开始有可感知延迟 |
| 最大上下文 | 8192 token |
这只是单张4090的表现。如果并发需求更高,建议换A100/A800或者多卡时调整vLLM的 --tensor-parallel-size 参数,把模型切分到多张卡上推理。
首token延迟这件事值得多说一句。vLLM默认会在请求到达时先做prefill,如果用户发来的上下文很长(比如把历史记录全部塞进去),首token可能要到2秒以上。解决办法是控制上下文长度,或者用vLLM的前缀缓存特性,让相同前缀的请求复用KV Cache。
6.3 联调踩过的五个坑
从后端到前端完整跑通,我花了整整两天,主要卡在这五个地方:
第一个坑是Nginx缓冲。本地直连FastAPI一切正常,一放到Nginx后面,前端就变成每3秒钟蹦一大段字。后来确认是 proxy_buffering on 导致的,解决方法是上面说的在FastAPI响应头加 X-Accel-Buffering: no ,或者在Nginx配置里把这个location的proxy_buffering关掉。
第二个坑是SpringBoot默认超时。刚开始没写 new SseEmitter(300000L) ,用的是无参构造,结果每次超过10秒响应就被切断。排查日志时看到 AsyncRequestTimeoutException 才发现问题。
第三个坑是前端中文乱码。第一次用 reader.read() 直接 TextDecoder 解码,没加 {stream: true} ,个别中文在分段边界上被拆成乱码。这个问题隐蔽在偶发场景里,很容易忽略。
第四个坑是vLLM的OOM。我把 gpu-memory-utilization 设为0.95,又把 max-model-len 调到16384,跑长问答时直接崩了。后来按90%显存利用率+8192上下文重新跑,再没出现过OOM。如果你在启动阶段就OOM,可以临时加上 --enforce-eager 看能不能扛过去。
第五个坑是会话历史膨胀。最开始把完整对话历史全量发给模型,第12轮对话之后开始报错,因为prompt超限了。后面加了“只保留最近10轮”的策略才稳住。
6.4 给生产部署的几点配置建议
- vLLM进程要和SpringBoot、FastAPI分开部署,至少保证模型推理异常时不影响业务后端;
- Python侧用
uvicorn --workers 1启动FastAPI就够了,因为vLLM本身对并发有限流,多worker反而容易把显存吃掉; - SpringBoot加一个熔断逻辑:如果vLLM返回503或超时,直接返回一条兜底文案给前端,别让用户看到空白页;
- 前端加一个连接状态的提示条,“生成中”和“已断开”要明确区分,因为SSE连接一旦静默断开,用户会以为是模型还在思考。
7. 经验之谈:这套架构还能往哪走
项目跑通之后,我最大的感受是:本地大模型应用真正难的不是模型本身,而是模型和业务系统之间的这条“管道”。vLLM解决的是推理性能,FastAPI解决的是模型网关的灵活性,SpringBoot解决的是业务接入成本,Vue3解决的是用户体验。每个组件都不复杂,但把它们串成一个稳定链路,需要把超时、缓冲、编码、并发这些基础问题一个个抠干净。
后续我在这个架构上还加了一个简单的RAG模块:用户在SpringBoot上传文档,FastAPI在调用vLLM之前先从向量库里检索相关片段,拼进上下文。整个过程对上层业务完全透明,这也就是当初拆出FastAPI网关层带来的好处。
如果你也在搭类似系统,建议先把最小链路跑通,再一步步增加历史、鉴权和RAG。一次想做完所有功能,出问题的时候会很难定位。
更多推荐


所有评论(0)