从零部署千问模型(四):对接API与搭建Web对话界面

前言

在前面三篇教程中,我们完成了环境搭建、vLLM 高性能推理部署和 Ollama 轻量部署。但命令行交互终究不够直观——本篇将带你搭建一个美观的 Web 对话界面,让千问模型拥有类似 ChatGPT 的用户体验。

我们将介绍三种方案:Open WebUI(开箱即用)、LobeChat(现代化界面)和自建前端(完全可控),满足不同场景需求。

一、Open WebUI(推荐方案)

1.1 简介

Open WebUI(原名 Ollama WebUI)是一个功能丰富的自托管 Web 界面,特性包括:

  • 类 ChatGPT 界面,支持多轮对话、流式输出
  • 多用户管理,支持 RBAC 权限控制
  • 多模型切换,同时对接 Ollama 和 OpenAI 兼容 API
  • 文档上传与 RAG(检索增强生成)
  • 对话历史保存与导出
  • Markdown 渲染、代码高亮、LaTeX 公式
  • 多语言支持(含中文)

1.2 Docker 部署(推荐)

如果已按照本系列第三篇部署了 Ollama,只需一行命令即可启动 Open WebUI:

docker run -d \
  --name open-webui \
  --restart always \
  -p 3000:8080 \
  -e OLLAMA_BASE_URL=http://host.docker.internal:11434 \
  -v open-webui-data:/app/backend/data \
  ghcr.io/open-webui/open-webui:main

如果 Ollama 运行在同一台机器上,也可以使用 host 网络:

docker run -d \
  --name open-webui \
  --restart always \
  --network host \
  -v open-webui-data:/app/backend/data \
  ghcr.io/open-webui/open-webui:main

国内拉取镜像慢,可配置加速:

# 使用镜像加速
docker pull docker.1ms.run/ghcr.io/open-webui/open-webui:main
docker tag docker.1ms.run/ghcr.io/open-webui/open-webui:main ghcr.io/open-webui/open-webui:main

1.3 对接 vLLM

如果你使用 vLLM 而非 Ollama,Open WebUI 同样支持对接:

docker run -d \
  --name open-webui \
  --restart always \
  -p 3000:8080 \
  -e OPENAI_API_BASE_URL=http://host.docker.internal:8000/v1 \
  -e OPENAI_API_KEY=not-needed \
  -v open-webui-data:/app/backend/data \
  ghcr.io/open-webui/open-webui:main

1.4 Docker Compose 完整方案

推荐使用 Docker Compose 统一管理 Ollama + Open WebUI:

# docker-compose.yml
version: "3.8"

services:
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    restart: always
    ports:
      - "11434:11434"
    volumes:
      - ollama-data:/root/.ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: open-webui
    restart: always
    ports:
      - "3000:8080"
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
      - WEBUI_AUTH=true
      - ENABLE_SIGNUP=true
    volumes:
      - open-webui-data:/app/backend/data
    depends_on:
      - ollama

volumes:
  ollama-data:
  open-webui-data:
# 启动
docker compose up -d

# 查看状态
docker compose ps

# 拉取模型
docker exec -it ollama ollama pull qwen3:8b

1.5 初始化配置

  • 访问 http://your-server:3000
  • 第一个注册的用户自动成为管理员
  • 进入「设置」→「模型」,选择已拉取的 Qwen 模型
  • 可在「设置」→「通用」中切换中文界面
  • 支持上传文档进行 RAG 问答

二、LobeChat(现代化界面)

2.1 简介

LobeChat 是一个开源的高性能聊天机器人框架,界面设计精美,功能丰富:

  • 精美的 UI 设计,支持亮/暗主题切换
  • 插件系统,支持函数调用(Function Calling)
  • 多模型服务商支持(OpenAI、Ollama、vLLM 等)
  • 角色市场,内置数百种预设角色
  • 支持语音输入(Whisper)和语音输出(TTS)
  • PWA 支持,可安装为桌面/手机应用

2.2 Docker 部署

