Skip to content

🐣 小a的尴尬

阶段一学完,小a能接龙、能思考、能被统一调用了。它觉得自己很厉害,跑去跟老z吹:"我现在什么都会!"

老z丢给它一个任务:"帮我把 config.ts 里所有的硬编码改成读环境变量。"

小a张口就来:"好的!第一步,打开 config.ts……第二步,找到硬编码的地方……第三步,改成 process.env.XXX……您看,这样改——"

老z等了半天:"然后呢?"

小a:"……然后您自己去改啊,我都告诉您怎么改了。"

老z叹气:"你会指挥,但不会动手。 我要的是个会下厨的厨子,不是个只会背菜谱的播音员。"

小a委屈:"可我只会说话啊,我又没长手。"

老z微微一笑,从抽屉掏出一样东西:"手,我给你造。它叫 function calling。"


5.1 小a为什么"只会说"

先诊断病因。回忆第 1 章——模型本质是接龙,它只会"输出文字"。

当你说"帮我改 config.ts",它接出来的,是一段描述怎么改的文字:"你应该打开文件……找到第 X 行……改成……"

但"描述怎么改" ≠ "真的改了"。文字不会执行,代码不会自己跑。 模型生成的始终是 token,不是动作。

🧙 核心矛盾:

模型只会输出文字,但现实任务需要执行动作(读文件、跑命令、改代码)。

中间隔着一道墙——模型生成的"指令文字",怎么变成"真实动作"?

这道墙,就是 function calling 要拆的。


5.2 Function Calling:让模型吐"结构化指令"而非"人话"

老z的方案很巧妙:别让模型吐人话,让它吐一份机器能懂的"结构化订单"。

🧙 Function Calling 是什么?

Function calling 是一种机制:模型不输出自然语言描述,而是按约定的格式,输出一个结构化的"函数调用"

  • 没有它:模型输出 → "你应该打开 config.ts 这个文件……"(人话,机器还得解析)
  • 有了它:模型输出 → { "name": "read_file", "arguments": { "path": "config.ts" } }(结构化,机器直接执行)

点菜比喻

老z打了个比方:

想象一家餐厅。服务员(模型)要把顾客的需求告诉厨房(你的程序)。

  • 没有 function calling:服务员冲厨房喊"那个……有个客人……想吃什么来着……做个鱼香肉丝吧大概" → 厨房得猜、得问、容易做错
  • 有 function calling:服务员在点菜单上工整勾选:菜名:鱼香肉丝,份量:大,辣度:中 → 厨房照单做,精确无误

function calling 就是那张"点菜单"——把模糊的意图,变成精确的结构化订单。

为什么这比"解析人话"靠谱?

第 1 章讲过模型会幻觉——它可能说错、可能含糊。如果你让它输出人话再去解析,等于"在不可靠的东西上再解析一次",错误叠加。

但结构化输出不一样:

  • 格式是约定好的 schema(字段名、类型都定死)
  • 模型只需在限定格式里填值,自由度小 → 出错概率低
  • 解析端是确定性的代码(解析 JSON),不依赖模型的"表达技巧"

🐣 小a恍然:"所以 function calling 不是让我变聪明,是让我输出得更可控?"

🧙 "对。 它把『模型生成内容』和『程序执行动作』之间,加了一个结构化的中转站。模型在中转站这头填订单,程序在那头照单做。"


5.3 一份"订单"长什么样:ToolCall

我们来看一份"订单"——也就是 function calling 的产物——到底长什么样。下面是一个示意例子(展示的是约定俗成的标准结构,不对应任何具体实现):

🧾 一份订单的样子(示意)

json
{
  "id": "call_abc123",
  "name": "read_file",
  "arguments": { "path": "config.ts" }
}

三个核心字段:

字段含义例子
name要调的函数名"read_file"
arguments函数的参数(对象){ "path": "config.ts" }
id这次调用的编号"call_abc123"

🧙 读懂这份订单:

模型生成一段话,其中嵌着这么一个 ToolCall 块——它等于在说:"我想调 read_file 这个函数,参数是 path="config.ts",请帮我执行,执行完把结果告诉我。"

你的程序收到这个块,根据 name 找到对应的函数实现,用 arguments 调用它,拿到结果,再喂回模型。

这就拆掉了 5.1 那道墙:模型吐结构化订单,程序照单执行,再把结果喂回去。 模型从"只会说"变成"会指挥执行"。


5.4 模型怎么知道有哪些函数可调?

