预算闸门Budget ceilings
先说问题The problem
真实事故:一个无人值守 agent 扫 DN42,把操作者的 AWS 账单刷成 6,531 美元。HN 最高赞的一句话是「不要用不能方便设置硬上限的服务」。你的 agent 有没有硬上限?超限是报错,还是继续?
A real incident: an unattended agent scanned DN42 and billed its operator $6,531 on AWS. The top HN line was 'never use a service without an easy to find and set hard cap'. Does your agent have one? And when it trips, does it fail or carry on?
先动手Do it first
先在页面上接线——页面只懂规则。然后跑下面那条判题,那才是真会失败的东西。Wire it on the page — the bench only knows the rules. Then run the check below; that is the part that can actually fail.
extra_agent_middlewares=
AgentMiddlewareFactory
选填optional
工厂按 (user, agent, session) 返回一组 MiddlewareBase,拦截的是回复循环本身:token 预算、链路追踪、模型路由。它不像工具那样能被模型绕开——因为它从不向模型征求许可。A factory that returns MiddlewareBase objects per (user, agent, session). It intercepts the reply loop itself: token budgets, tracing, routing. Unlike a tool it cannot be talked around, because it never asks the model for permission.
create_app(…)
为什么这样设计Why it is built this way
01预算是中间件,不是提示词A budget is a middleware, not a prompt
ReplyBudgetControlMiddleware 按加权 token 预算拦截每一步推理。写在 system prompt 里的「省着点用」不是闸门。
ReplyBudgetControlMiddleware enforces a weighted token budget by intercepting every reasoning step. 'Please be economical' in a system prompt is not a gate.
02计量要覆盖内部调用Metering must cover internal calls
压缩、路由这类内部模型调用也要计入。真实框架里就出现过压缩调用不记 token、批量合并把 usage 清零的情况。
Internal calls — compression, routing — must count too. Real frameworks have shipped where compression calls recorded no tokens and batch merging zeroed usage.
03上限怎么落地,要看代码不是看名字上限怎么落地,要看代码不是看名字
实测:达到上限时中间件往上下文注入一个 HintBlock,并把下一步的 tool_choice 强制成 "none"。它不挂起、不报错、不丢已完成的工作——会话以 idle 结束,钱已经花掉了。
How the ceiling actually lands: measured, on hitting the limit the middleware injects a HintBlock into the context and forces the next step's tool_choice to "none". It does not park, does not raise, does not discard finished work — the session ends idle with the money already spent.
04迭代上限不等于钱迭代上限不等于钱
max_iters=3 实测跑了 4 次调用:它在当前那一轮落地之后才停。预算上限才按成本停,而且成本是加权的——output_token_weight=11 时同一条 tape 从 2 次调用降到 1 次。
max_iters=3 measured 4 calls: it stops only after the in-flight iteration lands. A budget stops on cost, and the cost is weighted — output_token_weight=11 cuts the same tape from 2 calls to 1.
用到的 APIAPIs in play
代码The code
middleware = ReplyBudgetControlMiddleware(
token_budget=3000, # weighted ceiling per reply, not a raw token count
input_token_weight=1.0,
output_token_weight=11.0, # output costs 11x: the loop dies earlier
hint_message="预算已到,请收尾。",
)
agent = Agent(..., middlewares=[middleware], react_config=ReActConfig(max_iters=50))
# on trip, the middleware does not raise: it appends a block of type "hint"
# and calls the model again with ToolChoice(mode="none")
state.middle_context -> recorded spend, e.g. 2000 of 2000
判题The check
python lab.py run c6
它断言了什么What it asserts
budget 2000 at cost 900+100 per step -> 2 tool call(s), 3 model call(s), recorded spend 2000same tape, same ceiling, output_token_weight=11 -> 1 tool call(s); the budget is weighted, not a token count (1x cost 1000, 11x cost 2000)no budget, max_iters=3 -> 4 call(s) and 4000 tokens spent: an iteration ceiling bounds steps (plus the in-flight one), not costservice with the block wired: 3 refund(s) moved out of 12 tape steps, 4 model call(s), session idlesame tape, block removed from the assembly: 12 refund(s) and 13 model call(s) — the ceiling was the only thing between themoutput_token_weight=20, same ceiling -> 2 refund(s): cost is 900+20x100 per step, so the loop dies earlier
会踩的坑Where it hurts
只统计成功调用。失败重试也是钱——预算要按真实 API 往返次数算,不是按最终答案算。
Counting only successful calls. Failed retries cost money too: budget on real API round trips, not on the final answer.
费曼闸门Say it back
框架选的是「注入提示 + 强制 tool_choice=none」,不是抛异常也不是挂起。这个选择把「谁决定继续」交给了谁?如果模型无视提示又调了一次工具,钱是谁花的?
The framework chose 'inject a hint and force tool_choice=none' over raising or parking. Who does that hand the 'shall we continue' decision to? If the model ignores the hint and calls a tool anyway, whose money was it?
页面只存通行码的哈希。判题没真跑过,这里就解不开。The page stores a hash, not the code. Nothing unlocks until the command really ran.