从零理解Function Calling:大模型与外部世界交互的核心协议

从零理解Function Calling:大模型与外部世界交互的核心协议

1. 项目概述:从“笨办法”开始,理解Function Calling的本质

最近在AI圈里,Function Calling和AI Agent这两个词的热度居高不下。无论是想自己动手搭建一个能自动处理任务的智能体,还是想搞明白大模型除了聊天还能怎么用,Function Calling都是一个绕不开的核心概念。但很多教程一上来就讲架构、讲框架,对于新手来说,就像还没学会走路就被要求跑步,很容易一头雾水。

所以,今天我们不谈那些高大上的架构图,也不急着去配置复杂的开发环境。我们就用一个最“笨”的办法,亲手写几行代码,来把Function Calling到底是什么、怎么工作、以及它和AI Agent的关系,给彻底搞明白。这个方法虽然“笨”,但胜在直观。当你亲手实现一遍之后,再看那些开源框架和复杂项目,就会有一种“哦,原来如此”的通透感。你会发现,那些看似神秘的AI Agent,其最基础的通信机制,正是建立在Function Calling这块基石之上。

简单来说,Function Calling就是大语言模型(LLM)与外部世界“握手”的协议。模型本身是个“思想家”,它擅长理解和生成文本,但它不会查天气、不会发邮件、不能操作数据库。Function Calling就是给这位“思想家”配上了一双可以指挥“手”和“脚”的大脑皮层。模型通过一种结构化的方式告诉系统:“我想调用‘查询天气’这个功能,参数是‘北京’”,然后系统就去执行对应的代码,并把结果返回给模型,模型再组织成自然语言回答你。这个“告诉系统”的过程,就是Function Calling。而一个能够自主规划、调用多个功能来完成复杂目标的系统,就是AI Agent的雏形。

2. 核心需求解析:为什么我们需要Function Calling?

在深入代码之前,我们必须先弄清楚为什么要发明Function Calling。直接让大模型输出一段可执行的Python或JavaScript代码不就行了吗?理论上可以,但这在实践中存在巨大的缺陷和风险,这正是Function Calling要解决的核心问题。

2.1 解决大模型的“幻觉”与不可控性

大模型生成代码是开放式的,它可能生成任何语法正确但逻辑诡异、甚至存在安全风险的代码。比如,你问“帮我删除一些没用的文件”,模型可能直接生成os.system(‘rm -rf /’)这样的危险命令。Function Calling通过“定义功能清单”的方式,将模型的输出严格限制在预设的安全范围内。模型只能从清单里选择功能,并提供符合预定义结构的参数,这就好比给了模型一份安全的“工具菜单”,它只能点菜,不能自己进厨房乱搞。

2.2 实现结构化与可靠的数据交换

让模型生成自然语言描述的结果,再由程序去解析,是极其不可靠的。例如,模型回答“今天北京最高气温28度,最低气温15度”。程序要如何准确无误地从这句话里提取出“28”和“15”这两个数字?正则表达式会写得非常复杂且脆弱。Function Calling要求模型必须按照{“temperature_high”: 28, “temperature_low”: 15}这样的JSON格式输出,程序解析起来就变成了一个简单的字典键值对读取,百分之百可靠。这种结构化的输出,是AI与现有软件系统、API接口无缝集成的前提。

2.3 构建复杂AI Agent的基石

一个真正的AI Agent,比如能自动处理客服工单、能进行多步骤数据分析的智能体,其核心工作流就是“思考-决策-执行-再思考”。Function Calling标准化了“决策”到“执行”的接口。Agent的“大脑”(LLM)根据当前目标和状态,从技能库(一组定义好的Function)中选择一个或多个来调用。这个选择过程本身就是一种规划能力。没有Function Calling,Agent的规划和执行将是割裂的;有了它,Agent才能形成一个完整的感知-决策-执行闭环。

所以,学习Function Calling,绝不是仅仅学习一个API调用技巧。它是在学习如何为AI构建可扩展、安全、可靠的行为能力,是打开AI Agent开发大门的第一把钥匙。

