news 2026/10/16 11:02:08

手写 Tool Use 的三种方案:从提示词约束到原生 Tool Calling

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写 Tool Use 的三种方案:从提示词约束到原生 Tool Calling

前一阵去某家公司面试,聊到 Agent 相关的实践时,面试官突然打断:"别光讲概念,给你半小时,手写一个 Tool Use 给我看看。"我愣了一下,随后在白板前从 Prompt 约束写到 JSON Schema 再写到原生 tool calling,一口气给了三种方案。现在回头看,这半小时的临场实现,几乎把我对大模型工程化的理解全暴露了。

这篇文章就把我当时写过的三种 Tool Use 方案完整拆开讲一遍:每种方案的原理、可以直接跑的代码、优点和局限,以及面试官真正想看的东西。无论你是准备面试还是要在真实项目里做 Agent,这三条路基本覆盖了从零手写到大厂生产的全路径。

1. Tool Use 表面是代码题,实际考的是"意图到执行"的完整闭环

1.1 Tool Use 到底在解决什么问题

很多人把 Tool Use 和 Function Calling 混为一谈,真到写代码的时候就吃亏。先理清概念:大模型本质上是一个只会"生成下一个 token"的文本引擎,它不知道现在是几点、明天的天气如何、落地页的接口通不通、用户 ID 是否真实存在。Tool Use 就是让模型在需要实时数据或外部动作时,不直接编造答案,而是输出一个"结构化意图",由外部程序解析、执行、再把真实结果喂回给模型。

我当时在白板上画了一条线:

用户说"帮我查下明天的天气" -> 模型判断需要调用 get_weather 工具 -> 模型输出参数 {"city": "北京", "date": "明天"} -> 程序执行天气接口 -> 把返回的天气数据拼成消息 -> 模型基于真实天气数据生成自然语言回复。

这条线就是 Tool Use 的全部。面试官说"手写一个 Tool Use",本质是让我从零实现这条链路的某个版本。别小看这个拆解,很多候选人上来就说"用 function calling 不就行了",但说不清链路里的每一步,面试官就会认为你只是个 API 调用者,不是工程实现者。

1.2 一个完整的 Tool Use 链路应该包含哪些环节

我把标准链路拆成五步,面试时按这个框架讲,基本不会乱:

环节负责什么常见坑
意图识别判断当前对话是否需要调用工具所有 query 都触发工具,误召回
参数抽取从用户话中提取工具所需参数缺字段、格式错、日期表达理解错
工具匹配与执行找到对应函数并真实调用工具名拼错、参数类型不匹配
结果回传把工具结果作为新消息交给模型结果太长、格式混乱导致模型看不懂
错误处理工具抛错或返回空时如何兜底直接让模型"发挥",产生幻觉

这五步是我在三个方案里都要反复处理的。说句实在话,参数抽取和错误处理占整个工程量的七成,工具执行本身反而是最简单的一环。面试官让你"手写",其实第一想看你有没有这个完整认知,第二才看具体代码。

1.3 面试官对"手写"的隐含期望

"手写"两个字有很强的信号,他不想要你背 API 文档,而是想看你在没有框架兜底的情况下,怎么把一个不确定的 LLM 输出变成稳定可执行的程序。所以面试时我不会只写一种方案,而是把三种方案当作一条演进路径讲:从最笨但最通用的提示词抽取,到工程上最常用的函数声明 + JSON Schema,再到依赖模型平台原生能力的 tool calling。这样讲既展示了广度,又展示了判断力——知道什么场景该用什么方案。

先提醒一点:面试时手写代码不追求完美,但每一步都要能解释"为什么这么写"。接下来我就按当时白板上的顺序,把三个方案逐个展开。

2. 方案一:提示词约束 + 正则抽取,20 分钟内能交差的朴素实现

2.1 思路:让模型把工具调用意图"格式化"吐出来

第一个方案最朴素:既然 LLM 只会输出文本,那就用提示词硬性规定它输出一段固定格式的文本,比如 JSON。然后程序用正则或者简单的字符串处理,从这段文本里抽出工具名和参数。

这其实是早期很多个人项目做 Agent 的方式,也是我一开始最习惯的方式。核心就两件事:一是提示词写得足够死,二是解析器写得足够容错。

当时的 Prompt 模板大概是这样的:

