第6章 从“会说”到“会做”——Function Calling

小A在演示里做了个功能:让模型“帮我读一下配置文件”。界面上整整齐齐显示着“模型已读取 config.ts”,他翻日志时却愣住了:里面没有任何读文件记录。
“它不是说读了吗?”他问老Z。
“它只是‘说’了。”老Z说,“前置规则能约束模型如何表达,却不能让文本自动产生外部效果。模型写出的‘我已读取文件’只是一段文字,不是一次文件访问。”
“要让它真的读文件,得怎么办?”
“这就要讲到 Function Calling 了:把‘模型的话’和‘程序的动作’分开。”老Z说。
本章说明 Function Calling 如何把模型请求与实际执行分开;不讨论具体工具的权限设计。
先把名字对齐
| 中文术语 | 英文全称或缩写 | 本书工作定义 | 不等于什么 |
|---|---|---|---|
| 函数调用/工具使用 | function calling/tool use | 模型输出结构化调用请求的接口能力 | 宿主已经执行动作 |
| 工具调用 | tool call | 带名称、参数、调用 ID 的未执行请求 | 已验证的用户意图 |
| JSON Schema | JSON Schema | 描述参数数据形状的约束 | 权限、路径安全或业务授权 |
| 工具结果 | tool result | 执行器按调用 ID 回传的结构化状态 | 模型自称完成 |
| 并行调用 | parallel calls | 一轮出现多个调用请求的协议可能性 | 可以安全并发执行 |
6.1 模型请求不是程序动作
小A把术语表从头看了两遍,指着第一行问老Z:“‘读文件’这件事,到底算谁干的?”
“把模型想成点菜的人,程序是后厨。”老Z说,“点菜的人写好一张订单:‘read_file,参数 path=src/config.ts’,但订单不等于菜端上桌。Function Calling(也常称 tool calling)是一种协议能力:模型在响应中返回结构化的调用请求,写明想调用什么、参数是什么,执不执行由宿主程序决定。名称、字段和是否支持并行调用均随 API 而异,这条边界却是同一个:模型输出的只是请求。”
// 示意:尚未执行的请求
{ "name": "read_file", "arguments": { "path": "src/config.ts" } }“所以这段 JSON 本身不会读文件?”
“对。这段 JSON 既不读取文件,也不能证明路径允许读取。执行权永远在宿主程序。”
“模型为什么有时候表现得像‘已经做了’?”
“因为生成文本就是顺着上下文接话,‘我已读取文件’和它接出一段代码、接出一个 API 名字是同一回事。”老Z说,“没有 function calling,模型只能用自然语言描述自己想做什么;有了 function calling,这个‘想做什么’被写成了结构化请求。”
“要是不用它,硬从自然语言里解析‘读文件’呢?”
“你试试就知道。”老Z说,“模型可能写‘请读取 config.ts’,也可能写‘你看下配置文件呗’,还可能夹在别的句子里。你要解析动作、提取参数,还得防它自己脑补步骤。从文字里猜动作,等于把解析器当成另一个模型。function calling 做的事,是让模型在协议层面直接表达‘我要调用什么、参数是什么’,应用能解析、能校验、能拒绝,把这一步从‘解析散文’变成‘读结构化字段’。”
“它给出的参数呢,总归规整了吧?”
“规整归规整。”老Z说,“可信度还是老样子,它给出的文本都一样不可信。结构化改变的只是处理方式。”
比喻:点菜
模型是点菜的人,程序是后厨。订单写得再工整,菜也要后厨真的做;模型“说”读了文件,不等于文件被读过。
| 对比项 | 自然语言描述 | 结构化调用请求 |
|---|---|---|
| 模型表达方式 | 用句子“说想做什么” | 用字段“请求调用什么” |
| 应用怎么取信息 | 解析散文,容易出错 | 读结构化字段,可校验 |
| 可拒绝性 | 拒绝与否靠人判断 | 宿主可直接拒绝并回原因 |
| 执行保证 | 无 | 仍无:协议不负责执行 |
6.2 从声明到结果的闭环
比喻听懂了,小A接着问流程:“一次完整的‘点菜到上菜’,有几步?”
“一个完整回合至少包含四步。”老Z说:
- 应用向模型声明可调用能力及其参数 schema;
- 模型返回一个或多个调用请求;
- 应用验证请求并执行,或拒绝执行;
- 应用将结构化结果关联回原调用,再让模型决定下一步。
老Z把四步画成一张图:
① 声明工具 + schema ──> ② 模型返回调用请求(id, name, args)
^ │
│ ↓
④ 结果按 id 回填给模型 <── ③ 校验 → 执行 / 拒绝
│
结构化结果“声明这步呢?模型是怎么知道有哪些工具的?”
“应用在请求里带上工具列表,通常是名称、描述和参数 schema。”老Z说,“模型读到这份列表,结合用户任务,决定要不要调用、调用哪个、参数填什么。声明漏了一个必要工具,模型就只能凑合用别的,或者用自然语言告诉你它做不到。”
“描述写得不好呢?”
“模型就会选错或传错参数,这是第8章 给能力装上把手——Tool要展开的问题。”老Z说,“本章只关注调用协议本身的闭环。”
“少一步会怎样?”
“先看第④步。结果不回传,模型就只有前半截信息:知道‘我想做什么’,永远不知道‘做成了没有’,这就像打电话没人接,对面听不到你的下一句。”老Z说,“第三步的验证要是跳过,模型就越过了权限边界。环闭合起来,结果送回去,模型才能进入下一轮决策。”
“回到第③步,校验到底验什么?”
“至少两层:参数形状对不对,以及这个调用该不该被允许。”老Z说,“形状错,直接拒绝并说明原因;该不该允许,看宿主策略,这层 6.3 会细讲。校验要做的,是把错误挡在落地之前。”
要点
完整闭环 = 声明 → 请求 → 校验执行 → 回传结果。缺回传,模型“瞎”;缺校验,模型“越权”。
6.3 schema 约束形状,不保证意图
小A在术语表里圈出 JSON Schema 那一行,松了口气:“有了这个约束,参数是不是就不会传错了?”
“不会。参数 schema 可以要求 path 是字符串、line 是整数、mode 属于枚举。”老Z说,“这能减少格式错误,也便于生成 UI 和校验错误。但 schema 不能证明 path 位于工作区、请求是否符合用户目的、命令是否安全,这些要在 schema 之后再做业务和安全校验。”
模型参数 → schema 校验 → 权限/路径/策略校验 → 执行 → 标准化结果“它还是会传错吧?”
“会。漏必填字段、类型不对、枚举值越界,甚至整段无效 JSON、干脆选错工具,都算。”老Z说,“执行器把这些当成正常错误路径:把校验失败、失败原因、期望的形状回给模型,让它修正参数再试一次,任务照样往下推进。”
“有 schema 合法、意图却错的例子吗?”
“能。schema 要求 path 是字符串,模型填了 path: "/etc/passwd",格式合法,路径越界。”老Z说,“或者 schema 要求 recursive: boolean,模型填了 true,格式合法,但语义上你不一定希望递归删除。schema 是形状约束,意图要由权限层和业务层判断,这两层不能合并成一道。”
| 校验层 | 检查什么 | 抓不住什么 |
|---|---|---|
| schema 校验 | 字段类型、必填、枚举值 | 路径越界、命令危险、意图错误 |
| 权限/路径校验 | 是否在工作区内、是否允许写 | 业务语义是否正确 |
| 业务/审批 | 是否符合用户本次意图 | —(最后一道) |
小A把表格从上到下扫了一遍:“这三层是串行的,一层都不能省?”
“对。任何一层放行,后面一层都得自己兜。”老Z说,“schema 过了不代表路径安全,路径安全不代表意图正确,省了哪层,就把那层的风险留给下一层,或者留给事故。”
“这些错一般是什么原因?”
“漏字段,常常是任务描述里没给足信息;传错类型,是模型把‘字符串’填成了数组;选错工具,是声明里的描述没写清楚。”老Z说,“三类原因都值得回填给模型,让它下一次调用更有依据。”
| 常见错误 | 示例 | 回给模型的信息 |
|---|---|---|
| 漏必填字段 | path 缺失 | 提示缺哪个字段,要求补全 |
| 类型不对 | path 填成数组 | 提示期望类型,说明为何不合法 |
| 枚举值越界 | mode 填了列表外的值 | 列出可选枚举值 |
| 工具选错 | 用 read_file 读不存在的配置 | 说明调用失败原因,允许换工具 |
“这四类都算 schema 层的?”
“前三类算,第四类更像工具选择问题。”老Z说,“但处理方式一样:结构化地告诉模型‘这个调用不成立、为什么’,别让它面对一个空结果自己猜。”
“schema 声明本身呢?写得详细还是粗略好?”
“详细到能约束形状,别详细到写成散文。”老Z说,“schema 起的是护栏作用:必填、类型、枚举写清楚,模型照着填;业务背景是给工具开发者的,描述里不用堆。”
6.4 流式与并行的边界
轮到流式了,小A先替后厨担心起来:“流式输出会不会把订单‘撕碎’?”
“会。流式协议中,参数可能分多段到达。”老Z说,“未收到完成标记前,任何部分 JSON 都只能用于预览或进度展示,不能作为执行依据。”
“流到一半的东西长什么样?”
“比如参数字段流到一半是 { "path": "src/con,你能看出它要读 src/ 下某个文件,但不知道完整路径。”老Z说,“拿这半截去执行,要么报错,要么读错文件。正确的做法是等完成事件,把完整参数拼起来,再做 schema 校验。流式是给 UI 显示进度用的。”
老Z又补了一张流式示意:
时间 → t1 t2 t3 t4
{ "name": "read_ file", (完成事件)
{ "path": "src/co nfig.ts" } ← 此刻才能解析
└─────────────────────────────────────┘
之前任何一帧都只是未完成的碎片“在 t1、t2、t3 任一帧拿到的都是不完整的参数。”老Z说,“有的实现用增量 delta 字段表示‘又多了这一块’,有的直接给分片,字段名随 API 而异,但都一样:完成事件之后的内容,才轮到校验和执行。”
“术语表里那行‘并行调用’,一轮真能回来好几个请求?”
“对,某些服务允许同一响应请求多个调用。”老Z说,“但模型这么写,只是表达‘这几个我都要’;谁先执行、能不能同时执行,由宿主根据资源依赖判断,模型一次提了几个,说明不了并行安不安全。两个读不同文件可以并行;一个读一个写同一个文件,就得串行;写同一个文件、同一数据库记录或同一外部订单,同样要串行或加锁。”
“资源关系具体怎么分?”
“按三类看。”老Z说,“读读无冲突,两个只读调用并行最安全;写写冲突,两个写同一资源的调用串行或加锁;读写依赖,一个调用的输入来自另一个调用的输出,后一个必须等前一个。并行是优化,串行是底线,先保证不冲突,再谈快。”
| 资源关系 | 例子 | 执行方式 |
|---|---|---|
| 读读 | 读 A、读 B | 可并行 |
| 写写 | 都写同一个文件 | 串行或加锁 |
| 读写依赖 | 先读配置再据此创建 | 后者等前者 |
“并行会带来什么额外成本?”小A问。
“结果对账的成本。”老Z说,“并行执行后,结果是一批一起回来的。没有调用 ID,你分不清哪个结果对应哪个请求;有了 ID,还要处理部分成功:三个调用一个失败两个成功,剩下的任务还推不推进、怎么推,都要宿主定。并行省了时间,把排序和对账的成本留给了宿主。”
6.5 失败、重试和幂等
并行讲完,小A掉头去想另一头:“执行失败了呢?”
“工具结果要区分成功、可恢复失败、拒绝、超时和已取消,并给模型足够的机器可读信息。”老Z说,“模型调用或流式传输的重试并不天然幂等;若工具会发消息、付款、删除或写入,重复执行可能产生重复副作用。为此需要调用 ID、幂等键、执行记录或人工审批,不能只靠模型‘不要重复’。”
“失败结果回传给模型,它会怎么处理?”
“取决于失败类型,但放行权在宿主。”老Z说,“可恢复失败(如文件暂时锁定),模型可以提出等待后重试;拒绝(如路径越界),模型可以改路径或放弃;超时(不知道远端是否生效),不能盲目重试,而要查执行记录或请求人工确认。是否允许重试、重试几次、哪些错误可重试,必须由宿主策略判定;工具调用不是安全重试的默认许可。”
| 失败类型 | 含义 | 模型合理的下一步 |
|---|---|---|
| 可恢复失败 | 临时故障(锁、限流) | 等待后重试 |
| 拒绝 | 权限或路径不允许 | 改参数或放弃 |
| 超时 | 远端状态未知 | 查记录、用幂等键或转人工 |
| 已取消 | 外部 signal 中止 | 停止当前回合 |
小A又想到一种情况:“‘静默成功’算什么?”
“那是最糟的。”老Z说,“工具执行失败却不回传,或回传成‘成功’,模型就在错误信息上继续推进,产出看似合理实则错误的结果。失败要显式、要可分类、要带上下文。”
小A再问:“失败结果里至少要带什么?”
“三条信息缺一不可:状态码或状态名、失败类型、机器可读的原因。”老Z说,“光有‘失败’两个字,模型不知道该重试、改参数还是放弃;光有原因也不够,缺了状态,程序没法自动分支。给模型的信息,要让它分得清‘这步还能补救’和‘这步根本没戏’。”
“重试呢?多试几次总没事吧,读个文件而已。”小A猜。
“读文件确实读不坏,重试十次也一样。”老Z说,“可发一条消息、扣一次款,重试一次就多一个副作用。重试安不安全,看操作能不能重复执行。所以约定:可重入的调用,失败后重试没关系;不可重入的调用,重试必须带幂等键,或者先查执行记录再决定。”
| 防护手段 | 防什么 | 用在什么时候 |
|---|---|---|
| 调用 ID | 结果对不上请求 | 并行、流式、多轮调用 |
| 幂等键 | 重复执行产生重复副作用 | 远端创建、支付、发消息 |
| 执行记录 | 无法判断上次是否生效 | 超时、断线、恢复 |
| 人工审批 | 不可逆动作被盲目重放 | 删除、发布、大额支付 |
小A把“幂等键”三个字记在本子上:“这键长什么样?”
“它是这次操作唯一的标识,由调用方生成,远端记住‘这个键已经处理过’。”老Z说,“重试时带上同一个键,远端看到重复就直接返回第一次的结果,不再执行一遍。这套判断依据放在远端,调用方要做的只有两件事:保证键唯一,重试时不变。”
“没有幂等键的话,最安全的替代是什么?”
“查执行记录。”老Z说,“超时后先查‘这个调用到底生效了没有’,生效就不动,没生效才重试。查不到记录,就转人工确认。三个手段都是把‘未知’变成‘已知’,只是代价不同。”
“重试的次数和间隔呢?谁来定?”
“宿主定上限,模型可以参与要不要重试的决策,但次数和间隔是策略。”老Z说,“模型说‘再试一次’,宿主查一下重试预算:还剩几次、距上次多久,够了才放行。让模型无限重试,等于把成本控制权交给了不可靠的一方。”
6.6 调用关联与补偿
回到并行那个对账的麻烦,小A问老Z:“两个工具同时跑,怎么保证结果不串?”
“每一次调用都应有稳定的调用 ID;工具结果必须带回同一 ID,才能在并行时避免把 A 的结果交给 B。”老Z说,“执行记录至少区分‘已请求、已批准、执行中、成功、失败、已拒绝、已取消’。这既是恢复依据,也是审计边界。”
老Z用一条线把一次调用从头连到尾:
模型请求(id) → 校验 → 批准 → 执行 → 结果(id, status) → 回填消息
└── 拒绝/超时 ────────────────┘“调用 ID 为什么这么要紧?”
“因为并行。”老Z说,“一轮里模型请求了三个调用:读 A、读 B、写 C。三个结果回来时,没有 ID 关联,就可能把写 C 的失败当成读 A 的失败,模型于是去改 A 的路径,真正出事的 C 没人处理。调用 ID 是结果和请求之间的接线,丢了这根线,并行就变成一锅粥。”
“ID 是模型生成的,还是宿主生成的?”
“通常是模型在请求里带上,宿主在结果里原样回填。”老Z说,“具体字段名随 API 而异,但‘请求和结果能配对’这个要求不变。如果模型没带 ID,宿主就得自己分配并维护映射,总之必须有人负责配对,不能假设结果自然对得上。”
小A越听越觉得这根线不能丢:“配对失败会怎样?”
“会串台。”老Z说,“两个并行调用 A、B,如果 B 的结果被标成 A 的回填,模型会以为‘读 A 成功了’,实际上读到的是 B 的内容。错误不一定立刻暴露,而是在模型下一步决策里悄悄生效。ID 配对失败,比结果失败更隐蔽,因为它长得像成功。”
“执行记录里,每种状态对应什么动作?”
“成功才能继续推进,失败要带原因回填,拒绝要说明拒绝理由,取消要停掉当前回合。”老Z说,“状态分得越粗,恢复时越要猜。”
“这些状态,跟审计是什么关系?”小A再问。
“审计就是状态的完整回放。”老Z说,“没有状态,审计就无从谈起;出了事,你只能翻回忆复盘,而回忆这本日志最靠不住。所以每个调用记下 ID、发起者、时间、参数摘要、状态迁移,出了事才能回答‘这个动作是谁在什么时候发起的、经过了几次重试、最后停在哪个状态’。”
“参数摘要是什么意思?要存完整参数吗?”
“存摘要,不存敏感内容。”老Z说,“路径、命令这类便于排查的信息可以存;密钥、令牌这类一旦落库就是新的泄露面。日志里存什么,取决于丢了你查不查得回来,以及存了会不会惹祸。”
示意:
create_issue在网络超时后,调用方不知道远端是否已经创建。此时直接重试可能得到两个 Issue;更安全的设计是使用服务支持的幂等键、查询可关联记录,或交给人确认。不是所有副作用都能补偿,尤其是发送通知、支付或删除数据。
6.7 调用是小型事务,不是 JSON 片段
一章听下来,小A先想要一张总图:“这一整套,能不能画成流程图?”
“可以,而且应该。”老Z说着,在纸上拉出一条主线:
声明 ToolSpec → model tool call(id) → schema 校验
→ 策略/授权校验 → 执行或拒绝 → tool result(id, status) → 写回下一轮“正常路径之外,最容易踩的坑是什么?”
“两个反例。”老Z说,“反例一是参数符合 schema 但指向工作区外文件,必须由执行边界拒绝。反例二是远端创建动作超时,调用方不知道是否已生效;直接重试会重复副作用,须查执行记录、使用服务的 idempotency key 或请求人工确认。”
“这些字段是行业标准吗?”
“不是。各家的工具调用文档只说明自己接口的事件和结果回传:OpenAI Chat Completions 把调用放在 tool_calls,结果用 role: "tool" 的消息回传;Anthropic Messages 用 tool_use 内容块发起、tool_result 内容块回传。具体字段没有统一标准。”老Z说,“本书的工程取舍是把 schema、权限、并发和结果分别建模:可测试且可审计,代价是需要维护稳定 ID、状态机和补偿/确认路径。”
“这个取舍也画进图里?”
“可以。”老Z这回连岔路一起标了出来:
┌────────────── 一次完整调用的事务边界 ──────────────┐
│ │
用户任务 → 声明 → 请求(id) → schema → 策略 → 执行 → 结果(id) → 回填
│ │ │
形状错误 越权拒绝 超时
│ │ │
回填原因 回填原因 查记录/幂等键
│
无法确认 → 人工确认
└─────────────────────────────────────────────┘“这张图里每一格都是可测试的。”老Z说,“形状校验、策略校验、执行、回填,每一层输入输出固定,可以单独写测试。把一次调用当小事务来设计,测试就能覆盖,恢复就能有据,审计就能追溯。”
“实际项目里,这套流程要不要每个调用都走全?”
“要看调用类型。”老Z说,“只读的、纯本地的、失败无副作用的调用,可以走精简路径;会写外部、会发消息、会扣款的调用,才值得把事务状态机铺全。”
小A抓住最后一处不放心:“‘模型说完成了’和‘执行器返回成功’,到底差在哪?”
“前者是语言,后者是事实。”老Z说,“结构化调用让这种不确定性可见,却不会自动消除它。模型说‘我已经创建了 Issue’,只是一句话;执行器返回 status: success + issue_id,才是可以拿去核对的证据。”
小结
Function Calling 让模型从“会说”走到“会做”,靠的是让它学会提需求:输出一个带工具名称、参数和调用 ID 的结构化请求,动手的仍然是宿主程序。请求本身什么都没执行,执行权始终在宿主程序手里,这个区分是整条可靠链路的根基。结构化让请求可以被解析、被校验、被拒绝,内容的可信度并未因此改变;从自然语言里猜动作,等于把解析器当成另一个模型。一次完整的工具往返分四步:声明工具让模型知道有什么可用,校验模型给出的参数是否合法,执行通过校验的调用,把结果按调用 ID 回传给模型。环不闭合,模型就只有“想做什么”,永远不知道“做成了没有”。JSON Schema 负责描述参数的形状,参数是否符合用户意图要靠 schema 之后的运行时校验把关,模型可能给出一个 schema 合法、语义完全错误的参数,所以这道校验不能省。
校验本身分三层:schema 管形状、权限层管范围、业务层管意图,任何一层放行,后面一层都得自己兜。校验失败也算不上任务失败,它只是模型需要一次修正的机会:把失败原因结构化地回给模型,比让它面对空结果自己猜值钱得多。真正的风险藏在结果回传这一步:工具执行失败也是一种结果,把失败明确地回传给模型,模型才有机会调整策略,重试或放弃;把失败静默吞掉,模型就会在信息缺失的情况下继续编造。失败要显式、要可分类、要带上下文:可恢复失败等待重试,拒绝就改参数,超时不能盲目重试,要查记录或用幂等键。给模型的信息,要让它分得清“这步还能补救”和“这步根本没戏”。
一轮里可能出现多个并行的调用请求,但“请求并行”不等于“执行并行”,到底是不是真并行,由宿主的执行策略决定,并行的安全性来自资源分析,跟模型一次提了几个无关。调用 ID 是请求和结果之间的接线,丢了这根线,并行就变成一锅粥;配对失败比结果失败更隐蔽,因为它长得像成功。把这一切串起来,一次工具调用应当按小型事务来设计:稳定的调用 ID、明确的状态集合、可补偿的失败路径,以及对“模型说完成了”和“执行器返回成功”这两件事的严格区分。
防护的厚度跟着副作用的大小走:只读、无副作用的调用走精简路径,会写外部、会发消息、会扣款的调用才值得铺全状态机。跳过四步里的任何一步,尤其跳过校验,就会产生无法追溯的副作用。等工具数量多起来、动作开始涉及外部系统,“怎么让动作真正落地”就从协议问题变成了工具本身的接口设计问题。
动手核验
实现一个只读的 read_file 模拟执行器。分别喂入缺少路径、工作区外路径和合法路径的调用请求,确认每种结果都有不同状态且不会读取未授权文件。再模拟一段未完成 JSON,确认它不会进入执行器。
