# 阻止智能体循环的外部调用预算

自主智能体不需要无限访问外部服务，也能发挥作用。它们需要一份明确的权限额度，在尝试、等待、重试和支出达到上限后，把工作交还出来。没有这条边界，一个小小的歧义，例如搜索结果为空或任务延迟，就可能变成数百次请求、重复的副作用，以及一笔无人预料的账单。

外部调用预算是单次智能体运行时的一份契约。它限制请求尝试次数、经过时间和资金敞口，并在任一上限耗尽时强制停止，同时留下清晰可见的停止结果。应把它当作运行限制，而不是智能体提示中的建议。模型为了完成任务，很容易重新解释提示；真正的执行控制必须放在模型之外。

## 预算必须分别计算尝试次数、时间和金额

单一限制无法覆盖智能体消耗资源的各种方式。请求数量可以发现循环。经过时间限制可以发现缓慢轮询和重试等待。支出限制可以拦住少量但昂贵的操作。每项指标对应一种不同的故障模式，即使另外两项仍有余量，也可以单独结束运行。

统计**尝试次数**，不要只统计成功响应。一次超时的请求仍可能已经到达服务商。它消耗了连接、服务商的处理能力，也常常会在计量 API 中产生一条记录。如果只统计成功请求，智能体就能不断发出失败请求，直到找到一个自己喜欢的响应。

每次运行至少记录以下值：

- `attempts_used` 和 `attempts_remaining`，包括会发起新外部请求的重试和重定向
- `deadline_at`，使用单调时钟计算经过时长，而不是使用墙上时钟
- `reserved_spend` 和 `settled_spend`，以服务商实际使用的最小计费单位记录
- `side_effect_attempts`，单独统计写入、发送、创建或购买等操作
- `budget_stop_reason`，记录第一个拒绝后续工作的限制

不要给每个请求虚构一个价格，再把请求数和支出合并。元数据查询和模型生成端点可能都只算一次请求，但账单金额可能相差很大。反过来，某个 API 可能不报告请求价格，却会创建一个需要付费的云资源。即使已有成本估算，也应保留请求上限。

对于从一组已知 API 收集数据的智能体，一个有用的初始预算可能是总共 40 次尝试、10 分钟运行时间和一笔较小的固定预留。部署型智能体可能需要更少请求，但应有更严格的副作用额度。会跟随发现的 URL 的研究型智能体，其可触达范围应远小于作者最初提出的范围。这些只是起始假设，不是通用设置。先测量正常运行，再把限制设得足够接近异常行为的边界，以便在问题变成清理工作前中断它。

预算属于一次运行，而不是当天的模型进程。智能体收到新任务时，应获得新的运行标识和新的明确额度。共享的每日计数器会把昂贵的运行隐藏在正常工作中，也会让一个任务消耗原本留给另一个任务的容量。

## 第一次高成本调用前必须先预留额度

如果系统要等到购买之后才知道价格，支出上限就会失效。调用前先预留最高可能费用，立即减少剩余预算，等服务商返回实际用量后再核对。如果预留金额无法容纳这次调用，就应在请求离开你的边界前拒绝它。

考虑一个可以创建托管构建的智能体。服务商的请求会接受机器类型、区域、超时时间和产物大小，但确切费用要在完成后才显示。智能体网关需要价格表或保守估算，不需要完美的账务系统才能做出安全决策。

预留记录可以写成这样：

```json
{
  "run_id": "run_7c1f",
  "budget": {
    "attempts_remaining": 18,
    "deadline_at": "2025-04-21T14:42:00Z",
    "spend_remaining_cents": 1200
  },
  "reservation": {
    "action": "create_build",
    "maximum_cents": 850,
    "provider_reference": "build-request-41"
  },
  "decision": "allow"
}
```

完成这次预留后，运行只剩 350 美分可用于其他操作。如果实际账单结算为 620 美分，就释放 230 美分。如果结算为 910 美分，就记录超支、拒绝后续支出，并调查估算为何偏差。不要因为最终数字不方便，就让智能体随意借用未来预算。

有些服务商会在请求中提供价格，或在工作开始前返回用量估算。应使用这些信息。有些服务则不会。对于这类服务，应按风险对操作分类。普通预算下可以允许廉价读取。凡是可能创建无上限资源、发送付费消息、下单，或触发无法限定成本的工作，都应要求明确审批。