docker run -d \
  --name lobe-chat \
  --restart always \
  -p 3210:3210 \
  -e OPENAI_API_KEY=not-needed \
  -e OPENAI_PROXY_URL=http://host.docker.internal:8000/v1 \
  -e ACCESS_CODE=qwen2026 \
  -v lobe-chat-data:/app/data \
  lobehub/lobe-chat:latest

参数说明:

  • OPENAI_PROXY_URL:指向 vLLM 或 Ollama 的 API 地址
  • ACCESS_CODE:访问密码,防止未授权访问

2.3 Docker Compose 配置

version: "3.8"

services:
  lobe-chat:
    image: lobehub/lobe-chat:latest
    container_name: lobe-chat
    restart: always
    ports:
      - "3210:3210"
    environment:
      - OPENAI_API_KEY=not-needed
      - OPENAI_PROXY_URL=http://vllm:8000/v1
      - ACCESS_CODE=qwen2026
      - DEFAULT_AGENT_CONFIG={"model":"qwen3-8b"}
    volumes:
      - lobe-chat-data:/app/data
    depends_on:
      - vllm

  vllm:
    image: vllm/vllm-openai:latest
    container_name: vllm
    restart: always
    ports:
      - "8000:8000"
    volumes:
      - ./models:/models
    command: --model /models/Qwen3-8B --served-model-name qwen3-8b --host 0.0.0.0 --trust-remote-code
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

volumes:
  lobe-chat-data:

三、自建 Web 前端

如果需要完全自定义的界面,可以用 Vue3 + FastAPI 快速搭建。下面是一个完整的实现方案。

3.1 后端 API(FastAPI)

创建项目目录:

mkdir -p qwen-web/{backend,frontend}
cd qwen-web/backend

创建 requirements.txt

fastapi==0.115.0
uvicorn[standard]==0.32.0
httpx==0.27.0
python-dotenv==1.0.1

创建 .env 配置文件:

# LLM 后端地址(vLLM 或 Ollama 均可)
LLM_BASE_URL=http://localhost:8000/v1
LLM_MODEL_NAME=qwen3-8b
LLM_API_KEY=not-needed

# 服务配置
API_HOST=0.0.0.0
API_PORT=5000

创建 main.py

import os
import json
from typing import AsyncGenerator
from dotenv import load_dotenv
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import httpx

load_dotenv()

app = FastAPI(title="Qwen Chat API")

# 跨域配置
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://localhost:8000/v1")
LLM_MODEL = os.getenv("LLM_MODEL_NAME", "qwen3-8b")
LLM_API_KEY = os.getenv("LLM_API_KEY", "not-needed")


class ChatMessage(BaseModel):
    role: str
    content: str


class ChatRequest(BaseModel):
    messages: list[ChatMessage]
    temperature: float = 0.7
    max_tokens: int = 2048


@app.post("/api/chat")
async def chat(req: ChatRequest):
    """非流式对话"""
    async with httpx.AsyncClient(timeout=120) as client:
        resp = await client.post(
            f"{LLM_BASE_URL}/chat/completions",
            headers={"Authorization": f"Bearer {LLM_API_KEY}"},
            json={
                "model": LLM_MODEL,
                "messages": [m.model_dump() for m in req.messages],
                "temperature": req.temperature,
                "max_tokens": req.max_tokens,
            },
        )
        return resp.json()


@app.post("/api/chat/stream")
async def chat_stream(req: ChatRequest):
    """流式对话 - SSE"""
    async def generate() -> AsyncGenerator[str, None]:
        async with httpx.AsyncClient(timeout=120) as client:
            async with client.stream(
                "POST",
                f"{LLM_BASE_URL}/chat/completions",
                headers={"Authorization": f"Bearer {LLM_API_KEY}"},
                json={
                    "model": LLM_MODEL,
                    "messages": [m.model_dump() for m in req.messages],
                    "temperature": req.temperature,
                    "max_tokens": req.max_tokens,
                    "stream": True,
                },
            ) as resp:
                async for line in resp.aiter_lines():
                    if line.startswith("data: "):
                        data = line[6:]
                        if data.strip() == "[DONE]":
                            yield "data: [DONE]\n\n"
                            break
                        try:
                            chunk = json.loads(data)
                            delta = chunk["choices"][0].get("delta", {})
                            content = delta.get("content", "")
                            if content:
                                yield f"data: {json.dumps({'content': content}, ensure_ascii=False)}\n\n"
                        except json.JSONDecodeError:
                            continue

    return StreamingResponse(generate(), media_type="text/event-stream")


