资讯动态

手写Tool Use:从纯Prompt到原生Function Calling的三种实现方案

发布时间:2026/10/20 13:52:44 来源:尧图企业网站定制
1. 面试间里那道让我手心冒汗的题“手写一个 Tool Use 吧不限语言不限框架给我讲讲你的思路就行。”听到这句话的时候我正坐在一间不大的会议室里面前是一台白板笔快没水的白板。面试官是个看起来挺随和的中年人前面聊项目聊得挺顺我甚至有点飘结果这一句直接把我拉回了地面。Tool Use说白了就是让大模型学会“调用外部工具”。你问它今天天气怎么样它自己不知道但它可以生成一段结构化的调用请求去调一个天气查询接口拿到结果之后再组织成自然语言回答你。这个概念在 Agent 圈子里已经火了好一阵但真让你在白板上从零写一个很多人第一反应是懵的——因为平时都是用现成的框架bind_tools一挂tool_calls一取完事。真到了要手写的时候才发现自己其实没想清楚里面到底发生了什么。我当时脑子里飞速转了三圈最后决定不装直接跟面试官说“我给您三种方案从最裸的到最工程化的您想听哪种咱们就展开哪种。”面试官眼睛亮了一下说行你都讲讲。这篇文章就是那次面试之后我整理出来的东西。不管你是正在准备面试还是想真正搞明白 Tool Use 的底层机制又或者你已经在用框架但总觉得心里没底这篇应该都能给你一些实在的东西。我会把三种方案的完整代码、设计取舍、踩过的坑都摊开讲尽量做到你照着就能跑起来。2. 先搞清楚 Tool Use 到底在解决什么问题2.1 大模型的三道硬伤在动手写代码之前得先把问题定义清楚。大模型再强它有三个绕不过去的硬伤第一知识有截止日期。你问它某个实时数据它只能告诉你训练时见过的东西之后发生的事它一概不知。第二不会算数。别看它参数量那么大真让它算个多位数乘法它经常给你一个看起来很像但就是不对的答案。第三碰不到外部世界。它没法帮你发邮件、查数据库、调接口、操作文件系统。Tool Use 的本质就是给模型装上一双手。模型负责“想”——理解你的意图决定该用什么工具、传什么参数工具负责“做”——真正去执行那些模型做不到的事情。两者配合才能完成一个完整的任务闭环。2.2 一次完整的 Tool Use 到底经历了什么很多人用框架用久了以为 Tool Use 就是“模型返回一个函数名和参数”。这只说对了一半。完整链路其实有五个环节工具注册你得先告诉模型有哪些工具可用每个工具叫什么名字、干什么用、需要什么参数。这份描述会作为系统提示的一部分发给模型。意图识别与工具选择模型读到用户的问题后判断是否需要调工具如果需要选哪个工具。参数生成模型按照工具定义的参数格式生成结构化的调用请求。这一步通常要求模型输出 JSON。实际执行你的代码解析这个 JSON找到对应的函数把参数传进去真正执行。结果回传与二次生成工具的执行结果再喂回给模型模型基于这个结果生成最终的自然语言回答。这五步里框架帮你做的主要是第 1 步的格式化、第 3 步的解析和第 5 步的回传。第 2 步和第 4 步的核心逻辑其实你自己写也完全能搞定而且写一遍之后你会对整个过程有完全不同的理解。2.3 为什么面试官爱考这道题我后来复盘的时候想明白了面试官考这道题考的不是你会不会用某个框架的 API。他考的是三件事你知不知道 Tool Use 的完整链路还是只会调包你能不能在不同的约束条件下做出合理的工程取舍你对 JSON Schema、函数签名、错误处理这些基础功的理解到不到位想清楚这三点你就知道该怎么答了。下面我把我当时讲的三种方案完整展开。3. 方案一纯 Prompt 约定最裸但最能看清本质3.1 核心思路用自然语言约定格式第一种方案我管它叫“纯 Prompt 约定”。不依赖任何框架的工具调用能力就是通过系统提示词告诉模型“如果你需要调用工具请按照下面这个格式输出。”具体来说我在系统提示里写清楚三件事有哪些工具可用、每个工具的用途和参数、以及调用时应该输出什么格式。然后约定一个特殊标记比如模型输出里出现[TOOL_CALL]开头的内容我就认为它要调工具。这个方案最大的好处是透明。你能亲眼看到模型是怎么“决定”调工具的它的输出长什么样参数是怎么拼出来的。对于理解 Tool Use 的本质这是最好的起点。3.2 工具描述该怎么写工具描述写得好不好直接决定模型能不能选对工具。我一般遵循三个原则名字要动词开头get_weather比weather好search_articles比article_search好。动词开头让模型一眼就知道这个工具是“做动作”的。描述要说清楚“什么时候用”不要只写“查询天气”要写“当用户询问某个城市的实时天气、温度、是否下雨时使用”。把触发场景写进去模型选错的概率会低很多。参数说明要带类型和示例city参数要写“城市名称字符串例如北京、上海”。模型看到示例生成参数时格式会更规范。我见过很多人工具描述写得特别简略然后抱怨模型老是选错工具。十有八九是描述的问题不是模型的问题。3.3 完整实现与解析逻辑下面是我当时在白板上写的大致代码用 Python 写的逻辑很直白import json import re # 工具的真实实现 def get_weather(city: str) - str: # 实际项目中这里会调真实接口这里用模拟数据 mock_data { 北京: 晴25摄氏度微风, 上海: 多云28摄氏度湿度较高, 广州: 雷阵雨30摄氏度注意带伞 } return mock_data.get(city, f暂时查不到{city}的天气数据) # 工具注册表 TOOLS { get_weather: { function: get_weather, description: 当用户询问某个城市的实时天气时使用。参数 city 为城市名称字符串。 } } # 系统提示词 SYSTEM_PROMPT 你是一个助手可以使用以下工具 工具名get_weather 用途查询指定城市的实时天气 参数city字符串城市名称 当你需要调用工具时请严格按照以下格式输出 [TOOL_CALL]{name: 工具名, arguments: {参数名: 参数值}}[/TOOL_CALL] 如果不需要调用工具直接回答即可。 def parse_tool_call(text: str): 从模型输出中解析工具调用 pattern r\[TOOL_CALL\](.*?)\[/TOOL_CALL\] match re.search(pattern, text, re.DOTALL) if not match: return None try: return json.loads(match.group(1).strip()) except json.JSONDecodeError: return None def execute_tool(tool_call: dict) - str: 执行工具调用 name tool_call.get(name) args tool_call.get(arguments, {}) if name not in TOOLS: return f错误未知工具 {name} try: return TOOLS[name][function](**args) except Exception as e: return f工具执行出错{str(e)}整个流程就是把系统提示和用户问题一起发给模型拿到输出后用正则提取[TOOL_CALL]标记里的 JSON解析出来执行再把结果拼回对话里发给模型做二次生成。3.4 这个方案最容易翻车的三个地方第一个坑模型不按格式输出。你约定用[TOOL_CALL]标记它有时候会忘了加有时候会加错有时候会在标记外面多写一段解释。解决办法是在提示词里反复强调格式并且解析的时候做容错——比如先尝试找 JSON 块找不到再找标记。第二个坑JSON 解析失败。模型生成的 JSON 经常有各种小毛病单引号、尾随逗号、中文引号、缺少闭合括号。我一般会加一层清洗逻辑把常见的格式问题修掉再解析。如果实在解析不了就把错误信息回传给模型让它重试。第三个坑参数类型不对。你定义city是字符串模型可能给你传个{city: [北京]}。执行前最好做一次参数校验类型不对就报错回传让模型修正。提示纯 Prompt 方案适合快速验证想法和理解原理但生产环境不建议直接用。解析的鲁棒性太依赖模型发挥换个模型可能就崩了。4. 方案二JSON Schema 约束让模型输出结构化数据4.1 从“约定格式”升级到“约束格式”方案一的问题是太依赖模型的自觉性。方案二的思路是既然模型对 JSON 的理解很好那我就用 JSON Schema 来严格定义工具的参数结构让模型按照 Schema 来生成参数。这个方案的核心变化在于工具定义从自然语言描述变成了结构化的 JSON Schema。模型看到的不是“参数 city 是城市名称字符串”而是{ name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海 } }, required: [city] } }4.2 JSON Schema 到底约束了什么JSON Schema 的价值在于它把“参数应该长什么样”这件事从模糊的自然语言变成了精确的机器可读定义。它约束了参数名和类型city必须是字符串不能是数组或数字。哪些参数必填required字段列出了必须提供的参数。参数的取值范围可以用enum限定只能取某几个值用minimum/maximum限定数值范围。嵌套结构复杂参数可以用object和array嵌套定义。模型在生成参数时会参考这个 Schema输出的 JSON 结构会规范很多。而且很多模型服务本身就支持“结构化输出”或“JSON 模式”能进一步保证输出格式的合法性。4.3 实现上的关键差异方案二的代码骨架和方案一类似但有几个关键差异# 工具定义改为 JSON Schema 格式 TOOLS_SCHEMA [ { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京、上海 } }, required: [city] } } ] # 系统提示中嵌入 Schema SYSTEM_PROMPT f你可以使用以下工具 {json.dumps(TOOLS_SCHEMA, ensure_asciiFalse, indent2)} 需要调用工具时输出如下格式的 JSON {{tool: 工具名, arguments: {{...}}}} 不需要调用工具时直接回答。解析逻辑从正则匹配变成了直接尝试 JSON 解析因为整个输出就是一个 JSON 对象。参数校验也可以用jsonschema库来做比手写校验可靠得多。4.4 参数校验这一步千万别省我踩过的一个坑模型生成的参数看起来对但类型不对。比如city传了个数字或者required的参数漏了。如果不校验直接执行轻则报错重则产生副作用比如删错了数据。用jsonschema库做校验很简单from jsonschema import validate, ValidationError def validate_args(schema: dict, args: dict): try: validate(instanceargs, schemaschema) return True, None except ValidationError as e: return False, str(e)校验失败时把错误信息回传给模型让它重新生成参数。实测下来模型看到具体的校验错误后修正的成功率很高。4.5 这个方案的边界在哪里方案二比方案一稳但也不是万能的。它的边界在于模型必须支持结构化输出。如果模型本身对 JSON 的生成能力弱Schema 再严格也没用。复杂嵌套参数容易出错。参数层级一深模型生成时容易漏字段或搞错嵌套关系。多工具选择仍然靠模型判断。Schema 只约束了参数格式没约束“该选哪个工具”工具选错的问题依然存在。所以方案二适合工具数量不多、参数结构中等复杂的场景。工具一多就得考虑方案三了。5. 方案三原生 Function Calling工程化的正解5.1 原生工具调用协议做了什么方案三就是现在主流模型服务都支持的“原生工具调用”能力。它的核心变化是工具调用不再是你和模型之间的“民间约定”而是模型服务官方支持的一种输出类型。具体来说你通过 API 的tools参数把工具定义传进去模型在需要调工具时会返回一个结构化的tool_calls字段里面包含工具名和参数。这个字段是模型服务保证格式合法的你不需要自己写正则去解析。这带来的最大好处是可靠性。模型服务在训练时就专门优化了工具调用的输出格式参数生成的准确率和格式合法性都比纯 Prompt 方案高一个档次。5.2 工具定义的标准格式原生工具调用的工具定义格式基本是统一的大致长这样tools [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称 } }, required: [city] } } } ]注意这里比方案二多了一层type: function的包装。这是协议规定的表示这是一个函数类型的工具。未来可能还支持其他类型的工具但目前函数是最常用的。5.3 完整调用链路与多轮工具调用原生方案最爽的地方是它天然支持多轮工具调用。模型返回tool_calls后你执行工具把结果以特定角色通常是tool角色回传模型可以基于结果决定是继续调下一个工具还是生成最终回答。def run_conversation(user_input: str, max_rounds: int 5): messages [ {role: system, content: 你是一个可以使用工具的助手。}, {role: user, content: user_input} ] for _ in range(max_rounds): response call_model(messages, toolstools) msg response.choices[0].message # 没有工具调用直接返回回答 if not msg.tool_calls: return msg.content # 有工具调用逐个执行 messages.append(msg) for tool_call in msg.tool_calls: name tool_call.function.name args json.loads(tool_call.function.arguments) result execute_tool(name, args) messages.append({ role: tool, tool_call_id: tool_call.id, content: str(result) }) return 达到最大轮次限制任务未完成这个循环就是 Agent 的核心骨架。max_rounds是防止模型陷入无限调用循环的保护机制生产环境一定要加。5.4 多轮调用里最容易忽略的细节细节一tool_call_id必须对应。每个工具调用都有一个唯一的 id回传结果时必须带上对应的 id否则模型不知道这个结果是哪个调用的。多个工具并行调用时这个 id 尤其重要。细节二消息顺序不能乱。正确的顺序是assistant 消息含 tool_calls→ 每个 tool 结果消息 → 下一轮请求。顺序错了模型会报错或行为异常。细节三工具执行失败也要回传。工具执行出错时不要把异常直接抛出去而是把错误信息作为 tool 结果回传。模型看到错误信息后可能会换个方式重试或者告诉你它做不到。这比直接崩溃友好得多。细节四并行工具调用要处理好。有些模型会一次返回多个 tool_calls表示这些工具可以并行执行。如果你的工具之间有依赖关系要串行执行如果没有依赖可以并行提速。5.5 三种方案的横向对比维度方案一纯 Prompt方案二JSON Schema方案三原生调用实现复杂度低中中格式可靠性低依赖模型自觉中Schema 约束高协议保证多工具支持差中好多轮调用需自己实现需自己实现原生支持调试难度低全透明中中黑盒较多生产可用性不建议小规模可用推荐学习价值最高中中这张表是我面试时在白板上画的面试官看完点了点头说“你这个对比挺清楚”。其实关键不在于哪个方案最好而在于你能不能根据场景选对方案。6. 面试官追问的三个问题我是这么答的6.1 “工具特别多的时候怎么选”这是面试官的第一个追问。工具一多把所有工具定义都塞进提示词里token 消耗大不说模型选错的概率也会上升。我的回答是分两层第一层是检索用向量检索或关键词匹配先从几百个工具里筛出最相关的十几个第二层是模型选择把这十几个工具的定义给模型让它从中选。这样既控制了 token又提高了准确率。另外工具描述的质量比数量重要。与其堆一百个描述模糊的工具不如精写二十个描述清晰的工具。工具命名也要有体系比如按领域加前缀让模型能通过名字快速判断归属。6.2 “工具执行失败了怎么办”第二个追问是错误处理。我的回答分三种情况参数错误把校验错误回传给模型让它重新生成参数。这是最常见的情况模型通常能自己修正。执行超时设置超时时间超时后把“执行超时”作为结果回传让模型决定是重试还是放弃。业务错误比如查询的数据不存在把具体的错误信息回传模型会基于错误信息调整策略。核心原则是永远不要把异常直接抛给用户而是把错误信息作为工具结果回传给模型让模型来处理。模型处理不了的时候它至少能告诉用户“我尝试了但没成功原因是……”这比一个崩溃的堆栈友好得多。6.3 “怎么防止模型乱调工具”第三个追问是安全性。模型有时候会“幻觉”出一个不存在的工具或者用错误的参数调用工具。我的回答是三道防线第一道是白名单执行前检查工具名是否在注册表里不在就直接拒绝第二道是参数校验用 JSON Schema 严格校验参数不合法就拒绝第三道是权限控制敏感操作比如删除、支付需要额外确认不能模型说调就调。对于有副作用的工具我还会加一个“确认机制”模型提出调用请求后先展示给用户确认用户同意后才真正执行。这在生产环境里是必须的。7. 手写一遍之后我对 Tool Use 的理解变了那次面试最后过了但比结果更重要的是手写这三种方案的过程让我对 Tool Use 的理解从“会用”变成了“懂”。以前用框架的时候bind_tools一挂就完事从来没想过里面到底发生了什么。手写之后才发现原来工具描述的质量直接决定模型的选择准确率原来参数校验这一步这么重要原来多轮调用的消息顺序这么讲究。这些细节框架帮你藏起来了但藏起来不等于不存在。出了问题的时候不懂底层的人只能干瞪眼。我现在带新人的时候都会让他们先手写一遍纯 Prompt 方案再手写一遍 JSON Schema 方案最后才让他们用原生方案。走完这一圈他们对 Tool Use 的理解就扎实了。如果你也想真正搞明白 Tool Use我的建议是找个周末别用框架从零写一个能查天气、能算数的小 Agent。写完之后你会发现那些框架的 API 突然变得特别好理解因为你已经知道它们背后在干什么了。最后分享一个我自己的小习惯每次设计一个新工具我都会先自己扮演模型看看这个工具描述能不能让我在只看描述的情况下选对工具、填对参数。如果我自己都选不对那模型大概率也选不对。这个自检方法帮我省了很多调试时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