"""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, }, } 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)