Appearance
🐣 小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 给我装了第一只手——但严格说,它只是让我能下单。真正的『干活』还需要:
- 执行订单的程序(Tool 的实现)
- 把结果喂回给我的循环(Agent Loop)
- 记住整个过程的笔记本(Memory)
这三样齐了,我才算真正会干活。"
🧙 老z点头:"对。function calling 是地基,Tool/Loop/Memory 是建在上面的房子。 下一章,我们把房子盖起来——那时你才是一个真正的 Agent。"
本章小结
🐣 小a的第五课
┌──────── 给我装第一只手 ────────┐ │ │ │ • 矛盾:模型只会输出文字, │ │ 但任务需要执行动作 │ │ │ │ • Function Calling = 让模型 │ │ 吐结构化订单,而非人话 │ │ → 像点菜单,精确可执行 │ │ │ │ • 订单长这样:ToolCall │ │ { name, arguments, id } │ │ │ │ • 流式难题:partial JSON │ │ → 边吐边猜,尽力解析 │ │ │ │ • 并行:一次下多张单, │ │ 共享资源要串行 │ │ │ │ • 安全:先校验再执行 │ │ 格式校验 + 危险拦截 │ │ │ │ • 局限:可能不调/乱调, │ │ 结果要喂回,不是都支持 │ └────────────────────────────────┘
关键认知:function calling 是 Agent 的"神经接头"——它让模型的输出能触发程序的动作。但没有 Tool(肌肉)和 Loop(循环),光有接头还动不起来。
课后实验
- 看一次真实的 function calling:用任何支持 function calling 的 API(OpenAI/Anthropic),定义一个简单的
get_weather函数,问模型"北京天气怎么样",观察它返回的 ToolCall(注意name和arguments字段)。 - 感受 partial JSON:写个小脚本,把一段完整的 JSON 一个字符一个字符地"喂"给解析器,每次尝试解析,看它在什么时候开始能解析出部分字段。
- 想想局限:试着诱导模型"该调工具却不调"(比如给它工具但问题它觉得自己能答),观察它的行为。
- 体验并行调用:定义两个只读工具(如
read_file两个不同路径),让模型同时查询——看它是否一次返回两个 ToolCall,再观察你的程序怎么处理"多张订单"。
下一章:有了 function calling 这只"手",还缺一把趁手的兵器。老z说:"工具好不好用,直接决定你会不会干活。" → 第 6 章 · Tool 与工具设计