面试知识库

增强 F · FinOps 预算闸(token 从「测」到「控」)—— 开发文档(面试向)#

这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 对应 docs/BACKEND_ENHANCEMENT.md 的 F 块(第二波 P1,单机定稿部分)。

一句话概括#

给单次任务(一棵 fork 树)设一个美元成本上限:每次模型调用一返回,就把它的 token 换算成成本、 累进全树计数;累计越过硬线后,由 middleware 把「成本放大器」工具(fork / 检索 / 品类洞察)拦下, 逼 Agent 用现有候选走收尾链结束——FinOps 不是事后看账单,而是事前设闸

⚠️ 全文口径更正(与当前代码一致):本文多处写「把工具从模型可见工具表里摘掉」,是早期做法。 后来为了 M6.1 前缀缓存治理 统一改成执行层哨兵、不再摘工具—— 工具表一变就打断 prompt cache 前缀。现在撞硬线时是「工具照常在表里,但执行前被拦、回一条哨兵」。 下文所有「摘工具 / 从工具表拿掉」请按执行层哨兵拦截理解。这个「摘工具省解码 vs 破前缀缓存」的 取舍详见 Mperf.1

打个比方:原来只有一块「跑完才看读数」的电表(usage.py),现在加了个「超了就跳闸」的闸刀。


1. 背景:token「测了,但没控」#

项目原来有 usage.py:一轮跑完,从 messages 把 token 用量聚合出来(carried / peak / cache 命中率) 发 Langfuse + 日志。但它是事后测量——只「看」,不「拦」。Agent 是天生的成本放大器:fork 出 N 个子 Agent、每子多轮工具链、每轮重发压缩后的历史,token 是乘法累积的。一个跑飞的任务,等你 从 Langfuse 看到账单时,钱已经花完了。

F 块就是把这条腿从「测」补成「控」:给成本设一个硬上限,超了当场跳闸。


2. 方案:复用「全树聚合」那套机制,从「次数闸」长出「成本闸」#

这块最值得讲的是:它不是从零造的,而是把已有的 retrieval_budget(检索次数闸)那套机制原样 复制到 token 成本维度上。两者同构:

  • 同一套「按 session_dir 聚合的全树计数」。为什么不能只盯单次调用?因为成本是全树乘法累积的。 主 loop 和所有 fork 出去的子 Agent,靠 thread_scope 让子继承父 session_dir,于是大家按同一个 key 往同一个计数器里累加——这样「子任务们合起来烧了多少」才数得到。这正是 retrieval_budget 已经 验证过的模式(堵 fork 口、动机会从主 loop 直调漏出来,所以打在「总量」而非「机制」上)。
  • 同一套「耗尽即夺权」的执行手法。不靠模型自觉遵守预算(弱模型不听 prompt 软提示)。越硬线后, 请求模型前直接把成本放大器工具从模型可见工具表里摘掉——模型根本看不到、不会再调,被迫用已有 候选收尾。这与 fork 安全四层、检索次数闸是同一套「机制兜底优于提示词」哲学

为什么摘的是「成本放大器」而不是全部工具? 摘掉 fork / item_search / web_search / category_insight 这几个会派生更多模型调用与外呼的口子,但保留便宜的收尾链(price_compare → shipping_calc → item_picker → shopping_summary)。目的是让任务**「花得起地」体面结束**——基于已收敛的 候选给出清单,而不是硬停、把已经花钱搜到的候选全丢掉。


3. 成本怎么算#

价格表按「每百万 token」记三档:input / output / cache_read(命中缓存的历史前缀享折扣)。计费时把 input 里命中缓存的部分单独按折扣价算,没命中的按正常价,加上 output——这样 M6 cache-breakpoint 省下 的钱能如实反映到成本里(命中越多越便宜,和真实账单口径一致)。价格与预算上限全走 env,代码不 写死费率;默认取 DeepSeek-V3 量级的便宜档,真实费率以 provider 账单为准。

诚实标注:成本准不准,取决于价格档配得对不对。默认档若与真实模型差很多,闸的位置就会偏——这是 配置项,不是代码 bug,.env.example 里写清了怎么调。


