# 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` 已示例) ```python 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` 档有意义): ```json {"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)。 偏差说明:<如有写在这里,无则写"无"> ```