☰
手写Tool Use的三种方案:从Function Calling到Agent引擎
2026/10/22 6:19:54 网站建设 项目流程

面试官让我手写一个 Tool Use,我用了 3 种方案。这道题是在考察大模型应用里非常核心的一环:怎么让模型不只是“能说”,还能“能做”。我当时从最省事的原生 Function Calling,到完全不依赖平台能力的指令解析版,再到带多轮循环的 Agent 引擎,逐个写了一遍。这篇文章把三种方案的思路、完整代码、选型逻辑和踩过的坑都整理出来,不管你是准备面试还是正在设计自己的工具调用框架,都能直接拿去用。

1. 先把这道题看懂:Tool Use 到底在问什么

1.1 这道面试题在考什么

很多同学一听到“手写 Tool Use”就慌,其实面试官要考察的并不是你能把某个框架背得多熟,而是你是否理解大模型工具调用的完整链路。这个链路我拆成四段:

  1. 模型怎么知道自己有哪些工具可以用——对应的是工具清单(schema)的定义。
  2. 模型怎么表达“我想调某个工具、传这些参数”——对应的是模型输出中的 tool_call 结构。
  3. 程序怎么收到这个调用请求并执行真实函数——对应的是解析、路由、参数校验。
  4. 执行结果怎么回到模型那里,让模型继续生成最终回答——对应的是把工具结果作为新的上下文消息再发回模型。

面试时我一边写一边把这个链路讲出来,面试官立刻知道我不是背答案,而是真的调过。这道题的本质是:你能不能独立把一个以 LLM 为核心的应用闭环搭起来,而不是只会在 Jupyter Notebook 里跑现成的封装。

1.2 工具调用的本质是“说话”和“做事”分离

大模型本身没有执行能力,它只能生成文本。Tool Use 要解决的核心矛盾就是:把模型“生成的内容”和“程序执行的动作”分开。模型负责理解用户意图、决定该调用哪个工具、生成调用参数;程序负责真正执行这个调用,再把执行结果交回模型。

一句话总结就是:模型出“意图”,程序出“效果”。这两者之间需要一套协议来衔接。原生 Function Calling 用的是结构化 JSON 协议,自拆方案用的是文本指令协议,本质都是在定义这个衔接层。

1.3 我选择这三种方案递进展示的原因

面试只有大概三四十分钟,我不可能把所有方案都写一遍,但我选三个方向分别代表不同层次的实现:

  • 方案一是“平台派”:直接用大模型平台提供的 tools 参数,快速解决“能不能调”的问题。
  • 方案二是“手工派”:不靠任何平台能力,用提示词约束加正则解析,解决“没有原生支持怎么办”的问题。
  • 方案三是“架构派”:在方案一的基础上加循环、校验、重试、上下文管理,解决“生产环境怎么扛”的问题。

这样递进有一个好处:从零到一、从一到生产,覆盖了面试官可能追问的所有角度。下面按这个顺序展开。

2. 方案一:原生 Function Calling,先把链路跑通

2.1 为什么先写这个方案

现在主流的大模型平台基本都提供了 function calling 能力,也就是在 chat completions 接口中传一个tools参数,模型会在需要时返回一段结构化的tool_calls,而不是普通文本。这是最简单直接的方式,代码量最少,也是最容易让面试官认可的“标准答案”。

先写这个方案还有一层策略意义:它证明了你知道什么是行业标准做法,后面的手工方案都是在这个基础上的降级或升级。

2.2 工具清单定义与调用代码

工具清单本质是一个 JSON 数组,每个工具包含type: "function"和function两个字段。function里最关键的是parameters,它必须是一个合法的 JSON Schema。我以天气查询和四则运算两个工具为例。

from openai import OpenAI import json client = OpenAI(api_key="YOUR_API_KEY") tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如北京、上海"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"} }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calc", "description": "执行数学表达式计算,支持加减乘除和括号", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式,例如 1+2*3"} }, "required": ["expression"] } } } ] resp = client.chat.completions.create( model="gpt-4o", messages=[ {"role": "system", "content": "你是助手,需要工具时按格式调用。"}, {"role": "user", "content": "北京今天多少度?顺便算一下 (3+5)*2 等于多少。"} ], tools=tools, tool_choice="auto" ) msg = resp.choices[0].message print(msg)

这里有两个容易被忽略的细节:description不要写废话,但也不能漏写,因为模型选工具的准确率很大程度依赖描述里的关键信息;required只放绝对必需的字段,能设默认值的就设默认值,否则模型会因为纠结参数而失败。

2.3 结果回填与二轮回调

模型返回的消息里如果有tool_calls,我们不是直接把这段 JSON 展示给用户,而是要把每个工具真正执行,然后把执行结果作为role: "tool"的消息追加进对话上下文,再发给模型一次,让模型基于真实结果生成最终回答。

