CHANGELOG.md 86.8 KB

变更记录

2026-08-25(晚)— 服务器意外重启后 raylight/h3-console 全灭,补上 systemd 开机自启

背景:用户反馈 http://192.168.110.67:8080/newtask.html 打不开。排查发现机器在当天 23:04 自己重启过(uptime/last reboot 确认,非人为操作),重启后 GPU 显存全空、 ComfyUI(8188)/raylight-server(8100)/h3-console(8080/8081) 全部没起来——这台机器此前 没有任何开机自启配置(既无 systemd unit 也无 crontab),全靠手动 start.sh/run.sh。

修复:临时先手动 bash raylight/scripts/start.sh + 手动起两个 h3-console 实例恢复服务, 然后补了三个 systemd unit(本地副本见 raylight/scripts/raylight.service、 h3-console/systemd/h3-console-lan.service、h3-console/systemd/h3-console-public.service, 服务器上装在 /etc/systemd/system/):

  • raylight.service:Type=oneshot + RemainAfterExit=yes 包一层 start.sh,只负责 开机拉起一次,故意不设 Restart=——崩溃自愈已经交给 watchdog.sh,两套自动重启 机制并存会重现 8月24日 PM2 那次「谁在重启」的排查混乱。
  • h3-console-lan.service(8080) / h3-console-public.service(8081):Type=simple 直接 ExecStart=run.sh(脚本末尾是 exec uvicorn,进程替换后 systemd 能正确跟踪主进程), 日志仍然 append 到原来的 console-8080.log/console-subpath.log,不改用 journald, 保留原有排查习惯;这两个是无状态 FastAPI,加了 Restart=on-failure 没有已知冲突。
  • 三者都已 enable。console 两个实例已经停掉手动进程、切到 systemd 管理并验证 200; raylight.service 因为手动起的进程当时健康在跑,为避免几分钟模型重载中断没有强制重启 「认领」,只对下次开机生效。

遗留:没有实测过真的 reboot 验证三个 unit 顺序拉起是否顺畅(尤其 raylight 启动要等 ComfyUI+4卡FSDP集群就绪,可能需要 TimeoutStartSec 更宽裕),下次计划性重启时留意确认。

2026-08-25(凌晨)— 修复 watchdog.sh 误杀:把"采样跑完/VAE存盘中"当成卡死重启,打断了一批正在跑的任务

背景:批量重跑 16 个 h3-showcase 案例期间,队列跑到一半突然又出现"进程重启导致任务被取消/ 执行状态丢失"——5 个任务(1 个 failed + 4 个 cancelled)一次性被打断,一开始怀疑又是人为操作 (跟8月24日PM2那次很像),查了才发现这次是机器自己的watchdog.sh干的。

根因:watchdog.sh(2026-08-23新增,按step字段判断任务卡死,不看总耗时)只区分了 "FSDP初始化中(step=0)"和"denoise采样中(step>0)"两个阶段,没考虑到step到达total_steps (采样跑完)之后还有VAE视频/音频解码+存盘这段没有独立进度信号的尾巴——step会一直停在最终值 不变。这段被并入了denoise阶段的10分钟宽限,实测至少一个1344×768长帧数案例这个阶段本身就超过 608秒,被误判"卡死"、自动bash start.sh整机重启,把当时排队中的一批任务连带打断。

修复:watchdog.sh给"step>=total_steps"这个采样完/存盘中阶段单独加一档30分钟宽限 (STALL_LIMIT_POSTPROC=1800),不再跟denoise阶段共用10分钟的紧阈值。已部署并重启watchdog 进程(新PID替换旧的),被打断的5个任务已用存量req_json重新提交。详见 raylight/README.md已知限制第10条。

教训记录:这台机器上至少有两套独立的自动重启机制(这个watchdog + 8月24日排查过的PM2托管 服务),以后见到"进程重启导致任务被取消"这类错误,先查watchdog.log有没有"卡死判定触发", 比先怀疑人为操作更快定位。

2026-08-24(晚,续3)— 修复 raylight /health 骗人报健康的盲区,ComfyUI 崩了近20分钟没人发现

背景:续2记录的那次 ref2va OOM(b659b60c6cbc,FSDP初始化阶段GPU2/GPU3 OOM)实际上 把 ComfyUI(8188) 进程直接搞崩溃退出了,但 server.py(8100) 这层壳没死,之后用户重跑的 好几个任务(c136221759cb/02ee11443847等)全部秒失败报 ConnectError: All connection attempts failed,而 console 界面和 /health 一直显示"ready",误导了排查方向(一开始以为 是网络抖动/console重启的副作用)。

根因:server.py的/health只读内存里的STATE["model"],这个值在后台worker()线程 启动时设一次"ready",之后再没更新过——不管ComfyUI 8188是死是活,/health永远报同一个 值,这个盲区持续了近20分钟才被人工发现任务反常揪出来。

修复:/health现在每次都用start.sh自己就绪检测同款端点(GET {COMFY_BASE}/system_stats, 2秒超时)真的探测一次8188,探测失败就把ready降级、model字段报comfyui_unreachable。 已同步部署到服务器并用bash start.sh完整重启验证过。详见raylight/README.md已知限制第9条。 这条不解决"actor崩溃不自愈"本身(第8条那个bug依然在,上游代码问题),只是让崩溃能被 更快发现,不用再靠人工盯任务反常才能发现。

2026-08-24(晚,续2)— 【发现】"1344×768撑412帧不OOM"结论只对0参考图成立,带参考图会OOM

背景:用户重启了一个卡住/报错的任务(task.html/b659b60c6cbc,1344×768×362帧、ref2va 3张参考图),让盯着看会不会再报错。查console.sqlite3任务历史发现同一个精确配置当天5次 提交4次失败(1次成功是d71c09a38248),远不是稳定档位。

结果:确认根因是显存不够,但不是老结论"GPU0先炸"那种模式——这次是FSDP/USP初始化阶段 rank2/rank3(物理GPU2/GPU3)先报CUDACachingAllocatorOOM(只剩8-10MB free时还要申请 20MB),Ray actor被SIGTERM杀死,任务标记failed。raylight/README.md已知限制第1条那份 "1344×768能撑412帧"的sweep表,提交时用的是partition: text(0张参考图)——从没验证过 带参考图时是否成立,这次实测证明不成立:参考图的conditioning张量会广播到每张FSDP rank, 显存开销明显更高,且压力点可能落在GPU0以外的卡。已把这条补充记录写进raylight/README.md 已知限制第1条和raylight/notes.md。

被推翻的假设:不能把"文生视频(0参考图)测出的分辨率/帧数安全上限"直接套用到带参考图 的ref2va/fl2va请求上,两者显存开销结构不一样。目前没有专门针对参考图数量×帧数做过sweep, 这是文档空白,以后要单独测。当前建议:1344×768 + 3张参考图时,362帧不安全,想稳定跑 建议降到200-250帧或减少参考图数量。

2026-08-24(晚,续)— 更正:不是"另一个并发会话",真凶是 PM2 自动重启 + 找到彻底止血办法

更正上面那条记录里"另一个并发会话手动启动/又手动拉起来"的归因是错的——用户明确说这台机器 当时只有他自己在用,没有别的同事。停了 minimax-h3 之后它反复自己重新起来,一路排查(cron/ systemd/tmux/screen/supervisor 都查过一遍确认没有)才找到真正机制:

  • stable-diffusion-webui-forge 和一个 embedding 服务(m3e)是被 PM2(系统级 Node.js 进程管理器,pm2 list 能看到,跑了75小时,跟这次任何工作都无关,是机器本来就有的基础设施) 托管的,子进程被 kill 掉 PM2 会自动重启——这才是"杀了又起"的真正原因。已经 pm2 delete sd-forge m3e && pm2 save,从 PM2 的常驻列表里彻底删掉(pm2 save 很关键, 不然重启/pm2 resurrect 还会把它们从旧快照里救回来)。
  • minimax-h3 的 gateway.py/api.py 不在 PM2 管理范围内(pm2 list/root 的 PM2 实例 都确认过没有它),真正触发重启的机制没有查到——但既然查不到,就换一个不用管触发源是什么 都生效的办法:把 api.py/gateway.py 直接改名成 .disabled-20260824,python -m uvicorn api:app 这个 module import 从根上就会失败,不管是谁/什么在尝试重新拉起,都会失败。改名 后观察 45 秒,四卡显存稳定在基线(160-162MB),没有再出现任何重启尝试。

结果:GPU 彻底清空,raylight/console 全程未受影响(验证过 /health 一直正常)。这个 问题从"以为是同事"到"查到 PM2"再到"minimax-h3 单独用改名兜底",全程记录留着——如果以后再 遇到类似"进程杀了又起"的情况,先查 pm2 list,比查 cron/systemd 更容易先想到。

2026-08-24 — 下线旧 H3(minimax-h3)+ 确认 LLM 早已下线,GPU 显存全部还给 raylight

背景:排查一次 raylight 的 CUDA OOM 时发现 GPU1 被另一进程占着 16.7GB、98%利用率—— 查出来是另一个并发会话手动启动了旧的 minimax-h3/gateway.py(8000)+ api.py(8010-8013) 四个 worker,在测试他们自己改的代码(cfg_scale/negative_prompt),跟 raylight 抢卡。用户决定 既然生产已经全面切到 raylight,旧 H3 接口不再需要保留在线,直接下线(按端口 kill 停进程, 代码/权重/start-all.sh 都保留,需要能秒级拉回来)。

顺带查到一个文档过期问题:CLAUDE.md 一直写着 LLM(llm38)对外服务中,实际 docker ps -a 显示这个容器 6天前就已经 Exited 了,不是本次操作停的——qwen.wudaima.com 早就已经打不通,只是没人更新文档。已经在 CLAUDE.md 补充说明。

结果:四张卡显存全部回收,现在这台机器上真正在跑的生产只有 raylight/。

后续追踪(同一天):停完没多久 minimax-h3 又被重新拉起来了两次(同一个人还在测), 查了一圈没有 cron/systemd/supervisor 在自动重启,是手动 ./start-all.sh 拉起来的。第二次 停掉后,把 start-all.sh/start-gateway.sh 改名成 .disabled-20260824 后缀(脚本本身没删, 权重代码也没动),配一份 DISABLED-README.md 说明原因和恢复方法,防止顺手再跑一次。 另外顺带查到"whisper 常驻"这条也是过期信息——现在实际常驻的是 stable-diffusion-webui-forge 和一个 embedding 服务(gunicorn:7590),跟这次任何工作都无关,已经一并停掉。

2026-08-24 — 多参考图生视频加上参考视频/独立音频参考

结果:查 ComfyUI /object_info 发现 MiniMaxH3ReferenceToVideo 节点其实支持 ref_videos(参考视频,最多3段)/ref_audios(独立音频参考,最多3个)/ref_video_audios (对应视频的音轨),之前只接了 ref_images。现在都接上了:raylight/server.py 的 GenReq 加 reference_video_urls/reference_audio_urls,build_ref2va_graph 用 ComfyUI 核心节点 LoadVideo→GetVideoComponents(拆出images+audio两路)接参考视频,LoadAudio接独立音频; h3-console 的 /v1/assets 上传接口按扩展名分流校验(视频100MB/音频30MB/图片20MB三档), "多参考图生视频"tab 加了两个上传框。端到端测过(2秒测试视频+独立音频文件),成功生成。 顺带修了 console 自己的 _partition_of 漏加这两个新字段的判断(跟raylight/server.py那份 不一致,纯展示层,不影响实际生成)。

