Skip to content

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

小a在演示里做了个功能:让模型“帮我读一下配置文件”。界面上整整齐齐地显示着“模型已读取 config.ts”,但小a翻日志时愣住了——里面没有任何读文件记录。

“它不是说读了吗?”他问老z。

“它只是‘说’了。”老z说,“前置规则能约束模型如何表达,却不能让文本自动产生外部效果。模型写出的‘我已读取文件’只是一段文字,不是一次文件访问。”

“那要它真的读文件,得怎么办?”小a问。

“这就要讲到 Function Calling——把‘模型的话’和‘程序的动作’分开。”老z说。

本章说明 Function Calling 如何把模型请求与实际执行分开;不讨论具体工具的权限设计。

先把名字对齐 ​

中文术语英文全称或缩写本书工作定义不等于什么
函数调用/工具使用function calling/tool use模型输出结构化调用请求的接口能力宿主已经执行动作
工具调用tool call带名称、参数、调用 ID 的未执行请求已验证的用户意图
JSON SchemaJSON Schema描述参数数据形状的约束权限、路径安全或业务授权
工具结果tool result执行器按调用 ID 回传的结构化状态模型自称完成
并行调用parallel calls一轮出现多个调用请求的协议可能性可以安全并发执行

6.1 模型请求不是程序动作 ​

“那‘读文件’到底算谁干的?”小a问。

“你可以把模型想成点菜的人,程序是后厨。”老z说,“点菜的人写好一张订单:‘read_file,参数 path=src/config.ts’——但订单不等于菜端上桌。Function Calling(也常称 tool calling)是一种协议能力:模型在响应中返回结构化的‘希望调用什么、参数是什么’,宿主程序决定是否执行。名称、字段和是否支持并行调用均随 API 而异;通用边界不变:模型输出只是不可信的请求。”

json
// 示意:尚未执行的请求
{ "name": "read_file", "arguments": { "path": "src/config.ts" } }

“所以这段 JSON 本身不会读文件?”小a问。

“对。这段 JSON 既不读取文件,也不能证明路径允许读取。执行权永远在宿主程序。”

“那模型为什么有时候表现得像‘已经做了’?”小a追问。

“因为它在生成文本时,顺着上下文接出了‘我已读取文件’这类表述——这和它接出一段代码、接出一个 API 名字是同一回事。”老z说,“没有 function calling 时,模型只能用自然语言‘描述’它想做什么,应用没法可靠地从文字里解析出动作;有了 function calling,模型把‘想做什么’写成结构化请求,应用能解析、能校验、能拒绝。协议解决的是‘可解析’,不是‘已执行’。”

“那如果不用 function calling,硬要从自然语言里解析出‘读文件’,会怎样?”小a问。

“你试试就知道。”老z说,“模型可能写‘请读取 config.ts’,也可能写‘你看下配置文件呗’,还可能夹在别的句子里。你要解析动作、提取参数、还得防它自己脑补步骤——**文字是给读者看的,不是给程序执行的,从文字里猜动作,等于把解析器当成另一个模型。**function calling 做的事,是让模型在协议层面直接表达‘我要调用什么、参数是什么’,把这一步从‘解析散文’变成‘读结构化字段’。”

“那它给出的参数,可不可信?”

“和它给出的任何文本一样不可信。”老z说,“结构化让它可以被解析、被校验、被拒绝,但没有让它的内容变成事实。结构化改变的是处理方式,不是可信度。”

比喻:点菜

模型是点菜的人,程序是后厨。订单写得再工整,菜也要后厨真的做;模型“说”读了文件,不等于文件被读过。

对比项自然语言描述结构化调用请求
模型表达方式用句子“说想做什么”用字段“请求调用什么”
应用怎么取信息解析散文,容易出错读结构化字段,可校验
可拒绝性拒绝与否靠人判断宿主可直接拒绝并回原因
执行保证无仍无——协议不负责执行

6.2 从声明到结果的闭环 ​

“那一次完整的‘点菜到上菜’有几步?”小a问。

