Files
meida/docs/开发任务包/T1-四档熔断降级.md
xsl e511b6a66f 收尾两批并行开发的文档回写与任务包
- Sprint 执行计划 v1.1:§1.1 补第一批交付实据;T9 SPIKE 结论回写 Sprint 1 表
  (T-P0-16 通过 2.8ms;T-P0-12 有条件;T-P0-10 原型 6/6 + 真实照片重验前置);
  Sprint 2 表标注 T-P2-01~06 交付;新增 §9 两批交付记录
- 开发任务拆解 v1.5:交付回写 + 新风险登记(RTMPose 域差异、BiRefNet 异步化)
- 新增 开发任务包/:三批多 agent 并行的协作规程与 14 份任务契约
- 新增 前置开通清单-云资源与资质.md
- 清理已用毕的评审报告(文档评审 2026-08-13、项目整体评审 2026-08-14),
  README 中相应引用改为留痕表述
2026-08-14 21:07:20 +08:00

8.4 KiB
Raw Permalink Blame History

T1 · 全局日成本四档熔断降级

对应任务:T-P1-25b | 需求:NFR-COST-02、架构 §13.3 | 分支:task/t1-tiered-budget | worktree:/home/xsl/meida_backend-t1 先读《README.md(协作规程)》,遵守全部纪律。

1. 目标

把现有"单一日预算阈值"(DailyBudgetGuard:到 100% 拒绝非 critical 场景)升级为架构 §13.3 的四档分级降级,并补上 T-P1-25b 的验收要求:被熔断拒绝的请求配额净扣减 = 0(须有集成断言)。

四档(阈值可配置,百分比相对 LLM_DAILY_BUDGET_CENTS):

档位 阈值 动作
tier 1 warn ≥60% 只告警(日志 + 指标),不影响任何请求
tier 2 degrade_secondary ≥80% 拒绝 blocked 列表中的场景(默认 hd_export、report_regen)
tier 3 degrade_generation ≥90% 追加拒绝(默认 hairstyle、tryon)
tier 4 block_all ≥100% 拒绝所有非 critical 场景,但 allow 名单内的场景放行(默认 agent_chat——架构原文"仅保留在途任务与纯文本对话")

规则:

  • critical 场景永远豁免(现有 SceneConfig.critical,如 INTENT_CLASSIFY),但照常计入成本;
  • 手动干预:Redis 键可强制指定当前档位(运营解除/加严),优先级高于成本水位;
  • 在途任务放行 = 熔断只拦网关新请求(现有行为已满足,不要在 worker 侧加检查)。

2. 已就绪的契约(协调提交已提供,直接用,禁止改动)

配置(packages/shared/config.py 已加字段 + .env.example 已示例)

llm_budget_tiers: list[dict[str, Any]]   # env: LLM_BUDGET_TIERS_JSON,**JSON 是一个列表**(无外层 key)

默认 JSON(元素结构:{"threshold": 0.6, "name": "warn", "blocked": []};blocked 为字符串列表,"*" 表示全部;allow 仅 block_all 档有意义):

{"tiers":[
 {"threshold":0.6,"name":"warn","blocked":[]},
 {"threshold":0.8,"name":"degrade_secondary","blocked":["hd_export","report_regen"]},
 {"threshold":0.9,"name":"degrade_generation","blocked":["hairstyle","tryon"]},
 {"threshold":1.0,"name":"block_all","blocked":["*"],"allow":["agent_chat"]}
]}

错误码(packages/shared/errors.py 已加)

  • LLM_SCENE_DEGRADED = 60006,meta:http 503,message「该功能今日暂时关闭」,hint「AI 服务今日已达服务上限,明天恢复」。

现有代码事实(先读这些文件再动手)

  • packages/llm/budget.py:DailyBudgetGuard(counter, *, daily_budget_cents, enabled, key_prefix, tz_offset_hours, clock);方法 current_key()(llm:cost:daily:{UTC+8日期})、spent_today()、ensure_within_budget(scene, critical)、add_cost(cost_cents);CostCounter Protocol(InMemoryCostCounter/RedisCostCounter)。
  • packages/llm/gateway.py:110:await self._budget.ensure_within_budget(scene=scene.value, critical=config.critical) —— 你要替换的就是这一处调用点。
  • packages/llm/errors.py:已有 DailyBudgetExceededError。
  • apps/api/routers/agent.py:38:已 catch DailyBudgetExceededError → LLM_DAILY_BUDGET_EXCEEDED。
  • packages/agent/service.py:reserve → commit → gateway 异常时先 refund 再 raise(配额净扣减=0 的机制已存在,你只需用集成测试证明它)。

3. 实现要点

