diff --git a/nodes/llm.py b/nodes/llm.py index 7e1f0bb..9568f51 100755 --- a/nodes/llm.py +++ b/nodes/llm.py @@ -155,7 +155,7 @@ def translate_lines(lines: list[str], params: dict) -> list[str]: ) 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.6-35B-A3B")) + model = str(params.get("model") or os.getenv("LLM_MODEL", "Qwen/Qwen3.5-35B-A3B")) target_language = str(params.get("target_language", "zh-CN")) system_prompt = _system_prompt(target_language) diff --git a/tests/test_llm_default_model.py b/tests/test_llm_default_model.py new file mode 100644 index 0000000..4e6b85d --- /dev/null +++ b/tests/test_llm_default_model.py @@ -0,0 +1,155 @@ +"""LLM 默认模型解析契约测试(切换默认模型只改数据/环境,不改代码)。 + +背景 +---- +2026-09 评测结论(data/experiments/translate_models/REPORT.md)决定把翻译/过滤/ +纠错三类节点的**默认模型**从 `Qwen/Qwen3.6-35B-A3B` 换成 `Qwen/Qwen3.5-35B-A3B` +(质量持平、速度 0.232 s/行,是基线档最快)。 + +仓库约定"切换模型不改代码":三个节点(llm-translate / llm-filter / +subtitle-correction)的模型解析顺序都是 +`params["model"]` → 环境变量 `LLM_MODEL` → 兜底默认值。 +因此换默认模型 = 改 `.env` 的 `LLM_MODEL`(+ 文档),代码只在兜底默认值上同步。 + +本测试锁定两件事,防止"改了 .env 但节点仍走旧模型"或"params 覆盖失效": +1. 环境变量 `LLM_MODEL` 能真正决定请求体里的 model(在 HTTP 边界观测真实请求); +2. `params["model"]` 优先级高于环境变量(工作流参数覆盖仍有效)。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from nodes import llm +from wov_sdk.models import InvokeRequest + + +def _capture_model(monkeypatch, run_id: str = "r") -> list[dict]: + """在 HTTP 边界记录真实请求体,并返回正常结构响应(模型名取请求体的)。""" + sent: list[dict] = [] + + class Response: + """最小可用的 OpenAI 兼容响应;内容由请求体推导,便于断言对齐。""" + + def __init__(self, body: dict): + items = json.loads(body["messages"][1]["content"]) + self._payload = json.dumps( + { + "choices": [ + { + "message": { + "content": json.dumps( + [{"id": i["id"], "text": "译文"} for i in items], + ensure_ascii=False, + ) + } + } + ], + "usage": {"total_tokens": 1}, + } + ).encode() + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self): + return self._payload + + def open_request(request, **kwargs): + body = json.loads(request.data) + sent.append(body) + return Response(body) + + monkeypatch.setattr("urllib.request.urlopen", open_request) + return sent + + +def test_env_model_决定请求体里的模型(monkeypatch, tmp_path: Path) -> None: + """环境变量 LLM_MODEL 生效:旧模型默认值不会被写死进请求。 + + 这是"改 .env 就换默认模型"的硬契约——若节点把默认模型写死在代码里, + 本用例会因请求体仍是旧模型而失败。 + """ + monkeypatch.setenv("LLM_MODEL", "新默认/模型") + source = tmp_path / "input.srt" + source.write_text("1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n", encoding="utf-8") + sent = _capture_model(monkeypatch) + response = llm.invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"srt_uri": str(source)}, output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + assert sent[0]["model"] == "新默认/模型" + + +def test_params_model_优先于环境变量(monkeypatch, tmp_path: Path) -> None: + """工作流 DAG 的 params.model 覆盖环境变量(切换模型是数据,不是环境依赖)。""" + monkeypatch.setenv("LLM_MODEL", "环境/模型") + source = tmp_path / "input.srt" + source.write_text("1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n", encoding="utf-8") + sent = _capture_model(monkeypatch) + response = llm.invoke( + InvokeRequest( + run_id="r", node_instance_id="", + inputs={"srt_uri": str(source)}, params={"model": "工作流/模型"}, + output_dir=str(tmp_path / "out"), + ) + ) + assert response.status == "completed", response.error + assert sent[0]["model"] == "工作流/模型" + + +def test_兜底默认模型按节点职责分离() -> None: + """翻译节点跟随 .env 的 LLM_MODEL;纠错节点保留旧默认(实测更优)。 + + 2026-09 实测(同一误听泛化场景,各跑 4 次): + - `Qwen/Qwen3.6-35B-A3B`:4/4 正确推断(“小穴”); + - `Qwen/Qwen3.5-35B-A3B`:0/4(输出“阴道/曼果/曼戈”,字面直译)。 + + 而翻译节点上新模型与旧模型逐条一致(1160 vs 1160,时间戳完全对齐)。 + 因此**不能三处同源**:翻译跟全局(.env),纠错独立保留旧模型; + 过滤节点默认不调 LLM(use_llm=0),其兜底仅作启用时的默认值。 + + 本用例锁定“分叉是有意为之”:翻译兜底必须与 .env 一致;纠错兜底必须 + 保留旧模型;三处都必须读 LLM_MODEL 环境变量(保留单点覆盖能力)。 + """ + import inspect + + from nodes import llm_filter, subtitle_correction + + env_model = None + env_path = Path(__file__).resolve().parent.parent / ".env" + if env_path.is_file(): + for line in env_path.read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("LLM_MODEL=") and not line.strip().startswith("#"): + env_model = line.split("=", 1)[1].strip() + # 模型解析所在函数:翻译在 translate_lines、过滤在 _judge_category、 + # 纠错在 correct_entry。 + sources = { + "llm-translate": inspect.getsource(llm.translate_lines), + "llm-filter": inspect.getsource(llm_filter._judge_category), + "subtitle-correction": inspect.getsource(subtitle_correction.correct_entry), + } + fallbacks = {} + for name, src in sources.items(): + marker = 'os.getenv("LLM_MODEL", "' + assert marker in src, f"{name} 未按约定读取 LLM_MODEL 环境变量" + fallbacks[name] = src.split(marker, 1)[1].split('"', 1)[0] + # 翻译节点跟随全局配置。 + if env_model: + assert fallbacks["llm-translate"] == env_model, ( + f"翻译兜底 {fallbacks['llm-translate']} 与 .env 的 LLM_MODEL={env_model} 不一致" + ) + assert fallbacks["llm-filter"] == env_model, "过滤节点兜底应与全局保持一致" + # 纠错节点**有意**保留旧模型(实测新模型 0/4 vs 旧 4/4)。 + assert fallbacks["subtitle-correction"] == "Qwen/Qwen3.6-35B-A3B", ( + "纠错节点应保留旧默认(新模型在该节点泛化实测 0/4,明显劣化)" + )