4. 三块联动 + 几个把细节做对的地方(含 review 抓到的)#

  • 进了 A 块的监控。每个任务收尾把该任务的全树成本加进 Prometheus counter(shoppingx_llm_cost_usd_total), 并按 ok / soft / hard 记一次预算档位(shoppingx_budget_outcome_total)——/metrics 上能直接看「总开销 / 均值 / 多少比例任务撞到预算闸」。FinOps 的数据就这样可看板化了。
  • soft 档是观测信号,不是循环内提示(诚实区分)。预算分软线(80%)/ 硬线(100%)。但与 retrieval_budget 不同:本块 soft 只进 metric / 日志,不在循环里插「快收尾」提示。原因是成本是 事后才测得的——软线那一刻,当轮昂贵调用早已发生,临时插句提示意义有限。真正的执行落在 hard (夺权摘工具)。文档里把这点写明,免得后人误以为 soft 会 nudge。
  • 计费绝不反噬主链路(review 抓到的 must-fix)。记账是附属品,绝不能因为它把模型调用搞挂。所以 计费的整段都用 try/except 兜住:万一某次 provider 返回畸形的 usage_metadata(token 是非数字串、 字段类型不对)或非常规的 response 形状,记账静默跳过、记一条 debug 日志,模型调用照常返回。
  • 收尾在 finally 记账 + 清理。把成本归集进 metrics、清掉模块级 dict 条目,都放在 finally—— 这样取消 / 超时的任务也照样记账、照样回收内存,绝不漏账、不泄漏。档位只读一次,metric 标签与日志 共用同一个值(review 抓到的:原来读了两次,理论上可能不一致)。
  • 子 Agent 不清树。和 retrieval_budget 同口径:只有主 loop 的 run_agent 收尾才 reset_tree, 子 Agent(dispatch_tool)绝不清——否则会把父的全树账目当场抹掉。

5. 后续增强:token 用量前端可见#

F 块原本只做「后端闸控」——闸的效果是模型侧看不到被摘掉的工具,前端只能间接推测「为什么突然收尾了」。后续增强把全树 token 用量推到前端

  • task_result 事件新增 tokens 字段(input / output / total / cost_usd),是全树记账口径(主 + 各 fork 子 Agent,与 F 块计费共用同一份 tree_snapshot())。
  • 前端在每轮右下角与「用时」并排显示 total tokens,hover 看输入/输出/成本拆分。
  • 同一份数据也落进 turns.json(历史回看一致)。

这样 FinOps 的闭环就不仅是「后台跳闸」,用户也能直观感知每一轮消耗了多少 token / 花了多少钱——从「花完才知道」变成「花着就知道」。


6. 范围与诚实边界#

  • 跨 provider fallback 留毕业线llm.py 现在是单一 OpenAI 兼容 endpoint(一家 key)。所谓 「某家挂了切另一家」前提是至少配两家——现状没有第二家,硬写就是叙事倒地。所以本块本体是成本 治理(归集 + 预算闸),fallback 等真有第二家 provider 再点亮。
  • 模型路由已是雏形,不在本块重做get_llm / get_fast_llm(同模型关推理省解码)/ get_judge_llm 已经是「按任务难度匹配模型档」的落地,本块不重复造。
  • 硬线是安全backstop,不是日常调度。默认 0.50 美元对一次购物任务很宽裕,正常跑不到。它存在的 意义是兜住「跑飞」的极端情况,而非精细调度每一分钱。撞线时的「突然摘工具」是可接受的——硬上限的 语义本就是「到此为止,别再花了」。

7. 一句话面试总结#

F 块体现的是**「复用已验证的机制,把一个维度的控制长到另一个维度」**:检索次数闸已经跑通了 「按 session_dir 全树聚合 + 耗尽夺权」这套,token 成本闸直接复用同一套骨架,只换了「计什么」 (次数→美元)和「摘什么」(检索工具→成本放大器)。配合 M6 cache 折扣如实计费、A 块 metrics 把成本 可看板化、try/except 保证记账不反噬主链路——把 token 从「事后看账单」升级成「事前会跳闸」,且几乎 没引新依赖。