
简介这是一套完整的大模型聊天应用工程技术栈覆盖前端、双后端与模型服务可以帮助希望掌握本地化大模型 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流式传输为什么是Vue3SpringBootFastAPIvLLM2.1 整体架构与请求流转这套系统的核心思路是“模型服务、业务后端、前端展示”三层解耦。vLLM负责干GPU推理的脏活累活FastAPI把推理能力封装成稳定的接口SpringBoot承担鉴权、会话管理、限流这些业务逻辑Vue3只关心页面和数据渲染。每一层可以独立替换、独立扩容这也是我推荐前后端分离的原因——大模型服务端的迭代速度远快于传统业务如果全部塞在一个SpringBoot进程里升级一次模型要重新构建一次整个应用运维成本会非常难看。层技术栈在本系统里的职责前端展示层Vue3 Vite页面交互、流式输出渲染、对话历史维护业务后端层SpringBootRESTful API、用户鉴权、会话管理、SSE转发模型封装层FastAPI流式接口封装、超参透传、异常归一化推理引擎层vLLM通义千问模型加载、连续批处理、KV Cache管理一次完整请求的流转大致是Vue3页面把用户输入的消息POST到SpringBoot的/api/chatSpringBoot校验完身份后用WebClient异步转发给FastAPI的/api/chatFastAPI再以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可以做到零额外依赖转发SSEFastAPI的StreamingResponse更是为这种场景设计的。下表是两者关键差异方便你给团队做决策维度SSEWebSocket方向性服务端单向推送全双工协议普通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到StreamingResponseFastAPI这一层要做的不是重复造轮子而是把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_urlhttp://localhost:8000/v1, api_keyEMPTY) async def generate_stream(messages: list[dict], max_tokens: int, temperature: float): stream await client.chat.completions.create( modelqwen, messagesmessages, max_tokensmax_tokens, temperaturetemperature, streamTrue, ) async for chunk in stream: delta chunk.choices[0].delta if delta is not None and delta.content: payload {content: delta.content} yield fdata: {json.dumps(payload, ensure_asciiFalse)}\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_typetext/event-stream, headers{ Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, } )逻辑说明AsyncOpenAI的base_url指向vLLM的8000端口api_keyEMPTY是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在这套系统里的定位是BFFBackend 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); FluxString 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的内存缓冲上限调到10MBSpring默认值是256KB虽然SSE一般每条消息不大但一旦vLLM的某个chunk里带有长文本工具调用或者代码块256KB很容易溢出报错信息又很隐晦。new SseEmitter(0L)里的0表示不设置超时时间默认超时30秒聊天生成经常超过30秒不设置为0前端连接会被服务端直接掐断。为什么选SseEmitter而不是WebSocket因为这里只需要单向转发SseEmitter开箱即用SpringMVC会对它自动处理异步请求生命周期。如果引入WebSocket需要维护会话注册表、心跳机制、断线清理线程池复杂度明显上升。但是要注意SseEmitter本身是线程安全的emitter.send()在多线程调用时内部有锁所以使用异步回调时不需要额外加同步块。4.2 Vue3前端接收SSE事件流fetchReadableStream与进度展示前端的核心工作是把SSE事件流解析成可渲染的文本。很多人第一反应是使用EventSource对象但它有两个限制只支持GET请求、不能自定义请求头。聊天场景通常要POST消息体并携带Token所以我更推荐用fetch配合ReadableStream手动解析。这样代码稍微多一点但对头、请求方式完全可控。template div classchat-window div v-foritem in chunks :keyitem.id classchat-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要设置超时为0emitter.setTimeout(0L)。前端也要做断线重连不要在报错后直接消失而是在SSE断了之后提示用户“生成中断”并提供“继续生成”按钮。5.4 现象前端显示的中文变成\uXXXX转义序列原因FastAPI里用json.dumps序列化增量内容时默认ensure_asciiTrue会把所有非英文字符转成\u形式另外SpringBoot的ResponseBodyEmitter在发送字符串时可能强制按ISO-8859-1编码导致中文再次被转义。解决Python侧safeTrue准确说是json.dumps(payload, ensure_asciiFalse)。Java侧在Controller的produces里指定编码写成MediaType.TEXT_EVENT_STREAM_VALUE ;charsetUTF-8。前端用TextDecoder(utf-8)解码。这三处任何一个遗漏都会出现中文显示问题。5.5 现象SpringBoot日志报OutOfMemoryError或者WebClient报DataBufferLimitException原因Spring WebClient默认的maxInMemorySize只有256KBFastAPI返回的一条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_urlhttp://localhost:8000/v1, api_keyEMPTY) async def one_request(sem): async with sem: start time.time() stream await client.chat.completions.create( modelqwen, messages[{role: user, content: 写一篇800字的技术博客}], streamTrue, ) 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-utilizationKV 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( modelqwen, messages[{role: user, content: hi}], max_tokens1, streamFalse, ) return {status: ok} except Exception: return {status: unavailable}健康检查里的请求不能太复杂max_tokens1就够只验证链路通不通和是否有足够显存启动新推理。从那以后我每次部署这类系统都会强制走一遍完整检查vLLM单独压测FastAPI健康检查SpringBoot转发首token计时前端断线重连演练。四步全过才会把版本交出去。模型服务这东西玄学太多了把每一步卡死才能把不稳定因素挡在交付之前。希望帮到你。本文还有配套的精品资源点击获取