@app.get("/api/models")
async def list_models():
    """列出可用模型"""
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(
            f"{LLM_BASE_URL}/models",
            headers={"Authorization": f"Bearer {LLM_API_KEY}"},
        )
        return resp.json()


@app.get("/api/health")
async def health():
    return {"status": "ok", "model": LLM_MODEL}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(
        "main:app",
        host=os.getenv("API_HOST", "0.0.0.0"),
        port=int(os.getenv("API_PORT", "5000")),
        reload=True,
    )
pip install -r requirements.txt
python main.py
# 启动后访问 http://localhost:5000/docs 查看 API 文档

3.2 前端界面(Vue3)

cd ../frontend
npm create vite@latest . -- --template vue
npm install axios marked highlight.js

核心组件 src/components/ChatBox.vue

<template>
  <div class="chat-container">
    <!-- 消息列表 -->
    <div class="messages" ref="messagesRef">
      <div
        v-for="(msg, i) in messages"
        :key="i"
        :class="['message', msg.role]"
      >
        <div class="avatar">{{ msg.role === 'user' ? '🧑' : '🤖' }}</div>
        <div class="content" v-html="renderMarkdown(msg.content)"></div>
      </div>
      <!-- 加载指示 -->
      <div v-if="loading" class="message assistant">
        <div class="avatar">🤖</div>
        <div class="content"><span class="dots">思考中...</span></div>
      </div>
    </div>

    <!-- 输入区域 -->
    <div class="input-area">
      <textarea
        v-model="input"
        @keydown.enter.exact.prevent="send"
        placeholder="输入消息,Enter 发送,Shift+Enter 换行..."
        rows="3"
      />
      <button @click="send" :disabled="loading || !input.trim()">
        {{ loading ? '生成中' : '发送' }}
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, nextTick } from 'vue'
import axios from 'axios'
import { marked } from 'marked'
import hljs from 'highlight.js'

const API_BASE = 'http://localhost:5000/api'

const messages = ref([
  { role: 'assistant', content: '你好!我是千问AI助手,有什么可以帮你的?' }
])
const input = ref('')
const loading = ref(false)
const messagesRef = ref(null)

// Markdown 渲染 + 代码高亮
function renderMarkdown(text) {
  const html = marked(text)
  // 高亮代码块
  const div = document.createElement('div')
  div.innerHTML = html
  div.querySelectorAll('pre code').forEach((block) => {
    hljs.highlightElement(block)
  })
  return div.innerHTML
}

// 滚动到底部
function scrollToBottom() {
  nextTick(() => {
    if (messagesRef.value) {
      messagesRef.value.scrollTop = messagesRef.value.scrollHeight
    }
  })
}

// 发送消息(流式)
async function send() {
  const text = input.value.trim()
  if (!text || loading.value) return

  // 添加用户消息
  messages.value.push({ role: 'user', content: text })
  input.value = ''
  loading.value = true
  scrollToBottom()

  // 添加空的助手消息,逐步填充
  const assistantMsg = ref({ role: 'assistant', content: '' })
  messages.value.push(assistantMsg.value)

  try {
    const response = await fetch(`${API_BASE}/chat/stream`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messages: messages.value
          .filter((m, i) => i < messages.value.length - 1)
          .map(m => ({ role: m.role, content: m.content })),
        temperature: 0.7,
        max_tokens: 2048
      })
    })

    const reader = response.body.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      const text = decoder.decode(value)
      const lines = text.split('\n')

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const data = line.slice(6)
          if (data === '[DONE]') break
          try {
            const parsed = JSON.parse(data)
            assistantMsg.value.content += parsed.content
            scrollToBottom()
          } catch (e) { /* ignore parse errors */ }
        }
      }
    }
  } catch (error) {
    assistantMsg.value.content = `❌ 请求失败: ${error.message}`
  } finally {
    loading.value = false
    scrollToBottom()
  }
}
</script>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  max-width: 800px;
  margin: 0 auto;
  background: #f5f5f5;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
}

