feat: 核心模块增强 — 加密 + 运行时配置 + 日志缓冲

- crypto.py: API Key 加密/解密工具
- runtime_config.py: 运行时动态配置管理
- log_buffer.py: 内存日志缓冲区
- config.py: 新增加密配置项
- http_client.py: 增强重试和错误处理
This commit is contained in:
shangfangjian
2026-09-19 11:58:03 +08:00
parent b3e2c52b49
commit 786f10aa11
57 changed files with 3178 additions and 488 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ async def run_agent(
user=user_prompt,
json_mode=True,
temperature=0.2,
max_tokens=600,
max_tokens=4096, # 推理模型需要更大余量
)
if resp.error:
logger.warning("agent %s LLM failed: %s", spec.name, resp.error)
+75 -41
View File
@@ -13,6 +13,7 @@ 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 _upsert_prediction
from src.llm.agents.base import AgentReport, AgentSpec, load_agent_prompt
from src.llm.context_builder import (
MatchHeader,
@@ -24,6 +25,7 @@ from src.llm.context_builder import (
load_match_header,
stats_slice,
)
from src.core.runtime_config import get_runtime_value
from src.llm.provider import LLMProvider, get_default_provider
logger = logging.getLogger(__name__)
@@ -58,7 +60,20 @@ SPECIALIST_SPECS: list[AgentSpec] = [
),
]
AGGREGATOR_SYSTEM = "你是足球预测终裁专家。综合各领域报告输出最终预测。只输出 JSON。"
AGGREGATOR_SYSTEM = (
"你是足球预测终裁专家。综合各领域专家报告输出最终预测。"
"引用专家时必须使用报告中的专家全名(如「攻防数据分析专家」),禁止使用英文代码。"
"只输出 JSON。"
)
# 专家代码 → 终裁/展示统一称呼
AGENT_LABELS_ZH: dict[str, str] = {
"form": "近期状态分析专家",
"stats": "攻防数据分析专家",
"home_away": "主客因素分析专家",
"injuries": "阵容完整性分析专家",
"h2h": "历史交锋分析专家",
}
@dataclass
@@ -70,6 +85,8 @@ class MultiPredictResult:
mode: str
pred_home_goals: float | None
pred_away_goals: float | None
alt_pred_home_goals: int | None
alt_pred_away_goals: int | None
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
@@ -80,31 +97,38 @@ class MultiPredictResult:
raw: dict | None
def _get_specialist_provider() -> LLMProvider:
"""专家模型: LLM_SPECIALIST_MODEL 回落 LLM_MODEL。"""
p = get_default_provider()
if settings.LLM_SPECIALIST_MODEL:
p.model = settings.LLM_SPECIALIST_MODEL
return p
async def _agent_provider(agent_id: str, *, tier: str) -> LLMProvider:
"""构造某 agent 专属 provider。
def _get_aggregator_provider() -> LLMProvider:
"""终裁模型: LLM_AGGREGATOR_MODEL 回落 LLM_MODEL。"""
p = get_default_provider()
if settings.LLM_AGGREGATOR_MODEL:
p.model = settings.LLM_AGGREGATOR_MODEL
覆盖优先级:
模型: AGENT_MODEL_{ID}(运行时) → 层级默认(LLM_SPECIALIST/AGGREGATOR_MODEL) → 全局 LLM_MODEL
地址/密钥: AGENT_BASE_URL_{ID} / AGENT_API_KEY_{ID}(运行时) → 全局 LLM_BASE_URL / LLM_API_KEY
"""
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
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
return p
async def run_specialists(
header: MatchHeader,
*,
provider: LLMProvider,
version: str = "v1",
) -> list[AgentReport]:
"""并行执行 5 个专家 agent。fail-open: 单个失败不影响其他。"""
tasks = [
_run_one(spec, header, provider, version=version)
_run_one(spec, header, await _agent_provider(spec.name, tier="specialist"), version=version)
for spec in SPECIALIST_SPECS
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -125,7 +149,13 @@ async def _run_one(spec, header, provider, *, version) -> AgentReport:
def _reports_to_json(reports: list[AgentReport]) -> str:
return json.dumps([r.to_dict() for r in reports], ensure_ascii=False, indent=1)
"""报告序列化: 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(
@@ -147,7 +177,7 @@ async def run_aggregator(
user=user_prompt,
json_mode=True,
temperature=0.2,
max_tokens=1000,
max_tokens=4096, # 推理模型需要更大余量
)
if resp.error:
raise RuntimeError(f"aggregator LLM error: {resp.error}")
@@ -171,12 +201,11 @@ async def predict_match_multi(
prediction_cutoff_at = header.match_dt # 默认:比赛时间作为数据截止
now = datetime.now(timezone.utc)
# 2. 并行专家
specialist_provider = _get_specialist_provider()
reports = await run_specialists(header, provider=specialist_provider, version=version)
# 2. 并行专家(各自独立配置)
reports = await run_specialists(header, version=version)
# 3. 终裁
aggregator_provider = _get_aggregator_provider()
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
)
@@ -203,30 +232,33 @@ async def predict_match_multi(
# agent_weights 同样必须过校验(旧实现直接取 raw 值落库,未做任何检查)
agent_weights = validate_agent_weights(final.get("agent_weights"))
pred = Prediction(
pred = await _upsert_prediction(
session,
match_id=match_id,
provider=settings.LLM_PROVIDER,
provider_name=settings.LLM_PROVIDER,
model=aggregator_provider.model,
prompt_version=f"multi_{version}",
mode="multi",
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,
pred_away_goals=validated.pred_away_goals,
pred_1x2=validated.pred_1x2,
subjective_confidence=validated.subjective_confidence,
reasoning=validated.reasoning,
raw_response=final,
agent_outputs=[r.to_dict() for r in reports],
status="success",
match_kickoff_at=match_kickoff_at,
prediction_cutoff_at=prediction_cutoff_at,
prediction_created_at=now,
input_hash=input_hash,
values={
"prompt_version": f"multi_{version}",
"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,
"pred_away_goals": validated.pred_away_goals,
"alt_pred_home_goals": validated.alt_pred_home_goals,
"alt_pred_away_goals": validated.alt_pred_away_goals,
"pred_1x2": validated.pred_1x2,
"subjective_confidence": validated.subjective_confidence,
"reasoning": validated.reasoning,
"raw_response": final,
"agent_outputs": [r.to_dict() for r in reports],
"status": "success",
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
)
session.add(pred)
await session.refresh(pred)
return MultiPredictResult(
prediction_id=pred.id,
@@ -236,6 +268,8 @@ async def predict_match_multi(
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,
+9
View File
@@ -20,6 +20,7 @@ from src.db.unit_of_work import get_uow
from src.llm.eval import settle_prediction
from src.llm.predict import predict_match
from src.llm.utils import actual_1x2
from src.data.team_names_zh import zh_name
logger = logging.getLogger(__name__)
@@ -31,6 +32,8 @@ class BacktestMatchResult:
league_code: str | None
home_team: str
away_team: str
home_team_zh: str | None
away_team_zh: str | None
match_date: str
actual_home: int
actual_away: int
@@ -54,6 +57,8 @@ class BacktestCandidate:
league_code: str | None
home_team: str
away_team: str
home_team_zh: str | None
away_team_zh: str | None
match_date: datetime
home_goals: int
away_goals: int
@@ -113,6 +118,8 @@ async def _get_historical_matches(
league_code=m.league.code if m.league else None,
home_team=m.home_team.name if m.home_team else "?",
away_team=m.away_team.name if m.away_team else "?",
home_team_zh=zh_name(m.home_team.name) if m.home_team else None,
away_team_zh=zh_name(m.away_team.name) if m.away_team else None,
match_date=m.match_date,
home_goals=m.home_goals,
away_goals=m.away_goals,
@@ -164,6 +171,8 @@ async def run_backtest(
league_code=c.league_code,
home_team=c.home_team,
away_team=c.away_team,
home_team_zh=c.home_team_zh,
away_team_zh=c.away_team_zh,
match_date=c.match_date.strftime("%Y-%m-%d") if c.match_date else "?",
actual_home=c.home_goals,
actual_away=c.away_goals,
+68 -21
View File
@@ -11,6 +11,8 @@ from pathlib import Path
from threading import Lock
from src.core.config import settings
from sqlalchemy import select
from src.db.base import AsyncSessionLocal
from src.db.models import Match, Prediction
from src.db.unit_of_work import get_uow
@@ -89,6 +91,8 @@ class PredictResult:
prompt_version: str
pred_home_goals: float | None
pred_away_goals: float | None
alt_pred_home_goals: int | None
alt_pred_away_goals: int | None
pred_1x2: str | None
subjective_confidence: float | None
reasoning: str | None
@@ -97,6 +101,43 @@ class PredictResult:
raw: dict | None
async def _upsert_prediction(
session,
*,
match_id: int,
provider_name: str,
model: str,
mode: str,
values: dict,
) -> Prediction:
"""按 (match, provider, model) 唯一约束写入预测。
已存在且未结算 → 覆盖更新(重新预测语义);已结算 → 拒绝(保护评估数据)。
"""
existing = (
await session.execute(
select(Prediction).where(
Prediction.match_id == match_id,
Prediction.provider == provider_name,
Prediction.model == model,
)
)
).scalar_one_or_none()
if existing is not None and existing.settled:
raise ValueError("该比赛已有已结算的预测,不能重新预测")
pred = existing if existing is not None else Prediction(
match_id=match_id, provider=provider_name, model=model,
)
pred.mode = mode
for k, v in values.items():
setattr(pred, k, v)
if existing is None:
session.add(pred)
await session.flush() # 拿到自增 id;事务由 UnitOfWork 退出时提交
return pred
async def predict_match(
match_id: int,
*,
@@ -140,7 +181,7 @@ async def _predict_single(
) -> PredictResult:
"""单次调用路径(原有实现)。"""
if provider is None:
provider = get_default_provider()
provider = await get_default_provider()
if model:
provider.model = model
version = prompt_version or "v1"
@@ -172,7 +213,7 @@ async def _predict_single(
user=user_prompt,
json_mode=True,
temperature=0.3,
max_tokens=800,
max_tokens=4096, # 推理模型的 reasoning 也计入输出 token,需留足余量
)
if resp.error:
@@ -194,28 +235,32 @@ async def _predict_single(
if m is None:
raise ValueError(f"match {match_id} not found")
pred = Prediction(
pred = await _upsert_prediction(
session,
match_id=match_id,
provider=settings.LLM_PROVIDER,
provider_name=settings.LLM_PROVIDER,
model=provider.model,
prompt_version=version,
prompt_tokens=resp.prompt_tokens,
completion_tokens=resp.completion_tokens,
latency_ms=resp.latency_ms,
pred_home_goals=validated.pred_home_goals,
pred_away_goals=validated.pred_away_goals,
pred_1x2=validated.pred_1x2,
subjective_confidence=validated.subjective_confidence,
reasoning=validated.reasoning,
raw_response=resp.raw,
status="success",
match_kickoff_at=match_kickoff_at,
prediction_cutoff_at=prediction_cutoff_at,
prediction_created_at=now,
input_hash=input_hash,
mode="single",
values={
"prompt_version": version,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms,
"pred_home_goals": validated.pred_home_goals,
"pred_away_goals": validated.pred_away_goals,
"alt_pred_home_goals": validated.alt_pred_home_goals,
"alt_pred_away_goals": validated.alt_pred_away_goals,
"pred_1x2": validated.pred_1x2,
"subjective_confidence": validated.subjective_confidence,
"reasoning": validated.reasoning,
"raw_response": resp.raw,
"status": "success",
"match_kickoff_at": match_kickoff_at,
"prediction_cutoff_at": prediction_cutoff_at,
"prediction_created_at": now,
"input_hash": input_hash,
},
)
session.add(pred)
await session.refresh(pred)
result = PredictResult(
prediction_id=pred.id,
@@ -224,6 +269,8 @@ async def _predict_single(
prompt_version=version,
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,
+6 -3
View File
@@ -9,7 +9,8 @@
裁决规则:
- 各报告的 confidence 和 data_sufficiency 是采信依据: no_data/error 状态的报告必须忽略,不得编造
- 5 专家维度: form(近期状态) / stats(攻防数据) / home_away(主客因素) / injuries(阵容完整性) / h2h(历史交锋)
- 5 专家: 近期状态分析专家 / 攻防数据分析专家 / 主客因素分析专家 / 阵容完整性分析专家 / 历史交锋分析专家
- 引用专家意见时使用上述全称,不要使用英文代码(form/stats/h2h 等)
- home_edge 是各专家的方向性判断(-1~1),冲突时给出你的权衡理由
- agent_weights 体现你对各报告的采信度(0-1,总和无须为 1)
- reasoning 需引用具体报告的证据
@@ -17,8 +18,10 @@
严格按此 JSON 输出,不要其他内容:
```json
{
"pred_home_goals": <float, >,
"pred_away_goals": <float, >,
"pred_home_goals": <int 0-10, ,>,
"pred_away_goals": <int 0-10, ,>,
"alt_pred_home_goals": <int 0-10, (,)>,
"alt_pred_away_goals": <int 0-10, >,
"1x2": "<'1'|'X'|'2'>",
"confidence": <0.0-1.0>,
"reasoning": "<250 字内推理,引用各报告证据>",
+4 -2
View File
@@ -5,8 +5,10 @@
严格按此 JSON 输出:
```json
{
"pred_home_goals": "<float, 预测主队进球>",
"pred_away_goals": "<float, 预测客队进球>",
"pred_home_goals": "<int 0-10, 预测主队进球,必须是整数>",
"pred_away_goals": "<int 0-10, 预测客队进球,必须是整数>",
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
"1x2": "<'1'|'X'|'2'>",
"confidence": "<0.0-1.0>",
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
+4 -2
View File
@@ -11,8 +11,10 @@
严格按此 JSON 输出,不要其他内容:
```json
{
"pred_home_goals": "<float, 预测主队进球>",
"pred_away_goals": "<float, 预测客队进球>",
"pred_home_goals": "<int 0-10, 预测主队进球,必须是整数>",
"pred_away_goals": "<int 0-10, 预测客队进球,必须是整数>",
"alt_pred_home_goals": "<int 0-10, 备选比分主队进球(第二可能的比分,必须与主选不同)>",
"alt_pred_away_goals": "<int 0-10, 备选比分客队进球>",
"1x2": "<'1'|'X'|'2'>",
"confidence": "<0.0-1.0>",
"score_probable": {"home": "<int>", "away": "<int>", "prob": "<float>"},
+19 -6
View File
@@ -10,8 +10,11 @@ import time
from dataclasses import dataclass, field
from typing import Any
import httpx
from src.core.config import settings
from src.core.http_client import get_client
from src.core.runtime_config import get_runtime_value
logger = logging.getLogger(__name__)
@@ -67,17 +70,26 @@ class LLMProvider:
start = time.perf_counter()
try:
client = get_client()
# 连接与读取分离:端点不可达时 10s 内快速失败,
# 避免每个 agent 各挂满 LLM_TIMEOUT 导致整次预测长时间无响应
resp = await client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
timeout=self.timeout,
timeout=httpx.Timeout(connect=10.0, read=float(self.timeout), write=float(self.timeout), pool=10.0),
)
resp.raise_for_status()
data = resp.json()
latency = int((time.perf_counter() - start) * 1000)
usage = data.get("usage", {})
content = data["choices"][0]["message"]["content"]
message = data["choices"][0]["message"]
content = message.get("content") or ""
if not content:
# 推理模型可能把 token 全花在 reasoning_content 上
raise RuntimeError(
"模型未返回文本内容"
+ ("(token 花在推理上,请增大 max_tokens)" if message.get("reasoning_content") else "")
)
parsed = None
if json_mode:
try:
@@ -105,10 +117,11 @@ class LLMProvider:
return LLMResponse(content="", error=str(e), latency_ms=latency)
def get_default_provider() -> LLMProvider:
async def get_default_provider() -> LLMProvider:
"""构造默认 provider:运行时配置(DB)优先,回落 .env。"""
return LLMProvider(
api_key=settings.LLM_API_KEY,
base_url=settings.LLM_BASE_URL,
model=settings.LLM_MODEL,
api_key=await get_runtime_value("LLM_API_KEY"),
base_url=await get_runtime_value("LLM_BASE_URL"),
model=await get_runtime_value("LLM_MODEL"),
timeout=settings.LLM_TIMEOUT,
)
+32 -4
View File
@@ -5,6 +5,7 @@
from __future__ import annotations
import logging
from decimal import ROUND_HALF_UP, Decimal
from pydantic import BaseModel, Field, field_validator, model_validator
@@ -54,12 +55,28 @@ class AgentReportSchema(BaseModel):
class PredictionOutputSchema(BaseModel):
"""最终预测输出的校验 schema。"""
pred_home_goals: float = Field(ge=0.0, le=10.0)
pred_away_goals: float = Field(ge=0.0, le=10.0)
pred_home_goals: int = Field(ge=0, le=10)
pred_away_goals: int = Field(ge=0, le=10)
# 备选比分(次可能比分);缺失/无效/与主选相同 → None
alt_pred_home_goals: int | None = Field(default=None, ge=0, le=10)
alt_pred_away_goals: int | None = Field(default=None, ge=0, le=10)
pred_1x2: str
subjective_confidence: float = Field(ge=0.0, le=1.0)
reasoning: str = ""
@model_validator(mode="after")
def check_alt_score(self) -> "PredictionOutputSchema":
"""备选比分与主选相同则丢弃(备选必须是不同比分)。"""
if (
self.alt_pred_home_goals is not None
and self.alt_pred_away_goals is not None
and self.alt_pred_home_goals == self.pred_home_goals
and self.alt_pred_away_goals == self.pred_away_goals
):
self.alt_pred_home_goals = None
self.alt_pred_away_goals = None
return self""
@field_validator("pred_1x2")
@classmethod
def validate_1x2(cls, v: str) -> str:
@@ -172,9 +189,20 @@ def validate_prediction_output(raw: dict) -> PredictionOutputSchema:
logger.warning("Deprecated field 'confidence' used, prefer 'subjective_confidence'")
conf = raw["confidence"]
def _alt(side: str):
v = raw.get(f"alt_pred_{side}_goals")
if v is None:
return None
try:
return int(Decimal(str(v)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
except Exception:
return None
return PredictionOutputSchema(
pred_home_goals=float(raw.get("pred_home_goals", 0)),
pred_away_goals=float(raw.get("pred_away_goals", 0)),
pred_home_goals=int(Decimal(str(raw.get("pred_home_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
pred_away_goals=int(Decimal(str(raw.get("pred_away_goals", 0))).quantize(Decimal("1"), rounding=ROUND_HALF_UP)),
alt_pred_home_goals=_alt("home"),
alt_pred_away_goals=_alt("away"),
pred_1x2=raw.get("1x2") or raw.get("pred_1x2", "X"),
subjective_confidence=float(conf if conf is not None else 0.5),
reasoning=str(raw.get("reasoning", ""))[:1000],