池预算与熔断 + 前台回复优先保 #90

Merged
Yushu merged 3 commits from feature/82-budget-breaker into main 2026-07-28 08:16:57 +00:00
Member

Closes #82

每池独立预算上限(静态 config),超了即停;reply 池例外,永不停。

四个 grill 决策

  • 日预算而不是月:熔断器过了午夜自愈。月预算一旦在月中被一注异常流量烧光,剩下半个月那个能力完全不在线,而唯一恢复手段是改配置重启 —— 那种不可恢复的坑部署者可能很久才发现。代价是控不住月总额(30 天 × 日上限),明知的取舍。
  • 入口 + 包装器双层检查。入口那层让熔断的能力根本不开始这一轮(不留半截状态 —— 门控扣了沉默预算、Delta 缓冲已被 drain、拉取间隔已被标记消耗);包装器那层才保证 AC 的「确实不再发起调用」连不经入口的调用也覆盖住(工具循环第 N 轮、子智能体内部)。
  • 横切池熔断时降级而非报错embedding/multimodal 可能正在服务一次 reply 轮次,而 reply 不该因为别的池超预算而死掉 —— 让 PoolExhausted 冒泡等于让「用户说话它不应」通过侧门发生。这正是 AC 说的「可以降级」。
  • reply 池不做主动降级。AC 原话是「可以降级」(允许而非要求),而每条降级策略都是拿回复质量换钱、且「换多少」在有真实数据之前是拍脑袋 —— 本片交付后才第一次有那个数据。

BreakerState 为什么是五态

closed / open / protected(reply 超预算但不停)/ unlimited(没配)/ unpriced(配了上限没配定价,无从判断)。每一个都是部署者需要区分的不同处境:

  • protected 独立成状态是为了让「前台已经超预算了,只是我们没停它」可见 —— 渲染成 closed 等于把这件事藏起来。
  • unpricedfail-open(不熔断)+ 显式标出:那是配置错误,而「因为你没填价格所以我把这个能力关了」是最糟的应对,部署者会以为功能坏了。

熔断状态与花费一起进 /cost,不只写日志 —— 部署者来问「今天花了多少」的时刻,正是最该看见「某个能力已经停了」的时刻。

自查与两轴 review 后修掉的

自查:第一个 commit 只给三条 proactive 入口加了闸,而我声称的「不留半截状态」在另外三个池上没兑现 —— reflection 的 cycle 会先跑起来、处理掉前几个 chat 才抛(而 drain_delta先删库提交再调 LLM,这是数据丢失级的);delegate 会先 mark_pull_attempted 再失败;multimodal 会逐张学到中途才停。三处都补了入口闸。

规格轴抓到真 bugsearch_stickers 工具内部那次 embed 没有保护_run_tool_loop 全文零 except、反应式那条路是裸 create_task —— embedding 熔断 + 模型调一次这个工具 = 整条回复静默消失,直接违反本片唯一不可省略的约束。既有那条「其余五池全熔断」测试摸不到它(脚本只发消息)。

规格轴指出的无用 IO:reply 池的熔断读结论恒定(它永不 open),却发生在每一轮 complete() 之前 —— 正踩在「不增加前台回复延迟」这条红线上。已短路。