团队常因估算不准确而拒绝预留。这混淆了账务精度和控制能力。防火门在关闭前不需要精确计算火灾会产生多少热量。保守预留可能会拒绝一项原本可以完成的任务，但智能体可以说明理由，请求更大的额度。这总比智能体消失后才发现一笔没有上限的费用要好。

把服务商积分和组织级配额放在运行预算之外。它们是最后一道防线，不能替代运行预算。月度配额通常允许单次运行消耗远超任务应得的资源。

## 重试的额度应小于首次尝试

重试应当少见、有上限，并根据重复请求是否可能重复副作用进行分类。笼统地指示智能体重试错误，正是把临时故障变成昂贵流量模式的方式。

RFC 9110 根据预期的服务器效果定义了 GET、PUT 和 DELETE 等幂等方法。它还指出，除非客户端知道重复请求是安全的，否则不应自动重试非幂等请求。对智能体来说，这一限制比普通应用代码更重要：智能体可能在尝试之间改变载荷，认为之前的响应不完整，然后发出一个看似全新的请求。

RFC 6585 定义了 HTTP 429 Too Many Requests，并指出响应可能包含 `Retry-After`。如果存在该标头，应遵守它。但不要把它当成可以一直睡到标头到期、之后无限恢复的许可。重试仍会消耗运行的时间预算，原始任务也可能已经不值得继续等待。

使用重试台账，在每次重复之前回答四个问题：发生了什么故障，远程操作是否可能已经发生，应等待多久，以及再次尝试由哪项预算支付。一项实用策略如下：

```yaml
request_classes:
  read:
    max_attempts: 3
    retry_on: [408, 429, 502, 503, 504]
    backoff_seconds: [2, 8]
  idempotent_write:
    max_attempts: 2
    require_idempotency_token: true
    retry_on: [408, 429, 503]
  non_idempotent_write:
    max_attempts: 1
    retry_on: []
```

这项策略可以避免一种常见故障。智能体发送 `POST /invoices` 后，在收到响应前连接断开。草率重试可能会创建第二张发票。幂等令牌可以让配合的服务商识别重复请求，但前提是智能体重复使用同一个令牌和完全相同的逻辑操作。如果智能体在第二次尝试时改变金额、客户或令牌，这种保护就不再适用。

对于非幂等操作，应优先使用由客户端生成的操作标识查询状态。如果服务商不支持幂等性或状态查询，就应把不明确的超时交给人工审核。只要有人需要撤销重复转账、订单或消息，自动重试看起来更快的好处就会消失。

当许多智能体同时遇到相同故障时，抖动很重要。使用随机退避，避免它们同时重试。时间表也要足够短，让运行截止时间保持实际意义。六小时的指数退避也许能保护服务商，却会让智能体会话在任务已经失去意义后仍然长时间保持打开。

## 轮询循环需要单独的上限

等待异步工作的智能体应获得明确的轮询额度，因为普通请求限制往往等到模式已经造成高昂成本后才会暴露问题。轮询有合理用途，但必须设置间隔、最大检查次数，以及属于该操作的截止时间。

日志中很容易看出错误的写法：

```text
14:00:03 POST /exports                 202 accepted
14:00:04 GET  /exports/ea91            202 running
14:00:05 GET  /exports/ea91            202 running
14:00:06 GET  /exports/ea91            202 running
...
14:11:58 GET  /exports/ea91            202 running
```

智能体把“仍在运行”理解成了“再次询问”。这不是坚持，而是缺少策略。

从服务商公开的指导开始。如果端点返回 `Retry-After`，就在剩余截止时间内遵守它。如果返回预计完成时间，在该时间之前不要轮询。如果提供 webhook 或回调，就用它代替让智能体运行一直保持打开。外部回调可以稍后恢复受控工作流，但不应使用旧权限复活已经过期的运行。

轮询预算还应区分任务状态和传输故障。`202 running` 表示任务存在。超时并不能告诉你状态请求是否到达。不要对这两种情况使用同一套重试额度。前者可以等到下一次计划检查，后者可以在普通重试限制内合理地再试一次。

轮询额度到期时，应设置明确的终止动作：记录任务最后已知状态，保留远程操作标识，并返回恢复指令。除非原始任务说明取消是安全的，否则不要自动取消。有些任务在智能体放弃等待后仍会留下可用结果，而某些取消请求本身也会产生副作用。

