Docker+FastAPI+千问API,复刻豆包式流式聊天界面

Docker+FastAPI+千问API,复刻豆包式流式聊天界面

💡 核心目标:学会Docker容器化部署、FastAPI SSE流式输出,对接千问大模型,拥有一个和豆包长得几乎一样的聊天界面,实现"打字式"实时响应。

📌 前置准备:一台装了Windows/Mac/Linux的电脑、阿里云账号(用来申请千问API Key)、耐心(跟着敲,别跳步)

目录

  • 一、Docker入门:从"是什么"到"装完能用"(零基础友好)

  • 1.1 Docker到底是什么?大白话讲透

  • 1.2 Docker安装(Windows/Mac/Linux全场景)

  • 1.3 Docker常用命令(必背,90%场景够用)

  • 二、Docker Compose:多容器一键启动神器

  • 2.1 Docker Compose是什么?解决什么痛点?

  • 2.2 Docker Compose安装(全系统)

  • 2.3 Compose核心概念(新手必懂)

  • 三、千问API准备:获取真实接口Key

  • 四、项目实战:搭建后端+前端(核心环节)

  • 4.1 项目目录结构(严格对照,别乱改)

  • 4.2 后端开发:FastAPI+千问API+SSE流式输出

  • 4.3 前端开发:复刻豆包式聊天界面(HTML+CSS+JS)

  • 五、Docker配置:Dockerfile+docker-compose.yml逐行解析

  • 5.1 Dockerfile:构建镜像的"菜谱"(逐行翻译)

  • 5.2 docker-compose.yml:多容器编排配置(新手能懂)

  • 六、一键启动项目:见证奇迹的时刻

  • 6.1 环境变量配置(关键,别漏)

  • 6.2 启动命令(复制粘贴即可)

  • 6.3 运行效果演示

  • 七、常见问题排查(避坑指南)

  • 八、拓展方向(进阶可选)

一、Docker入门:从"是什么"到"装完能用"

1.1 Docker到底是什么?大白话讲透

很多新手刚接触Docker,被"镜像、容器"搞得头大,其实一句话就能懂:Docker就是一个"环境打包工具",解决"在我机子上能跑,服务器上就挂了"的千古难题。

举个生活化例子:你在自己家厨房(本地电脑)做了一道菜(写了代码),用的是自家的锅碗瓢盆(Python版本、依赖库),味道很好;但把菜谱(代码)拿到饭店厨房(服务器),因为锅碗瓢盆不一样(环境不同),做出来的菜就没法吃。

Docker的作用就是:把你家的锅碗瓢盆+菜谱+食材(代码+环境+依赖),全部打包成一个"密封的盒子"(镜像),这个盒子拿到任何一个装了Docker的厨房(电脑/服务器),打开就能做出一模一样的菜(运行代码),环境完全一致!

核心3个概念(记死,后面全用到):

  • 镜像(Image):只读的"盒子模板",相当于"盖房子的设计图纸""蛋糕的模具",里面包含了运行代码所需的所有环境(Python、依赖库、配置),是死的文件。

  • 容器(Container):镜像的运行实例,相当于"用图纸盖好的房子""用模具烤出来的蛋糕",是活的进程。一个镜像可以启动多个容器,彼此独立,互不干扰(比如一个模具烤10个蛋糕,每个蛋糕都是独立的)。

  • Dockerfile:构建镜像的"菜谱",是一个文本文件,里面写满了一条条指令,告诉Docker"先装什么、再复制什么、最后怎么启动",Docker照着这个菜谱,就能做出镜像。

🧠 醍醐灌顶总结:镜像=模板,容器=模板运行后的实例,Dockerfile=模板的制作步骤。

1.2 Docker安装(Windows/Mac/Linux全场景)

不搞复杂原理,直接上"复制粘贴"式安装步骤,全程下一步,新手也能搞定。

