第 4 章 · 工具调用基础

Harness 锚点|判别清单第 2 项「工具执行」——工具如何注册、校验、被调用并回填结果。

从“会写 SQL”到“能查数据”

那个卖家又来了。这次他问 Agent:“上个月哪个 SKU 卖得最好?”

如果 Agent 只有一个大模型,它会很诚实地卡住:它能写出一句查询销量的 SQL,却没法真的去数据库里这句 SQL;它知道该查什么,却够不到数据。它像一个知识渊博却被绑住手脚的分析师。

要让它真的答上来,就得给它松绑——给它一座通往数据库的桥,让它能“请求执行这句查询、再拿回结果”。这座桥,就是工具调用(Function Calling),也叫函数调用。它是 Agent 能“动手”的技术基础。

工具调用的三步:描述、请求、执行

工具调用听起来玄,其实就三步,而且有个关键前提要先说清楚:

模型自己不执行任何代码。 它只负责“生成一个调用请求”,真正的执行由你的程序完成。

这个边界至关重要——因为它意味着安全、权限、错误处理全都握在你手里(这也是第 28 章能做隔离的根基)。理解了这点,三步就顺理成章:

  1. 描述:你用结构化的方式告诉模型“你有哪些工具可用”——每个工具的名字、用途说明、参数格式。
  2. 请求:模型在推理时若判断需要某个工具,就输出一个调用请求——工具名 + 参数(通常是 JSON)。注意它只是“提出请求”,不是执行。
  3. 执行与回填:你的程序(或运行时)接过请求,真正执行对应函数,把结果作为消息喂回给模型。

回顾第 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 };
  },
});

三个部分对应三步:

  • namedescriptionparameters描述——它们会被转成模型能理解的 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 里,不一定有一个固定字段叫 descriptioninstructions;它们可能来自系统提示词、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开箱即用
piregisterTool 手写名字、描述、参数最直接、最透明

差异主要在“schema 是你手写还是框架推导”,底层机制完全一致。理解了本质,换任何框架你都知道该填什么。

小结

  1. 工具调用(Function Calling)是 Agent 能“动手”的基础:模型生成调用请求,你的程序执行——模型自己从不执行代码。
  2. 它正是 ReAct 里 Action + Observation 的实现:请求对应“做”,结果回填对应“看”。
  3. pi 用 registerTool 定义工具的名字、描述、参数、执行逻辑。
  4. 工具描述就是提示工程——写得越清楚,模型用得越准。
  5. Agent 级提示词要分清 descriptioninstructions 和运行时上下文,别把临时事实写成永久规则。
  6. 最小权限锁死工具的能力边界,从根上杜绝误操作。

下一章,我们看怎么批量接入别人已经写好的工具,而不必每个都自己实现——这就是 MCP 协议。