这种设计会让延迟工作对人保持可见。“导出在六次检查后仍在运行；稍后可以检查操作 ea91”是一份有用的交接。“智能体已完成”如果背后隐藏着后台循环，就不是。

## 截止时间必须覆盖等待、工具和队列时间

运行截止时间应衡量智能体能够造成外部工作的整个期间。它必须包括调用之间的模型推理、重试等待、本地工具执行、队列延迟、DNS 卡顿，以及等待审批的时间。只在 HTTP 客户端周围设置计时器，会留下很大的空白，让循环继续运行。

使用单调经过时间计时器执行这一限制。笔记本睡眠、恢复或收到时间校正时，墙上时钟可能跳变。审计记录可以保存墙上时钟时间戳，但过期计算应使用不会倒退的经过时间。

把剩余时间传递给每个操作。如果运行只剩 40 秒，就不应启动一个客户端超时为 90 秒的 HTTP 请求，也不应调用完全没有超时设置的 SSH 命令。子操作应取自身上限与运行剩余时间两者中更小的值。

```text
remaining = run_deadline_monotonic - now_monotonic
if remaining \u003c= 0:
    deny("run_deadline_exhausted")
else:
    operation_timeout = min(remaining, endpoint_timeout)
    execute(operation_timeout)
```

应谨慎处理审批等待。人可能五分钟后返回，但智能体运行可能只剩 30 秒。审批在过期后到达时，应拒绝操作并说明原因。允许审批唤醒已经过期的运行，会制造一个漏洞：智能体可以在截止时间前排队任意数量的高成本操作，等人稍后逐个点击卡片再执行。

长任务需要另一种结构。把它拆成多个检查点，每个检查点都有新的预算和记录在案的状态转换。例如，智能体可以在一次运行中提交导出，之后由另一次运行检查已完成的导出，并决定如何处理。每次运行都有明确目的、小范围权限和清晰的结束时间。这不像一个永生的智能体会话那样神奇，但正因如此，更容易审计。

## 范围限制可以防止智能体把预算花在错误的地方

预算回答一次运行可以进行多少外部活动。范围回答它可以接触哪里、接触什么。没有范围限制，智能体可以把看似合理的请求额度花在探测无关主机、跟随恶意链接，或选择任务从未要求的高成本端点上。

用具体内容定义范围：获准主机、凭据身份、HTTP 方法、SSH 目标、允许的路径或命令族，以及最大请求体大小。定义应足够窄，让审核者能够理解。不要试图为每种可能条件编写一门微型编程语言。复杂的策略系统会不断积累例外，最终没人能在压力下预测它的结果。

凭据边界和预算边界的区别很重要。凭据边界决定智能体是否可以使用某个秘密调用服务。预算边界决定智能体在获得这项权限后，本次运行是否还能再调用一次。团队常常只安装第一种边界，就以为它能提供第二种保护。事实并非如此。一个保护完善的 API 令牌仍可能为一千次不必要的请求买单。

对于 HTTP，如果重定向离开获准主机集合，就应拒绝它，除非有人明确允许目标地址。重定向很容易被忽略，因为许多客户端会自动跟随。一个从获准 URL 开始的请求，最终可能到达另一台主机，携带请求头，或把预算消耗在任务从未指定的服务上。

对于 SSH，不要仅因为智能体的第一项工作涉及一条命令，就给它一个通用 shell。尽可能限制目标和命令接口。设置命令超时，并统计每次连接尝试。连接失败循环仍然属于外部活动，即使它从未完成身份验证。

把读取范围和写入范围分开。数据收集任务可能需要广泛的读取端点，却完全不需要创建记录的能力。变更任务可能只需要一个写入端点和一次范围很窄的预检读取。这种划分能让预算更有意义，因为单靠请求数量无法区分无害重复和反复产生副作用的操作。

## 拒绝必须留下足够证据，以便还原循环

预算停止只有在事后能够解释时才有用。外部调用开始前记录每次决策，完成后记录结果，并像记录成功调用一样认真地记录拒绝。否则，缺失事件看起来就和被阻止的请求完全一样。