场景1:Windows 10/11 用户(最常用)
  1. 下载Docker Desktop:Docker Desktop官网(直接点击"Download for Windows")

  2. 安装:双击安装包,一路"Next",注意:安装过程中会提示"开启WSL 2",直接点击"确定",系统会自动安装WSL 2(如果没提示,后续打开Docker会报错,到时再手动开启即可)。

  3. 验证:安装完成后,打开Docker Desktop,等待鲸鱼图标变绿(大概1-2分钟),然后打开"命令提示符(CMD)"或"PowerShell",输入命令 docker --version,出现版本号(比如 Docker version 26.0.0, build 2ae903e),就是安装成功了。

⚠️ 注意:Windows家庭版可能需要手动开启WSL 2,具体步骤:控制面板→程序→启用或关闭Windows功能→勾选"适用于Linux的Windows子系统"和"虚拟机平台"→重启电脑,再重新安装Docker。

场景2:Mac 用户
  1. 下载Docker Desktop:Docker Desktop官网(点击"Download for Mac",根据自己的芯片选择Intel或Apple Silicon版本)。

  2. 安装:将下载的.dmg文件拖到"应用程序"文件夹,打开Docker,首次打开会提示"允许权限",点击"允许"即可。

  3. 验证:打开"终端",输入 docker --version,出现版本号即安装成功。

场景3:Linux 用户(服务器常用,以Ubuntu/Debian为例)

直接复制下面的命令,逐行粘贴到终端,回车执行即可(不要一次性复制所有,逐行来):

bash 复制代码
# 1. 更新系统包索引(确保能下载到最新的Docker包)
sudo apt-get update

# 2. 安装依赖包,允许apt通过HTTPS访问仓库
sudo apt-get install 
    ca-certificates 
    curl 
    gnupg 
    lsb-release

# 3. 添加Docker官方GPG密钥(验证下载的包是官方的,防止篡改)
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg

# 4. 设置Docker稳定版仓库(告诉系统从哪里下载Docker)
echo 
  "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu 
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

# 5. 再次更新包索引,安装Docker Engine(核心组件)
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io

# 6. 验证安装(查看Docker版本)
docker --version

✅ 安装成功标志:终端输出Docker版本号,无报错。

1.3 Docker常用命令(必背,90%场景够用)

新手不用记太多,先把这6个命令练熟,后续实战全程用到,每句命令都带解释,一看就懂。

命令 作用 示例
docker --version 查看Docker版本(验证是否安装成功) docker --version
docker images 查看本地所有镜像(相当于查看"所有模具") docker images
docker ps 查看正在运行的容器(相当于查看"正在烤的蛋糕") docker ps
docker ps -a 查看所有容器(包括停止的,相当于查看"所有烤过的蛋糕") docker ps -a
docker stop 容器ID/容器名 停止正在运行的容器(相当于"停止烤蛋糕") docker stop 123456(123456是容器ID的前6位)
docker rm 容器ID/容器名 删除容器(相当于"扔掉烤坏的蛋糕") docker rm 123456
docker exec -it 容器ID /bin/bash 进入容器内部(相当于"打开蛋糕盒子,查看里面的东西") docker exec -it 123456 /bin/bash

🧠 醍醐灌顶总结:记住"镜像管模板,容器管运行",常用命令就围绕"查看、启动、停止、删除"这几个操作,多敲2遍就记住了。

二、Docker Compose:多容器一键启动神器

2.1 Docker Compose是什么?解决什么痛点?

咱们实战的项目,目前只需要一个"Web服务容器"(FastAPI应用),但实际开发中,你可能需要多个容器:Web容器+数据库容器(MySQL)+向量库容器(Chroma)。

如果没有Docker Compose,你需要:

  1. 手动启动数据库容器,配置端口、密码;

  2. 手动启动向量库容器,配置网络;

  3. 手动启动Web容器,配置和其他两个容器的连接;

  4. 一旦重启电脑,所有容器都要重新手动启动,麻烦到崩溃!

