ARTICLE DETAIL

资讯详情

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

通义千问本地化部署实战:vLLM+FastAPI+SpringBoot+Vue3构建Web交互系统

通义千问本地化部署实战:vLLM+FastAPI+SpringBoot+Vue3构建Web交互系统 简介面向AI应用开发工程师与全栈学习者该压缩包围绕通义千问大模型的本地化部署提供了一套完整的前后端分离式AI聊天应用工程。前端使用Vue3搭建响应式交互界面后端以Spring Boot承载业务逻辑FastAPI负责调度本地大模型推理SSE实现对话内容的流式返回RESTful接口则统一管理认证与消息收发整个工程可直接改造为私有化客服、知识库问答或教学实验平台部署思路清晰版本兼容性较易梳理。资源共43个文件涵盖Java服务代码、Python模型调用脚本、Vue页面组件、前端工程配置、数据库脚本以及说明文档等压缩包整体105KB结构紧凑便于快速定位与二次开发附带的说明文件和附赠文档对启动流程、模块职责、参数配置与常见问题进行了补充能有效缩短环境搭建时间。目前已有201人浏览学习适合想掌握大模型落地与Web系统集成实践的开发者。1. 本地化的通义千问为什么要把大模型放进自己的Web系统我接过不少类似的诉求公司内部想做一个AI助手但数据不能出内网或者学生党想在教学环境里把通义千问跑起来让同学通过浏览器访问而不是人人去装Python环境。基于Vue3、SpringBoot、FastAPI和vLLM这套技术栈把通义千问大模型本地化部署成Web交互系统正好能解决这类问题——前端用Vue3做界面SpringBoot负责业务逻辑和权限FastAPI充当Python侧的AI服务层vLLM做推理引擎四者串成一条前后端分离的链路。这套方案适合手里有一张NVIDIA显卡哪怕只是24G显存的开发者也适合想完整走一遍“模型部署-后端封装-前端交互”全流程的人。下面我把整个落地路径拆开讲从模型启动到SSE流式传输每一步都给到可以直接抄的配置和代码。2. 前后端分离架构与选型理由SpringBoot、FastAPI、vLLM各司其职2.1 vLLM做推理引擎为什么本地化首选它跑通义千问的开源模型Qwen系列常见推理方案有HuggingFace Transformers、FastChat、Text Generation InferenceTGI和vLLM。实际用下来vLLM的吞吐量在同级别显存下能快出3到5倍主要靠PagedAttention把KV Cache按页管理减少显存碎片。另一个关键好处是它自带OpenAI兼容的API服务这样FastAPI、SpringBoot都可以直接拿HTTP请求去调不必在Java里折腾Python模型库。vLLM启动Qwen的常规命令如下python -m vllm.entrypoints.openai.api_server \ --model Qwen/Qwen2.5-7B-Instruct \ --tokenizer-mode auto \ --served-model-name qwen-local \ --host 0.0.0.0 \ --port 8001 \ --max-model-len 8192 \ --gpu-memory-utilization 0.85 \ --enable-auto-tool-choice这里--served-model-name是给模型起个对外名字后面所有API调用都带这个名字。--max-model-len控制最大上下文长度7B模型在24G显存下开到8192是稳妥的如果显存只有12G建议降到4096。--gpu-memory-utilization表示vLLM最多使用85%的显存留一点给CUDA context和其他进程。启动成功后vLLM会在8001端口暴露两个地址/v1/chat/completions非流式和/v1/chat/completions带stream: true参数时走SSE流式返回。我一般先用curl验证一下curl http://localhost:8001/v1/models如果返回模型列表里有qwen-local说明推理引擎已经待命。注意vLLM新版本对Qwen2.5系列支持很完善但如果你用的是Qwen3系列比如Qwen3-4B建议把vLLM升到0.8以上否则可能不识别某些网络结构。2.2 FastAPI做AI服务层面向模型的Python后端很多团队会问既然vLLM已经提供了OpenAI兼容接口为什么还要中间再套一层FastAPI直接让SpringBoot调vLLM不行吗技术上能行但现实里往往有几个理由让这层Python服务存在需要把vLLM的请求体转换成团队内部的消息协议比如统一字段名、增加敏感词过滤。需要在调用模型前做提示词模板拼接、会话历史管理这些逻辑用Python写更顺手。需要在多个模型之间做路由比如不同用户用不同模型FastAPI可以根据请求动态选择vLLM的served-model-name。FastAPI的项目结构我习惯这样划分fastapi_service/ ├── app/ │ ├── main.py │ ├── routers/ │ │ ├── chat.py │ │ └── health.py │ ├── schemas/ │ │ └── chat.py │ ├── services/ │ │ ├── llm_client.py │ │ └── prompt_builder.py │ └── config.py ├── requirements.txtmain.py里创建应用并注册路由。llm_client.py封装对vLLM的HTTP调用这里我直接用httpx.AsyncClient因为它支持流式响应正好对接SSE。# app/services/llm_client.py import httpx import json from typing import AsyncGenerator class LLMClient: def __init__(self, base_url: str, model_name: str): self.base_url base_url self.model_name model_name async def stream_chat( self, messages: list, temperature: float 0.7, max_tokens: int 2048 ) - AsyncGenerator[str, None]: 调用vLLM的OpenAI兼容接口异步产出增量文本 payload { model: self.model_name, messages: messages, temperature: temperature, max_tokens: max_tokens, stream: True } async with httpx.AsyncClient(timeout60) as client: async with client.stream(POST, f{self.base_url}/v1/chat/completions, jsonpayload) as resp: if resp.status_code ! 200: error_body await resp.aread() raise RuntimeError(fvLLM返回异常: {resp.status_code} - {error_body}) async for line in resp.aiter_lines(): if not line or not line.startswith(data:): continue json_str line[5:].strip() if json_str [DONE]: break chunk json.loads(json_str) if len(chunk[choices]) 0: delta chunk[choices][0].get(delta, {}) content delta.get(content, ) if content: yield content这段代码的关键在于streamTrue会返回text/event-stream格式每一行以data:开头最后以data: [DONE]结束。很多新手以为模型返回的是一整个JSON实际流式接口里每行都是一个JSON片段字段结构是{choices: [{delta: {content: ...}}]}。我这里直接用aiter_lines()逐行读避免自己解析SSE帧。2.3 SpringBoot做业务后端Java生态的稳定入口在前后端分离架构里SpringBoot的位置是“业务门面”——它不能直接碰模型但负责接收前端请求、做登录鉴权、查询历史记录、调FastAPI、再把结果转发给前端。这么设计的原因很实际Java生态在企业里仍然是主流数据库、缓存、消息队列这些中间件和SpringBoot集成最成熟。让SpringBoot占住前端入口后端Python服务就不需要在公网内网都暴露自己的端口。SpringBoot这边我建议做一个透明转发RestController RequestMapping(/api/chat) public class ChatController { Value(${fastapi.base-url}${fastapi.chat-path}) private String fastApiUrl; private final RestTemplate restTemplate; public ChatController(RestTemplateBuilder builder) { this.restTemplate builder.build(); } PostMapping(/send) public ResponseEntityString sendChat(RequestBody ChatRequest request) { HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityChatRequest entity new HttpEntity(request, headers); ResponseEntityString response restTemplate.exchange( fastApiUrl, HttpMethod.POST, entity, String.class); return ResponseEntity.status(response.getStatusCode()).body(response.getBody()); } }但这样是非流式的前端要等FastAPI跑完整个模型推理才会收到响应体感非常差。SSE流式这部分的SpringBoot转发我放在第三章细讲。3. 后端打通从SpringBoot到FastAPI再到vLLM的完整链路3.1 用FastAPI把vLLM包装成RESTful与SSE接口FastAPI这个服务最核心的任务是把vLLM的流式输出转成前端能直接消费的SSE流。先定义请求体# app/schemas/chat.py from pydantic import BaseModel, Field from typing import Optional class ChatMessage(BaseModel): role: str Field(..., description用户或助手取值 user/assistant) content: str Field(..., description消息内容) class ChatRequest(BaseModel): messages: list[ChatMessage] temperature: float Field(0.7, ge0.0, le2.0) max_tokens: int Field(2048, ge1, le8192) stream: bool Field(True, description是否SSE流式返回)然后在路由里接收这个schema调用LLMClient# app/routers/chat.py from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse from ..services.llm_client import LLMClient from ..config import settings router APIRouter(prefix/api/chat, tags[chat]) client LLMClient(settings.vllm_base_url, settings.served_model_name) router.post(/send) async def send_chat(chat_req: ChatRequest, request: Request): if not chat_req.stream: # 非流式分支直接聚合所有增量文本 collected [] async for chunk in client.stream_chat( chat_req.messages, chat_req.temperature, chat_req.max_tokens): collected.append(chunk) return {role: assistant, content: .join(collected)} # 流式分支用SSE返回 async def event_generator(): async for chunk in client.stream_chat( chat_req.messages, chat_req.temperature, chat_req.max_tokens): if await request.is_disconnected(): break yield fdata: {json.dumps({content: chunk})}\n\n return StreamingResponse(event_generator(), media_typetext/event-stream)注意request.is_disconnected()这个检查很重要——前端如果关了页面SpringBoot连接断掉需要及时停止生成不然后台会一直算到结束浪费显存。StreamingResponse的media_type必须是text/event-stream否则一些前端库不会按SSE解析。3.2 SpringBoot如何做SSE代理SpringBoot转发SSE最省事的方式是用Spring WebFlux的WebClient因为它天生支持响应式流。在pom.xml里引入dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency然后在Controller里改用WebClientRestController RequestMapping(/api/chat) public class ChatController { Value(${fastapi.base-url}) private String fastApiBaseUrl; private final WebClient webClient; public ChatController(WebClient.Builder builder) { this.webClient builder.baseUrl(fastApiBaseUrl).build(); } PostMapping(value /send, produces text/event-stream) public FluxServerSentEventString sendChatStream(RequestBody ChatRequest request) { return webClient.post() .uri(/api/chat/send) .bodyValue(request) .retrieve() .bodyToFlux(ServerSentEvent.class) .map(sse - ServerSentEvent.builder(String.class) .data(String.valueOf(sse.data())) .build()); } }这里有个关键点PostMapping里的produces text/event-stream必须声明SpringMVC才知道返回的是一个流式响应而不是普通JSON。bodyToFlux(ServerSentEvent.class)会把FastAPI返回的SSE帧解析成ServerSentEvent对象我们原样转发给前端。有人问为什么不用RestTemplateRestTemplate是同步阻塞的它必须等响应体全部读完才能操作对于无限流式的SSE来说这会一直占着一个线程并发一多Tomcat线程池就炸。WebFlux的响应式模型能很好支撑长连接。3.3 同步与流式RESTful与SSE的消息格式约定为了让前端不用区分情况我建议统一这次接口的消息格式接口类型请求路径请求参数示例响应格式非流式RESTfulPOST /api/chat/send?streamfalse{messages:[…], temperature:0.7}JSON: {role, content}流式SSEPOST /api/chat/send?streamtrue{messages:[…], temperature:0.7}data: {content:增量文本}实际开发里我倾向于让SpringBoot的/send接口永远走SSE前端默认用流式模式只有在做单元测试、或者调第三方系统时才改用streamfalse。因为流式响应完成后前端可以自己拼接收到的增量文本效果等同于非流式。4. Vue3前端实现聊天交互SSE接收与消息渲染4.1 用fetch读取SSE流不是只有EventSource很多教程让你用EventSource但它的局限很明显——只能发GET请求不能自定义Headers。而实际聊天系统通常需要POST请求携带上下文还要带上JWT Token。所以更实用的方案是把fetch和ReadableStream组合起来手动解析SSE帧。下面这段Vue3组件代码里我实现了一个sendMessage方法script setup import { ref } from vue const messages ref([ { role: user, content: 你好介绍一下你自己 } ]) const isStreaming ref(false) const currentAssistantMessage ref() async function sendMessage() { const requestBody { messages: messages.value, stream: true, temperature: 0.7, max_tokens: 2048 } const response await fetch(/api/chat/send, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${localStorage.getItem(token)} }, body: JSON.stringify(requestBody) }) if (!response.ok || !response.body) { throw new Error(请求失败) } const reader response.body.getReader() const decoder new TextDecoder(utf-8) let buffer isStreaming.value true currentAssistantMessage.value messages.value.push({ role: assistant, content: }) while (true) { const { done, value } await reader.read() if (done) break buffer decoder.decode(value, { stream: true }) const parts buffer.split(\n\n) buffer parts.pop() // 保留最后一个不完整的部分 for (const part of parts) { const lines part.split(\n) const dataLine lines.find(line line.startsWith(data:)) if (!dataLine) continue const jsonStr dataLine.slice(5).trim() if (jsonStr [DONE]) return const chunk JSON.parse(jsonStr) const content chunk.content || currentAssistantMessage.value content messages.value[messages.value.length - 1].content currentAssistantMessage.value } } isStreaming.value false } /script这段代码的要点在于buffer的维护。reader.read()每次读到的字节流可能在一个SSE帧中间切断所以不能直接按行解析先把数据追加到buffer里再用\n\n作为分割符拆出完整的帧。每个帧内部以data:开头的行才是载荷。这里没有用EventSource但实现了同样的效果而且可以传POST body——做聊天系统时这是必选方案。4.2 消息管理与流式token渲染上面的代码里我用currentAssistantMessage保存当前这轮的累计输出并且实时更新messages最后一项的content。Vue3的响应式系统会确保每次给ref赋值DOM都同步刷新。这里要特别小心messages.value[messages.value.length - 1].content ...这种深层次赋值如果messages是ref([])Vue3只会代理第一层对数组里对象的属性赋值依然是响应式的因为Vue3通过Proxy支持深层响应。但如果你用了Object.freeze或者把数据传给了非响应式变量就可能出现DOM不更新的情况。另外模板渲染时建议给消息内容加一个white-space: pre-wrap样式因为大模型经常输出换行和缩进默认HTML会折叠空白template div classchat-container div v-for(msg, idx) in messages :keyidx :class[message, msg.role] pre classmessage-content{{ msg.content }}/pre /div /div /template style scoped .message-content { white-space: pre-wrap; word-break: break-word; font-family: inherit; } /style4.3 与SpringBoot联调代理配置与跨域开发阶段Vue运行在Vite的5173端口SpringBoot在8080端口浏览器直接发/api/chat/send会请求到Vite然后需要Vite把请求代理到SpringBoot。在vite.config.js里import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 注意SSE长连接需要关闭proxy的超时 proxyTimeout: 0, timeout: 0 } } } })这里两个timeout都设成0不然SSE连接如果超过30秒没有数据Vite代理层默认会断开。后端SpringBoot这边如果前端不走代理直接访问8080就需要解决CORS。我在SpringBoot的配置类里加Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }如果同时存在Spring Security这段配置可能会被Security的CORS过滤器覆盖。常见做法是在Security配置里显式设置http.cors(cors - cors.configurationSource(corsConfigurationSource()))5. 部署与避坑从本地跑通到内网可用的五条经验5.1 显存不够引发的OOMvLLM参数排查现象vLLM启动时报CUDA out of memory或者请求时直接崩溃日志里出现torch.cuda.OutOfMemoryError。原因--max-model-len设置得太高或者--gpu-memory-utilization设成了1.0。Qwen2.5-7B在FP16下模型权重约14G还要给KV Cache留空间。如果显存只有24Gmax-model-len开8192是极限但如果你同时跑了多个并发请求每个请求都会占用临时KV Cache超出是必然。解决先把--gpu-memory-utilization降到0.8--max-model-len降到4096再逐步上调。如果显存只有16G建议直接换Qwen2.5-3B模型。另外启动时加上--disable-log-stats能减少一些显存碎片不过影响有限。5.2 SSE连接被网关断开超时与缓冲现象前端流式接收一段后连接中断网络面板里看到连接显示(canceled)或者502。原因链路中任何一个代理或网关空闲超时、或者响应缓冲未及时flush。Nginx默认proxy_read_timeout是60秒如果模型生成一句很长的话超过60秒没有产出新token连接就被切了。SpringBoot的WebClient如果设置了readTimeout也可能触发同样问题。解决Nginx配置里调大这几个值——proxy_buffering off; proxy_read_timeout 300s; proxy_send_timeout 300s;。SpringBoot的WebClient不要设置readTimeout或者设置为0表示无限。FastAPI这边确保每一次生成后就立即yield不要等整个响应生成完再返回。5.3 CORS跨域问题SpringBoot与Vite之间现象浏览器控制台报Access to fetch at http://localhost:8080/api/chat/send from origin http://localhost:5173 has been blocked by CORS policy。原因Vite代理配置没生效或者SpringBoot没正确允许来源。有时候是allowedOriginPatterns写成了精确值但前端端口飘忽不定了。解决优先用Vite代理让浏览器以为请求是同源的这样根本不需要CORS。如果一定要走跨域SpringBoot侧把allowedOriginPatterns改为allowedOrigins(*)同时设置allowCredentials为true注意allowedOrigins(*)不能用变量要用patterns。5.4 流式响应乱序前端处理异步的坑现象流式输出时部分的字偶尔会跳位置或者结尾多了一段重复文本。原因前端解码时没有处理多字节字符被切断。比如一个中文字“我”的UTF-8编码是E6 88 91如果reader.read()恰好在这三个字节中间返回decoder.decode(value, {stream: true})会暂时无法解码但你把它追加到buffer后再用\n\n分割会把不完整的字符截断导致解析JSON失败。解决分割SSE帧时不要直接在buffer.split(\n\n)后就把最后一段丢掉而是保留在buffer里。同时用TextDecoder解码时一定要传{stream: true}这样内部会保留未完成的字节序列下一次读取时补全。上面4.1节的代码已经正确处理了这一点问题通常出在有人想当然改成split(\n)遇到回车换行就割裂。5.5 FastAPI与vLLM接口不匹配现象FastAPI报AttributeError: NoneType object has no attribute get或者前端收到{error: Internal Server Error}。原因vLLM的API在0.6版本之后delta字段里可能不只是content还有reasoning_content深度思考模型、tool_calls。旧代码只取delta.content没说明问题。更隐蔽的是模型路径配置错误导致vLLM返回404FastAPI误把404响应体当成JSON去解析。解决先手动用curl请求vLLM的流式接口把原始响应打出来看字段结构curl -N -X POST http://localhost:8001/v1/chat/completions \ -H Content-Type: application/json \ -d {model:qwen-local,messages:[{role:user,content:hi}],stream:true}对照实际输出调整解析代码。另外建议在FastAPI里加一个try-except把异常信息写进日志而不是直接让500漂到前端。6. 进阶优化让本地大模型Web应用更贴近生产6.1 上下文管理滑动窗口与Token计算聊天系统与裸调模型最大的区别在于消息历史会不断膨胀。如果把全部历史都塞进messages很快会撞上max-model-len。我在SpringBoot后端维护一张会话表存最近N轮消息调用FastAPI时先按字符粗略切掉最旧的消息再用tiktoken针对Qwen用qwen.tiktoken精确计算token数确保总长度不超过模型上下文上限的70%留出生成空间。这部分逻辑如果放在前端容易被用户改请求体绕过所以务必放后端。6.2 用流式渲染实现打字机效果前端拿到SSE增量后如果直接一次性赋值给ref虽然有网络上的增量更新但每次赋值都是把整个字符串替换视觉上仍是一行行蹦出来的。想做成逐字出现的打字机效果可以在每次拿到增量时把字符拆开用setTimeout逐个追加function typewriterAppend(text, callback) { let i 0 const timer setInterval(() { if (i text.length) { clearInterval(timer) callback() return } currentAssistantMessage.value text[i] i }, 30) }不过实测中模型生成速度往往远快于30ms一个字的显示速度如果堆积太多未显示的字符会出现“跳过打字”的跳变。更好的做法是维护一个显示队列每次从SSE收到的增量push到队列然后用一个消费者定时从队列头部取字符渲染这样生成速度和显示速度解耦。6.3 生产化补充日志、鉴权、HTTPS本地部署不等于演示完就完事。我在上线这类系统时会在SpringBoot加一层请求日志切面记录每次对话的user_id、token数、耗时FastAPI侧用uvicorn --log-config把访问日志输出到文件配合按天切割。鉴权方面前端用的是JWTSpringBoot解析后用HeaderX-User-ID传给FastAPI这样FastAPI不需要自己管用户体系也方便以后接RAG或日志审计。至于HTTPS内网系统可以用自签名证书但记得把Common Name设为服务器IP否则浏览器会拦。如果要暴露到公网建议前面挂Nginx统一终止TLSSpringBoot和FastAPI保持内网HTTP即可。做这套本地化大模型Web系统我最大的教训就是流式链路每一层都有“隐式缓存和超时”Nginx、SpringBoot WebClient、Vite代理、浏览器本身任何一层不关缓冲SSE就会被攒一批才吐一批体感就是从“打字机”变成“卡一坨再蹦一坨”。先把第5章的避坑全过一遍再开始调体验。希望帮到你。本文还有配套的精品资源点击获取
返回列表