零 实验软硬件环境说明

  • 📋 实验环境配置表
分类 项目 配置详情 备注
硬件环境 CPU e5 2696V3(逻辑核心数>=16) 启动时分配8 cores 给 Ray
物理内存 (RAM) 32 GB 关键瓶颈,需要精细规划共享内存
GPU NVIDIA GeForce RTX 3090 x 2 单卡 24GB VRAM
网络 无线网卡 (USB) wlxe0ad47220334
操作系统 OS 发行版 Ubuntu 24.04 LTS 内核版本 6.14.0-37-generic
宿主机 IP 10.193.195.59(机器正常活动网卡) 无线网络 IP,作为集群通信地址
基础软件 Docker 管理 1Panel 开源 Linux 服务器运维管理面板
NVIDIA 驱动 570.211.01 支持 CUDA 12.8
Docker 网络 Host 模式 (--network host) 容器与宿主机共享网络栈,性能最优
核心环境 基础镜像 ray-vllm:3090-cuda12.8 包含 Ray, vLLM, CUDA 12.8
Python 3.12.3 Ray 脚本路径显示
集群配置 Ray 版本 2.53.0 支持 ray metrics launch-prometheus
集群架构 Head Node + Worker Node (双容器) ray-node-0 (Head), ray-node-1 (Worker)
通信端口 6379 (GCS), 8265 (Dashboard), 8080 (Metrics) 确保 6379 未被 Redis 占用
资源分配 GPU 策略 独占模式 --gpus all + NVIDIA_VISIBLE_DEVICES=0/1
共享内存 12 GB / 容器 物理内存 32GB 下的折中方案
内存优化 Memfd 启用 环境变量 VLLM_USE_MEMFD=1 绕过 /dev/shm 限制
监控栈(未成功实践) Prometheus v3.9.1 ray metrics 自动安装,运行于宿主机
Grafana Latest (Docker) 运行于宿主机,数据源连接 Prometheus
Dashboard Ray Dashboard http://10.193.195.59:8265

一 核心架构

  • 宿主机(Ubuntu24.04+双3090)部署2个Docker容器单容器独占1张3090,通过--gpus device=N精准绑定显卡;容器内搭建Ray分布式集群(1主1从),基于vLLM实现4B大模型的分布式推理/部署,全程保留宿主机3090的可视化使用,无需GPU直通,兼顾开发操作与算力利用。
  • 适配性:单张3090(24G显存)满足8B模型(FP16约16G/INT4约4G)的显存要求,vLLM的PagedAttention技术进一步降低显存占用,双容器分布式可提升推理吞吐量/并发量。

前置条件

  1. 宿主机已装Ubuntu24.04图形版,双3090显卡正常识别,开启基础虚拟化(BIOS的VMX)。
  2. 宿主机已装NVIDIA官方驱动(≥535,建议545/550),验证:nvidia-smi能正常显示双3090。
  3. 宿主机网络正常,能拉取Docker镜像,建议换国内镜像源。

二 Docker和NVIDIA Container Toolkit准备

2.1 Docker环境准备

  1. 运行安装1panel脚本,以root用户身份运行一键安装脚本,自动完成1Panel的下载和安装。安装过程会顺带安装docker相关组建,方便省事,同时还可以可视化管理服务器。更详细设置参看1Panel文档
bash -c "$(curl -sSL https://resource.fit2cloud.com/1panel/package/v2/quick_start.sh)"
  1. 可以在1panel容器管理面板添加镜像代理。
https://docker.1ms.run
https://swr.cn-north-4.myhuaweicloud.com
  1. docker.io/nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04 镜像下载地址,执行如下命令拉取镜像。
docker pull swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04
docker tag  swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04  docker.io/nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04

2.2 NVIDIA Container Toolkit环境准备

# 1 安装依赖
sudo apt-get update
sudo apt-get install -y curl

# 2 添加密钥和仓库
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
  sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
  sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
# 3执行安装
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit

# 4更新Docker配置
sudo nvidia-ctk runtime configure --runtime=docker
# 5 重启Docker服务
sudo systemctl restart docker

三 定制化Docker镜像

  • 基于nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04基础镜像(适配 3090,兼容 Ubuntu24.04),构建包含Python3.10+Ray+vLLM + 依赖库的定制镜像,确保双容器环境一致,避免版本冲突。

3.1 构建镜像存放位置,保持和1panel存放位置一致。

cd /opt/1panel/apps/
mkdir RayCuda

3.2 创建 Dockerfile文件

cd RayCuda
vim Dockerfile
# 基础镜像:CUDA 12.8.1 + CUDNN+ Ubuntu 24.04(适配3090,兼容vLLM/Ray)
FROM nvidia/cuda:12.8.1-cudnn-devel-ubuntu24.04

