函数调用与工具使用:让模型长出双手


文档摘要

函数调用与工具使用:让模型长出双手 本节摘要:LLM 什么都做不了——它们只生成文本,这是全部能力。它们查不了天气、查不了数据库、发不了邮件、跑不了代码、读不了文件。你见过的每一个「AI Agent」,本质都是一个 LLM 生成 JSON 说「该调哪个函数」,然后你的代码真正去调它。模型是大脑,工具是双手,函数调用是连接它们的神经系统。本节带你吃透这条主轴:函数调用的五步循环(用户→模型→工具调用 JSON→执行→结果回灌);用 JSON Schema 写出模型能可靠调用的工具定义(描述字段就是给模型的「工具选择提示」);三大厂商(OpenAI/Anthropic/Google)与开源模型在 API

函数调用与工具使用:让模型长出双手

本节摘要:LLM 什么都做不了——它们只生成文本,这是全部能力。它们查不了天气、查不了数据库、发不了邮件、跑不了代码、读不了文件。你见过的每一个「AI Agent」,本质都是一个 LLM 生成 JSON 说「该调哪个函数」,然后你的代码真正去调它。模型是大脑,工具是双手,函数调用是连接它们的神经系统。本节带你吃透这条主轴:函数调用的五步循环(用户→模型→工具调用 JSON→执行→结果回灌);用 JSON Schema 写出模型能可靠调用的工具定义(描述字段就是给模型的「工具选择提示」);三大厂商(OpenAI/Anthropic/Google)与开源模型在 API 形态上的差异;工具选择的三档(自动/必需/指定);并行调用把多工具压进一轮;以及不可妥协的五条安全铁律(永不直传模型生成的 SQL、函数白名单、参数校验、结果脱敏、调用限速)。

对应原课程:Phase 11 · Lesson 09 · function-calling(原英文 phases/11-llm-engineering/09-function-calling/docs/en.md)。

学习目标

阅读完本节,你应当能够:

  1. 实现一个函数调用循环:定义工具 schema、解析模型的工具调用 JSON、执行函数、回灌结果。
  2. 设计带清晰描述与类型化参数的工具 schema,让模型可靠调用。
  3. 构建一个多轮 Agent 循环,串联多次函数调用回答复杂查询。
  4. 处理函数调用的边界情况:并行调用、错误传播、防止无限工具循环。

一、问题与直觉

你做了个聊天机器人。用户问:「东京现在天气怎么样?」

模型答:「我没有实时天气数据,但根据季节,东京大概 15 摄氏度……」

这是披着免责声明的幻觉。模型不知道天气,永远不会——天气每小时在变,模型训练数据是几个月前的。

正确答案需要调 OpenWeatherMap API、拿当前温度、返回真实数字。模型调不了 API,你的代码能。缺的那块拼图是:一套结构化协议,让模型说「我要带这些参数调天气 API」,让你的代码执行它、把结果喂回来。

这就是函数调用。模型输出结构化 JSON,描述调哪个函数、带什么参数;你的应用执行函数;结果回到对话里;模型用这个结果产出最终答案。没有函数调用,LLM 是百科全书;有了它,它们变成 Agent。

函数调用循环

每一次工具使用都遵循同一个五步循环。

第 1 步用户发消息;第 2 步模型收到消息和工具定义(描述可用函数的 JSON Schema);第 3 步模型不输出文字而输出工具调用——含函数名和参数的结构化 JSON;第 4 步你的代码执行函数、捕获结果;第 5 步结果回灌给模型,它现在有真实数据来产出最终答案。

模型从不执行任何东西,它只决定调什么、带什么参数。你的代码才是执行器。

工具定义:JSON Schema 契约

每个工具由一个 JSON Schema 定义,告诉模型这函数干什么、要什么参数、参数什么类型。

