feat(repair): asyncio.Semaphore 并发 + 断点续跑 + CLI 参数
--concurrency 默认 16,--reaggregate-all 强制全量重聚合。 Semaphore 限视频并发数,视频内四步串行。progress 文件 asyncio.Lock + os.replace 原子写入。熔断阈值 max(.env, concurrency*2)。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -41,7 +41,7 @@ REDIS_URL=redis://localhost:6379/0
|
|||||||
LLM_TIMEOUT=120
|
LLM_TIMEOUT=120
|
||||||
LLM_MAX_RETRIES=3
|
LLM_MAX_RETRIES=3
|
||||||
LLM_RETRY_BASE_DELAY=2.0
|
LLM_RETRY_BASE_DELAY=2.0
|
||||||
LLM_CIRCUIT_BREAKER_THRESHOLD=5
|
LLM_CIRCUIT_BREAKER_THRESHOLD=5 # 实际阈值 = max(此值, concurrency*2)
|
||||||
LLM_CIRCUIT_BREAKER_COOLDOWN=60
|
LLM_CIRCUIT_BREAKER_COOLDOWN=60
|
||||||
LLM_TTFT_TIMEOUT=30
|
LLM_TTFT_TIMEOUT=30
|
||||||
LLM_INTER_TOKEN_TIMEOUT=15
|
LLM_INTER_TOKEN_TIMEOUT=15
|
||||||
|
|||||||
+75
-26
@@ -125,7 +125,7 @@ def should_skip_video(vid: str, finished: set[str], *, reaggregate_all: bool) ->
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_clients():
|
def _build_clients(concurrency: int = 16):
|
||||||
"""构建 GovernedLLMClient(LLM + VLM)。
|
"""构建 GovernedLLMClient(LLM + VLM)。
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
@@ -141,6 +141,7 @@ def _build_clients():
|
|||||||
telemetry = SQLiteTelemetryRecorder(str(PROJECT_ROOT / "logs" / "repair_telemetry.db"))
|
telemetry = SQLiteTelemetryRecorder(str(PROJECT_ROOT / "logs" / "repair_telemetry.db"))
|
||||||
|
|
||||||
breaker_threshold = int(os.getenv("LLM_CIRCUIT_BREAKER_THRESHOLD", "5"))
|
breaker_threshold = int(os.getenv("LLM_CIRCUIT_BREAKER_THRESHOLD", "5"))
|
||||||
|
breaker_threshold = max(breaker_threshold, concurrency * 2)
|
||||||
breaker_cooldown = int(os.getenv("LLM_CIRCUIT_BREAKER_COOLDOWN", "60"))
|
breaker_cooldown = int(os.getenv("LLM_CIRCUIT_BREAKER_COOLDOWN", "60"))
|
||||||
timeout_s = float(os.getenv("LLM_TIMEOUT", "120"))
|
timeout_s = float(os.getenv("LLM_TIMEOUT", "120"))
|
||||||
max_retries = int(os.getenv("LLM_MAX_RETRIES", "3"))
|
max_retries = int(os.getenv("LLM_MAX_RETRIES", "3"))
|
||||||
@@ -323,52 +324,87 @@ async def _repair_one_video(
|
|||||||
|
|
||||||
|
|
||||||
async def main_async(args: argparse.Namespace) -> None:
|
async def main_async(args: argparse.Namespace) -> None:
|
||||||
"""异步主流程:遍历所有视频,逐个修复。"""
|
"""异步主流程:并发修复视频。"""
|
||||||
videos_dir = Path(args.videos_dir)
|
videos_dir = Path(args.videos_dir)
|
||||||
srt_dir = Path(args.srt_dir)
|
srt_dir = Path(args.srt_dir)
|
||||||
questions_dir = Path(args.questions_dir)
|
questions_dir = Path(args.questions_dir)
|
||||||
|
concurrency = args.concurrency
|
||||||
|
reaggregate_all = args.reaggregate_all
|
||||||
|
|
||||||
# 扫描所有视频
|
# 扫描所有视频
|
||||||
vid_dirs = sorted(
|
vid_dirs = sorted(
|
||||||
d for d in videos_dir.iterdir()
|
d for d in videos_dir.iterdir()
|
||||||
if d.is_dir() and (d / "tree.json").exists()
|
if d.is_dir() and (d / "tree.json").exists()
|
||||||
)
|
)
|
||||||
logger.info("发现 {} 个视频待修复", len(vid_dirs))
|
logger.info("发现 {} 个视频", len(vid_dirs))
|
||||||
|
|
||||||
|
# 加载 progress
|
||||||
|
progress_path = PROJECT_ROOT / "logs" / PROGRESS_FILE
|
||||||
|
finished = load_progress(progress_path)
|
||||||
|
if finished:
|
||||||
|
logger.info("已完成 {} 个视频(从 progress 文件加载)", len(finished))
|
||||||
|
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
logger.info("=== DRY RUN 模式:仅检测不修复 ===")
|
logger.info("=== DRY RUN 模式:仅检测不修复 ===")
|
||||||
|
|
||||||
# 构建客户端(dry_run 模式不需要)
|
# 构建客户端(dry_run 模式不需要)
|
||||||
llm, vlm = (None, None) if args.dry_run else _build_clients()
|
llm, vlm = (None, None) if args.dry_run else _build_clients(concurrency)
|
||||||
|
|
||||||
# 逐视频修复
|
# 过滤跳过的视频
|
||||||
all_stats = []
|
pending = []
|
||||||
start_time = time.time()
|
skipped_count = 0
|
||||||
for idx, vid_dir in enumerate(vid_dirs):
|
for vid_dir in vid_dirs:
|
||||||
vid = vid_dir.name
|
vid = vid_dir.name
|
||||||
tree_path = vid_dir / "tree.json"
|
if should_skip_video(vid, finished, reaggregate_all=reaggregate_all):
|
||||||
frames_dir = vid_dir # frame_path 已含 "frames/" 前缀,不再嵌套
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
pending.append(vid_dir)
|
||||||
|
|
||||||
logger.info(
|
if skipped_count:
|
||||||
"[{}/{}] 开始修复 {}",
|
logger.info("跳过 {} 个已完成视频,待处理 {} 个", skipped_count, len(pending))
|
||||||
idx + 1, len(vid_dirs), vid,
|
|
||||||
)
|
|
||||||
|
|
||||||
stats = await _repair_one_video(
|
# 并发编排
|
||||||
vid, tree_path, frames_dir, srt_dir, questions_dir,
|
sem = asyncio.Semaphore(concurrency)
|
||||||
llm, vlm, dry_run=args.dry_run,
|
progress_lock = asyncio.Lock()
|
||||||
)
|
all_stats: list[dict] = []
|
||||||
all_stats.append(stats)
|
stats_lock = asyncio.Lock()
|
||||||
|
start_time = time.time()
|
||||||
|
completed = 0
|
||||||
|
|
||||||
# 每 10 个视频汇总一次
|
async def _process(vid_dir: Path) -> None:
|
||||||
if (idx + 1) % 10 == 0:
|
nonlocal completed
|
||||||
elapsed = time.time() - start_time
|
async with sem:
|
||||||
rate = (idx + 1) / elapsed * 60
|
vid = vid_dir.name
|
||||||
logger.info(
|
tree_path = vid_dir / "tree.json"
|
||||||
"进度: {}/{}, 已用 {:.0f}s, 速率 {:.1f} 视频/分钟",
|
frames_dir = vid_dir
|
||||||
idx + 1, len(vid_dirs), elapsed, rate,
|
|
||||||
|
logger.info("开始修复 {}", vid)
|
||||||
|
|
||||||
|
stats = await _repair_one_video(
|
||||||
|
vid, tree_path, frames_dir, srt_dir, questions_dir,
|
||||||
|
llm, vlm, dry_run=args.dry_run,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async with stats_lock:
|
||||||
|
all_stats.append(stats)
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
# 无 error 且非 dry_run 才记 finished
|
||||||
|
if stats["error"] is None and not args.dry_run:
|
||||||
|
await save_progress(progress_path, progress_lock, vid)
|
||||||
|
|
||||||
|
# 进度日志
|
||||||
|
if completed % 10 == 0:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
rate = completed / elapsed * 60 if elapsed > 0 else 0
|
||||||
|
logger.info(
|
||||||
|
"进度: {}/{}, 已用 {:.0f}s, 速率 {:.1f} 视频/分钟",
|
||||||
|
completed, len(pending), elapsed, rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks = [asyncio.create_task(_process(vd)) for vd in pending]
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# 最终汇总
|
# 最终汇总
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
total_issues = sum(s["issues_found"] for s in all_stats)
|
total_issues = sum(s["issues_found"] for s in all_stats)
|
||||||
@@ -379,11 +415,13 @@ async def main_async(args: argparse.Namespace) -> None:
|
|||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("修复完成")
|
logger.info("修复完成")
|
||||||
logger.info(" 视频总数: {}", len(all_stats))
|
logger.info(" 视频总数: {}", len(all_stats))
|
||||||
|
logger.info(" 跳过数: {}", skipped_count)
|
||||||
logger.info(" 问题总数: {}", total_issues)
|
logger.info(" 问题总数: {}", total_issues)
|
||||||
logger.info(" L3 修复数: {}", total_repaired)
|
logger.info(" L3 修复数: {}", total_repaired)
|
||||||
logger.info(" 事实注入数: {}", total_injected)
|
logger.info(" 事实注入数: {}", total_injected)
|
||||||
logger.info(" 失败数: {}", total_errors)
|
logger.info(" 失败数: {}", total_errors)
|
||||||
logger.info(" 总耗时: {:.0f}s", elapsed)
|
logger.info(" 总耗时: {:.0f}s", elapsed)
|
||||||
|
logger.info(" 并发数: {}", concurrency)
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
if total_errors > 0:
|
if total_errors > 0:
|
||||||
@@ -416,6 +454,17 @@ def parse_args() -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="仅检测不修复,不调用 VLM/LLM",
|
help="仅检测不修复,不调用 VLM/LLM",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--concurrency",
|
||||||
|
type=int,
|
||||||
|
default=16,
|
||||||
|
help="并发修复视频数(默认: 16)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reaggregate-all",
|
||||||
|
action="store_true",
|
||||||
|
help="强制全量重聚合,忽略 progress 文件",
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user