
一句话读懂所谓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结构化字段② 解析从文本里抽出函数名 参数解析 JSONJSON Schema 校验③ 执行函数根据函数名查注册表真正跑一遍Agent 运行时调用真实工具/API④ 结果拼回上下文把执行结果编码成 Tokennp.concatenate追加回去追加一条tool角色的消息真实系统和你想象中最大的不同在 ①模型只是会写符合格式的文本而不是会调用函数。格式约定好谁来解析、谁去执行那是框架的事。三、环境准备通用、可复现为了不污染基础环境建议新建一个独立的 conda 环境这里的名字是给示例用的你可以随意改conda create-nfncall-demopython3.11numpy conda activate fncall-demo python-cimport 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_TOKENnp.array(VOCAB,dtypeobject)# 用 NumPy 数组存放解码表defencode(text:str)-np.ndarray:把文本切词并编码成 np.int32 的 token-id 数组。# 这里用正则做一次极简切词真实场景是 BPE/词表分词tokensre.findall(rtool_call|end|RESULT|eot|get_weather|[^\s,()。]|[(),:。]| ,text,)returnnp.array([TOKEN_TO_ID.get(t,TOKEN_TO_ID[unk])fortintokens],dtypenp.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 包裹符只保留 函数名(参数)corere.sub(rtool_call|end|RESULT,,raw).strip()mre.match(r(\w)\((.*)\),core)ifnotm:raiseValueError(f无法解析的函数调用:{raw!r})name,args_strm.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,argsparse_tool_call(raw_call)ifnamenotinTOOLS:raiseKeyError(f未知工具:{name})returnTOOLS[name](*args)defappend_result(context:np.ndarray,result:str)-np.ndarray:把工具结果编码成 token附加到上下文 token 序列末尾模拟拼回上下文。suffixf 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用户 问 北京 今天 多少 度 contextencode(question)print(初始上下文内容:,decode(context))print(\n—— 模型开始生成 ——)forstepinrange(2):outputrespond(context)print(f[step{step}] 模型输出:{output!r})# ① 把模型输出拼进上下文contextnp.concatenate([context,encode(output)])# ② 检测特殊 Token是否包含 tool_callifTOKEN_TO_ID[tool_call]incontextandTOKEN_TO_ID[RESULT]notincontext:# ③ 从未消费的部分里截出函数调用文本并解析、执行call_textoutput.strip()name,argsparse_tool_call(call_text)print(f[tool] 解析到调用: 函数{name}, 参数{args})resultexecute_tool(call_text)print(f[tool] 执行结果:{result!r})# ④ 结果拼回上下文contextappend_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 流工具结果就嵌在中间逐行解读[step 0] 模型输出: tool_call get_weather(北京) end模型觉得需要查天气于是输出了一段带格式的文本。注意它没有真的去查它只是写出了要查天气这件事。[tool] 解析到调用: 函数get_weather, 参数[北京]框架检测到上下文里出现了特殊 Tokentool_call便把这段文本截出来用正则抽出函数名和参数。[tool] 执行结果: 北京 气温 26 摄氏度晴。根据函数名get_weather去TOOLS注册表里找到对应的真实函数并执行。这一步才是**真正发生动作**的地方。[tool] 已把结果编码回填上下文长度 - 38 tokensnp.concatenate把编码后的结果追加到上下文末尾上下文拉到 38 个 token初始提问 14 → 模型输出工具调用后 25 → 回填结果后 38。[step 1] 模型输出: 北京 今天 气温 26 摄氏度 。第二次生成时模型发现上下文里已经有RESULT了于是不再调用工具而是基于结果给出最终的自然语言回答。这个回答里的信息26 摄氏度是工具告诉它的不是模型胡编的。最终上下文展示了模型眼睛里完整的、一条龙的所有 Token——提问、工具调用、结果、最终回答全部在同一条序列里。整个运行过程可以抽象成一个两阶段状态机——上下文里有没有RESULT决定了模型下一步做什么六、对照真实 LLM 的 Function Calling我们这个小实现和真实系统如 OpenAI / Anthropic 的工具调用在结构上一一对应我们的玩具实现真实系统特殊 Tokentool_call/end模型输出的结构化tool_calls字段TOOLS {名字: 函数}字典请求里的tools参数JSON Schema 描述签名parse_tool_call正则JSON 解析 JSON Schema 校验参数类型、必填项append_resultnp.concatenate回填追加一条roletool的 assistant 消息encode/decode词表查表大模型的分词器BPE / SentencePiecerespond两阶段规则策略真实大模型的自回归生成真实的区别主要在于真实系统的模型是真正会按概率生成文本的模型而这里我用一个规则函数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中直接运行无平台、路径、环境名等任何绑定。