- 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 中相应引用改为留痕表述
8.4 KiB
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);CostCounterProtocol(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:已 catchDailyBudgetExceededError → 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)。你只管实现。
- 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 即可)。
- 新增 pydantic
- gateway.py:把
ensure_within_budget调用换成ensure_scene_allowed(scene=scene.value, critical=config.critical);构造函数若需传 tiers,加默认参数(deps 已传)。 - agent.py:新增
except SceneDegradedError as exc: raise AppError(ErrorCode.LLM_SCENE_DEGRADED) from exc。 - 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. 验收标准
- 四档降级可由配置驱动,
warn档无用户可感知变化; - 手动 override 可加严/解除(redis-cli 可操作,写清键名与取值到 budget.py docstring);
- 集成断言证明"reserve 之后熔断 → refund → 净扣减 0";
- §3 检查全绿(mypy 范围:
packages/llmstrict +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)。
偏差说明:<如有写在这里,无则写"无">