Docker Compose的作用就是:用一个配置文件(docker-compose.yml),定义所有需要的容器,然后敲一句命令,所有容器一键启动、自动配置网络、互相连通,重启后也能一键重新启动,彻底解放双手!

类比:你要做一顿饭,需要蒸米饭、炒青菜、炖排骨,每个菜用一个锅(容器)。没有Compose,你需要一个个开火、调火候;有了Compose,你只需要按一个按钮,所有锅同时开火、自动调好转火,做完自动关火。

2.2 Docker Compose安装(全系统)

重点:Windows和Mac用户,安装Docker Desktop时,已经自带Docker Compose,不需要单独安装!只有Linux用户需要手动安装。

Linux 用户安装步骤(Ubuntu/Debian为例)
bash 复制代码
# 1. 下载Docker Compose(版本号可以去官网找最新的,这里用v2.20.2,稳定版)
sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose

# 2. 给下载的文件添加执行权限(让系统能运行它)
sudo chmod +x /usr/local/bin/docker-compose

# 3. 验证安装(查看版本号)
docker-compose --version

✅ 安装成功标志:终端输出 docker-compose version v2.20.2, build xxxxx

2.3 Compose核心概念(新手必懂)

Docker Compose的核心就是一个配置文件:docker-compose.yml,里面主要包含3个核心部分,后续实战会逐行解析,这里先提前了解:

  • version:Compose文件的格式版本(固定写3.8,最稳定、最通用);

  • services:定义所有需要启动的容器(每个容器就是一个service),比如web容器、数据库容器;

  • 每个service下的配置:比如镜像怎么来、端口怎么映射、环境变量怎么传、容器怎么重启。

🧠 醍醐灌顶总结:Docker Compose就是"容器的管家",一个配置文件管所有容器,一键启动、一键停止,不用手动操作每个容器。

三、千问API准备:获取真实接口Key

咱们要实现"真实的AI聊天",不能用模拟接口,必须接入千问大模型的真实API,步骤很简单,全程免费(阿里云百炼有免费额度)。

  1. 访问阿里云百炼平台:阿里云百炼 - DashScope(复制链接到浏览器打开);

  2. 注册/登录阿里云账号(如果没有,注册一个,实名认证后即可使用);

  3. 登录后,点击左侧菜单栏的「API-KEY 管理」;

  4. 点击「创建API-KEY」,输入名称(随便起,比如"我的千问Key"),点击确定;

  5. 创建成功后,点击「复制」,保存好这个Key(格式是 sk-xxxxxx),不要泄露给别人(泄露后别人会用你的额度)。

⚠️ 注意:千问API有免费额度(qwen-turbo模型,足够咱们实战使用),如果免费额度用完,需要充值,新手先不用管,免费额度完全够用。

四、项目实战:搭建后端+前端(核心环节)

这部分是重点,咱们严格按照"目录结构→后端→前端"的顺序来,代码逐行注释,复制粘贴就能用,不用修改任何内容(除了千问API Key)。

4.1 项目目录结构(严格对照,别乱改)

先在你的电脑上创建一个文件夹(名字随便起,比如 my_qwen_chatbot),然后按照下面的结构创建子文件夹和文件,路径和文件名不能错,否则后续启动会报错。

text 复制代码
my_qwen_chatbot/          # 项目根目录
├── app/                  # 存放后端代码和前端页面
│   ├── main.py           # FastAPI后端核心代码(接入千问API+SSE)
│   └── index.html        # 高仿豆包的前端聊天页面
├── Dockerfile            # 构建后端镜像的"菜谱"
├── docker-compose.yml    # Docker Compose配置文件(一键启动)
└── requirements.txt      # Python依赖包列表(告诉Docker要装哪些库)

✅ 操作建议:先创建根文件夹 my_qwen_chatbot,然后在里面创建 app 子文件夹,再依次创建其他文件,避免路径错误。

4.2 后端开发:FastAPI+千问API+SSE流式输出

