手撕 Function Calling:大模型怎么“调用工具“

手撕 Function Calling:大模型怎么“调用工具“

一句话读懂:所谓"Function Calling(工具调用)",本质上是模型输出一个特殊 Token → 解析出函数名和参数 → 执行函数 → 把结果拼回上下文 → 模型基于结果继续作答的闭环。本文用 NumPy + Python 标准库从零实现这套逻辑,全程可运行、可复现,不依赖任何第三方 LLM。


写在前面

很多同学第一次接触"工具调用"时觉得神秘:模型不是只会"生成文本"吗?它怎么就能去查天气、算数学、调数据库了?

真相是:模型本身并不会"执行"任何东西。它做的仍然只是"一个 Token 一个 Token 地往外吐"。所谓"调用工具",其实是模型在你的上下文里写出了一段特殊的、格式约定的文本(比如get_weather('北京')),由框架(Agent 运行时)去识别这段文本、帮你执行真实的函数,再把执行结果塞回上下文,让模型"看到"结果后继续生成。

这篇文章就用一个极简的、刻意不依赖任何大模型的实现,把这条链路完整地跑一遍。代码里用到 NumPy 做 Token 的编码 / 解码 / 拼接,用到 Python 标准库re做解析。


一、先看结果:一个完整的工具调用闭环

先看一张总览图,整篇文章就是围绕它展开的:

下面是完整代码在本地跑通的真实输出(详细运行环境见第五节):

============================================================ 环境: numpy 2.4.4 ============================================================ 初始上下文内容: 用户 问 北京 今天 多少 度 ? —— 模型开始'生成' —— [step 0] 模型输出: " <tool_call> get_weather('北京') <end>" [tool] 解析到调用: 函数=get_weather, 参数=['北京'] [tool] 执行结果: '北京 气温 26 摄氏度,晴。' [tool] 已把结果编码回填,上下文长度 -> 38 tokens [step 1] 模型输出: ' 北京 今天 气温 26 摄氏度 。' [model] 最终回答: 用户 问 北京 今天 多少 度 ? <tool_call> get_weather ( ' 北京 ' ) <end> [RESULT] 北京 气温 26 摄氏度 , 晴 。 北京 今天 气温 26 摄氏度 。 —— 最终上下文(模型眼里看到的全部内容)—— 用户 问 北京 今天 多少 度 ? <tool_call> get_weather ( ' 北京 ' ) <end> [RESULT] 北京 气温 26 摄氏度 , 晴 。 北京 今天 气温 26 摄氏度 。

注意最后一行:整个对话 + 函数调用记录 + 工具返回结果,全部以 Token 的形式存在同一条上下文里。这就是"模型看到的东西"——它既不神秘,也不会有超能力,只是它的上下文里恰好有了一段"工具帮忙生成的文本"。


二、先搞清楚四件事

整条链路拆开只有四步,先看它们的时序关系:

环节我们做什么对应真实系统
① 输出特殊 Token模型输出<tool_call> get_weather('北京') <end>这类带格式的文本LLM 输出tool_calls结构化字段
② 解析从文本里抽出"函数名 + 参数"解析 JSON(JSON Schema 校验)
③ 执行函数根据函数名查注册表,真正跑一遍Agent 运行时调用真实工具/API
④ 结果拼回上下文把执行结果编码成 Token,np.concatenate追加回去追加一条tool角色的消息

真实系统和你想象中最大的不同在 ①:模型只是"会写符合格式的文本",而不是"会调用函数"。格式约定好,谁来解析、谁去执行,那是框架的事。


三、环境准备(通用、可复现)

为了不污染基础环境,建议新建一个独立的 conda 环境(这里的名字是给示例用的,你可以随意改):

conda create-nfncall-demopython=3.11numpy conda activate fncall-demo python-c"import numpy; print(numpy.__version__)"

本文实测环境:Python 3.11 + NumPy 2.4(数据见下方运行输出)。代码只依赖numpy和标准库re,换任何 Python 3.10+ 环境都能跑。