.message {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
}

.message.user {
  flex-direction: row-reverse;
}

.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 20px;
  background: #fff;
  flex-shrink: 0;
}

.content {
  max-width: 70%;
  padding: 12px 16px;
  border-radius: 12px;
  background: #fff;
  line-height: 1.6;
}

.message.user .content {
  background: #007aff;
  color: #fff;
}

.content :deep(pre) {
  background: #1e1e1e;
  color: #d4d4d4;
  padding: 12px;
  border-radius: 8px;
  overflow-x: auto;
  margin: 8px 0;
}

.content :deep(code) {
  font-family: 'Fira Code', monospace;
  font-size: 14px;
}

.input-area {
  display: flex;
  gap: 12px;
  padding: 16px 20px;
  background: #fff;
  border-top: 1px solid #e0e0e0;
}

textarea {
  flex: 1;
  padding: 12px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 14px;
  resize: none;
  outline: none;
  font-family: inherit;
}

textarea:focus {
  border-color: #007aff;
}

button {
  padding: 12px 24px;
  background: #007aff;
  color: #fff;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
  white-space: nowrap;
}

button:disabled {
  background: #ccc;
  cursor: not-allowed;
}

.dots::after {
  content: '';
  animation: dots 1.5s infinite;
}

@keyframes dots {
  0%, 20% { content: '.'; }
  40% { content: '..'; }
  60%, 100% { content: '...'; }
}
</style>

修改 src/App.vue

<template>
  <div id="app">
    <header class="header">
      <h1>🤖 千问 AI 助手</h1>
    </header>
    <ChatBox />
  </div>
</template>

<script setup>
import ChatBox from './components/ChatBox.vue'
</script>

<style>
* { margin: 0; padding: 0; box-sizing: border-box; }

.header {
  background: #fff;
  padding: 16px;
  text-align: center;
  border-bottom: 1px solid #e0e0e0;
}

.header h1 {
  font-size: 20px;
  color: #333;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
</style>
# 启动前端开发服务器
npm run dev
# 访问 http://localhost:5173

3.3 打包部署

# 前端打包
npm run build

# 将 dist 目录复制到后端
cp -r dist ../backend/static

# 修改 main.py 添加静态文件服务
# 在 main.py 末尾添加:
'''
from fastapi.staticfiles import StaticFiles
import os

if os.path.exists("static"):
    app.mount("/", StaticFiles(directory="static", html=True), name="static")
'''

3.4 Docker 一键部署

# Dockerfile
FROM node:20-slim AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ .
RUN npm run build

FROM python:3.11-slim
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install -r requirements.txt
COPY backend/ .
COPY --from=frontend-builder /app/frontend/dist ./static
EXPOSE 5000
CMD ["python", "main.py"]
# docker-compose.yml(完整版)
version: "3.8"

services:
  vllm:
    image: vllm/vllm-openai:latest
    container_name: vllm
    restart: always
    ports:
      - "8000:8000"
    volumes:
      - ./models:/models
    command: --model /models/Qwen3-8B --served-model-name qwen3-8b --host 0.0.0.0 --trust-remote-code --gpu-memory-utilization 0.90
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]

  web:
    build: .
    container_name: qwen-web
    restart: always
    ports:
      - "5000:5000"
    environment:
      - LLM_BASE_URL=http://vllm:8000/v1
      - LLM_MODEL_NAME=qwen3-8b
      - LLM_API_KEY=not-needed
    depends_on:
      - vllm