“一个完整回合至少包含四步。”老z说:

  1. 应用向模型声明可调用能力及其参数 schema;
  2. 模型返回一个或多个调用请求;
  3. 应用验证请求并执行,或拒绝执行;
  4. 应用将结构化结果关联回原调用,再让模型决定下一步。

“少一步会怎样?”小a问。

“若缺少第四步,模型不知道真实世界发生了什么;若跳过第三步的验证,模型就越过了权限边界。”

“那声明这步,模型是怎么知道有哪些工具的?”小a追问。

“应用在请求里带上工具列表——通常是名称、描述和参数 schema。”老z说,“模型读到这份列表,结合用户任务,决定要不要调用、调用哪个、参数填什么。**声明决定了模型能‘看见’什么,但不决定它选什么。**声明漏了一个必要工具,模型只能凑合用别的,或者用自然语言告诉你它做不到。”

“那描述写得不好呢?”

“模型就会选错或传错参数——这是第8章 给能力装上把手——Tool要展开的问题。”老z说,“本章只关注调用协议本身的闭环。”

老z把四步画成一张图:

text
 ① 声明工具 + schema ──> ② 模型返回调用请求(id, name, args)
        ^                                    │
        │                                    ↓
 ④ 结果按 id 回填给模型 <── ③ 校验 → 执行 / 拒绝
                                      │
                                 结构化结果

“注意这个环是闭合的。”老z说,“第④步把结果送回模型,模型才能进入下一轮决策。**环不闭合,模型就只有前半截信息——它知道‘我想做什么’,却永远不知道‘做成了没有’。**这就像打电话没人接,对面听不到你的下一句。”

“那第③步的校验,到底验什么?”小a追问。

“至少两层:参数形状对不对,以及这个调用该不该被允许。”老z说,“形状错,直接拒绝并说明原因;该不该允许,看宿主策略——这层 6.3 会细讲。校验的意义是让错误停在落地之前,而不是让错误在日志里再走一遭。”

要点

完整闭环 = 声明 → 请求 → 校验执行 → 回传结果。缺回传,模型“瞎”;缺校验,模型“越权”。

6.3 schema 约束形状,不保证意图 ​

“那给参数加 schema,是不是就不会传错?”小a问。

“不会。参数 schema 可以要求 path 是字符串、line 是整数、mode 属于枚举。”老z说,“这能减少格式错误,也便于生成 UI 和校验错误。但 schema 不能证明 path 位于工作区、请求是否符合用户目的、命令是否安全。应在 schema 之后再做业务和安全校验。”

text
模型参数 → schema 校验 → 权限/路径/策略校验 → 执行 → 标准化结果

“那模型还是会传错?”小a问。

“对。模型可能漏字段、生成无效 JSON 或选择不合适的工具;执行器必须把这些视为正常错误路径。”

“能举个例子说明 schema 合法但意图错吗?”小a追问。

“能。”老z说,“schema 要求 path 是字符串,模型填了 path: "/etc/passwd"——格式合法,但路径越界。或者 schema 要求 recursive: boolean,模型填了 true,格式合法,但语义上你不一定希望递归删除。schema 是形状约束,意图要由权限层和业务层判断,这两层不能合并成一道。”

校验层检查什么抓不住什么
schema 校验字段类型、必填、枚举值路径越界、命令危险、意图错误
权限/路径校验是否在工作区内、是否允许写业务语义是否正确
业务/审批是否符合用户本次意图—(最后一道)

“所以这三层是串行的,不是可选的?”小a问。

“对。任何一层放行,后面一层都得自己兜。”老z说,“schema 过了不代表路径安全,路径安全不代表意图正确。三层各自负责一段,省了哪层就把那层的风险留给下一层或留给事故。”

“那 schema 校验不过,具体是什么样子?”小a追问。

“模型漏了必填字段、类型不对、枚举值不在列表里,都算。”老z说,“执行器应该把它当成正常错误路径:把‘校验失败 + 失败原因 + 期望的形状’回给模型,让它修正参数再试一次——而不是直接退出整个任务。校验失败不是任务失败,是模型需要一次修正的机会。”