四、完整代码:手撕 Function Calling

新建一个脚本function_calling_demo.py,把下面代码整段放进去即可运行。代码按"词表 → 工具注册表 → 解析 → 执行回填 → 模拟模型 → 主循环"六大模块组织,数据流转关系如下:

# -*- coding: utf-8 -*-""" 手撕 Function Calling:大模型怎么"调用工具" ================================================ 用 NumPy + 标准库实现一个极简的"工具调用"闭环: 模型输出特殊Token -> 解析 -> 执行函数 -> 结果拼回上下文 本脚本刻意不依赖任何 LLM,只为了让机制本身清晰可见。 真实的模型生成被替换为一个"规则策略"(respond),它根据上下文 是否已有工具结果来决定下一步该输出什么 —— 这模拟了大模型 "规划调用工具 / 消费工具结果"的两阶段行为。 """importreimportnumpyasnp# ---------------------------------------------------------------------------# 1. 玩具词表:token -> id / id -> token# ---------------------------------------------------------------------------# 把一句话拆成颗粒度很粗的"词法 token",其中夹杂着几个"特殊 Token":# <tool_call> 表示"我要调用一个工具"(相当于真实 LLM 里的 function-call 标记)# <end> 表示"函数调用参数结束"# <RESULT> 是工具结果回填进上下文时插入的标记VOCAB=['<tool_call>','<end>','<RESULT>','<eot>','<unk>',# <unk>: 词表外回退'用户','问',':','北京','今天','多少','度','?','上海','深圳','天气','如何','get_weather','(',')',',',"'",' ','是','的',',','摄氏度','。','气温','26','晴',]TOKEN_TO_ID={t:ifori,tinenumerate(VOCAB)}ID_TO_TOKEN=np.array(VOCAB,dtype=object)# 用 NumPy 数组存放"解码表"defencode(text:str)->np.ndarray:"""把文本切词并编码成 np.int32 的 token-id 数组。"""# 这里用正则做一次极简切词(真实场景是 BPE/词表分词)tokens=re.findall(r"<tool_call>|<end>|<RESULT>|<eot>|get_weather|[^\s,,:()'。]+|[(),,:。']| ",text,)returnnp.array([TOKEN_TO_ID.get(t,TOKEN_TO_ID['<unk>'])fortintokens],dtype=np.int32)defdecode(ids:np.ndarray)->str:"""把 token-id 数组还原成文本。"""return" ".join(ID_TO_TOKEN[ids].tolist())# ---------------------------------------------------------------------------# 2. 工具注册表:name -> callable# ---------------------------------------------------------------------------TOOLS={"get_weather":lambdacity:f"{city}气温 26 摄氏度,晴。",}# ---------------------------------------------------------------------------# 3. 解析:从 <tool_call> ... <end> 里抽出 "函数名(参数)"# ---------------------------------------------------------------------------defparse_tool_call(raw:str)->tuple[str,list[str]]:# 去掉 <tool_call> ... <end> 包裹符,只保留 "函数名(参数)"core=re.sub(r"<tool_call>|<end>|<RESULT>","",raw).strip()m=re.match(r"(\w+)\((.*)\)",core)ifnotm:raiseValueError(f"无法解析的函数调用:{raw!r}")name,args_str=m.group(1),m.group(2)args=[a.strip("' ")forainargs_str.split(",")]ifargs_strelse[]returnname,args# ---------------------------------------------------------------------------# 4. 执行 + 回填:把 "name(args) => result" 拼回上下文# ---------------------------------------------------------------------------defexecute_tool(raw_call:str)->str:name,args=parse_tool_call(raw_call)ifnamenotinTOOLS:raiseKeyError(f"未知工具:{name}")returnTOOLS[name](*args)defappend_result(context:np.ndarray,result:str)->np.ndarray:"""把工具结果编码成 token,附加到上下文 token 序列末尾(模拟"拼回上下文")。"""suffix=f" <RESULT>{result}"returnnp.concatenate([context,encode(suffix)])# ---------------------------------------------------------------------------# 5. 模拟"模型":根据上下文决定下一步输出什么# ---------------------------------------------------------------------------defrespond(context:np.ndarray)->str:"""两阶段策略: - 上下文里还没有 <RESULT> -> 规划一次工具调用 - 已经拿到 <RESULT> -> 基于结果给出最终自然语言回答 """ifTOKEN_TO_ID['<RESULT>']incontext:# 阶段二:工具结果已就位,模型把它"组织"成一句话return" 北京 今天 气温 26 摄氏度 。"# 阶段一:模型决定调用工具return" <tool_call> get_weather('北京') <end>"# ---------------------------------------------------------------------------# 主循环:生成 -> 遇到特殊Token -> 解析 -> 执行 -> 回填 -> 继续生成# ---------------------------------------------------------------------------defmain():np.random.seed(0)print("="*60)print("环境: numpy",np.__version__)print("="*60)# 用户提问(作为初始上下文)question="用户 问 : 北京 今天 多少 度 ?"context=encode(question)print("初始上下文内容:",decode(context))print("\n—— 模型开始'生成' ——")forstepinrange(2):output=respond(context)print(f"[step{step}] 模型输出:{output!r}")# ① 把模型输出拼进上下文context=np.concatenate([context,encode(output)])# ② 检测特殊 Token:是否包含 <tool_call>ifTOKEN_TO_ID['<tool_call>']incontextandTOKEN_TO_ID['<RESULT>']notincontext:# ③ 从未消费的部分里截出函数调用文本并解析、执行call_text=output.strip()name,args=parse_tool_call(call_text)print(f"[tool] 解析到调用: 函数={name}, 参数={args}")result=execute_tool(call_text)print(f"[tool] 执行结果:{result!r}")# ④ 结果拼回上下文context=append_result(context,result)print(f"[tool] 已把结果编码回填,上下文长度 ->{context.size}tokens")else:# ⑤ 没有工具调用 —— 这是最终回答print("[model] 最终回答:",decode(context).replace("<RESULT>","[RESULT]"))print("\n—— 最终上下文(模型眼里看到的全部内容)——")print(decode(context).replace("<RESULT>","[RESULT]"))if__name__=="__main__":main()