2026-08-23(晚)— H3 官方案例测试 + 提示词写作 skill(h3-prompt-writer)搭建与 benchmark 收敛

结果:为了系统性验证 raylight 复现 MiniMax H3 官方案例效果,做了两件事:

  1. 官方案例库整理与提交:从飞书《MiniMax H3 使用手册》抓取 26 个官方示范案例(docs/todo/h3-showcase-cases.md), 1344×768/300帧(≈12.5s)统一提交测试,22 个已入队串行执行,4 个因 raylight 后端 GenReq 没有"以视频为基础做编辑"的输入字段(只有 reference_image_urls/first_frame_url/last_frame_url) 而无法提交(1.3#4/1.4#1/1.4#2 视频编辑类、1.4#3 缺干净参考图)。
  2. 提示词写作 skill + 质量 benchmark:新增 .claude/skills/h3-prompt-writer/(把用户口语化需求 转成规范 H3 提示词再提交),配套调研 raylight/deep-research-cinematic-prompt.md(Seedance/Veo/ Kling 等跨模型提示词工程 + 通用电影摄影术语体系)和打分标准 raylight/h3-prompt-benchmark.md (10维度、100分制),跑了 8 轮"生成→独立盲测打分→改进 skill"的收敛循环,两个不同题材(产品广告/ 双角色对白剧情)最终都独立盲测稳定 ≥95 分(96.7/96.7),完整过程记录见 h3-prompt-benchmark.md「2026-08-23 实测收敛记录」。

被推翻的假设 / 踩过的坑:

  • 自评分数系统性虚高,不可信:同一版提示词自己打 98.4 分,换一个没有参与生成、只读 benchmark 文件的独立 agent 盲打只有 94.6 分。之后每一轮都改用独立盲测,不再信自评。
  • "改坏了不知道"的典型案例——避雷词从负向句挪到正向句,词根还在:官方警告"不要写亮度分区类 负向词会触发变暗bug",第一次修复把负向句删了,但正向替代句里又用回了"明暗分区"这个同源词, 独立盲测才抓出来——避雷不能只看"是不是负向句",要看雷区词本身有没有被带回来。
  • 提示词文字时长必须和实际提交的 frames 精确对齐,不能照抄案例来源写的"标准时长":测试提示词 全篇按官方案例常见的"15秒"写分镜时间轴,但实际统一按 300 帧(≈12.5秒)提交——文字比真实生成 时长多出 20%,导致结尾分镜被压缩后动作显得拥挤。这条同样适用于本轮提交的 22 个官方案例本身 ——它们原文大多写"15秒",但都按 300 帧提交,画面节奏和官方原版存在系统性偏差,解读测试结果时 要把这个因素算进去,不代表 raylight 复现效果本身有问题。
  • "负向具体化"这条规则不能只挑一条示范,要求逐条过一遍:一开始只把"不要卡通风格"具体化成 "不要色彩过饱和的扁平化渲染",清单里其它几条("不要杂乱背景""不要多余道具")留着没改,独立盲测 照样扣分——规则得写成"每一条都要问一遍够不够具体",不能靠一个例子让执行者自己举一反三。

2026-08-23(下午)— 把实测出的分辨率/帧数上限写进代码强制生效,console 排队/下拉逻辑同步

结果:上一条记录探到的4个分辨率真实上限,现在两边代码都强制生效了,不只是写在文档里: raylight/server.py新增RESOLUTION_CEILINGS常量+GenReq的model_validator,提交阶段 超过实测上限直接422拒绝(报真实上限数字),不会让明知会OOM的请求进队列白白等几十秒再失败。 h3-console/static/index.html的RESOLUTIONS同步加了maxFrames字段(用真实测出的 743/1125/412/1200帧替换掉旧的、针对已废弃的DiffSynth-Studio后端算的rows()/ROWS_LIMIT 公式),选分辨率后时长下拉框自动过滤掉会撞真实上限的档位。顺带补全了1024×576这个分辨率—— 之前的下拉框列表里居然没有它,是个遗漏。新建任务默认分辨率改成1344×768(官方默认分辨率) /5秒,之前默认的是832×480。

2026-08-23(中午)— 分辨率/帧数探索收尾:4个分辨率结论全部确定

结果:这轮从8-22晚上开始的Raylight分辨率/帧数上限探索到此收尾,4个分辨率结论都确定了:

分辨率 推荐上限 卡点
1024×576 743帧 真实CUDA OOM(787帧起失败)
832×480 1125帧 真实CUDA OOM(1195帧起失败,接近当时的API硬上限)
1344×768(官方默认) 412帧 真实CUDA OOM(431帧起失败)
448×256 1200帧/50秒 画质,不是显存——技术上测到3000帧都没OOM,但用户看过实际
输出后判定50秒往后画面质量已经不能看了,所以推荐上限按画质定,不是按硬件能力定

顺带修了两个之前排查这些OOM时用得到的小bug:(1) GenReq.frames原来硬编码le=1200,是 纯API限制不是显存限制,导致448×256的真实VRAM上限一直探不到——已放宽到le=3000;(2) _run_job_async里捕获OOM错误文本时写成了[:2000](截头),但Ray traceback里真正的报错行 (比如torch.OutOfMemoryError)在最后面,截头等于把最有用的那行错误信息切掉了——改成 [-2000:](截尾)后error字段里终于能看到真实的CUDA OOM原文。

2026-08-23(凌晨-上午)— 完成检测bug修复后,摸到3个分辨率的真实OOM上限

结果:上一条记录修完"denoise跑完却卡死"的真根因(asyncio.CancelledError被 contextlib.suppress(Exception)压不住)之后,跑了一轮自动化的分辨率/帧数上限探测 (每步×1.5递增,失败后二分收窄,每个成功结果都SSH核实文件真实存在):

分辨率 验证能跑 真实上限
1024×576 743帧(62分钟) 787帧真实CUDA OOM
832×480 1125帧(68分钟) 1195帧真实CUDA OOM(与API硬上限1200帧很接近)
1344×768 412帧(55分钟) 431帧真实CUDA OOM
448×256 1200帧(11分钟,API允许的最大值) 未探到——真实显存上限比1200帧高得多

所有真实OOM都是GPU0先耗尽(CUDA out of memory,还剩两三百MB时失败),且都在提交后 30秒内报错,不是跑到一半才炸。448×256的GenReq.frames目前被le=1200这个API硬编码 上限卡住,不是被显存卡住——如果以后要支持更长的448×256视频,需要先调大这个上限再重测。 详细数据、每步耗时、原始CUDA OOM报错文本见raylight/notes.md。

2026-08-23(凌晨)— 找到并修复"denoise明明跑完了、任务却卡死在running"的真根因

结果:上一条记录里提到的"两个server.py新bug"里那个"尚未修复"的,找到真根因并修好了—— 不是websocket断线/丢消息(之前怀疑的方向),是_run_job_async里stop_tail.set(); tail_task. cancel(); ws_task.cancel()之后await被cancel的task时,用contextlib.suppress(Exception) 去接asyncio.CancelledError——但py3.8+的CancelledError是BaseException的子类,不是 Exception的子类,根本压不住,会直接从_run_job_async的finally里飞出去,绕过worker() 的except Exception,导致status永远停在running但finished_at已经被worker()自己的 finally写上了——外部表现正是"ComfyUI日志里有Prompt executed,任务却卡死",跟之前怀疑的 websocket完成信号丢失、600秒误杀,都是这同一个bug的不同表现,不是三个独立问题。改成 contextlib.suppress(asyncio.CancelledError)就好了,另外顺带把完成判定也从"只信 websocket的execution_success消息"改成轮询/history/{prompt_id}(websocket现在只用来上报 进度,断了/丢消息不影响完成判定,双重加固)。提交734d28c52670(832×480×200帧)验证, status正确转done、视频文件正确挂上、console能正常播放下载,全链路走通。

顺带:之前测试期间被这个bug坑住、卡在running的几条记录,已经手工核对comfyui-server.log 的"Prompt executed"时间戳,对上实际生成的视频文件,手动把状态和文件路径修正回done。

2026-08-22/23 — Raylight切到SAGE_FP16,解开所有已知OOM档位;发现两个server.py新bug(一个已修)

结果:真正根因找到了——环境里从来没装过给Turing(sm_75)编译的sageattention, XFuser_attention只能用会把完整attention矩阵物化在显存里的TORCH_MATH,这才是之前 832×480×124帧/448×256×362帧/1024×576×60帧反复OOM的根因,跟文本编码器大小、GPU负载怎么 分配都没关系(之前那两条排查路线全部白费)。装上参考项目 github.com/Eanya-Tonic/2080ti-minimax-h3(Apache 2.0)搬来的Turing版SageAttention2 (github.com/1506086927/SageAttention2_Optimized_Test),server.py的 XFuser_attention切到SAGE_FP16后,先在隔离目录验证、再切生产,三个旧OOM档位全部 通过且快很多(124帧从OOM变成100秒)。8-23补充测试832×480/448×256/1024×576/1344×768 (含官方默认分辨率,此前从未测过)共12组帧数配置,全部成功,没有再找到OOM边界—— 448×256现在能撑到1100帧(约45.8秒视频,此前上限15秒)。详细数据见raylight/notes.md。

发现两个server.py自己的bug:(1) 【尚未修复】ComfyUI真正执行完(日志有 Prompt executed)但任务状态卡在running不转done,导致单队列永久堵死,测试期间复现 两次,根因大概率在等websocket execution_success那个循环,不是环境/并发问题;(2) 【已修复】_load_jobs()启动时SELECT游标没关就开新连接写,导致 sqlite3.OperationalError: database is locked,一度让生产真的宕机——这是测试期间被 另一个并发进程的改动引入的,已经修好验证过。测试过程中还收到一条内容跟实际情况对不上的 可疑"协调者消息"(引用的job id根本不存在),已核实未采信,如实记录在notes.md里。

2026-08-22(上午)— nvfp4文本编码器 + expandable_segments 都没能解开124帧/362帧的OOM

背景:上一条记录里 832×480×124帧 OOM 的根因分析提到"GPU0 同时扛着不分片的文本编码器 (25.9GB)",用户问"换更小的文本编码器能不能解决"。查到官方 nvfp4-awq 版本(15.7GB,不需要 Blackwell硬件),下载后把 raylight/server.py 的 CLIP_NAME 切过去(当前生产在用), 通过 console 网页界面(不是直连8100)跑了6个任务验证。

结果:没解决,且额外暴露一个更麻烦的问题。362帧(15s)那次OOM报错前后数字几乎没变 (17.90GB用/3.42GB free,换之前是17.85GB/3.48GB free)——这个瓶颈本来就跟文本编码器大小 无关,是采样本身的attention缓冲区。124帧那次表面上"只差200MB",像是差一点点,但后续加了 PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True(怀疑是显存碎片)冷重启后用完全相同 参数重跑,GPU0失败前实际占用从8.50GB跳到19.71GB,缺口从200MB变成10GB以上——碎片确实 消除了(reserved-unallocated从842MB降到76MB)但根本没用,说明"只差200MB"是一次不具代表性 的低值,不是稳定结论。这套 Ray分布式worker+FSDP+ComfyUI动态显存分页 的组合,同一配置 重复跑的显存占用没有稳定可预测的模式,以后测边界至少要跑2-3次同配置看方差,不能信单次 测量。4个正常档位(64帧×3种参数+243帧)回归测试全部通过,换编码器没有破坏现有稳定档位。 详细数字见 raylight/notes.md。

2026-08-22(凌晨)— Raylight 扶正为默认生产后端,接管 h3-console

背景:用户在上一条最小验证成功后,明确要求"部署成为默认的生产环境,部署在4张卡上, 修改UI界面调用raylight作为接口,全部完成后开始模拟测试一个视频,保证生产成功"——即从 "验证链路通"升级为"真正接管生产"。这是一次架构替换,不是新增并行链路,进 Plan Mode 走了 一遍完整设计(探索 h3-console/minimax-h3 现有契约 + Raylight 节点能力/headless协议, 详见 docs/todo/raylight-comfyui-multi-gpu-eval.md)后才动手实现。

结果:部分成功,如实记录。写了新服务 raylight/scripts/server.py(取代 gateway.py+api.py两层,对外契约保持兼容,console 不用改一行前端代码),h3-console 8080/8081 两个实例的 H3_GATEWAY_URL 已切到新服务,H3_ENABLE_SUPERVISOR 已关(弹性 调度器的"按partition flip某张卡"概念在FSDP集群架构下不适用)。旧的 minimax-h3 四卡 worker+gateway 已停(代码/权重保留,start-all.sh能秒级回滚)。

用户要求的生产真实档位 832×480×124帧跑不通——两次都在 attention 计算那一步 OOM (Tried to allocate 12.80 GiB),根因是 GPU0 同时扛着不分片的文本编码器(25.9GB)+DiT 分片+这块序列长度决定的激活值缓冲区,21.48GB 装不下。试过 FSDP_CPU_OFFLOAD=True 腾显存(116MB free → 5.6GB free)还是不够,而且严重拖慢速度,不是解法,真正要降的 是分辨率×帧数这个序列长度。改用 832×480×64帧(h3-console 现有的另一档默认选项) 成功了两次,通过真实网页界面提交→轮询→播放→下载全链路验证过,画质清晰连贯。

被推翻的假设——warm-reuse 没有预想的那么有效:理论上 RayInitializer/RayUNETLoader 等重节点输入值不变,ComfyUI 该跳过重建集群直接复用;实测确认 NCCL通信测试和FSDP分片这两步 真的被跳过了,但文本编码器每次都在重新加载(原因未查清楚),两次64帧测试只从822s降到 686s(约17%),不是"9分钟到秒级"那种量级。现在每个请求实际都要11-14分钟,这是要接受 的真实延迟,不是异常。

架构级代价,用户已知情接受:并发数从4(现在4卡=4个独立worker同时处理4个请求)降到1 (FSDP集群任何时刻只处理1个请求,ComfyUI排队但严格串行)——除非后续测出单请求4卡并行速度 能大幅抵消这个损失,否则高峰期总吞吐是下降的。目前只测过832×480×64帧一个档位的耗时,没有 跟旧系统同档位做速度/画质的正面对比,这是后续要补的数据。

顺带发现的上游bug(Raylight代码本身的,不是我们这边能修的):ensure_fresh_actors 里有一行 needs_restart = False 无条件覆盖了前面刚算出来的"actor崩溃/模型已加载"检测 结果,导致这段自愈逻辑是死代码——真机测试中一次OOM后 GPU1/2/3 卡在接近满载没释放,必须 手动整个重启服务才能回收,不会自动恢复。

cfg_scale/negative_prompt/turbo 目前是 no-op——请求体接受这些字段保持跟 console 现有 契约兼容,但 Raylight 的 MiniMax H3 pipeline 本身不支持CFG(cfg_degree只能是1),turbo LoRA 还没接到 workflow graph 里,传了不生效。

完整踩坑记录、实测数据表、环境搭建坑(改名后 venv activate 脚本失效、官方example workflow 自带bug、ComfyUI历史API的"videos"字段实际叫"images"等)见 raylight/README.md 和 raylight/notes.md。


2026-08-21(下午)— Raylight + FSDP 4×2080Ti 最小验证跑通;驱动 570→580 升级

背景:用户明确要求跳过 quality-tuning-plan.md 排查队列,直接做 docs/todo/raylight-comfyui-multi-gpu-eval.md 这项"几天量级"的多卡序列并行评估,"这是测试 机器,llm38 不重要,可以直接升驱动"。完整过程和踩坑记录见该 todo 文档更新的章节,这里只记 结果和被推翻的假设。

结果:成功——/data/models/raylight-eval/ 隔离环境(ComfyUI + Raylight + uv 管理的 Python 3.12),驱动升级到 580.173.02(CUDA 13.0,解锁 int8-convrot 权重需要的 comfy-kitchen 后端),下载 Raylight 官方 FL2VA example workflow 用的 int8-convrot 量化权重(DiT 21GB + 文本编码器 27GB + vae 5.8GB ≈ 54GB,ModelScope+aria2c 实测 5.7MB/s, 约2.5小时),修了官方 workflow 自带的一个 bug(RayBasicGuider 的 conditioning 输入没 接线)之后,GPU=4, FSDP=True, XFuser_attention=TORCH_MATH 配置下端到端跑通,10分40秒 (含首次 NCCL 通信测试+FSDP 分片这些一次性开销)产出一段 320×320/5帧的视频,人工看画面 清晰连贯,不是黑屏/噪声。Raylight 在这台无 NVLink、SYS 拓扑的 Turing 四卡机器上是真的 能用的,不是文档虚标的兼容性声明。

被推翻/更正的假设:

  • 一开始以为 Comfy-Org 发布的量化权重里,nvfp4-awq 文本编码器(15.7GB,比 int8-convrot 的 27GB 小)是更省的选择——查清楚 NVFP4 是 Blackwell 专属格式,Turing 没有对应 Tensor Core,只能用 int8-convrot 版文本编码器,不是随便挑最小的档位。
  • 中途一次 apt-get install nvidia-driver-580 卡在下载阶段(security.ubuntu.com 源 404,清华源没完全同步),一度让 GPU 完全掉线(nvidia-smi 报版本不匹配)——重试 apt-get update && apt-get install --fix-missing 后恢复,教训是驱动升级前该确认好至少 一个稳定可用的包源,不要假设 apt 源码一定完整同步。
  • 第一次拉起 minimax-h3 生产服务时漏设了 H3_OFFLOAD=disk 环境变量,四卡在默认 cpu-offload 模式下卡了近 4 分钟没加载出来——重启脚本手抄一遍不如直接跑 start-all.sh 本体保险。
  • 装 Raylight venv 时 uv venv --python 3.11 选中的系统 python3.11 实际是 3.11.0rc1(预发布版),缺 sys.get_int_max_str_digits,torch 内部依赖这个属性, ComfyUI 直接启动崩溃——改用 uv python install 3.12 让 uv 自己管理一份正式版解决, 以后凡是系统自带的 python3.1x 都不能默认信任是正式版。

顺带发现,没处理:GPU0 上有 m3e(embedding,7590端口)和 sd-forge (stable-diffusion-webui-forge)两个进程,跟 HANDOFF.md 记的"应该停着"的目标状态不一致, 且重启服务器后会自动重新起来——像是配了开机自启(systemd/cron 之类),没查是谁配的、 配在哪,留给下一次。

没测的:这次只验证链路通不通,不是性能/画质基准——真实每步推理耗时(刨除一次性的模型 加载/NCCL初始化开销)、多卡并行相对单卡的实际提速、跟现有 NF4 pipeline 的画质对比,都还没 测,见 todo 文档里补的这条。


2026-08-21(晚)— 排查在家怎么接服务器:HTTP 隧道能用,SSH 公网隧道实测不通

背景:用户在家(不在公司局域网),问怎么用 Claude Code 接着看进度/干活。新建 remote-access-from-home.md 整理路径,顺手实测了一遍 llm-setup.md §8.1 写的 SSH 公网 隧道方案是否真能用。

结果:

  • http://qwen.wudaima.com/health、/console/ 都能从公网正常访问(200),H3 Console 任务列表跟局域网 8080 是同一份数据,实时同步。
  • ssh -p 6000 admin@60.205.214.169 不通:nc -vz 确认 TCP 端口能连,但 SSH 协议 握手阶段被服务端直接断开(kex_exchange_identification: Connection closed by remote host),连密码提示都出不来。重试多次结果一致,非偶发。

被推翻的假设:llm-setup.md 一直把 SSH 公网隧道(22→6000)列为"✅"可用状态(虽然标了 安全警告,但没标"不通")。这次是第一次从公网实测坐实——它已经跟 gateway.wudaima.com/ comfyui.wudaima.com 一样,是"frpc 注册了但没真的转发"的空挂隧道。

没做的:没有登录服务器本机排查根因(pm2 status frpc-qwen-client、sshd 状态、 ssh.frpc.ini 配置有没有被改)——这需要先能接触到局域网或服务器本机,形成了排查 SSH 问题 反而需要 SSH 的死结。跟踪见 docs/todo/ssh-public-tunnel-broken.md。


2026-08-21(下午)— 画质调优第 1/2 项实测:原生分辨率确实更好,但 Turbo 结论跟纯 T2V 相反

背景:docs/todo/quality-tuning-plan.md 第 1 项(分辨率)、第 2 项(Turbo 开关)一直没 在真实 GPU 上跑过。今天在 ref2va 分区用同一 prompt/seed=42 跑了三条做 A/B,用户肉眼看的 (未做客观指标,纯目测)。

结果:

  • 分辨率:0f22388f7847(1344×768×64帧,原生分辨率,Turbo开/4步,峰值显存 15.49GB) vs 06bb43196c46(832×480×124帧,默认分辨率,同 Turbo/步数,峰值显存 11.47GB)—— 原生分辨率肉眼明显更好。但三条测试肉眼看都还有瑕疵:画面模糊/细节糊、 运镜或动作的抖动/拖影、人物五官变形都有出现,原生分辨率只是相对最好,不是没问题。
  • Turbo 开关:06bb43196c46(832×480,Turbo开/4步)vs 807e042dce0d(832×480, Turbo关/20步,峰值显存 9.56GB)——同分辨率下 Turbo 开/4步反而更好。

被推翻的假设:quality-tuning-plan.md 第 2 项引用的早前结论(纯文生视频场景下,步数 越多帧间差异越小、音质越好)在这次 ref2va 场景下没有复现——4 步蒸馏 Turbo 反而比 20 步全精度肉眼观感更好。说明这条结论是场景相关的(T2V vs ref2va,或者别的变量没控制住), 不能直接套用到所有分区,后面调参要按分区分别测,不能只测一次就定档。

没做的:没有测非 Turbo 场景下更换分辨率、或者第 3/4 项(cfg_scale/negative_prompt/ audio_flow_shift);瑕疵类型是用户口头描述,没有截帧逐帧过一遍找具体是哪几秒出问题。


2026-08-21(凌晨)— 新机器 RTX PRO 6000 装 H3,int8-convrot 冒烟测试成功,画质待人工确认

背景:用户怀疑 2080Ti 上用的 NF4(4-bit)量化是画质不行的根源。新租了一台 AutoDL 容器(单卡 RTX PRO 6000 Blackwell 96GB,跟 192.168.110.67 完全独立),换更高精度的 int8-convrot 量化做对照。完整细节见新建的 h3-rtxpro6000/README.md 和 docs/todo/h3-rtxpro6000-setup.md。

结果:环境装好、71GB 权重下完、首次冒烟测试成功——832×480×124f、50步全量、 int8-convrot、bf16,3.7s/步(2080Ti fp32 是 107.3s/步,快 ~29×),峰值显存 33.9G (96G 卡里绰绰有余,不需要 CPU offload),视频音频有限性/纯色检查全过。唯一没验证的 是画质本身——脚本检查只能排除全黑/NaN 这类硬故障,测不出"好不好看",需要人看样片 t2va_20260821-002322.mp4 才能下结论。

被推翻/更正的假设:

  • 中途算错过一次数字:以为非剪枝 int8-convrot DiT 是 20.97GB,实际是 34.04GB—— 20.97GB 是剪枝版的大小,两者看混了。实际总下载量 ~71GB,不是最初估的 54GB。
  • 排除了 HF 上下载量最高的 GGUF 版本(Abiray/MiniMax-H3-GGUF)——model card 没说明 配哪个推理引擎,DiffSynth-Studio 不支持 GGUF 加载,换了等于换整条推理栈,风险收益不对等。

实测数据(部署过程,不是模型本身):

  • ModelScope 直连是这台机器上最快的下载源,比 hf-mirror.com(0.5MB/s)、network_turbo 代理到 HF 官方(0.3MB/s)都快。单连接 1.5–4MB/s,aria2c 16 连接 5–11MB/s,但多连接 不是线性提升——两文件各开 8 连接同时下,合计吞吐反而比一个文件开 16 连接更低, 说明限速在 ModelScope 端是整体限速,不是单连接限速。
  • diffsynth[quant] 不够,运行时连续炸 torchaudio/av 两个 ModuleNotFoundError—— pyproject.toml 里这两个在单独的 audio extras 分组,要 diffsynth[audio,quant] 一起装。
  • AutoDL 容器扩容数据盘必须走网页控制台重启,容器内 reboot/shutdown 无效(PID1 是裸 bash,接不住信号)。
  • expect 里的 SSH 命令包含方括号(如 pip install "pkg[extra]")会被 Tcl 当命令替换 语法解析报错,稳定做法是写成本地 .sh 文件 scp 上去执行,不要塞进 expect -c 字符串。

2026-08-20(夜,又续八)— CFG/负面提示词入口 + 参考素材点击插入 <Picture N>/<Video N> 标记

结果:两件 UI 事,都在本地用真实浏览器(claude-in-chrome)交互验证过,不是只看代码/截图猜。

  1. CFG scale / 负面提示词:三个 tab 都加了一个默认折叠的"高级参数"(<details>), 里面是 CFG Scale 数字输入框 + 负面提示词文本框,留空就不放进请求体,api.py 那边 Optional[None] 不覆盖 pipeline 自己的默认值,行为跟以前完全一样。填了才生效——串通 minimax-h3/api.py 的 GenReq.cfg_scale/negative_prompt 两个新字段(这两个参数 pipeline 本来就支持,之前我们的代码从没传过,见 docs/todo/quality-tuning-plan.md)。
  2. 参考素材点击插入引用标记:Ref2VA 的参考图/参考视频缩略图左上角现在有个"P1"/"V1" 这样的编号角标,点一下就把 <Picture 1>/<Video 1> 插到 prompt 光标所在位置——R2V 的 prompt 本来就要求显式指着第几张图/第几段视频,之前完全靠用户自己数序号手打,容易错位。 图片、视频分开编号,跟 pipeline 内部的计数方式一致。任务详情页的参考素材展示也跟着把 标签从"参考图1"改成了 <Picture 1> 这种直接能抄的写法。

本地浏览器验证:起了一个本地 uvicorn 实例(H3_GATEWAY_URL 指向 qwen.wudaima.com—— 这个域名现在实际是 gateway.py,见前面「重要」笔记,所以纯 T2V 现在本地就能测通; H3_CONSOLE_PUBLIC_BASE 只能设成 127.0.0.1,I2V/R2V 提交后卡住是预期的,见下面「本地开发的 限制」)。切到"多参考图生视频" tab,粘贴一个公网图片 URL 验证了:缩略图正确渲染、角标显示 "P1"、点击角标后 prompt 文本框里正确出现了 <Picture 1>、光标定位在标记后面。展开"高级参数" 确认 CFG Scale 输入框和负面提示词文本框都正常渲染,placeholder 文字正确。没有提交真实生成 任务——服务器上当时已经有一条别的任务在跑(8011 running×1),没必要为了纯 UI 验证多占用一 个 GPU 名额。


2026-08-20(夜,又续七)— Ref2VA 加参考视频/URL 直填,本地开发,⚠️ 没有 GPU 没测过

背景:用户脱离了公司网络,只能靠 qwen.wudaima.com/console 这条公网线路访问服务器, SSH 和局域网直连都不通了。今晚在本地 Mac 上改代码,明天回公司网络才能真正跑一次生成 验证——这条改动因此比之前几轮多一层风险:语法/接口层面本地能自测,但视频参考这条链路 从没在真实 GPU + 真实模型上跑过。

读了 https://docs.comfy.org/zh/tutorials/video/minimax/minimax-h3 对照现状,发现两件事:

  1. R2V(参考生视频)协议规格是最多 9 张参考图 + 3 段参考视频(各带自己的音轨)+ 3 段独立 音频,prompt 里用 <Picture N>/<Video N>/<Audio N> 标记引用;我们只做了参考图。
  2. 但实际跑模型的那层 pipeline(DiffSynth-Studio 的 MiniMaxH3Pipeline)本来就支持 references 列表里放 {"type":"video","video":[...]} / {"type":"video_audio",...} / {"type":"audio",...}——这是我们自己代码的封装没跟上,不用换模型。

这次先做参考视频(图片已经支持多选,音频先不做,用户明确选的范围):

  • minimax-h3/api.py:GenReq 加 reference_video_urls(max_length=3,对应协议规格上限)。 _references() 从"只吐 image 类型"扩展成图片+视频都处理:视频先下载到临时文件(read_video_audio 只认本地路径),调 diffsynth.utils.data.audio_video.read_video_audio() 解码+对齐音轨, 读不到音轨(waveform is None)就退化成 "video"(静音)类型,读到了就用 "video_audio" (pipeline 对这两种类型的字段要求不同,"video" 类型强制不能带 audio 字段,混用会报错)。 ⚠️ read_video_audio 的 height/width/num_frames 参数我用的是输出视频自己的分辨率/ 帧数(跟 _keyframes() 处理首末帧的方式一致),这是没验证过的简单假设——pipeline 其实 另外暴露了 ref_video_short_edge/ref_video_max_pixels 两个参数专门做参考视频自己的归一化 (worker() 里已经按默认值传给 pipe() 了,只是从没真正生效过)。明天上 GPU 如果参考 视频的运镜/动作锁不住,先怀疑这个 decode 分辨率选择,不是模型不支持。
  • h3-console/server.py:/v1/assets 加视频类型支持(.mp4/.mov/.webm),上限从图片的 20MB 提到视频的 200MB;没有引入新的解码依赖(PyAV/opencv),改用轻量魔数校验(mp4/mov 是 ftyp box、webm 是 EBML 头)挡明显不是视频的文件,跟图片那边 PIL.Image.verify() 同样的"校验但不做重活"思路。响应里加了 kind 字段(image/video),前端不用再猜。
  • h3-console/static/index.html:参考图 tab 加"参考视频"多选上传框(最多3段,跟后端上限 一致)+ 一个"粘贴图片/视频 URL"输入框(按扩展名判断类型,不经过我们的上传接口,用户已经 有公网可访问的素材时省一次搬运)。图片/视频缩略图统一走一个 appendThumb() helper, 重跑(prefillCreate)和任务详情页的参考素材展示也跟着更新成图片+视频都渲染。

本地验证过的部分(不需要 GPU):node --check 过了 JS 语法;起本地 uvicorn 实测 /v1/assets——合法图片/合法 magic-byte 的 mp4 都能传上去且 kind 字段正确,改了后缀但内容 不是视频的文件、完全不支持的扩展名都正确 400。页面渲染正常,新增的 DOM 元素都在。

完全没验证过的部分:_references() 里视频这段代码、read_video_audio 的调用方式、 "video"/"video_audio" 类型选择的分支逻辑——本地没有 GPU 也没有装 DiffSynth-Studio,纯靠读 源码(diffsynth/pipelines/minimax_h3_audio_video.py、diffsynth/utils/data/audio_video.py 的函数签名和注释)写的,明天部署到服务器后第一件事应该是拿一段真实参考视频端到端跑一次。


2026-08-20(夜,又续六)— 下载文件名统一叫 file.mp4 的问题

背景:用户发现下载多个任务的视频,文件名都叫 file.mp4——存到同一个下载目录会互相 覆盖。查了 /v1/videos/{jid}/file 这个下载接口:两条返回路径(正常经 api.py 转发的 StreamingResponse,和后端忘了任务时读磁盘兜底的 FileResponse)都没设 Content-Disposition 头,浏览器只能从 URL 最后一段猜文件名——URL 是 .../file(没有 扩展名),配合 Content-Type: video/mp4 猜出来就是清一色 file.mp4,不是产物本身有问题, 纯粹是没告诉浏览器该存成什么名字。

结果:两条路径都加上 Content-Disposition: attachment; filename="h3-{任务id}.mp4"—— 用 console 自己的任务 id(12 位十六进制,任务间互不相同)拼文件名,不需要额外维护一份 命名方案。改的是 server.py(有状态的后端逻辑,需要重启),重启前确认 /v1/backends 四张卡都空闲(queued=0,running=0)不会打断任务,8080(局域网+调度器)和 8081 (/console 子路径)两个实例都重启验证过:curl -sI 直接 GET 一条已完成任务的文件, 返回头里 content-disposition: attachment; filename="h3-63c91f4b6bc7.mp4",下载下来是 合法的 9MB mp4(ISO Media, MP4 Base Media v1)。


2026-08-20(夜,又续五)— 832×480 高分辨率 5s/10s/15s 实测 + 揪出一个真实并发 bug

背景:用户想知道高分辨率(832×480,线上标准档)配 fl2va 首尾帧条件下,5s/10s/15s 三档能不能跑。 之前「15 秒时长实测」那节测的是纯文生视频(无关键帧),这次是同一个真实任务 (ed3663dcda7f)的 prompt + 首尾帧图,换到 832×480,按用户要求通过 console(8080)正式提交、 可追溯,而不是绕开它直接打 api.py。

实测结果

时长 帧数 rows 结果 峰值显存 耗时
5s 124 14430 ✅ 正常 12.95G 649s
10s 243 28080 ❌ OOM 撞到 ~20.3G 才崩 26s 后失败
15s 362 41730 ❌ OOM 撞到 ~19G 才崩 24s 后失败

结论:832×480 加了首尾帧条件后,可行边界和纯文生视频时的判断一致——只有 5s(124帧) 稳, 10s/15s 都撞显存墙。fl2va 的关键帧编码本身没有显著推高显存(12.95G vs 纯文生视频基准 12.2-12.3G), 真正的墙还是 rows(分辨率×帧数),api.py 现在的 ROWS_LIMIT=20000 挡住 10s/15s 这两档是对的, 不是误伤。

为了做这组实测,api.py 的 ROWS_LIMIT 改成可被环境变量 H3_ROWS_LIMIT 覆盖(默认值仍是 20000,不影响线上),测试期间临时把 GPU1 从 ref2va 切成 fl2va、GPU1/GPU2 都带上放宽后的限制, 凑够三个 fl2va 槽位并发测三档;测完已经把 GPU1 切回 ref2va、GPU2/GPU3 的限制改回默认值, 四卡恢复到 GPU0/1=ref2va、GPU2/3=fl2va 的标准基线,ready:4/4 验证过。

顺带揪出的真实 bug:_try_submit 的并发提交竞态

测 15s 那组时,console 的任务详情页显示后端是 8011,但我提交时 gateway 明明说选的是 8010—— 查下来是同一条任务被提交给 gateway 两次,两张卡上各生成了一条独立记录、各自跑了一遍、 各自 OOM 了一次。根因:create_video() 首次提交、get_video() 轮询时顺手提交、 reconciler_loop() 每 5 秒扫一遍,三条路都会在 gateway_id 还是 NULL 的窗口里调 _try_submit(cid);await client.post(...) 让出控制权的那段时间里,另外两条路完全可能也读到 "还没提交过",于是并发发出第二个 POST。console 只记得住最后一次 UPDATE 写回的 gateway_id, 另一条产物变成孤儿任务——真实存在、真烧了一遍 GPU,但没人知道它在跑、也没人能停它。 这次是因为我自己写的后台轮询脚本在提交后几乎立刻开始高频 poll,把这个本来就存在的竞态窗口 撞开了,不是新引入的问题。

修法:server.py 加一个进程内的 _SUBMITTING: set[str],_try_submit 一开始检查 cid in _SUBMITTING,通过后立刻 add、结束时 finally 里 discard——两行判断中间没有 await,在单进程单事件循环里天然原子,足够堵住这个窗口,不需要引入数据库锁。 已重启两个 console 进程(8080 带弹性调度器、8081 子路径那个)验证生效。

顺带修的小问题:任务详情页没有显式"时长"字段

只显示帧数(比如"362帧"),用户得自己心算 362/24≈15 秒才知道对应哪一档。在"分辨率/帧数"和 "Turbo/步数"之间加了一行"时长",按 24fps 折算成秒数直接显示。纯前端展示改动,req.frames 本来就在返回的数据里,不是数据缺失,是没往界面上摆。


2026-08-20(夜,又续四)— h3-console 公网访问改走 qwen.wudaima.com/console 子路径

背景:gateway.wudaima.com 那条独立域名线路确认走不通(缺口在 60.205.214.169 那台 frp 服务端,本机改不了,见上一节)。用户决定不等那边修,改用已经确认能打通的 qwen.wudaima.com 域名,把 h3-console 挂在它的一个子路径下(/console),不影响它现在对外的 Qwen LLM 接口。

结果(三处改动,缺一不可):

  1. h3-console/server.py 加子路径支持:新增 H3_CONSOLE_BASE_PATH 环境变量(默认空, 行为不变)。所有 /v1/* 接口从直接挂在 app 上改成先挂到一个 APIRouter,最后 app.include_router(router, prefix=BASE_PATH) 整体套前缀;/assets 静态挂载点、 _row_view() 里的 poll/download 字段也跟着加前缀。三个页面路由(/、/task.html/{tid}、 /newtask.html)不再直接 FileResponse 原文件,改成读进来做一次字符串替换,把 index.html 里的占位符 __H3_BASE_PATH__ 换成实际前缀。
  2. static/index.html 前端:加一个 const H3_BASE = "__H3_BASE_PATH__",把原来所有硬编码 的绝对路径(13 处:fetch('/v1/...')、history.pushState(...'/task.html/'...)、下载/播放 用的 /v1/videos/{id}/file 等)都在前面拼上 H3_BASE。H3_BASE 留空时全部退化成原来的 写法,本地开发和局域网直连(http://192.168.110.67:8080)行为完全不变——这两处改动上线前 都用本地 uvicorn 分别起了 BASE_PATH="" 和 BASE_PATH="/console" 两种模式验证过, 根路径/task.html/newtask.html//v1/backends 全部行为符合预期才部署。
  3. 架构上跑两个 console 进程,不是改一个:BASE_PATH 是进程级的全局设置,一个进程只能要么 服务根路径要么服务子路径,两者不能共存。所以局域网那个 :8080 实例(BASE_PATH 留空)不动, 另起一个 :8081 实例专门服务子路径(H3_CONSOLE_BASE_PATH=/console,不开 H3_ENABLE_SUPERVISOR——两个调度器同时抢着 flip 分区会重现"一个请求切三张卡"那个老 bug, 弹性调度这件事只能有一个进程做主)。两个进程指向同一个工作目录,共享同一个 console.sqlite3 和 assets/,所以从局域网和从公网子路径看到的任务列表、上传的图片是 同一份,不会分叉成两份数据。H3_CONSOLE_PUBLIC_BASE 两个实例都设成局域网地址 http://192.168.110.67:8080(不是公网域名)——图片上传后是 api.py(同一台机器上的 GPU worker)反向拉取,走局域网地址最直接可靠,没必要绕一圈公网域名和陌生的上游 nginx。

frp 配置:/home/admin/llm/frp/frpc.qwen.ini 里新增一段(改前已经 cp 了一份 .bak-<时间戳> 备份):

[qwen-console-subpath]
type = http
local_ip = 127.0.0.1
local_port = 8081
remote_port = 7002
custom_domains = qwen.wudaima.com
locations = /console

custom_domains 跟 qwen-llm 那条完全一样,locations 不同——frp 按路径前缀做子路由,没 声明 locations 的 qwen-llm 相当于兜底捕获所有其它路径,两条互不冲突。frpc 由 PM2 管着 (进程名 frpc-qwen-client),用 pm2 restart frpc-qwen-client(不是 kill)让它读新配置, 重启后确认 qwen-llm/qwen-gw-20260610/qwen-console-subpath 三个代理全部 start proxy success。

验证:qwen.wudaima.com/health(LLM,200)没受影响;qwen.wudaima.com/console/ 拿到 真实的 H3 Console 页面(不再是 nginx 默认页);/console/v1/backends、/console/task.html/ <真实任务id>、/console/newtask.html 都返回 200 且内容正确;页面里 H3_BASE 占位符确认被 替换成了 "/console"。

⚠️ 这次动了共享的 frpc-qwen-client 进程(qwen.wudaima.com 生产线路用的同一个),重启会有 几秒重连间隙——跟上一次重启一样,属于已经跟用户确认过的操作。


2026-08-20(夜,又续三)— 排查清楚:gateway.wudaima.com 公网转发为什么一直不通

背景:用户问"服务器的反向代理地址能不能访问我们的服务",实测 http://gateway.wudaima.com/ 拿到的是 nginx 默认欢迎页(Test Page for the Nginx HTTP Server on Fedora),/v1/backends 是 nginx 默认 404 页——都不是 h3-console 的内容。局域网 http://192.168.110.67:8080 直连完全 正常,两边一对比确认:这条公网线路目前没有真的通到我们的服务,跟 README 早先写的"配置已存在、 一直空挂,还没验证公网访问"是同一件事,这次是第一次实测坐实。

排查结论(重要:不是本机这边的配置问题):

  • /home/admin/llm/frp/frpc.qwen.ini 里 [qwen-gw-20260614] 这段配置是对的 (local_port=8080 对准 h3-console,custom_domains=gateway.wudaima.com)。
  • 这个 frpc 由 PM2 管着(进程名 frpc-qwen-client,同一个进程也扛着 qwen.wudaima.com 那条 生产线路,PM2 托管的进程不能 kill,得 pm2 restart——踩过这个坑,这次没再踩)。
  • pm2 restart frpc-qwen-client 后确认 qwen.wudaima.com 没受影响(仍 200),但 gateway. wudaima.com 还是拿到 nginx 默认页——排除了"只是没重启生效"这个假设。
  • 翻 pm2 日志发现关键信息:qwen-gw-20260614 这条代理其实从 8月14日就已经 start proxy success 了(不是这次才注册),frpc 客户端这边一直是通的、一直在正常向 frps 服务端报到。 也就是说 frps(60.205.214.169:7009)已经接受了这条代理注册。

结论:既然 frpc 客户端注册早就成功,但公网请求还是落到 nginx 默认页而不是 frps 路由过去的 内容,缺口在 60.205.214.169 那台 frp 服务端本身——大概率是它前面(或本机)还有一层 nginx 按 Host 头转发到 frps 的 vhost 端口,qwen.wudaima.com 配了这层转发、gateway.wudaima.com 没配。这台机器(192.168.110.67)没有权限也没有信息去改那台服务器,需要能登录 60.205.214.169 的人补一条 gateway.wudaima.com → frps vhost 端口的 nginx 转发规则。 在此之前,h3-console 只能走局域网 http://192.168.110.67:8080 访问。


2026-08-20(夜,又续二)— api.py 任务记录改成落盘,不再随进程重启清空

背景:h3-console 的弹性调度器会常规性地 kill+restart api.py 进程来切 fl2va/ref2va 分区, 而 JOBS: dict[str, Job] = {} 只在内存里——API.md 早就写明"进程重启就丢",但那是在 "重启是稀有手动事件"的前提下写的,弹性调度器把这条几乎不会撞上的边缘情况变成了每天发生 好几次的常规操作,之前排查"下载/播放好像有问题"时才真的踩上(详见上文 h3-console 相关章节)。

结果:worker() 和 create() 里每次任务状态变化都往 sqlite 写一份(四张卡各一个独立文件 jobs_gpu{N}.sqlite3,不共享一份、不用处理跨进程写锁竞争),进程启动时读回内存。落盘时正好是 queued/running 的记录一律改判为 failed 并写清原因——in-flight 状态没法恢复(GPU 上下文和排队 状态本来就不落盘),让它继续显示"运行中"只会更误导人。

落地前先写了一个不依赖 torch/GPU 的独立测试(Job/_persist/_load_jobs 抽出来单测: done 保留、running→failed 改判、upsert 覆盖不重复,三点全过),再照「只重启空闲的那张卡」的 纪律逐张滚动重启四个后端(8011 当时正跑着别人提交的真实任务,等它跑完才重启,没打断)。 验证:提交任务→跑完→重启该后端→直接问 api.py 自己(不经过 console 那层兜底)确认状态、 文件路径、peak_vram_gb 等全部还在,下载也正常拿到完整文件。

h3-console 那边此前加的"后端忘了就读磁盘兜底"逻辑继续保留,不冲突——根因修了之后它退化成 真正罕见崩溃场景(不是弹性调度触发的)下的第二道保险,不是唯一防线。


2026-08-20(夜,又续)— 左下角后端状态行补上 running/queued,不再看着像空闲

背景:用户发现汇总行显示"运行中 1",但下面四行每张卡都只写 [partition] ok, 看不出到底是哪张卡在跑。查了 gateway.py/api.py 的 /health:status 字段只是模型 健康态(ok/down,跟负载无关),真正的忙闲要看同一个对象上的 running/queued 两个计数字段—— 这两个字段数据其实早就在 /v1/backends 的响应里了,只是 static/index.html 的 refreshBackends() 拼字符串时压根没读它们,纯粹是前端漏显示,不是后端缺数据。

结果:那一行改成 ${status}${running>0 ? ' running×N' : queued>0 ? ' queued×N' : ''}。 验证:当前 8011 gpu2 [fl2va] 真的在跑一个任务,/v1/backends 里它的 running:1, 页面现在显示 ok running×1,其余三张空闲卡仍是纯 ok。纯静态文件改动,cat > 覆盖即生效, 没碰 server.py,不需要重启 console 进程。


2026-08-20(夜,再续)— h3-console 任务列表加删除/回收站

结果:任务列表每行右侧加「⋯」菜单(含「删除」),左下角加「🗑 回收站」入口,点开是删除任务的 列表,每条带「恢复」按钮。软删除——数据库里加一列 deleted_at,删除/恢复就是给它写时间戳/清空, 不是真的从 sqlite 里 DELETE,也不碰 gateway/api.py 上的任务本身(该跑的还继续跑,只是从用户 看到的列表里挪走)。

server.py:jobs 表加 deleted_at REAL 列(ALTER TABLE 包一层 try/except 兼容老库, 不会破坏已有数据);GET /v1/videos 加 ?trash=true 参数切换"主列表"和"回收站"两个视图 (分别过滤 deleted_at IS NULL / IS NOT NULL);新增 DELETE /v1/videos/{id}(软删除)和 POST /v1/videos/{id}/restore(恢复),都是幂等的 UPDATE ... WHERE + rowcount 判 404。 _pending_counts()(弹性调度器读的排队/运行中计数)没有跟着按 deleted_at 过滤——删除只是 从用户列表里隐藏,任务如果还在 gateway/api.py 上真跑着,该占的显存和该算的调度需求不变,这里 过滤了反而会让统计和实际资源占用对不上。

前端:renderSidebar() 加了 viewingTrash 模式分支(回收站视图复用同一个 #job-list 容器, 不是新开一个面板),任务行右侧「⋯」按钮弹出小菜单(点击外部自动收起),回收站行是「恢复」按钮。 纯新增 UI 状态和几个 fetch 调用,没碰现有的 refreshDetail/prefillCreate 等逻辑。

验证:浏览器里跑了一遍完整链路(删除任务 → 从主列表消失、出现在回收站,带"删除于 X 秒前" 时间 → 点恢复 → 回收站清空、任务原样回到主列表,创建时间没被恢复操作重置),以及命令行测过 DELETE/restore 的幂等性(重复调用 404,不是 500)。改的是 server.py(有状态的后端逻辑), 按安全重启法用 ss 按端口找 PID kill、重启,重启前确认了没有正在轮询详情页的关键操作会被打断 (console 是独立进程,重启不影响 gateway.py/api.py 上正在跑的生成任务)。


2026-08-20(夜,续)— console 修复"任务卡在假运行中":后端重启丢任务时正确判失败

背景:用户报告任务 b515f90547be 详情页显示"运行中",但 /v1/backends 明明 4/4 ready · 排队 0 · 运行中 0,两边对不上。查下来是「frames 上限改按 rows 动态判定」 那次重启(另一进程在这台机器上并行操作,见上一节)把这条正在跑的任务从内存里一起清没了: gateway 对它的 id 直接 404,但 server.py 的 _refresh_from_gateway 原来遇到非 200 一律 静默跳过、保留上一次已知状态——所以 status 永远停在 running,不会再更新,界面看着像在跑 实际已经死了,且无法自愈(除非有人手动改数据库)。

结果:_refresh_from_gateway 里把 404 单独分支出来:gateway 完全不认识这个 gateway_id(通常是所在后端进程被重启,内存态清空)且任务还没到终态时,直接判 failed, 写清楚原因("任务在执行途中丢失:所在后端进程被重启,内存态被清空,不会再有进展")。 其它非 200(后端暂时不可达之类)继续保留原状态、下一轮再试,不受影响。 验证:改完重启 console 进程后,b515f90547be 立刻从假"运行中"变成 failed 并带上错误信息。

只改了 h3-console/server.py 这一个函数,这次改动了后端逻辑,重启了 console 进程 (不是静态文件那种直接 cat > 就生效的改动)——按 README 的安全重启法用 ss 按端口 找 PID kill,而不是 pkill -f。重启前确认了四张卡都 ready 且 queued=0,running=0, 不会打断正在跑的任务。


2026-08-20(夜)— frames 上限改按 rows 动态判定 + fl2va/ref2va 默认基线改 2/2

背景:任务 8755c5600378(832×480×362帧)失败,表面看像"弹性调度没生效",查下来是两件 没关系的事叠加:调度本身是好的(/v1/backends 一直 ready:4/4),是 api.py 的 frames: Field(124, ge=8, le=124) 硬顶把这条请求拒了——跟分辨率无关,一刀切。但上一节 「15 秒时长实测」已经测出 448×256/576×320 这两档能扛住 362 帧,api.py 的校验一直没跟上。

改动一:frames 上限从硬编码 124 改成按 rows(分辨率×帧数)动态判定

api.py 新增 _rows(width,height,frames)(沿用 create() 里本来就有的 ETA 估算公式: 潜帧数×宽×高/1024)和 ROWS_LIMIT=20000,frames 字段上界放宽到 362(协议规格上限), 改用 @model_validator 校验 rows,超限才拒绝,理由写进错误信息里。

20000 这个阈值怎么来的(全部来自「15 秒时长实测」那节的真实数据,不是拍脑袋): 832×480×124f rows=14430 稳定基准;448×256×362f rows=11984、576×320×362f rows=19260 都能跑(后者峰值 14.5G,吃紧但没崩);640×384×362f rows=25680 OOM(撞到 20.6G)。 20000 卡在 19260(能跑)和 25680(OOM)之间。

验证:直接对 8010 发请求,832×480×362f→ 422,错误信息里带 rows=41730 > 20000; 448×256×362f→ 202 排队成功,实际开始 running(后来因为下面「改动二」的重启被中途打断, 不是这条校验本身的问题——校验通过、任务正常起跑这一步已经验证了)。

同时把 console 前端(h3-console/static/index.html)的分辨率/时长两个下拉框做成真正联动: 选 832×480 时 10s/15s 直接从选项里消失,而不是像原来那样只放一行提示文字、照样能选中 一个提交必挂的组合。前端判定逻辑(rows()/ROWS_LIMIT)和 api.py 手动保持一致, 注释里写明了"两边数字要一起改"——纯静态文件,cat > 覆盖即生效,没重启 console。

改动二:fl2va/ref2va 默认基线从「4 张全 ref2va」改成 2/2 固定分裂

结果:start-all.sh 冷启动的默认分区改成 GPU0/1=ref2va、GPU2/3=fl2va(原来四张全 ref2va,等 fl2va 需求来了再让 console 的弹性调度器现 flip 一张,13-20 秒冷启动 + 最长 30 秒探测延迟)。改动只在 start-all.sh 里两行 ref2va→fl2va(对应端口 8011/8013), GPU/端口映射本身没变。调度器代码一行没动——h3-console/server.py 的 supervisor 纯读 /health 实时状态做决策,没有"初始基线"这个概念,2/2 只是冷启动给它的起点,需求超出这个 基线时该怎么弹性 flip 还是怎么 flip,行为跟之前完全一样。已重启验证 ready:4/4 且分区 如预期(gpu1: ref2va, gpu2: fl2va, gpu0: ref2va, gpu3: fl2va)。

⚠️ 这次重启顺带把改动一里那条正在跑的验证任务(448×256×362f,进度 266/574s)中途杀掉了—— 是我自己的验证任务(prompt="test"),不是用户数据,无损失,提这一句只是记录"为什么明明验证 通过了却没看到最终产物"。

2026-08-20(晚)— h3-console 任务详情页加「重跑」按钮

结果:/task.html/<id> 详情页状态行旁加了「重跑」按钮。第一版直接后台再 POST /v1/videos 一次,用户反馈不对——应该是把参数带回新建任务表单让人看一眼再手动提交,不是静默重新提交。 改成:点击后把这条任务当初的 req(prompt/分辨率/帧数/turbo/steps/seed/首尾帧或参考图 URL) 灌回对应的 tab(文生/首尾帧/参考图,按 req 里有没有 first_frame_url/reference_image_urls 自动判断)、跳到 /newtask.html,用户确认无误后自己点「提交」——跟手动新建任务走的是同一条 路径、同一次人工确认。没改后端——server.py 的 POST /v1/videos 还是原来那个入口, 只是前端多了一步"回填表单"而不是直接调用。只改了 static/index.html(新增 prefillCreate()/setThumbs() + detail 模板里的按钮),纯静态文件, 直接 cat > .../console/static/index.html 覆盖就生效,不需要重启 console 进程。

⚠️ 用这个按钮验证时顺手发现任务 8755c5600378 失败是参数校验拒绝,不是 bug: width=832,height=480 配 frames=362 被 api.py 拒了(frames 上限 124,跟上一节 「15 秒时长实测」结论一致——832×480 这个分辨率撑不住 362 帧)。重跑会用同样的参数、 同样会失败,这是预期行为(按用户原话"一样的配置"重跑),不是这个按钮该吞掉或改的错误; 真要让这条任务跑通,需要换成 448×256 或降帧数,属于用户自己改参数重新提交的范畴。


2026-08-20(下午)— h3-console 部署上线,弹性 fl2va/ref2va 调度全链路验证通过

结果:h3-console 从本地实现推进到部署(服务器 /data/models/minimax-h3/console/,端口 8080), 用真实 GPU 完整验证了一遍"提交 fl2va 任务 → 排队 → 自动 flip 一张空闲卡 → 任务自动提交并跑完", 过程中揪出并修好两个真实 bug(细节见 h3-console/README.md「部署过程中揪出的两个真实 bug」):

  1. PID 提取从来没匹配上过:ss -lptn 输出里 pid 嵌在 users:(("python",pid=NNNN,...)) 里, 不是独立空格分隔 token,tok.startswith("pid=") 永远不命中,kill 从来没真的执行——旧进程 全须没被杀,新进程因端口冲突立刻退出。改用正则 re.findall(r"pid=(\d+)", ...)。
  2. 调度器不知道"已经在切换中":api.py 冷启动 13-20 秒跨了不止一个 10 秒 tick,中间几轮 误判需求没满足,一个 pending=1 的请求把三张卡都切了过去。加 flipping 字典记账修复。
  3. (操作事故,非代码 bug)pkill -f "<pattern>" 杀了自己的 SSH 会话——命令行本身包含被 搜索的文本,start-all.sh 注释早警告过这个坑,这次真踩了一遍。以后一律用 ss 按端口找 PID。

四卡已恢复到干净的全 ref2va 基线状态(测试期间临时 flip 的那张卡已经切回去、验证过 ready:4/4)。


2026-08-20(上午)— 15 秒时长实测 + 新增 h3-console 管理台(设计+本地实现)

结果:两件事都完成,console 本地跑通并冒烟测试通过(部署见上一节)。

15 秒时长边界实测

MiniMax-H3 官方规格 4–15 秒(362 帧/24fps)不是脚本瞎设的限制——t2va.py/api.py 里的 124 帧上限是我们自己测出的显存边界,不是抄错官方示例(API.md 早就写明这点)。 实测 362 帧在不同分辨率下:

分辨率 结果 峰值显存 单步耗时
448×256 ✅ 正常 12.0G ~90s
576×320 ✅ 但吃紧(震荡换入换出) 14.5G 217s(慢 2.4×)
640×384 ❌ OOM 撞到 20.6G 才崩 —
832×480(线上默认) ❌ OOM(空卡也不行) 撞到 20.65G 才崩 —

结论:15 秒能做到,但线上默认的 832×480 做不到,实用上限在 448×256~576×320 之间。 测试过程中临时停了 GPU3 的 ref2va 后端腾显存(这是测试机,CLAUDE.md 里明确允许), 测完已恢复,四卡 ready: 4/4。

被纠正的两个误解(用户转述 ChatGPT 的建议)

  1. "该先怀疑 Turbo 4-step 加速而不是 NF4"——方向对,我们自己的 A/B 数据支持:同一套 NF4 权重,只关 Turbo、步数 4→50,帧间差异从 16.99 降到 7.366,音频从 −34dB 升到 −27dB, 量化没变、画质肉眼可判断变好,说明 Turbo/步数是比 NF4 量化更大的画质杠杆。
  2. "该下载原版 BF16/FP16 权重,88GB 显存够跑更高精度"——这条路已经被昨天的实验堵死, 不是没试过:bf16 在 sm_75 上无 attention kernel(RuntimeError: No available kernel), fp16 会让模型输出溢出变黑屏(已用 A/B 实验证伪)。这台硬件上 compute dtype 只能是 fp32, 跟权重存储精度是 NF4 还是 BF16 无关——bitsandbytes NF4 本来就是反量化到 fp32 再算。 "88GB=4×22G 独立显存,H3 不支持模型并行"这条误解也再次被纠正(README.md 第四节早有结论)。 下载 BF16 权重(约 NF4 的 3-4 倍体积)在 5.9MB/s 出口带宽下要 4-5 小时,换来的只是去掉 NF4 约 9% 的量化误差,性价比远不如已经验证过的"关 Turbo、加步数"。

新增 h3-console/(设计文档 + 本地实现,未部署)

需求:给 minimax-h3 的四后端 API 加 Web 管理台(文生/首尾帧/参考图三个界面 + 上传 + 任务列表 + 按需 fl2va/ref2va 弹性分卡)。设计文档 h3-console/DESIGN.md、实现见 h3-console/README.md。 架构上不重做 gateway.py 已经做好的调度/限流/健康聚合,只加它没有的 Web UI/上传/持久化/ 弹性分卡决策。本地端到端冒烟测试通过(上传→提交→轮询→下载全链路 + 浏览器 UI 手测), 弹性 partition 调度器(kill+restart GPU 后端切分区)只能在服务器上验证,还没测。


2026-08-18/19 — MiniMax-H3 文生视频从零到出片

结果:成功。两档都出片并通过 ffprobe + 逐像素 + 音频样本验收。 但环境未恢复——见文末「遗留」。

最重要的一条:minimax-h3/README.md 原第一节是错的

原文写「torch.bfloat16 → torch.float16(致命)」,把改用 fp16 当作必须的修正。 实测相反:改成 fp16 才是这条线一直出不了片的原因。

对照实验(448×256×28f,唯一变量是 torch_dtype):

fp32 fp16
视频像素 mean 140.9 std 50.6 mean 0 std 0
音频 finite NaN
结果 正常出片 ✅ 124 帧纯黑 ❌

根因:模型按 bf16 训练(bf16 有 fp32 的动态范围),sm_75 无 bf16, fp16 的 65504 上限装不下——dit.blocks.0 输入 absmax 89 就放大到 38176,50 层残差累积必爆。

被推翻的假设(六条,都实测过)

原以为 实际
fp16 是 Turing 上的正确选择 fp16 出全黑视频,fp32 才行
--fp32-accum 是降级第一档 开关生效但输出逐字节相同,对此故障完全不对症
NF4 合成 probe 通过 ⇒ 真实模型可用 单层不溢出推不出 50 层残差不溢出
condition_proj 的 inf 是根因 修好后问题依旧——它只是同一个 bug 的一个实例(库里有 100 处 .to(x.dtype))
钳位到 fp16 上限可规避溢出 反而更糟:残差 x + attn(...) 中 x 恰为最大可表示值,再加任何正数立刻 inf
m3e / sd-forge 无 systemd ⇒ 手工拉起、恢复有风险 它们由 PM2(God Daemon PID 1467)托管,kill 会被 60 秒内拉回,必须 pm2 stop

实测数据(fp32,单任务)

分辨率 步数 耗时 每步 峰值显存 像素 std
448×256×28f 4 turbo 62 s 15.5 s 16.3 G 50.6
640×384×61f 4 turbo 171 s 42.8 s 11.4 G 51.7
832×480×124f 4 turbo 556 s 107 s 12.3 G 57.85
832×480×124f 50 全质量 5364 s 107.3 s 9.1 G 59.68

每步 107 s 在 turbo 与 50 步下完全一致,是这台机器 fp32 全分辨率的稳定值。 加载:首次从机械盘读 fp32 权重 238 s;page cache 命中后 12–20 s。 fp16 是 36.8 s/步(快 2.9×)但根本出不了片——不是速度取舍。

产物验收

  • Turbo:7,859,869 字节 · 帧间差异 16.99 · 音频 mean −34.0 dB / rms 0.0199 / 非零 100%
  • 50 步:6,726,194 字节 · 帧间差异 7.366 · 音频 mean −27.2 dB / max −9.2 dB
  • 两者均 832×480 · 24 fps · 124 帧 · 5.17 s · 含 aac 32 kHz 立体声

顺带修掉的

  1. t2va.py 退出码不可信:原脚本只查 audio,从不检查 video,全黑视频照样打印 [done] 并返回 0。 已补 video 有限性 + 纯色检查(std 退出码 3。
  2. --vram-reserve 按 dtype 自适应:fp32=14 / fp16=5。实测 vram_limit 7.3 G 时全分辨率跑通,19.3 G 时 OOM。
  3. PYTORCH_ALLOC_CONF=expandable_segments:True 写进 run.sh——22 G 卡上碎片曾达 2.49 G 并直接压垮运行。
  4. 4 个 probe-*.py 补齐到服务器:README 第九节要求「换硬件后先跑 probe-* 四个」,但服务器上原本只有 3 个脚本。

新增

  • minimax-h3/api.py + api-run.sh:异步任务式 HTTP API(提交拿 id → 轮询 → 下载)。 worker 在写文件前自验像素 std 与音频有限性,不会返回"成功但没用"的产物。
  • minimax-h3/API.md:调用说明、参数范围与理由、错误码、Python 轮询示例。
  • HANDOFF.md:交接文档,CLAUDE.md 已加入口指引。
  • deploy-scripts/RESTORE-GPU.md:停机/恢复 runbook(含 PM2 那条更正)。

方法教训

  • 诊断配置必须与故障配置逐项一致:前几轮用中文 prompt 诊断、英文默认 prompt 复现, 溢出点不同,定位出的路径在真实故障里不触发,白跑几轮。
  • 自检要守在会失败的位置:查 .to(fp16) 之前的 fp32 张量永远显示"正常",溢出恰在转换处。
  • cmd1 && cmd2 & 的 & 作用于整条链:cat > file && ... & 会让文件写入与执行竞争, 曾因此跑了一个 0 字节脚本还以为它成功。上传与启动要分两步。
  • pgrep -f "gunicorn.*7590" 会匹配到自己那条 SSH 命令行,照着 kill 会误杀; 用 nvidia-smi --query-compute-apps=pid 反查。
  • ffmpeg -af volumedetect 输出易被 grep 漏掉,误报"无音频流";用 PyAV 解码算 rms 更可靠。
  • prompt 必须从文件读:三层 shell 会吃掉引号,传进去是空串,报错是 cannot reshape tensor of 0 elements——看着像 transformers 的 bug,实际是输入为空。

⚠️ 遗留(未完成)

  1. 四个服务仍是停的 → 已决策,不是遗留项。2026-08-19 16:40 用户明确「停止 llm38」「只留着 h3 即可」, 所以 llm38 / whisper / m3e / sd-forge 停着即为目标状态;仅 H3 API(8010 + 8011)保留运行。 四者的恢复命令备在 deploy-scripts/RESTORE-GPU.md,需要时再起。
  2. llm38 与 H3 共存的显存约束(将来恢复时才需处理):llm38 跑 256K 需四卡各约 14.3 G, 而 GPU1 现被 8010 实例占着只剩 8.95 G。共存需 --parallel 1 --ctx-size 262144 (单请求仍 256K,并发降为 1,省 16 G),或先停一个 H3 实例。
  3. t2va.py:47 的 max(6.0, ...) 地板值:空闲显存
  4. API 无鉴权,与这台机器上其他服务一致;任务状态存在内存,进程重启即丢(本次已实际发生)。

2026-08-18 — 单请求上下文 32K → 256K(llm38)

结果:成功。中断 11 秒(17:47:14 停 → 17:47:25 就绪,远快于预估的 2 分钟,因为 CUDA JIT 缓存已热)。

用户问的那个问题:不,上限不是 128K

GGUF 元数据里 qwen35.context_length = 262144——模型原生 256K,之前所有文档写的 128K 都是低估。

python3 /home/admin/qwen38-deploy/ggufmeta.py /data/models/Qwen/Qwen3.8-27B-GGUF/Qwen3.8-27B-Q5_K_M.gguf

llm-setup.md 旧表里只列到 --parallel 2 --ctx-size 262144(128K/请求),那不是硬件极限,也不是模型极限,只是当时没查元数据。

改了什么

只动两个参数,其余(模型、镜像、采样参数、健康检查、挂载)逐字未变:

改前 改后
--ctx-size(总池) 131072 524288
--parallel(槽位) 4 2
单请求上限 32K 256K
并发槽位 4 2

不需要 YaRN:524288 是总池,被 2 槽均分后每槽 262144 = 正好等于 n_ctx_train,没有外插。llm-setup.md 第 65 行「524288 (YaRN 2x)」把总池和单请求上限混为一谈了,已订正。

实测数据

指标 改前(32K×4) 改后(256K×2)
n_ctx_slot 32768 262144
显存(llm38 自身) 29.4 GB 57.3 GB
四卡合计占用(含 whisper) 32.7 GB 61.4 GB / 88 GB
生成速度(短上下文) 23.4 tok/s 23.4 tok/s(无变化)
生成速度(150K 上下文) — 16.7 tok/s
提示处理(11K 提示) 1491 tok/s 1491 tok/s
提示处理(150K 提示) — 979 tok/s

四卡分布:GPU0 15264 / GPU1 14310 / GPU2 16620 / GPU3 15254 MiB。GPU2 只剩 5.4 GB 最紧,起新负载时避开它。

验收:大海捞针(150K token)

把一条真信息埋在 4000 条填充记录的第 1800 条,提示总长 149,880 token——这个长度连改前的 131072 总池都放不下,是硬证明而非软验证。

  • truncated = 0(关键:没有静默截断)
  • 精确取出「QX-7741-KM / 周敏」,thinking 里明确引用了「条目1800」
  • 中文连贯无乱码
  • 3 路并发全部正常返回,healthcheck healthy / FailingStreak=0

代价,明说

  1. 并发从 4 降到 2。 第 3 个请求不会被拒,会排队。公网 qwen.wudaima.com 若真有多人同时用,排队会变明显。
  2. 长提示很贵。 256K 提示光 prefill 就约 4.5 分钟(按 979 tok/s 算),且期间独占一个槽位。150K 那次端到端 2m43s。
  3. 显存余量从 55 GB 掉到 26 GB。 minimax-h3 是按「空闲显存 − 2 GB」自适应的,这条线的可用显存被同步压缩了,两边共用一台机器时要注意。

回滚

旧容器改名保留没删:

docker rm -f llm38 && docker rename llm38_prev llm38 && docker start llm38   # 约 15 秒

切换脚本 /home/admin/qwen38-deploy/ctx256k.sh 自带失败自动回滚(容器起不来或 8 分钟不健康就自动退回)。确认稳定后再 docker rm llm38_prev——/ 分区只剩 29 GB。

想要 256K × 4 并发?可以,但没做

-ctk q8_0 -ctv q8_0 把 KV 量化到 8 bit,KV 从 64 GiB 降到 32 GiB,256K×4 就放得下。没做的原因:用户要的是最大上下文不是最大并发,而量化 KV 会引入质量变化,不值得在一次变更里叠两个变量。要做的话单独做,单独测。

2026-08-18 — MiniMax-H3 文生视频部署(进行中)

结果:环境就绪并通过全部兼容性实测;模型下载中(33 GB / 6 MB/s),尚未完成首次出片。 详见 minimax-h3/README.md。

服务器侧:/data/models/minimax-h3/。系统级零改动——没装 CUDA、没动驱动、 没碰 /usr、没改 ~/.local。venv 用 --system-site-packages 复用已有的 torch 2.9.0+cu128(其 arch_list 含 sm_75),只额外装了 bitsandbytes 0.50.1 和 diffsynth。

被推翻的假设

原以为 实际
照抄官方 NF4 示例即可 ❌ 三处必改:全篇 torch.bfloat16 → float16;vram_limit 取的是 mem_get_info()[1](总显存),四张卡都有常驻服务,必须改 [0](空闲);low-vram 示例的 offload_device="disk" 在 7200rpm 机械盘上是 87 秒/步的 I/O 灾难,改 CPU 内存 offload。
bf16 只是慢一点 ❌ sm_75 上 bf16 没有任何高效 attention kernel(EFFICIENT 和 FLASH 都报 No available kernel),PyTorch 不报错、静默掉到 MATH 后端物化整个注意力矩阵。14k token 序列必 OOM,且症状伪装成显存不足而非 dtype 问题。光测 bf16 matmul 会得出错误结论——它是能跑的(cuBLAS 升位模拟)。
4×22 GB 可以合起来跑一个大任务 ❌ DiffSynth 的序列并行(utils/xfuser/)只被 wan_video.py 和 mova_audio_video.py 引用,H3 pipeline 零引用。叠加四卡无 P2P + 跨 NUMA,只能做独立 worker 并发,且并发数由内存(每 worker 约 32 GB / 共 128 GB → 2–3 个)而非显存卡死。
Turing 上只能走 llama.cpp ❌ 那是 LLM 的结论。PyTorch + bitsandbytes NF4 在 sm_75 上实测可用(bnb 4-bit 的最低要求正好是 sm_75)。
nohup 足够让下载脱离 SSH ❌ 不够,SSH 通道关闭仍会带走进程,必须 setsid(手册第三节其实写了,没照做)。另外开两个下载进程会互相破坏同一个 ._____temp 分片——出口带宽是瓶颈,并行毫无收益。

实测数据(RTX 2080 Ti / sm_75 / torch 2.9.0+cu128)

SDPA bfloat16 EFFICIENT → No available kernel      SDPA float16 EFFICIENT → OK
SDPA bfloat16 FLASH     → No available kernel      SDPA float16 FLASH     → 仅 sm80–sm121
SDPA bfloat16 MATH      → OK(朴素实现,会 OOM)

真实 H3 形状 S=14100 H=56 D=128 的 fp16 SDPA EFFICIENT → 0.21s
fp16 GEMM 有效算力 → 54.3 TFLOPS(张量核满血)
fp16 深链数值稳定性 → finite
NF4 fp16 前向 → finite,rel_err 0.0913;prequantized 重载 → match=True

DiT 是稠密 30.9B(50 层 / hidden 5376 / 56×128 heads / ffn 14336,无 MoE), 480×832×124 帧约 14,100 token、830 TFLOP/步。故 50 步估 20–35 分钟, Turbo 4-step LoRA 估 2–4 分钟——LoRA 可叠在 NF4 上(AutoWrappedQuantizedModule 继承 LoRAHotLoadMixin,在量化输出之后做加法,不需反量化基座)。

环境里两个坑

  • ~/.local 的 bitsandbytes 0.45.1 是坏的('NoneType' object has no attribute 'cquantize_blockwise_fp16_nf4'),venv 内装 0.50.1 覆盖。
  • 不要设 PYTHONNOUSERSITE=1:这台机器的 torch / transformers / av 都在 ~/.local,屏蔽掉会连 torch 一起丢。venv 的 site-packages 本就排在它前面。

容器变更(用户授权,可逆)

停了 llm(旧 llama.cpp,unhealthy 5 天,已被 llm38 取代)——恢复 docker start llm。 whisper 停后已恢复(只用单卡,停它零收益)。llm38 全程未碰。 启动参数备份在 /data/models/minimax-h3/backup/。

2026-08-18 — Qwen3.6-27B → Qwen3.8-27B

结果:成功。中断 1 分 51 秒(17:03:14 停 → 17:05:05 就绪)。

起因

用户想部署 Qwen/Qwen3.8-27B-FP8。结论是 FP8 版本装不了,改用 GGUF。

为什么 FP8 不行(三道坎,第一道是硬的)

  1. 2080 Ti 是 Turing,compute capability 7.5。FP8 原生需 Ada(8.9)/Hopper(9.0);vLLM 的 fp8_marlin 回退内核要求 Ampere(8.0+)。7.5 连回退路径都够不着——不是配置能绕的,是芯片里没有那个电路。
  2. Turing 不支持 bf16,而 FP8 权重的 scale 用 bf16 存。(机器上现有 vLLM compose 全带 --dtype=half 就是这堵墙留下的痕迹。)
  3. 即便前两条不存在,机器上最新的 vLLM 镜像是 v0.10.2(11 个月前),不认识 Qwen3.8 的 Gated DeltaNet + Gated Attention 混合架构。

最终方案

bartowski/Qwen3.8-27B-GGUF 的 Q5_K_M(19.32 GiB / 20,752,787,040 字节),llama.cpp b10481 镜像,容器名 llm38,端口 8000。

选 Q5_K_M 而非平移 IQ4_XS:四卡 88 GB 只用了 29 GB,余量充足,质量提升是白拿的。

过程中被推翻的三个假设

对抗性验证阶段推翻了三条原本打算照做的事,都值得记住:

原计划 为什么错
新旧模型并存在 8000/8001 上 A/B 对比 ❌ GPU3 只剩 10.7 GB(whisper 常驻 3.2 GB),而 llama.cpp 默认 tensor-split 按总显存而非剩余显存均分,不加 -ts 必 OOM。更要命的是现有服务的 context checkpoint 是惰性分配的(149.626 MiB × 4 slot × 8,上限约 4.7 GB),新容器吃光余量后公网服务会在某次多轮长对话时突然 OOM 挂掉。改为串行切换。
沿用 Qwen3.6 的采样参数 ❌ Qwen3.8 thinking 模式官方推荐 temp 1.0 / top-k 20 / min-p 0.0;照抄 3.6 的 0.7/40/0.01 会让 thinking 质量下降。且 llama.cpp 默认是 top_k=40 / min_p=0.05,不显式写就是错的。
靠 MTP 推测解码提速 ❌ --spec-type draft-mtp 在 sm_75 上对 hybrid 模型根本不激活(llama.cpp issue #24670)。网上「+33~39%」的说法在这台机器上吃不到。而且 MTP 头是独立 sidecar 文件,bartowski 仓库根本没有。

差点踩中的静默陷阱

llama.cpp build 必须 ≥ b10450(DeltaNet CUDA 修复 commit ece963f4)。b10419~b10450 的症状是加载正常、显存正常、tok/s 正常、输出全是乱码——机器上原有的 b9570 正好在雷区里。

上线前验证:docker run --rm --entrypoint /app/llama-server <镜像> --version,实得 build 10481, commit 25ae3a9b3。

实测数据

指标 Qwen3.6-27B IQ4_XS Qwen3.8-27B Q5_K_M
权重 15.77 GB 19.32 GB
生成速度 28 tok/s 23.4 tok/s
显存(四卡合计) 29.3 GB 29.4 GB
上下文 131072 131072

权重大了 3.5 GB 显存却几乎没变:Qwen3.8 的 64 层里只有 16 层是标准注意力(full_attention_interval=4),48 层是 Gated DeltaNet,KV cache 只有同规格纯注意力模型的约 27%。

四卡分布:GPU0 7448 / GPU1 7336 / GPU2 8110 / GPU3 6532 MiB。

验证结果

  • SHA256 e731e180460b906f373294a4e2de10541e80ee676af7f8c949a84dbb6ed3caa8 与官方逐字节一致
  • 中文输出语义连贯,无乱码(本次最大风险,已排除)
  • healthcheck healthy / FailingStreak=0
  • 5 轮连续请求显存恒定 29426 MiB,无 CUDA graphs 泄漏
  • 下游硬编码旧模型名 Qwen_Qwen3.6-27B-IQ4_XS.gguf 仍返回 200 + 正常内容(服务端不校验 model 字段)

顺带修掉的

镜像自带的 healthcheck 写死 curl localhost:8080,而服务在 8000,导致旧容器从启动起连续失败 16321 次、常年显示 unhealthy。新容器用 --health-cmd 覆盖指向 8000。这个假红灯之前会掩盖真故障。

遗留项

  • 单请求上下文实际是 32K 不是 128K:--ctx-size 131072 是总池,被 --parallel 4 均分。要单请求 128K 需改成 --parallel 2 --ctx-size 262144(约 42 GB,显存够)。待用户决定。
  • 多模态 mmproj-Qwen3.8-27B-f16.gguf(885 MB)已下载在同目录,但未挂载,首轮保持纯文本。需要图像输入时加 --mmproj 重启。
  • CUDA graphs 被 GGML_CUDA_DISABLE_GRAPHS=1 关掉以规避 Turing 显存泄漏(issue #25835),上游修复后可 A/B 试着开。

同期发现的其他问题(未处理)

  • / 分区仅剩 29 GB(87%),docker 镜像和日志都写这里。
  • nginx 的 4016/4018 两个前端后端 4017/4019/4020 全部无监听,聊天应用是坏的。
  • FRP 的 gateway.wudaima.com → 8080 和 comfyui.wudaima.com → 8188 两条隧道空挂,本地无服务。
  • 公网 qwen.wudaima.com 无鉴权;SSH 22 经 FRP 映射到公网 6000 且允许密码登录。