在模型消息、本地工具、HTTP 请求、SSH 连接、审批和审计记录之间使用同一个不可变运行标识。每次外部尝试都应包含序号。如果发生重试，应将其关联到原始逻辑操作，并说明该操作是幂等的、状态不明确的，还是已确认未发生。

一条紧凑的事件记录应包含足够的调查信息，但不必保存敏感载荷：

```json
{
  "run_id": "run_7c1f",
  "attempt": 17,
  "logical_operation": "fetch_export_status:ea91",
  "channel": "http",
  "destination_class": "approved-export-api",
  "outcome": "denied",
  "reason": "poll_allowance_exhausted",
  "attempts_remaining": 0,
  "elapsed_ms": 598244,
  "reserved_spend_cents": 0
}
```

默认不要记录 bearer 令牌、密码、私钥或完整请求体。预算审计需要标识符、类别、必要时的哈希和决策背景，不需要再建一个装满相同凭据的秘密存储库。

让事件序列具备防篡改能力。哈希链让每条记录都依赖前一条，因此删除或修改记录会破坏验证。条件允许时，应把验证材料存放在运行中的智能体之外。能够写入自身历史的智能体，不能同时编辑证明自己超出预算的记录。

Sallyport 使用一种对写入不可见的加密哈希链审计日志，同时记录智能体会话和单独调用；其 `sp audit verify` 命令可以在密文上离线验证链。这种设计对预算执行很有用，因为被拒绝的操作会作为证据的一部分保留下来，而不会作为内部决策消失。

## 人工审批应处理例外，而不是每次读取

人工审批应处理数字预算无法判断的后果或范围变化，不应变成例行调用的橡皮图章。如果每次请求都弹出审批卡片，人们会用处理第一个提示的注意力去批准第十个提示。

当操作新增收款方、支出超过预留、写入生产系统、发送外部可见的通信、改变获准目标集合，或在任务输入发生重大变化后恢复时，应请求审批。审批内容应以通俗语言展示操作、目标、预算影响，以及发起请求的进程身份。

不要把审批当作弥补预算薄弱的办法。分心的人可能批准错误操作，无人值守的运行也可能一直收不到回应。等待审批时，预算仍应过期。如果任务仍值得继续，人可以为新运行授予新的额度。

避免让人审批一个没有上下文的“继续”。继续什么？针对哪项服务？预计费用是多少？运行已经发出多少次调用？好的审批请求会让这些事实清晰可见。含糊的请求会迫使审核者相信智能体的总结，而智能体恰恰有继续工作的动机。

两者可以明确分工。网关负责一致地执行固定限制。人负责判断特殊操作是否值得获得更多权限。保持职责分离，双方都不必假装能够解决对方的问题。

## 预算耗尽后应生成可恢复的交接信息

限制停止运行时，智能体应返回一份简短的状态记录，让人或后续运行能够安全继续。含糊的失败消息会促使人从头重跑任务，重复已经完成的调用，也可能重复副作用。

交接信息需要包含任务目标、已完成的操作标识、待处理的远程工作、确切的停止原因，以及对下一次额度的建议。它应说明重试是否安全，绝不能隐瞒操作最终处于不明确状态这一事实。

例如：

```text
Stopped: elapsed-time budget exhausted.
Completed: submitted export job ea91; downloaded no files.
Last observed state: running at 14:09:58 UTC.
External attempts: 14 of 14; spend reserved: 0 cents.
Safe resume: check status of ea91 once after 14:20 UTC.
Unsafe action: do not submit another export request.
```

这条消息能避免最昂贵的恢复方式：因为没人知道发生了什么，只好从头开始。它也让预算调整成为可能。如果许多正常运行在一次状态检查后就停止，说明允许的时长太短。如果运行经常在没有进展的搜索上耗尽全部额度，说明任务拆分或范围设置有问题。

预算耗尽后不要自动补充额度。除非有独立权限决定何时适用，否则补充规则只是把无限预算写成更小的增量。应要求开启新运行或执行明确的人工操作，保留旧记录，并让下一次尝试说明为什么值得获得更多空间。

最值得首先执行的策略很简单：每次自主运行在接触外部服务前，都必须获得有限的请求次数、硬性截止时间和支出预留。随后立即加入范围限制和完善记录。当智能体知道自己不能永远尝试时，失败就会变成受控的工作项，而不是一夜之间演变成事故。