五、运行与验证(实操证据)

运行脚本:

python function_calling_demo.py

实测输出(Python 3.11 + NumPy 2.4.4):

============================================================ 环境: numpy 2.4.4 ============================================================ 初始上下文内容: 用户 问 北京 今天 多少 度 ? —— 模型开始'生成' —— [step 0] 模型输出: " <tool_call> get_weather('北京') <end>" [tool] 解析到调用: 函数=get_weather, 参数=['北京'] [tool] 执行结果: '北京 气温 26 摄氏度,晴。' [tool] 已把结果编码回填,上下文长度 -> 38 tokens [step 1] 模型输出: ' 北京 今天 气温 26 摄氏度 。' [model] 最终回答: 用户 问 北京 今天 多少 度 ? <tool_call> get_weather ( ' 北京 ' ) <end> [RESULT] 北京 气温 26 摄氏度 , 晴 。 北京 今天 气温 26 摄氏度 。 —— 最终上下文(模型眼里看到的全部内容)—— 用户 问 北京 今天 多少 度 ? <tool_call> get_weather ( ' 北京 ' ) <end> [RESULT] 北京 气温 26 摄氏度 , 晴 。 北京 今天 气温 26 摄氏度 。

把上面的运行过程"画"成 Token 序列,你会看得更清楚——上下文像一条不断变长的 Token 流,工具结果就嵌在中间