后端核心功能:接收前端的问题,调用千问API,通过SSE流式输出(像ChatGPT、豆包一样,逐字返回回答),最后把接口暴露给前端。

第一步:先写 requirements.txt(告诉Docker要安装哪些Python依赖),在项目根目录创建 requirements.txt 文件,复制下面的内容:

text 复制代码
fastapi==0.136.0          # 核心Web框架,轻量、快,支持异步
uvicorn==0.44.0           # ASGI服务器,用来运行FastAPI应用
sse-starlette==3.3.4      # 实现SSE流式输出的核心库
python-dotenv==1.2.2      # 读取环境变量(方便管理API Key)
dashscope==1.25.17         # 千问大模型官方SDK(必须装,否则无法调用千问API)

第二步:写后端核心代码 main.py,在 app 文件夹下创建 main.py 文件,复制下面的代码,不需要修改任何内容(千问API Key后续通过环境变量传入,不用写死在代码里):

python 复制代码
import asyncio
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse
from sse_starlette.sse import EventSourceResponse
import dashscope

# ================= 初始化配置 =================
app = FastAPI(title="千问流式聊天服务")

# 千问API配置(直接写死确保生效)
DASHSCOPE_API_KEY = "DASHSCOPE_API_KEY"
dashscope.api_key = DASHSCOPE_API_KEY

# ================= 核心:无缓冲千问流式调用 =================
async def call_qwen_stream(user_message: str):
    """
    无缓冲流式调用,Python 3.13 原生asyncio.to_thread彻底解决同步转异步的缓冲问题
    """
    # 同步生成器:直接从千问拿增量chunk,无任何中间攒包
    def sync_qwen_stream():
        responses = dashscope.Generation.call(
            model="qwen-plus",
            messages=[
                {"role": "system", "content": "你是一个乐于助人的AI助手,名字叫千问。"},
                {"role": "user", "content": user_message}
            ],
            stream=True,
            result_format='message',
            # ✅ 最关键参数:禁用SDK内部缓冲,强制逐增量返回
            incremental_output=True
        )
        # 逐一遍历增量chunk,来一个yield一个,终端可实时打印验证
        for response in responses:
            if response.status_code == 200:
                chunk = response.output.choices[0].message.content
                print(chunk, end="", flush=True) # 终端逐字打印,先确认后端是真流式
                yield chunk
            else:
                error_msg = f"\n[调用出错] 状态码:{response.status_code},错误:{response.message}"
                print(error_msg)
                yield error_msg
                break

    # Python 3.9+ 原生同步转异步,无自定义队列的隐性bug
    for chunk in await asyncio.to_thread(sync_qwen_stream):
        yield chunk

# ================= API接口 =================
# 根路径:返回前端页面
@app.get("/")
async def get_index():
    return FileResponse("index.html")

# 流式聊天接口:标准SSE协议,强制无缓存
@app.get("/chat/stream")
async def chat_stream(request: Request, query: str):
    if not DASHSCOPE_API_KEY:
        return EventSourceResponse([{"data": "请先配置千问API Key!"}])

    # SSE事件生成器:来一个chunk发一个,无任何缓冲
    async def event_generator():
        async for token in call_qwen_stream(query):
            if await request.is_disconnected():
                print("客户端断开,终止流式输出")
                break
            # 标准SSE格式,强制换行,浏览器立即解析
            yield f"data: {token}\n\n"

    # ✅ 关键:强制禁用浏览器/代理缓存,确保逐字刷新
    headers = {
        "Cache-Control": "no-cache, no-store, must-revalidate, max-age=0",
        "Connection": "keep-alive",
        "X-Accel-Buffering": "no"
    }

    return EventSourceResponse(
        event_generator(),
        headers=headers,
        ping=300 # 15秒心跳保活,防止连接断开
    )

