简介:这是一套完整的大模型聊天应用工程,技术栈覆盖前端、双后端与模型服务,可以帮助希望掌握本地化大模型 Web 交互的开发者。项目采用前后端分离架构,前端使用 Vue3 构建,后端分别由 Spring Boot 和 FastAPI 承担业务接口与高性能 API,模型侧则接入通义千问并基于 vLLM 完成本地化推理,同时还通过 SSE 实现流式输出,让对话回复逐字显示,体验更加自然。资源包共 43 个文件,约 105KB,包含 11 个 Java 源码、5 个 Python 脚本、4 个 XML、4 个 JSON、4 个 JS、3 个 Vue 组件,以及 SQL、yml、properties 等配置文件,覆盖了后端服务、模型服务、前端页面和部署说明等主要模块,目录结构清晰,适合直接对照学习和二次开发。此外还附带说明文档,可帮助理解环境配置、接口设计和大模型调用流程。这份资源已有 201 人学习浏览,适合具备一定开发经验、想参考完整工程实现来搭建 AI 助手的读者,尤其能在前后端分离、SSE 实时通信、RESTful 接口规范等方面提供直接借鉴。
1. 从“能跑模型”到“能上线对话”:本地化部署真正缺的是Web交互层
大多数团队第一次碰通义千问本地化部署,都会卡在同一个地方:模型明明是通的,但给业务方演示时拿不出手——没有网页,没有流式输出,不能对接登录体系,更谈不上多个用户同时访问。这个资源直接给出了一条可复用的完整链路,把vLLM、FastAPI、SpringBoot、Vue3按前后端分离的方式串起来,最终形成一个真正能用的AI聊天应用,支持SSE流式传输和RESTful API。它解决的不是“如何启动一个千问模型”,而是“如何把千问模型变成一个可以交付的Web系统”。适合做私有化AI客服、内部知识库助手、或者想在真实项目中研究大模型工程化落地的人,新手能跟着把服务跑起来,熟手可以直接替换业务模块。
2. 三层服务链路与SSE流式传输:为什么是Vue3+SpringBoot+FastAPI+vLLM
2.1 整体架构与请求流转
这套系统的核心思路是“模型服务、业务后端、前端展示”三层解耦。vLLM负责干GPU推理的脏活累活,FastAPI把推理能力封装成稳定的接口,SpringBoot承担鉴权、会话管理、限流这些业务逻辑,Vue3只关心页面和数据渲染。每一层可以独立替换、独立扩容,这也是我推荐前后端分离的原因——大模型服务端的迭代速度远快于传统业务,如果全部塞在一个SpringBoot进程里,升级一次模型要重新构建一次整个应用,运维成本会非常难看。
| 层 | 技术栈 | 在本系统里的职责 |
|---|---|---|
| 前端展示层 | Vue3 + Vite | 页面交互、流式输出渲染、对话历史维护 |
| 业务后端层 | SpringBoot | RESTful API、用户鉴权、会话管理、SSE转发 |
| 模型封装层 | FastAPI | 流式接口封装、超参透传、异常归一化 |
| 推理引擎层 | vLLM | 通义千问模型加载、连续批处理、KV Cache管理 |
一次完整请求的流转大致是:Vue3页面把用户输入的消息POST到SpringBoot的/api/chat,SpringBoot校验完身份后,用WebClient异步转发给FastAPI的/api/chat,FastAPI再以OpenAI兼容协议向vLLM发起chat.completions请求,vLLM把生成的token通过流式通道回传。这个回传不是一次性返回,而是每生成一个片段就推一段,从而让用户看到“打字机”效果。层与层之间全走HTTP协议,调试时每一层都可以单独用curl打,不需要额外引入内网通信中间件。
选型时有人会问:为什么不直接用SpringBoot调vLLM,中间再插一个FastAPI是不是多此一举?我的经验是,FastAPI这一层非常值得保留。首先vLLM的OpenAI兼容接口只是最基础的能力,你在实际项目中往往要加一层自己的逻辑,比如多模型路由、敏感词过滤、日志埋点、降级策略;其次FastAPI的异步生成器与SSE天然契合,Python侧处理token级别的流式逻辑比Java侧方便得多,特别是要做流式内容改写的时候。
2.2 SSE流式传输的协议原理与选型理由
SSE全称Server-Sent Events,是HTML5标准里基于HTTP的服务端推送技术。它和WebSocket最大的区别是单向性:SSE只允许服务端往客户端推数据,客户端到服务端仍然走普通请求。对话场景恰好是单向流式推送——用户发一次消息,服务端持续吐回复,中间不需要客户端频繁给服务端发指令,因此SSE是足够且更简单的方案。
SSE的线上协议格式非常直观,服务端不断输出data:前缀的行,事件之间用空行分隔。我习惯用curl直接验证vLLM的流式输出,命令大致长这样:
curl -N http://localhost:8000/v1/chat/completions \ -X POST \ -H "Content-Type: application/json" \ -d '{ "model": "qwen", "messages": [{"role": "user", "content": "给我讲一个笑话"}], "stream": true }'注意这里必须加-N参数,关闭curl的缓冲,否则你会等整个响应全部结束才看到内容,而不是逐行显示。vLLM开启流式后,会一行行返回data: {...}格式的JSON,内容可能是增量token,也可能是usage数据;当所有内容生成完毕后,会返回一个data: [DONE]标记,前端收到这个标记就知道流结束了。
选SSE而不是WebSocket,原因有三个。第一,SSE基于普通HTTP,可以复用现有Nginx、Spring Security、网关体系,不需要额外维护长连接协议状态;第二,SSE自带断线重连机制,浏览器原生EventSource对象会在连接断开后自动重试,而WebSocket需要自己写心跳和重连;第三,Spring Boot提供的SseEmitter可以做到零额外依赖转发SSE,FastAPI的StreamingResponse更是为这种场景设计的。下表是两者关键差异,方便你给团队做决策:
| 维度 | SSE | WebSocket |
|---|---|---|
| 方向性 | 服务端单向推送 | 全双工 |
| 协议 | 普通HTTP | 独立协议 |
| 自动重连 | 原生支持 | 需自行实现 |
| 自定义Header(用于鉴权) | 原生EventSource不支持,需用fetch | 握手时可携带 |
| 服务端实现成本 | FastAPI/SpringBoot都极简 | 需要专门维护连接管理器 |
3. vLLM与FastAPI模型服务层:Qwen本地部署的核心与参数调优
3.1 vLLM启动Qwen模型:命令、参数与实测吞吐
vLLM是目前大模型本地推理里吞吐表现最好的框架之一,它的核心是PagedAttention和连续批处理,能把同批次里不同长度的请求动态组合,最大化GPU利用率。通义千问系列模型对 vLLM 的支持很完善,官方社区已经适配得很成熟,所以我直接用官方镜像启动。
假设你的模型权重放在/mnt/models/Qwen2.5-7B-Instruct,启动命令大致如下:
docker run --gpus all --shm-size 16g \ -v /mnt/models:/models \ -p 8000:8000 \ vllm/vllm-openai:0.27.1 \ --model /models/Qwen2.5-7B-Instruct \ --served-model-name qwen \ --port 8000 \ --gpu-memory-utilization 0.90 \ --max-model-len 32768 \ --tensor-parallel-size 1参数解析:--model指向本地权重目录,第一次启动会做权重加载和计算图编译,耗时几分钟,后续再启动会快很多;--served-model-name qwen是关键,它决定了OpenAI接口里model字段的值,也方便你在同一套vLLM进程里挂多个不同模型;--gpu-memory-utilization 0.90告诉vLLM最多使用90%显存,剩下10%留给CUDA上下文和其他开销;--max-model-len 32768是最大上下文长度,越长KV Cache占用越高;--tensor-parallel-size 1表示单卡推理,如果你的机器有多张卡可以设置成卡数,但需要确保卡间通信带宽足够。
--shm-size 16g这段容易被忽略,实际踩过坑就知道了。vLLM在tokenizer分词、张量并行时的IPC操作依赖共享内存,默认shm只有64MB,并发一高就报错。如果你用的是K8s或Docker Compose,一定把shm_size显式调大。
启动后vLLM会监听8000端口,暴露一个OpenAI兼容的API,同时还会输出当前GPU显存分布、KV Cache block数量、吞吐预测值。我一般会重点看两行:一行是GPU memory usage,确认KV Cache拿到了总显存的百分之多少;另一行是Maximum concurrency,它表示当前配置下最大并发请求数,如果这个数字小于你的预期并发,说明max-model-len或gpu-memory-utilization需要调整。
3.2 FastAPI封装SSE接口:从AsyncIterator到StreamingResponse
FastAPI这一层要做的不是重复造轮子,而是把vLLM的OpenAI兼容接口变成内部业务接口。直接用openai官方Python SDK的异步版去调vLLM,返回一个异步迭代器,再把迭代器包装成StreamingResponse。这样代码量最小,且天然支持Streaming。
import json from fastapi import FastAPI from fastapi.responses import StreamingResponse from openai import AsyncOpenAI from fastapi.requests import Request app = FastAPI() client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") async def generate_stream(messages: list[dict], max_tokens: int, temperature: float): stream = await client.chat.completions.create( model="qwen", messages=messages, max_tokens=max_tokens, temperature=temperature, stream=True, ) async for chunk in stream: delta = chunk.choices[0].delta if delta is not None and delta.content: payload = {"content": delta.content} yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" @app.post("/api/chat") async def chat(request: Request): body = await request.json() return StreamingResponse( generate_stream(body["messages"], body.get("max_tokens", 2048), body.get("temperature", 0.7)), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", } )逻辑说明:AsyncOpenAI的base_url指向vLLM的8000端口,api_key="EMPTY"是vLLM兼容模式的惯例,它不校验key,但框架要求字段非空。generate_stream是核心异步生成器,vLLM返回的每个chunk都携带增量token,我们把delta.content取出来拼成SSE格式;注意使用async for而不是普通for,因为流式请求本身是异步的,如果这里写成同步遍历,会阻塞FastAPI整个事件循环,其他请求全部卡死。
响应头里X-Accel-Buffering: no是给Nginx看的,告诉它不要对这条响应做缓冲。如果没有这个头,Nginx会先把SSE内容攒到缓冲区,攒满才吐出去,前端看到的就不是打字机效果,而是等了十几秒后一次性全部出现。这个头不一定被所有代理程序识别,但Nginx、Tengine、部分云负载均衡都支持。
参数方面,temperature默认我给0.7,一般在0.6到0.9之间调。做客服场景建议往下降,用0.5左右让回答更稳定;做创意写作场景可以调到0.9增加随机性。max_tokens控制单次回复的最大长度,注意它不包含输入上下文token,所以不需要为多轮对话额外预留空间。
4. SpringBoot与Vue3的前后端实现:业务隔离与流式渲染
4.1 SpringBoot作为BFF层:转发SSE与WebSocket选型
SpringBoot在这套系统里的定位是BFF,Backend For Frontend。它不直接和模型服务打交道时涉及业务逻辑,只把来自前端的请求做鉴权、参数校验、会话补全后转发给FastAPI。转发SSE有一个现成的组件叫SseEmitter,使用便捷,也不需要引入额外的消息中间件。
@PostMapping(value = "/api/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter chat(@RequestBody ChatRequest request) { WebClient webClient = WebClient.builder() .baseUrl("http://localhost:8000") .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) .build(); SseEmitter emitter = new SseEmitter(0L); Flux<String> stream = webClient.post() .uri("/api/chat") .bodyValue(request.getRawMessages()) .retrieve() .bodyToFlux(String.class); stream.subscribe( data -> { try { emitter.send(data); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); return emitter; }这段代码里值得解释的是两个边界设置。maxInMemorySize(10 * 1024 * 1024)把WebClient的内存缓冲上限调到10MB,Spring默认值是256KB,虽然SSE一般每条消息不大,但一旦vLLM的某个chunk里带有长文本工具调用或者代码块,256KB很容易溢出,报错信息又很隐晦。new SseEmitter(0L)里的0表示不设置超时时间,默认超时30秒,聊天生成经常超过30秒,不设置为0前端连接会被服务端直接掐断。
为什么选SseEmitter而不是WebSocket?因为这里只需要单向转发,SseEmitter开箱即用,SpringMVC会对它自动处理异步请求生命周期。如果引入WebSocket,需要维护会话注册表、心跳机制、断线清理线程池,复杂度明显上升。但是要注意,SseEmitter本身是线程安全的,emitter.send()在多线程调用时内部有锁,所以使用异步回调时不需要额外加同步块。
4.2 Vue3前端接收SSE事件流:fetch+ReadableStream与进度展示
前端的核心工作是把SSE事件流解析成可渲染的文本。很多人第一反应是使用EventSource对象,但它有两个限制:只支持GET请求、不能自定义请求头。聊天场景通常要POST消息体并携带Token,所以我更推荐用fetch配合ReadableStream手动解析。这样代码稍微多一点,但对头、请求方式完全可控。
<template> <div class="chat-window"> <div v-for="item in chunks" :key="item.id" class="chat-message">{{ item.content }}</div> </div> </template> <script setup> import { ref } from 'vue'; const chunks = ref([]); async function sendChat(messages) { const resp = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ messages }) }); if (!resp.ok) return; 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 events = buffer.split('\n\n'); buffer = events.pop() || ''; for (const event of events) { const line = event.trim(); if (!line.startsWith('data:')) continue; const payload = line.replace(/^data:\s*/g, '').trim(); if (payload === '[DONE]') return; const json = JSON.parse(payload); chunks.value.push({ id: Date.now(), content: json.content }); } } } </script>逻辑说明:resp.body.getReader()拿到的是响应体的流对象,每次reader.read()返回一个Uint8Array二进制块。用TextDecoder把二进制块解码成字符串,并且传入{ stream: true },这会处理多字节字符被截断在块边界的情况,比如一个中文字符的三字节被拆到了两个数据块里,如果不加这个参数就会出现乱码。之后按空行\n\n切分SSE事件,最后一段可能是不完整的,需要留到下一轮循环继续拼接。
注意这里不能用Object.entries遍历JSON,因为流式返回的每个事件可能是多个字段,我们只需要content字段。解析时还要做try-catch,因为网络中断时最后一段可能是半个JSON,解析异常直接跳过,不要破坏整个循环。生产环境里我会把解析逻辑放到一个独立的parseSSEStream工具函数里,单元测试也方便写。
5. 部署与联调中的常见问题排查:六条实测踩坑记录
5.1 现象:vLLM容器一启动就退出,日志里出现CUDA out of memory
原因:gpu-memory-utilization设置过高,模型权重加载完后剩下的显存不够给KV Cache初始化分配,或者max-model-len设的上下文长度过大,导致KV Cache预留空间超过实际可用显存。
解决:先降低gpu-memory-utilization到0.85,同时把max-model-len从32768降到16384。如果还不行,用nvidia-smi确认当前GPU显存是否被其他进程占用。我一般会先用一个很小的max-model-len(比如4096)启动,看到进程正常后再逐步调大,而不是一步到位直接拉满。
5.2 现象:前端等了很久才开始出字,首字延迟超过10秒
原因:最常见的是Nginx开启了缓冲,把FastAPI传给SpringBoot、再传给浏览器的SSE数据全部攒在缓冲区,直到流结束才一次性发给前端;第二种可能是vLLM没有开启流式,接口返回的是整体内容,不过这个问题在vLLM的OpenAI兼容接口里很少见。
解决:在所有涉及SSE的代理层显式关闭缓冲。Nginx配置里加proxy_buffering off;并设置proxy_read_timeout 300s;。FastAPI响应头里加X-Accel-Buffering: no。另外确认vLLM启动时没有加--disable-stream这类参数。
5.3 现象:SSE流在生成到一半时断开,前端只收到半截回答
原因:负载均衡或网关默认空闲超时时间在30到60秒,而大模型生成长文时,两次发送数据块之间的间隔可能超过30秒。这里的“空闲”不是没有数据发送,而是TCP层没有新包,某些LB策略会把这种情况判定为死连接。
解决:在Nginx设置proxy_read_timeout 3600s;云负载均衡则把响应超时调到最大值。SpringBoot的SseEmitter要设置超时为0,emitter.setTimeout(0L)。前端也要做断线重连,不要在报错后直接消失,而是在SSE断了之后提示用户“生成中断”,并提供“继续生成”按钮。
5.4 现象:前端显示的中文变成\uXXXX转义序列
原因:FastAPI里用json.dumps序列化增量内容时默认ensure_ascii=True,会把所有非英文字符转成\u形式;另外SpringBoot的ResponseBodyEmitter在发送字符串时可能强制按ISO-8859-1编码,导致中文再次被转义。
解决:Python侧safe=True,准确说是json.dumps(payload, ensure_ascii=False)。Java侧在Controller的produces里指定编码,写成MediaType.TEXT_EVENT_STREAM_VALUE + ";charset=UTF-8"。前端用TextDecoder('utf-8')解码。这三处任何一个遗漏都会出现中文显示问题。
5.5 现象:SpringBoot日志报OutOfMemoryError,或者WebClient报DataBufferLimitException
原因:Spring WebClient默认的maxInMemorySize只有256KB,FastAPI返回的一条SSE数据如果包含长文档或工具调用参数,很容易超过这个限制。OutOfMemoryError则更隐蔽,通常是因为没有正确释放Flux订阅的资源,或者在转发过程中把整个流拼成了一个大字符串。
解决:在WebClient.builder()中把maxInMemorySize调整到10MB以上。注意bodyToFlux(String.class)返回的事件流要使用doFinally信号做资源清理,比如stream.doFinally(sig -> log.info("stream closed: " + sig)),排查是否有泄漏。
5.6 现象:多轮对话越来越慢,显存占用持续上升
原因:前端把全部历史消息无脑拼进messages,每次请求的上下文长度都会增加,vLLM需要管理越来越大的KV Cache,显存占用升高,prefill阶段的计算量也变大,最终拖慢首字生成速度。
解决:在SpringBoot层对会话做上下文窗口管理。常见做法是只保留最近6轮对话,或者把更早的对话用一次本地模型调用做摘要,再把摘要拼入system角色。我通常会在业务层限制输入长度,比如超过8000字符就强制打断并提示用户开启新会话。
6. 进阶:用并发压测验证“本地化部署值不值”,以及一套稳定运行的开关
6.1 压测:首token延迟与生成吞吐
我判断一套大模型系统是否能上线,从来不看单条对话测得多快,而是看并发下首token延迟和吞吐。最简单的方式是用Python脚本模拟20个并发请求,统计从发送到第一个token返回的时间,以及整体生成完成后的总token数除以总耗时。注意压测时不要让FastAPI参与,直接压vLLM的8000端口,这样可以先排除业务层的干扰。
import asyncio import time from openai import AsyncOpenAI client = AsyncOpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") async def one_request(sem): async with sem: start = time.time() stream = await client.chat.completions.create( model="qwen", messages=[{"role": "user", "content": "写一篇800字的技术博客"}], stream=True, ) first_token_time = None token_count = 0 async for chunk in stream: if first_token_time is None and chunk.choices[0].delta.content: first_token_time = time.time() - start token_count += 1 return first_token_time, token_count async def main(): sem = asyncio.Semaphore(20) results = await asyncio.gather(*[one_request(sem) for _ in range(20)]) print("avg first token:", sum(r[0] for r in results) / len(results)) print("total tokens:", sum(r[1] for r in results)) asyncio.run(main())这个脚本不依赖SpringBoot,可以单独验证模型服务层的性能边界。如果压测结果里首token延迟普遍在2秒内,说明服务状态健康;如果超过5秒,优先检查max_num_seqs是不是太小,这个参数控制vLLM同时处理的序列数量,默认会根据显存自动算,但有时算出的值偏低,可以手动调大。
6.2 参数微调:max_num_seqs与KV Cache的权衡
| 参数 | 调大影响 | 调小影响 |
|---|---|---|
max_num_seqs | 并发吞吐提升,但显存分配更紧张,单条延迟可能增加 | 吞吐下降,但每条请求的延迟更稳定 |
--max-model-len | 支持更长上下文,KV Cache占用上升 | 显存占用减少,但长文档会被截断 |
--gpu-memory-utilization | KV Cache空间更大,可并发更高 | 显存余量不足时直接OOM |
调优原则是:先定max-model-len,再根据显存算gpu-memory-utilization上限,最后用压测脚本验证max_num_seqs。不要一开始就追求最大模型支持长度,实际业务里大多数提问在2K到4K以内,把max-model-len设为16384,剩下的显存留给并发,整体体验会更好。
6.3 稳定运行的开关:预热与健康检查
vLLM首次启动后,第一个请求往往需要做CUDA kernel编译和模型预热,延迟会异常高。生产过程里我会在SpringBoot启动完成后,先向FastAPI发一条空对话,让vLLM把所有计算图跑一遍,再开启业务流量。同时给FastAPI加一个健康检查接口,SpringBoot通过/health轮询后端状态,vLLM挂掉时直接返回503给前端,而不是让用户等到超时。
@app.get("/health") async def health(): try: await client.chat.completions.create( model="qwen", messages=[{"role": "user", "content": "hi"}], max_tokens=1, stream=False, ) return {"status": "ok"} except Exception: return {"status": "unavailable"}健康检查里的请求不能太复杂,max_tokens=1就够,只验证链路通不通和是否有足够显存启动新推理。从那以后我每次部署这类系统都会强制走一遍完整检查:vLLM单独压测,FastAPI健康检查,SpringBoot转发首token计时,前端断线重连演练。四步全过才会把版本交出去。模型服务这东西,玄学太多了,把每一步卡死,才能把不稳定因素挡在交付之前。希望帮到你。
本文还有配套的精品资源,点击获取