import requests def get_weather(city: str, unit: str = "celsius"): # 实际项目替换为真实天气 API return {"city": city, "temperature": 26 if unit == "celsius" else 79, "unit": unit} def calc(expression: str): # 生产环境建议用安全解析库,不要直接 eval return {"result": eval(expression)} tool_map = { "get_weather": get_weather, "calc": calc } # 第一步:把模型返回的 tool_calls 逐个执行 tool_results = [] for tc in msg.tool_calls: fn_name = tc.function.name args = json.loads(tc.function.arguments) print(f"调用工具: {fn_name}, 参数: {args}") fn = tool_map[fn_name] result = fn(**args) tool_results.append({ "tool_call_id": tc.id, "role": "tool", "content": json.dumps(result, ensure_ascii=False) }) # 第二步:把执行结果追加回 messages,再请求一次 messages = [ {"role": "system", "content": "你是助手,基于工具结果回答用户。"}, {"role": "user", "content": "北京今天多少度?顺便算一下 (3+5)*2 等于多少。"}, msg.model_dump(), *tool_results ] second_resp = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, tool_choice="auto" ) answer = second_resp.choices[0].message.content print(answer)

注意tool_call_id必须和模型返回的tc.id保持一致,否则接口会报错。这是新手最容易踩的坑:只把结果作为普通user消息回填,导致上下文里的 tool_call 和 tool 消息对不上。

2.4 这个方案的边界

原生 Function Calling 的优点太明显了:代码少、稳定、解析由模型平台保证。但它也有几个限制:

  • 依赖特定平台,换一个没有 function calling 的模型就得推倒重来。
  • 对工具数量有限制,一次性塞上百个工具会导致模型选择准确率下降。
  • 模型平台对工具的 schema 有严格校验,某些自定义字段可能被过滤。

所以我在面试里把它定义为“快速验证方案”,而不是“生产唯一解”。这也自然引出第二个方案。

3. 方案二:不用原生接口,用提示词加解析器手搓

3.1 思路:让模型输出一段可解析的指令

面试官听完方案一通常会有个追问:“如果你的模型不支持 function calling,或者你用的是本地开源模型,怎么办?”这时候方案二就有价值了。核心思路是:既然模型只能输出文本,那我们就约定一种文本格式,让模型把“想要调用工具”这件事写成一行结构化的指令,程序再用正则或者文本解析器把它截出来执行。

这个方案在业界很常见,开源社区把这类格式叫 function call 的“文本协议”。最常见的是 XML 格式和 JSON 行格式。我一般偏好 JSON 行格式,因为有现成的json.loads可以直接解析。

3.2 指令协议设计和正则解析

我设计的协议是:模型在需要调用工具时,输出一行以特殊标记包裹的 JSON,其他正常文本随便写。示例:

今天北京天气怎么样? <tool_call>{"name": "get_weather", "arguments": {"city": "北京"}}</tool_call>

系统提示词里明确告诉模型:需要调用工具时,先输出工具调用行,再继续输出用户可见文本,而且每一行只放一个调用。

import re import json # 方案二使用的系统提示词 TOOL_SYSTEM_PROMPT = """ 你是一个助手。当用户请求需要调用工具时,请严格按以下格式输出: <tool_call>{"name": "工具名", "arguments": {"参数名": "参数值"}}</tool_call> 规则: 1. 每一行只能有一个tool_call,多个工具调用要分多行输出。 2. 不需要调用工具时,只输出普通文本。 3. 工具名只能从下面列表中选择:get_weather, calc。 """ pattern = re.compile(r"<tool_call>(.*?)</tool_call>", re.S) def execute_tool_commands(text: str): matches = pattern.findall(text) if not matches: return text, [] calls = [] for block in matches: try: data = json.loads(block) fn_name = data["name"] args = data.get("arguments", {}) calls.append({"name": fn_name, "arguments": args}) except (json.JSONDecodeError, KeyError) as e: print(f"解析失败,保留原文: {e}") # 顺序执行 results = [] for call in calls: fn = tool_map.get(call["name"]) if not fn: results.append({"error": f"未知工具 {call['name']}"}) continue try: result = fn(**call["arguments"]) results.append(result) except TypeError as e: results.append({"error": str(e)}) return text, results

这里的关键点在于:解析失败时不要崩溃,把原文本原样返回,并把错误信息作为结果回喂给模型,让模型自己修正格式。我在线上跑这种协议的时候,几乎每个星期都会遇到模型输出少个右括号或者把中文括号打成英文括号的情况,宽容解析是必须的。

3.3 这个路子的坑在哪

方案二看起来很“土”,但它在很多场景里反而是最优解。比如本地部署一个量化模型,原生没有 function calling 能力,或者模型平台的接口不开放 tools 参数,这时候文本协议是唯一选择。

