
做 Agent 开发的朋友大概率都遇到过同一个尴尬大模型嘴上功夫一流写文案、做总结、编代码样样都行可一让它去“做事”立刻就露馅了。你让它查个天气、查个订单、算个运费它要么给你编一堆看起来像模像样其实完全不能用的假数据要么直接摊手说“我没有这个能力”。不是我黑大模型这是它的架构决定的——它压根没有长出“手”。今天这篇要讲的 Function Call就是给大模型补上这双手的关键技术。标题里写的“1.26 Function Call实战让大模型调用函数实现真正的AI Agent”说白了就是解决一个问题怎么把模型输出的意图变成程序里真实可执行的函数调用再把函数结果回传给模型形成一个闭环。我前后折腾过不少 Agent 项目经历过模型乱填参数、循环死磕、上下文爆炸各种坑这轮把完整实战过程重新梳理一遍包括设计思路、代码骨架、踩坑记录尽量一次讲透。适合的读者很明确对 Agent 有基本概念但没真正跑通 Function Call 的开发者以及跑通过单个工具但想把多工具切换、参数校验、循环控制做扎实的朋友。1. 为什么说 Function Call 才是 Agent 的灵魂先把一个认知掰扯清楚很多人做 Agent 的第一反应是把所有逻辑塞进系统提示词里让模型“扮演”一个角色按固定格式输出 JSON。这种方式不是不行但天花板非常低。模型自由发挥生成的 JSON字段命名、嵌套结构、值类型全靠它临场理解稍微复杂一点的结构就翻车就算你侥幸让它输出对了它也根本没有真正触发任何程序逻辑给用户的数据全是模型自己“脑补”出来的。Function Call 解决的痛点非常直接它让模型不再自由输出一段文本而是输出一个“结构化调用意图”。程序收到这个意图后负责去执行真实的函数把结果拿回来再交给模型继续推理。这个动作不是提示词能稳定约束出来的它是模型在训练阶段就被专门对齐过的能力输出的参数会严格跟随你声明的 JSON Schema甚至字段类型不对它还会自动修正。1.1 模型为什么天生够不到外部世界稍微回顾一下大模型的推理过程模型接收的是 token 序列输出的也是 token 序列从头到尾它没有任何途径去发 HTTP 请求、读写数据库、执行系统命令。模型内部没有一个“操作系统”的概念它不知道现在是几点几分不知道某个接口正在返回什么数据更不可能去感知你业务系统里的实时状态。你问它“今天市场大盘怎么样”它给出的答案来自训练数据里的历史记忆运气好能对上运气不好就是一本正经地胡说八道。所以如果你希望模型具备“感知能力”“行动能力”就必须在模型之外搭一条通路。Function Call 正是这条通路的“协议层”你提前声明一批函数告诉模型这些函数各自长什么样、需要什么参数、能干什么事。当模型在推理过程中发现自己缺信息、缺动作时它会生成一个特定格式的输出表示“我需要调用 get_weather参数是 city北京”。真正执行天气接口的是宿主代码不是模型。执行完了宿主把结果作为一条新的消息塞回对话历史模型看到结果后才能继续组织最终答案。这条通路一旦打通模型就从“只读记忆库”升级成了“可操作智能体”。这也是为什么几乎所有主流的大模型服务都在原生接口里支持了工具调用能力。1.2 它和普通 JSON 输出的关键差异不少人问过我我让模型“输出 JSON”和“调用函数”到底有什么区别区别非常大。普通 JSON 输出本质上还是文本生成。模型在生成 token 的时候它只对“这段文字长得像不像 JSON”有粗糙的判断并不保证字段一定齐全类型一定正确枚举值一定合法。更麻烦的是模型并不知道这段 JSON 会被拿去干什么它不会主动“记得”自己刚才说要调用某个函数后续对话也不会围绕这个函数结果展开。Function Call 则不同。平台层面有一套专门的机制约束模型输出模型生成的是一个结构化的“函数调用对象”里面包含函数名和平台按参数 Schema 解析好的参数对象。模型清楚自己发出了调用也清楚函数结果会在下一轮回到对话里所以它的推理状态是连续的。这种“意图执行反馈”的闭环结构才是 Agent 循环真正的基础。2. 动手写代码前先把设计骨架想清楚我在这个专题里踩过最大的坑就是没想清楚全局结构就急着抄代码最后各个函数之间耦合得一团糟。建议你动手之前先把四个问题想明白工具从哪来、描述怎么写、参数怎么定、调用结果怎么回喂。这四个问题直接决定代码长什么样别指望后面能随便糊弄过去。2.1 别再硬编码工具列表了用注册表模式实际项目里Agent 不可能只调一个函数。查天气、查库存、算运费、写工单、查日历动辄十几个工具。如果你在每次请求前手动组装一份 JSON Schema 数组改一次工具加一次代码维护起来太痛苦了。更合理的方式是做一个工具注册表用一个字典维护“工具名”到“函数本体参数Schema”的映射提供一个装饰器或者注册函数让各模块自行注册自己的工具主循环统一遍历注册表动态生成所有 Schema 提交给模型。我用这个模式很久了最大的好处是工具之间互不干扰。某个模块想新增一个工具只需要在模块内部调用注册器主流程一行代码都不用改。而且这个注册表是可以热更新的工具下线直接摘掉对应条目模型下一轮对话就感知不到这个工具了不用重新部署整个服务。2.2 参数 Schema 的写法决定了成功率先强调一句Function Call 里最容易翻车的不是代码逻辑是工具描述和参数 Schema 写得太烂。模型非常依赖你给出的文本描述来理解这个工具该不该用、参数该怎么填。比如你写“查询天气”模型可能模模糊糊知道用如果你写“根据城市名返回实时天气数据适用于需要告知用户当前户外温度、风力、降水概率的场景”模型就知道得很清楚。参数描述也一样。不是只声明一个字段类型就完事每个字段的 description 最好写清楚取值范围和填写规则。比如城市字段你要注明“必须是标准的城市中文名称例如北京、上海不接受拼音”。我发现加了这类约束以后参数乱填的概率直线下降。不要给模型太多自由发挥的余地能收敛尽量收敛。能用枚举约束的值就列枚举能用字符串模板约束的就写正则提示能拆成多个小参数的就别合成一个大 JSON 对象。2.3 温度参数和工具选择策略要提前定模型服务的配置里temperature 和工具选择策略都会显著影响工具调用质量。temperature 决定随机性写代码类、检索类任务我一般压到 0.2 以下不然模型可能同一个意图输出不同的参数导致结果不稳定。工具选择策略一般有三档自动选择、强制调用指定工具、完全不调用本批工具。自动模式下模型根据用户输入判断要不要调用工具适合通用对话场景强制模式适合做结构化提取任务比如你明确希望模型把用户的一段话转成“创建工单”的函数调用那就直接指定必须调用这个工具。还有一种情况是某些敏感场景你希望模型坚决不碰工具比如纯闲聊模式那就把工具列表传空就行。这个策略在代码里一般叫 tool_choice动手前想清楚你的核心场景偏哪一边能少走很多弯路。3. 完整实操从零跑通第一次函数调用下面直接进入代码环节。我以兼容主流接口的 SDK 写法来演示你可以把 client 替换成自己项目里的服务端实例。整个流程分四步定义函数、声明 Schema、发起带工具请求、解析并执行函数调用。3.1 先定义两个实实在在的业务函数为了方便演示我写两个最常见的函数一个是查天气一个是查某商品库存。这两个函数体本身不重要关键是它们要能被宿主代码真正执行并且返回给模型的结果得是便于阅读的文本结构。import json import urllib.request from typing import Any def get_weather(city: str) - str: 根据城市名返回真实的天气快讯这里用模拟接口示意。 # 实际项目中这里会请求天气服务商接口 mock_data { 北京: {temp: 17, wind: 3级, condition: 晴}, 上海: {temp: 22, wind: 2级, condition: 多云}, } info mock_data.get(city) if info is None: return f未找到城市 {city} 的天气数据 return f{city}气温 {info[temp]}℃风力 {info[wind]}天气 {info[condition]} def get_inventory(product_id: str) - str: 根据商品ID查询实时库存。 mock_data { A001: {name: 无线键盘, stock: 120}, A002: {name: 显示器支架, stock: 45}, } info mock_data.get(product_id) if info is None: return f商品 {product_id} 不存在 return f商品 {info[name]} 当前库存 {info[stock]} 件函数返回字符串这个地方我是刻意这么设计的。函数返回结构最好提前约定成“人类可读的一句话摘要”或者“紧凑的结构化数据”因为模型会把这串文本当作上下文来推理。如果函数返回一个巨大无比的对象不仅浪费 token还容易把模型的注意力带偏。3.2 把函数声明成模型能理解的工具 Schema下一步是把函数转换成平台要求的结构。每家平台大同小异核心是四个字段type、function、name、description、parameters。parameters 内部是 JSON Schema 格式。TOOLS [ { type: function, function: { name: get_weather, description: 查询指定城市当前天气、气温、风力与天气现象。适用于用户询问天气、出行建议、户外活动安排等场景。, parameters: { type: object, properties: { city: { type: string, description: 标准中文城市名例如北京、上海不接受拼音/缩写。 } }, required: [city] } } }, { type: function, function: { name: get_inventory, description: 根据商品ID查询商品当前库存数量与名称。适用于用户咨询现货、下单前确认库存等场景。, parameters: { type: object, properties: { product_id: { type: string, description: 商品唯一编号格式为大写字母加三位数字例如A001。 } }, required: [product_id] } } } ]这里我要特意强调下 description 的写法。不要写“查询天气”这种一句话最好带上“约束条件使用范围”。模型判断一个工具要不要用依据就是 description 的语义信号。描述越明确误调用率越低。我见过把 description 写得含糊导致模型把所有问题都往某一个大而全的工具上套排查半天才发现是描述歧义。3.3 带工具发起第一轮请求并解析调用意图建立好清单之后就可以发起请求了。这里给你看最核心的请求代码和响应解析逻辑。def call_llm(messages: list[dict], tools: list[dict] | None None, tool_choice: str auto): 封装一次模型请求。 payload { model: your-model-name, messages: messages, tools: tools or [], tool_choice: tool_choice, temperature: 0.1, } resp client.chat.completions.create(**payload) return resp.choices[0].message第一轮对话用户说“上海天气怎么样顺便看看 A001 还有没有货”。这时 messages 里只有 user 一条消息。模型拿到工具列表后会返回一个 assistant 消息里面带 tool_calls 数组结构大致是这样的message.tool_calls [ { id: call_abc123, function: { name: get_weather, arguments: {city: 上海} } }, { id: call_def456, function: { name: get_inventory, arguments: {product_id: A001} } } ]现在你需要做的是根据这个名字去注册表里找到真实函数再把 arguments 里的 JSON 字符串解析成 Python 字典进行函数调用。def execute_tool_call(tool_call): 执行单个工具调用返回 (tool_name, tool_response)。 name tool_call.function.name args json.loads(tool_call.function.arguments) if name get_weather: result get_weather(**args) elif name get_inventory: result get_inventory(**args) else: result f未注册的工具: {name} return name, result真实项目里“elif 堆一长串”显然很蠢。配合前面说的注册表模式这个函数应该变成从注册表查表执行而不是手写一堆分支。我这里是为了看得直观实际生产环境请务必用注册表。3.4 将函数结果回传给模型生成最终回复函数执行完了结果不能偷偷摸摸丢掉必须组装回服务器的对话上下文。设计是这样的先把 assistant 消息原样 append 进 messages然后对每一个 tool_calls 里的调用都 append 一条 role 为 tool 的消息tool_call_id 对应调用里的 idcontent 放函数结果字符串。def agent_once(user_text: str): messages [{role: user, content: user_text}] assistant_msg call_llm(messages, TOOLS) messages.append({ role: assistant, content: assistant_msg.content or , tool_calls: [{ id: tc.id, type: function, function: {name: tc.function.name, arguments: tc.function.arguments} } for tc in (assistant_msg.tool_calls or [])] }) for tc in assistant_msg.tool_calls: name, result execute_tool_call(tc) messages.append({ role: tool, tool_call_id: tc.id, name: name, content: result }) final_msg call_llm(messages, TOOLS) return final_msg.content这轮关键点在于模型的第二次请求能看到“它自己刚才说要调哪些函数”以及“这些函数执行后的真实结果”。它必须同时掌握这两部分信息才能摒弃编造基于真实返回值组织最终回答。我最初写代码时有一版只回传结果不回传 assistant 的 tool_calls结果模型完全不知道结果对应哪个函数逻辑直接错乱这个坑大家别踩。到这里一轮最基本的 Function Call 闭环就通了。是不是感觉原理上不难难的其实是把它扩展成一个稳定的 Agent 循环。4. 从单次调用升级成完整 Agent 循环单次调用只能解决“一次性工具请求”。但真实 Agent 场景里用户的第一句话往往不能一步到位。比如用户说“帮我规划一份周末出行方案”模型可能需要先查天气再查目的地门票还要查周边餐厅又或者一次工具执行完模型发现还缺信息需要二次调用。这就需要一个 while 循环让模型反复“思考-调用-观察-再思考”直到它不再产生新的 tool_calls 为止。4.1 一个足够稳健的 Agent 主循环核心思路不复杂每次请求之后检查返回的 assistant 消息里有没有 tool_calls。如果有就执行把结果追加到 messages然后带着最新上下文继续请求如果没有说明模型已经拿到足够信息当前轮的 content 就是最终答案。下面是核心循环骨架def run_agent(user_text: str, max_steps: int 8): messages [{role: user, content: user_text}] for step in range(max_steps): assistant_msg call_llm(messages, TOOLS) if not assistant_msg.tool_calls: return assistant_msg.content, messages # 记录 assistant 的工具调用意图 messages.append({ role: assistant, content: assistant_msg.content or , tool_calls: [{ id: tc.id, type: function, function: {name: tc.function.name, arguments: tc.function.arguments} } for tc in assistant_msg.tool_calls] }) # 逐个执行并把结果追加到上下文 for tc in assistant_msg.tool_calls: name, result execute_tool_call(tc) messages.append({ role: tool, tool_call_id: tc.id, name: name, content: result }) print(f[step-{step1}] 调用函数: {[tc.function.name for tc in assistant_msg.tool_calls]}) return 达到最大步骤限制未获得最终答复, messagesmax_steps 这个参数是个保险丝。没有它模型可能在一个问题上反复调用工具停不下来既浪费 token 又拖慢响应。我一般给 610具体看业务复杂度。日志里把每一步调用了什么函数打出来调试时救命。4.2 支持并行工具调用但别强求顺序现在主流模型服务基本都支持一次返回多个并行 tool_calls。比如前面那个例子“查上海天气”和“查 A001 库存”两个调用之间没有任何依赖关系同时发起效率更高。循环里逐个执行没有问题因为对于没有依赖的工具先执行后执行不影响最终结果。可我必须提醒一旦某个工具的输出要被另一个工具当输入并行就会出问题。比如“查询某仓库的库存并为库存不足的商品生成补货订单”第一个函数的输出决定了第二个函数的参数这两个就不能并行必须先执行第一个把结果作为新消息塞回去让模型看到之后再去调用第二个工具。不要指望模型在这个问题上替你判断你可以把参数描述里写清楚依赖关系或者干脆把两步合并成一个函数。4.3 上下文管理是 Agent 循环里最大的暗坑每一轮工具调用的返回结果都会追加到 messages 里。理论上循环步数一多上下文就会越来越长token 消耗呈线性上涨。更麻烦的是有些工具返回结果特别长比如查询数据库返回几百条记录全部塞进上下文既浪费 token又会让模型“记”不住真正重要的信息。我处理这个问题的思路有三条。一是让工具的返回结果尽量精炼函数内部做好汇总只返回模型需要的那几项关键信息别动不动就返回一整个原始报文。二是对历史消息做截断超过一定轮数就把早期消息折叠成摘要比如用模型对已完成对话生成压缩摘要替换掉原始消息。三是针对长文本工具结果做二次提炼先把原始结果喂给一个小模型生成结构化摘要再丢回主上下文。这三种策略按需组合实测下来能省掉不少开销。4.4 进阶用工具协议把 Agent 能力标准化等你的 Function Call 架构跑稳之后你会发现不同 Agent 项目之间的大量工具逻辑是重复的。这时候可以考虑引入标准化工具协议比如 MCP把工具注册、参数声明、调用执行的方式统一起来。我不是劝所有团队都第一时间上 MCP而是想说你的架构理应做到“工具与主流程解耦”这样将来无论接哪种协议迁移成本都很低。先把注册表和主循环写好后续就是换一个加载后端的问题。我这里没有展开讲协议细节因为当前专题的重点是把 Function Call 实战跑通但架构上你务必留好扩展位。5. 高频问题与排查技巧实录这部分内容基本都是我在项目里一条一条填坑填出来的。每一个问题都真实到让人血压升高列成速查表方便你遇到直接翻。现象可能原因排查与调整思路模型就是不返回 tool_calls工具描述语义不清晰或用户问题与工具关联性太弱加强 description把“什么时候用、什么时候不用”写明白也可以直接调整 tool_choice 为 required返回的 arguments 不是合法 JSON模型服务解析异常或参数 Schema 过于复杂检查 Schema 是否嵌套过深尽量拍平某些模型对嵌套对象类型支持不稳定参数值不符合预期比如城市名填了拼音字段 description 约束不够在描述里写死格式要求能用枚举用枚举并提供示例值同一功能重复调用多轮停不下来函数返回结果没让模型得到充分信息或者缺失终止条件检查工具返回值是否说清楚了“成功/失败/缺什么”给循环加 max_steps 保险丝应答中混入了工具返回原始报文返回值太长模型直接复述精简返回值函数内部先做汇总或摘要工具报错但模型还在继续没有设计错误回传机制函数异常要捕获把错误信息作为 tool 消息回传给模型让它有机会修复或向用户说明换一个模型服务后调用风格变了各家平台的 tool_calls 字段名、结构有细微差异抽象一层适配器解析统一转成内部结构避免业务代码散落解析逻辑5.1 模型返回了工具调用但函数报错了怎么办千万别把异常吞掉就完事。我推荐的做法是函数体内做严格 try-except一旦出错把错误说明直接作为该工具的返回内容。模型看到“当前接口超时请稍后重试”之后通常会自己组织一句对用户更友好的回复或者干脆重新发起一次调用。这个设计比你在宿主代码里写一堆重试逻辑简单有效得多。不过要注意模型自行重试不能无限重试所以外层 while 循环的 step 上限还是要把控。5.2 参数校验到底该放在模型侧还是函数侧答案很明确两侧都要。模型侧靠 Schema 和 description 尽量防止错误产生函数侧则用强校验兜底接收参数后立刻做类型检查、取值校验防止脏数据流入业务逻辑。比如 get_weather 的 city 参数函数内要检查它是字符串且去掉首尾空格后非空如果为空就返回固定错误而不是直接抛 KeyError。这一层保护看起来冗余但能显著提升 Agent 的稳定性。5.3 如何避免模型“猜”参数模型在没有足够信息时倾向于根据上下文猜一个值。比如用户说“帮我查一下天气”没说城市模型很可能自己脑补一个默认城市调函数。解决办法是把“缺信息就要反问用户”的逻辑写进 description或者干脆在参数校验时发现缺了核心参数返回“参数缺失城市名请向用户确认城市”。甚至可以考虑在函数体内检查参数当检测到可疑默认值时强制让模型回问。我在一个模拟项目里就是靠这套机制把无效调用率从三成压到了不到一成。5.4 严格模式下每次产生的日志要留好日志不建议只记最终回复。每一轮的 tool_calls 内容、函数执行结果、耗时、token 数最好都结构化落盘。排查问题的时候你会发现错误往往出现在交互的第三轮、第四轮没有中间过程日志你根本没法定位。我一般会把 messages 快照打成 JSON 存到日志系统方便事后回放。这属于看起来不起眼、但真正救命的设计。6. 从单函数到工具生态一次实战引发的延伸思考Function Call 跑通只是起点。我在实际项目里体会最深的是它把大模型从“对话机器”解放成了“任务编排器”。但工具一旦多起来新的问题跟着就来模型面对几十个工具选错工具的概率会明显上升。这时候就要对工具做分类分组或者子路由先让模型选定领域再在该领域的工具子集里做二次选择类似路由网关的概念。也就是说Agent 自身就是一个分层系统不是把一堆工具平铺给模型就完事。另一个体面是工具的设计要以“模型能理解”为前提。很多团队把给程序用的 API 直接拿来当工具参数非常工程化字段名全是缩写比如 prodId、sku 之类。模型对这种参数名是很懵的。做工具的 Schema 前应该像写产品文案一样把每个字段讲明白必要时为 Agent 封装一层“翻译层”把内部参数形式转化成模型好理解的语义形式。这一步做好整个 Agent 的成功率都会有明显提升反而是很多人忽略的细节。还有Function Call 与内容审核也有关联。工具一旦能操作真实业务系统就意味着模型权限和用户权限都要受管控。不是所有工具都该无条件暴露给所有用户函数执行前要有一层鉴权至少要校验当前会话是否允许调用该工具。安全设计一定要前置别等出了事故再补这个不是恐吓是干过的人都懂的教训。7. 最后分享一点我的个人实操心得如果只留一句话我想说Function Call 的难点从来不在“把接口调通”而在“把工具声明设计得像人话、把循环写稳、把异常想全”。我在第一次做多函数 Agent 的时候犯过最蠢的错误就是在函数返回里塞入大量无关字段结果模型在后续回答里一本正经地复述了那一堆乱码。后来我把每个工具的结果全部改成“面向模型重新组织的摘要文案”质量立刻上去了。这个思路你们可以直接抄。另外建议你从今天就开始积累自己的“工具描述语料库”。哪类描述命中率高、哪些词容易让模型误判这些都是跑出来的经验多记录多对比多迭代比照搬任何一份现成模板都管用。Agent 这个方向才刚刚开始Function Call 是地基地基稳了后面无论是接新的模型服务、扩展工具协议都会顺手很多。