你可能会问:模型怎么知道 read_file 这个函数存在?怎么知道它要 path 参数?

答案:你得先告诉它。 这就要定义 Tool(工具)——下一章的主题。这里先看个轮廓:

🧙 Tool 的定义(预告)

每个 Tool 包含:

  • 名字 + 描述:告诉模型"这个工具是干嘛的"
  • 参数 schema:告诉模型"调用它要传什么参数"

调用 LLM 时,你把所有 Tool 的定义一起传过去。模型看到这些定义,就知道"哦,我有这些工具可用",然后决定要不要调、调哪个、传什么参数。

所以完整的 function calling 流程是:

你 → 模型:[问题 + 可用的 Tool 定义]
模型 → 你:生成回答,其中可能包含 ToolCall(我要调 read_file)
你 → 执行:根据 ToolCall.name 找到函数,用 ToolCall.arguments 调用
你 → 模型:[工具执行的结果]
模型 → 你:基于结果,继续生成(可能再调工具,或给出最终答案)

注意最后一步——模型拿到结果后会继续。这就埋下了第 7 章"循环"的伏笔:工具调完不是结束,模型可能还要再调下一个工具。


5.5 流式 Function Calling:话没说完就要开始猜

function calling 有个特别烦的衍生问题,老z必须提醒小a。

回忆第 3 章——模型是流式输出的,一个 token 一个 token 地吐。这意味着 ToolCall 的 JSON 也是一点点吐出来的:

