简介:随着大模型应用加速落地,本地化部署成为企业保护数据隐私、降低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 一条完整的问答请求是怎么流转的

用户在浏览器里发出一条消息,前端做四件事:

  1. 先调用SpringBoot的RESTful接口,创建会话记录、保存用户问题;
  2. 接着发起一个SSE请求,带上当前会话ID和消息内容;
  3. SpringBoot收到这个流式请求后,转发给FastAPI网关;
  4. 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 完全够用,它的语义就是为服务端推送设计的。实现思路是:

  1. 前端请求SpringBoot的一个接口;
  2. SpringBoot内部用WebClient请求FastAPI;
  3. 拿到的流式数据逐段通过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在线工具先看:

  1. 响应头里是否带 Content-Type: text/event-stream ;
  2. 数据是否一段一段到达,而不是一次性返回;
  3. 特殊字符、换行符是否被正确编码。

我用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。一次想做完所有功能,出问题的时候会很难定位。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