Files
vrsub/scripts/bench_translate_models.py
cat-shark 8f6083f8cf feat: 全系统统一 Whisper V2 权重并移除 demo 工作流
确认所有运行时引用均使用 V2(V3 已停用),并修复一处真实不一致:

- 工作流数据文件:learn-translate 用 faster-whisper-large-v2、
  zh-direct 用 whisper-large-v2-translate-zh-v0.2-st-ct2(原本即 V2);
- 本地库的 demo 最新版本仍指向 large-v3:workflows/demo.json 早已改为 V2,
  但 seed 对已存在工作流刻意跳过,导致旧库停留在历史上用 V3 保存的定义,
  即本机跑 demo 实际加载 V3 权重。按用户决定移除 demo 工作流及其关联的
  6 个 run、1 个批量任务与 431 条明细(媒体库中已放置的 6 个字幕成品保留);
- nodes/whisper.py 候选与远端兜底本就是 large-v2;
- V3 权重目录保留在盘上仅作对照实验,文档标注为废弃;
  scripts/compare_whisper_v2_vs_v3.py 保留用于对照。

顺带修复与清理:
- src/wov_app/scheduler.py:_file_size 补捕 ValueError(见上一条提交说明
  的真实缺陷,此处为同一批改动);
- .gitignore:data/ 改为 /data/,避免连带忽略 tests/**/data/;
- scripts/*:评测集路径改到 scripts/data/translate_eval/;
- 代码注释与文档同步移除 demo 引用(历史调研文档保留说明性引用)。

验证:全量 477 passed;新库 seed 只创建 3 个 V2 工作流。
2026-09-13 15:41:58 +08:00

304 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""翻译模型横向评测执行器(真实节点代码,模型是唯一变量)。
设计原则(对齐 AGENTS.md 与本仓库测试约定)
------------------------------------------------
1. **不改生产逻辑**:评测直接调用 `nodes/llm.py` 的真实实现
`_system_prompt` / `translate_lines` / `invoke`:提示词、专名规则注入、
ID 严格校验、重试、幻觉清洗、SRT 写出全部走生产路径);被评测的模型只通过
`params["model"]` 传入。
2. **只在 I/O 边界做手脚**AGENTS 允许的最小 mock 面):包装
`urllib.request.urlopen`,用于
a) 记录每次请求的耗时与 token 用量(生产日志只有批级耗时,这里要精确到
单次调用,用于"每行摊薄秒数"判定);
b) `strip_thinking=True` 时把请求体里的 `enable_thinking` 键删掉——
Qwen3-VL 系列不接受该参数,生产代码固定携带(实测返回 400
code 20015)。这是**评测侧兼容**,生产代码保持原样,报告里单独标注。
3. **不并发**llm-translate 生产实现是逐批串行的,评测同样串行(用户已确认
本次不评估并发能力)。
用法
----
# 用某个模型跑完整 SRT(产出 cn.srt + calls.jsonl + summary.json
uv run python scripts/bench_translate_models.py run \
--model Qwen/Qwen3-14B --tag cloud-qwen3-14b
# 本地 ollamaOpenAI 兼容端点)
uv run python scripts/bench_translate_models.py run \
--model qwen3:14b --tag local-qwen3-14b \
--api-base http://localhost:11434/v1/chat/completions --api-key ""
# 评测窗口对照(打印各模型在同一窗口的译文,供人工 review)
uv run python scripts/bench_translate_models.py compare --tags cloud-qwen3-14b local-qwen3-14b
"""
from __future__ import annotations
import argparse
import json
import os
import time
import urllib.request
from contextlib import contextmanager
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# 所有评测产物落在 data/experimentsgitignored),不污染测试数据。
OUT_ROOT = PROJECT_ROOT / "data/experiments/translate_models"
# 评测输入:run_d386ccf124f7 的日语 ASR(与 run_479b411f299d 同音轨,已核对
# 前 5 分钟 16k 单声道 PCM md5 一致),1161 条 cue。
DEFAULT_INPUT = PROJECT_ROOT / "data/storage/runs/run_d386ccf124f7/steps/asr/transcript.srt"
@contextmanager
def _instrument(model: str, log_path: Path, strip_thinking: bool):
"""包装 urlopen:记录每次 LLM 请求耗时/token,并可按需剔除 enable_thinking。
只替换 `urllib.request.urlopen` 这一个 I/O 入口,返回对象与异常语义保持
不变;非 LLM 请求(如 ollama 其它端点)原样透传。
"""
original = urllib.request.urlopen
calls: list[dict] = []
fh = log_path.open("w", encoding="utf-8")
def patched(request, *args, **kwargs):
body = None
if hasattr(request, "data") and request.data:
try:
body = json.loads(request.data.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
body = None
# 评测侧兼容:Qwen3-VL 不接受 enable_thinking(生产代码固定携带)。
had_thinking = isinstance(body, dict) and "enable_thinking" in body
if strip_thinking and had_thinking:
body.pop("enable_thinking")
request.data = json.dumps(body).encode("utf-8")
started = time.monotonic()
record: dict = {"model": model, "had_enable_thinking": had_thinking}
try:
response = original(request, *args, **kwargs)
payload = response.read()
elapsed = time.monotonic() - started
record["elapsed_s"] = round(elapsed, 3)
try:
parsed = json.loads(payload)
usage = parsed.get("usage") or {}
record["prompt_tokens"] = usage.get("prompt_tokens")
record["completion_tokens"] = usage.get("completion_tokens")
record["total_tokens"] = usage.get("total_tokens")
record["finish_reason"] = (parsed.get("choices") or [{}])[0].get("finish_reason")
record["ok"] = True
except ValueError:
record["ok"] = True
calls.append(record)
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
fh.flush()
class _Replay:
"""把已读出的响应体重新包装成文件式对象,交给上层原样消费。"""
def __init__(self, inner, data: bytes):
self._inner = inner
self._data = data
def read(self, *a):
return self._data
def __enter__(self):
# 生产代码用 with urlopen(...) as response,包装对象必须
# 支持上下文管理器协议,否则断链。
return self
def __exit__(self, *a):
return self._inner.__exit__(*a)
def __getattr__(self, name):
return getattr(self._inner, name)
return _Replay(response, payload)
except Exception as exc: # noqa: BLE001 - 记录后原样抛出,行为不变
record["elapsed_s"] = round(time.monotonic() - started, 3)
record["ok"] = False
record["error"] = f"{type(exc).__name__}: {exc}"
calls.append(record)
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
fh.flush()
raise
urllib.request.urlopen = patched
try:
yield calls
finally:
urllib.request.urlopen = original
fh.close()
def run_model(args: argparse.Namespace) -> None:
"""用指定模型跑完整翻译,产出 cn.srt / calls.jsonl / summary.json。"""
from dotenv import load_dotenv
from wov_sdk.models import InvokeRequest
from nodes import llm
load_dotenv(PROJECT_ROOT / ".env")
if args.api_base:
os.environ["LLM_API_BASE"] = args.api_base
# 本地 ollama 不需要密钥;显式传空串时清掉环境里的云端 key,避免误带。
if args.api_key is not None:
os.environ["LLM_API_KEY"] = args.api_key
if args.timeout:
os.environ["LLM_TIMEOUT_SECONDS"] = str(args.timeout)
out_dir = OUT_ROOT / args.tag
out_dir.mkdir(parents=True, exist_ok=True)
input_path = Path(args.input)
# --limit 仅用于冒烟验证(会截断 SRT 到前 N 条 cue)。
if args.limit:
from nodes.srt import parse_srt, serialize_srt
cues = parse_srt(input_path.read_text(encoding="utf-8"))[: args.limit]
input_path = out_dir / f"input_first{args.limit}.srt"
input_path.write_text(serialize_srt(cues), encoding="utf-8")
if args.warmup:
# 预热:本地 ollama 首次请求包含模型加载(实测 qwen3:14b 约 10s),
# 会把"每行摊薄秒数"算高。预热会丢弃结果,只让模型常驻显存。
from nodes.srt import parse_srt, serialize_srt
warm_input = out_dir / "warmup.srt"
warm_input.write_text(
serialize_srt(parse_srt(input_path.read_text(encoding="utf-8"))[:2]), encoding="utf-8"
)
warm_started = time.monotonic()
llm.invoke(
InvokeRequest(
run_id=f"warmup_{args.tag}",
node_instance_id="translate",
inputs={"srt_uri": str(warm_input)},
params={"model": args.model, "target_language": "zh-CN"},
output_dir=str(out_dir / "warmup_steps"),
)
)
print(f"[warmup] {time.monotonic() - warm_started:.1f}s")
print(f"[run] model={args.model} tag={args.tag} input={input_path.name} "
f"strip_thinking={args.strip_thinking} base={os.environ.get('LLM_API_BASE')}")
started = time.monotonic()
with _instrument(args.model, out_dir / "calls.jsonl", args.strip_thinking) as calls:
response = llm.invoke(
InvokeRequest(
run_id=f"bench_{args.tag}",
node_instance_id="translate",
inputs={"srt_uri": str(input_path)},
params={"model": args.model, "target_language": "zh-CN"},
output_dir=str(out_dir / "steps"),
)
)
wall = time.monotonic() - started
summary = {
"model": args.model,
"tag": args.tag,
"api_base": os.environ.get("LLM_API_BASE"),
"strip_thinking": args.strip_thinking,
"input": str(input_path),
"status": response.status,
"error": response.error,
"wall_s": round(wall, 2),
"calls": len(calls),
"failed_calls": sum(1 for c in calls if not c.get("ok")),
"total_tokens": sum(int(c.get("total_tokens") or 0) for c in calls),
}
if response.status == "completed":
srt_path = Path(response.outputs["cn_srt_uri"])
cues = sum(1 for line in srt_path.read_text(encoding="utf-8").splitlines() if "-->" in line)
summary["cn_srt"] = str(srt_path)
summary["cues_out"] = cues
summary["per_cue_s"] = round(wall / cues, 3) if cues else None
summary["cue_per_s"] = round(cues / wall, 2) if wall else None
(out_dir / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=1), encoding="utf-8"
)
print(json.dumps(summary, ensure_ascii=False, indent=1))
def _window_rows(tags: list[str], limit: int, only: str | None, emit: str | None = None,
eval_set: str | None = None) -> None:
"""打印/写出评测窗口的多模型译文对照表(人工 review 用)。
默认使用人工确认的评测集 scripts/data/translate_eval/eval_set.jsonl124 个窗口,
按时间分层抽样);未提供时回退到候选池 windows.jsonl。
stdout 与 markdown 文件**共用同一批文本行**(单一出口):此前两套格式
各自拼接,导致 review 材料与实际打印内容漂移(回归见
tests/test_translate_model_bench.py 的对照表一致性用例)。
"""
from nodes.srt import parse_srt
source = Path(eval_set) if eval_set else PROJECT_ROOT / "scripts/data/translate_eval/eval_set.jsonl"
if not source.is_file():
source = PROJECT_ROOT / "scripts/data/translate_eval/windows.jsonl"
windows = [json.loads(line) for line in source.read_text(encoding="utf-8").splitlines()]
ja_cues = parse_srt(DEFAULT_INPUT.read_text(encoding="utf-8"))
# 每个 tag 读 cn.srt,按"时间戳 -> 译文"建索引(翻译不改变时间戳;被幻觉
# 清洗删掉的 cue 缺失属预期,用 None 表示)。
outputs: dict[str, dict[str, str]] = {}
for tag in tags:
path = OUT_ROOT / tag / "steps" / "cn.srt"
if not path.is_file():
print(f"!! 缺少 {path}")
continue
outputs[tag] = {
f"{c.start}-->{c.end}": c.text for c in parse_srt(path.read_text(encoding="utf-8"))
}
selected = windows if only is None else [w for w in windows if only in w["zh_start"]]
lines_out: list[str] = []
for window in selected[: limit or None]:
ja = window["ja_lines"]
keys = [f"{ja_cues[i - 1].start}-->{ja_cues[i - 1].end}" for i in window["ja_ids"]]
header = f"### {window.get('id', '?')}. {window['zh_start']} [{len(ja)}条]"
lines_out.append(header)
lines_out.append("- JA: " + " ".join(ja))
lines_out.append(f"- 基准中文(OCR): {window['zh_ref']}")
for tag, index in outputs.items():
texts = [index.get(k) or "<已删除>" for k in keys]
lines_out.append(f"- {tag}: " + " ".join(texts))
lines_out.append("")
text = "\n".join(lines_out)
print(text)
if emit:
Path(emit).write_text(text, encoding="utf-8")
print(f"\n对照表已写入 {emit}")
def main() -> None:
parser = argparse.ArgumentParser(description="翻译模型横向评测")
sub = parser.add_subparsers(dest="command", required=True)
run = sub.add_parser("run", help="用某模型跑完整翻译")
run.add_argument("--model", required=True)
run.add_argument("--tag", required=True, help="产物目录名(data/experiments/translate_models/<tag>")
run.add_argument("--input", default=str(DEFAULT_INPUT))
run.add_argument("--api-base", default=None, help="覆盖 LLM_API_BASE(本地 ollama 用)")
run.add_argument("--api-key", default=None, help="覆盖 LLM_API_KEY(本地传空串)")
run.add_argument("--timeout", type=float, default=None, help="LLM_TIMEOUT_SECONDS")
run.add_argument("--strip-thinking", action="store_true", help="请求体剔除 enable_thinkingVL 模型)")
run.add_argument("--warmup", action="store_true", help="先跑 2 条预热(本地模型加载耗时不计入)")
run.add_argument("--limit", type=int, default=0, help="只跑前 N 条 cue(冒烟)")
run.set_defaults(func=run_model)
cmp_parser = sub.add_parser("compare", help="打印窗口级多模型译文对照")
cmp_parser.add_argument("--tags", nargs="+", required=True)
cmp_parser.add_argument("--limit", type=int, default=0)
cmp_parser.add_argument("--only", default=None, help="只看时间戳包含该字符串的窗口")
cmp_parser.add_argument("--emit", default=None, help="把对照表写入 markdown 文件")
cmp_parser.add_argument("--eval-set", default=None, help="改用指定评测集 jsonl")
cmp_parser.set_defaults(func=lambda a: _window_rows(a.tags, a.limit, a.only, a.emit, a.eval_set))
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()