逐行解读

  1. [step 0] 模型输出: " <tool_call> get_weather('北京') <end>"
    模型"觉得"需要查天气,于是输出了一段带格式的文本。注意它没有真的去查,它只是"写出了"要查天气这件事。

  2. [tool] 解析到调用: 函数=get_weather, 参数=['北京']
    框架检测到上下文里出现了特殊 Token<tool_call>,便把这段文本截出来,用正则抽出函数名和参数。

  3. [tool] 执行结果: '北京 气温 26 摄氏度,晴。'
    根据函数名get_weatherTOOLS注册表里找到对应的真实函数并执行。这一步才是**真正"发生动作"**的地方。

  4. [tool] 已把结果编码回填,上下文长度 -> 38 tokens
    np.concatenate把编码后的结果追加到上下文末尾,上下文拉到 38 个 token(初始提问 14 → 模型输出工具调用后 25 → 回填结果后 38)。

  5. [step 1] 模型输出: ' 北京 今天 气温 26 摄氏度 。'
    第二次"生成"时,模型发现上下文里已经有<RESULT>了,于是不再调用工具,而是基于结果给出最终的自然语言回答。这个回答里的信息(26 摄氏度)是"工具"告诉它的,不是模型胡编的。

  6. 最终上下文:展示了模型"眼睛"里完整的、一条龙的所有 Token——提问、工具调用、结果、最终回答,全部在同一条序列里。

整个运行过程可以抽象成一个两阶段状态机——上下文里有没有<RESULT>,决定了模型下一步做什么


六、对照真实 LLM 的 Function Calling

我们这个小实现,和真实系统(如 OpenAI / Anthropic 的工具调用)在结构上一一对应:

我们的玩具实现真实系统
特殊 Token<tool_call>/<end>模型输出的结构化tool_calls字段
TOOLS = {名字: 函数}字典请求里的tools参数(JSON Schema 描述签名)
parse_tool_call(正则)JSON 解析 + JSON Schema 校验(参数类型、必填项)
append_resultnp.concatenate回填)追加一条role="tool"的 assistant 消息
encode/decode(词表查表)大模型的分词器(BPE / SentencePiece)
respond(两阶段规则策略)真实大模型的自回归生成

真实的区别主要在于:真实系统的"模型"是真正会按概率生成文本的模型,而这里我用一个规则函数respond代替它——但它揭示的机制是完全一致的:识别格式 → 执行 → 回填 → 再生成


七、这个玩具没有覆盖到的地方(局限与扩展)

忠于"极简"的定位,它刻意省掉了这些在真实系统里非常重要的能力:

  • 真实模型生成:这里用respond()代替了模型,真实场景是模型真正按概率一个个吐 Token。
  • 参数校验:真实系统用 JSON Schema 校验参数类型、必填字段,这里只是简单split(",")
  • 循环边界:真实 Agent 会限制"最多调用 N 次工具",防止模型陷入无限循环;这里硬编码了range(2)
  • 并行工具调用:真实系统允许一次输出多个tool_calls并行执行。
  • 安全与权限:工具执行可能带来副作用(写库、发请求),真实系统需要白名单、鉴权、审计。
  • <unk>回退:代码里预留了词表外 token 的<unk>回退,真实分词器同样处理未登录词。

如果你想把"模型"换成真模型,只要把respond()替换成对某个 LLM 的调用,并让它输出{"function": "...", "arguments": {...}}这样的 JSON,再在"解析"环节改成解析 JSON 即可——骨架完全不用动。


八、小结

一次"工具调用"的真相,浓缩成一句话:

模型负责"写"(写出符合约定的特殊文本),框架负责"做"(解析、执行、回填),模型再"写"(基于结果继续作答)。

用 NumPy 几十行代码复现它,不是为了生产可用,而是为了亲手拆开黑盒、看清机制。当你下次再听到"这个 Agent 会调用工具"时,你心里就有了那张图:一个特殊 Token,被框架接住、翻译成一次真实执行,再把结果放回那个自回归的上下文里。


附:本实验代码仅依赖 Python 标准库re与第三方库numpy,可在任意干净环境(Python 3.10+)中直接运行,无平台、路径、环境名等任何绑定。