但这个方案的天花板很低,最大的坑有三个:

  • 模型不遵守格式:模型可能输出tool_call、工具调用、或者带语法错的 JSON。需要靠提示词强化加解析容错双管齐下。
  • 参数类型失真:文本协议里头所有字段都是字符串,模型可能把数字26写成"26",程序侧需要做类型转换。
  • 多工具并发有限:协议天然是串行的,一个工具输出一行,想要并行执行就得在协议里设计 batch 结构,复杂度随之上升。

面试中把这三个坑主动说出来,反而能体现你对方案边界有真实认知,比光报优点强得多。

4. 方案三:通用 Tool Use 引擎,加循环、校验和重试

4.1 从单次调用到 Agent 循环

面试进行到这里,面试官一般会加码:“如果用户的问题需要连续调用多个工具,比如先查一下今天北京的天气,再根据天气决定要不要提醒我带伞,这个流程怎么做一个通用框架?”这就是第三个方案要解决的问题。它不再是一次调用,而是一个Agent 循环:模型决定调用哪个工具,程序执行,把结果回填,模型再决定下一步,直到模型认为不需要调用工具为止。

这个循环在业界有很多名字:ReAct、function calling loop、agent executor。它的代码并不复杂,难在把每一步的上下文管理好。

def run_tool_agent(user_query: str, max_steps: int = 8): messages = [ {"role": "system", "content": "你是助手,可以调用工具完成任务。"}, {"role": "user", "content": user_query} ] for step in range(max_steps): resp = client.chat.completions.create( model="gpt-4o", messages=messages, tools=tools, tool_choice="auto" ) message = resp.choices[0].message # 没有 tool_calls,说明模型认为可以给出最终答案 if not message.tool_calls: return message.content print(f"\n== Step {step + 1} ==") messages.append(message.model_dump()) for tc in message.tool_calls: fn_name = tc.function.name args = json.loads(tc.function.arguments) print(f"调用 {fn_name}({args})") try: result = tool_map[fn_name](**args) except Exception as e: result = {"error": f"{type(e).__name__}: {str(e)}"} messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False) }) return "模型在限定步数内未完成,已终止。"

这段代码就是绝大多数 Agent 框架的骨架。需要注意messages.append(message.model_dump())这句不能省,因为模型返回的带tool_calls的消息本身也是上下文的一部分,后续的 tool 消息要依附于它。如果漏掉,模型的 tool_call 就会孤立,接口直接报错。

4.2 参数校验、错误重试与安全的执行环境

真正的生产环境不能直接eval表达式,也不能让工具调用无限循环。我面试时把这三个生产化改造点列了出来:

参数校验层:在分发工具前,用 JSON Schema 校验一遍参数。可以用jsonschema库,这样即使模型传错了类型,也能在调用前拦截并生成友好错误信息。

from jsonschema import validate, ValidationError def safe_call_tool(fn_name: str, arguments: dict, schemas: dict): schema = schemas.get(fn_name, {}) if schema: try: validate(instance=arguments, schema=schema) except ValidationError as e: return {"error": f"参数校验失败: {e.message}"} fn = tool_map.get(fn_name) if not fn: return {"error": f"未知工具: {fn_name}"} try: return fn(**arguments) except Exception as e: return {"error": f"{type(e).__name__}: {str(e)}"}

错误信息回喂:这是 Agent 循环里最重要的细节。工具执行失败时,不要把错误藏着,而是把错误消息作为tool消息返回给模型,让模型自己决定是换参数重试还是换工具。很多 Agent 跑几轮就断,就是因为工具报错后程序直接终止了。

步数上限:max_steps一定要设。我见过一个 Agent 因为工具返回值里带了一个循环触发的字段,结果无限调用工具,把一次请求的 token 预算烧穿。设上限后在返回消息里提示用户“需要更多步骤才能完成”,体验远比卡死好。

4.3 Agent 循环里的上下文膨胀问题

多轮循环最大的隐性成本是上下文膨胀。假设每轮工具调用加上回填消息大约消耗 2k token,跑 10 轮就是 20k token,费用成倍增长。我常用的方案是:每轮结束后做一次消息裁剪,只保留系统提示词、最初的用户问题、最近两轮的工具调用结果。

def trim_messages(messages, keep_last_n=6): # 保留首条 system,尾部最近 keep_last_n 条 head = [messages[0]] tail = messages[-(keep_last_n + 1):] return head + tail

注意裁剪时不能把带tool_calls的消息剪掉,而把它的tool结果留着,那样模型会理解错上下文,因为 tool 消息和 tool_call 消息必须配对。我建议以“一个 step 的所有消息”为最小单位裁剪,不要从中间拦腰截断。

面试时给出这个节流方案,会显得你对成本模型有真实感知,而不只是会打字调用接口。