docker compose up -d --build
# 访问 http://your-server:5000 即可使用

四、Nginx 反向代理与 HTTPS

生产环境建议通过 Nginx 统一入口,配置 HTTPS 和 WebSocket 支持:

server {
    listen 443 ssl http2;
    server_name chat.example.com;

    ssl_certificate     /etc/nginx/ssl/cert.pem;
    ssl_certificate_key /etc/nginx/ssl/key.pem;

    # 前端页面
    location / {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # API 接口
    location /api/ {
        proxy_pass http://127.0.0.1:5000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # SSE 流式输出关键配置
        proxy_buffering off;
        proxy_cache off;
        proxy_read_timeout 300s;
        chunked_transfer_encoding on;
    }

    # WebSocket(如有需要)
    location /ws {
        proxy_pass http://127.0.0.1:5000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400s;
    }
}

五、方案对比

方案部署难度功能丰富度定制性适用场景
Open WebUI⭐ 极简⭐⭐⭐⭐⭐⭐⭐开箱即用、团队使用
LobeChat⭐⭐ 简单⭐⭐⭐⭐⭐⭐⭐美观界面、插件生态
自建前端⭐⭐⭐⭐ 较难⭐⭐⭐(按需)⭐⭐⭐⭐⭐深度定制、产品开发

六、系列总结

恭喜!到这里,从零部署千问模型系列教程就全部完成了。让我们回顾整个系列的内容:

  • 第一篇:Qwen 模型简介、硬件选型、环境搭建(NVIDIA 驱动 + CUDA + PyTorch + 模型下载)
  • 第二篇:使用 vLLM 部署高性能推理服务(PagedAttention、连续批处理、OpenAI 兼容 API、Function Calling、Systemd 服务管理)
  • 第三篇:使用 Ollama 快速部署(一行命令安装、模型管理、自定义 Modelfile、多模型管理)
  • 第四篇:搭建 Web 对话界面(Open WebUI、LobeChat、自建 Vue3 + FastAPI 前端、Docker 一键部署)

部署架构总览

┌─────────────────────────────────────────────────────┐
│                    用户浏览器                        │
│              http://chat.example.com                 │
└──────────────────────┬──────────────────────────────┘
                       │ HTTPS
                 ┌─────▼─────┐
                 │   Nginx    │  反向代理 + SSL + SSE支持
                 │  (443)     │
                 └─────┬─────┘
                       │
          ┌────────────┼────────────┐
          │            │            │
   ┌──────▼──────┐ ┌──▼───┐ ┌─────▼─────┐
   │ Open WebUI  │ │Lobe  │ │ 自建Web    │
   │  (3000)     │ │Chat  │ │ (5000)    │
   │             │ │(3210)│ │ FastAPI+  │
   └──────┬──────┘ └──┬───┘ │ Vue3      │
          │            │     └─────┬─────┘
          │            │           │
          └────────────┼───────────┘
                       │ OpenAI API
              ┌────────┼────────┐
              │                 │
       ┌──────▼──────┐  ┌──────▼──────┐
       │    vLLM     │  │   Ollama    │
       │  (8000)     │  │  (11434)    │
       │ Qwen3-8B    │  │ qwen3:8b    │
       │ GPU推理     │  │ GGUF量化    │
       └─────────────┘  └─────────────┘

进阶方向

  • RAG 知识库:接入 LangChain / LlamaIndex,让模型回答基于私有文档
  • Agent 开发:利用 Function Calling 开发工具调用型 AI 助手
  • 模型微调:使用 LoRA / QLoRA 在特定领域数据上微调 Qwen
  • 多模态:部署 Qwen-VL 支持图片理解
  • 负载均衡:多实例部署 + Nginx 负载均衡应对高并发
  • 监控告警:Prometheus + Grafana 监控推理延迟和 GPU 利用率

希望这个系列教程能帮助你顺利部署千问模型!如果有问题,欢迎在评论区交流。🚀

参考资料

发表回复

后才能评论