一句话读懂:所谓"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 -n fncall-demo python=3.11 numpy
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),它根据上下文
是否已有工具结果来决定下一步该输出什么 —— 这模拟了大模型
"规划调用工具 / 消费工具结果"的两阶段行为。
"""

import re
import numpy as np

# ---------------------------------------------------------------------------
# 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: i for i, t in enumerate(VOCAB)}
ID_TO_TOKEN = np.array(VOCAB, dtype=object)  # 用 NumPy 数组存放"解码表"


def encode(text: str) -> np.ndarray:
    """把文本切词并编码成 np.int32 的 token-id 数组。"""
    # 这里用正则做一次极简切词(真实场景是 BPE/词表分词)
    tokens = re.findall(
        r"<tool_call>|<end>|<RESULT>|<eot>|get_weather|[^\s,,:()'。]+|[(),,:。']| ",
        text,
    )
    return np.array([TOKEN_TO_ID.get(t, TOKEN_TO_ID['<unk>']) for t in tokens],
                    dtype=np.int32)


def decode(ids: np.ndarray) -> str:
    """把 token-id 数组还原成文本。"""
    return " ".join(ID_TO_TOKEN[ids].tolist())


# ---------------------------------------------------------------------------
# 2. 工具注册表:name -> callable
# ---------------------------------------------------------------------------
TOOLS = {
    "get_weather": lambda city: f"{city} 气温 26 摄氏度,晴。",
}


# ---------------------------------------------------------------------------
# 3. 解析:从 <tool_call> ... <end> 里抽出 "函数名(参数)"
# ---------------------------------------------------------------------------
def parse_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)
    if not m:
        raise ValueError(f"无法解析的函数调用: {raw!r}")
    name, args_str = m.group(1), m.group(2)
    args = [a.strip("' ") for a in args_str.split(",")] if args_str else []
    return name, args


# ---------------------------------------------------------------------------
# 4. 执行 + 回填:把 "name(args) => result" 拼回上下文
# ---------------------------------------------------------------------------
def execute_tool(raw_call: str) -> str:
    name, args = parse_tool_call(raw_call)
    if name not in TOOLS:
        raise KeyError(f"未知工具: {name}")
    return TOOLS[name](*args)


def append_result(context: np.ndarray, result: str) -> np.ndarray:
    """把工具结果编码成 token,附加到上下文 token 序列末尾(模拟"拼回上下文")。"""
    suffix = f" <RESULT> {result}"
    return np.concatenate([context, encode(suffix)])


# ---------------------------------------------------------------------------
# 5. 模拟"模型":根据上下文决定下一步输出什么
# ---------------------------------------------------------------------------
def respond(context: np.ndarray) -> str:
    """两阶段策略:
       - 上下文里还没有 <RESULT>  -> 规划一次工具调用
       - 已经拿到 <RESULT>        -> 基于结果给出最终自然语言回答
    """
    if TOKEN_TO_ID['<RESULT>'] in context:
        # 阶段二:工具结果已就位,模型把它"组织"成一句话
        return " 北京 今天 气温 26 摄氏度 。"
    # 阶段一:模型决定调用工具
    return " <tool_call> get_weather('北京') <end>"


# ---------------------------------------------------------------------------
# 主循环:生成 -> 遇到特殊Token -> 解析 -> 执行 -> 回填 -> 继续生成
# ---------------------------------------------------------------------------
def main():
    np.random.seed(0)
    print("=" * 60)
    print("环境: numpy", np.__version__)
    print("=" * 60)

    # 用户提问(作为初始上下文)
    question = "用户 问 : 北京 今天 多少 度 ?"
    context = encode(question)
    print("初始上下文内容:", decode(context))

    print("\n—— 模型开始'生成' ——")
    for step in range(2):
        output = respond(context)
        print(f"[step {step}] 模型输出: {output!r}")

        # ① 把模型输出拼进上下文
        context = np.concatenate([context, encode(output)])

        # ② 检测特殊 Token:是否包含 <tool_call>
        if TOKEN_TO_ID['<tool_call>'] in context and TOKEN_TO_ID['<RESULT>'] not in context:
            # ③ 从未消费的部分里截出函数调用文本并解析、执行
            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+)中直接运行,无平台、路径、环境名等任何绑定。

更多推荐