【SenseNova U1.5 Lite实战】AMD ROCm 192G显存部署全流程与性能调优
一、环境声明
| 项目 | 值 | 验证方式 |
|---|---|---|
| 硬件 | AMD GPU,192GB 显存 | amd-smi |
| ROCm 版本 | 7.2.3 | amd-smi 头部 |
| PyTorch | 2.11+ (ROCm 版) | python -c "import torch; print(torch.version.hip)" |
| transformers | 5.14.1 | `pip list |
| 平台 | 魔搭社区 AMDGPU 实例 | 实例面板 |
| 磁盘 | 200GB | df -h |
| Python | 3.12 | python --version |

二、参考资料
提示:本文档使用魔搭AMD GPU + 魔搭社区参考资源部署,博主从0搭建,值得参考,并且提供了WebUi方便大家部署后进行测试

一键验证命令(复制执行):
Bash
echo "=== GPU ===" && amd-smi | head -5
echo "=== PyTorch ROCm ===" && python -c "import torch; print(f'ROCm可用: {torch.cuda.is_available()}'); print(f'GPU型号: {torch.cuda.get_device_name(0)}'); print(f'显存: {torch.cuda.get_device_properties(0).total_mem / 1e9:.0f} GB'); print(f'HIP版本: {torch.version.hip}')"
echo "=== transformers ===" && python -c "import transformers; print(transformers.__version__)"
echo "=== 关键路径 ===" && ls /mnt/workspace/SenseNova-U1/requirements.txt && ls /mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master/config.json
本机实际输出验证:
Plaintext
ROCm可用: True
GPU型号: AMD Radeon Graphics
显存: 196 GB
HIP版本: 6.3.4
transformers: 5.14.1
三、从零部署全流程
SenseNova U1.5 Lite 的官方部署文档写得比较简略,实际在 AMD ROCm 环境上走下来遇到不少魔搭特有的问题。下面按实际执行顺序给出完整流程,每一步带命令和诊断方法。
第 1 步:启动 GPU 实例 + 克隆仓库
魔搭实例启动后自带 Git,直接克隆官方仓库:
Bash
cd /mnt/workspace && git clone https://github.com/OpenSenseNova/SenseNova-U1.git
cd SenseNova-U1
注意: 魔搭实例的
/mnt/workspace是持久化存储,重启后文件不会丢失。但模型文件很大(50GB),建议放在这里而不是/root临时目录。
第 2 步:安装依赖(pip install -r)
第一个坑:清华源对某些 wheel 返回 403。 实测阿里云镜像可用:
Bash
# ⚠️ 用阿里云镜像(清华源容易 403)
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
# ⚠️ 关键:必须可编辑安装,否则 import sensenova_u1 会失败
pip install -e .
pip install -e . 会把当前目录以软链接形式注册到 Python 环境,这样 import sensenova_u1 才指向仓库的 src/ 目录,而不是 pip 缓存里的旧版本。
第 3 步:ROCm PyTorch 重装(最大坑!)
问题: 魔搭实例默认装的是 CPU 版 PyTorch 。torch.cuda.is_available() 返回 False,推理时 GPU 0% 利用率、CPU 满载。这是 AMD ROCm 环境最容易踩的坑,几乎所有第一次部署的人都会遇到。
诊断方法:
Bash
python -c "import torch; print(torch.__version__, 'HIP:', torch.version.hip)"
# 如果 torch.version.hip 是 None → CPU 版,必须重装!
解决命令:
Bash
# 1. 卸载 CPU 版 + 清理缓存
pip uninstall torch torchvision torchaudio -y
pip cache purge
# 2. 安装 ROCm 版(选 rocm6.2 或 rocm6.3,匹配 ROCm 7.x)
# timeout 600 秒防止大文件下载中断,retries 5 次自动重试
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2 --timeout 600 --retries 5
# 3. 验证(必须输出 True)
python -c "import torch; print(torch.cuda.is_available())"
关于 torchaudio: AMD ROCm 环境不需要 torchaudio,装了反而会报 ImportError: libtorch_hip.so。如果遇到,直接卸载:
Bash
pip uninstall torchaudio -y
第 4 步:下载模型(魔搭内部免流量)
SenseNova U1.5 Lite 8B MoT Preview 版本约 50GB,魔搭内部下载免费:
Bash
# 官方推荐方式,自动路由到魔搭镜像
python -c "from modelscope import snapshot_download; snapshot_download('SenseNova/SenseNova-U1.5-8B-MoT-Preview')"
第 5 步:跑通第一张图
设置模型路径环境变量,执行官方推理脚本:
Bash
export MODEL=/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master
cd /mnt/workspace/SenseNova-U1
python examples/t2i/inference.py \
--model_path $MODEL \
--prompt "一只猫坐在桌子上,阳光从窗户照进来" \
--width 2048 --height 2048 \
--cfg_scale 4.0 --num_steps 8 \
--output /mnt/workspace/output.png

同时另开一个终端监控 GPU:
Bash
watch -n 1 amd-smi
判断 GPU 是否在跑:
-
GPU 利用率跳动 → 成功在 GPU 上推理
-
全程 0% → PyTorch 还是 CPU 版,回到第 3 步重装
四、踩坑 10 条(根因分析 + 解决方案)
部署过程中记录了 12 个实际遇到的问题,按影响程度排序。每条都带根因分析和可直接复制的解决方案。
坑 1:pip 清华源 403
现象: pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple/ 返回 HTTP error 403。
根因: 清华源对某些 wheel 包(尤其是 CUDA/ROCm 相关的大包)做了访问限制。
解决: 换阿里云镜像,加 --trusted-host 跳过证书验证:
Bash
pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
坑 2:终端粘贴带控制字符
现象: bash: $'\E[200~echo': command not found。
根因: 从某些网页粘贴时,浏览器会附带终端控制字符(如 bracketed paste mode 的转义序列 \E[200~)。
解决: 手动重新输入命令,或先粘到本地记事本/VS Code 再复制。
坑 3:ModuleNotFoundError: sensenova_u1
现象: 运行推理脚本时报 ImportError: No module named 'sensenova_u1'。
根因: 没执行 pip install -e .,Python 找不到仓库里的 src/sensenova_u1 包。
解决: 进入仓库根目录执行可编辑安装:
Bash
cd /mnt/workspace/SenseNova-U1 && pip install -e .
坑 4:torchaudio 报 libtorch_hip.so 缺失
现象: ImportError: libtorch_hip.so: cannot open shared object file。
根因: AMD ROCm 环境不需要 torchaudio,装了反而会因为找不到 HIP 音频库而 import 失败。
解决: 直接卸载:
Bash
pip uninstall torchaudio -y
坑 5:模型路径找不到 config.json
现象: FileNotFoundError: /xxx/config.json not found。
根因: modelscope 的缓存路径是 ~/.cache/modelscope/hub/ 或 /mnt/workspace/models/models/名称/snapshots/master/,嵌套两层 models/。
解决: 用 find 定位:
Bash
find / -name "config.json" -path "*SenseNova*" 2>/dev/null
坑 6:GPU 0% 利用率(最隐蔽的坑)
现象: amd-smi 显示 GPU 利用率 0%,CPU 满载,推理比预期慢 10-50 倍。
根因: 魔搭默认装 CPU 版 PyTorch,torch.cuda.is_available() 返回 False,所有 tensor 都在 CPU 上跑。
解决: 卸载重装 ROCm 版(详见第 3 步),安装后必须验证:
Bash
python -c "import torch; print(torch.cuda.is_available())" # 必须 True
坑 7:端口 7860 被占用
现象: Flask 启动时报 Address already in use。
根因: 之前的 Flask 进程没杀干净。
解决:
Bash
# 方法 1:杀所有 Flask 进程
pkill -f "python webui.py"
# 方法 2:用 fuser 杀端口
fuser -k 7860/tcp
# 方法 3:直接换端口
cd /mnt/workspace && python -c "import webui; webui.app.run(host='0.0.0.0', port=7863, threaded=True)"
坑 8:Gradio 清华源 403 + 依赖太重
现象: 一开始想用 Gradio 搭 Web UI,但 pip install gradio 清华源 403,且 Gradio 依赖链很重(几十 MB)。
根因: 同坑 1,且 Gradio 在无外网或受限镜像环境下安装困难。
解决: 放弃 Gradio,改用 Flask + 原生 HTML,依赖只有 2 个轻量包:
Bash
pip install flask pillow flask-cors -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
坑 9:POST 请求 504 网关超时
现象: Flask Web UI 的文生图按钮点下去,网关等了几分钟后返回 504。
根因: SenseNova U1 推理一次需要几十秒到几分钟(取决于分辨率和步数),魔搭网关有超时限制。同步 Flask 要等推理完才能返回,网关先掐断了连接。
解决: 改异步任务模式:POST 立即返回 task_id,后端线程异步执行推理,前端每 2 秒轮询 /api/status/<task_id>:
Python
# Flask 后端:立即返回 task_id
def _submit_task(fn, *args, **kwargs):
task_id = uuid.uuid4().hex
with task_lock:
task_store[task_id] = {"status": "pending"}
t = threading.Thread(target=_run_task, args=(task_id, fn) + args, kwargs=kwargs, daemon=True)
t.start()
return task_id
@app.route("/api/t2i", methods=["POST"])
def api_t2i():
tid = _submit_task(_do_t2i, request.json)
return jsonify({"task_id": tid})
# 前端:轮询任务状态
async function submitAndPoll(endpoint, payload) {
const r = await fetch(endpoint, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
const {task_id} = await r.json();
return new Promise((resolve, reject) => {
const timer = setInterval(async () => {
const sr = await fetch('/api/status/' + task_id);
const td = await sr.json();
if (td.status === 'done') { clearInterval(timer); resolve(td.result); }
else if (td.status === 'error') { clearInterval(timer); reject(new Error(td.error)); }
}, 2000);
});
}
坑 10:SSE 流式被网关缓冲
现象: Chat 和 VQA 的 SSE 流式,前端一直等到模型推理完才一次性收到全部数据,没有打字机效果。
根因: Flask 没立即发送数据,网关在等全量响应才开始转发,导致 SSE 的流式特性完全失效。
解决: Flask SSE 必须做三件事------
-
立即 yield connected 帧: 请求进来先立刻发一个 JSON 帧,让网关开始转发
-
每 10 秒 ping 保活: 推理过程中每 10 秒发一条 SSE 注释帧
: ping\n\n,防止网关因长时间无数据而掐断连接 -
设置三个关键响应头:
Cache-Control: no-cache+X-Accel-Buffering: no+Connection: keep-alive
Python
def _sse_gen():
# 1. 立即发 connected 帧 ------ 让网关开始转发,不再缓冲
yield f"data: {json.dumps({'type': 'connected', 'msg': '思考中...'}, ensure_ascii=False)}\n\n"
# 2. 等待后台推理完成,每 10 秒发 ping 保持连接
while True:
try:
evt, payload = result_q.get(timeout=10)
break
except queue.Empty:
yield ": ping\n\n" # SSE 注释帧,前端忽略,仅用于保活
# 3. 推理完成后打字机逐字输出
text = payload["text"]
for i in range(len(text)):
yield f"data: {json.dumps({'type': 'delta', 'text': text[:i+1]}, ensure_ascii=False)}\n\n"
time.sleep(0.02)
yield f"data: {json.dumps({'type': 'done', 'full_text': text}, ensure_ascii=False)}\n\n"
return Response(
stream_with_context(_sse_gen()),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
五、对比实验设计与数据
SenseNova U1.5 Lite 的推理质量和速度受多个参数影响,下面给出 4 组对比实验设计。实验数据通过配套 run_one.py 脚本自动采集------该脚本用 time.time() 统计耗时、从 sysfs 的 /sys/class/drm/card0/device/mem_info_vram_used 读取推理前后显存变化(ROCm 环境下 sysfs 比 amd-smi 表格解析更可靠)、os.path.getsize() 取图片大小。
以下数据表格为待填充状态,跑完实验后将实际值填入。
实验 1:8 步蒸馏 vs 50 步完整
SenseNova U1.5 Lite 的 "Lite" 版核心卖点就是 8 步蒸馏------把原来需要 50 步的扩散过程压缩到 8 步,速度提升 6 倍以上。本组实验验证这个加速比的实际表现和质量影响。
固定参数: 分辨率 2048×2048、cfg_scale 4.0、prompt 相同
| 步数 | 提示词 | 分辨率 | CFG_Scale | 耗时秒 | 图片大小(KB) | 质量备注 |
|---|---|---|---|---|---|---|
| 8 | 一只橘色猫咪坐在木质餐桌上,温暖阳光从窗户照进来 | 2048x2048 | 4.0 | 38.47 | 5866.9 | 蒸馏快,细节略糙 |
| 50 | 一只橘色猫咪坐在木质餐桌上,温暖阳光从窗户照进来 | 2048x2048 | 4.0 | 96.82 | 4846.7 | 完整推理,细节丰富 |


实验 2:分辨率对比
SenseNova U1 的训练集覆盖特定分辨率,不在训练集里的分辨率会出现明显质量下降(伪影、模糊)。patch_size=32,所以分辨率最好对齐 patch。
固定参数: 8 步蒸馏、cfg_scale 4.0
| 分辨率 | 比例 | 训练集命中 | 提示 | 步数 | CFG_Scale | 耗时(秒) | 图片大小(KB) | 质量备注 |
|---|---|---|---|---|---|---|---|---|
| 1024×1024 | 1:1 | ❌ 不在 | 壮丽山脉风景,蓝天白云,金色阳光洒在雪山顶峰,宁静湖泊倒影 | 8 | 4.0 | 26.92 | 1362.8 | 非训练分辨率,质量下降 |
| 2048×2048 | 1:1 | ✅ 命中 | 壮丽山脉风景,蓝天白云,金色阳光洒在雪山顶峰,宁静湖泊倒影 | 8 | 4.0 | 36.55 | 5686.8 | t2i 最佳训练分辨率 |
| 2720×1536 | 16:9 | ✅ 命中 | 壮丽山脉风景,蓝天白云,金色阳光洒在雪山顶峰,宁静湖泊倒影 | 8 | 4.0 | 36.82 | 5437.5 | 宽屏风景最佳 |
图片对比

图片以及数据对比

实验 3:CFG Scale 对比
Classifier-Free Guidance 控制生成的创意自由度:值越小越自由,值越大越严格遵循提示词。官方推荐 4.0。
固定参数: 8 步蒸馏、2048×2048
| cfg_scale | 测试文案 | 分辨率 | 步数 | 耗时(秒) | 图片大小(KB) | 效果描述 |
|---|---|---|---|---|---|---|
| 1.0 | 赛博朋克城市夜景,霓虹灯闪烁,雨天湿润街道,飞行汽车 | 2048x2048 | 8 | 31.36 | 4695.5 | 创意自由,可能偏离提示词 |
| 4.0 | 赛博朋克城市夜景,霓虹灯闪烁,雨天湿润街道,飞行汽车 | 2048x2048 | 8 | 36.84 | 6350.0 | 平衡,官方推荐 |
| 7.0 | 赛博朋克城市夜景,霓虹灯闪烁,雨天湿润街道,飞行汽车 | 2048x2048 | 8 | 36.42 | 7231.3 | 严格遵循提示词,可能过拟合 |

实验 4:Think Mode 对比(Chat Tab 深度思考开关)
SenseNova U1 的 Chat 功能有一个 think_mode 参数------开启后模型会先在内部推理再输出,关闭则直接给出答案。
固定参数: 4 步文本生成、相同 prompt
测试一prompt: "一个长方形长10宽5对角线长度是多少图请给出计算过程和结果"
| think_mode | 显存增量(MB) | 耗时(秒) | 文本长度 | 效果备注 |
|---|---|---|---|---|
| True | 20 | 37.1 | 685 | 思考时间更长,回答更详细 |
| False | 20 | 11.3 | 257 | 直接给答案,速度快 |

测试二prompt: "A rectangle has length 10 and width 5. What is the length of its diagonal? Show your calculation step by step and give the exact result."
| think_mode | 显存增量(MB) | 耗时(秒) | 文本长度 | 效果备注 |
|---|---|---|---|---|
| True | 10 | 37.1 | 685 | 思考时间更长,回答更详细 |
| False | 10 | 11.3 | 257 | 直接给答案,速度快 |

测试三:"一只未来感机械凤凰展开翅膀站在县崖边"
| think_mode | 文案 | 耗时(秒) | 图片大小(KB) | 效果备注(个人眼光) |
|---|---|---|---|---|
| True | 一只未来感机械凤凰展开翅膀站在县崖边 | 85.79 | 6437.5 | 看起来场景比较单一 |
| False | 一只未来感机械凤凰展开翅膀站在县崖边 | 37.11 | 6192.5 | 肉眼看见配色比较丰富 |

一键实验脚本(复制执行)
以下脚本会依次跑完全部 4 组实验,每组自动采集指标存进 JSON 和日志文件:
Bash
#!/bin/bash
MODEL=/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master
OUT=/mnt/workspace/experiments
mkdir -p $OUT && cd /mnt/workspace/SenseNova-U1
echo "========== 实验1: 8步 vs 50步 =========="
START=$(date +%s)
python examples/t2i/inference.py --model_path $MODEL \
--prompt "一只猫坐在桌子上,阳光从窗户照进来,电影质感" \
--width 2048 --height 2048 --cfg_scale 4.0 --num_steps 8 \
--output $OUT/exp1_8steps.png --profile 2>&1 | tee $OUT/exp1_8steps_日志.txt
echo "8步耗时: $(($(date +%s)-START)) 秒"
START=$(date +%s)
python examples/t2i/inference.py --model_path $MODEL \
--prompt "一只猫坐在桌子上,阳光从窗户照进来,电影质感" \
--width 2048 --height 2048 --cfg_scale 4.0 --num_steps 50 \
--output $OUT/exp1_50steps.png --profile 2>&1 | tee $OUT/exp1_50steps_日志.txt
echo "50步耗时: $(($(date +%s)-START)) 秒"
echo "========== 实验2: 分辨率 =========="
for r in "1024 1024" "2048 2048" "2720 1536"; do
W=$(echo $r | cut -d' ' -f1); H=$(echo $r | cut -d' ' -f2)
START=$(date +%s)
python examples/t2i/inference.py --model_path $MODEL \
--prompt "风景照片,山脉,蓝天白云,高清" \
--width $W --height $H --cfg_scale 4.0 --num_steps 8 \
--output $OUT/exp2_${W}x${H}.png --profile 2>&1 | tee $OUT/exp2_${W}x${H}_日志.txt
echo "${W}x${H} 耗时: $(($(date +%s)-START)) 秒"
done
echo "========== 实验3: cfg_scale =========="
for c in 1.0 4.0 7.0; do
python examples/t2i/inference.py --model_path $MODEL \
--prompt "赛博朋克城市夜景,霓虹灯,雨天,反光路面" \
--width 2048 --height 2048 --cfg_scale $c --num_steps 8 \
--output $OUT/exp3_cfg${c}.png --profile 2>&1 | tee $OUT/exp3_cfg${c}_日志.txt
echo "cfg_scale=$c done"
done
echo "========== 全部完成 =========="
ls -lh $OUT/
六、Web UI 架构设计
部署成功后,命令行推理只能自己用。我用 Flask + 原生 HTML 搭了一套完整的 Web UI,封装了 SenseNova U1 全部 4 个功能。单文件、依赖轻量、网关友好。

本套UI方便大家测试:我已更新至GitHub地址
技术选型
| 组件 | 选择 | 原因 |
|---|---|---|
| 后端框架 | Flask | 轻量(单文件可用),原生支持 SSE 流式 |
| 前端 | 原生 HTML + CSS + JS | 无需构建工具,复制粘贴即可运行 |
| 通信协议 | SSE(Server-Sent Events) | 比 WebSocket 轻,Flask 原生支持 |
| 流式模式 | 打字机伪流式 + ping 保活 | 网关不掐断,视觉效果等同真流式 |
4 个功能 Tab
| Tab | 后端推理类 | 通信模式 | 说明 | |
|---|---|---|---|---|
| 🎨 文生图 | SenseNovaU1T2I |
异步任务轮询 | prompt → 生成图片,默认 8 步蒸馏 | |
| ✏️ 图片编辑 | SenseNovaU1Editing |
异步任务轮询 | 上传原图 + 指令 → 改图 | |
| 🔍 图片理解 VQA | SenseNovaU1Interleave |
SSE 打字机 | 上传图 + 问题 → 逐字回答 | |
| 💬 多模态对话 | SenseNovaU1Interleave |
SSE 打字机 | 纯文本对话,带深度思考开关 |
1.文生图


2.图片编辑

3.图片理解

4.多模态对话

懒加载策略
SenseNova U1 三个引擎(T2I / Edit / Interleave)各 50GB+,同时加载会撑爆 192GB 显存。采用 按需懒加载------引擎只在第一次调用对应 Tab 时才初始化:
Python
engine_t2i = engine_edit = engine_il = None
def get_t2i():
global engine_t2i
if engine_t2i is None:
print("[加载] 文生图引擎 (T2I)...")
engine_t2i = SenseNovaU1T2I(MODEL_PATH, dtype=torch.bfloat16)
print("[加载] ✅ T2I 就绪")
return engine_t2i
SSE 流式完整时序
以 Chat Tab 为例,一次对话请求的完整时序:
Plaintext
前端 POST /api/chat {prompt, think_mode: true}
→ Flask 立即 yield {"type":"connected","msg":"思考中..."} ← 网关开始转发
→ 后台线程执行 interleave_gen() ← 可能 30-60 秒
→ 每 10s yield ": ping\n\n" ← 保活,防网关掐断
→ 推理完成后逐字 yield {"type":"delta","text":"..."} ← 打字机效果
→ yield {"type":"done","elapsed":"45.2s"} ← 结束帧
关键设计:connected 帧必须在推理开始前立即发送 ------这是防止网关缓冲的核心。ping 帧用 SSE 注释帧格式(以 : 开头),前端会自动忽略,只用于保持 TCP 连接活跃。
前端 SSE 通用解析器
前端的 SSE 解析逻辑可以复用到 VQA 和 Chat 两个 Tab:
JavaScript
async function streamPost(endpoint, payload, onConnected, onDelta, onDone, onError) {
const res = await fetch(endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
// 按 \n\n 拆分 SSE 帧
let idx;
while ((idx = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const line = frame.split('\n').find(l => l.startsWith('data:'));
if (!line) continue; // : ping 注释帧,跳过
const data = JSON.parse(line.slice(5).trim());
if (data.type === 'connected') onConnected(data.msg);
else if (data.type === 'delta') onDelta(data.text);
else if (data.type === 'done') { onDone(data); return; }
else if (data.type === 'error') { onError(data.msg); return; }
}
}
}
Web UI 启动
Bash
# 1. 装依赖(2 个包,几秒搞定)
pip install flask pillow flask-cors -i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com
# 2. 启动
cd /mnt/workspace && python webui.py
# 输出:Running on http://0.0.0.0:7860
# 3. 访问
# 在浏览器打开 http://<服务器IP>:7860
七、结论与可复现脚本
SenseNova U1.5 Lite 在 AMD ROCm 环境上的部署,核心踩坑集中在三个方面:ROCm PyTorch 默认未装、pip 镜像选择、Flask Web UI 的网关超时与缓冲。解决这三个问题后,整体部署流程稳定,推理质量和速度达到官方预期。
SenseNova U1 的 NEO-unify 架构(统一多模态解码循环)是它既优秀又难调的原因------一个模型覆盖文生图、图片编辑、VQA、多模态对话四大能力,但核心解码函数 interleave_gen() 的自定义循环也让真 token 级流式变得困难。
快速上手清单(可复现 · 新 AMD 服务器从零跑通)
⚠️ 注意: 以下每一步都是独立命令,请逐行复制执行 ,不要用
&&串联------中间失败了不会报错但后面会挂。每步成功后会有[OK]提示。
第 1 步:克隆仓库 + 安装依赖
Bash
# 1a. 克隆官方仓库(放到 /mnt/workspace 持久化目录,不要放 /root)
cd /mnt/workspace
git clone https://github.com/OpenSenseNova/SenseNova-U1.git && echo "[OK] clone 成功"
cd SenseNova-U1 && echo "[OK] 进入仓库"
# 1b. 安装 requirements(用阿里云镜像,清华源容易 403)
pip install -r requirements.txt \
-i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
&& echo "[OK] requirements 安装成功"
# 1c. 可编辑安装(关键!否则 import sensenova_u1 失败)
pip install -e . && echo "[OK] editable install 成功"
# 1d. 卸载 torchaudio(AMD ROCm 不需要,装了会报 libtorch_hip.so 缺失)
pip uninstall torchaudio -y && echo "[OK] torchaudio 已卸载"
第 2 步:安装 ROCm 版 PyTorch(最大坑!必须重装)
Bash
# 2a. 诊断:如果输出 None 说明是 CPU 版,必须重装
python -c "import torch; print('HIP版本:', torch.version.hip)"
# 2b. 卸载 CPU 版 + 清理缓存
pip uninstall torch torchvision torchaudio -y
pip cache purge && echo "[OK] 旧版 PyTorch 已清理"
# 2c. 安装 ROCm 版(rocm6.2 或 rocm6.3 都可,匹配 ROCm 7.x)
# timeout 600 防大文件下载中断,retries 5 自动重试
pip install torch torchvision \
--index-url https://download.pytorch.org/whl/rocm6.2 \
--timeout 600 --retries 5 \
&& echo "[OK] ROCm PyTorch 安装成功"
# 2d. 验证(必须输出 True,否则回到 2b 重走)
python -c "import torch; print('ROCm可用:', torch.cuda.is_available()); print('GPU:', torch.cuda.get_device_name(0)); print('显存GB:', torch.cuda.get_device_properties(0).total_mem / 1e9)"
本机验证输出参考:
Plaintext
ROCm可用: True
GPU: AMD Radeon Graphics
显存GB: 196.0
第 3 步:下载模型(50GB,魔搭内部免流量)
Bash
# 3a. 用 modelscope 下载(魔搭内部自动路由到镜像,外网服务器换 huggingface_hub)
python -c "
from modelscope import snapshot_download
path = snapshot_download('SenseNova/SenseNova-U1.5-8B-MoT-Preview')
print('模型路径:', path)
" && echo "[OK] 模型下载完成"
# 3b. 找 config.json 确认(modelscope 缓存路径嵌套两层)
find /mnt/workspace -name "config.json" -path "*SenseNova*Preview*" 2>/dev/null | head -3
本机找到的路径参考:
Plaintext
/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master/config.json
第 4 步:跑通第一张图
Bash
# 4a. 设置模型路径(从上一步 find 结果复制,把 /config.json 去掉)
export MODEL=/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master
# 验证路径是否正确
ls $MODEL/config.json && echo "[OK] 模型路径确认"
# 4b. 执行推理(另开终端跑 watch -n 1 amd-smi 看 GPU 是否在跑)
cd /mnt/workspace/SenseNova-U1
python examples/t2i/inference.py \
--model_path $MODEL \
--prompt "一只猫坐在桌子上,阳光从窗户照进来" \
--width 2048 --height 2048 \
--cfg_scale 4.0 --num_steps 8 \
--output /mnt/workspace/output.png \
&& echo "[OK] 第一张图生成成功 → /mnt/workspace/output.png"

判断 GPU 是否在跑: 另开终端跑 watch -n 1 amd-smi,GPU 利用率跳动=成功,全程 0%=PyTorch 还是 CPU 版(回到第 2 步)。
第 5 步:启动 Web UI
Bash
# 5a. 上传 webui.py 到服务器(代码见附录 B)
# 如果从 GitHub 拉取:
cd /mnt/workspace && git clone <你的webui仓库地址> temp_webui && cp temp_webui/webui.py /mnt/workspace/ && rm -rf temp_webui
# 5b. 装依赖
pip install flask pillow flask-cors \
-i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com \
&& echo "[OK] Web UI 依赖安装成功"
# 5c. 启动(按需换端口)
cd /mnt/workspace && python webui.py
# 看到 "Running on http://0.0.0.0:7860" 就成功了
# 浏览器打开 http://<服务器IP>:7860

附录 A:关键路径速查
| 路径 | 说明 |
|---|---|
/mnt/workspace/SenseNova-U1 |
官方仓库 |
/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master |
模型目录(config.json 所在) |
/mnt/workspace/webui.py |
Web UI 单文件(640 行,Flask + 原生 HTML) |
/mnt/workspace/run_one.py |
实验辅助脚本(自动采集耗时/显存/图片大小) |
/mnt/workspace/experiments/ |
对比实验输出目录 |
src/sensenova_u1/models/neo_unify/modeling_neo_chat.py:1003 |
interleave_gen() 核心解码循环 |
附录 B:webui.py 代码获取
webui.py 是 640 行的单文件 Flask 应用,包含完整的 4 Tab Web UI + SSE 流式。获取方式:
-
GitHub 仓库 **:下载 https://github.com/SupStrong/SenseNova-webui 或复制下面代码**
-
从本地复制 :本文附件同目录下的
webui.py文件,直接上传到服务器/mnt/workspace/
启动后访问 http://0.0.0.0:7860,4 个 Tab:
XML
"""
SenseNova U1.5 Lite · 完整 Web UI
功能:文生图 | 图片编辑 | 图片理解VQA | 多模态对话
启动: pip install flask pillow flask-cors && python webui.py
访问: http://0.0.0.0:7860
"""
import sys, time, base64, io, os, importlib.util, threading, uuid, queue, json
from flask import Flask, request, jsonify, Response, stream_with_context
from flask_cors import CORS
from PIL import Image
import torch
# ===== 异步任务基础设施 =====
task_store = {} # {task_id: {"status": "pending"/"done"/"error", "result": ...}}
task_lock = threading.Lock()
def _load_module_from_path(name, path):
"""用绝对路径加载模块,避免同名 inference.py 冲突"""
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
# === 干净导入三个官方推理类(分别用独立模块名)====
_BASE = "/mnt/workspace/SenseNova-U1/examples"
t2i_mod = _load_module_from_path("sensenova_t2i", f"{_BASE}/t2i/inference.py")
edit_mod = _load_module_from_path("sensenova_edit", f"{_BASE}/editing/inference.py")
il_mod = _load_module_from_path("sensenova_interleave", f"{_BASE}/interleave/inference.py")
SenseNovaU1T2I = t2i_mod.SenseNovaU1T2I
SenseNovaU1Editing = edit_mod.SenseNovaU1Editing
SenseNovaU1Interleave = il_mod.SenseNovaU1Interleave
T2I_RES = t2i_mod.SUPPORTED_RESOLUTIONS
app = Flask(__name__)
CORS(app)
MODEL_PATH = "/mnt/workspace/models/models/SenseNova--SenseNova-U1.5-8B-MoT-Preview/snapshots/master"
# ===== 引擎懒加载(按需加载,避免 3×50GB 撑爆 192GB)=====
engine_t2i = None
engine_edit = None
engine_il = None
def get_t2i():
global engine_t2i
if engine_t2i is None:
print("[加载] 文生图引擎 (T2I)...")
engine_t2i = SenseNovaU1T2I(MODEL_PATH, dtype=torch.bfloat16)
print("[加载] ✅ T2I 就绪")
return engine_t2i
def get_edit():
global engine_edit
if engine_edit is None:
print("[加载] 图片编辑引擎 (Edit)...")
engine_edit = SenseNovaU1Editing(MODEL_PATH, dtype=torch.bfloat16)
print("[加载] ✅ Edit 就绪")
return engine_edit
def get_il():
global engine_il
if engine_il is None:
print("[加载] 多模态引擎 (Interleave)...")
engine_il = SenseNovaU1Interleave(MODEL_PATH, dtype=torch.bfloat16)
print("[加载] ✅ Interleave 就绪")
return engine_il
print(f"[模型路径] {MODEL_PATH}")
print("🚀 Web UI 启动后将按需加载引擎(懒加载)")
# ===== 工具函数 =====
def b64_to_pil(b64_str: str) -> Image.Image:
if b64_str.startswith("data:"):
b64_str = b64_str.split(",", 1)[1]
data = base64.b64decode(b64_str)
return Image.open(io.BytesIO(data)).convert("RGB")
def pil_to_b64(img: Image.Image) -> str:
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
# ===== 后台线程执行器 =====
def _run_task(task_id, fn, *args, **kwargs):
try:
result = fn(*args, **kwargs)
with task_lock:
task_store[task_id] = {"status": "done", "result": result}
except Exception as e:
import traceback
traceback.print_exc()
with task_lock:
task_store[task_id] = {"status": "error", "error": str(e)}
def _submit_task(fn, *args, **kwargs):
"""提交任务,立即返回 task_id"""
task_id = uuid.uuid4().hex
with task_lock:
task_store[task_id] = {"status": "pending"}
t = threading.Thread(target=_run_task, args=(task_id, fn) + args, kwargs=kwargs, daemon=True)
t.start()
return task_id
# ===== API 路由 =====
@app.route("/")
def index():
return HTML_PAGE
@app.route("/api/status/<task_id>")
def api_status(task_id):
with task_lock:
task = task_store.get(task_id)
if not task:
return jsonify({"status": "not_found"}), 404
# 用完清理
if task["status"] in ("done", "error"):
with task_lock:
task_store.pop(task_id, None)
return jsonify(task)
# ---- 文生图 ----
def _do_t2i(data):
w, h = T2I_RES.get(data.get("ratio", "1:1"), (2048, 2048))
start = time.time()
images = get_t2i().generate(
data.get("prompt", ""), image_size=(w, h),
cfg_scale=float(data.get("cfg_scale", 4.0)),
num_steps=int(data.get("num_steps", 8)),
seed=int(data.get("seed", 42)),
)
elapsed = time.time() - start
print(f"[T2I] {elapsed:.1f}s | {w}x{h}")
return {"image": pil_to_b64(images[0]), "elapsed": f"{elapsed:.1f}s", "size": f"{w}x{h}"}
@app.route("/api/t2i", methods=["POST"])
def api_t2i():
tid = _submit_task(_do_t2i, request.json)
return jsonify({"task_id": tid})
# ---- 图片编辑 ----
def _do_edit(data):
input_imgs = [b64_to_pil(b) for b in data.get("images", [])]
ratio = data.get("ratio", "1:1")
if ratio in T2I_RES:
w, h = T2I_RES[ratio]
else:
if input_imgs:
w, h = input_imgs[0].size
else:
w, h = 2048, 2048
start = time.time()
images, think = get_edit().edit(
data.get("prompt", ""), input_imgs, image_size=(w, h),
cfg_scale=float(data.get("cfg_scale", 4.0)),
num_steps=int(data.get("num_steps", 8)), seed=int(data.get("seed", 42)),
)
elapsed = time.time() - start
print(f"[Edit] {elapsed:.1f}s | {w}x{h}")
return {"image": pil_to_b64(images[0]), "elapsed": f"{elapsed:.1f}s", "think": think}
@app.route("/api/edit", methods=["POST"])
def api_edit():
tid = _submit_task(_do_edit, request.json)
return jsonify({"task_id": tid})
# ---- VQA ----
def _do_vqa(data):
"""后台执行 VQA 推理,返回完整结果 dict"""
input_imgs = [b64_to_pil(b) for b in data.get("images", [])]
start = time.time()
text, images = get_il().generate(
data.get("prompt", ""), input_images=input_imgs,
num_steps=int(data.get("num_steps", 6)), think_mode=False,
seed=int(data.get("seed", 42)),
)
elapsed = time.time() - start
print(f"[VQA] {elapsed:.1f}s | text_len={len(text)}")
result = {"text": text, "elapsed": f"{elapsed:.1f}s"}
if images:
result["image"] = pil_to_b64(images[0])
return result
@app.route("/api/vqa", methods=["POST"])
def api_vqa():
"""SSE 流式 VQA:先发 connected → 推理中定时 ping → 打字机逐字输出 → 最后发图片"""
data = request.json
result_q: queue.Queue = queue.Queue()
def _run():
try:
result = _do_vqa(data)
result_q.put(("result", result))
except Exception as e:
result_q.put(("error", str(e)))
threading.Thread(target=_run, daemon=True).start()
def _sse_gen():
# 1. 立即发 connected 帧 ------ 让网关开始转发,不再缓冲
yield f"data: {json.dumps({'type': 'connected', 'msg': '思考中...'}, ensure_ascii=False)}\n\n"
# 2. 等待后台推理完成,每 10 秒发 ping 保持连接
while True:
try:
evt, payload = result_q.get(timeout=10)
break
except queue.Empty:
yield f": ping\n\n" # SSE 注释帧,仅用于保活,前端忽略
# 3. 推理完成,发结果或错误
if evt == "error":
yield f"data: {json.dumps({'type': 'error', 'msg': payload}, ensure_ascii=False)}\n\n"
return
# 4. 打字机逐字输出文本
text = payload["text"]
for i in range(len(text)):
yield f"data: {json.dumps({'type': 'delta', 'text': text[: i + 1]}, ensure_ascii=False)}\n\n"
time.sleep(0.02)
# 5. 完成帧
final = {k: v for k, v in payload.items() if k != "text"}
final["type"] = "done"
final["full_text"] = text
yield f"data: {json.dumps(final, ensure_ascii=False)}\n\n"
return Response(
stream_with_context(_sse_gen()),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
# Chat 专用 system prompt:禁止生成图片,只做纯文本对话
CHAT_SYSTEM_PROMPT = """You are a helpful, concise text-only assistant.
Rules:
- ONLY respond with natural language text. Do NOT generate or reference images.
- NEVER use tags like <image>, <image1>, <image2> or any image-related placeholders.
- NEVER use tags like <think> or </think>. Reason silently and directly give the final answer.
- Answer the user's question clearly and concisely in the same language as the question.
- When asked about a visual task (like drawing or describing an image), describe it in words only --- do not attempt to generate any image.
"""
# ---- Chat ----
@app.route("/api/chat", methods=["POST"])
def api_chat():
"""SSE 流式 Chat:纯文本对话,禁止生成图片"""
data = request.json
think_mode = bool(data.get("think_mode", True))
result_q: queue.Queue = queue.Queue()
def _run():
try:
start = time.time()
text, images = get_il().generate(
data.get("prompt", ""), input_images=[],
num_steps=int(data.get("num_steps", 4)),
think_mode=think_mode,
seed=int(data.get("seed", 42)),
system_message=CHAT_SYSTEM_PROMPT,
)
elapsed = time.time() - start
print(f"[Chat] {elapsed:.1f}s | think_mode={think_mode} | text_len={len(text)}")
result_q.put(("result", {"text": text, "elapsed": f"{elapsed:.1f}s", "think_mode": think_mode}))
except Exception as e:
import traceback
traceback.print_exc()
result_q.put(("error", str(e)))
threading.Thread(target=_run, daemon=True).start()
def _sse_gen():
# 1. 立即发 connected 帧
yield f"data: {json.dumps({'type': 'connected', 'msg': '思考中...'}, ensure_ascii=False)}\n\n"
# 2. 等待后台推理,每 10 秒 ping
while True:
try:
evt, payload = result_q.get(timeout=10)
break
except queue.Empty:
yield f": ping\n\n"
# 3. 结果
if evt == "error":
yield f"data: {json.dumps({'type': 'error', 'msg': payload}, ensure_ascii=False)}\n\n"
return
text = payload["text"]
# 4. 打字机逐字
for i in range(len(text)):
yield f"data: {json.dumps({'type': 'delta', 'text': text[: i + 1]}, ensure_ascii=False)}\n\n"
time.sleep(0.015)
# 5. 完成
yield f"data: {json.dumps({'type': 'done', 'elapsed': payload['elapsed'], 'full_text': text}, ensure_ascii=False)}\n\n"
return Response(
stream_with_context(_sse_gen()),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
# ===== 前端 HTML =====
HTML_PAGE = r"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>SenseNova U1.5 Lite · 全功能 Web UI</title>
<style>
*{box-sizing:border-box}
body{font-family:-apple-system,"PingFang SC",sans-serif;margin:0;background:linear-gradient(135deg,#1a1a2e,#16213e 50%,#0f3460);min-height:100vh;color:#333}
.container{max-width:1280px;margin:0 auto;padding:24px}
h1{color:#fff;text-align:center;margin:0 0 24px;font-size:26px;text-shadow:0 2px 8px rgba(0,0,0,.3)}
.subtitle{color:#a0aec0;text-align:center;margin-bottom:24px;font-size:13px}
.tabs{display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap}
.tab{padding:10px 20px;background:rgba(255,255,255,.1);color:#cbd5e1;border:none;border-radius:10px;cursor:pointer;font-size:14px;font-weight:600;transition:all .2s}
.tab.active{background:#fff;color:#1a1a2e;box-shadow:0 4px 12px rgba(0,0,0,.2)}
.tab:hover:not(.active){background:rgba(255,255,255,.2)}
.card{background:#fff;border-radius:16px;padding:24px;box-shadow:0 10px 40px rgba(0,0,0,.2)}
.tab-panel{display:none}
.tab-panel.active{display:block}
.row{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.row-3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px}
label{display:block;font-weight:600;margin:14px 0 6px;color:#374151;font-size:13px}
textarea,select,input[type=number]{width:100%;padding:10px 12px;border:2px solid #e5e7eb;border-radius:10px;font-size:14px;background:#fafafa}
textarea{resize:vertical;min-height:110px}
textarea:focus,select:focus,input:focus{outline:none;border-color:#667eea;background:#fff}
button{width:100%;padding:13px;margin-top:16px;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;border:none;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;transition:transform .15s,opacity .2s}
button:hover:not(:disabled){transform:translateY(-2px)}
button:disabled{opacity:.55;cursor:not-allowed}
.result-box{text-align:center;min-height:300px;display:flex;flex-direction:column;justify-content:center;align-items:center}
.result-box img{max-width:100%;max-height:60vh;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,.1);margin-top:8px}
.result-box .empty{color:#9ca3af;font-size:14px}
.info{margin-top:10px;color:#666;font-size:12px}
.chat-area{max-height:60vh;overflow-y:auto;padding:10px;border:2px solid #e5e7eb;border-radius:12px;background:#f9fafb;min-height:300px}
.msg{margin-bottom:12px;padding:10px 14px;border-radius:10px;font-size:14px;line-height:1.6}
.msg.user{background:#dbeafe;color:#1e40af;margin-left:20%}
.msg.bot{background:#e0e7ff;color:#3730a3;margin-right:10%;white-space:pre-wrap}
.msg.bot img{max-width:100%;border-radius:8px;margin-top:8px}
.think-box{background:#fef3c7;color:#92400e;padding:8px 12px;border-radius:8px;margin-top:8px;font-size:12px;border-left:3px solid #f59e0b}
.upload-area{border:2px dashed #d1d5db;border-radius:12px;padding:20px;text-align:center;cursor:pointer;transition:border-color .2s}
.upload-area:hover{border-color:#667eea}
.upload-area img{max-width:100%;max-height:150px;border-radius:8px;margin-top:8px}
.thinking{display:inline-block;color:#667eea;font-weight:600}
@media (max-width:900px){.row{grid-template-columns:1fr}.row-3{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<div class="container">
<h1>🚀 SenseNova U1.5 Lite · 全功能 Web UI</h1>
<p class="subtitle">AMD ROCm 192GB · 8B NEO-unify 原生统一多模态 · T2I / 编辑 / VQA / 对话</p>
<div class="tabs">
<button class="tab active" onclick="switchTab('t2i')">🎨 文生图</button>
<button class="tab" onclick="switchTab('edit')">✏️ 图片编辑</button>
<button class="tab" onclick="switchTab('vqa')">🔍 图片理解</button>
<button class="tab" onclick="switchTab('chat')">💬 多模态对话</button>
</div>
<!-- ========== T2I ========== -->
<div id="t2i" class="tab-panel active">
<div class="card row">
<div>
<label>提示词 Prompt</label>
<textarea id="t2i_prompt">一只猫坐在桌子上,阳光从窗户照进来,电影质感</textarea>
<div class="row-3">
<div><label>比例</label><select id="t2i_ratio">
<option>1:1</option><option>16:9</option><option>9:16</option><option>3:2</option><option>2:3</option></select></div>
<div><label>CFG</label><input type="number" id="t2i_cfg" value="4.0" step="0.5"></div>
<div><label>步数</label><input type="number" id="t2i_steps" value="8"></div>
</div>
<div class="row-3">
<div><label>种子</label><input type="number" id="t2i_seed" value="42"></div>
</div>
<button id="t2i_btn" onclick="apiT2I()">🚀 生成</button>
</div>
<div class="result-box">
<img id="t2i_img" style="display:none" />
<div id="t2i_info" class="empty">生成结果将显示在这里</div>
</div>
</div>
</div>
<!-- ========== EDIT ========== -->
<div id="edit" class="tab-panel">
<div class="card row">
<div>
<label>上传原图(可多张)</label>
<div class="upload-area" onclick="document.getElementById('edit_file').click()" id="edit_upload">
📷 点击上传图片
<input type="file" id="edit_file" accept="image/*" multiple style="display:none" onchange="handleEditUpload(this)">
</div>
<div id="edit_previews" style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap"></div>
<label>编辑指令</label>
<textarea id="edit_prompt" placeholder="例如:把外套换成红色,保持背景不变">把背景换成日落场景,人物保持不变</textarea>
<div class="row-3">
<div><label>输出比例</label><select id="edit_ratio">
<option>跟随原图</option><option>1:1</option><option>16:9</option><option>9:16</option></select></div>
<div><label>CFG</label><input type="number" id="edit_cfg" value="4.0" step="0.5"></div>
<div><label>步数</label><input type="number" id="edit_steps" value="8"></div>
</div>
<button id="edit_btn" onclick="apiEdit()">✏️ 编辑</button>
</div>
<div class="result-box">
<img id="edit_img" style="display:none" />
<div id="edit_info" class="empty">编辑结果将显示在这里</div>
</div>
</div>
</div>
<!-- ========== VQA ========== -->
<div id="vqa" class="tab-panel">
<div class="card row">
<div>
<label>上传图片</label>
<div class="upload-area" onclick="document.getElementById('vqa_file').click()" id="vqa_upload">
📷 点击上传要理解的图片
<input type="file" id="vqa_file" accept="image/*" style="display:none" onchange="handleVqaUpload(this)">
</div>
<img id="vqa_preview" style="display:none;max-height:200px;margin-top:10px;border-radius:10px" />
<label>问题</label>
<textarea id="vqa_prompt" placeholder="这张图里有什么?描述一下细节">这张图片里有什么?详细描述一下</textarea>
<div class="row-3">
<div><label>种子</label><input type="number" id="vqa_seed" value="42"></div>
<div><label>步数</label><input type="number" id="vqa_steps" value="4"></div>
</div>
<button id="vqa_btn" onclick="apiVqa()">� 分析</button>
</div>
<div class="result-box" style="text-align:left">
<div id="vqa_text" style="display:none;white-space:pre-wrap;padding:14px;background:#f9fafb;border-radius:10px;line-height:1.7"></div>
<img id="vqa_gen_img" style="display:none;margin-top:12px;max-height:300px;border-radius:10px" />
<div id="vqa_info" class="empty">分析结果将显示在这里</div>
</div>
</div>
</div>
<!-- ========== CHAT ========== -->
<div id="chat" class="tab-panel">
<div class="card">
<div class="chat-area" id="chat_area">
<div class="msg bot">👋 你好!我是 SenseNova U1.5 Lite 多模态助手。可以问我问题,也可以让我生成图片!</div>
</div>
<div style="display:flex;gap:10px;margin-top:14px">
<textarea id="chat_input" placeholder="输入问题或描述...(支持多模态,模型会自动判断)" style="flex:1;min-height:50px" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();apiChat()}"></textarea>
<button id="chat_btn" onclick="apiChat()" style="width:auto;padding:0 24px">发送</button>
</div>
<div style="display:flex;gap:16px;margin-top:8px;align-items:center">
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;color:#4b5563">
<input type="checkbox" id="chat_deep" checked style="width:16px;height:16px;cursor:pointer">
🧠 深度思考 (think_mode)
</label>
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px;color:#4b5563">
<input type="checkbox" id="chat_wide" style="width:16px;height:16px;cursor:pointer">
🌐 联网搜索(预留)
</label>
</div>
<div id="chat_info" class="info" style="margin-top:6px"></div>
</div>
</div>
</div>
<script>
const editImgs = []; // 存 base64
function switchTab(name){
document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.textContent.includes({t2i:'文生图',edit:'图片编辑',vqa:'图片理解',chat:'多模态对话'}[name])));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.toggle('active',p.id===name));
}
// 通用轮询函数:post提交 → 拿 task_id → 轮询 /api/status/<id> → 返回 result
async function submitAndPoll(endpoint, payload, setLoading){
const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const {task_id}=await r.json();
return new Promise((resolve,reject)=>{
const timer=setInterval(async()=>{
const sr=await fetch('/api/status/'+task_id);
const td=await sr.json();
if(td.status==='done'){clearInterval(timer);resolve(td.result);}
else if(td.status==='error'){clearInterval(timer);reject(new Error(td.error));}
else if(setLoading){setLoading();}
},2000);
});
}
async function apiT2I(){
const btn=document.getElementById('t2i_btn');btn.disabled=true;btn.textContent='生成中...';
document.getElementById('t2i_info').textContent='⏳ 提交任务成功,推理中...';
document.getElementById('t2i_img').style.display='none';
try{
const d=await submitAndPoll('/api/t2i',{
prompt:document.getElementById('t2i_prompt').value,
ratio:document.getElementById('t2i_ratio').value,
cfg_scale:+document.getElementById('t2i_cfg').value,
num_steps:+document.getElementById('t2i_steps').value,
seed:+document.getElementById('t2i_seed').value,
},()=>{document.getElementById('t2i_info').textContent='⏳ 推理中...';});
document.getElementById('t2i_img').src=d.image;document.getElementById('t2i_img').style.display='block';
document.getElementById('t2i_info').textContent='✅ '+d.elapsed+' | '+d.size;
}catch(e){document.getElementById('t2i_info').textContent='❌ '+e.message;}
btn.disabled=false;btn.textContent='🚀 生成';
}
function handleEditUpload(input){
editImgs.length=0;const pre=document.getElementById('edit_previews');pre.innerHTML='';
[...input.files].forEach(f=>{
const r=new FileReader();r.onload=e=>{editImgs.push(e.target.result);
const i=document.createElement('img');i.src=e.target.result;i.style.height='60px';i.style.borderRadius='6px';pre.appendChild(i);};
r.readAsDataURL(f);
});
}
async function apiEdit(){
if(editImgs.length===0){alert('请先上传图片');return}
const btn=document.getElementById('edit_btn');btn.disabled=true;btn.textContent='编辑中...';
document.getElementById('edit_info').textContent='⏳ 提交任务成功,编辑推理中...';
document.getElementById('edit_img').style.display='none';
try{
const d=await submitAndPoll('/api/edit',{
prompt:document.getElementById('edit_prompt').value,
images:editImgs,
ratio:document.getElementById('edit_ratio').value,
cfg_scale:+document.getElementById('edit_cfg').value,
num_steps:+document.getElementById('edit_steps').value,
seed:42,
});
document.getElementById('edit_img').src=d.image;document.getElementById('edit_img').style.display='block';
document.getElementById('edit_info').textContent='✅ '+d.elapsed;
}catch(e){document.getElementById('edit_info').textContent='❌ '+e.message;}
btn.disabled=false;btn.textContent='✏️ 编辑';
}
let vqaImg='';
function handleVqaUpload(input){
const r=new FileReader();r.onload=e=>{vqaImg=e.target.result;
const p=document.getElementById('vqa_preview');p.src=vqaImg;p.style.display='block';};
r.readAsDataURL(input.files[0]);
}
// ===== 通用 SSE 流式消费:POST 请求 → 逐字打字机渲染 =====
async function streamPost(endpoint, payload, onConnected, onDelta, onDone, onError){
const res=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
if(!res.ok){onError&&onError('HTTP '+res.status);return}
const reader=res.body.getReader();
const decoder=new TextDecoder();
let buffer='', fullText='';
while(true){
const {done,value}=await reader.read();
if(done)break;
buffer+=decoder.decode(value,{stream:true});
// 解析 SSE 帧(data: {...}\n\n)
let idx;
while((idx=buffer.indexOf('\n\n'))!==-1){
const frame=buffer.slice(0,idx);
buffer=buffer.slice(idx+2);
const line=frame.split('\n').find(l=>l.startsWith('data:'));
if(!line)continue; // 可能是 : ping 注释帧,跳过
const data=JSON.parse(line.slice(5).trim());
if(data.type==='connected'){onConnected&&onConnected(data.msg);}
else if(data.type==='delta'){onDelta&&onDelta(data.text);}
else if(data.type==='done'){onDone&&onDone(data);return;}
else if(data.type==='error'){onError&&onError(data.msg);return;}
}
}
}
async function apiVqa(){
if(!vqaImg){alert('请先上传图片');return}
const btn=document.getElementById('vqa_btn');btn.disabled=true;btn.textContent='分析中...';
const textEl=document.getElementById('vqa_text');
textEl.style.display='block';textEl.textContent='⏳ 连接中...';
document.getElementById('vqa_gen_img').style.display='none';
document.getElementById('vqa_info').textContent='';
try{
await streamPost('/api/vqa',{
prompt:document.getElementById('vqa_prompt').value,
images:[vqaImg],
num_steps:+document.getElementById('vqa_steps').value,
seed:+document.getElementById('vqa_seed').value,
},(msg)=>{textEl.textContent='⏳ '+msg;},
(text)=>{textEl.textContent=text;},
(final)=>{
if(final.image){document.getElementById('vqa_gen_img').src=final.image;document.getElementById('vqa_gen_img').style.display='block'}
document.getElementById('vqa_info').textContent='✅ '+final.elapsed;
},(err)=>{document.getElementById('vqa_info').textContent='❌ '+err;});
}catch(e){document.getElementById('vqa_info').textContent='❌ '+e.message;}
btn.disabled=false;btn.textContent='🔍 分析';
}
async function apiChat(){
const btn=document.getElementById('chat_btn');btn.disabled=true;btn.textContent='...';
const input=document.getElementById('chat_input');const area=document.getElementById('chat_area');
const text=input.value.trim();if(!text){btn.disabled=false;btn.textContent='发送';return}
const thinkMode=document.getElementById('chat_deep').checked;
const um=document.createElement('div');um.className='msg user';
um.textContent=text+(thinkMode?' 🧠':'');area.appendChild(um);
input.value='';area.scrollTop=area.scrollHeight;
const bm=document.createElement('div');bm.className='msg bot';bm.textContent='⏳ 连接中...';area.appendChild(bm);area.scrollTop=area.scrollHeight;
try{
await streamPost('/api/chat',{prompt:text,think_mode:thinkMode},
(msg)=>{bm.textContent='⏳ '+msg;area.scrollTop=area.scrollHeight;},
(delta)=>{bm.textContent=delta;area.scrollTop=area.scrollHeight;},
(final)=>{document.getElementById('chat_info').textContent='✅ '+final.elapsed+(final.think_mode?' 🧠':'');},
(err)=>{bm.textContent='❌ '+err;});
}catch(e){bm.textContent='❌ 出错: '+e.message}
btn.disabled=false;btn.textContent='发送';area.scrollTop=area.scrollHeight;
}
</script>
</body>
</html>
"""
if __name__ == "__main__":
print("\n🌐 Web UI: http://0.0.0.0:7860 (threaded=True, 异步轮询模式)")
print("功能: 🎨文生图 ✏️图片编辑 🔍图片理解VQA 💬多模态对话\n")
app.run(host="0.0.0.0", port=7860, debug=False, threaded=True)
| Tab | 后端类 | 协议 |
|---|---|---|
| 🎨 文生图 | SenseNovaU1T2I |
异步任务轮询 |
| ✏️ 图片编辑 | SenseNovaU1Editing |
异步任务轮询 |
| 🔍 图片理解 VQA | SenseNovaU1Interleave |
SSE 打字机 |
| 💬 多模态对话 | SenseNovaU1Interleave |
SSE 打字机 |