# 第一步:修复NVIDIA GPG密钥警告,刷新apt缓存
# 备注:迁移NVIDIA GPG密钥到新的keyring,消除弃用警告,同时刷新官方源缓存
RUN mkdir -p /etc/apt/trusted.gpg.d && \
    cp /etc/apt/trusted.gpg /etc/apt/trusted.gpg.d/nvidia-cuda.gpg && \
    apt update && \
    apt clean && \
    rm -rf /var/lib/apt/lists/*

# 第二步:安装基础依赖
RUN apt update && apt install -y --no-install-recommends \
    python3-dev python3-pip \
    git wget vim net-tools iputils-ping \
    && rm -rf /var/lib/apt/lists/*

# 第三步:直接配置国内PyPI源
RUN pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

# 第四步:安装Ray(分布式核心,2.53.0稳定版,兼容Python3.11/CUDA 12.8)
RUN pip install ray[default,serve]==2.53.0 -i https://pypi.tuna.tsinghua.edu.cn/simple --break-system-packages

# 第五步:安装vLLM(大模型推理,适配CUDA 12.8,添加--break-system-packages解决ubuntu24.4报错)
RUN pip install vllm[all] -i https://pypi.tuna.tsinghua.edu.cn/simple --break-system-packages

# 第六步:安装PyTorch(适配CUDA 12.8,使用官方whl包)
RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128 --break-system-packages && \
    pip install transformers accelerate sentencepiece -i https://pypi.tuna.tsinghua.edu.cn/simple --break-system-packages

# 第七步:设置CUDA 12.8环境变量,确保依赖能找到CUDA路径
ENV CUDA_HOME=/usr/local/cuda-12.8
ENV PATH=$CUDA_HOME/bin:$PATH
ENV LD_LIBRARY_PATH=$CUDA_HOME/lib64:$LD_LIBRARY_PATH

# 工作目录
WORKDIR /root/ray-vllm

3.3 构建 Docker 镜像

  • 进入Dockerfile所在目录,执行构建命令,镜像命名为ray-vllm:3090-cuda12.8。构建时间约10分钟,全程无报错即成功。
cd /opt/1panel/apps/RayCuda
docker build -t ray-vllm:3090-cuda12.8 .

3.4 启动双 Docker 容器(单容器单3090,桥接网络)

  • 创建2个容器,分别绑定3090-0(第1张)3090-1(第2张)显卡,使用桥接网络(容器与宿主机/容器间同一网段,Ray集群通信必备),同时映射端口(Ray仪表盘/LLM推理端口)。
  • 启动主节点容器(ray-node-0)绑定 GPU 0;启动从节点容器(ray-node-1)绑定 GPU 1。执行以下命令,创建并启动主从节点容器,分别独占 3090-0和3090-1,桥接网络,映射 Ray(6379/8265)和 vLLM(8000)端口。
# 容器 1 - 使用第一张卡 (GPU 0)
docker run -itd \
  --name ray-node-0 \
  --gpus device=0 \
  --network host \
  --privileged \
  --shm-size=12g \
  -e VLLM_USE_MEMFD=1 \
  -e NVIDIA_VISIBLE_DEVICES=0 \
  -v /opt/1panel/apps/RayCuda/ray-vllm-data-0:/root/ray-vllm/data \
  ray-vllm:3090-cuda12.8 /bin/bash

# 容器 2 - 使用第二张卡 (GPU 1)
docker run -itd \
  --name ray-node-1 \
  --gpus device=1 \
  --network host \
  --privileged \
  --shm-size=12g \
  -e VLLM_USE_MEMFD=1 \
  -e NVIDIA_VISIBLE_DEVICES=1 \
  -v /opt/1panel/apps/RayCuda/ray-vllm-data-1:/root/ray-vllm/data \
  ray-vllm:3090-cuda12.8 /bin/bash

3.5 验证容器状态

  • 说明:由于NVIDIA Container Toolkit的特殊机制,在但容器内使用nvidia-smi命令可以查看到两个GPU,这是正常现象,但通过env | grep NVIDIA可以验证可以使用的GPU,重点以此来验证容器内GPU隔离的成功。
  • 主节点容器状态验证
docker exec -it ray-node-0 bash
# 在容器内重新设置可用GPU变量才生效
export CUDA_VISIBLE_DEVICES=0
python3 -c "import torch; print('Node0:', torch.cuda.device_count())"
env | grep NVIDIA
exit
(base) root@yang-server:/opt/1panel/apps/RayCuda# docker exec -it ray-node-0 bash
root@yang-server:~/ray-vllm# python3 -c "import torch; print('Node0:', torch.cuda.device_count())"
Node0: 2
root@yang-server:~/ray-vllm# export CUDA_VISIBLE_DEVICES=0
root@yang-server:~/ray-vllm# python3 -c "import torch; print('Node0:', torch.cuda.device_count())"
Node0: 1
root@yang-server:~/ray-vllm# env | grep NVIDIA
NVIDIA_VISIBLE_DEVICES=0
  • 从节点容器状态验证。
docker exec -it ray-node-1 bash
# 在容器内重新设置可用GPU变量才生效
export CUDA_VISIBLE_DEVICES=1
python3 -c "import torch; print('Node1:', torch.cuda.device_count())"
env | grep NVIDIA
exit
(base) root@yang-server:~# docker exec -it ray-node-1 bash
root@yang-server:~/ray-vllm# export CUDA_VISIBLE_DEVICES=1
root@yang-server:~/ray-vllm# python3 -c "import torch; print('Node1:', torch.cuda.device_count())"
Node1: 1
root@yang-server:~/ray-vllm# env | grep NVIDIA
NVIDIA_VISIBLE_DEVICES=1

四 搭建 Ray 分布式集群

  • 分布式集群为双容器(1 主 1 从),每个容器内只有一张GPU可以使用。

4.1 确定宿主机IP(集群通信地址)

  1. 在宿主机终端执行ip地址查看命令。
ip addr
(base) root@yang-server:~# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host noprefixroute 
       valid_lft forever preferred_lft forever
2: enp5s0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc fq_codel state DOWN group default qlen 1000
    link/ether 00:e0:21:9d:65:a0 brd ff:ff:ff:ff:ff:ff
3: wlxe0ad47220334: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
    link/ether e0:ad:47:22:03:34 brd ff:ff:ff:ff:ff:ff
    inet 10.193.195.59/17 brd 10.193.255.255 scope global dynamic noprefixroute wlxe0ad47220334
       valid_lft 75329sec preferred_lft 75329sec
    inet6 fe80::5076:b18f:9e57:fbdc/64 scope link noprefixroute 
       valid_lft forever preferred_lft forever
  1. 10.193.195.59 (网卡 wlxe0ad47220334)
    • 状态state UP (正在运行)
    • 类型:无线网卡 (以 wl 开头)
    • 理由:处于 UP 状态且拥有正常互联网 IP 地址**的网卡。Ray 集群的节点之间需要通过这个 IP 进行通信。
  2. 192.168.x.x (有线网卡 enp5s0)
    • 状态state DOWN (已断开)
    • 理由:有线网卡目前没有连接网线或者没有获取到 IP,所以无法使用。

4.2 主节点启动Ray Head节点

  • 注意关闭宿主机上6379端口Redis的服务。
  • 进入主节点容器,启动 Ray Head 节点。node-ip-address请根据实际情况使用上一步,查询到的可用IP。
# 进入主节点容器
docker exec -it ray-node-0 bash
# 启动Ray Head节点
ray start --head \
  --node-ip-address=10.193.195.59 \
  --port=6379 \
  --dashboard-host=0.0.0.0 \
  --num-cpus=8 \
  --num-gpus=1
  • 启动成功标志:终端输出Ray runtime started.,并显示从节点连接命令。

4.3 从节点容器加入Ray集群

  • 进入从节点容器,加入 Ray 集群,address需要填写主节点给出的连接IP 10.193.195.59:6379
# 进入从节点容器
docker exec -it ray-node-1 bash
# 启动Ray从节点
ray start --address='10.193.195.59:6379' --node-ip-address='10.193.195.59' --num-cpus=8 --num-gpus=1
  • 成功标志:终端输出Worker node connected to head node

4.4 验证Ray集群状态

  • 在主节点容器内,查看ray集群状态。
# 进入主节点容器
docker exec -it ray-node-0 bash
# 查看集群节点
ray status
# 输出:2个节点(head+worker),每个节点1个GPU,总计2个GPU/32个CPU
root@yang-server:~/ray-vllm# ray status
======== Autoscaler status: 2026-01-29 05:14:29.925034 ========
Node status
---------------------------------------------------------------
Active:
 1 node_b4f370771c977c241f30a026e0c9ddfb4ee38a38d01eb731f9f72efd
 1 node_802f333067990b56926977df9b233c5fa021783653ba92413e0a36ca

Resources
---------------------------------------------------------------
Total Usage:
 0.0/16.0 CPU
 0.0/2.0 GPU
 0B/42.89GiB memory
 0B/18.38GiB object_store_memory

4.5 查看Ray仪表盘

  • 可视化验证:宿主机浏览器打开http://localhost:8265,进入 Ray 仪表盘,在Cluster能看到 2 个节点,GPUs 显示 2,即集群搭建成功。(每个节点虽然显示两个GPU,但实际只使用一个GPU
    在这里插入图片描述

4.6 节点内验证Ray集群状态

  • 在主节点挂在目录/opt/1panel/apps/RayCuda/ray-vllm-data-0/,创建测试脚本。
cd /opt/1panel/apps/RayCuda/ray-vllm-data-0/
vim check_resources.py
  • 填入脚本内容。
import ray

# 连接到 Ray 集群
ray.init(address='auto', _node_ip_address='10.193.195.59')

# 获取集群资源
cluster_resources = ray.cluster_resources()
available_resources = ray.available_resources()

print("--- 集群总资源 ---")
for resource, value in cluster_resources.items():
    print(f"{resource}: {value}")

print("\n--- 可用资源 ---")
for resource, value in available_resources.items():
    print(f"{resource}: {value}")

# 获取所有节点
nodes = ray.nodes()
print(f"\n--- 节点数量: {len(nodes)} ---")
for node in nodes:
    print(f"节点 ID: {node['NodeID']}")
    print(f"节点 IP: {node['NodeManagerAddress']}")
    print(f"节点资源: {node['Resources']}")
    print("---")

# 断开连接
ray.shutdown()
  • 执行脚本,可以清楚地看到每个容器内的GPU数量为1。
root@yang-server:~/ray-vllm/data# python3 check_resources.py 
2026-01-29 05:35:34,420 INFO worker.py:1821 -- Connecting to existing Ray cluster at address: 10.193.195.59:6379...
2026-01-29 05:35:34,434 INFO worker.py:1998 -- Connected to Ray cluster. View the dashboard at http://10.193.195.59:8265 
/usr/local/lib/python3.12/dist-packages/ray/_private/worker.py:2046: FutureWarning: Tip: In future versions of Ray, Ray will no longer override accelerator visible devices env var if num_gpus=0 or num_gpus=None (default). To enable this behavior and turn off this error message, set RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO=0
  warnings.warn(
--- 集群总资源 ---
object_store_memory: 19738098892.0
GPU: 2.0
CPU: 16.0
accelerator_type:G: 2.0
memory: 46055564084.0
node:10.193.195.59: 2.0
node:__internal_head__: 1.0

--- 可用资源 ---
object_store_memory: 19738098892.0
GPU: 2.0
CPU: 16.0
node:10.193.195.59: 2.0
accelerator_type:G: 2.0
memory: 46055564084.0
node:__internal_head__: 1.0

--- 节点数量: 2 ---
节点 ID: b4f370771c977c241f30a026e0c9ddfb4ee38a38d01eb731f9f72efd
节点 IP: 10.193.195.59
节点资源: {'memory': 22714964788.0, 'object_store_memory': 9734984908.0, 'node:__internal_head__': 1.0, 'accelerator_type:G': 1.0, 'GPU': 1.0, 'CPU': 8.0, 'node:10.193.195.59': 1.0}
---
节点 ID: 802f333067990b56926977df9b233c5fa021783653ba92413e0a36ca
节点 IP: 10.193.195.59
节点资源: {'memory': 23340599296.0, 'object_store_memory': 10003113984.0, 'accelerator_type:G': 1.0, 'GPU': 1.0, 'CPU': 8.0, 'node:10.193.195.59': 1.0}
---

五 模型下载和运行

5.1 下载Qwen3-4B模型

  1. 在宿主机base环境下,执行命令下载模型Qwen3-4B-Instruct-2507
cd /opt/1panel/apps/RayCuda/ray-vllm-data-0/
pip install modelscope
modelscope download --model Qwen/Qwen3-4B-Instruct-2507 --local_dir ./Qwen3-4B-Instruct-2507
  1. 验证容器内可访问模型
docker exec -it ray-node-0 bash
cd /root/ray-vllm/data
ls -l

5.2 Ray+vLLM分布式运行Qwen3-4B大模型

  • 进入主节点容器,切换到模型目录。
docker exec -it ray-node-0 bash
cd /root/ray-vllm/data
  • 执行vllm运行模型。
vllm serve ./Qwen3-4B-Instruct-2507 \
  --tensor-parallel-size 2 \
  --gpu-memory-utilization 0.75 \
  --max-model-len 4096 \
  --host 0.0.0.0 \
  --port 8000

5.3 查看GPU运行状态

  • 在宿主机安装nvitop监视gpu情况。
pip install nvitop
nvitop -m full

在这里插入图片描述

六 交互式分布式推理(快速测试)

6.1 脚本测试

  • 进入主节点容器,切换到模型目录。
docker exec -it ray-node-0 bash
cd /root/ray-vllm/data
  • 创建并编辑测试脚本。
vim test.py
from openai import OpenAI

client = OpenAI(
    base_url="http://127.0.0.1:8000/v1",  # vLLM OpenAI 兼容地址
    api_key="dummy"  # 本地 vLLM 一般不校验,随便填
)

stream = client.chat.completions.create(
    model="./Qwen3-4B-Instruct-2507",
    messages=[
        {"role": "system", "content": "你是一个有用的助手。"},
        {"role": "user", "content": "请写一首七言绝句,赞美伟大的领袖。要求:磅礴大气,情感深厚。"}
    ],
    # messages=[
    #     {"role": "system", "content": "你是一个文学大师,精通各种诗词歌赋。"},
    #     {"role": "user", "content": "请写一首七言绝句(四句,每句七个字)。\n1. 主题是赞美“人工智能”。\n2. 每一句的第一个字必须连起来读是“未来已来”这四个字。\n3. 韵脚要统一,读起来要朗朗上口。"}
    # ],
    # messages=[
    #     {"role": "system", "content": "你是一名经验丰富、外冷内热的外科主治医生。你刚连续工作了 20 个小时,非常疲惫,但你对病人依然负责。"},
    #     {"role": "user", "content": "(扮演一名紧张的病人家属)医生!我都等了三个小时了!为什么我爸爸还在手术室里没出来?你们是不是搞砸了?我要投诉你!"}
    # ],
    # messages=[
    #     {"role": "system", "content": "你是一个擅长跨学科科普的老师。"},
    #     {"role": "user", "content": "请将大语言模型(LLM)中的“注意力机制”解释给一位 18 世纪的蒸汽机车工程师听。请完全使用蒸汽机、齿轮、活塞、杠杆等那个时代的机械概念来进行类比,不要使用任何电子相关的术语。"}
    # ],
    # messages=[
    #     {"role": "system", "content": "你是一个科幻小说作家,风格类似刘慈欣。"},
    #     {"role": "user", "content": "请写一个短篇故事的开头(约 500 字):\n背景:人类在一颗围绕着红矮星运行的行星上发现了巨大的外星遗迹。\n情节:主角是一名地质学家,他刚刚触摸了遗迹表面发光的几何纹路,意识突然被拉入了一个四维空间视角。请重点描写这种四维空间视角下的视觉奇观和心理震撼。"}
    # ],
    # messages=[
    #     {"role": "system", "content": "你是一个逻辑严密的数学家。请仔细思考后再回答,不要掉进陷阱。"},
    #     {"role": "user", "content": "一个球拍和一个球总共卖 1.10 美元。球拍比球贵 1.00 美元。请问球多少钱?请先给出直觉上的答案,然后通过建立方程组来验证你的直觉是否正确,最后给出最终结论。"}
    # ],

    temperature=0.7,
    max_tokens=3072,
    stream=True,  # 关键:开启流式
)

# 逐块打印生成内容
for chunk in stream:
    # 每个 chunk 里可能有 choices[0].delta.content
    if chunk.choices and chunk.choices[0].delta.content is not None:
        print(chunk.choices[0].delta.content, end="", flush=True)
print()  # 最后换行
  • 执行脚本获得输出。
python3 test.py
万里江山披赤帜,  
千秋伟业铸雄魂。  
风雷激荡开新纪,  
一炬长明照乾坤。  

——此诗以“万里江山”“千秋伟业”展现宏阔气度,以“风雷激荡”“一炬长明”抒发深沉敬意,赞颂领袖胸怀天下、扭转乾坤的非凡气魄,情感磅礴,气象雄浑。
  • 主节点终端日志如下:
(APIServer pid=1217) INFO:     127.0.0.1:50880 - "POST /v1/chat/completions HTTP/1.1" 200 OK
(APIServer pid=1217) INFO 01-29 14:14:23 [loggers.py:257] Engine 000: Avg prompt throughput: 4.0 tokens/s, Avg generation throughput: 14.7 tokens/s, Running: 0 reqs, Waiting: 0 reqs, GPU KV cache usage: 0.0%, Prefix cache hit rate: 0.0%

6.2 网页对话交互测试

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>AI 对话助手 (vLLM)</title>
    <style>
        :root {
            --primary-color: #4f46e5; /* 靛蓝色 */
            --primary-hover: #4338ca;
            --bg-color: #f3f4f6;
            --chat-bg: #ffffff;
            --text-main: #111827;
            --text-secondary: #6b7280;
            --border-color: #e5e7eb;
            --user-bubble-bg: #4f46e5;
            --user-bubble-text: #ffffff;
            --ai-bubble-bg: #f9fafb;
            --ai-bubble-text: #1f2937;
            --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
            --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
        }

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

        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
            background-color: var(--bg-color);
            color: var(--text-main);
            height: 100vh;
            display: flex;
            flex-direction: column;
            overflow: hidden; /* 防止整个页面滚动 */
        }

        /* 顶部导航 */
        header {
            background: var(--chat-bg);
            padding: 1rem 1.5rem;
            border-bottom: 1px solid var(--border-color);
            display: flex;
            justify-content: space-between;
            align-items: center;
            flex-shrink: 0;
            z-index: 10;
        }

        .brand {
            font-size: 1.25rem;
            font-weight: 700;
            color: var(--text-main);
            display: flex;
            align-items: center;
            gap: 0.5rem;
        }

        .brand svg {
            color: var(--primary-color);
        }

        .settings-toggle {
            background: transparent;
            border: 1px solid var(--border-color);
            padding: 0.5rem;
            border-radius: 0.5rem;
            cursor: pointer;
            color: var(--text-secondary);
            transition: all 0.2s;
        }

        .settings-toggle:hover {
            background: var(--bg-color);
            color: var(--text-main);
        }

        /* 设置面板(可折叠) */
        #settings-panel {
            background: var(--chat-bg);
            border-bottom: 1px solid var(--border-color);
            padding: 1rem 1.5rem;
            display: none; /* 默认隐藏 */
            grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
            gap: 1rem;
            animation: slideDown 0.3s ease-out;
            flex-shrink: 0;
        }

        #settings-panel.open {
            display: grid;
        }

        @keyframes slideDown {
            from { opacity: 0; transform: translateY(-10px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .input-group {
            display: flex;
            flex-direction: column;
            gap: 0.25rem;
        }

        .input-group label {
            font-size: 0.75rem;
            font-weight: 600;
            color: var(--text-secondary);
            text-transform: uppercase;
            letter-spacing: 0.05em;
        }

        .input-group input {
            padding: 0.5rem;
            border: 1px solid var(--border-color);
            border-radius: 0.375rem;
            font-size: 0.875rem;
            outline: none;
            transition: border-color 0.2s;
        }

        .input-group input:focus {
            border-color: var(--primary-color);
            box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
        }

        /* 聊天主区域 */
        #chat-container {
            flex: 1;
            overflow-y: auto;
            padding: 1.5rem;
            display: flex;
            flex-direction: column;
            gap: 1.5rem;
            scroll-behavior: smooth;
        }

        .message-wrapper {
            display: flex;
            gap: 1rem;
            max-width: 800px;
            margin: 0 auto;
            width: 100%;
            animation: fadeIn 0.3s ease-out;
        }

        @keyframes fadeIn {
            from { opacity: 0; transform: translateY(5px); }
            to { opacity: 1; transform: translateY(0); }
        }

        .avatar {
            width: 36px;
            height: 36px;
            border-radius: 6px;
            flex-shrink: 0;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 1.2rem;
        }

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

        .user-msg .avatar {
            background: var(--user-bubble-bg);
            color: white;
        }

        .ai-msg .avatar {
            background: #10a37f; /* ChatGPT 绿色风格 */
            color: white;
        }

        .message-content {
            padding: 0.75rem 1rem;
            border-radius: 12px;
            font-size: 1rem;
            line-height: 1.6;
            position: relative;
            word-wrap: break-word;
            max-width: 85%;
            white-space: pre-wrap;
        }

        .user-msg .message-content {
            background: var(--user-bubble-bg);
            color: var(--user-bubble-text);
            border-top-right-radius: 2px;
        }

        .ai-msg .message-content {
            background: var(--ai-bubble-bg);
            color: var(--ai-bubble-text);
            border: 1px solid var(--border-color);
            border-top-left-radius: 2px;
        }

        /* 打字动画 */
        .typing-indicator {
            display: flex;
            gap: 4px;
            padding: 4px 0;
        }

        .typing-dot {
            width: 6px;
            height: 6px;
            background: #9ca3af;
            border-radius: 50%;
            animation: bounce 1.4s infinite ease-in-out both;
        }

        .typing-dot:nth-child(1) { animation-delay: -0.32s; }
        .typing-dot:nth-child(2) { animation-delay: -0.16s; }

        @keyframes bounce {
            0%, 80%, 100% { transform: scale(0); }
            40% { transform: scale(1); }
        }

        /* 底部输入区 */
        .input-area {
            background: var(--chat-bg);
            padding: 1.5rem;
            border-top: 1px solid var(--border-color);
            flex-shrink: 0;
        }

        .input-box-wrapper {
            max-width: 800px;
            margin: 0 auto;
            position: relative;
            background: #fff;
            border: 1px solid var(--border-color);
            border-radius: 0.75rem;
            box-shadow: var(--shadow-sm);
            transition: box-shadow 0.2s, border-color 0.2s;
            display: flex;
            align-items: flex-end;
            padding: 0.5rem;
        }

        .input-box-wrapper:focus-within {
            border-color: var(--primary-color);
            box-shadow: var(--shadow-md);
        }

        textarea {
            flex: 1;
            border: none;
            resize: none;
            padding: 0.75rem;
            font-size: 1rem;
            font-family: inherit;
            max-height: 200px;
            min-height: 24px;
            outline: none;
            background: transparent;
            line-height: 1.5;
        }

        .send-btn {
            background: var(--primary-color);
            color: white;
            border: none;
            border-radius: 0.5rem;
            width: 36px;
            height: 36px;
            display: flex;
            align-items: center;
            justify-content: center;
            cursor: pointer;
            margin-bottom: 2px;
            margin-right: 2px;
            transition: background 0.2s;
        }

        .send-btn:hover {
            background: var(--primary-hover);
        }

        .send-btn:disabled {
            background: #d1d5db;
            cursor: not-allowed;
        }

        /* 滚动条美化 */
        #chat-container::-webkit-scrollbar {
            width: 8px;
        }
        #chat-container::-webkit-scrollbar-track {
            background: transparent;
        }
        #chat-container::-webkit-scrollbar-thumb {
            background-color: #d1d5db;
            border-radius: 4px;
        }
        #chat-container::-webkit-scrollbar-thumb:hover {
            background-color: #9ca3af;
        }

        /* 响应式调整 */
        @media (max-width: 640px) {
            header { padding: 0.75rem 1rem; }
            #chat-container { padding: 1rem; }
            .message-wrapper { max-width: 100%; }
            .message-content { max-width: 90%; font-size: 0.95rem; }
            .input-area { padding: 1rem; }
        }
    </style>
