8. 给 Agent 装上手:工具调用
这是什么:工具(Tool) = Agent 能调用的外部能力,模型通过函数名、描述和参数 schema 认识它。
为什么用:Agent 的能力上限由工具决定——模型看不见你的函数体,只能读工具描述;描述写不清,它就会瞎调。
如何用:本章把工具说明书、参数 schema、返回值格式和危险操作的刹车这四件事一次讲透。
8.1 工具描述是写给模型看的说明书
模型看不到你的函数体,它只能读 description。描述含糊,模型就会瞎调:
| 描述写法 | 模型的理解 |
|---|---|
"查天气" |
不知道传什么参数,也不知道返回什么 |
"查询指定城市的当前天气。城市名用中文,如 '北京'。返回形如「北京:晴,26℃」的文本。" |
知道传什么、传什么格式、期待什么结果 |
写描述的三个要点:
- 说清边界:什么情况下该用、什么情况下不该用
- 说清参数:格式、取值范围、单位
- 说清返回:模型会照着你的描述复述结果
{
"name": "query_db",
"description": "查询订单表。status 取值限 'pending'/'paid'/'cancelled';"
"返回最多 20 条记录的 JSON 字符串,字段为 id、amount、created_at。",
"parameters": {
"type": "object",
"properties": {
"status": {"type": "string", "enum": ["pending", "paid", "cancelled"]},
"limit": {"type": "integer", "description": "返回条数,默认 20,最大 100"}
},
"required": ["status"]
}
}
enum 是个好用的东西——它把可选值直接告诉模型,模型基本不会传错。
8.2 参数设计:宁可多问一句
模型传错参数是常事。两种防御:
① 用 Schema 约束(类型、枚举、必填、默认值):
"parameters": {
"type": "object",
"properties": {
"days": {"type": "integer", "description": "未来几天,1-30", "default": 3},
"city": {"type": "string", "description": "城市名"}
},
"required": ["city"]
}
② 在函数里兜住脏数据,别让异常直接把 Agent 掀翻:
def query_db(status: str, limit: int = 20):
limit = max(1, min(limit, 100)) # 夹紧范围
status = status.strip().lower()
if status not in ("pending", "paid", "cancelled"):
return f"status 只能是 pending/paid/cancelled,收到的是 {status!r}"
...
返回一句人话让模型自己纠正,比抛异常好:
return "没找到该城市,请确认城市名后重试" # 模型会追问或换参数
8.3 返回值:给模型看什么,不给什么
模型读得懂自然语言,读不懂冗长的原始数据。
| 做法 | 效果 |
|---|---|
| 直接返回 API 的完整 JSON | 噪音大,token 烧得快,模型容易看错重点 |
| 返回裁剪过的关键字段 + 人类可读的格式 | ✅ 模型抓重点快,回答自然 |
# ❌ 噪音
return json.dumps(data, ensure_ascii=False) # 可能几千 token
# ✅ 裁剪 + 结构化 + 人类可读
lines = [f"订单 {o['id']}:{o['amount']} 元,状态 {o['status']}" for o in rows]
return f"共 {len(rows)} 条:\n" + "\n".join(lines)
还有一个常被忽略的点:结果里要带上「规模」信息。模型不知道返回了 20 条还是 20000 条,就没法判断要不要再查。
8.4 危险工具:给 Agent 装个刹车
能读文件、跑命令、发请求的工具,一旦被诱导就可能造成真实损失。三道防线:
① 分级:只读工具随便给,写/执行类工具要克制。
READ_ONLY = {"query_db", "read_file", "search_docs"} # 无副作用
SIDE_EFFECT = {"send_email", "delete_file", "run_command"} # 有副作用
② 人工确认:有副作用的操作,先问人。
if name in SIDE_EFFECT:
ok = input(f"Agent 想执行 {name}({args}),确认?(y/N) ")
if ok.strip().lower() != "y":
result = "用户拒绝了这次操作。"
else:
result = run_tool(name, args)
③ 范围收紧:别把整台机器交出去。
def run_command(cmd: str) -> str:
ALLOWED = {"ls", "cat", "git status", "git log", "pytest"}
if not any(cmd.startswith(a) for a in ALLOWED):
return f"命令不在白名单内,已拒绝:{cmd}"
...
这三条不是「以后再说」的事——Agent 拿到工具的那一刻,权限就已经给出去了。
8.5 工具太多也有问题
给 Agent 挂 30 个工具,它会开始「选择困难」,甚至调错。经验值:
- 常用工具控制在 5~10 个
- 工具多的时候按场景分组,让模型分批用
- 把功能相近的工具合并成一个带参数的(比如「查天气」和「查空气质量」合成「查环境」)
8.6 常见翻车现场
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
| 模型老调错工具 | 两个工具描述太像 | 把「什么时候用我」写进 description |
| 参数总是传错 | Schema 太松 | 加 enum / type / 必填 / 默认值 |
| 工具一报错 Agent 就死 | 没兜住异常 | try/except,把错误信息回传 |
| 结果太长烧 token | 直接返回原始数据 | 裁剪 + 聚合 |
| Agent 删了你的文件 | 没设人工确认 | 副作用操作加确认和白名单 |
| 模型忽略某个工具 | 描述没写清或和其他工具重叠 | 改描述,或合并工具 |
8.7 动手改
- 给你手上真实的工具写一版
description,故意写得很含糊,跑一次看模型怎么误解 - 给一个有多个可选值的参数加上
enum,对比加与不加的调用准确率 - 给你的循环加一条规则:同一个工具连续调两次就拒绝,看模型怎么反应
8.8 小结
- 工具描述是写给模型看的说明书:边界、参数、返回三样都要写清
- Schema 约束能挡住大部分参数错误,
enum最好用 - 返回值裁剪 + 人类可读,并带上规模信息
- 副作用工具要分级 + 人工确认 + 白名单
- 工具控制在 5~10 个,多了模型会选错
下一章:记忆与上下文,让 Agent 记住东西、又不被上下文撑爆 🐸