feat: 批量分块流水线、本地模型显存让渡与任务列表分工
批量引擎改为「分块流水线」:视频按 WOV_BATCH_STAGE_GROUP_SIZE(默认 8)分组, 组内按 DAG 拓扑序跑完全部视频(全部 extract → 全部 ASR → 全部翻译 → 全部 ASS) 再进入下一组,本地模型每组只加载一次、卸载一次,而不是每个视频来回加载卸载; 产物仍按组增量落到视频旁。调度器新增 execute_run(run_id, stop_after=节点): 该节点完成后任务保持 RUNNING 不收尾,下一次调用从产物表跳过已完成节点继续, 用于实现阶段边界。 - nodes/llm.py:翻译节点结束释放本机 Ollama 显存(node 参数 unload_after > LLM_UNLOAD_AFTER > 本机 loopback 端点默认卸载,云端端点不卸载;卸载失败只告警), 新增 keep_model.flag 语义(阶段内保持常驻)与 release_local_model(); 新增节点内暂停(按批 20 行检查 paused.flag,抛 PauseRequested,调度器保持 PAUSED)。 - src/wov_app/batch.py:分组阶段执行与阶段末统一释放显存;失败视频只在它失败 节点的那个阶段重试(避免 LLM 已常驻时重跑 ASR 抢显存);任务没有明细时保持 QUEUED 等登记完成、仍有未完成视频时置回 QUEUED 自愈(原先留 RUNNING 会卡死: 引擎只拾取 QUEUED,任务停在“运行中但没人推进”);无失败视频时删除任务级空目录; 每个阶段开始前清理 paused.flag / keep_model.flag,避免强杀残留影响后续阶段。 - src/wov_app/config.py:新增 WOV_BATCH_STAGE_GROUP_SIZE(设为 1 即旧的每视频全链路)。 - 任务列表与批量页分工:GET /api/runs 默认排除 source=batch(一个批量任务会产生 N 条单视频 run,会把 20 条窗口占满;且任务管理页的暂停/重试/删除对批量 run 语义不成立),需要排查时用 include_batch=1;作为补偿批量页详情新增阶段列 (阶段 i/N · 中文标签,由该视频 run 的 current_node_id 在 DAG 拓扑序中的位置 推导,节点类型映射中文标签)。阶段只有节点边界粒度,句级进度不落库、只在日志。 - 顺带纳入此前未提交的批量僵尸状态恢复:recover_interrupted_batch_jobs 除 RUNNING 外也把「COMPLETED 但仍含未结束视频」的任务置回 QUEUED;fix_zombie_batch_jobs.py 改为按条件扫描并支持 --apply 预览;批量页明细只列本批真正处理过的视频。 测试新增/更新:分块流水线调用顺序(组内按节点跑完再下一组)、每组只释放一次模型、 阶段内保持常驻标志、翻译按批暂停、失败视频不跨阶段推进、任务无明细/中途登记视频时 置回 QUEUED、任务工作空间与残留信号清理、任务列表默认过滤批量 run、详情阶段字段、 前端阶段列渲染;全量 507 passed(唯一失败为既有素材缺失的 integration 用例)。
This commit is contained in:
+141
-25
@@ -6,6 +6,9 @@
|
||||
本地按 cue 回填,避免模型重排断句时译文贴错时间轴。
|
||||
- **提示词**:要求逐行独立翻译、碎片句按语境独立成行、禁止合并或拆分。
|
||||
- **严格错误处理**:结构重试耗尽立即失败,不用补空或合并掩盖对应关系丢失。
|
||||
- **显存让渡**:LLM 节点结束前卸载本机 Ollama 模型(`unload_after` / `LLM_UNLOAD_AFTER`
|
||||
可显式控制;本机端点默认卸载),避免常驻显存与后续 whisper 转写争抢——
|
||||
Ollama 默认常驻数分钟,下一个视频的 ASR 会直接 CUDA OOM。
|
||||
- 拼接 system_prompt 时用 `+` 显式连成单个字符串:括号内的隐式字符串拼接
|
||||
遇到 f-string 表达式会失效,生成 tuple 后序列化成数组,API 会返回 400。
|
||||
"""
|
||||
@@ -16,8 +19,10 @@ import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from wov_app.logging import get_logger
|
||||
|
||||
@@ -31,9 +36,33 @@ CHUNK_SIZE = 20
|
||||
# 批次翻译最大尝试次数(ID/正文结构校验失败时重发本批,不用占位恢复)。
|
||||
MAX_BATCH_RETRIES = 3
|
||||
|
||||
# 卸载本地模型是收尾动作,超时上限固定 30s:翻译超时(LLM_TIMEOUT_SECONDS,
|
||||
# 默认 600)不适合它,否则卡住的端点会把节点拖住十分钟。
|
||||
UNLOAD_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
# 节点运行日志:翻译分批进度与处理速度输出到主进程控制台。
|
||||
logger = get_logger("llm-translate")
|
||||
|
||||
# 暂停信号文件名:位于 run 根目录(<storage>/runs/<run_id>/paused.flag),与
|
||||
# whisper/subtitle-ocr 约定一致;翻译按批检查,暂停粒度不超过一批(20 行)。
|
||||
PAUSE_FLAG = "paused.flag"
|
||||
|
||||
# 保持模型常驻信号文件名:批量分块流水线期间引擎写入 run 根目录,翻译节点据此
|
||||
# 不在每次调用后卸载模型(一组视频共用一个已加载模型),阶段结束由引擎统一释放。
|
||||
KEEP_MODEL_FLAG = "keep_model.flag"
|
||||
|
||||
# 默认 LLM 端点与模型(与 docs/configuration.md 的环境变量默认值一致)。
|
||||
DEFAULT_API_BASE = "https://api.siliconflow.cn/v1/chat/completions"
|
||||
DEFAULT_MODEL = "Qwen/Qwen3.5-35B-A3B"
|
||||
|
||||
|
||||
class PauseRequested(Exception):
|
||||
"""节点内暂停信号:翻译检测到任务被暂停后抛出,由调度器保持 PAUSED。
|
||||
|
||||
不把暂停误报为 FAILED:调度器捕获异常时若任务已是 PAUSED 则保持暂停,
|
||||
等用户 resume 后整节点重跑(已完成批次不落盘,不留半成品)。
|
||||
"""
|
||||
|
||||
|
||||
def _system_prompt(target_language: str) -> str:
|
||||
"""构造翻译系统提示词(返回单个字符串,不用隐式拼接避免 tuple bug)。
|
||||
@@ -131,7 +160,71 @@ def _parse_translations(content: str, expected: set[int]) -> dict[int, str]:
|
||||
return result
|
||||
|
||||
|
||||
def translate_lines(lines: list[str], params: dict) -> list[str]:
|
||||
def _is_local_endpoint(api_base: str) -> bool:
|
||||
"""判断 LLM 端点是否在本机(loopback),决定是否需要默认卸载显存。"""
|
||||
return (urllib.parse.urlsplit(api_base).hostname or "").lower() in (
|
||||
"localhost", "127.0.0.1", "::1",
|
||||
)
|
||||
|
||||
|
||||
def _should_unload(params: dict, api_base: str, keep_model_loaded: bool = False) -> bool:
|
||||
"""判断节点结束时是否卸载模型:参数 > 环境变量 LLM_UNLOAD_AFTER > 本机端点默认卸载。
|
||||
|
||||
本机(loopback)跑模型时显存是本机共用的,翻译结束后默认让出,后面还要跑
|
||||
whisper 的 ASR;云端/别的机器上的端点不占本机显存,默认不发多余请求。
|
||||
keep_model_loaded(引擎写入的保持常驻信号)为真时一律不卸载:分块流水线里
|
||||
同一组视频共用一个已加载模型,阶段结束由引擎调用 release_local_model 释放。
|
||||
"""
|
||||
if keep_model_loaded:
|
||||
return False
|
||||
flag = params.get("unload_after", os.getenv("LLM_UNLOAD_AFTER"))
|
||||
if flag is None or str(flag).strip() == "":
|
||||
return _is_local_endpoint(api_base)
|
||||
return str(flag).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def release_local_model(model: str | None = None) -> None:
|
||||
"""按当前配置卸载本机 LLM 模型释放显存(批量分阶段执行时由引擎在阶段末调用)。
|
||||
|
||||
只对本机端点生效:云端/远端端点不占本机显存,不发无意义请求。
|
||||
"""
|
||||
api_base = os.getenv("LLM_API_BASE", DEFAULT_API_BASE)
|
||||
if not _is_local_endpoint(api_base):
|
||||
return
|
||||
_unload_local_model(api_base, str(model or os.getenv("LLM_MODEL", DEFAULT_MODEL)))
|
||||
|
||||
|
||||
def _unload_local_model(api_base: str, model: str) -> None:
|
||||
"""请求 Ollama 卸载模型释放显存(keep_alive=0);失败只记录,不影响翻译。
|
||||
|
||||
批量链路里 translate 是最后一个占显存的节点,之后下一个视频要跑 whisper;
|
||||
Ollama 默认让模型常驻数分钟,与 ASR 抢显存会直接 CUDA OOM,所以节点结束
|
||||
时显式释放。云端端点没有该路径,请求失败视为不支持卸载即可。
|
||||
"""
|
||||
parts = urllib.parse.urlsplit(api_base)
|
||||
origin = f"{parts.scheme}://{parts.netloc}"
|
||||
request = urllib.request.Request(
|
||||
origin + "/api/generate",
|
||||
data=json.dumps({"model": model, "keep_alive": 0}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=UNLOAD_TIMEOUT_SECONDS):
|
||||
pass
|
||||
except (OSError, ValueError) as exc:
|
||||
# HTTPError/URLError 都是 OSError 子类;不支持卸载的端点走到这里。
|
||||
logger.warning("本地模型卸载失败(%s): %s", origin, exc)
|
||||
else:
|
||||
logger.info("已卸载本地模型 %s,显存让给后续节点", model)
|
||||
|
||||
|
||||
def translate_lines(
|
||||
lines: list[str],
|
||||
params: dict,
|
||||
stop_requested: Callable[[], bool] | None = None,
|
||||
keep_model_loaded: bool = False,
|
||||
) -> list[str]:
|
||||
"""分批调用 LLM 翻译纯文本行,返回顺序一致的译文列表。
|
||||
|
||||
列表的每项是一条 cue 正文(可多行);每批按全局 ID 对齐,空 cue 原样
|
||||
@@ -141,16 +234,20 @@ def translate_lines(lines: list[str], params: dict) -> list[str]:
|
||||
日志:每完成一批打印总进度(已完成行数/总行数、第几批/共几批、累计
|
||||
耗时与行处理速度),结束打印汇总(总耗时、累计 tokens 与 tok/s),
|
||||
便于评估 LLM 处理速度。
|
||||
|
||||
stop_requested 返回 True 时抛 PauseRequested 中止(调度器保持 PAUSED);
|
||||
keep_model_loaded 为真时不卸载模型(批量分阶段执行,阶段结束由引擎释放)。
|
||||
"""
|
||||
api_base = os.getenv(
|
||||
"LLM_API_BASE",
|
||||
"https://api.siliconflow.cn/v1/chat/completions",
|
||||
DEFAULT_API_BASE,
|
||||
)
|
||||
api_key = os.getenv("LLM_API_KEY", "")
|
||||
request_timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "600"))
|
||||
model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.5-35B-A3B"))
|
||||
model = str(params.get("model") or os.getenv("LLM_MODEL", DEFAULT_MODEL))
|
||||
target_language = str(params.get("target_language", "zh-CN"))
|
||||
system_prompt = _system_prompt(target_language)
|
||||
unload_after = _should_unload(params, api_base, keep_model_loaded)
|
||||
|
||||
total_lines = len(lines)
|
||||
total_batches = (total_lines + CHUNK_SIZE - 1) // CHUNK_SIZE if total_lines else 0
|
||||
@@ -162,27 +259,36 @@ def translate_lines(lines: list[str], params: dict) -> list[str]:
|
||||
translated: list[str] = []
|
||||
total_tokens = 0
|
||||
all_started = time.monotonic()
|
||||
for batch_index in range(1, total_batches + 1):
|
||||
start = (batch_index - 1) * CHUNK_SIZE
|
||||
chunk = lines[start : start + CHUNK_SIZE]
|
||||
# 每批日志前缀(第几批/共几批),供单次 LLM 请求日志与批进度复用。
|
||||
log_prefix = f"第 {batch_index}/{total_batches} 批"
|
||||
batch_started = time.monotonic()
|
||||
batch_translated, batch_tokens = _translate_batch(
|
||||
chunk, api_base, api_key, model, system_prompt, request_timeout, log_prefix,
|
||||
start_id=start + 1,
|
||||
)
|
||||
translated.extend(batch_translated)
|
||||
total_tokens += batch_tokens
|
||||
# 批进度日志:已完成行数/总行数、当前批耗时、累计耗时与行处理速度。
|
||||
done = len(translated)
|
||||
elapsed_total = time.monotonic() - all_started
|
||||
logger.info(
|
||||
"翻译进度 %d/%d 行 (%s完成, 批耗时 %.1fs, 累计 %.1fs, %.1f 行/s)",
|
||||
done, total_lines, log_prefix,
|
||||
time.monotonic() - batch_started, elapsed_total,
|
||||
done / elapsed_total if elapsed_total > 0 else 0.0,
|
||||
)
|
||||
try:
|
||||
for batch_index in range(1, total_batches + 1):
|
||||
# 暂停检查:调度器置 PAUSED 并写 paused.flag 后,翻译在批边界立刻停下,
|
||||
# 已完成的批保留在内存中但不落盘,恢复时整节点重跑,不留半成品。
|
||||
if stop_requested is not None and stop_requested():
|
||||
raise PauseRequested("翻译被暂停")
|
||||
start = (batch_index - 1) * CHUNK_SIZE
|
||||
chunk = lines[start : start + CHUNK_SIZE]
|
||||
# 每批日志前缀(第几批/共几批),供单次 LLM 请求日志与批进度复用。
|
||||
log_prefix = f"第 {batch_index}/{total_batches} 批"
|
||||
batch_started = time.monotonic()
|
||||
batch_translated, batch_tokens = _translate_batch(
|
||||
chunk, api_base, api_key, model, system_prompt, request_timeout, log_prefix,
|
||||
start_id=start + 1,
|
||||
)
|
||||
translated.extend(batch_translated)
|
||||
total_tokens += batch_tokens
|
||||
# 批进度日志:已完成行数/总行数、当前批耗时、累计耗时与行处理速度。
|
||||
done = len(translated)
|
||||
elapsed_total = time.monotonic() - all_started
|
||||
logger.info(
|
||||
"翻译进度 %d/%d 行 (%s完成, 批耗时 %.1fs, 累计 %.1fs, %.1f 行/s)",
|
||||
done, total_lines, log_prefix,
|
||||
time.monotonic() - batch_started, elapsed_total,
|
||||
done / elapsed_total if elapsed_total > 0 else 0.0,
|
||||
)
|
||||
finally:
|
||||
# 成功与失败都在此释放显存:下一个视频的 ASR 需要独占 GPU。
|
||||
if unload_after:
|
||||
_unload_local_model(api_base, model)
|
||||
# 任务汇总日志:总耗时、累计 tokens 与 token/行处理速度。
|
||||
wall = time.monotonic() - all_started
|
||||
tok_rate = total_tokens / wall if wall > 0 and total_tokens > 0 else 0.0
|
||||
@@ -257,9 +363,19 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
|
||||
try:
|
||||
entries = parse_srt(srt_path.read_text(encoding="utf-8"))
|
||||
translated_lines = translate_lines([entry.text for entry in entries], request.params)
|
||||
# run 根目录 = <storage>/runs/<run_id>/;引擎在分块流水线里会写
|
||||
# keep_model.flag(阶段内保持模型常驻),暂停接口写 paused.flag。
|
||||
run_root = Path(request.output_dir).parent.parent
|
||||
translated_lines = translate_lines(
|
||||
[entry.text for entry in entries],
|
||||
request.params,
|
||||
stop_requested=(run_root / PAUSE_FLAG).exists,
|
||||
keep_model_loaded=(run_root / KEEP_MODEL_FLAG).exists(),
|
||||
)
|
||||
if len(translated_lines) != len(entries):
|
||||
raise ValueError("translation count does not match subtitle cues")
|
||||
except PauseRequested as exc:
|
||||
return InvokeResponse(status="failed", error=f"{exc}(run {request.run_id})")
|
||||
except (ValueError, TypeError, OSError) as exc:
|
||||
return InvokeResponse(status="failed", error=str(exc))
|
||||
# 时间轴始终来自原始 cue,译文通过已校验的 ID 顺序回填。
|
||||
|
||||
Reference in New Issue
Block a user