5. 三个方案的横向对比与选型逻辑

5.1 核心维度对比表

我把三个方案的核心差异整理成一张表,面试官和我沟通时基本都在围绕这几点:

维度方案一:原生 Function Calling方案二:提示词+解析器方案三:Agent 引擎
依赖平台能力需要不需要需要但可替换
代码量最少中等最多
稳定性高中等依赖前序方案
多轮推理需自己实现需自己实现天然支持
错误恢复弱中等强
成本控制一般便宜需额外工程
适用模型商业大模型任意文本模型商业或开源均可

这张表也是我回答“你为什么要这么设计”的底稿。面试官一旦追问“三个方案怎么选”,我就从这张表出发讲场景。

5.2 面试官到底想听什么

我复盘时意识到,这道题面试官不只是要一个正确答案,而是想看三个能力:

第一,工程判断力:能不能在约束条件下选择合适方案,而不是只会背最优方案。我答完方案一时主动补充“这个方案有平台绑定问题”,面试官眼睛一亮。

第二,异常意识:能不能提前想到模型输出不合法、工具调用失败、上下文膨胀这些异常。很多候选人写到一半就停,以为跑通就结束,我不会那样。

第三,架构视野:能不能从一次函数调用跳到多轮 agent 循环,看到工具调用在真实产品里不是一个点,而是一条链路。

5.3 真实业务里的选型建议

根据我的实际项目经验:

  • 如果是做一个 demo、内部工具、快速验证想法,选方案一就够了,别过度设计。
  • 如果团队要部署私有化模型,或者对接的是开源小模型,只能用方案二,这时需要花时间打磨提示词和解析器。
  • 如果业务复杂度高,比如客服机器人需要查库存、算价格、下单、通知用户,必须上方案三的 Agent 循环,并且配套做好观测、限流、熔断。

还有一种混合姿势:平台原生 function calling 负责工具参数解析,底层再用提示词约束开源模型作为兜底。这种分层选型在真实业务里很常见。

6. 踩坑实录:从“代码能跑”到“生产能扛”

6.1 模型就是不按格式输出怎么办

方案二里最烦的问题就是模型不守格式。我踩过一个具体场景:提示词里要求一行一个调用,小模型偏要在一行里输出两个<tool_call>,导致正则只抓到第一个,第二个被当成普通文本。

我最后的解决方案有两层:正则改成re.findall,确保一行有多个也能全抓;同时把系统提示词优化成“如果你要调用多个工具,请换行后逐个列出,不要写在一行”,效果立竿见影。提示词里加负例比只加正例有用得多。

6.2 tool_call 的 arguments 被模型截断

大模型返回的arguments是字符串,我在一次长参数场景里遇到它输出到一半就结束,json.loads直接崩溃。解决方案是加一个“参数完整性检查”:解析失败时,把原始 arguments 字符串用异常信息拼进返回内容,让模型知道“我上次只传了一半,重新生成完整版本”。这招比我人工补全 JSON 可靠得多。

6.3 工具并发与竞态

方案一的 tools 列表如果一次塞了很多工具,模型会选择困难;方案三的 Agent 循环如果多个工具互相依赖,还得自己实现依赖编排。我在一个项目里遇到“先下单再查物流”的需求,工具 A 的输出要作为工具 B 的输入,必须在循环里维护一个临时变量池,把前一个工具的输出暂存到上下文中供模型读取,再让模型决定传给下一个工具。这个场景已经超出简单 Tool Use,变成真正的 Agent 编排,面试时点到为止即可。

6.4 错误信息必须回喂给模型

这是我最想强调的一个经验。很多人写工具调用时,工具报错了就直接返回“调用失败”给用户。但在 Agent 架构里,错误信息是给模型看的,要让它学会修正。我在safe_call_tool里把所有异常都包装成一个带错误类型的 JSON 返回,模型看到这种方式后,下一轮往往能自己修正参数。少做这个环节,Agent 的成功率可能下降一半以上。

7. 面试后的复盘:我为什么按这个顺序答

回头看这轮面试,我觉得最大的收获不是“写对了代码”,而是理解了 Tool Use 这道题背后的设计哲学:模型和应用的分工。模型负责模糊到精确的意图转换,应用负责确定性的执行力,二者通过一把协议粘合。

我现在做真实项目,也始终先问一个问题:这个工具调用是“一次就好”还是“可能要很多轮”?一次就好就选方案一或二,可能要很多轮就果断上方案三。别急着堆框架,先想清楚复杂度在哪里。

最后再分享一个面试小技巧:手写代码时不要闷头敲,边写边把“我在做什么、下一步要解决什么问题”说出来。面试官让你手写 Tool Use,不只是看代码能力,更是看你的逻辑链路清不清楚。能把自己的思考过程完整展示出来,这道题基本就拿下了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询