# 测试接口:模拟流式输出,排除千问SDK问题(可选)
@app.get("/test/stream")
async def test_stream(request: Request):
    async def test_event_generator():
        test_text = "这是测试流式输出,你应该能看到我逐字蹦出来!如果能看到,说明前后端传输完全正常。"
        for char in test_text:
            if await request.is_disconnected():
                break
            yield f"{char}\n\n"
            await asyncio.sleep(0.1)
    return EventSourceResponse(test_event_generator(), headers={"Cache-Control": "no-cache"})

🧠 醍醐灌顶总结:后端核心就是"接收问题→调用千问API→流式返回回答",SSE的作用是让服务器主动往前推数据,不用前端反复请求,实现"打字效果"。

4.3 前端开发:复刻豆包式聊天界面(HTML+CSS+JS)

前端核心需求:和豆包的聊天界面长得几乎一样,包含左侧侧边栏、右侧聊天区、用户输入框、消息气泡、打字光标动画,能对接后端SSE接口,实现实时聊天。

在 app 文件夹下创建 index.html 文件,复制下面的代码,不需要修改任何内容,直接用(样式已经调好,适配电脑端,和豆包界面高度相似):

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>千问 - 你的AI助手</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            background-color: #f4f5f7;
            height: 100vh;
            display: flex;
            overflow: hidden;
        }

        .sidebar {
            width: 260px;
            background-color: #ffffff;
            border-right: 1px solid #e5e6eb;
            display: flex;
            flex-direction: column;
            padding: 16px;
            flex-shrink: 0;
        }

        .new-chat-btn {
            width: 100%;
            padding: 12px;
            background-color: #1677ff;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 15px;
            cursor: pointer;
            margin-bottom: 20px;
        }

        .chat-area {
            flex: 1;
            display: flex;
            flex-direction: column;
            background-color: #f7f8fa;
        }

        .chat-header {
            height: 60px;
            background-color: #fff;
            border-bottom: 1px solid #e5e6eb;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 18px;
            font-weight: 600;
        }

        .messages-container {
            flex: 1;
            overflow-y: auto;
            padding: 20px;
            display: flex;
            flex-direction: column;
        }

        .message-wrapper {
            display: flex;
            margin-bottom: 24px;
            width: 100%;
        }

        .user-wrapper {
            justify-content: flex-end;
        }

        .ai-wrapper {
            justify-content: flex-start;
        }

        .avatar {
            width: 36px;
            height: 36px;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 18px;
            flex-shrink: 0;
            margin: 0 12px;
        }

        .ai-avatar {
            background-color: #1677ff;
            color: white;
        }

        .user-avatar {
            background-color: #f2f3f5;
            color: #4e5969;
        }

        .message-bubble {
            max-width: 70%;
            padding: 12px 16px;
            border-radius: 12px;
            font-size: 15px;
            line-height: 1.6;
            word-wrap: break-word;
            white-space: pre-wrap;
        }

        .ai-bubble {
            background-color: #ffffff;
            border-top-left-radius: 4px;
        }

        .user-bubble {
            background-color: #1677ff;
            color: white;
            border-top-right-radius: 4px;
        }

        .typing-cursor {
            display: inline-block;
            width: 8px;
            height: 18px;
            background-color: #1d2129;
            margin-left: 2px;
            animation: blink 1s infinite;
            vertical-align: middle;
        }

        @keyframes blink {
            0%, 50% { opacity: 1; }
            51%, 100% { opacity: 0; }
        }

        .input-area {
            padding: 20px;
            background-color: #fff;
            border-top: 1px solid #e5e6eb;
            flex-shrink: 0;
        }

        .input-wrapper {
            max-width: 900px;
            margin: 0 auto;
            border: 1px solid #e5e6eb;
            border-radius: 12px;
            padding: 12px 16px;
            display: flex;
            align-items: flex-end;
            background-color: #f7f8fa;
        }

        textarea {
            flex: 1;
            border: none;
            background: transparent;
            outline: none;
            resize: none;
            font-size: 15px;
            max-height: 150px;
            line-height: 1.5;
        }

        .send-btn {
            background-color: #1677ff;
            color: white;
            border: none;
            border-radius: 8px;
            padding: 8px 16px;
            cursor: pointer;
            font-size: 14px;
            margin-left: 12px;
        }

        .send-btn:disabled {
            background-color: #c9cdd4;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
    <div class="sidebar">
        <button class="new-chat-btn" onclick="clearChat()">+ 新建对话</button>
        <div class="history-list">
            <div style="padding: 10px; text-align: center;">对话历史</div>
        </div>
    </div>

    <div class="chat-area">
        <div class="chat-header">千问</div>
        <div class="messages-container" id="messagesContainer">
            <div class="message-wrapper ai-wrapper">
                <div class="avatar ai-avatar">包</div>
                <div class="message-bubble ai-bubble">你好!我是千问,有什么可以帮你的吗?</div>
            </div>
        </div>

        <div class="input-area">
            <div class="input-wrapper">
                <textarea id="userInput" placeholder="输入你的问题..." rows="1"></textarea>
                <button class="send-btn" id="sendBtn">发送</button>
            </div>
        </div>
    </div>

<script>
// ✅ 修复核心:等待页面DOM加载完成再执行JS
document.addEventListener('DOMContentLoaded', function() {
    let isGenerating = false;
    const textarea = document.getElementById('userInput');
    const sendBtn = document.getElementById('sendBtn');
    const messagesContainer = document.getElementById('messagesContainer');

    // 自动调整输入框高度
    textarea.addEventListener('input', function() {
        this.style.height = 'auto';
        this.style.height = Math.min(this.scrollHeight, 150) + 'px';
    });

    // 回车发送
    textarea.addEventListener('keydown', function(e) {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            sendMessage();
        }
    });

    // 点击发送
    sendBtn.addEventListener('click', sendMessage);

    // 清空对话
    window.clearChat = function() {
        isGenerating = false;
        sendBtn.disabled = false;
        sendBtn.innerText = '发送';
        messagesContainer.innerHTML = `
            <div class="message-wrapper ai-wrapper">
                <div class="avatar ai-avatar">包</div>
                <div class="message-bubble ai-bubble">你好!我是千问,有什么可以帮你的吗?</div>
            </div>
        `;
    };

    // 核心发送函数
    async function sendMessage() {
        if (isGenerating) return;
        const query = textarea.value.trim();
        if (!query) return;

        // 添加用户消息
        appendMessage('user', query);
        textarea.value = '';
        textarea.style.height = 'auto';

        // 锁定状态
        isGenerating = true;
        sendBtn.disabled = true;
        sendBtn.innerText = '生成中...';

        // 创建AI消息框
        const aiBubble = appendMessage('ai', '');
        const contentDiv = aiBubble.querySelector('.message-content');
        const cursor = document.createElement('span');
        cursor.className = 'typing-cursor';
        contentDiv.appendChild(cursor);

        try {
            const res = await fetch(`/chat/stream?query=${encodeURIComponent(query)}`);
            const reader = res.body.getReader();
            const decoder = new TextDecoder();

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

                const chunk = decoder.decode(value, { stream: true });
                const lines = chunk.split('\n');

                for (let line of lines) {
                    // 全局清空所有 data: 前缀,不管在什么位置
                    let cleanLine = line.replace(/data:\s*/g, '').trim();
                    if (!cleanLine) continue;
                    cursor.remove();
                    contentDiv.innerHTML += cleanLine;
                    contentDiv.appendChild(cursor);
                    scrollToBottom();
                }
            }
        } catch (err) {
            console.error(err);
        } finally {
            // 解锁
            cursor.remove();
            isGenerating = false;
            sendBtn.disabled = false;
            sendBtn.innerText = '发送';
        }
    }

    // 添加消息
    function appendMessage(role, text) {
        const wrapper = document.createElement('div');
        wrapper.className = `message-wrapper ${role}-wrapper`;

        const avatar = document.createElement('div');
        avatar.className = `avatar ${role}-avatar`;
        avatar.innerText = role === 'user' ? '我' : '包';

        const bubble = document.createElement('div');
        bubble.className = `message-bubble ${role}-bubble`;

        const content = document.createElement('span');
        content.className = 'message-content';
        content.innerHTML = text;
        bubble.appendChild(content);

        if (role === 'ai') {
            wrapper.appendChild(avatar);
            wrapper.appendChild(bubble);
        } else {
            wrapper.appendChild(bubble);
            wrapper.appendChild(avatar);
        }

        messagesContainer.appendChild(wrapper);
        scrollToBottom();
        return wrapper;
    }

    // 滚动到底部
    function scrollToBottom() {
        messagesContainer.scrollTop = messagesContainer.scrollHeight;
    }
});
</script>
</body>
</html>