时间 t1: 模型吐 {"name": "read_
时间 t2: 模型吐 file", "arguments": {"path
时间 t3: 模型吐 ": "config.ts"}}

问题来了:到 t2 的时候,JSON 还不完整({"path 后面没闭合),但上层已经需要开始处理了。 你不能等它吐完——那流式就没意义了。

🧙 流式部分 JSON(partial JSON)

这就是"流式部分 JSON"难题:模型吐到一半的 JSON,是不合法的(没闭合),但你要边收边解析。

🧾 "边吐边猜"的示意

实际工程里有个通用做法:边收边猜——用一个能容忍"半个 JSON"的解析器,尽量把已经出现的字段提取出来。比如吐到 {"name": "read_file", "arguments": {"path": "conf 时,它虽然闭合不了,但可以猜出 name 已经是 "read_file",path 大概以 "conf" 开头(还没收完)。

解析是尽力而为:不到最后一个 token,你拿到的都是『部分正确』的结果。这是流式 function calling 必须接受的妥协。

🐣 小a头大:"半个 JSON 也能解析?"

🧙 "**能,但代价是『结果可能不完整』。**不到最后一个 token,你拿到的都是部分正确的结果。这个真实现留到你深入 pi-ai 时再看。"

现在你只要知道:function calling 在流式下,有个『边吐边猜』的工程难点。


5.6 Function Calling 的局限

老z不让小a太兴奋,得讲讲局限:

🧙 局限①:模型可能"不调"或"乱调"

function calling 不是强制的。模型可能:

  • 该调却不调:你给它工具,它偏不用,直接瞎编答案(这正是"降智"的一种,见附录 E.1)
  • 乱传参数:把 path 传成文件内容、把数字传成字符串

这是为什么需要 Tool 的参数 schema 严格约束 + 执行前的校验

🧙 局限②:执行结果还得喂回去

function calling 只是"下单",真正的执行是你的代码干的。执行完,必须把结果喂回模型,否则模型不知道发生了什么,会卡住或重复下单。

这就要求一个循环:下单 → 执行 → 喂回 → 模型决定下一步 → 可能再下单……这就是第 7 章的 Agent Loop

🧙 局限③:不是所有模型都支持

有些便宜/老旧的模型没有 function calling 能力——给它们工具也白搭。pi 收录的模型都是面向编程场景、实践中支持工具调用的(Part II 第 17 章你会看到它们的选型),但"支持工具调用"是产品选型的事实,不是代码层面的强制门槛——你自己选型时,仍要逐个确认目标模型支不支持


5.7 并行调用与执行安全:两个躲不开的工程问题

"前面讲的是单张订单。"老z说,"现实里,还有两个问题你必须知道。"

问题一:模型可能一次下多张单(并行调用)

🧙 并行工具调用(parallel tool calls)

现代模型经常一次返回多个 ToolCall——比如"同时帮我查 A 文件和 B 文件":

json
{
  "toolCalls": [
    { "id": "call_1", "name": "read_file", "arguments": { "path": "a.ts" } },
    { "id": "call_2", "name": "read_file", "arguments": { "path": "b.ts" } }
  ]
}

模型一次点好几道菜,你的程序要挨个执行(可以并行),然后把所有结果一起喂回

⚠️ 但并行有坑:写文件的工具不能乱并行。

两个工具同时写同一个文件,就会互相覆盖(经典的"并发写文件"事故,附录 E.7)。成熟的框架会做操作排队:同文件串行、不同文件并行(pi 的 file-mutation-queue.ts,Part II 第 20 章会看到)。读到这个坑时记住:工具能并行,但共享资源要串行。

问题二:模型下的单,不能直接执行(执行安全)

老z的表情严肃起来:

🧙 信任边界:模型的参数不可信

ToolCall 的 arguments模型"猜"出来的——它可能传错参数、传危险参数,甚至被 prompt 注入后"被操纵着"传危险参数(第 15 章会讲)。

所以执行前必须过一道闸:

模型要调 bash("rm -rf /")

① schema 校验:参数类型对不对?(rm 的参数合法,但这不够)
② 危险操作拦截:这条命令危险吗?(rm -rf 危险 → 拦下)

拦截 → 把错误喂回模型,让它换个方式

🧙 两个层次,缺一不可:

  • schema 校验(格式层):参数对不对——数字别传成字符串
  • 安全拦截(语义层):动作危不危险——rm -rf 格式上完全合法,但很危险

校验失败、被拦截,都要告诉模型(fail loud,第 15 章还会见到它)——否则模型不知道自己错了,会一遍遍重试同一个错误。

🐣 小a:"所以『下单』只是第一步,『接单的闸门』同样重要?"

🧙 "对。function calling 给了模型'动手的能力',你就要负责'管住这只手'。 这两道闸——格式校验 + 安全拦截——是后面 Tool 设计(第 6 章)和安全(第 15 章)的核心。"


5.8 从"下单"到"干活":下一站的预告

故事讲完,小a明白了 function calling 的定位:

🐣 小a总结:

"function calling 给我装了第一只手——但严格说,它只是让我能下单。真正的『干活』还需要:

  1. 执行订单的程序(Tool 的实现)
  2. 把结果喂回给我的循环(Agent Loop)
  3. 记住整个过程的笔记本(Memory)

这三样齐了,我才算真正会干活。"

🧙 老z点头:"对。function calling 是地基,Tool/Loop/Memory 是建在上面的房子。 下一章,我们把房子盖起来——那时你才是一个真正的 Agent。"


本章小结

🐣 小a的第五课

┌──────── 给我装第一只手 ────────┐
│                                │
│  • 矛盾:模型只会输出文字,    │
│    但任务需要执行动作          │
│                                │
│  • Function Calling = 让模型   │
│    吐结构化订单,而非人话      │
│    → 像点菜单,精确可执行      │
│                                │
│  • 订单长这样:ToolCall        │
│    { name, arguments, id }     │
│                                │
│  • 流式难题:partial JSON      │
│    → 边吐边猜,尽力解析        │
│                                │
│  • 并行:一次下多张单,        │
│    共享资源要串行             │
│                                │
│  • 安全:先校验再执行          │
│    格式校验 + 危险拦截         │
│                                │
│  • 局限:可能不调/乱调,        │
│    结果要喂回,不是都支持      │
└────────────────────────────────┘

关键认知:function calling 是 Agent 的"神经接头"——它让模型的输出能触发程序的动作。但没有 Tool(肌肉)和 Loop(循环),光有接头还动不起来。


课后实验

  1. 看一次真实的 function calling:用任何支持 function calling 的 API(OpenAI/Anthropic),定义一个简单的 get_weather 函数,问模型"北京天气怎么样",观察它返回的 ToolCall(注意 namearguments 字段)。
  2. 感受 partial JSON:写个小脚本,把一段完整的 JSON 一个字符一个字符地"喂"给解析器,每次尝试解析,看它在什么时候开始能解析出部分字段。
  3. 想想局限:试着诱导模型"该调工具却不调"(比如给它工具但问题它觉得自己能答),观察它的行为。
  4. 体验并行调用:定义两个只读工具(如 read_file 两个不同路径),让模型同时查询——看它是否一次返回两个 ToolCall,再观察你的程序怎么处理"多张订单"。

下一章:有了 function calling 这只"手",还缺一把趁手的兵器。老z说:"工具好不好用,直接决定你会不会干活。" → 第 6 章 · Tool 与工具设计