Files
meida_backend/packages/llm
xsl a4d59aca30 feat(llm): four-tier daily budget degradation with manual override
把单一日预算熔断升级为架构 §13.3 的四档分级降级(T-P1-25b / NFR-COST-02):

- budget.py:新增 BudgetTier 配置模型与档位判定(current_tier/tier_name/
  blocked_scenes)。阈值相对 LLM_DAILY_BUDGET_CENTS:warn ≥60% 只告警;
  degrade_secondary ≥80% 拒 hd_export/report_regen;degrade_generation ≥90%
  追加拒 hairstyle/tryon;block_all ≥100% 拒全部非 critical、allow 名单
  (默认 agent_chat)放行。高档位 blocked 取「截至当前档的并集」(架构原文
  「追加拒绝」)。critical 场景全档豁免但照常计成本。
- 手动 override:Redis 键 llm:budget:override(值 "1"~"4"),优先于成本水
  位,读不到/非法则忽略;set_override TTL 到当日 UTC+8 24 点,跨预算日必然
  失效。CostCounter 协议相应增加 set/delete。
- 指标:Gauge llm_budget_tier + Counter llm_budget_blocked_total{scene}
  挂到共享 REGISTRY 随 /metrics 暴露;档位上升沿只告警一次
  (budget_tier_raised,实例内记 last_tier)。
- errors.py 新增 SceneDegradedError(tier2/3 拒绝);gateway.py 调用点换成
  ensure_scene_allowed(保留 ensure_within_budget 兼容入口);agent.py 映射
  LLM_SCENE_DEGRADED(60006);100% 档维持 DailyBudgetExceededError/60003 语义。
- 单测:新增 tests/unit/test_tiered_budget.py(27 例:四档边界、各档放行/
  拒绝、critical 豁免、override 加严/放宽/非法值/TTL、配置驱动)。
- 集成:tests/integration/test_vertical_slice.py 新增用例——预算 1 分 +
  block_all allow 清空 + Redis 注入当日成本,POST /agent/chat 返回 60003,
  断言 bl_ledger 中 consume(refunded,-1)+refund(committed,+1) 净和为 0,
  覆盖「reserve 之后才熔断 → refund」路径;fixture 重构为共享
  FakeProvider/fake_client,断言语义未弱化。

门禁:ruff check/format、mypy(packages/llm strict + 其余包)、lint-imports、
单测 196 passed、集成 4 passed。

偏差说明:
1. tests/unit/test_gateway.py 的 test_daily_budget_rejects_non_critical_
   scenes_before_spending 把被测场景 AGENT_CHAT 换成 REPORT_COPY(test_daily_
   budget.py 同用例换 tryon):block_all 档默认 allow=["agent_chat"](架构
   「仅保留纯文本对话」)后 agent_chat 在 100% 档不再被拒,原场景选择与新
   语义冲突;断言本身(进 provider 前被拒)未弱化。
2. 集成测试的 block_all 档配置为 blocked="*"+allow=[](文档示例为
   blocked=["agent_chat"]):后者按档位规则会走 SceneDegradedError/60006,
   前者才能按设计走既有 60003 语义(文档允许二者取其一且"按上面设计应为
   60003")。
2026-08-14 16:27:21 +08:00
..

packages/llm/ — LLM 网关

架构 §4.3、§8.1。V1 是进程内包,V2 拆成独立服务。 因此接口必须按远程调用来设计:无共享内存假设、明确超时、可序列化的入参出参。

唯一入口

所有 LLM 调用都走 LLMGateway.complete()。业务代码不许自己 httpx.post 到任何 模型供应商 —— 那样会绕过成本归因、预算闸门、故障转移和 Prompt 版本记录,而这四件事 一旦有缺口就补不回来(历史数据是补不出来的)。

已实现

能力 位置
场景化路由(scene → provider/model/温度/超时/预算,全配置化) routing.py
故障转移 + 熔断(超时/5xx/限流切备用;连续失败熔断,冷却后半开探测) gateway.py circuit.py
结构化输出(Pydantic Schema 约束 + 校验失败重试) gateway.py
Prompt 版本(文件化,按 id+version 加载,版本随每次调用落库) prompts.py /prompts
缓存前缀纪律(固定前缀含时间戳/随机 ID/昵称 → 拒绝或告警) prompts.py
成本归因(每次 provider 调用写一行 ob_ai_call,含缓存命中 token) cost.py recording.py
全局日成本硬熔断(超预算即拒绝非关键场景,不是只告警) budget.py

未实现(按 §8.1 还差)

  • 流式输出 SSE(FR-AGT-01 要求首字 p95 < 2s,非流式做不到)
  • 审核联动:输入输出审核应在网关内统一完成,业务方无需关心(CR-06)
  • 用户级频控:目前只有平台级日预算与业务侧配额
  • A/B 分流:按用户哈希选择 prompt 版本
  • 智谱 GLM provider:仅结构占位,见 providers/zhipu.py

加一个新场景

  1. 在 types.Scene 加枚举值。
  2. 在 routing._DEFAULTS 加默认配置,想清楚 critical 该不该为真 —— critical=True 意味着日预算耗尽时它仍然会花钱。
  3. 在 prompts/<scene>/v1.txt 写模板。[system] 段不许有任何变量。
  4. 跑 pytest tests/unit/test_prompt_cache_prefix.py,它会扫描仓库里全部 prompt 的固定前缀。

加一个新 provider

  1. 实现 providers/base.LLMProvider 协议(name / default_model / complete / aclose)。
  2. HTTP 错误必须映射到 errors.py 的异常族,retryable 决定要不要切备用。 映射错了会导致「明明该切备用却没切」或者「明明是我们请求写错了却把备用也打一遍」。
  3. usage 字段必须映射出 缓存命中 token 数,否则 NFR-COST-03 的缓存命中率 看板是空的。
  4. 在 apps/api/deps.build_llm_gateway() 注册。

费率

RateTable 来自配置(LLM_COST_RATES_JSON),不是常量。未知模型走 default 费率 而不是 0 —— 成本上的盲区比不精确更危险。上线前必须用供应商实际价目表校准。