3. 环境准备与工具选型:最小化起步

我们坚持“笨办法”哲学,意味着用最少的依赖、最直观的工具来开始。避免一上来就引入LangChain、AutoGen等重型框架,它们封装了太多细节,不利于理解本质。

核心工具:Python + OpenAI API(或兼容的本地模型)

  1. Python 3.8+:AI领域的事实标准语言,库生态丰富。确保你的环境已安装。
  2. OpenAI Python库pip install openai。我们将使用其ChatCompletion接口,这是目前Function Calling事实上的标准接口定义,绝大多数其他模型和平台都兼容此格式。
  3. 一个API Key:如果你使用OpenAI的模型,需要去平台申请。为了完全本地化和零成本学习,我强烈建议使用Ollama搭配本地模型
    • 安装Ollama:访问官网下载安装。
    • 拉取一个适合Function Calling的轻量级模型,例如ollama pull qwen2.5:7b-instruct。Qwen、Llama等较新的模型都具备良好的Function Calling能力。
    • 这样,你的所有实验都在本地进行,无需担心费用和网络问题。

为什么不用更高级的框架?像LangChain这样的框架,提供了Tool抽象和便捷的Agent执行器,但它们在你和底层机制之间增加了一层抽象。在初学阶段,这层抽象会掩盖掉“模型究竟输出了什么”、“请求体到底长什么样”这些关键细节。我们先用手动的方式把整个过程走通,未来再使用框架时,你就能清晰地知道它在帮你做什么,出了问题也能快速定位。

代码编辑器:VS Code、PyCharm甚至Jupyter Notebook都可以。选择你顺手的。

