第 6 / 15 章 约 10 分钟
Tool Calling 与 Function Calling
为工具调用设置结构、预算、超时和错误边界。
内容同步:2026年10月25日 · 文档版本 6
这个模块在 Agent 里的位置
Tool 是模型接触外部世界的唯一受控入口。它决定:
- 模型能读什么数据。
- 一次能读多少。
- 能不能写。
- 失败时如何自救。
- 结果如何进入上下文。
- 副作用如何被审计。
在工程上,Tool 不是“包一层函数”就结束了。它是 Agent 的能力边界、成本边界、数据边界和副作用边界。
先记住几个概念
| 概念 | 含义 | 为什么重要 |
|---|---|---|
| Tool schema | 输入参数的结构和范围 | 防止模型传非法参数 |
| Output contract | 返回字段、大小、分页、引用 | 防止上下文被原始数据淹没 |
| Tool budget | 单次调用、单 run、单租户的预算 | 控制成本和资源 |
| Typed error | 稳定错误码和 retryable 标记 | 让模型停止盲目重试 |
| Idempotency | 重复执行是否产生同一结果 | 防止外部副作用重复发生 |
| Permission | 是否允许读、写、执行 | 收口安全边界 |
| Observability | 记录耗时、大小、状态、错误 | 支撑排障和成本治理 |
从一个真实问题开始:一次 Tool 返回把任务拖垮
一个时间线分析 Agent 需要回答:
找出过去 24 小时风险最高的 10 个实体。
错误设计:
{
"name": "query_all_events",
"input": {
"tenantId": 7
}
}
这个工具看起来简单,实际会引发一连串问题:
- 查询没有时间范围,数据库可能扫数亿行。
- 返回没有 topK,可能带出几十万行。
- 没有字段白名单,原始 JSON 全量进入上下文。
- 没有分页,模型无法判断是否还有数据。
- 没有调用上限,模型可能反复查询。
- 没有稳定错误码,失败后模型只能盲试。
结果通常是:
工具返回 10 万行
-> 上下文膨胀
-> 模型注意力被稀释
-> 下一轮成本上升
-> 触发 compaction
-> 引用丢失
-> 模型重复查询
这不是“模型不够聪明”,而是 tool contract 没有把工程边界表达出来。
正确的 Tool 应该长什么样
同一个需求,应该拆成受限查询:
{
"name": "query_top_risk_entities",
"input": {
"tenantId": 7,
"startTs": 1790000000000,
"endTs": 1790086400000,
"groupBy": "entity",
"topK": 10,
"metric": "risk_score"
}
}
返回:
{
"ok": true,
"resolvedRange": {
"startTs": 1790000000000,
"endTs": 1790086400000
},
"entities": [
{
"entityId": "entity-9182",
"riskScore": 87.4,
"eventCount": 12345,
"evidenceWindowIds": ["win-1001", "win-1002"]
}
],
"totalEntities": 487320,
"truncated": true,
"nextCursor": "entity-cursor-9183"
}
这和错误设计的核心区别是:
- 查询有明确时间窗。
- topK 由工具 clamp,不是模型想多大就多大。
- 返回字段是白名单。
- truncated 明确告诉模型还有更多数据。
- nextCursor 让模型可以继续查,但每次仍受预算限制。
- evidenceWindowIds 把证据引用化,而不是把原始事件全塞进上下文。
Tool 的预算应该管什么
很多团队第一反应是“给 tool 估算 token”。这不够,也容易误导。
原因:
- token 计数只有模型 provider 是权威。
- 工具返回进入上下文后,还会受序列化和历史影响。
- 只扣 token 不控制数据形态,真实成本未必下降。
更可靠的预算结构是:
| 维度 | 控制点 | 示例 |
|---|---|---|
| 调用次数 | per-run / per-user / per-tenant | 单 run 最多 20 次 |
| 输出大小 | bytes / rows / fields / images | 单次最多 2KB |
| 执行时长 | timeout | 单次最多 10 秒 |
| 副作用 | read / write / approval | 写操作必须人审 |
| 成本 | model usage + tool cost | 按 run 聚合 |
一个 run 级预算可以这样表达:
{
"runId": "run-9f27",
"toolBudget": {
"maxToolCalls": 20,
"maxOutputBytes": 131072,
"maxDurationMs": 120000,
"maxExternalActions": 3
}
}
当预算耗尽时,不应该让模型继续尝试,而应该返回明确的错误:
{
"ok": false,
"errorCode": "TOOL_BUDGET_EXCEEDED",
"retryable": false,
"message": "本任务工具调用次数已达上限,请基于已有结果作答。"
}
工具错误要有类型
不能只返回“查询失败”。模型无法判断是该重试、换参数,还是直接停止。
更合理的错误格式:
{
"ok": false,
"errorCode": "TIME_RANGE_TOO_LARGE",
"retryable": false,
"message": "请缩小时间范围"
}
常用错误码:
| errorCode | 含义 | retryable |
|---|---|---|
| TIME_RANGE_TOO_LARGE | 时间范围过大 | false |
| TOO_MANY_ENTITIES | 请求实体过多 | false |
| TIMEOUT | 执行超时 | true |
| RATE_LIMITED | 被限流 | true |
| PERMISSION_DENIED | 无权限 | false |
| TOOL_BUDGET_EXCEEDED | 预算耗尽 | false |
有了 retryable,模型才能知道:
- true:可以等待后重试。
- false:应该换策略或基于已有结果作答。
输出设计:给模型结论和引用,不给原始世界
一个工具返回可以分成两类:
| 输出类型 | 模型可见 | 用途 |
|---|---|---|
| summary | 是 | 少量结论 |
| refs | 是 | 稳定 ID 引用 |
| rawPayload | 否 | 排障或后续精查 |
| imageId | 按需 | 需要视觉判断时再读取 |
这带来的直接收益是:
- 上下文不会膨胀。
- 引用可以被 validator 校验。
- 原始数据保留在数据查询层。
- 模型可以按需继续精查。
例如:
{
"summary": {
"topRiskEntityCount": 10,
"timeRange": "24h"
},
"refs": [
{
"entityId": "entity-9182",
"evidenceWindowIds": ["win-1001", "win-1002"]
}
],
"rawPayload": {
"objectKey": "s3://tool-results/run-9f27/query-1.json",
"bytes": 1048576
}
}
rawPayload 不进入模型上下文,只进入观测或审计。
数据库和查询下推
Tool 层不仅要限制返回,还要把查询下推到数据库。
错误做法:
select *
from raw_event
where tenant_id = 7
order by occurred_at desc;
正确做法:
select entity_id,
sum(event_count) as event_count,
sum(risk_score) as risk_score
from risk_event_rollup_hourly
where tenant_id = $1
and event_hour >= $2
and event_hour < $3
group by entity_id
order by risk_score desc
limit 10;
配套索引:
create index risk_rollup_query_idx
on risk_event_rollup_hourly (tenant_id, event_hour, entity_id);
这条索引的含义是:
- 先按租户隔离。
- 再按时间过滤。
- 再按实体聚合。
- 最后由数据库完成排序和 limit。
不要把 10 万行拉到应用层再排序,那会把成本从数据库转移到 Agent 上下文。
副作用工具必须拆成 proposal 和 execution
只读工具可以直接执行。
写工具应该分成两步:
Agent 生成 proposal
-> 权限 / 人审
-> Action Executor 执行
-> terminal result
-> audit
也就是说,工具本身不应该因为模型“想执行”就直接执行。
| 工具类型 | 是否直接执行 | 说明 |
|---|---|---|
| 只读查询 | 可以 | 无外部副作用 |
| 聚合查询 | 可以 | 只读 |
| 写文件 | 不可以 | 必须进入 proposal / approval |
| 发通知 | 不可以 | 必须进入 proposal / approval |
| 执行代码 | 不可以 | 必须进入沙箱和权限链路 |
和传统后端的关系
Tool Calling 的很多问题,本质还是后端服务治理问题:
| Agent 里的概念 | 传统后端里的对应物 |
|---|---|
| Tool schema | API contract / RPC 参数校验 |
| Tool timeout | 下游超时 |
| Retryable error | 可重试错误码 |
| Rate limit | QPS 限流 |
| Budget | 流量配额 / 资源配额 |
| Idempotency key | 写接口幂等键 |
| Typed error | HTTP status + 业务错误码 |
所以做过后端的人不需要重新学一遍“重试、降级、限流、幂等”,只需要把这些经验搬到 tool 边界上。
常见工程错误
| 错误 | 实际后果 | 正确做法 |
|---|---|---|
| 返回原始 row | 上下文膨胀、注意力被稀释 | 字段白名单 + topK |
| 没有分页 | 查询失控 | truncated + nextCursor |
| 错误无类型 | 模型盲目重试 | typed error + retryable |
| 只在 prompt 里限制次数 | 模型偶尔绕过 | runtime budget |
| 工具自己估算 token | 成本归因失真 | 记录真实 usage |
| 写工具直接执行 | 不可审计、不可回滚 | proposal + approval |
| 没有幂等 key | 重复执行外部动作 | action key |
| 没有权限分级 | 越权读写 | permission policy |
上线前检查
- 每个 tool 有 input schema。
- 每个 tool 有 output contract。
- 每个 tool 有 timeout。
- 每个 tool 有稳定 errorCode。
- run 级预算显式。
- 输出大小有上限。
- 大结果有分页。
- 只读和写操作分级。
- 写操作走 proposal / approval / executor。
- 观测记录耗时、大小、状态和错误。