🧠 醍醐灌顶总结:前端核心就是"渲染界面+调用SSE接口+逐字更新DOM",打字光标动画、消息对齐、自动滚动,都是为了还原豆包的交互体验,代码已经全部写好,直接用即可。

五、Docker配置:Dockerfile+docker-compose.yml逐行解析

这部分是"容器化部署"的核心,也是新手最容易出错的地方,我会逐行解析每个配置的含义,让你不仅知道"写什么",还知道"为什么这么写"。

5.1 Dockerfile:构建镜像的"菜谱"

Dockerfile 是用来告诉Docker"怎么构建镜像"的文本文件,每一行都是一条指令,Docker会按照指令顺序,一步步构建出包含我们应用的镜像。

在项目根目录创建 Dockerfile 文件(没有后缀名),复制下面的代码,每一行都有详细注释:

dockerfile 复制代码
# ================= 第一阶段:构建基础镜像 =================
# 1. FROM:指定基础镜像(相当于"买一个现成的空盒子")
# python:3.11-slim:轻量版Python 3.11镜像,体积小、启动快,足够咱们实战使用
FROM python:3.11-slim

# 2. LABEL:给镜像添加元数据(可选,用来标识镜像作者,显得专业)
LABEL maintainer="CSDN-博主"

# 3. WORKDIR:设置容器内的工作目录(相当于"在空盒子里创建一个文件夹,后续所有操作都在这个文件夹里")
# /app:容器内的目录,后续我们的代码都会放在这里
WORKDIR /app

