第 4 章 · 工具调用基础
Harness 锚点|判别清单第 2 项「工具执行」——工具如何注册、校验、被调用并回填结果。
从“会写 SQL”到“能查数据”
那个卖家又来了。这次他问 Agent:“上个月哪个 SKU 卖得最好?”
如果 Agent 只有一个大模型,它会很诚实地卡住:它能写出一句查询销量的 SQL,却没法真的去数据库里跑这句 SQL;它知道该查什么,却够不到数据。它像一个知识渊博却被绑住手脚的分析师。
要让它真的答上来,就得给它松绑——给它一座通往数据库的桥,让它能“请求执行这句查询、再拿回结果”。这座桥,就是工具调用(Function Calling),也叫函数调用。它是 Agent 能“动手”的技术基础。
工具调用的三步:描述、请求、执行
工具调用听起来玄,其实就三步,而且有个关键前提要先说清楚:
模型自己不执行任何代码。 它只负责“生成一个调用请求”,真正的执行由你的程序完成。
这个边界至关重要——因为它意味着安全、权限、错误处理全都握在你手里(这也是第 28 章能做隔离的根基)。理解了这点,三步就顺理成章:
- 描述:你用结构化的方式告诉模型“你有哪些工具可用”——每个工具的名字、用途说明、参数格式。
- 请求:模型在推理时若判断需要某个工具,就输出一个调用请求——工具名 + 参数(通常是 JSON)。注意它只是“提出请求”,不是执行。
- 执行与回填:你的程序(或运行时)接过请求,真正执行对应函数,把结果作为消息喂回给模型。
回顾第 3 章的 ReAct 循环:第 2 步就是 Action,第 3 步的结果回填就是 Observation。工具调用,正是 ReAct 里“做”和“看”这两拍的具体实现。
用 pi 实现:registerTool
在 pi 里,定义一个工具就是调用 registerTool。给那个卖家做一个查销量的工具:
pi.registerTool({
name: "query_sales",
description: "查询指定时间段内各 SKU 的销量,返回按销量降序的结果",
parameters: {
type: "object",
properties: {
start: { type: "string", description: "开始日期,如 2026-06-01" },
end: { type: "string", description: "结束日期,如 2026-06-30" },
limit: { type: "number", description: "返回前几名,默认 10" },
},
required: ["start", "end"],
},
async execute({ start, end, limit = 10 }) {
const rows = await db.readonly(
`SELECT sku, SUM(qty) AS sold FROM orders
WHERE date BETWEEN $1 AND $2
GROUP BY sku ORDER BY sold DESC LIMIT $3`,
[start, end, limit],
);
return { rows };
},
});
三个部分对应三步:
name、description、parameters是描述——它们会被转成模型能理解的 schema(结构化的能力说明)。- 模型输出调用请求后,运行时自动匹配到这个工具(请求)。
execute()是执行——它的返回值会被序列化,作为 Observation 回填进对话。
注意一个细节:查询走的是 db.readonly(只读副本)。这不是随手写的——工具的能力边界,你在定义时就该锁死。给分析类工具只读权限,能从根上杜绝“模型一时糊涂改了生产数据”的风险。
工具描述,就是写给模型的说明书
这一节要单独强调,因为它是新手最容易忽略、却最影响效果的地方:
工具的
description和参数说明,本身就是提示工程(Prompt Engineering)的一部分。
模型完全靠这些文字来判断“什么时候该用这个工具、该传什么参数”。描述含糊,模型就会用错、传错、或该用时不用。几条实践经验:
- 说清用途和时机:“查询销量”不如“查询指定时间段内各 SKU 的销量”——后者让模型知道什么问题该找它。
- 参数写明格式和示例:“开始日期,如 2026-06-01”比光写“开始日期”可靠得多,模型照着例子传,不容易格式出错。
- 在描述里暗示安全属性:“在只读副本上执行”这类话,既是给人看的,也在告诉模型“这个操作安全、可放心用”。
- 别贪多:一个工具只干一件清楚的事。把“查询+修改+导出”塞进一个工具,模型反而分不清何时用。
一句话:你把工具描述写得多清楚,模型就把工具用得多准。
Agent 级提示词:description、instructions、context
工具有自己的 description,Agent 也需要自己的说明。新手常把所有提示词都塞进一个大 system prompt:角色定位、行为规则、用户资料、当前任务、临时约束混在一起。短期能跑,长期会变得难维护,也容易把“本轮才有效”的信息误写成“永久规则”。
更稳的写法,是把 Agent 级提示词拆成三层:
| 层 | 回答的问题 | 应该写什么 | 不该写什么 |
|---|---|---|---|
| description(角色描述) | “你是谁,负责什么?” | Agent 的身份、领域、能力边界,例如“你是面向外贸卖家的经营分析 Agent” | 具体用户数据、临时任务、今天的筛选条件 |
| instructions(行为指令) | “你做事时必须遵守什么规则?” | 输出格式、工具使用原则、安全边界、失败时如何处理 | 会频繁变化的上下文事实 |
| runtime context(运行时上下文) | “这一次任务有哪些已知事实?” | 当前用户、会话状态、文件、订单、检索结果、临时偏好 | 长期角色设定和通用行为规则 |
例如同一个销量分析 Agent,可以这样拆(下面是提示词配置伪代码,不是 pi 的固定 API):
const agentPrompt = {
description: "面向跨境电商卖家的经营分析 Agent,擅长解释销量、库存与广告数据。",
instructions: [
"回答经营问题前,优先使用只读数据工具查询真实数据。",
"涉及金额、销量、日期时,必须说明数据口径。",
"如果工具返回错误,不要编造结果,说明失败原因并给出下一步建议。",
],
};
const runtimeContext = {
userId: "seller_123",
shopId: "shop_456",
timezone: "Asia/Shanghai",
currentQuestion: "上个月哪个 SKU 卖得最好?",
};
在 pi 里,不一定有一个固定字段叫 description 或 instructions;它们可能来自系统提示词、Agent 配置、Skill 文档或扩展注入。重要的不是字段名,而是职责分层:
- 角色描述要稳定:它定义 Agent 的身份和边界,改动应该少。
- 行为指令要可审查:它定义规则,适合版本化、复用和测试。
- 运行时上下文要短命:它只服务当前调用,用完就该随会话流转或被压缩,而不是写回永久提示词。
这三层分清后,后面的工具选择也会更稳:角色描述告诉模型“我大概负责哪类事”,行为指令告诉模型“遇到数据问题要查工具”,运行时上下文告诉模型“这次该查哪个店、哪个时间段”。
动手看看
在 pi 里,工具能力主要通过扩展(第 8 章)来注册。可以先读 packages/coding-agent/docs/extensions.md 里关于 registerTool 的部分,看它支持哪些字段;再翻 pi 内置的工具(如文件读写、shell 执行)是怎么描述自己的——它们的 description 写法,是很好的模仿范本。
实战中的几个坑
坑一:描述太笼统,模型乱用。
- 现象:模型在不该用时调用、或传错参数。
- 原因:
description和参数说明不够具体。 - 对策:把用途、时机、参数格式都写清楚,并给示例。
坑二:工具执行报错没兜住。
- 现象:工具内部抛异常,整个循环崩了。
- 原因:
execute没做异常处理。 - 对策:捕获异常,返回结构化错误(
{ error: "..." }),让模型知道失败了、可重试或换路。
坑三:返回结果太大,撑爆上下文。
- 现象:一次查询返回上万行,全塞回对话。
- 原因:工具没做分页/截断就回填。
- 对策:在工具里就限制返回量(如例子里的
limit),只回填必要部分。
坑四:权限给太宽。
- 现象:本该只读的工具能写、能删。
- 原因:定义工具时没锁死能力边界。
- 对策:用最小权限(只读连接、受限目录),别指望模型自觉。
对比:其他框架
各家定义工具的方式不同,但都是这套“描述—请求—执行”:
| 框架 | 怎么定义工具 | 特点 |
|---|---|---|
| LangGraph | 沿用 LangChain,用 @tool 装饰器,docstring 即描述 | 生态成熟,工具可复用 |
| CrewAI | 工具挂在角色上,声明该角色能用哪些;兼容 LangChain 工具 | 以角色为中心 |
| PydanticAI | 参数用 Python 类型标注,从类型自动推导 schema | 省去手写 schema,带类型校验 |
| Agno | 内置大量现成工具,自定义则从函数签名自动生成 schema | 开箱即用 |
| pi | 用 registerTool 手写名字、描述、参数 | 最直接、最透明 |
差异主要在“schema 是你手写还是框架推导”,底层机制完全一致。理解了本质,换任何框架你都知道该填什么。
小结
- 工具调用(Function Calling)是 Agent 能“动手”的基础:模型生成调用请求,你的程序执行——模型自己从不执行代码。
- 它正是 ReAct 里 Action + Observation 的实现:请求对应“做”,结果回填对应“看”。
- pi 用
registerTool定义工具的名字、描述、参数、执行逻辑。 - 工具描述就是提示工程——写得越清楚,模型用得越准。
- Agent 级提示词要分清
description、instructions和运行时上下文,别把临时事实写成永久规则。 - 用最小权限锁死工具的能力边界,从根上杜绝误操作。
下一章,我们看怎么批量接入别人已经写好的工具,而不必每个都自己实现——这就是 MCP 协议。