“那三种最常见的行为是什么?”

“漏字段、传错类型、选错工具。”老z说,“漏字段常常是因为任务描述里没给足信息;传错类型是模型把‘字符串’填成了数组;选错工具是声明里的描述没写清楚。三类原因都值得回填给模型,让它下一次调用更有依据。”

常见错误示例回给模型的信息
漏必填字段path 缺失提示缺哪个字段,要求补全
类型不对path 填成数组提示期望类型,说明为何不合法
枚举值越界mode 填了列表外的值列出可选枚举值
工具选错用 read_file 读不存在的配置说明调用失败原因,允许换工具

“这四类都算 schema 层的?”小a问。

“前三类算,第四类更像工具选择问题。”老z说,“但处理方式一样:结构化地告诉模型‘这个调用不成立、为什么’,而不是让它面对一个空结果自己猜。给模型一个可修正的错误,比给它一个不可见的失败值钱得多。”

“那 schema 声明本身,写得详细还是粗略好?”小a问。

“详细到能约束形状,别详细到写成散文。”老z说,“必填、类型、枚举这些该写清楚,因为模型要照着填;但不要在描述里堆业务背景——那是给工具开发者看的,不是给模型选参用的。schema 是护栏不是说明书,护栏要窄,说明要短。”

6.4 流式与并行的边界 ​

“流式输出会不会把订单‘撕碎’?”小a问。

“会。流式协议中,参数可能分多段到达。未收到完成标记前,任何部分 JSON 都只能用于预览或进度展示,不能作为执行依据。”老z说,“某些服务允许同一响应请求多个调用;并行是否安全取决于资源:独立读取可以并发,写同一文件、同一数据库记录或同一外部订单应串行或加锁。”

“那部分 JSON 长什么样?”小a追问。