system_prompt = """ 你是一个智能助手。当用户需要查询实时信息或执行动作时, 你必须输出一个 JSON 对象,格式严格如下: {"tool": "工具名", "arguments": {"参数名": "参数值"}} 可选工具: - get_weather:查询天气,参数 city(string) 和 date(string) 如果用户的话不需要调用工具,直接回复 "NO_TOOL"。 除了上述 JSON,不要输出任何其他内容。 """

用户消息进来之后,拼上这段 system prompt,一起发给模型。拿到模型输出,先判断是不是 "NO_TOOL",否则就尝试从整段文本里找到最像 JSON 的部分。

2.2 解析代码:正则先粗筛,json 再兜底

因为模型有时候会在 JSON 前后加一些废话,所以直接用 json.loads 很容易炸。我是先抠出花括号部分,再尝试反序列化。下面这个解析函数虽然粗糙,但在 demo 场景里够用:

import re import json def parse_tool_call(raw_output: str) -> dict: # 先直接尝试 try: data = json.loads(raw_output) if isinstance(data, dict) and "tool" in data: return data except json.JSONDecodeError: pass # 用正则捞出第一个 JSON 对象 match = re.search(r"\{.*\}", raw_output, re.DOTALL) if not match: raise ValueError(f"未找到 JSON 格式的工具调用: {raw_output}") data = json.loads(match.group()) return data

注意这里有个细节:re.DOTALL 必须加上,否则模型输出换行后正则会在第一行就断开。这种小坑不实际跑一次根本发现不了。

解析完成之后,就能根据 tool 名字从工具注册表里找到对应函数执行。伪代码:

TOOL_REGISTRY = { "get_weather": get_weather, } def handle_tool_call(tool_name: str, arguments: dict): if tool_name not in TOOL_REGISTRY: return {"error": f"未知工具: {tool_name}"} try: result = TOOL_REGISTRY[tool_name](**arguments) return {"result": result} except TypeError as e: return {"error": f"参数错误: {e}"}

2.3 这个方案的价值:零依赖、全模型通用

为什么我先写这个方案?因为它不依赖任何特定模型平台的私有接口。无论是开源模型还是闭源模型,只要你把提示词写清楚,模型大概率会照着输出。对白板面试来说,这个方案能在最短时间内跑通一个最小闭环,也最能体现你对模型局限性的认知。

我当时还补了一句:真实项目中这套方案的主要问题不是"不 work",而是"不稳定"。模型的格式遵循能力虽然越来越强,但依然可能出现参数名微调、多余的引号、甚至把 arguments 写成 array 的情况。你需要写大量防御逻辑,本质上是在跟概率作斗争。

2.4 局限:提示词稍微改一点,解析器就崩溃

方案一最大的问题,就是把"协议"放在了提示词里。提示词对模型来说是软约束,不是硬协议。我见过最典型的翻车场景是:换一个新版本模型后,它开始用单引号代替双引号输出 JSON,正则解析直接失败;还有人为了让模型输出更稳定,不断给提示词加各种"必须""一定",结果模型反而被吓住,频繁拒绝输出。

所以这个方案只适合快速验证、原型 demo、或者对格式一致性要求不高的内部工具。它的升级方向,就是把"协议"从提示词里拿出来,交给程序来定义和校验——这就是第二种方案。

3. 方案二:函数声明 + JSON Schema 驱动的结构化调用

3.1 把工具变成一份机器可读的"合同"

方案二比方案一更进一步:我们不再只在提示词里描述工具,而是单独定义一份工具清单,这份清单既给模型看,也给程序用。每个工具的描述里写清楚功能说明、参数名、参数类型、是否必填,甚至允许的枚举值。这样模型要做的,就是根据用户话术在这份清单里挑一个工具,并把参数填成符合要求的 JSON。

一个完整的工具声明长这样:

tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市在指定日期的天气情况", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如 北京" }, "date": { "type": "string", "description": "日期,格式 YYYY-MM-DD,当天则传今天", "enum": ["今天", "明天", "2025-02-10"] } }, "required": ["city", "date"] } } } ]

这份声明直接作为 messages 之外的结构化上下文传给模型。为什么要费劲写成 JSON Schema?因为它可以被程序直接做合法性校验,也能保证模型收到的"合同"是明确的、可解析的。比方案一里那段提示词强的地方是:模型在训练和指令遵循上会更偏好这种结构化输入格式,大模型的 tool calling 能力本质上就是被这种协议训练出来的。