{ "type": "function", "function": { "name": "get_weather", "description": "获取某城市当前天气,返回摄氏温度与天气状况。", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如 '东京' 或 '旧金山'"}, "units": {"type": "string", "enum": ["celsius","fahrenheit"], "description": "温度单位"} }, "required": ["city"] } } }

description 字段至关重要。模型读它来决定何时、如何用工具。「获取天气」这种模糊描述,工具选择质量远不如「获取某城市当前天气,返回摄氏温度与天气状况」。描述就是给模型的工具选择提示

厂商对比

主流厂商都支持函数调用,但 API 形态不同。

厂商 API 参数 工具调用格式 并行调用 强制调用
OpenAI(GPT-5、o4) tools tool_calls[].function 是(单轮多个) tool_choice="required"
Anthropic(Claude 4.6/4.7) tools content[].type="tool_use" 是(多个块) tool_choice={"type":"any"}
Google(Gemini 3) function_declarations functionCall function_calling_config
开源(Llama 4、Qwen3、DeepSeek-V3) Llama 4 原生 tools;其余 Hermes 或 ChatML 混合 视模型 基于提示或支持的 tool_choice

到 2026 年,三家闭源厂商已收敛到近乎相同的 JSON Schema 格式。Llama 4 带原生 tools 字段,形状与 OpenAI 一致;第三方微调仍各异,Hermes 格式(NousResearch)最常见。要跨宿主共享工具,优先用 MCP(第 14 节)而非内联函数调用——服务器对所有客户端都一样。

工具选择:自动、必需、指定

你控制模型何时用工具。

自动(默认):模型自己决定调工具还是直接答。「2+2 等于几?」直接答;「天气怎么样?」调工具。

必需:模型必须至少调一次工具。当你确知用户意图需要工具时用,防止模型瞎猜而不查真实数据。

指定函数:强制模型调某个特定函数。tool_choice={"type":"function","function":{"name":"get_weather"}} 保证天气工具被调,无论查询是什么。用于路由——上游逻辑已确定要哪个工具。

并行函数调用

GPT-4o 和 Claude 能在单轮里调多个函数。用户问:「东京和纽约天气怎么样?」模型同时输出两个工具调用:

[ {"name": "get_weather", "arguments": {"city": "Tokyo"}}, {"name": "get_weather", "arguments": {"city": "New York"}} ]

你的代码(理想情况下并发)执行两者,把两个结果都返回,模型综合成一条响应。这把往返从 2 轮压到 1 轮。对每次查询 510 次工具调用的 Agent,并行调用降低延迟 6080%。

结构化输出 vs 函数调用

第 03 节讲了结构化输出。函数调用用同样的 JSON Schema 机制,但目的不同。

结构化输出:强制模型按特定形状产出数据,输出就是最终产物。例:从文本抽取产品信息为 {name, price, in_stock}

函数调用:模型声明要执行一个动作的意图,输出是中间步骤。例:get_weather(city="Tokyo")——模型在请求一个动作,不是产出最终答案。

要数据抽取用结构化输出,要与外部系统交互用函数调用。

安全:不可妥协的五条铁律

函数调用是你给 LLM 的最危险能力。模型选择执行什么;工具集含数据库查询,模型就构造查询;含 shell 命令,模型就写命令。

铁律 1:永不把模型生成的 SQL 直传数据库。 模型会且一定会生成 DROP TABLE、UNION 注入、返回每一行的查询。永远参数化、永远校验、永远用操作白名单。

铁律 2:函数白名单。 模型只能调你显式定义的函数。永远不要造一个「按名执行任意函数」的通用工具。你有 50 个内部函数,只暴露用户需要的 5 个。

铁律 3:校验参数。 模型可能传一个城市名 "; DROP TABLE users; --"。执行前按期望类型、范围、格式校验每个参数。

铁律 4:工具结果脱敏。 工具若返回敏感数据(API key、PII、内部错误),回灌模型前先过滤——模型会把工具结果逐字写进响应。

铁律 5:工具调用限速。 循环里的模型能调几百次工具。设上限(每对话 10~20 次合理),打断无限循环。

错误处理

工具会失败:API 超时、数据库宕机、文件不存在。模型需要知道工具何时失败、为何失败。

把错误作为结构化工具结果返回,而不是抛异常:

{ "error": true, "message": "找不到城市 'Toky'。你是想查 'Tokyo' 吗?", "code": "CITY_NOT_FOUND", "suggestions": ["tokyo"] }

模型读了它,调整参数,重试。模型擅长从结构化错误消息自纠正,不擅长从空响应或「出错了」这种泛泛错误中恢复。

MCP:模型上下文协议

MCP 是 Anthropic 的工具互操作开放标准。与其每个应用各定各的工具,MCP 提供一个通用协议:工具由 MCP 服务器提供,被 MCP 客户端(Claude Code、Cursor 或你的应用)消费。

一个 MCP 服务器能把工具暴露给任何兼容客户端。一个 Postgres MCP 服务器给任何兼容 MCP 的 Agent 数据库访问;一个 GitHub MCP 服务器给任何 Agent 仓库访问。工具定义一次,到处用。MCP 之于函数调用,如同 HTTP 之于网络——它标准化传输层,让工具变可移植。第 14 节专门讲它。

二、从零实现

完整代码见原课程 code/function_calling.py,这里给出关键骨架。本节用模拟的「模型决策」函数代替真实 LLM,让你看清循环结构;换成真实 API 时,只替换那一个函数即可。

步骤 1:工具注册表

注册表存工具定义(模型看到的)和实现(你的代码执行的)。

import json, math, time TOOL_REGISTRY = {} def register_tool(name, description, parameters, function): TOOL_REGISTRY[name] = { "definition": { "type": "function", "function": {"name": name, "description": description, "parameters": parameters}}, "function": function, }

步骤 2:实现工具(以天气和计算器为例)

每个工具都是普通 Python 函数,返回 dict(成功或错误)。

WEATHER_DB = { "东京": {"temp_c": 18, "condition": "多云", "humidity": 72}, "纽约": {"temp_c": 22, "condition": "晴", "humidity": 45}, "伦敦": {"temp_c": 12, "condition": "雨", "humidity": 88}, } def get_weather(city, units="celsius"): key = city.strip() if key not in WEATHER_DB: return {"error": True, "message": f"找不到城市 '{city}'。", "suggestions": [c for c in WEATHER_DB], "code": "CITY_NOT_FOUND"} data = WEATHER_DB[key].copy() if units == "fahrenheit": data["temp_f"] = round(data.pop("temp_c") * 9/5 + 32, 1) data["city"] = city return data def calculator(expression, precision=2): allowed = set("0123456789+-*/.() ") if not all(c in allowed for c in expression): return {"error": True, "message": f"表达式含非法字符: {expression}"} try: result = eval(expression, {"__builtins__": {}}, {"math": math}) return {"result": round(float(result), precision), "expression": expression} except Exception as e: return {"error": True, "message": str(e)}

💡 注意 calculator 里的字符白名单与受限 __builtins__——这正是铁律 1 和 3 的落地:模型生成的表达式绝不裸跑,先过滤字符、再封死内建函数,把 __import__('os') 这类注入挡在门外。

步骤 3:注册所有工具

def register_all_tools(): register_tool("get_weather", "获取某城市当前天气,返回温度、天气状况、湿度。", {"type":"object","properties":{ "city":{"type":"string","description":"城市名,如 '东京'"}, "units":{"type":"string","enum":["celsius","fahrenheit"],"description":"温度单位"}}, "required":["city"]}, get_weather) register_tool("calculator", "计算数学表达式,支持 +、-、*、/、括号、小数,返回数值结果。", {"type":"object","properties":{ "expression":{"type":"string","description":"数学表达式,如 '(10+5)*3'"}, "precision":{"type":"integer","description":"结果小数位","default":2}}, "required":["expression"]}, calculator) # web_search / read_file / run_code 同理……

步骤 4:函数调用循环

这是核心引擎。simulate_model_decision 模拟模型决定调哪个工具;真实使用时换成 LLM API 调用。

def simulate_model_decision(user_message, tools, history): """模拟模型决策:真实场景换成 LLM API 调用。""" msg = user_message.lower() if any(w in msg for w in ["天气", "温度"]): cities = [c for c in WEATHER_DB if c in user_message] or ["东京"] return [{"name": "get_weather", "arguments": {"city": c}} for c in cities] if any(w in msg for w in ["计算", "等于"]) or any(c in msg for c in "+-*/"): expr = "".join(c for c in msg if c in "0123456789+-*/.() ") return [{"name": "calculator", "arguments": {"expression": expr.strip()}}] if expr.strip() else [] # search / read_file / run_code 分支同理…… return [] def execute_tool_call(tool_call): name, args = tool_call["name"], tool_call["arguments"] if name not in TOOL_REGISTRY: return {"tool": name, "result": {"error": True, "message": f"未知工具: {name}"}} start = time.time() try: result = TOOL_REGISTRY[name]["function"](**args) except TypeError as e: result = {"error": True, "message": f"参数无效: {e}"} return {"tool": name, "result": result, "execution_time_ms": round((time.time()-start)*1000, 2)} def run_function_calling_loop(user_message, max_iterations=5): conversation = [{"role": "user", "content": user_message}] tool_defs = [t["definition"] for t in TOOL_REGISTRY.values()] all_results = [] for it in range(max_iterations): calls = simulate_model_decision(user_message, tool_defs, conversation) if not calls: break results = [execute_tool_call(c) for c in calls] conversation.append({"role": "assistant", "content": None, "tool_calls": calls}) for r in results: conversation.append({"role": "tool", "tool_name": r["tool"], "content": json.dumps(r["result"], ensure_ascii=False)}) all_results.extend(results) break # 模拟单轮;真实多步 Agent 此处继续循环 return {"conversation": conversation, "tool_results": all_results, "iterations": it+1 if calls else 0}

步骤 5:参数校验

执行前按 JSON Schema 校验工具调用参数。

def validate_tool_arguments(tool_name, arguments): if tool_name not in TOOL_REGISTRY: return [f"未知工具: {tool_name}"] schema = TOOL_REGISTRY[tool_name]["definition"]["function"]["parameters"] errors = [] if not isinstance(arguments, dict): return [f"参数必须是对象,得到 {type(arguments).__name__}"] for req in schema.get("required", []): if req not in arguments: errors.append(f"缺少必填参数: {req}") props = schema.get("properties", {}) type_map = {"string": str, "integer": int, "number": (int,float), "boolean": bool, "array": list, "object": dict} for name, val in arguments.items(): if name not in props: errors.append(f"未知参数: {name}"); continue ps = props[name] t = ps.get("type") if t in type_map and not isinstance(val, type_map[t]): errors.append(f"参数 '{name}': 期望 {t},得到 {type(val).__name__}") if "enum" in ps and val not in ps["enum"]: errors.append(f"参数 '{name}': '{val}' 不在 {ps['enum']} 中") return errors

校验在执行前拦住缺参、错型、非法枚举值——这是铁律 3 的落地。

三、框架对比

OpenAI 函数调用

# from openai import OpenAI # client = OpenAI() # tools = [{"type":"function","function":{ # "name":"get_weather","description":"获取某城市当前天气", # "parameters":{"type":"object","properties":{ # "city":{"type":"string"},"units":{"type":"string","enum":["celsius","fahrenheit"]}}, # "required":["city"]}}}] # resp = client.chat.completions.create(model="gpt-4o", # messages=[{"role":"user","content":"东京天气?"}], # tools=tools, tool_choice="auto") # tc = resp.choices[0].message.tool_calls[0] # result = get_weather(**json.loads(tc.function.arguments)) # final = client.chat.completions.create(model="gpt-4o", messages=[ # {"role":"user","content":"东京天气?"}, # resp.choices[0].message, # {"role":"tool","tool_call_id":tc.id,"content":json.dumps(result, ensure_ascii=False)}])

OpenAI 把工具调用返回为 message.tool_calls,每个调用有 id,回灌结果时必须带上,模型靠它匹配结果与调用。GPT-4o 单轮可返回多个工具调用,要遍历全部执行。

Anthropic 工具使用

# import anthropic # client = anthropic.Anthropic() # resp = client.messages.create(model="claude-sonnet-5", max_tokens=1024, # tools=[{"name":"get_weather","description":"获取某城市当前天气", # "input_schema":{"type":"object","properties":{ # "city":{"type":"string"},"units":{"type":"string","enum":["celsius","fahrenheit"]}}, # "required":["city"]}}], # messages=[{"role":"user","content":"东京天气?"}]) # block = next(b for b in resp.content if b.type == "tool_use") # result = get_weather(**block.input) # final = client.messages.create(model="claude-sonnet-5", max_tokens=1024, tools=[...], # messages=[{"role":"user","content":"东京天气?"}, # {"role":"assistant","content":resp.content}, # {"role":"user","content":[{"type":"tool_result","tool_use_id":block.id, # "content":json.dumps(result, ensure_ascii=False)}]}])

Anthropic 把工具调用返回为 type:"tool_use" 的内容块,工具结果放在 type:"tool_result" 的用户消息里。关键差异:Anthropic 用 input_schema 定义参数,OpenAI 用 parameters

MCP 集成

# MCP 服务器通过标准化协议暴露工具,任何兼容客户端都能发现并调用。 # from mcp import ClientSession, StdioServerParameters # from mcp.client.stdio import stdio_client # params = StdioServerParameters(command="npx", # args=["-y","@modelcontextprotocol/server-postgres","postgresql://localhost/mydb"]) # async with stdio_client(params) as (read, write): # async with ClientSession(read, write) as session: # await session.initialize() # tools = await session.list_tools() # result = await session.call_tool("query", {"sql":"SELECT count(*) FROM users"})

MCP 把工具实现与工具消费解耦:Postgres 服务器懂 SQL,GitHub 服务器懂 API,你的 Agent 只管发现并调用——不需要为每个集成写厂商专属代码。LangChain 的 @tool 装饰器、LlamaIndex 的 FunctionTool 则把本节的注册表/校验/循环封装成更省事的抽象,但底层循环与本节一致。

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-tool-designer.md:一个可复用提示模板,用于设计工具定义。给它一段「你想让工具做什么」的描述,它产出完整的 JSON Schema 定义,含描述、类型、约束。
  • skill-function-calling-patterns.md:一个决策框架,用于在生产中实现函数调用,覆盖工具设计、错误处理、安全、厂商专属模式。

Python 代码(code/function_calling.py)是一个独立的函数调用引擎,把 simulate_model_decision 换成真实 LLM API、把模拟工具换成真实 API,即可投产;注册表、校验、循环、限速逻辑均无需修改。

五、练习

  1. 加第 6 个工具:数据库查询:实现一个带内存表的模拟 SQL 工具,接受表名和过滤条件(不是裸 SQL)。校验表名在白名单、过滤运算符限于 =><>=<=,返回匹配行。

  2. 带错误反馈的重试:工具调用失败时(如城市找不到),把错误消息回灌给模型决策函数,让它修正参数。跟踪每次调用的重试次数,设单次调用最多 3 次重试。

  3. 多步 Agent:有些查询要链式调工具:「读配置文件,告诉我配的是什么模型,再搜这个模型的价格」。实现一个循环,跑到模型认为不再需要工具为止,把累计结果传进每步决策,限 10 次迭代防死循环。

  4. 测工具选择准确率:造 30 条带期望工具名的测试查询,跑你的决策函数,测出选对工具的百分比,找出最容易在工具间混淆的查询。

  5. 工具调用缓存:60 秒内同一工具带相同参数被调,返回缓存结果而非重跑。用 (tool_name, frozenset(args.items())) 做 key。在 20 条查询的对话里测缓存命中率。

本节要点回顾

  1. LLM 只生成文本:所有「Agent」本质是模型生成「调哪个函数」的 JSON,你的代码真正执行。
  2. 五步循环:用户→模型(带工具定义)→工具调用 JSON→执行→结果回灌→最终答案。
  3. 模型从不执行:它只决定调什么、带什么参数,你的代码是执行器。
  4. 工具定义是 JSON Schema 契约:description 字段是给模型的工具选择提示,越具体越可靠。
  5. 工具选择三档:自动(模型决定)、必需(防瞎猜)、指定(用于路由)。
  6. 并行调用压往返:GPT-4o/Claude 单轮多工具,把 Agent 延迟降 60~80%。
  7. 结构化输出 vs 函数调用:前者产出最终数据,后者声明执行动作的意图。
  8. 五条安全铁律:不裸传 SQL、函数白名单、参数校验、结果脱敏、调用限速。
  9. 错误要结构化返回:模型擅长从结构化错误自纠正,不擅长从空响应恢复。
  10. MCP 让工具可移植:工具定义一次、到处用,是函数调用的标准化传输层(第 14 节深入)。

下一节,我们转向评估——搭出来的 LLM 应用到底行不行,如何用检索指标、生成指标、LLM-as-judge 客观量化,告别「感觉不错」的主观判断。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U