# 4. COPY:复制宿主机的文件到容器内(宿主机就是你的电脑)
# 格式:COPY 宿主机路径 容器内路径
# 先复制requirements.txt:因为requirements.txt很少改,这样可以利用Docker的缓存层
# 只要requirements.txt不变,这一步就不会重新执行,安装依赖的速度会快很多
COPY requirements.txt .
# 这里的"."表示容器内的当前目录(也就是上面设置的/app目录)

# 5. RUN:在容器内执行命令(相当于"在空盒子里安装东西")
# 第一步:升级pip(避免pip版本过低,导致安装依赖失败)
# 第二步:安装requirements.txt里的所有依赖,用清华源加速(国内用户必加,否则速度很慢)
# --no-cache-dir:不缓存安装包,减少镜像体积
RUN pip install --no-cache-dir --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple 
    && pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

# 6. COPY:复制宿主机的app文件夹(后端代码+前端页面)到容器内的/app/app目录
# 因为前面已经设置了WORKDIR /app,所以这里的路径是/app/app
COPY app /app/app

# 7. CMD:容器启动时默认执行的命令(相当于"打开盒子后,自动运行里面的程序")
# uvicorn app.main:app:启动FastAPI应用,app.main表示app文件夹下的main.py,app是FastAPI实例
# --host 0.0.0.0:允许容器外部访问(如果不设,只能在容器内部访问)
# --port 8000:容器内的端口,后续会映射到宿主机的8000端口
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

🧠 醍醐灌顶总结:Dockerfile的逻辑就是"先找一个基础盒子(基础镜像)→ 在盒子里创建文件夹(WORKDIR)→ 复制依赖清单(COPY requirements.txt)→ 安装依赖(RUN)→ 复制代码(COPY app)→ 设定启动命令(CMD)",一步步把我们的应用打包成镜像。