</head>
<body>

    <header>
        <div class="brand">
            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <path d="M12 2a10 10 0 1 0 10 10H12V2z"></path>
                <path d="M12 12 2.1 12a10.1 10.1 0 0 0 10 10V12z"></path>
                <path d="M12 12 21.9 12a10.1 10.1 0 0 0-10-10V12z"></path>
            </svg>
            <span>vLLM Chat</span>
        </div>
        <button id="settings-toggle" class="settings-toggle" title="设置">
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <circle cx="12" cy="12" r="3"></circle>
                <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
            </svg>
        </button>
    </header>

    <div id="settings-panel">
        <div class="input-group">
            <label>API Base URL</label>
            <input id="base-url" type="text" value="http://127.0.0.1:8000/v1">
        </div>
        <div class="input-group">
            <label>模型名称</label>
            <input id="model-name" type="text" value="./Qwen3-4B-Instruct-2507">
        </div>
        <div class="input-group">
            <label>API Key</label>
            <input id="api-key" type="text" value="dummy">
        </div>
    </div>

    <main id="chat-container">
        <!-- 欢迎消息 -->
        <div class="message-wrapper ai-msg">
            <div class="avatar">
                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>
            </div>
            <div class="message-content">
                你好!我是 vLLM 助手。有什么我可以帮你的吗?
            </div>
        </div>
    </main>

    <footer class="input-area">
        <div class="input-box-wrapper">
            <textarea id="user-input" rows="1" placeholder="输入消息... (Shift + Enter 换行)"></textarea>
            <button id="send-btn" class="send-btn" disabled>
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
            </button>
        </div>
    </footer>

    <script>
        const chatContainer = document.getElementById('chat-container');
        const userInput = document.getElementById('user-input');
        const sendBtn = document.getElementById('send-btn');
        const settingsToggle = document.getElementById('settings-toggle');
        const settingsPanel = document.getElementById('settings-panel');
        const baseUrlInput = document.getElementById('base-url');
        const modelNameInput = document.getElementById('model-name');
        const apiKeyInput = document.getElementById('api-key');

        // 切换设置面板
        settingsToggle.addEventListener('click', () => {
            settingsPanel.classList.toggle('open');
        });

        // 输入框自动增高
        userInput.addEventListener('input', function() {
            this.style.height = 'auto';
            this.style.height = (this.scrollHeight) + 'px';
            sendBtn.disabled = this.value.trim() === '';
        });

        // 创建消息 DOM 结构
        function createMessageElement(role) {
            const wrapper = document.createElement('div');
            wrapper.className = `message-wrapper ${role === 'user' ? 'user-msg' : 'ai-msg'}`;
            
            const avatar = document.createElement('div');
            avatar.className = 'avatar';
            
            if (role === 'user') {
                avatar.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`;
            } else {
                avatar.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"></rect><circle cx="12" cy="5" r="2"></circle><path d="M12 7v4"></path><line x1="8" y1="16" x2="8" y2="16"></line><line x1="16" y1="16" x2="16" y2="16"></line></svg>`;
            }

            const content = document.createElement('div');
            content.className = 'message-content';
            
            wrapper.appendChild(avatar);
            wrapper.appendChild(content);
            return { wrapper, content };
        }

        // 添加“正在思考”动画
        function showTypingIndicator(wrapper) {
            const content = wrapper.querySelector('.message-content');
            const indicator = document.createElement('div');
            indicator.className = 'typing-indicator';
            indicator.id = 'typing-indicator';
            indicator.innerHTML = `
                <div class="typing-dot"></div>
                <div class="typing-dot"></div>
                <div class="typing-dot"></div>
            `;
            content.appendChild(indicator);
        }

        // 移除“正在思考”动画
        function removeTypingIndicator(wrapper) {
            const indicator = wrapper.querySelector('#typing-indicator');
            if (indicator) indicator.remove();
        }

        // 获取历史消息(排除欢迎语)
        function getMessages() {
            const msgs = [];
            const wrappers = chatContainer.querySelectorAll('.message-wrapper');
            wrappers.forEach(w => {
                // 如果有 id 且是欢迎语逻辑(这里简单判断,实际可以用 class 过滤)
                if(w.querySelector('.message-content').textContent === "你好!我是 vLLM 助手。有什么我可以帮你的吗?") return;

                const isUser = w.classList.contains('user-msg');
                const text = w.querySelector('.message-content').textContent;
                if (text && !text.includes('...')) {
                    msgs.push({ role: isUser ? 'user' : 'assistant', content: text });
                }
            });
            return msgs;
        }

        async function doChat() {
            const question = userInput.value.trim();
            if (!question) return;

            const baseUrl = baseUrlInput.value.replace(/\/+$/, '');
            const modelName = modelNameInput.value;
            const apiKey = apiKeyInput.value;

            // UI 状态更新
            userInput.value = '';
            userInput.style.height = 'auto'; // 重置高度
            sendBtn.disabled = true;

            // 1. 添加用户消息
            const { wrapper: userWrapper } = createMessageElement('user');
            userWrapper.querySelector('.message-content').textContent = question;
            chatContainer.appendChild(userWrapper);

            // 2. 添加助手消息占位符(带动画)
            const { wrapper: aiWrapper, content: aiContent } = createMessageElement('assistant');
            chatContainer.appendChild(aiWrapper);
            showTypingIndicator(aiWrapper);
            chatContainer.scrollTop = chatContainer.scrollHeight;

            // 构建消息列表
            const messages = [
                { role: 'system', content: '你是一个有用的助手。' },
                ...getMessages(),
                { role: 'user', content: question }
            ];

            const url = `${baseUrl}/chat/completions`;

            try {
                const response = await fetch(url, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${apiKey}`
                    },
                    body: JSON.stringify({
                        model: modelName,
                        messages: messages,
                        temperature: 0.7,
                        max_tokens: 3072,
                        stream: true
                    })
                });

                if (!response.ok) {
                    const errText = await response.text();
                    throw new Error(`请求失败:${response.status} ${errText}`);
                }

                const reader = response.body.getReader();
                const decoder = new TextDecoder('utf-8');
                let buffer = '';
                let firstChunk = true;

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

                    buffer += decoder.decode(value, { stream: true });
                    const lines = buffer.split(/\r?\n/);
                    buffer = lines.pop() || '';

                    for (const line of lines) {
                        if (!line.trim() || line.startsWith(':')) continue;
                        if (line.trim() === 'data: [DONE]') continue;

                        if (line.startsWith('data: ')) {
                            if (firstChunk) {
                                removeTypingIndicator(aiWrapper);
                                aiContent.textContent = '';
                                firstChunk = false;
                            }

                            const jsonStr = line.slice(6);
                            try {
                                const data = JSON.parse(jsonStr);
                                const delta = data.choices && data.choices[0] && data.choices[0].delta;
                                const content = delta && delta.content;
                                if (content) {
                                    aiContent.textContent += content;
                                    chatContainer.scrollTop = chatContainer.scrollHeight;
                                }
                            } catch (e) {
                                console.warn('解析行失败', e);
                            }
                        }
                    }
                }
            } catch (err) {
                removeTypingIndicator(aiWrapper);
                aiContent.textContent += `\n[错误: ${err.message}]`;
                aiContent.style.color = '#ef4444';
            } finally {
                sendBtn.disabled = userInput.value.trim() === '';
                userInput.focus();
            }
        }

        sendBtn.addEventListener('click', doChat);
        
        // 支持回车发送(Shift+Enter 换行)
        userInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                doChat();
            }
        });
    </script>
</body>
</html>

在这里插入图片描述

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