fix(concurrency): 消除 model 覆盖的模块级变量中转(并发串味)

R4 首版实现用模块级 _ACTIVE_MODEL_OVERRIDE 中转 model override,
理由是不想改动 run_specialists 的签名(既有测试会 mock 它)。

但 backtest.py:225 会 asyncio.gather 并发 8 场预测(Semaphore(8)),
每场都调用 predict_match_multi —— 模块级变量会被并发调用互相覆盖,
导致 A 场的预测用上 B 场的模型。这是静默的正确性缺陷。

改为: run_specialists 新增 model_override 形参,一路显式下传;
删除模块级变量。同步更新 6 处 mock 签名。

新增 3 个守卫并做变异验证:
  - test_r4_no_module_level_model_override_global (源码级,可判别)
  - test_r4_run_specialists_accepts_model_override_parameter
  - test_r4_dispatch_passes_override_to_specialists
变异测试: 重新引入全局变量方案 → 两个守卫变红;还原 → 全绿。

注: 曾尝试写并发行为测试,但真实 run_specialists 会访问数据库,
测试环境下不稳定(ConnectionRefusedError),会是 flaky 的假证据,
故改用清晰的源码级判别守卫,并在注释中说明原因。

240 passed / 1 skipped; tsc 0 error; vite build 成功。
This commit is contained in:
shangfangjian
2026-09-21 17:52:45 +08:00
parent 50a6753ff8
commit 06e973d34d
5 changed files with 80 additions and 33 deletions
+62 -8
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import inspect
import logging
import pathlib
import re
import pytest
@@ -348,9 +349,13 @@ def test_r4_override_does_not_pollute_cache():
assert "_AGENT_PROVIDER_CACHE[cache_key]" in src
async def test_r4_dispatch_sets_override_for_specialists(monkeypatch):
"""行为测试: predict_match_multi 把 model 放进 _ACTIVE_MODEL_OVERRIDE,
并在 run_specialists 执行期间对 specialist 生效(退出后复位)。
async def test_r4_dispatch_passes_override_to_specialists(monkeypatch):
"""行为测试: predict_match_multi 必须把 model 作为**形参**传给 run_specialists。
早期实现用模块级变量 _ACTIVE_MODEL_OVERRIDE 中转,但 backtest 会
asyncio.gather 并发 8 场预测(backtest.py Semaphore(8)),全局变量会被
并发调用互相覆盖 → A 场预测用上 B 场的模型。故此处断言「形参传递」,
并显式断言该模块级变量已不存在。
参照 tests/test_multi_agent_degraded.py 的 stub 方式,避免触碰真实 DB。
"""
@@ -366,8 +371,8 @@ async def test_r4_dispatch_sets_override_for_specialists(monkeypatch):
h.match_dt = None
return h
async def _fake_specialists(header, *, version, before):
seen["override_during_run"] = orch_mod._ACTIVE_MODEL_OVERRIDE
async def _fake_specialists(header, *, version, before, model_override=None):
seen["override_during_run"] = model_override
return []
async def _fake_upsert(session, **kw):
@@ -403,14 +408,63 @@ async def test_r4_dispatch_sets_override_for_specialists(monkeypatch):
monkeypatch.setattr(orch_mod, "run_specialists", _fake_specialists, raising=True)
monkeypatch.setattr(orch_mod, "_upsert_prediction", _fake_upsert, raising=True)
monkeypatch.setattr(orch_mod, "get_uow", _FakeUow, raising=True)
monkeypatch.setattr(orch_mod, "_ACTIVE_MODEL_OVERRIDE", None, raising=False)
assert get_uow is not None # 确保 import 生效,session 未被真实打开
await orch_mod.predict_match_multi(999, model="OVERRIDE-X")
assert seen["override_during_run"] == "OVERRIDE-X"
assert orch_mod._ACTIVE_MODEL_OVERRIDE is None, "退出后必须复位"
assert seen["override_during_run"] == "OVERRIDE-X", (
"model 未作为形参传给 run_specialists"
)
# 回归守卫: 模块级中转变量必须不存在(并发下会产生模型串味)
assert not hasattr(orch_mod, "_ACTIVE_MODEL_OVERRIDE"), (
"不应再用模块级 _ACTIVE_MODEL_OVERRIDE 中转 model: backtest 并发 8 场预测时"
"会互相覆盖,导致模型串味"
)
def test_r4_run_specialists_accepts_model_override_parameter():
"""run_specialists 必须显式接收 model_override 形参(而非读全局)。"""
import inspect
sig = inspect.signature(orch_mod.run_specialists)
assert "model_override" in sig.parameters, (
"run_specialists 缺少 model_override 形参 —— 并发场景下模型会串味"
)
assert sig.parameters["model_override"].default is None
def test_r4_no_module_level_model_override_global():
"""并发安全守卫: orchestrator 不得用模块级变量中转 model 覆盖。
backtest 会 asyncio.gather 并发 8 场预测(backtest.py 的 Semaphore(8)),
模块级变量会被并发调用互相覆盖 → A 场的预测用上 B 场的模型(模型串味)。
正确做法是把 model 作为形参一路下传。
说明: 这里用源码级断言而非并发行为测试 —— 真实 run_specialists 会调用
数据库(_agent_provider -> load_match_header),在无 DB 的测试环境下
无法稳定执行,写出来的并发测试会是 flaky 的假证据(已实测确认)。
形参方案与全局方案的判别点清晰且可直接观测,故用源码守卫。
"""
src = _orchestrator_source()
# 1) 不得存在模块级覆盖变量
assert "_ACTIVE_MODEL_OVERRIDE" not in src, (
"orchestrator 又引入了模块级 model 覆盖变量 —— 并发预测会模型串味"
)
# 2) 不得有 `global` 声明去写模型覆盖
assert not re.search(r"^\s*global\s+.*MODEL", src, re.M), (
"orchestrator 使用 global 声明中转模型覆盖 —— 并发下不安全"
)
# 3) model_override 必须作为实参出现在 run_specialists 调用里
call = re.search(
r"await run_specialists\((.*?)\)", src, re.S
)
assert call, "未找到 run_specialists 调用点"
assert "model_override=" in call.group(1), (
"run_specialists 调用点未显式传 model_override —— "
"model 可能又走回隐式中转,并发下会串味"
)
# ============================================================