5.2 docker-compose.yml:多容器编排配置

咱们目前只需要一个Web容器(FastAPI应用),但配置文件按照多容器的标准来写,后续添加数据库、向量库时,直接在services里加就行,不用改整体结构。

在项目根目录创建 docker-compose.yml 文件,复制下面的代码,逐行解析:

yaml 复制代码
# 1. version:指定Compose文件的格式版本(固定写3.8,最稳定、最通用,兼容所有Docker版本)
version: '3.8'

# 2. services:定义所有需要启动的容器(每个容器就是一个service)
# 这里我们只定义一个service:web(FastAPI应用容器)
services:
  
  # 2.1 定义web服务(名字可以随便起,比如web、app,后续用这个名字操作容器)
  web:
    # build:告诉Compose,这个容器的镜像怎么来(这里是通过Dockerfile构建)
    build:
      context: .  # context: . 表示使用当前目录(项目根目录)的Dockerfile来构建镜像
      dockerfile: Dockerfile  # 指定Dockerfile的文件名(默认就是Dockerfile,可省略,写出来更清晰)
    # ports:端口映射(核心配置,让宿主机能访问容器内的服务)
    # 格式:宿主机端口:容器内端口
    # 这里把宿主机的8000端口,映射到容器内的8000端口(和Dockerfile里CMD指定的端口一致)
    # 后续访问 http://localhost:8000 ,就相当于访问容器内的8000端口
    ports:
      - "8000:8000"
    # environment:设置环境变量(关键!用来传入千问API Key,不用写死在代码里)
    # DASHSCOPE_API_KEY:变量名,和后端main.py里os.getenv("DASHSCOPE_API_KEY")对应
    # ${DASHSCOPE_API_KEY}:表示从宿主机的环境变量中读取这个值(后续会教怎么设置)
    environment:
      - DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY}
    # restart:容器重启策略(新手必设,避免意外退出后需要手动重启)
    # always:无论容器因什么原因停止(除了手动停止),都会自动重启
    restart: always
    # volumes:数据卷映射(可选,这里用来实现"代码热更新",方便开发调试)
    # 把宿主机的app文件夹(后端+前端代码),映射到容器内的/app/app文件夹
    # 这样修改宿主机的代码后,容器内的代码会实时更新,不用重新构建镜像
    volumes:
      - ./app:/app/app
    # networks:网络配置(可选,单容器可省略,多容器时用来让容器互相通信)
    # 这里创建一个自定义网络,后续添加数据库等容器时,可加入同一个网络
    networks:
      - qwen-chatbot-network

# 3. networks:定义自定义网络(和上面services里的networks对应)
# driver: bridge:默认的桥接网络,适合本地开发场景
networks:
  qwen-chatbot-network:
    driver: bridge
相关推荐
费弗里1 小时前
新版本Dash完美支持原生FastAPI后端
python·fastapi·dash
小王要努力上岸2 小时前
K8S高可用集群安装 (基于Kubeadm+Docker)
docker·容器·kubernetes
Wy_编程2 小时前
docker仓库
docker·容器·eureka
亚空间仓鼠2 小时前
Docker 容器技术入门与实践 (三):Docker私有仓库
docker·容器·eureka
小陈99cyh2 小时前
安装NVIDIA Container Toolkit,让gpu容器环境跑通
运维·pytorch·docker·nvidia
雨奔2 小时前
Kubernetes Master-Node 通信全解析:路径、安全与配置
安全·容器·kubernetes
Y学院2 小时前
企业级Dify私有化部署全攻略(Docker Compose生产环境实战)
人工智能·docker·语言模型
草木红3 小时前
Python 中使用 Docker Compose
开发语言·python·docker·flask
雨奔3 小时前
Kubernetes Volume 完全指南:数据持久化与容器共享方案
云原生·容器·kubernetes