从零部署千问模型(四):对接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 利用率
希望这个系列教程能帮助你顺利部署千问模型!如果有问题,欢迎在评论区交流。🚀
参考资料
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。