规格轴的建议采纳delegate 熔断时让模型看不见那个工具(复用 issue #61 灰度开关那条通路),而不是「应声『我看看啊』再回一条『没能查出结果』」—— 后者要花两次 reply 池的钱去说一件本来就做不到的事。

标准轴的工具痕迹清理:删掉 SEP_NEWLINE = chr(10)(我为绕开自己生成脚本的转义坑加的,而同 commit 里两处都在正常用 "\n".join(...),自我反驳;还没加 _ 前缀泄成了公共 API);修掉五个助手 docstring 的双倍行距;去掉 runtime_loop.py 里新加的 logger(core 一律不直接写日志,且熔断已由 guard 记过)。

结构BudgetEntry 挪到 breaker.py(记账 vs 治理的边界);两处「池名打错」判定收敛到 cost.unknown_pool_names;新增 decide_pools/cost 与逐次检查共用同一套接线;补 _STATE_LABELget_args 穷举守卫。

验证

1336 测试全绿。10 处变异逐条验过(reply 也熔断 / 边界 off-by-one / 没配预算当零 / 未配定价 fail-closed / 包装器闸摘掉 / 入口闸摘掉 / 召回不降级 / 贴纸检索不降级 / delegate 工具仍可见 / 熔断状态不进 /cost),各自只挂对应测试。

其中两处第一次没杀掉测试:包装器那道闸摘掉全绿(我的测试只走到入口那层,兜底层永不执行 —— 正是「上游已拦掉、兜底层测不到」的空转形状)、/cost 的熔断段摘掉也没被抓到。两条都已补强。另有一条降级测试第一版断言被自己污染(事件内容与用户消息用了同一个词),已改成不同词并加反向对照。

Closes #82 每池独立**日**预算上限(静态 config),超了即停;`reply` 池例外,永不停。 ## 四个 grill 决策 - **日预算而不是月**:熔断器过了午夜自愈。月预算一旦在月中被一注异常流量烧光,剩下半个月那个能力完全不在线,而唯一恢复手段是改配置重启 —— 那种不可恢复的坑部署者可能很久才发现。代价是控不住月总额(30 天 × 日上限),明知的取舍。 - **入口 + 包装器双层检查**。入口那层让熔断的能力**根本不开始**这一轮(不留半截状态 —— 门控扣了沉默预算、Delta 缓冲已被 drain、拉取间隔已被标记消耗);包装器那层才保证 AC 的「确实不再发起调用」连不经入口的调用也覆盖住(工具循环第 N 轮、子智能体内部)。 - **横切池熔断时降级而非报错**。`embedding`/`multimodal` 可能正在服务一次 reply 轮次,而 reply 不该因为别的池超预算而死掉 —— 让 `PoolExhausted` 冒泡等于让「用户说话它不应」通过侧门发生。这正是 AC 说的「可以降级」。 - **reply 池不做主动降级**。AC 原话是「**可以**降级」(允许而非要求),而每条降级策略都是拿回复质量换钱、且「换多少」在有真实数据之前是拍脑袋 —— 本片交付后才第一次有那个数据。 ## `BreakerState` 为什么是五态 `closed` / `open` / `protected`(reply 超预算但不停)/ `unlimited`(没配)/ `unpriced`(配了上限没配定价,无从判断)。每一个都是部署者需要区分的不同处境: - `protected` 独立成状态是为了让「前台已经超预算了,只是我们没停它」**可见** —— 渲染成 `closed` 等于把这件事藏起来。 - `unpriced` 时 **fail-open**(不熔断)+ 显式标出:那是配置错误,而「因为你没填价格所以我把这个能力关了」是最糟的应对,部署者会以为功能坏了。 熔断状态与花费一起进 `/cost`,不只写日志 —— 部署者来问「今天花了多少」的时刻,正是最该看见「某个能力已经停了」的时刻。 ## 自查与两轴 review 后修掉的 **自查**:第一个 commit 只给三条 proactive 入口加了闸,而我声称的「不留半截状态」在另外三个池上没兑现 —— `reflection` 的 cycle 会先跑起来、处理掉前几个 chat 才抛(而 `drain_delta` 是**先删库提交**再调 LLM,这是数据丢失级的);`delegate` 会先 `mark_pull_attempted` 再失败;`multimodal` 会逐张学到中途才停。三处都补了入口闸。 **规格轴抓到真 bug**:`search_stickers` 工具内部那次 embed **没有保护**。`_run_tool_loop` 全文零 `except`、反应式那条路是裸 `create_task` —— embedding 熔断 + 模型调一次这个工具 = **整条回复静默消失**,直接违反本片唯一不可省略的约束。既有那条「其余五池全熔断」测试摸不到它(脚本只发消息)。 **规格轴指出的无用 IO**:reply 池的熔断读结论恒定(它永不 open),却发生在每一轮 `complete()` 之前 —— 正踩在「不增加前台回复延迟」这条红线上。已短路。 **规格轴的建议采纳**:`delegate` 熔断时**让模型看不见那个工具**(复用 issue #61 灰度开关那条通路),而不是「应声『我看看啊』再回一条『没能查出结果』」—— 后者要花两次 reply 池的钱去说一件本来就做不到的事。 **标准轴的工具痕迹清理**:删掉 `SEP_NEWLINE = chr(10)`(我为绕开自己生成脚本的转义坑加的,而同 commit 里两处都在正常用 `"\n".join(...)`,自我反驳;还没加 `_` 前缀泄成了公共 API);修掉五个助手 docstring 的双倍行距;去掉 `runtime_loop.py` 里新加的 logger(core 一律不直接写日志,且熔断已由 guard 记过)。 **结构**:`BudgetEntry` 挪到 `breaker.py`(记账 vs 治理的边界);两处「池名打错」判定收敛到 `cost.unknown_pool_names`;新增 `decide_pools` 让 `/cost` 与逐次检查共用同一套接线;补 `_STATE_LABEL` 的 `get_args` 穷举守卫。 ## 验证 1336 测试全绿。10 处变异逐条验过(reply 也熔断 / 边界 off-by-one / 没配预算当零 / 未配定价 fail-closed / 包装器闸摘掉 / 入口闸摘掉 / 召回不降级 / 贴纸检索不降级 / delegate 工具仍可见 / 熔断状态不进 /cost),各自只挂对应测试。 其中**两处第一次没杀掉测试**:包装器那道闸摘掉全绿(我的测试只走到入口那层,兜底层永不执行 —— 正是「上游已拦掉、兜底层测不到」的空转形状)、`/cost` 的熔断段摘掉也没被抓到。两条都已补强。另有一条降级测试第一版断言被自己污染(事件内容与用户消息用了同一个词),已改成不同词并加反向对照。
每池独立**日**预算上限(静态 config),超了即停;reply 池例外,永不停。

四个 grill 决策:
- **日预算**而不是月:熔断器过了午夜自愈。月预算一旦在月中被一注异常流量烧光,
  剩下半个月那个能力完全不在线,而唯一恢复手段是改配置重启 —— 那种不可恢复的坑
  部署者可能很久才发现。代价是控不住月总额,明知的取舍。
- **入口 + 包装器双层检查**。入口那层让熔断的能力**根本不开始**这一轮(不留半截
  状态:门控扣了预算、工具调了一半却没有回应);包装器那层才保证 AC 的「确实不再
  发起调用」连不经入口的调用也覆盖住(工具循环第 N 轮、子智能体内部)。
- **横切池熔断时降级而非报错**。embedding/multimodal 可能正在服务一次 reply 轮次,
  而 reply 不该因为别的池超预算而死掉 —— 让 `PoolExhausted` 冒泡等于让「用户说话
  它不应」通过侧门发生。召回捕获后返回空(这轮没有记忆注入),多模态那条**既有的**
  `gather(return_exceptions=True)` 兜底已经天然退回占位文本。这正是 AC 说的「可以降级」。
- **reply 池不做主动降级**。AC 原话是「**可以**降级」(允许而非要求),而每一条降级
  策略都是在拿回复质量换钱、且「换多少」在有真实数据之前就是拍脑袋 —— 本片交付后
  才第一次有那个数据。

`BreakerState` 有五个取值而不是一个布尔:`closed`/`open`/`protected`(reply 超预算但
不停)/`unlimited`(没配)/`unpriced`(配了上限没配定价,无从判断)。每一个都是部署者
需要区分的不同处境。`protected` 独立成状态是为了让「前台已经超预算了,只是我们没停它」
可见 —— 渲染成 closed 等于把这件事藏起来。

没配定价却配了预算时 **fail-open**(不熔断)+ 显式标出:那是配置错误,而「因为你没填
价格所以我把这个能力关了」是最糟的应对,部署者会以为功能坏了。

熔断状态与花费一起进 `/cost`,不只写日志 —— 部署者来问「今天花了多少」的时刻,正是
最该看见「某个能力已经停了」的时刻。

8 处变异逐条验过。其中**两处第一次没杀掉测试**,据此补强:
- 把包装器那道闸整个摘掉全绿 —— 我的测试只走到了入口那层,兜底层永不执行
  (正是「上游已拦掉、兜底层测不到」的空转形状)。补了包装器闸的单测。
- 熔断状态从 `/cost` 里摘掉也没被抓到 —— 补了对应断言。

另外那条降级测试第一版断言被自己污染:事件内容与用户消息用了同一个词,`not in prompt`
恒不成立。改成两个不同的词,并加了反向对照(embedding 健康时那条记忆**确实**被召回)。

1330 测试全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
自查发现:第一个 commit 只给三条 proactive 入口加了闸,而我在 commit message 里
声称的「入口那层让熔断的能力**根本不开始**这一轮、不留半截状态」在另外三个池上
没兑现 —— 它们只有包装器那一层:

- `reflection`(Delta 压缩 + 离线反思两个定时任务):cycle 会先跑起来、按 chat
  循环处理掉前几个,再在某次 complete() 上抛 —— 那正是"留下半截状态"。
- `delegate`(跨平台拉取):会先 `mark_pull_attempted` 再失败,白白消耗掉这个
  用户的拉取间隔。
- `multimodal`(贴纸识别):会逐张学到中途才停,半批结果没有意义。

三处都补了入口闸,并补两条测试(含对照组 —— 否则「没调 LLM」可能只是因为 Delta
缓冲压根是空的)。变异验证:摘掉 reflection 那两处闸会挂掉对应测试。

`delegate_task` 工具路径**刻意仍然只有包装器那层**:用户已经收到过"我看看啊"的
应声,子智能体失败后会写一条"没能查出结果"的 Callback 送回(ADR-0020 要求给个
结果)。让它中途失败并如实告知,比在入口拒绝后什么都不说更符合履约语义。

1332 测试全绿。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
真 bug(规格轴):`search_stickers` 工具内部那次 embed **没有保护**。`_run_tool_loop`
全文零 except、反应式那条路是裸 create_task —— embedding 熔断 + 模型调一次这个工具 =
**整条回复静默消失**,直接违反本片唯一不可省略的约束。既有那条「其余五池全熔断」测试
摸不到它:脚本只发消息、没调这个工具。改成降级成一条普通工具结果(形状同工具港既有的
失败处理),并补了会真调该工具的测试。

性能(规格轴):reply 池的熔断读是**恒定无用 IO** —— `breaker_decision` 保证它永不
open,而那次 `sum_pool_usage` 发生在每一轮 complete() 之前,正踩在「不增加前台回复
延迟」这条红线上。guard 里对 reply 直接放行。

行为改进(规格轴建议):`delegate` 池熔断时**让模型看不见那个工具**,复用
`delegate_enabled`(issue #61 灰度开关)那条既有通路。比「接受调用、应声『我看看啊』、
再回一条『没能查出结果』」好 —— 后者要花两次 reply 池的钱去说一件本来就做不到的事。

工具痕迹清理(标准轴):
- 删掉 `SEP_NEWLINE = chr(10)`。它是我为了绕开自己生成脚本的转义坑加的,而同一个
  commit 里 `breaker.py`/`cost.py` 都在正常用 `"\n".join(...)` —— 自我反驳。而且它没有
  `_` 前缀、泄成了公共 API,还插在别的注释与变量之间让那段注释挂错了符号。
- 修掉那五个助手 docstring 的双倍行距(同一生成脚本的痕迹,#81 起就在扩散)。
- 去掉 `runtime_loop.py` 新加的 logger:core 一律不直接写日志(对外报告走窄回调),
  而熔断本身已由 `_breaker_guard` 在抛出前记过一条。

结构(标准轴):`BudgetEntry` 从 `cost.py` 挪到 `breaker.py`(记账 vs 治理的边界,它
只被治理侧用);`misconfigured_budget_pool_names` 与定价表那条收敛到
`cost.unknown_pool_names`(同一条判定规则);新增 `breaker.decide_pools` 让 `/cost`
与逐次检查共用同一套接线(改周期只改一处);预算表打错池名的告警收进渲染器(与定价
表那条对称);补 `_STATE_LABEL` 的 `get_args` 穷举守卫(加第六个状态忘了给标签会在
运行时 KeyError);那处 `pytest.MonkeyPatch()` 改用 fixture。

10 处变异全部重跑并杀掉对应测试。1336 全绿。

过程记录:这轮变异脚本被我自己的 `\n` 转义弄成语法错误,8 条全报「锚点失效」——
因为脚本先打印失败再退出,没有产生假结论。修好后逐条重验。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Yushu merged commit 1db6da39bb into main 2026-07-28 08:16:57 +00:00
Yushu deleted branch feature/82-budget-breaker 2026-07-28 08:16:57 +00:00
Sign in to join this conversation.
No description provided.