大模型工具调用次数限制
本文介绍在 Agent / Tool Calling 场景中,如何设计与实现「工具调用次数限制」。
1. 背景:什么是工具调用
现代大模型(LLM)可通过 Function Calling / Tool Calling 调用外部能力,例如:
- 搜索网页
- 读写文件
- 执行代码
- 查询数据库
- 调用业务 API
典型交互循环如下:
text
用户提问
→ 模型决定是否调用工具
→ 执行工具,把结果回填给模型
→ 模型继续推理 / 再调工具 / 最终作答
若不加限制,模型可能:
- 反复调用同一工具(死循环)
- 一次任务打出上百次调用(成本失控)
- 长时间占住会话(延迟与资源占用)
因此需要在工程层显式约束「最多能调几次工具」。
2. 为什么要限制
| 风险 | 说明 |
|---|---|
| 成本 | 每次工具结果都会进入后续上下文,token 费用快速上升 |
| 延迟 | 串行工具调用会显著拉长端到端耗时 |
| 稳定性 | 模型可能陷入「再试一次」式循环 |
| 安全 | 危险工具(删文件、发邮件、支付)需要硬闸 |
| 配额 | 下游 API 本身有 QPS / 日调用上限 |
限制的目标不是「禁止用工具」,而是:在可控预算内完成任务。
3. 限制维度
实践中很少只设一个总开关,通常按多个维度组合:
3.1 按会话 / 任务
- 单次用户请求最多
N次工具调用 - 适用于大多数 Agent 主循环
3.2 按轮次(Turn)
- 模型每一轮回复里,最多允许
K个并行 / 串行 tool call - 防止单轮「工具风暴」
3.3 按工具类型
search:最多 5 次run_code:最多 3 次send_email:最多 1 次(敏感操作)- 通用工具松、危险工具紧
3.4 按时间窗口
- 每分钟 / 每小时 / 每天的调用配额
- 适合多用户共享同一下游服务的场景
3.5 按成本预算
- 不只数次数,还估算工具结果带来的 token 增量
- 例如:累计工具相关 token 不超过预算
B
3.6 按深度 / 递归
- 子 Agent、工具再调模型时,限制嵌套层数
- 防止「Agent 调 Agent」无限展开
推荐起步配置:
text
单任务总调用次数:8 ~ 20
单轮并行工具数:1 ~ 4
危险工具:1 ~ 2,且需确认
递归深度:1 ~ 3
具体数值应结合业务延迟、成本与成功率压测后调整。
4. 限制策略:软限制 vs 硬限制
4.1 硬限制(Hard Limit)
达到上限后直接拒绝后续工具调用,并强制模型进入收尾。
适用:
- 成本红线
- 安全红线
- 必须保证系统不会失控
行为示例:
text
已达工具调用上限(12/12)。
请基于已有信息直接给出最终答案,不要再请求调用工具。
4.2 软限制(Soft Limit)
接近上限时先「提醒」模型,仍允许少量调用。
例如:
used < 0.7 * max:正常0.7 * max ≤ used < max:注入提示「请尽快收敛」used ≥ max:硬拒绝
软限制能减少突然截断导致的答案质量下降,但仍需硬上限兜底。
4.3 分级响应
| 阶段 | 动作 |
|---|---|
| 正常 | 正常执行 |
| 预警 | 在 system / developer 消息中提示剩余次数 |
| 拒绝新调用 | 返回结构化错误给模型 |
| 强制收尾 | 不再把 tool schema 发给模型,或 tool_choice=none |
5. 核心实现思路
无论用哪种 SDK,本质都是在 Agent Loop 外包一层计数与裁决。
5.1 最小状态机
text
remaining = MAX_TOOL_CALLS
while True:
response = llm.chat(messages, tools=tools)
if response 没有 tool_calls:
return response.content
if len(tool_calls) > remaining:
拒绝超额部分 / 或整批拒绝
强制模型总结
break
执行允许的 tool_calls
remaining -= 实际执行次数
把 tool 结果写回 messages
5.2 伪代码(Python 风格)
python
from dataclasses import dataclass, field
@dataclass
class ToolBudget:
max_total: int = 12
max_per_turn: int = 3
used_total: int = 0
per_tool: dict[str, int] = field(default_factory=dict)
max_per_tool: dict[str, int] = field(default_factory=dict)
def allow(self, name: str, count: int = 1) -> bool:
if self.used_total + count > self.max_total:
return False
if count > self.max_per_turn:
return False
limit = self.max_per_tool.get(name)
if limit is not None and self.per_tool.get(name, 0) + count > limit:
return False
return True
def consume(self, name: str, count: int = 1) -> None:
self.used_total += count
self.per_tool[name] = self.per_tool.get(name, 0) + count
def remaining(self) -> int:
return max(0, self.max_total - self.used_total)
def run_agent(user_input: str, llm, tools, budget: ToolBudget) -> str:
messages = [{"role": "user", "content": user_input}]
while True:
# 接近上限时提示模型收敛
if budget.remaining() <= 2:
messages.append({
"role": "system",
"content": (
f"工具调用剩余 {budget.remaining()} 次,"
"请优先给出最终答案,避免非必要调用。"
),
})
# 用尽后不再暴露工具,逼模型直接回答
active_tools = tools if budget.remaining() > 0 else None
tool_choice = "auto" if active_tools else "none"
resp = llm.chat(messages, tools=active_tools, tool_choice=tool_choice)
calls = getattr(resp, "tool_calls", None) or []
if not calls:
return resp.content
# 单轮截断
calls = calls[: budget.max_per_turn]
allowed = []
denied = []
for call in calls:
if budget.allow(call.name):
allowed.append(call)
else:
denied.append(call)
messages.append(resp.as_assistant_message())
for call in allowed:
result = execute_tool(call)
budget.consume(call.name)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result,
})
for call in denied:
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": (
"ERROR: tool call budget exceeded. "
"Do not retry. Answer with available information."
),
})
if not allowed and denied:
# 已无法继续调工具,再要一轮纯文本总结
final = llm.chat(messages, tools=None, tool_choice="none")
return final.content
要点:
- 先裁决,再执行:超限工具不要真的跑
- 给模型可理解的错误:否则它可能反复重试
- 用尽预算后去掉 tools:比口头劝说更可靠
- 单轮与总量双重约束:防止「一轮打爆」
6. 与模型侧参数配合
除了业务层计数,还可利用 API 能力降低失控概率:
| 手段 | 作用 |
|---|---|
tool_choice="none" |
禁止本轮调用工具 |
tool_choice="required" |
强制调用(一般不用于限流) |
parallel_tool_calls=false |
降低单轮爆发 |
| 缩小 tools 列表 | 只暴露当前阶段需要的工具 |
| 更短 tool description | 降低模型「顺手多调一次」的倾向 |
注意:模型侧参数是辅助,不能替代服务端硬计数。客户端或模型请求可以被构造,真正闸门必须在执行层。
7. 常见架构落点
7.1 Agent Loop 内计数(最常见)
在 while 循环里维护 budget,简单、直观,适合单进程单会话。
7.2 工具执行中间件
把限制做成统一拦截器:
text
request → auth → rate limit → policy → execute → audit
优点:
- 所有入口一致
- 便于加审计日志、用户级配额
- 可与 HTTP API 网关限流思路对齐
7.3 分布式配额
多实例部署时,本地内存计数不够,需要:
- Redis
INCR+ TTL(按分钟 / 天) - 或令牌桶 / 漏桶算法
示例(概念):
text
KEY = tool_quota:{user_id}:{yyyyMMdd}
VALUE = INCR
IF VALUE > DAILY_LIMIT THEN REJECT
EXPIRE KEY 2 days
7.4 工作流 / 图编排框架
在 LangGraph、AutoGen、自研状态机中,把「调用次数」做成图状态字段:
text
state.tool_calls_used += 1
if state.tool_calls_used >= MAX:
next_node = "finalize"
这样限制成为编排的一部分,而不是散落在各工具内部。
8. 失败后怎么告诉模型
错误信息质量会直接影响是否「空转重试」。
推荐
text
Tool budget exceeded (12/12).
You must stop calling tools and produce the final answer now
using only information already in the conversation.
不推荐
text
Error
或:
text
请稍后重试
后者会诱导模型继续调工具。
可同时做两件事:
- tool 结果里返回明确
budget exceeded - 下一轮请求设
tool_choice="none",并可选移除 tools 定义
9. 观测与调参
没有指标就无法选对 N。至少记录:
| 指标 | 用途 |
|---|---|
| 每次任务的工具调用次数分布 | 定 max_total 的 P95 / P99 |
| 因超限而截断的比例 | 判断限制是否过严 |
| 超限后答案可用率 | 评估硬截断伤害 |
| 各工具调用占比 | 给高频 / 高危工具单独设限 |
| 平均时延与费用 | 把次数限制映射到 SLA / 成本 |
调参经验:
- 先统计无限制时的自然分布
- 把上限设在 P95 ~ P99 附近
- 对失败任务做 case review:是真需要更多调用,还是模型在空转
- 空转多 → 降上限 + 强化收敛提示;有效长链路多 → 提上限或做分阶段预算
10. 设计检查清单
- 有单任务总上限
- 有单轮并行上限
- 危险工具有独立更严上限
- 超限时工具不会被真实执行
- 超限错误对模型可读,且明确禁止重试
- 用尽预算后可关闭 tools / 设
tool_choice=none - 多实例场景有共享配额(如 Redis)
- 有审计日志:谁、何时、调了什么、是否被拒
- 有监控:调用次数、拒识率、费用、时延
- 递归 / 子 Agent 有深度限制
11. 最小可行方案(MVP)
若只想快速落地,按下面 5 步即可:
- 设定
MAX_TOOL_CALLS = 12 - 在 Agent 循环维护计数器
count >= MAX时停止执行新工具- 向模型返回「预算耗尽,请直接作答」
- 最后一轮强制
tool_choice="none"生成最终回答
这已经能挡住大多数失控循环。后续再按工具类型、用户等级、时间窗口逐步细化。
12. 小结
工具调用次数限制的本质是:在执行层实施可审计的预算控制。
记住三句话:
- 模型可以「请求」调用,但系统决定「是否执行」
- 软提示提高体验,硬上限保证安全与成本
- 限制值应由观测数据校准,而不是拍脑袋
掌握「计数 → 裁决 → 拒绝 / 收尾 → 观测调参」这条链路后,就可以在任意 Agent 框架中复用同一套思路。