3.2 用"双循环"实现完整的 Agent 闭环

方案二实现 Tool Use 的核心是两层循环。外层循环负责多轮对话,内层循环负责"模型要工具 -> 程序执行工具 -> 结果喂回去 -> 模型继续"的循环。经典实现如下:

import json import jsonschema def run_agent(user_message: str, messages: list): messages.append({"role": "user", "content": user_message}) for _ in range(5): # 限制最多 5 轮工具调用,防止死循环 resp = llm_chat(messages=messages, tools=tools) content = resp["content"] tool_calls = resp.get("tool_calls") messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls}) if not tool_calls: return content for call in tool_calls: tool_name = call["function"]["name"] args = json.loads(call["function"]["arguments"]) # 执行前先校验参数 result = execute_tool(tool_name, args) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, ensure_ascii=False) })

注意 messages 里多了一个"tool"角色的消息,并且每次工具结果都会拼到对话里,模型才能在下一轮基于真实数据生成回答。在 "tool_calls" 为空时,模型输出就是最终答案,直接返回给用户。

3.3 工具执行的校验与错误回传:细节决定成败

工具执行前我加了 jsonschema 校验,这一步很多人会漏,直接导致模型抽取出非法整型参数时,函数内部莫名其妙抛异常。正确的顺序是:先校验参数、再调用函数、最后统一捕获异常并把错误信息回传模型。

from jsonschema import validate def execute_tool(tool_name: str, arguments: dict): schema = get_tool_schema(tool_name) try: validate(instance=arguments, schema=schema["function"]["parameters"]) except jsonschema.ValidationError as e: return {"ok": False, "error": f"参数校验失败: {e.message}"} func = get_tool_function(tool_name) try: result = func(**arguments) return {"ok": True, "content": result} except Exception as e: return {"ok": False, "error": str(e)}

有一个非常重要的原则:工具的错误信息要给模型看,不要自己吞掉。例如天气接口超时,返回 {"ok": false, "error": "city参数city不存在"},模型看到后就会自行调整参数重新调用,而不是直接编一个天气骗用户。这就是 Agent 的"自我纠错"能力,成本几乎为零,效果好得出奇。

3.4 为什么这是真实项目中最值得手写的一套范式

现在很多框架都内置了类似函数声明 + JSON Schema 的逻辑,你直接用框架可能一小时就能做完 demo。但面试官让我"手写",重点不是场景是否有框架,而是你是否理解这套范式里的关键设计:工具注册表、参数合同、双层循环、错误回传。把这四点写出来,能力就立住了。

我自己在真实项目里也更倾向于用这套范式来做自定义工具中间层。因为它不绑定某个模型厂商,模型可以随意替换:同一个 tools 声明,换一家大模型,只要对方支持解析 JSON,套上 adapter 就能继续用。对于要在私有化环境部署、又要兼容多种模型客户端的项目,这个方案几乎是必须的。

3.5 方案二的软肋:非标准模型的兼容成本

当然,方案二的代价也很现实。模型并不天然理解你的 tools 声明,你需要把它转换成该模型可识别的格式。有的模型接受 XML,有的接受 JSON,有的甚至要求你把 tools 压进 prompt 的特定位置。这就意味着每个模型要写一个"适配层",工程量大。

还有并行调用的问题:用户问"帮我查一下北京和上海明天的天气",模型可能一次性返回两个 tool_calls。写代码时要么支持逐个循环执行,要么支持并行执行。我之前在模拟项目里就吃过亏,最初的循环里只处理了第一个 tool_call,第二天的天气被吞了。后来改为遍历 tool_calls 列表,才算真正支持了并行工具调用。

4. 方案三:复用模型平台的原生 Tool Calling 能力,把脏活交给 API

4.1 原生 Tool Calling 到底帮你做了什么

第三个方案最直白:如果模型 API 本身就支持 function calling / tool calling,那就直接用它的参数。调用时在请求里加一个 tools 数组,模型会自动判断是否调用工具、自动生成 JSON 参数、自动按标准格式返回 tool_calls。程序要做的只剩下执行工具、把结果塞进消息、再调用一次模型。

这个方案写起来最省事,但如果不知道背后发生了什么,遇到问题就抓瞎。所谓原生 tool calling,本质上是模型厂商在推理时将工具的"描述信息"拼接进模型的上下文,并约束最后输出是合法 JSON 之一。模型的"识别 + 参数生成"都被外包了,但"执行 + 反馈"仍然要你负责。

