# LLM Agent API 花费熔断开关：在供应商调用前截住失控账单

> 在每次付费调用前，将已结算费用、在途预留和本次费用上界一起检查；额度不足立即停，账单未知继续占用额度。附不连接供应商的可运行预算闸门示例。

- URL: https://blog.laozhang.ai/zh/posts/llm-agent-api-spend-kill-switch
- Published: 2026-06-14
- Updated: 2026-10-07
- Author: LaoZhang AI Team (https://blog.laozhang.ai/zh/about)
- Topic: API 接入指南
- Tags: LLM Agent, API 成本控制, AI 网关, 限流与预算

---
要阻止 LLM Agent 把 API 账单跑失控，预算检查必须发生在**下一次付费请求出站之前**：先为这次调用原子预留额度，预留成功才调用供应商，结束后按实际用量结算。额度不足时，停止当前任务的付费派发、重试和子 Agent；响应丢失时，保留预留并等待对账。

核心条件是：`已结算费用 + 在途预留 + 本次费用上界 ≤ 预算上限`。检查与增加预留必须是同一个不可交错的操作。否则多个 worker 都读到“还剩 1 美元”，仍可能一起发出合计超过 1 美元的请求。

供应商的硬上限也值得启用。当前 [OpenAI 官方文档](https://developers.openai.com/api/docs/guides/spend-limits)已支持组织、项目层面的硬花费限制，需要启用 `Enforce a hard limit`；只有花费提醒时，请求仍会继续。硬限制有传播延迟，已记录花费可能略超设定金额。因此，账户级限制负责后备保护，调用前闸门负责自己的任务预算，两者可以同时使用。

![LLM Agent 在付费模型调用前检查预算并阻断超额请求的流程示意。](https://blog.laozhang.ai/posts/zh/llm-agent-api-spend-kill-switch/img/cover.webp)

## 先确认哪一个组件能拒绝下一次付费调用

能看见花费，不等于能拦住花费。先沿真实调用链找出出站入口：主模型、子 Agent、摘要器、评估器、付费检索、图片工具、SDK 内部重试和备用路由，都应在开始计费前接受预算决策。只管住主 planner，工具里的一次隐藏模型调用仍能继续花钱。

| 控制方式 | 能解决什么 | 不能单独保证什么 |
| --- | --- | --- |
| 邮件提醒、花费看板 | 让操作者发现异常 | 下一次请求被拒绝 |
| 最大轮次、执行时限 | 缩短循环、控制任务规模 | 金额不超限；不同调用的单价与用量不同 |
| 单次输入、输出上限 | 缩小单次费用上界 | 大量小请求累计不超预算 |
| 供应商硬花费限制 | 在其组织、项目或账户范围阻断后续流量 | 单个 Agent 的预算，以及零传播延迟 |
| 网关预算 | 集中管理经过网关的流量 | 绕行流量受控，或并发下发票金额绝不超限 |
| 调用前原子预留 | 在已知费用上界和共享账本范围内做金额准入 | 已发出的调用被撤销，或估价错误自动消失 |

预算闸门放在内部代理、网关或执行器都可以，关键是它拥有实际出站控制权。用一句提示词要求模型“不要超过预算”，只改变模型行为，不能替代执行器拒绝请求。

预算身份也应由应用确定。可信的登录上下文、队列任务记录或服务端任务映射决定租户、父任务和预算周期；模型不能自己填一个新的 `budget_id`，更不能通过新会话获得新的总额度。子 Agent 可以有更小的子预算，但放行时还要满足父任务、租户和账户的共同限制，不能只扣其中一个。

若问题首先是“相同工具为什么一直调用”，可继续看 [AI Agent 工具调用死循环怎么停](https://blog.laozhang.ai/zh/posts/ai-agent-tool-loop)。重复检测和业务进展检查负责终止无效行为；本页的金额闸门负责阻止下一次付费调用，两种控制应共同生效。

## 怎样在调用前算出值得预留的金额

预留金额应来自**应用已经确定的路线、价格与调用上限**，不能由 Agent 自报“这次大概很便宜”。至少把以下输入固定下来：

- 实际供应商、模型、服务类型和价格版本，包含可能发生的备用路线。
- 完整输入的保守计量，包括系统提示、历史消息、工具结果和附件；字符数不能直接当作 token 数。
- 执行器真正会传给供应商的输出上限，以及适用的图像、音频、缓存写入、工具或其他计费项。
- 币种、计量单位和舍入方式。账本可以使用整数微美元，避免浮点数累计误差。

对于只包含文本输入与输出的假设调用，若输入最多 8,000 token、输出最多 2,000 token，假设输入单价为每百万 token 2 美元、输出单价为每百万 token 10 美元，则预留为：

`8,000 × 2 ÷ 1,000,000 + 2,000 × 10 ÷ 1,000,000 = 0.036 美元`

这里的价格和用量**仅用于演算，不是某个模型的现行报价**。若此路线还收取每次 0.005 美元的工具费，预留应变为 0.041 美元；重试再次调用供应商时，需要再次预留。首次预留 0.036 美元，不自动覆盖三次尝试。

有缓存也不能随意把输入成本算成零。[Anthropic 的用量说明](https://platform.claude.com/docs/en/api/rate-limits)区分普通输入、缓存读取与缓存写入字段；某些缓存 token 不计入输入速率限制，并不意味着不计费。若调用前无法确定缓存命中，应使用适用于该请求的保守费用上界，再按实际分类结算。

拿不到可信价格、无法限定付费工具次数，或者备用路线的成本尚未纳入，就拒绝开始这次付费工作，返回 `cost_bound_unknown`。可以先缩小输入、降低允许的输出、改用已核定路线，之后重新做预算决策；不能用零元估算让请求先出去。

## 原子预留、出站调用和结算分别做什么

三个阶段要通过稳定的请求标识连接起来。账本至少保存预算身份、周期、上限、已结算金额、未结算预留，以及每次请求的内容绑定、状态、预留金额和最终费用。

![预算检查、额度预留、模型调用和费用对账之间的关系。](https://blog.laozhang.ai/posts/zh/llm-agent-api-spend-kill-switch/img/reserve-call-reconcile.webp)

### 放行前同时检查余额并占用额度

原子操作先读取当前账本，确认任务未停止，再检查 `spent + held + reserve`，最后写入预留记录。只有写入成功，调用器才能出站。不能把“读余额”和“扣预留”分成两个独立请求。

例如总预算为 0.10 美元，两次并发调用各预留 0.06 美元：第一笔成功后，在途预留变为 0.06 美元；第二笔检查到需要 0.12 美元，必须被拒绝。等第一笔按 0.04 美元结算，剩余 0.06 美元才是已确认可用的额度。不过，若任务已因预算不足进入停止状态，结算释放额度也不能由模型自行解除停止。

生产实现可用支持正确隔离的数据库事务，或存储系统提供的原子操作。[Redis 的 Lua 脚本](https://redis.io/docs/latest/develop/programmability/eval-intro/)具有原子执行能力，可以把条件检查和预留更新放在一起；这不意味着供应商 HTTP 调用也在同一个事务内，更不自动提供持久化、故障切换或脚本报错回滚的完整保证。应在修改前验证输入，并另行设计恢复与审计。

若多个预算同时约束一次调用，例如租户月预算和父任务预算，都要共同通过并预留。扣了租户预算却没扣父任务预算，或者第二个扣款失败后留下第一笔孤立预留，都不算完成准入。

### 正常结束按实际金额结算，重复通知不能重复扣费

最终费用应来自可信用量与对应计费规则，必要时结合供应商的请求日志或账单对账。结算原子地将预留移出 `held`，将实际费用加入 `spent`，并记录请求结果。重复收到相同结算结果，只返回已有结果；同一请求标识出现不同路线、载荷或最终金额，应冻结并核查，不能覆盖旧记录。

请求标识的幂等性只保护账本，不自动保护供应商调用。两个 worker 拿到同一请求标识，必须由唯一执行权或持久化任务状态决定谁能出站；“已有预留，所以再调用一次”会造成重复计费。

### 超时、断流和进程崩溃先保留预留

请求可能已经被供应商接受，只是客户端没有得到响应。此时将记录改为 `unknown`，继续占用原预留，暂停该任务的自动付费续跑，再按供应商请求标识核对。TTL 到期不能作为释放费用的依据，否则旧调用尚未确认，新调用又占用了同一笔额度。

只有能证明请求没有发出、没有被接受，或已经取得明确的最终费用，才相应释放或结算。供应商返回错误本身并不能普遍证明费用为零。取消正在等待的流也不等于已经撤销计费。

如果实际费用高于预留，账本要记录真实金额、冻结后续派发并报警。此时应承认费用上界失效，排查价格、计量、路由或额外收费；不能只扣预留金额，让报表看上去没有超支。

## 一个可以离线运行的预算闸门

下面的 Python 示例只使用标准库，不读取密钥、不联网、不调用 SDK。它验证**单进程、多线程共享同一个对象**时的检查与预留、唯一调用权、未知费用保留及幂等结算。内存与线程锁不提供多进程共享、重启恢复或真实账单保证，生产系统应把同样的状态转移放进可靠的共享账本。

这里的 `Policy` 是应用创建的可信配置，`max_cost` 是假路线的费用上界，单位为微美元：`40_000` 表示 0.04 美元。示例不负责估算真实模型费用；接入真实调用前，必须完成上一节的路线计量与上界约束。模型输入只进入 `payload`，不能决定上限、身份或价格。

将代码保存为 `spend_gate.py`，执行 `python3 spend_gate.py`：

~~~python
from dataclasses import dataclass
from hashlib import sha256
from threading import Lock
from concurrent.futures import ThreadPoolExecutor
import json


@dataclass(frozen=True)
class Policy:
    budget: str
    period: str
    route_revision: str
    cap: int
    max_cost: int


def money(value):
    if type(value) is not int or value < 0:
        raise ValueError("金额必须是非负整数微美元")
    return value


class Gate:
    def __init__(self, policy):
        money(policy.cap)
        if money(policy.max_cost) == 0:
            raise ValueError("此示例只接受已知的正费用上界")
        self.policy = policy
        self.lock = Lock()
        self.spent = self.held = 0
        self.stop = None
        self.records = {}

    def admit(self, request_id, payload):
        if not isinstance(request_id, str) or not request_id:
            raise ValueError("缺少稳定请求标识")
        fingerprint = sha256(json.dumps(
            payload, sort_keys=True, ensure_ascii=False
        ).encode()).hexdigest()
        p = self.policy
        binding = (p.budget, p.period, p.route_revision, fingerprint)
        with self.lock:
            old = self.records.get(request_id)
            if old:
                if old["binding"] != binding:
                    self.stop = "request_conflict"
                return False  # 已有记录不能再次取得调用权
            if self.stop:
                return False
            if self.spent + self.held + p.max_cost > p.cap:
                self.stop = "over_budget"
                return False
            self.records[request_id] = {
                "binding": binding, "reserve": p.max_cost,
                "state": "in_flight", "actual": None
            }
            self.held += p.max_cost
            return True

    def unknown(self, request_id):
        with self.lock:
            row = self.records[request_id]
            if row["state"] != "settled":
                row["state"] = "unknown"
                self.stop = "usage_unknown"  # 不释放 held

    def settle(self, request_id, actual):
        actual = money(actual)
        with self.lock:
            row = self.records[request_id]
            if row["state"] == "settled":
                if row["actual"] != actual:
                    self.stop = "settlement_conflict"
                    raise ValueError("同一请求出现不同结算金额")
                return
            self.held -= row["reserve"]
            self.spent += actual
            row.update(state="settled", actual=actual)
            if actual > row["reserve"]:
                self.stop = "bound_broken"
            # 对账不自动解除已有停止状态


def guarded_call(gate, request_id, payload, provider):
    if not gate.admit(request_id, payload):
        return "blocked"
    try:
        actual = provider(payload)  # 假供应商返回最终费用
        gate.settle(request_id, actual)
    except Exception:
        gate.unknown(request_id)
        raise
    return "done"


def tests():
    p = Policy("tenant-a/run-1", "run-1", "fake-v1", 100_000, 40_000)
    gate = Gate(p)
    calls = []

    def fake(payload):
        calls.append(payload)
        return 40_000

    assert guarded_call(gate, "a", {"q": "one"}, fake) == "done"
    assert guarded_call(gate, "b", {"q": "two"}, fake) == "done"
    assert guarded_call(gate, "c", {"q": "three"}, fake) == "blocked"
    assert guarded_call(gate, "d", {"q": "retry"}, fake) == "blocked"
    assert len(calls) == 2 and gate.spent == 80_000
    gate.settle("a", 40_000)
    assert gate.spent == 80_000  # 重复结算不增加费用

    parallel = Gate(p)
    with ThreadPoolExecutor(max_workers=8) as pool:
        accepted = list(pool.map(
            lambda i: parallel.admit(str(i), {"q": i}), range(8)
        ))
    assert sum(accepted) == 2 and parallel.held == 80_000

    lost = Gate(p)
    assert lost.admit("lost", {"q": "x"})
    assert not lost.admit("lost", {"q": "x"})
    lost.unknown("lost")
    assert lost.held == 40_000 and not lost.admit("next", {"q": "y"})
    lost.settle("lost", 30_000)
    assert lost.spent == 30_000 and lost.held == 0
    assert lost.stop == "usage_unknown"

    overflow = Gate(p)
    assert overflow.admit("high", {})
    overflow.settle("high", 120_000)
    assert overflow.spent == 120_000 and overflow.stop == "bound_broken"
    assert not overflow.admit("later", {})
    print("offline checks passed")


if __name__ == "__main__":
    tests()
~~~

这份示例的通过输出为 `offline checks passed`。我们运行了上述离线代码：正常预算内的两次假调用完成，第三次和后续重试被拒绝，假供应商总调用次数保持为 2；8 个线程竞争未结算额度时只放行 2 个。重复结算没有再次扣费，未知费用没有释放额度，实际费用超过预留时如实入账并停止。

这个结果证明的是代码中锁保护下的状态转移。它没有测试真实供应商、流式 token 计量、外部 Redis、数据库故障或多节点账本。示例也没有管理 SDK 内部重试：真实适配器应确保每一次计费尝试都有独立准入；若 SDK 把重试藏在一次 `provider()` 调用里，就不能直接套用上述保证。

## 预算错误怎样传到 Agent，才不会换条路继续花钱

控制器应把预算拒绝当成任务停止结果，直接交付已有结果与停止原因，不把它当作普通工具失败重新交回模型规划。返回给调用方的结构可以是：

~~~json
{
  "error": "over_budget",
  "retryable": false,
  "budget_id": "tenant-a/run-1",
  "next_action": "stop_paid_dispatch"
}
~~~

随后停止主任务的付费调度、取消尚未出站的队列项，并把停止状态传播到子 Agent 与工具执行器。正在供应商侧执行的调用仍要保留预留并结算，停止新请求不会撤销它们。

`over_budget`、`cost_bound_unknown`、缺失预算身份及账本不可用都应阻止付费出站。若网络错误被判定可重试，也必须先处理前次未知费用，再为新尝试重新预留。供应商账户花费上限拒绝则按其明确错误码停止，不能笼统地遇到 HTTP 429 就自动重试。

备用模型、另一把 key、另一个网关仍然是付费路线。应用可以批准更便宜的路线，但必须保留原父任务预算与停止策略，以新路线的可信上界重新检查；模型自行改路线、生成新 trace ID 或重启会话不能解除熔断。

恢复应由有权限的操作者在对账后决定。记录新上限或缩小后的任务、批准人、策略版本、有效期与原因，再恢复受控派发。单纯清零内部 `spent` 只会改变计数，不会减少供应商已经产生的账单。

## OpenAI、Anthropic 和网关的预算各有什么边界

下表按 2026 年 10 月 6 日核对到的官方规则区分。它用于判断机制，不表示每个账户已启用相应选项，也不代表我们修改过这些平台的设置。

| 服务 | 预算阻断行为 | 对 Agent 的直接影响 |
| --- | --- | --- |
| [OpenAI](https://developers.openai.com/api/docs/guides/spend-limits) | 组织或项目启用硬限制后，触顶分别返回 `organization_spend_limit_exceeded`、`project_spend_limit_exceeded`，HTTP 429；提醒本身不停止流量 | 按具体错误码停止，不按普通吞吐限流反复重试；组织限额覆盖所有项目，项目限额只覆盖计入该项目的流量。执行传播期间可能略超限 |
| [Anthropic](https://platform.claude.com/docs/en/api/rate-limits) | 服务配置的组织月度花费上限触顶：429 `rate_limit_error`，Messages 的详情码为 `enforced_spend_limit_reached`，没有 `retry-after`；用户设定、低于该上限的花费限制：400 `invalid_request_error` | 不把这两种花费拒绝当成短暂限流；Claude Code 的工作区检查存在单独的 429 行为，不能仅凭状态码归因。AWS 计费路线也不能直接套用平台余额规则 |
| [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/features/spend-limits/) | 适用规则超限时在供应商调用前返回 429；成本在请求完成后记录，具有最终一致性，并发突刺可能超限 | 适合集中控制，但已放行的并发费用尚未进入累计值；成本为尽力估算，不能据此承诺发票精确上限 |
| [LiteLLM Proxy](https://docs.litellm.ai/docs/proxy/users) | 支持代理、团队、成员、用户、虚拟 key 以及多时间窗口的预算；没有设置预算的 key 不等于有默认保护 | 应核对部署版本和实际作用范围，不能从字段名推断原子预留。团队 key 从 v1.95 起不再执行所属用户的个人预算，团队内成员预算是另一个控制项 |

OpenAI 自行配置的硬花费上限，与 OpenAI 根据使用层级批准的月度 usage limit 是两类限制。遇到组织或项目花费码，核对相应范围；遇到 `organization_usage_limit_exceeded`，应核对批准的使用额度。提高每分钟 token 限制并不会自动解除月度金额限制。[官方花费限制说明](https://developers.openai.com/api/docs/guides/spend-limits)给出了对应区别与恢复条件。

Cloudflare 的身份维度也需要可信来源：`cf.user_id` 适用于 Access 保护的自定义域名与有效用户身份，服务令牌没有该用户字段；其他场景需由应用提供可信用户元数据。按用户拆桶与只过滤某个用户不是同一回事，不能让模型选择用户标识来获得新预算。[规则与身份条件见官方文档](https://developers.cloudflare.com/ai-gateway/features/spend-limits/)。

如果使用 [LiteLLM Agent 会话预算](https://docs.litellm.ai/docs/a2a_iteration_budgets)，还要区分会话计数与持久化总预算。其说明中的费用在成功响应后累计，下一次调用前检查，会话计数默认 TTL 为 1 小时。这有助于限制轮次和会话花费，却不能单独证明并发在途费用不会超限；TTL 过期或更换 trace ID 也不应让父任务总预算归零。配置位置须按实际部署版本确认，不能把文档里的示例当成已经验证的生产配置。

## 上线前要证明的是“被拒绝的调用没有出站”

先接假供应商计数器，再验证真实运行环境的入口与账本。看日志里有 `blocked` 不够，拒绝发生后出站调用次数必须不增加。

![假供应商计数器核对预算拒绝与停止后没有新增派发，同时保留在途费用的示意。](https://blog.laozhang.ai/posts/zh/llm-agent-api-spend-kill-switch/img/no-provider-call-test.webp)

| 场景 | 应观察到的结果 |
| --- | --- |
| 正常费用低于预留 | 结算真实费用，释放差额，任务在预算内可继续 |
| 下一次费用上界超过余额 | 预留失败，供应商调用计数不增加 |
| 多 worker 同时竞争余额 | 所有成功预留总额受同一上限约束；失败者不能出站 |
| 重复派发同一请求、重复结算 | 不重复调用、不重复扣费；内容或金额冲突停下核查 |
| 流式断开、响应丢失、发送后崩溃 | 保留在途预留，重启后仍能找到未知记录 |
| 账本写入失败或状态无法读取 | 拒绝新的付费调用，而非临时绕过账本 |
| SDK 重试、子 Agent、付费工具或备用路线 | 每个计费尝试进入同一父预算；停止状态不能被重新规划解除 |
| 实际费用超过预留 | 记录真实费用并冻结，不掩盖超支 |

离线示例覆盖其中的金额状态与单进程竞争。生产验收还要用自己的共享存储、队列与适配器验证持久化、故障恢复和所有付费路径；这些结果不能由离线锁测试代替。

发生费用事故时，先在可控的出站入口暂停新付费派发，再查看已结算、在途和未知三类金额。保存请求标识与脱敏日志，核对供应商费用后决定停止、缩小任务或批准恢复。不要先删记录、重置预算或换凭证续跑，那会让旧费用失去归属。

## 常见问题

### OpenAI 项目预算现在能硬停吗？

可以，但需要启用 `Enforce a hard limit`。当前组织和项目硬限制触顶后返回相应花费错误码；只设置提醒不会停止 API 流量。执行有传播延迟，记录花费可能略超，因此它是有价值的账户保护，不能替代每个 Agent 的调用前预留。[OpenAI 官方说明](https://developers.openai.com/api/docs/guides/spend-limits)

### 返回 429 后，Agent 应该重试吗？

先看具体错误。请求或 token 吞吐限制可能按服务要求等待后重试；组织、项目或服务花费上限触顶，不会因为短暂等待自动恢复。OpenAI 的花费码、Anthropic 的 `enforced_spend_limit_reached` 应进入停止处理；用户设定的 Anthropic 花费限制还可能返回 400。不要让统一重试器只按 429 分类。[OpenAI](https://developers.openai.com/api/docs/guides/spend-limits)、[Anthropic](https://platform.claude.com/docs/en/api/rate-limits)

### 超时后没拿到 usage，能把预留退回来吗？

不能仅凭超时释放。请求可能已产生费用，应保留未知预留并核对。能证明未发出，或者已取得明确最终用量，才相应释放或结算；超时、取消与错误响应都不是通用的零费用证明。

### 已经用了 AI 网关，还需要自建账本吗？

先看你的目标。如果只需要集中花费提醒与后续阻断，网关预算可能足够；若要让多个并发任务在固定金额内准入，就要核对网关是否把在途费用原子预留在同一预算里。Cloudflare 当前文档明确费用完成后入账、最终一致性与并发超限边界，不能把它直接当成严格预留账本。[官方限制说明](https://developers.cloudflare.com/ai-gateway/features/spend-limits/)

### 更便宜的模型可以在熔断后自动接着跑吗？

只有应用策略明确允许恢复，并以同一父预算完成新的费用检查时才可以。换模型不应改变预算身份；已经因未知用量或账本异常停止的任务，要先处理停止原因，不能仅因新模型便宜就继续出站。

### 调用前预留能保证真实账单永远不超预算吗？

需要费用上界正确、全部付费路径受控、共享账本可靠并且结算完整。任何额外收费、错误价格、未受控重试或绕行都可能破坏保证。预留上界被实际费用突破时，应如实入账并停止；本页离线代码证明自己的状态转移，没有证明真实供应商账单或多节点系统具备这些条件。

## 参考来源

本文引用的外部页面，按正文出现顺序排列。最后更新于 2026-10-07。

- [OpenAI 官方文档](https://developers.openai.com/api/docs/guides/spend-limits) (developers.openai.com)
- [Anthropic 的用量说明](https://platform.claude.com/docs/en/api/rate-limits) (platform.claude.com)
- [Redis 的 Lua 脚本](https://redis.io/docs/latest/develop/programmability/eval-intro/) (redis.io)
- [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/features/spend-limits/) (developers.cloudflare.com)
- [LiteLLM Proxy](https://docs.litellm.ai/docs/proxy/users) (docs.litellm.ai)
- [LiteLLM Agent 会话预算](https://docs.litellm.ai/docs/a2a_iteration_budgets) (docs.litellm.ai)