“比如参数字段流到一半是 { "path": "src/con——你能看出它要读 src/ 下某个文件,但不知道完整路径。”老z说,“如果你拿这半截去执行,要么报错,要么读错文件。**正确的做法是等完成事件,把完整参数拼起来,再做 schema 校验。**流式是为了让 UI 能显示进度,不是为了提前执行。”

老z又补了一张流式示意:

text
时间 →  t1          t2           t3          t4
       { "name":   "read_     file",      (完成事件)
       { "path":   "src/co   nfig.ts" }    ← 此刻才能解析
        └─────────────────────────────────────┘
        之前任何一帧都只是未完成的碎片

“在 t1、t2、t3 任一帧拿到的都是不完整的参数。”老z说,“有的实现用增量 delta 字段表示‘又多了这一块’,有的直接给分片——字段名随 API 而异。不管叫什么,都只有完成事件之后的内容才配进入校验和执行。”

“多个调用同时回来,执行顺序谁定?”

“宿主定。”老z说,“模型一次返回多个调用请求,只是表达了‘这几个我都要’;谁先执行、能不能同时执行,由宿主根据资源依赖判断。两个读不同文件可以并行,一个读一个写同一文件就得串行——并行的安全性来自资源分析,不来自模型一次提了几个。”

“资源分析具体怎么分?”小a追问。

“按三类看。”老z说,“一是读读无冲突,两个只读调用并行最安全;二是写写冲突,两个写同一资源的调用串行或加锁;三是读写依赖,一个调用的输入来自另一个调用的输出,后一个必须等前一个。并行是优化,串行是底线——先保证不冲突,再谈快。”

资源关系例子执行方式
读读读 A、读 B可并行
写写都写同一个文件串行或加锁
读写依赖先读配置再据此创建后者等前者

“那并行会带来什么额外成本?”小a问。

“结果对账的成本。”老z说,“并行执行后,结果是一批一起回来的。没有调用 ID,你分不清哪个结果对应哪个请求;有了 ID,还要处理部分成功——三个调用一个失败两个成功,剩下的任务还推不推进、怎么推,都要宿主定。并行省了时间,把排序和对账的成本留给了宿主。”

6.5 失败、重试和幂等 ​

“那执行失败怎么办?”小a问。

“工具结果要区分成功、可恢复失败、拒绝、超时和已取消,并给模型足够的机器可读信息。”老z说,“模型调用或流式传输的重试并不天然幂等;若工具会发消息、付款、删除或写入,重复执行可能产生重复副作用。为此需要调用 ID、幂等键、执行记录或人工审批,而不是只靠模型‘不要重复’。”

“那失败结果回传给模型,模型会怎么处理?”小a追问。

“取决于失败类型。”老z说,“可恢复失败(如文件暂时锁定),模型可能选择等待重试;拒绝(如路径越界),模型可能改路径或放弃;超时(不知道远端是否生效),模型不能盲目重试,而要查执行记录或请求人工确认。前提是失败结果携带足够信息让模型区分这几种情况——只返回‘出错了’,模型只能瞎猜。”

失败类型含义模型合理的下一步
可恢复失败临时故障(锁、限流)等待后重试
拒绝权限或路径不允许改参数或放弃
超时远端状态未知查记录、用幂等键或转人工
已取消外部 signal 中止停止当前回合

“那‘静默成功’算什么?”小a问。

“那是最糟的。”老z说,“工具执行失败却不回传,或回传成‘成功’——模型在错误信息上继续推进,产出看似合理实则错误的结果。失败要显式、要可分类、要带上下文,这三点决定了模型能否从错误中恢复。”

“失败结果里至少要带什么?”小a追问。

“三条信息缺一不可:状态码或状态名、失败类型、机器可读的原因。”老z说,“光有‘失败’两个字不够——模型不知道是重试、改参数还是放弃;光有原因也不够——没有状态,程序没法自动分支。给模型的信息,要让它能区分‘这步还能补救’和‘这步根本没戏’。”

“那重试,怎么知道安不安全?”小a问。

“看动作有没有副作用。”老z说,“读取一个文件,重试十次也读不坏;发一条消息、扣一次款,重试一次就多一个副作用。**重试本身不产生安全性,安全性来自操作是否可重复执行。**所以约定:可重入的调用,失败后重试没关系;不可重入的调用,重试必须带幂等键,或者先查执行记录再决定。”

防护手段防什么用在什么时候
调用 ID结果对不上请求并行、流式、多轮调用
幂等键重复执行产生重复副作用远端创建、支付、发消息
执行记录无法判断上次是否生效超时、断线、恢复
人工审批不可逆动作被盲目重放删除、发布、大额支付

“那幂等键长什么样?”小a问。

“它是这次操作唯一的标识,由调用方生成,远端记住‘这个键已经处理过’。”老z说,“重试时带上同一个键,远端看到重复就直接返回第一次的结果,而不是再执行一次。判断依据在远端,不在调用方——调用方只能保证键唯一、重试时不变。”

“没有幂等键的话,最安全的替代是什么?”

“查执行记录。”老z说,“超时后先查‘这个调用到底生效了没有’,生效就不动,没生效才重试。查不到记录,就转人工确认。三者都是把‘未知’变成‘已知’的手段,只是代价不同。”

“那重试的次数和间隔,谁来定?”

“宿主定上限,模型可以参与要不要重试的决策,但次数和间隔是策略。”老z说,“模型说‘再试一次’,宿主查一下重试预算:还剩几次、距上次多久,够了才放行。让模型无限重试,等于把成本控制权交给了不可靠的一方。”

6.6 调用关联与补偿 ​

“两个工具同时跑,怎么保证结果不串?”小a问。

“每一次调用都应有稳定的调用 ID;工具结果必须带回同一 ID,才能在并行时避免把 A 的结果交给 B。”老z说,“执行记录至少区分‘已请求、已批准、执行中、成功、失败、已拒绝、已取消’。这既是恢复依据,也是审计边界。”

text
模型请求(id) → 校验 → 批准 → 执行 → 结果(id, status) → 回填消息
                       └── 拒绝/超时 ────────────────┘

“所以‘模型说已完成’不能当结果?”小a问。

“对。结构化调用让这个不确定性可见,却不会自动消除它。”老z说,“把‘模型说已完成’改为‘执行器已返回成功状态’,是从语言承诺走向工程事实的关键一步。”

“那调用 ID 为什么这么重要?”小a追问。

“因为并行。”老z说,“一轮里模型请求了三个调用:读 A、读 B、写 C。三个结果回来时,如果没有 ID 关联,你不知道哪个结果对应哪个请求——把写 C 的失败当成读 A 的失败,模型就会去改 A 的路径而不是处理 C。调用 ID 是结果和请求之间的接线,丢了这根线,并行就变成一锅粥。”

“那 ID 是模型生成的还是宿主生成的?”

“通常是模型在请求里带上,宿主在结果里原样回填。”老z说,“具体字段名随 API 而异,但‘请求和结果能配对’这个要求不变。如果模型没带 ID,宿主就得自己分配并维护映射——总之必须有人负责配对,不能假设结果自然对得上。”

“配对失败会怎样?”小a追问。

“会串台。”老z说,“两个并行调用 A、B,如果 B 的结果被标成 A 的回填,模型会以为‘读 A 成功了’,实际上读到的是 B 的内容——错误不一定立刻暴露,而是在模型下一步决策里悄悄生效。ID 配对失败,比结果失败更隐蔽,因为它长得像成功。”

“那执行记录里,至少要记哪些状态?”小a问。

“已请求、已批准、执行中、成功、失败、已拒绝、已取消,这七种是底线。”老z说,“每种状态都有对应的恢复动作:成功才能继续推进,失败要带原因回填,拒绝要说明拒绝理由,取消要停掉当前回合。状态分得越粗,恢复时越要猜;分到可操作,恢复才有据可依。”

“那这些状态,跟审计是什么关系?”小a问。

“审计就是状态的完整回放。”老z说,“每个调用记下 ID、发起者、时间、参数摘要、状态迁移——出了事才能回答‘这个动作是谁在什么时候发起的、经过了几次重试、最后停在哪个状态’。没有状态,就没有审计;没有审计,就只能靠记忆复盘,而记忆是最不可靠的日志。”

“参数摘要是什么意思?要存完整参数吗?”

“存摘要,不存敏感内容。”老z说,“路径、命令这类便于排查的信息可以存;密钥、令牌这类一旦落库就是新的泄露面。日志里存什么,取决于丢了你查不查得回来,以及存了会不会惹祸。”

示意: create_issue 在网络超时后,调用方不知道远端是否已经创建。此时直接重试可能得到两个 Issue;更安全的设计是使用服务支持的幂等键、查询可关联记录,或交给人确认。不是所有副作用都能补偿,尤其是发送通知、支付或删除数据。

6.7 调用是小型事务,不是 JSON 片段 ​

“能不能把这一整套画成流程图?”小a问。

“可以,而且应该。”老z画了一张:

text
声明 ToolSpec → model tool call(id) → schema 校验
  → 策略/授权校验 → 执行或拒绝 → tool result(id, status) → 写回下一轮

“正常路径之外,最容易踩的坑是什么?”小a问。

“两个反例。”老z说,“反例一是参数符合 schema 但指向工作区外文件;必须由执行边界拒绝。反例二是远端创建动作超时,调用方不知道是否已生效;直接重试会重复副作用,须查执行记录、使用服务的 idempotency key 或请求人工确认。”

“这些字段是行业标准吗?”小a问。

“不是。官方 function-calling 指南说明了特定接口的工具调用事件和结果回传;字段不是行业标准。”老z说,“本书的工程取舍是把 schema、权限、并发和结果分别建模:可测试且可审计,代价是需要维护稳定 ID、状态机和补偿/确认路径。”

“那把这个取舍画成一张全景图?”小a问。

“可以。”老z画了一张:

text
            ┌────────────── 一次完整调用的事务边界 ──────────────┐
            │                                                     │
用户任务 → 声明 → 请求(id) → schema → 策略 → 执行 → 结果(id) → 回填
                                  │       │        │
                            形状错误    越权拒绝   超时
                                  │       │        │
                            回填原因    回填原因   查记录/幂等键
                                                       │
                                              无法确认 → 人工确认
            └─────────────────────────────────────────────┘

“这张图里每一格都是可测试的。”老z说,“形状校验、策略校验、执行、回填,每一层输入输出固定,可以单独写测试。把一次调用当小事务来设计,测试就能覆盖,恢复就能有据,审计就能追溯。”

“那实际项目里,这套流程要不要每个调用都走全?”

“要看调用类型。”老z说,“只读的、纯本地的、失败无副作用的调用,可以走精简路径;会写外部、会发消息、会扣款的调用,才值得把事务状态机铺全。防护的厚度跟着副作用的大小走,不是每个调用都套同一套重铠。”

“那最后一条线呢?‘模型说完成了’和‘执行器返回成功’,到底是什么区别?”小a问。

“前者是语言,后者是事实。”老z说,“模型说‘我已经创建了 Issue’,只是一句话;执行器返回 status: success + issue_id,才是可核对的证据。这条线跨过去,调用才从‘模型的自述’变成‘系统的账目’。”

小结 ​

Function Calling 让模型突破了"只会说"的限制,但突破的方式不是让模型直接动手,而是让它学会提需求。模型输出的是一个结构化的调用请求——带着工具名称、参数和调用 ID,但它本身什么都没执行,执行权始终在宿主程序手里。这一区分是整条可靠链路的根基。结构化让请求可以被解析、被校验、被拒绝,但没有让请求的内容变成事实;从自然语言里猜动作,等于把解析器当成另一个模型。一个完整的工具往返分四步:声明工具让模型知道有什么可用,校验模型给出的参数是否合法,执行通过校验的调用,把结果按调用 ID 回传给模型。环不闭合,模型就只有"想做什么",永远不知道"做成了没有"。JSON Schema 负责描述参数的形状,但它只解决数据形状的问题,不解决参数是否符合用户意图的问题——模型可能给出一个 schema 合法、但语义完全错误的参数,所以运行时校验不能省。

校验本身分三层:schema 管形状、权限层管范围、业务层管意图,任何一层放行,后面一层都得自己兜。而校验失败也不是任务失败,而是模型需要一次修正的机会——把失败原因结构化地回给模型,比让它面对空结果自己猜值钱得多。真正的风险藏在"结果回传"这一步:工具执行失败,也是一种结果,不是"没有结果"——把失败明确地回传给模型,模型才有机会调整策略重试或放弃;把失败静默吞掉,模型就会在信息缺失的情况下继续编造。失败要显式、要可分类、要带上下文:可恢复失败等待重试,拒绝就改参数,超时不能盲目重试而要查记录或用幂等键。给模型的信息,要让它能区分"这步还能补救"和"这步根本没戏"。

一轮里可能出现多个并行的调用请求,但"请求并行"不等于"执行并行",到底是不是真并行,由宿主的执行策略决定——并行的安全性来自资源分析,不来自模型一次提了几个。调用 ID 是请求和结果之间的接线,丢了这根线,并行就变成一锅粥;配对失败比结果失败更隐蔽,因为它长得像成功。把这一切串起来,一次工具调用应当被当成小型事务,而不是一段 JSON 片段:稳定的调用 ID、明确的状态集合、可补偿的失败路径,以及对"模型说完成了"和"执行器返回成功"这两件事的严格区分。

防护的厚度跟着副作用的大小走——只读、无副作用的调用走精简路径,会写外部、会发消息、会扣款的调用才值得铺全状态机。跳过四步里的任何一步,尤其跳过校验,就会产生无法追溯的副作用。当工具数量多起来、动作开始涉及外部系统时,"怎么让动作真正落地"就不再只是协议问题,而变成了工具本身的接口设计问题。

动手核验 ​

实现一个只读的 read_file 模拟执行器。分别喂入缺少路径、工作区外路径和合法路径的调用请求,确认每种结果都有不同状态且不会读取未授权文件。再模拟一段未完成 JSON,确认它不会进入执行器。