Files
meida_backend/packages/llm/routing.py
T

118 lines
4.2 KiB
Python
Executable File

"""Scene-based routing table (架构文档 §8.1 场景化路由).
``scene → (provider, model, temperature, max_tokens, timeout)`` is configuration,
so switching models never requires a code change (NFR-MAINT-05). Defaults live
here; ``LLM_SCENE_OVERRIDES_JSON`` overrides any field per scene at deploy time.
Token budgets follow 架构文档 §7.2: 输入合计 ≤ 4200, 输出 ≤ 800.
``thinking`` 在每个场景都显式给值。deepseek-v4 默认开启思考模式,实测同一个单句
问答开启要 13.5s、关闭只要 1.8s,且思考 token 按输出价计费 —— 漏配就会同时踩中
「响应超时」和「成本翻倍」。要开某个场景的思考,改 ``LLM_SCENE_OVERRIDES_JSON``
即可,不必改代码。
"""
from __future__ import annotations
from typing import Any
from packages.llm.errors import SceneNotRoutedError
from packages.llm.types import Scene, SceneConfig
_DEFAULTS: dict[Scene, dict[str, Any]] = {
Scene.AGENT_CHAT: {
"temperature": 0.7,
"max_tokens": 800,
"timeout_seconds": 30.0,
"prompt_id": "agent_chat",
"prompt_version": "v1",
"critical": False,
# 对话要在 NFR-PERF-02b 的整轮 15s 内返回,思考模式做不到
"thinking": False,
},
Scene.INTENT_CLASSIFY: {
"temperature": 0.0,
"max_tokens": 128,
"timeout_seconds": 10.0,
"prompt_id": "intent_classify",
"prompt_version": "v1",
# 分类是短输出的判别任务,思考只会拖慢并多花钱
"thinking": False,
# Intent classification gates 越界拒答 (FR-AGT-02); degrading it silently
# would let out-of-scope questions through, so it is never budget-gated.
"critical": True,
},
Scene.PREFERENCE_EXTRACT: {
"temperature": 0.0,
"max_tokens": 256,
"timeout_seconds": 15.0,
"prompt_id": "preference_extract",
"prompt_version": "v1",
"critical": False,
"thinking": False,
},
Scene.REPORT_COPY: {
"temperature": 0.6,
"max_tokens": 3000,
"timeout_seconds": 90.0,
"prompt_id": "report_copy",
"prompt_version": "v1",
"critical": False,
# 报告文案是异步任务,延迟宽松,理论上最适合开思考;V1 先关,
# 等黄金集能量化"思考带来的质量提升"再按场景打开(改配置即可)。
"thinking": False,
},
Scene.FULL_REPORT_COPY: {
"temperature": 0.6,
# 深度诊断四大块(总评+章节+季型解析+4 场景+30 天计划)一次产出,
# 输出预算按 REPORT_COPY 翻倍;异步任务,延迟宽松。
"max_tokens": 6000,
"timeout_seconds": 180.0,
"prompt_id": "full_report_copy",
"prompt_version": "v1",
"critical": False,
"thinking": False,
},
}
def build_scene_configs(
*,
primary_provider: str,
backup_provider: str | None = None,
structured_max_retries: int = 2,
models: dict[str, str] | None = None,
overrides: dict[str, dict[str, Any]] | None = None,
) -> dict[Scene, SceneConfig]:
"""Materialise the routing table from settings."""
providers = tuple(p for p in (primary_provider, backup_provider) if p)
configs: dict[Scene, SceneConfig] = {}
for scene, defaults in _DEFAULTS.items():
merged: dict[str, Any] = {
**defaults,
"structured_max_retries": structured_max_retries,
**(overrides or {}).get(scene.value, {}),
}
configs[scene] = SceneConfig(
scene=scene,
providers=tuple(merged.pop("providers", providers)),
models=dict(merged.pop("models", models or {})),
**merged,
)
return configs
class SceneRouter:
def __init__(self, configs: dict[Scene, SceneConfig]) -> None:
self._configs = dict(configs)
def resolve(self, scene: Scene) -> SceneConfig:
config = self._configs.get(scene)
if config is None:
msg = f"no routing configured for scene {scene.value}"
raise SceneNotRoutedError(msg)
return config
def scenes(self) -> tuple[Scene, ...]:
return tuple(self._configs)