4.2 一个 30 行以内能跑通的最小示例

这里的接口形态我按主流 chat 类 API 的风格写:

def chat_with_tool(messages, tools): payload = { "model": "your_model_name", "messages": messages, "tools": tools, "tool_choice": "auto" } return client.chat.completions.create(**payload)

第一次调用后,响应里可能带了 tool_calls。看它的结构:

resp = chat_with_tool(messages, tools) if resp.choices[0].message.tool_calls: tool_calls = resp.choices[0].message.tool_calls messages.append(resp.choices[0].message) for tc in tool_calls: result = execute_tool(tc.function.name, json.loads(tc.function.arguments)) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False) }) final_resp = chat_with_tool(messages, tools) return final_resp.choices[0].message.content

到这里,你看到了和方案二几乎一模一样的循环:一次请求,工具调用,结果回传,再次请求。这印证了 Tool Use 的内核其实非常统一,区别只是在第几层封装。原生 tool calling 的底层实现也逃不过这套逻辑,只是模型更聪明地帮你做了工具选择与参数生成。

4.3 多轮工具循环才是 Tool Use 的完整体验

很多新手误以为 Tool Use 就是"一次请求返回一个函数调用"。但实际使用中最常见的场景是多轮工具接力。比如用户说"帮我查北京明天天气,如果下雨就提醒我带伞"。模型第一次调用 get_weather,拿到结果后决定是否满足"下雨"条件,可能还要再调用一次 get_weather 查询后天,最后才生成提醒文案。

这就是为什么代码里要保留 messages 的完整历史,并把工具结果以 tool 消息追加进去。如果消息历史里缺少上一轮的 tool_calls,或者结果与调用 ID 对不上,模型会懵:它会把工具结果当成用户输入来理解,导致最终回答完全跑偏。

我犯过最经典的一个错是调用 ID 没对好。早期手写循环时,我用普通 role: "user" 的消息来塞工具结果,模型虽然也能生成答案,但准确性明显下降,而且完全没法做连续多次工具调用。后来改成带 tool_call_id 的 tool 角色消息后,整个链路一下子就顺了。面试时我把这个细节讲出来,面试官明显松了口气——他知道我不是只在 demo 层面玩过。

4.4 原生 Tool Calling 的适用范围:别当万能药

原生 tool calling 很省事,但它有几个死穴:

  • 模型不支持该能力(比如私有化部署的旧模型)时,这个方案直接不成立;
  • 平台限制 tools 数量或者上下文长度,工具描述太长会被截断或忽略;
  • 你可能需要在多个模型之间统一行为,但各家对工具选择的策略不同,比如同样一句话,某家优先调工具,某家却倾向直接回答;
  • 想对工具调用行为做复杂约束(比如必须调、禁止调、或者强制用某工具)时,tool_choice 的选项可能不够用。

所以面试时我特意强调:"方案三是我最常用的快车道,但遇到以上情况,我会退回方案二甚至方案一。"这句话本身就是给面试官的一个信号:我会取舍,而不是无脑堆最流行的方案。

5. 三种方案横向对比:面试答案与真实项目选型

5.1 一张表看懂复杂度、稳定性和适用场景

维度方案一:提示词 + 正则方案二:函数声明 + JSON Schema方案三:原生 Tool Calling
实现成本最低,30 分钟中等,需要注册表 + 校验最低,但依赖平台
稳定性较差,受模型风格影响大较高,协议清晰可校验最高,厂商已做约束
可控性完全可控,但代码脆高,工具声明即接口低,选择逻辑被封装
模型兼容性所有文本模型需能理解工具声明仅支持 tool calling 的模型
自主纠错需自己写需自己写,但结构清晰需自己写(执行层不变)
适合场景原型验证、模型受限私有化、多模型适配、可解释性要求高快速上线、单模型平台绑定

这三条路径不是相互排斥的。我在真实项目里常做的事是:外圈用方案二定义工具合同,中间层根据模型能力决定是否走方案三的原生 tool calling,当模型不支持时就降级成方案一的提示词 + 解析。底层的工具执行函数则永远共用一套。

5.2 面试官真正在意的四个能力点

