Files
Profeto/src/llm/agents/orchestrator.py
T
shangfangjian 64ae8e663a fix(P0-03): Prediction 幂等指纹——只追加,不覆盖
_upsert_prediction 改为 _insert_or_find_by_fingerprint:
- 同 input_hash → 返回已有行(绝不 UPDATE pred_/reasoning/agent_outputs)
- 不同 input_hash → INSERT 新行

input_hash 升级为规范 JSON SHA-256,捕获:match_id, cutoff, prompt_version,
prompt_hash, system_prompt_hash, provider, model, mode, run_type, temperature,
context_hash, agent_ids。移除旧 (match, provider, model, mode, run_type) 唯一约束,
改为 partial unique index(WHERE input_hash IS NOT NULL,兼容旧 NULL 数据)。

三条路径(single/multi/baseline)统一传足指纹字段。
迁移 0024 + 测试 test_p0_prediction_fingerprint(10/10);全量 295 通过。
2026-09-22 03:13:32 +08:00

384 lines
16 KiB
Python

"""多 agent 预测编排: 并行专家 → 终裁 → 存库。"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import time
from datetime import datetime, timezone
from src.core.config import settings
from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction
from src.db.unit_of_work import get_uow
from src.llm.predict import PredictResult, _insert_or_find_by_fingerprint
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
from src.llm.context_builder import (
MatchHeader,
form_slice,
h2h_slice,
header_text,
home_away_slice,
load_match_header,
standings_slice,
stats_slice,
)
from src.core.runtime_config import get_runtime_value
from src.llm.provider import LLMProvider, get_default_provider
logger = logging.getLogger(__name__)
# P3-2: agent provider 配置缓存(TTL 60s),避免每次 _agent_provider 都多次查 DB
_AGENT_PROVIDER_CACHE: dict[str, tuple[float, LLMProvider]] = {}
_AGENT_PROVIDER_CACHE_TTL = 60.0
# ── 5 个专家 agent 定义 ──
# A=近期状态 B=攻防数据 C=主客因素 D=联赛排名 E=历史交锋
SPECIALIST_SPECS: list[AgentSpec] = [
AgentSpec(
name="form",
system_prompt="你是足球近期状态分析专家。分析比分与关键事件,输出近期走势判断。只输出 JSON。",
slice_fn=form_slice,
),
AgentSpec(
name="stats",
system_prompt="你是足球攻防数据分析专家。评估进球、射门与控球,输出攻防强度。只输出 JSON。",
slice_fn=stats_slice,
),
AgentSpec(
name="home_away",
system_prompt="你是足球主客因素分析专家。对比主场与客场表现,评估地理优势影响。只输出 JSON。",
slice_fn=home_away_slice,
),
AgentSpec(
name="standings",
system_prompt="你是足球联赛排名分析专家。分析积分榜位置、积分走势与分区,评估两队整体实力差距。只输出 JSON。",
slice_fn=standings_slice,
),
AgentSpec(
name="h2h",
system_prompt="你是足球历史交锋分析专家。分析过去数年以及近期的交手数据,提取交手规律。只输出 JSON。",
slice_fn=h2h_slice,
),
]
AGGREGATOR_SYSTEM = (
"你是足球预测终裁专家。综合各领域专家报告输出最终预测。"
"引用专家时必须使用报告中的专家全名(如「攻防数据分析专家」),禁止使用英文代码。"
"只输出 JSON。"
)
# 专家代码 → 终裁/展示统一称呼
AGENT_LABELS_ZH: dict[str, str] = {
"form": "近期状态分析专家",
"stats": "攻防数据分析专家",
"home_away": "主客因素分析专家",
"standings": "联赛排名分析专家",
"h2h": "历史交锋分析专家",
}
# D2(工程债): multi 结果类型与 single 统一 —— 扩展后的 PredictResult 用可选
# 字段(agent_outputs/agent_weights/prompt_tokens/completion_tokens/mode)承载
# 全部模式,此处仅保留别名。保留 `MultiPredictResult` 名字的原因:
# 1. predict_match_multi 签名 `-> MultiPredictResult:` 是 R4 源码守卫的标记;
# 2. src/llm/agents/__init__.py 对外 re-export 该名字。
MultiPredictResult = PredictResult
async def _agent_provider(agent_id: str, *, tier: str, model_override: str | None = None) -> LLMProvider:
"""构造某 agent 专属 provider。
覆盖优先级:
模型: model_override(调用方显式指定) → AGENT_MODEL_{ID}(运行时) → 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) → 全局 LLM_MODEL
地址/密钥: AGENT_BASE_URL_{ID} / AGENT_API_KEY_{ID}(运行时) → 全局 LLM_BASE_URL / LLM_API_KEY
P3-2: 结果缓存 60 秒,避免每次预测都多次查询运行时配置 DB。
注意:model_override 生效时跳过缓存读写 —— 否则带 override 的结果会泄漏给
不带 override 的调用(反之亦然),导致跨调用的模型串味。
"""
cache_key = f"{agent_id}:{tier}"
if model_override is None:
cached = _AGENT_PROVIDER_CACHE.get(cache_key)
if cached is not None:
ts, provider = cached
if time.time() - ts < _AGENT_PROVIDER_CACHE_TTL:
return provider
pfx = f"AGENT_{agent_id.upper()}_"
p = await get_default_provider()
tier_model = settings.LLM_SPECIALIST_MODEL if tier == "specialist" else settings.LLM_AGGREGATOR_MODEL
if tier_model:
p.model = tier_model
model = await get_runtime_value(f"{pfx}MODEL")
if model:
p.model = model
# 调用方显式传入的 model 优先级最高,高于 agent 级与层级默认
if model_override:
p.model = model_override
base = await get_runtime_value(f"{pfx}BASE_URL")
if base:
p.base_url = base
key = await get_runtime_value(f"{pfx}API_KEY")
if key:
p.api_key = key
if model_override is None:
_AGENT_PROVIDER_CACHE[cache_key] = (time.time(), p)
# 简单淘汰:超过 20 条时清空(60s TTL 下不会累积太多)
if len(_AGENT_PROVIDER_CACHE) > 20:
_AGENT_PROVIDER_CACHE.clear()
return p
async def run_specialists(
header: MatchHeader,
*,
version: str = "v1",
before=None,
model_override: str | None = None,
) -> list[AgentReport]:
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。
before: 数据截止时间(回测防泄漏)。None 表示不限制。
model_override: 调用方显式指定的模型,覆盖各 agent 的层级默认。
注意:model_override 必须作为形参下传,不能用模块级变量中转。
backtest 会 asyncio.gather 并发 8 场预测(见 backtest.py 的 Semaphore(8)),
模块级变量会被并发调用互相覆盖,导致 A 场的预测用上 B 场的模型。
"""
tasks = [
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist", model_override=model_override), version=version, before=before)
for spec in SPECIALIST_SPECS
]
results = await asyncio.gather(*tasks, return_exceptions=True)
reports: list[AgentReport] = []
for spec, r in zip(SPECIALIST_SPECS, results):
if isinstance(r, Exception):
logger.warning(
"专家调用失败 match=%s agent=%s error=%s",
header.match_id, spec.name, str(r)[:120],
)
reports.append(AgentReport(agent=spec.name, status="error", analysis=str(r)[:200]))
else:
reports.append(r)
return reports
async def _run_one(spec, header, provider, *, version, before=None) -> AgentReport:
from src.llm.agents.base import run_agent
return await run_agent(spec, header, provider, before=before, version=version)
def _reports_to_json(reports: list[AgentReport]) -> str:
"""报告序列化: agent 字段直接用中文专家全名,引导终裁用统一称呼引用。"""
out = []
for r in reports:
d = r.to_dict()
d["agent"] = AGENT_LABELS_ZH.get(d.get("agent", ""), d.get("agent"))
out.append(d)
return json.dumps(out, ensure_ascii=False, indent=1)
async def run_aggregator(
header: MatchHeader,
reports: list[AgentReport],
*,
provider: LLMProvider,
version: str = "v1",
) -> tuple[dict, int, int]:
"""终裁: 汇总报告 → 最终 JSON。返回 (解析结果, prompt_tokens, completion_tokens)。"""
template = load_agent_prompt("aggregator", version)
user_prompt = (
template
.replace("{{match_header}}", header_text(header))
.replace("{{agent_reports}}", _reports_to_json(reports))
)
resp = await provider.chat(
system=AGGREGATOR_SYSTEM,
user=user_prompt,
json_mode=True,
temperature=0.2,
max_tokens=4096, # 推理模型需要更大余量
)
if resp.error:
raise RuntimeError(f"aggregator LLM error: {resp.error}")
if not resp.parsed:
raise RuntimeError(f"aggregator 输出无法解析: {resp.content[:200]}")
return resp.parsed, resp.prompt_tokens or 0, resp.completion_tokens or 0
async def predict_match_multi(
match_id: int,
*,
provider: LLMProvider | None = None,
version: str = "v1",
backtest: bool = False,
cutoff_at=None,
model: str | None = None,
) -> MultiPredictResult:
"""多 agent 端到端预测: 切片 → 并行专家 → 终裁 → 存库。
backtest: 回测模式。True 时 cutoff 自动设为 match_dt - 1 天。
cutoff_at: 显式截止时间(优先于 backtest 自动计算)。
model: 显式指定模型,优先于 agent 级/层级默认配置(single 模式语义一致)。
"""
start = time.perf_counter()
# 1. 比赛头(各 agent 共享;不存在则 404)
header = await load_match_header(match_id)
match_kickoff_at = header.match_dt
now = datetime.now(timezone.utc)
# 计算真正的数据截止时间(回测防泄漏)
# 优先级: 显式 cutoff_at > backtest 自动计算 > 默认(比赛时间)
if cutoff_at is not None:
cutoff = cutoff_at
elif backtest and header.match_dt:
from datetime import timedelta
cutoff = header.match_dt - timedelta(days=1)
else:
cutoff = header.match_dt
prediction_cutoff_at = cutoff
# 2. 并行专家(各自独立配置,使用统一 cutoff)
# model 作为形参下传,而非模块级变量:backtest 并发 8 场预测时,
# 模块级变量会被并发调用互相覆盖(模型串味)。
reports = await run_specialists(
header, version=version, before=cutoff, model_override=model
)
# 2.5 统计有效专家报告数量
ok_reports = [r for r in reports if r.status == "ok"]
has_valid_data = len(ok_reports) > 0
# 3. 终裁(仅当有有效专家报告时执行)
if has_valid_data:
aggregator_provider = await _agent_provider("aggregator", tier="aggregator")
final, agg_prompt_tokens, agg_completion_tokens = await run_aggregator(
header, reports, provider=aggregator_provider, version=version
)
else:
# 所有专家无数据/均失败:跳过终裁,标记 degraded
logger.warning(
"预测降级 match=%s mode=%s status=degraded experts=%d/%d 均无有效数据",
match_id, "multi", len(ok_reports), len(reports),
)
# 无有效专家时不调用 aggregator provider,避免多余开销
# model 使用 settings 默认值占位(无实际 LLM 调用)
final = {
"pred_home_goals": None,
"pred_away_goals": None,
"alt_pred_home_goals": None,
"alt_pred_away_goals": None,
"pred_1x2": None,
"subjective_confidence": None,
"reasoning": f"所有 {len(reports)} 位专家均无有效数据或预测失败(状态: {','.join(r.status for r in reports)})",
"agent_weights": {},
}
agg_prompt_tokens = 0
agg_completion_tokens = 0
aggregator_model = model or settings.LLM_MODEL # 占位,无实际 LLM 调用
latency_ms = int((time.perf_counter() - start) * 1000)
# P0-03: 指纹输入——终裁报告 hash 作 context_hash,专家列表作 agent_ids
reports_json = _reports_to_json(reports)
context_hash = hashlib.sha256(reports_json.encode("utf-8")).hexdigest()
agent_ids = sorted([r.agent for r in reports]) if reports else []
# 终裁模板 hash(规范:复用 prompt 版本 + 终裁 system prompt)
prompt_hash = hashlib.sha256(f"multi_{version}".encode("utf-8")).hexdigest()
system_prompt_hash = hashlib.sha256(AGGREGATOR_SYSTEM.encode("utf-8")).hexdigest()
# 4. 存库(使用 UnitOfWork)
async with get_uow() as session:
m = await session.get(Match, match_id)
if m is None:
raise ValueError(f"match {match_id} not found")
# 根据是否有有效数据决定校验策略
from src.llm.validation import validate_agent_weights, validate_prediction_output
if has_valid_data:
# 有有效报告:严格校验终裁输出
try:
validated = validate_prediction_output(final)
except Exception as e:
raise RuntimeError(f"终裁输出校验失败: {e}")
agent_weights = validate_agent_weights(final.get("agent_weights"))
pred_status = "success"
model_name = aggregator_provider.model
else:
# 无有效报告:跳过严格校验,直接构造降级结果
validated = None # type: ignore
agent_weights = {}
pred_status = "degraded"
model_name = aggregator_model
pred = await _insert_or_find_by_fingerprint(
session,
values={
"match_id": match_id,
"provider": settings.LLM_PROVIDER,
"model": model_name,
"mode": "multi",
"run_type": "backtest" if backtest else "live",
"prompt_version": f"multi_{version}",
"prompt_hash": prompt_hash,
"system_prompt_hash": system_prompt_hash,
"temperature": 0.2,
"context_hash": context_hash,
"agent_ids": agent_ids,
"prompt_tokens": sum(r.prompt_tokens or 0 for r in reports) + agg_prompt_tokens,
"completion_tokens": sum(r.completion_tokens or 0 for r in reports) + agg_completion_tokens,
"latency_ms": latency_ms,
"pred_home_goals": validated.pred_home_goals if validated else None,
"pred_away_goals": validated.pred_away_goals if validated else None,
"alt_pred_home_goals": validated.alt_pred_home_goals if validated else None,
"alt_pred_away_goals": validated.alt_pred_away_goals if validated else None,
"pred_1x2": validated.pred_1x2 if validated else None,
"subjective_confidence": validated.subjective_confidence if validated else None,
"reasoning": validated.reasoning if validated else final.get("reasoning", ""),
"raw_response": final,
"agent_outputs": [r.to_dict() for r in reports],
"agent_weights": agent_weights,
"status": pred_status,
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
},
)
logger.info(
"预测完成 match=%s mode=%s status=%s pred=%s:%s (%s) latency=%sms, experts=%d/%d, prediction_id=%s",
match_id, "multi", pred_status,
pred.pred_home_goals, pred.pred_away_goals, pred.pred_1x2,
latency_ms, len(ok_reports), len(reports), pred.id,
)
return MultiPredictResult(
prediction_id=pred.id,
provider=pred.provider,
model=pred.model,
prompt_version=pred.prompt_version,
mode="multi",
pred_home_goals=pred.pred_home_goals,
pred_away_goals=pred.pred_away_goals,
alt_pred_home_goals=pred.alt_pred_home_goals,
alt_pred_away_goals=pred.alt_pred_away_goals,
pred_1x2=pred.pred_1x2,
subjective_confidence=pred.subjective_confidence,
reasoning=pred.reasoning,
status=pred_status,
agent_outputs=pred.agent_outputs,
agent_weights=agent_weights,
context=_reports_to_json(reports),
latency_ms=latency_ms,
prompt_tokens=pred.prompt_tokens,
completion_tokens=pred.completion_tokens,
raw=final,
)