全部改动限定在:packages/llm/budget.py、packages/llm/errors.py、packages/llm/gateway.py(仅调用点与构造参数)、apps/api/routers/agent.py(异常映射)、apps/api/deps.py 不许改——预算档配置从 Settings.llm_budget_tiers 读取的地方在哪?build_llm_gateway() 已在 deps.py(禁改)。因此:DailyBudgetGuard.__init__ 增加关键字参数 tiers: list[dict[str, Any]] | None = None(默认 None = 内置默认四档),deps.py 已在协调提交里改为传入 tiers=list(settings.llm_budget_tiers)。你只管实现。

  1. budget.py:
    • 新增 pydantic BaseModel:BudgetTier(threshold: float, name: str, blocked: list[str], allow: list[str] = [])(llm 层允许 pydantic);
    • DailyBudgetGuard 扩展:current_tier() -> int(0=未触发,1~4 如上)、tier_name()、blocked_scenes() -> frozenset[str] | "*";
    • 新方法 ensure_scene_allowed(*, scene: str, critical: bool = False) -> None:
      • critical → 直接返回;
      • 手动 override(Redis llm:budget:override,值为 "1"~"4",读不到/值非法则忽略)优先于水位;
      • 按档位判定:tier2/3 且 scene ∈ blocked → 抛 SceneDegradedError(scene, tier_name)(新异常,加到 errors.py);tier4 且 blocked=="*" 且 scene ∉ allow → 抛 DailyBudgetExceededError(保持 100% 档的既有语义与 60003 错误码不变——现有测试依赖它);
    • 保留 ensure_within_budget() 兼容(内部转调 ensure_scene_allowed),或直接替换调用点后删除——二选一,保持 gateway.py 只调一个方法;
    • 新增 set_override(tier: int) / clear_override()(写 Redis,TTL 到当日 UTC+8 24 点),供运营/演练用;
    • 指标:prometheus_client 的 Gauge("llm_budget_tier", ...) 与 Counter("llm_budget_blocked_total", ..., ["scene"])(参考 packages/llm/recording.py 里 Prometheus 的用法);档位上升时 logger.warning("budget_tier_raised", tier=..., spent=..., budget=...)(仅上升沿打一次,避免每请求刷日志——实例内记 last_tier 即可)。
  2. gateway.py:把 ensure_within_budget 调用换成 ensure_scene_allowed(scene=scene.value, critical=config.critical);构造函数若需传 tiers,加默认参数(deps 已传)。
  3. agent.py:新增 except SceneDegradedError as exc: raise AppError(ErrorCode.LLM_SCENE_DEGRADED) from exc。
  4. mypy strict:budget.py 在 packages/llm 内,全量标注。

4. 测试要求

单测(tests/unit/test_tiered_budget.py 新文件;现有 test_daily_budget.py 保持通过,允许因 API 扩展做增量修改):

  • 四档边界:59.9%/60%/79.9%/80%/89.9%/90%/99.9%/100% 时 current_tier() 正确;
  • tier2 拒绝 hd_export 放行 agent_chat;tier4 拒绝非 allow 非 critical;critical 全档豁免;
  • override 强制 tier4 / 清除后回落;
  • allow=["agent_chat"] 在 tier4 生效;
  • 计数用 InMemoryCostCounter + 注入 clock(参考现有测试)。

集成测试(编辑 tests/integration/test_vertical_slice.py,你是唯一允许编辑它的任务):

  • 用 monkeypatch.setenv 把 LLM_DAILY_BUDGET_CENTS 设为极小值(如 "1")、LLM_BUDGET_TIERS_JSON 把 block_all.blocked 设为 ["agent_chat"](即在本测试里连纯文本对话也拦),get_settings.cache_clear() 后 create_app;
  • 先用 Redis 客户端把当日成本键 INCRBYFLOAT 到预算以上(键格式照 DailyBudgetGuard.current_key() 计算);
  • POST /v1/agent/chat(FakeProvider,参考现有 fixture)→ 断言:响应 code 为 60003(或 60006,取决于你把 agent_chat 归到哪个异常——按上面设计应为 60003)、且 bl_ledger 中该用户当日 consume 流水净和为 0(reserved 后 refund)——这正是 T-P1-25b 的验收"被熔断拒绝的请求配额净扣减 = 0,须有集成断言覆盖 reserve 之后才触发熔断这条路径";
  • 测试结束 get_settings.cache_clear() 恢复,避免污染其它用例。

5. 禁改清单(通用禁改之外)

packages/agent/**、packages/billing/**、packages/identity/**、packages/analytics/**、packages/storage/**、apps/api/routers/{auth,quota,tasks,events,uploads}.py、apps/api/schemas.py。

6. 验收标准

  1. 四档降级可由配置驱动,warn 档无用户可感知变化;
  2. 手动 override 可加严/解除(redis-cli 可操作,写清键名与取值到 budget.py docstring);
  3. 集成断言证明"reserve 之后熔断 → refund → 净扣减 0";
  4. §3 检查全绿(mypy 范围:packages/llm strict + apps packages/shared packages/identity packages/billing packages/task packages/agent packages/compliance packages/analytics packages/storage)。

7. 提交信息

feat(llm): four-tier daily budget degradation with manual override

四档熔断(warn/secondary/generation/block_all)+ critical 豁免 + Redis 手动 override
+ Prometheus 指标 + 集成断言:被拒请求配额净扣减为 0(T-P1-25b)。

偏差说明:<如有写在这里,无则写"无">