把这三种方案写完,面试官考察的无非是四个点。第一,你是否理解模型不会真正"操作"外部系统,它只是生成结构化指令,真正执行的是你的代码。第二,你是否清楚工具调用的消息闭环:assistant 请求工具、tool 角色回传结果、再次请求得到自然语言。第三,你有没有处理过失败场景:非法参数怎么拦截?工具异常怎么回传?死循环怎么限制?第四,你是否懂设计取舍:什么时候用原生接口,什么时候自己写中间层。

如果只能记住一条,我建议记这条:Tool Use 的核心不是"调用函数",而是把模型的输出变成程序可以信任的输入。一句话,面试官就能判断你是真做过还是背过。

5.3 真实项目中的推荐落地组合

如果你现在要做一个正式的 Agent 项目,我的建议是:

  1. 先用方案三快速跑通业务闭环,验证工具本身价值和用户体验;
  2. 如果业务需要支持多个模型、私有化部署或深度定制的工具调度策略,再加方案二的工具注册表和参数校验层;
  3. 方案一作为兜底,当某个模型不支持结构化输出时,用 prompt 约束临时顶上;
  4. 在工具执行层统一加超时、限流、错误回传,这比任何上层方案都重要。

另外,工具命名和描述值得花心思。模型选择工具时完全靠 description 里的语义,写得含糊就会频繁选错。我一般给每个工具名用"动词 + 名词"结构,比如 get_weather、send_email_reminder,description 里写清"什么时候用"和"不该用"的情况,能在很大程度上缓解误召回。

6. 收尾:一次面试带来的思考

那次面试我最终写完了三种方案,面试官看完只说了一句:"你算真正手写过 Tool Use 的人。"后来复盘,我觉得最有价值的并不是三种代码本身,而是那条思路:从最原始的提示词约束,到引入结构化协议,再到复用平台能力,每一步都是对着"模型输出不可控"做出的迭代。理解这条演进线,比背会任何 API 都要值钱。

最后分享一个小经验:无论用哪种方案,工具返回结果一定不要只给简洁字符串,尽量返回结构化 JSON,并在里面带上 ok 字段和 error 字段。模型读到 {"ok": false, "error": "..."} 时,会主动思考如何修正;读到一段模棱两可的文本时,它只会顺着幻觉编下去。这个细节我在好几个项目里都验证过,谁用谁知道。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 11:01:58

GPUImage色度键混合滤镜解析:原理与Android背景替换实践

1. 从"颜色加深"这个叫法说起:它到底是不是你想的那个滤镜今天这个打卡内容,其实是被产品经理逼出来的。用户一直在问:能不能把卧室背景一键换成马尔代夫?在美颜相机这类项目里,这就是典型的背景替换需求&am…

作者头像 李华
网站建设 2026/10/16 10:58:54

AI杀不死的光环资产:普通人如何建立信任溢价

最近总有人在群里讨论一个话题:什么资产在AI面前最扛揍?有人说是房产,有人说是黄金,更多的人焦虑着"我的工作是不是又快被取代了"。但我在观察身边真实的人与事后,有一个越来越清晰的判断:今年真…

作者头像 李华
网站建设 2026/10/16 10:58:35

求职机构怎么选?一份避坑指南教你识别靠谱就业服务

“找工作哪家机构好”——这个问题我这两年被问过不下几十回。问的人有刚毕业的应届生,也有工作了三五年想跳槽的职场人;有的是想转行学点硬技能,有的是简历投了上百份没回音,病急乱投医。先说结论:市面上不存在一家能…

作者头像 李华
网站建设 2026/10/16 10:55:48

基于SpringBoot+Vue3+MyBatis的农事管理系统设计与实现

1. 项目概述:一套农事管理系统到底管什么在农业信息化项目里,农事管理系统属于出现频率极高的那一类。我最近给一个种植合作社做技术方案时,对方第一诉求就是“把每天的农事活动记录下来,年底能算清楚每个地块的成本”。这类需求在…

作者头像 李华
网站建设 2026/10/16 10:55:48

AI原生超级全栈训练营:从写代码到编排AI的实战指南

1. 从“写代码的人”到“编排AI的人”,这个训练营到底在教什么 “全栈”这个词在过去十年里已经被说烂了。前端加后端,再加点运维和数据库,差不多就能自称全栈工程师。但最近一年,我身边不少做了七八年的老全栈都开始焦虑——不是…

作者头像 李华