注意:本文后续的代码示例将基于OpenAI API的格式,因为它是最通用的标准。如果你使用Ollama+本地模型,只需将请求的base_url指向本地服务(如http://localhost:11434/v1),并将model参数改为你拉取的模型名称即可,Function Calling的请求和响应格式是完全一致的。

4. 从零开始:手动实现第一个Function Calling

让我们从一个最简单的场景开始:让AI帮我们查询某个城市的当前天气。当然,我们没有真正的天气API,但我们可以模拟一个。这个过程分为三个清晰步骤:定义函数、与大模型对话、解析并执行。

4.1 第一步:定义你的“功能菜单”

首先,我们要告诉大模型,它现在有哪些“超能力”可以用。这个菜单需要按照特定的格式来写。

# 这是我们要提供给模型的“功能清单” tools = [ { “type”: “function”, # 固定字段,表示这是一个函数定义 “function”: { “name”: “get_current_weather”, # 函数的名字,要求清晰明确 “description”: “获取指定城市的当前天气情况”, # 关键!用自然语言描述这个函数是干什么的。模型主要靠这个描述来决定是否调用它。 “parameters”: { # 定义函数需要的参数,使用JSON Schema格式 “type”: “object”, “properties”: { “location”: { “type”: “string”, “description”: “城市名称,例如:北京, 上海”, # 对参数的描述同样重要 }, “unit”: { “type”: “string”, “enum”: [“celsius”, “fahrenheit”], # 枚举类型,限制参数只能是指定的值 “description”: “温度单位,摄氏度或华氏度”, } }, “required”: [“location”], # 指定哪些参数是必须的 }, }, } ]

关键解读

  • description字段是灵魂。模型不理解代码,它只理解自然语言。你必须用清晰、无歧义的语言描述这个函数的功能和每个参数的意义。比如,如果你把location描述成“地点”,模型可能填入“在公园里”,而“城市名称”则明确得多。
  • JSON Schema是一种描述数据结构的标准。在这里,它严格定义了模型输出参数的“形状”。这保证了我们收到的参数一定是可解析的JSON对象。

4.2 第二步:与大模型对话,触发Function Calling

现在,我们带着这份“菜单”去问大模型一个问题。

import openai # 如果你用Ollama,client可以这样初始化: # from openai import OpenAI # client = OpenAI(base_url=‘http://localhost:11434/v1’, api_key=‘ollama’) # 如果你用OpenAI官方API,请配置你的API Key # openai.api_key = ‘your-api-key’ # 模拟使用OpenAI格式的请求 def chat_with_ai(user_message): response = openai.ChatCompletion.create( model=“gpt-3.5-turbo”, # 或你在Ollama中使用的模型名,如“qwen2.5:7b-instruct” messages=[ {“role”: “user”, “content”: user_message} ], tools=tools, # 关键!在这里传入我们定义好的功能清单 tool_choice=“auto”, # “auto”表示让模型自己决定是否调用函数。还可以强制指定“none”或不调用,或指定某个函数。 ) return response # 用户提问 user_question = “北京今天天气怎么样?” response = chat_with_ai(user_question) print(“模型原始响应:”) print(response)

执行与观察: 运行这段代码,你会得到一个复杂的响应对象。不要被吓到,我们关心的是其中最关键的部分:response.choices[0].message

如果模型认为需要调用函数来回答你的问题,这个message对象里就不会有常规的content文本,而是会包含一个tool_calls数组。这是Function Calling机制的核心标志!

一个典型的tool_calls内容如下:

{ “role”: “assistant”, “content”: null, “tool_calls”: [ { “id”: “call_abc123”, “type”: “function”, “function”: { “name”: “get_current_weather”, “arguments”: “{\”location\“: \”北京\“, \”unit\“: \”celsius\“}” } } ] }

看!模型没有直接生成“北京天气是...”,而是说:“我要调用get_current_weather这个函数,参数是location=北京unit=celsius”。arguments是一个JSON格式的字符串,其结构完全符合我们之前定义的parametersSchema。

4.3 第三步:执行函数并返回结果给模型

模型已经做出了“决策”,现在轮到我们的程序来“执行”了。

# 首先,解析模型传来的参数 import json message = response.choices[0].message if message.tool_calls: # 通常一次只调用一个函数,我们取第一个 tool_call = message.tool_calls[0] function_name = tool_call.function.name function_args = json.loads(tool_call.function.arguments) # 将字符串解析为字典 print(f“模型要求调用函数:{function_name}”) print(f“函数参数:{function_args}”) # 根据函数名,执行对应的真实函数 if function_name == “get_current_weather”: # 这里是你的真实业务逻辑!可以调用真正的天气API。 # 我们这里用一个模拟函数代替。 def get_current_weather(location, unit): # 模拟API调用返回 weather_info = { “location”: location, “temperature”: 28, “unit”: unit, “condition”: “晴朗”, “humidity”: 65 } return weather_info # 执行模拟函数 result = get_current_weather(**function_args) # 用**将字典解包为关键字参数 print(f“执行结果:{result}”)

现在,我们得到了一个包含天气信息的字典result。但对话还没结束,我们需要把这个结果“喂回”给大模型,让它来组织最终的自然语言回答。

4.4 第四步:将结果返回,让模型生成最终回答

我们把模型的第一次回复(包含tool_calls的消息)和执行函数的结果,一起作为新的上下文,再次发送给模型。

# 构建新的消息列表,包含整个对话历史 messages = [ {“role”: “user”, “content”: user_question}, message, # 助理的第一次回复(包含tool_calls) { “role”: “tool”, # 注意!这是一个新的角色类型 “tool” “content”: json.dumps(result), # 将执行结果转为JSON字符串 “tool_call_id”: tool_call.id # 必须对应上第一次调用时的ID } ] # 第二次请求,让模型基于函数执行结果生成最终回答 final_response = openai.ChatCompletion.create( model=“gpt-3.5-turbo”, messages=messages, # 这次不需要再传递tools参数,除非你希望模型能继续调用新函数 ) final_answer = final_response.choices[0].message.content print(f“\nAI的最终回答:{final_answer}”)

这次,模型收到了roletool的消息,里面包含了它要求的天气数据。于是,它会生成类似这样的自然语言回答:“北京今天天气晴朗,气温28摄氏度,湿度65%。”

至此,一个完整的Function Calling流程就走通了。它清晰地分为两个回合:

  1. 第一回合:用户提问 -> 模型分析后,决定调用函数,并返回结构化调用请求。
  2. 第二回合:程序执行函数 -> 将结果以tool角色返回 -> 模型消化结果,生成面向用户的最终回答。

5. 核心机制深度剖析:不仅仅是“调用函数”

通过上面的“笨办法”实操,我们已经看到了Function Calling的外在流程。现在,我们来深入它的内在机制,理解它为何如此设计,以及它如何赋能AI Agent。

5.1 结构化输出:从自由文本到精确指令

这是Function Calling最根本的价值。传统的提示词工程(Prompt Engineering)是在和模型的“自由意志”博弈,你永远无法百分百保证输出的格式。而Function Calling通过tools参数,为模型划定了一个“结构化输出沙箱”。

当模型看到tools定义时,它内部的任务就从“生成一段回答”转变为“根据用户问题,从工具列表中选择最合适的一个,并填充其参数”。这是一种任务范式的转换。模型的输出被严格约束在预定义的JSON Schema内,这使得后续的程序处理变得 deterministic(确定性的)。对于构建生产级应用,这种可靠性是生命线。

5.2 多函数调用与并行处理

我们的例子只调用了一个函数。但tool_calls是一个数组,这意味着模型可以同时决定调用多个函数。例如,用户问:“对比一下北京和上海今天的天气。”一个足够聪明的模型可能会在同一个回复中,生成两个tool_calls,一个查询北京天气,一个查询上海天气。

程序可以并行或串行执行这两个函数调用,然后将所有结果收集起来,在一次tool消息中或分多条tool消息返回给模型。模型再综合这些信息,生成对比性的回答。这种并行任务规划与信息整合的能力,正是复杂AI Agent的核心。

5.3 Tool Choice策略:控制模型的自主权

在请求中,tool_choice参数给了我们控制权:

  • “auto”(默认):模型自主决定是否调用、调用哪个工具。这是构建自主Agent的模式。
  • “none”:强制模型不调用任何工具,只生成文本回复。当你想确保模型进行纯文本对话时使用。
  • {“type”: “function”, “function”: {“name”: “xxx”}}:强制模型调用指定的某个工具。这在构建严格工作流时很有用,比如第一步必须调用“数据查询”,第二步必须调用“数据分析”。

通过灵活运用tool_choice,我们可以设计出从完全自主到严格流程控制的各类AI应用。

5.4 与AI Agent架构的关联

现在,让我们把视野拉高,看看Function Calling在AI Agent宏大架构中的位置。一个典型的Agent架构包含以下层次:

  1. LLM核心(大脑):负责理解、规划、决策。
  2. Harness/Agent Core(基础设施层):这是包裹在LLM之外的一层框架。它负责管理对话状态(messages历史)、维护工具清单(tools)、处理Function Calling的请求/响应循环、调度工具执行。我们上面手写的代码,就是一个极简的Harness。
  3. Tools/Skills(技能层):一个个具体的函数,如get_weathersend_emailquery_database。这就是我们定义的tools列表里的内容。
  4. Memory(记忆层):存储对话历史、工具执行结果、知识片段等,为LLM的决策提供上下文。
  5. Planning & Execution(规划与执行循环):Agent的核心工作流。LLM根据目标(“用户想对比天气”)和记忆,规划步骤(“先调A工具,再调B工具”),通过Harness调用Tools执行,将结果存入Memory,再进行下一步规划,直到任务完成。

Function Calling,正是连接LLM(大脑)、Harness(调度中心)和Tools(手脚)的标准化协议。没有这个协议,Harness就无法理解LLM的意图,Tools也无法被准确调用。因此,深入理解Function Calling,是理解整个AI Agent运行机制的基础。

6. 实战进阶:构建一个多技能AI助手

理解了单次调用,我们来挑战一个更复杂的场景:一个能处理“查询天气”和“计算器”两种任务的AI助手。这会让我们的Harness逻辑变得更通用。

6.1 定义多工具清单

tools = [ { “type”: “function”, “function”: { “name”: “get_current_weather”, “description”: “获取指定城市的当前天气情况”, “parameters”: { “type”: “object”, “properties”: { “location”: {“type”: “string”, “description”: “城市名称”}, “unit”: {“type”: “string”, “enum”: [“celsius”, “fahrenheit”], “description”: “温度单位”} }, “required”: [“location”] } } }, { “type”: “function”, “function”: { “name”: “calculator”, “description”: “执行数学计算。支持加(+)、减(-)、乘(*)、除(/)、乘方(**)等运算。”, “parameters”: { “type”: “object”, “properties”: { “expression”: {“type”: “string”, “description”: “数学表达式,例如:’3 + 5 * 2‘ 或 ’(10 - 4) / 3‘”} }, “required”: [“expression”] } } } ]

6.2 实现通用的工具执行分发器

我们需要一个中央处理器,能根据模型返回的function_name,自动找到并执行对应的函数。

# 首先,实现具体的工具函数 def get_current_weather(location, unit=“celsius”): # 模拟实现 return {“location”: location, “temperature”: 22, “unit”: unit, “condition”: “多云”} def calculator(expression): # 警告:在生产环境中,直接eval是极度危险的!这里仅用于演示。 # 真实场景应使用安全表达式解析库(如`ast.literal_eval`或自定义解析器)。 try: result = eval(expression) # 仅作演示,切勿用于生产! return {“expression”: expression, “result”: result} except Exception as e: return {“expression”: expression, “error”: str(e)} # 建立工具名到函数对象的映射 TOOL_REGISTRY = { “get_current_weather”: get_current_weather, “calculator”: calculator, } # 通用的工具调用执行函数 def execute_tool_call(tool_call): function_name = tool_call.function.name function_args = json.loads(tool_call.function.arguments) if function_name in TOOL_REGISTRY: func = TOOL_REGISTRY[function_name] # 安全起见,可以在这里检查参数 return func(**function_args) else: return {“error”: f“未知的工具函数:{function_name}”}

6.3 实现多轮对话循环

一个真正的助手需要支持多轮对话,并且能记住历史。同时,模型在一次回复中可能调用多个工具。

def run_conversation(user_input, conversation_history=[]): # 1. 将用户输入加入历史 conversation_history.append({“role”: “user”, “content”: user_input}) # 2. 发送请求给模型,携带完整历史和工具定义 response = openai.ChatCompletion.create( model=“gpt-3.5-turbo”, messages=conversation_history, tools=tools, tool_choice=“auto”, ) assistant_message = response.choices[0].message # 3. 将助理的回复(可能包含tool_calls)加入历史 conversation_history.append(assistant_message.to_dict()) # 注意转为字典格式 all_tool_results = [] # 4. 检查并处理所有工具调用 if assistant_message.tool_calls: for tool_call in assistant_message.tool_calls: print(f“[系统] 正在执行工具:{tool_call.function.name}, 参数:{tool_call.function.arguments}”) # 执行单个工具 tool_result = execute_tool_call(tool_call) result_str = json.dumps(tool_result, ensure_ascii=False) # 为每个工具结果创建一条“tool”消息,并加入历史 tool_message = { “role”: “tool”, “content”: result_str, “tool_call_id”: tool_call.id } conversation_history.append(tool_message) all_tool_results.append(tool_result) # 5. 如果有工具被调用,需要再次请求模型,让它基于工具结果生成最终回复 print(“[系统] 工具执行完毕,正在生成最终回答...”) second_response = openai.ChatCompletion.create( model=“gpt-3.5-turbo”, messages=conversation_history, # 注意:这次请求通常不再需要传递tools,除非希望开启新一轮工具调用 ) final_message = second_response.choices[0].message conversation_history.append(final_message.to_dict()) print(f“[AI助手] {final_message.content}”) else: # 6. 如果没有调用工具,直接输出助理的回复 print(f“[AI助手] {assistant_message.content}”) # 返回更新后的对话历史,以便下一轮使用 return conversation_history # 开始多轮对话 history = [] print(“欢迎使用多技能AI助手(天气/计算器)。输入‘退出’结束。”) while True: user_input = input(“\n你: ”) if user_input.lower() in [“退出”, “exit”, “quit”]: break history = run_conversation(user_input, history)

这个进阶示例实现了一个微型的、但功能完整的AI Agent Harness。它具备了多工具管理、多轮对话状态维护、并行工具调用处理等核心能力。你可以通过向TOOL_REGISTRYtools列表添加新函数,轻松地为这个助手扩展新的技能,例如发送邮件、查询数据库等。

7. 避坑指南与最佳实践

在亲手搭建和实验的过程中,我踩过不少坑,也总结出一些让Function Calling更稳定、更高效的经验。

7.1 工具描述的“艺术”

工具的description和参数的description是模型决策的唯一依据。写得好坏,天差地别。

  • 要具体,不要抽象
    • 差:“处理数据”。(模型不知道具体做什么)
    • 好:“根据用户提供的城市名,从天气API查询当前的温度、湿度和天气状况。”
  • 明确边界和限制
    • 在描述中说明前提条件。例如,“此函数仅支持国内城市拼音或英文名查询。”
    • 说明输出格式。“返回一个包含temperature(数字)、condition(字符串)的JSON对象。”
  • 使用同义词和场景提示:如果用户可能用多种方式表达同一意图,在描述中涵盖。例如,“获取天气、查询气温、今天天气怎么样”。

7.2 处理模型的“错误”调用

模型有时会调用错误的工具,或提供不合规的参数。

  • 参数验证是必须的:在工具函数内部,第一步永远是验证参数。检查location是否在支持的城市列表里,检查expression是否包含危险字符。
  • 优雅降级:当模型调用错误时,不要在tool消息里返回一个程序错误堆栈。而是返回一个结构化的错误信息,比如{“error”: “暂不支持该城市查询”, “suggestion”: “请提供国内主要城市名”}。这样模型还能基于这个错误信息,生成对用户友好的回复。
  • 使用tool_choice进行引导:在复杂工作流中,可以通过动态设置tool_choice来限制模型在当前步骤只能调用特定工具,减少出错概率。

7.3 性能与成本考量

  • 工具列表不宜过长:每次请求都将完整的tools列表发送给模型,这会消耗Tokens(尤其是长描述)。如果工具很多(比如几十个),可以考虑根据对话上下文动态筛选相关的工具子集发送给模型。
  • 本地模型是学习的最佳伙伴:正如开头建议的,使用Ollama+本地模型进行学习和原型开发,零成本、响应快、无隐私顾虑。在确定流程后,再考虑切换到更强的云端模型进行生产部署。
  • 缓存结果:对于耗时或消耗资源的工具(如复杂的数据库查询),可以考虑对相同参数的调用结果进行短期缓存,避免重复执行。

7.4 安全第一

  • 永远不要相信模型的输入:将模型通过arguments传来的参数视为“用户输入”,必须进行严格的清洗、验证和转义,防止SQL注入、命令注入等攻击。上面的calculator函数使用eval是极其危险的示范,绝对不能在真实项目中使用。
  • 权限控制:不同的工具可能对应不同的权限级别。在Harness层,需要根据用户身份或会话上下文,动态过滤tools列表,只提供当前用户有权访问的工具。

通过这个从“笨办法”开始,逐步深入到架构理解的旅程,你应该已经对Function Calling有了扎实的、可操作的认识。它不是什么黑魔法,而是一种设计精巧的通信协议。掌握它,你就掌握了让大语言模型从“聊天机器人”迈向“智能体”的关键一步。接下来,你可以用这个模式去探索更复杂的Agent框架,那时你会更加得心应手,因为你已经理解了它们底层究竟在做什么。