news 2026/10/16 5:00:46

手写Tool Use的三种实现:从提示词硬控到原生Function Calling再到Agent注册表

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写Tool Use的三种实现:从提示词硬控到原生Function Calling再到Agent注册表

1. 为什么面试官总爱让你“手写 Tool Use”

最近后台收到不少读者留言,都在聊同一个面试题:让候选人现场手写一个 Tool Use(工具调用)。有位读者A同学前两天去面一个 Agent 方向的岗位,面试官上来没问八股,直接丢给他一句话:“我们不限定框架,你手写一个 Tool Use 的完整闭环给我看看。”

A同学一开始觉得这题不难,毕竟平时调 API 调惯了,心想不就是把函数描述传给模型,模型返回参数,我再执行函数把结果回传嘛。结果真动笔的时候发现,要写得清楚、写得完整、写得能跑,远比想象中复杂。他在电话里跟我复盘了两个小时,最后感慨了一句:这题看起来考的是手速,其实考的是你对“工具调用”这件事的理解深度。

其实我特别理解面试官的意图。Tool Use 是大模型从“聊天玩具”走向“生产力工具”的关键转折点。没有工具调用,模型只能基于训练数据里的静态知识作答;有了工具调用,模型才能去查天气、算数学、操作数据库、调用业务 API,真正变成一个能“动手干活”的智能体。所以面试官问这道题,本质上是在考察你懂不懂 Agent 的底层工作流。

我后来跟A同学说,这题如果你想答得出彩,至少得准备三种不同层级的实现方案,每种方案的取舍、适用场景、潜在坑点都要心里有数。今天我就把这三套方案完整地拆开揉碎写出来,既有可以直接抄的代码骨架,也有我在实际项目里踩过的坑。无论你是准备面试、刚接触 Agent 开发,还是想在团队里落地工具调用能力,这篇都能给你一个比较完整的坐标系。

先把话撂在前面:Tool Use 这件事,从实现难度上可以分成三个梯度——纯提示词方案、原生 Function Calling 协议方案、通用 Agent 执行循环方案。三套方案没有绝对的好坏,只有合不合适的区别。

2. 方案一:不依赖平台能力,用提示词硬控 JSON 输出

2.1 适用场景与核心思路

先讲最原始、最“手写”的方案。假设你面对的模型服务没有任何原生的工具调用接口,只有最普通的 chat/completions 能力,你还能不能实现 Tool Use?答案是能,靠的就是把工具描述写进系统提示词,再严格约束模型的输出格式。

这套方案的核心思路是:把“工具的定义”塞给模型,告诉它“当用户的问题需要用到外部工具时,你别直接回答,先输出一个特定格式的 JSON,JSON 里写明你要调用哪个工具、传什么参数”。然后你在代码里对这个 JSON 做解析,拿到参数后调用对应的本地函数,再把函数的结果拼接成一段文字,作为一条新的系统消息塞回给模型,让模型基于工具结果生成最终回答。

听起来简单,但这里面有一个关键认知:大模型本身不执行任何代码,它只负责“决定”调什么工具、填什么参数,真正执行工具的是你写的宿主程序。你是在用提示词“驯”出一个工具调用的决策协议。

2.2 关键实现代码骨架

我直接给出一版可以直接跑的 JavaScript 伪代码:

const tools = { get_weather: { description: "查询指定城市的实时天气", parameters: { type: "object", properties: { city: { type: "string", description: "城市名称" } }, required: ["city"] }, execute: async ({ city }) => { // 这里换成真实天气 API 调用 return `${city} 今天晴,气温 18~25℃`; } }, calc: { description: "计算两个数的四则运算结果", parameters: { type: "object", properties: { expr: { type: "string", description: "数学表达式,如 1+2*3" } }, required: ["expr"] }, execute: async ({ expr }) => { return eval(expr); // 仅示意,生产环境别用 eval } } }; const toolDescriptions = Object.entries(tools).map(([name, def]) => ( `### 工具 ${name}\n${def.description}\n参数 JSON Schema:\n${JSON.stringify(def.parameters)}` )).join("\n\n"); const systemPrompt = ` 你是一个智能助手。当用户问题需要外部信息或计算时,你必须调用工具。 工具列表如下: ${toolDescriptions} 调用规则: 1. 判断用户问题是否需要调用工具。若不需要,直接回答。 2. 若需要,严格输出如下 JSON,不要包含任何其他文字: {"name": "工具名", "arguments": {"参数名": "参数值"}} 3. 当收到工具结果后,基于结果组织自然语言回答。 `;

接着是主循环:

async function chat(messages) { const resp = await callModel({ messages: [{ role: "system", content: systemPrompt }, ...messages] }); const content = resp.choices[0].message.content; // 尝试解析 JSON const toolCall = tryParseJson(content); if (!toolCall || !tools[toolCall.name]) { return content; // 没有工具调用,直接返回 } // 执行工具 const result = await tools[toolCall.name].execute(toolCall.arguments); // 把工具结果回传给模型 messages.push( { role: "user", content }, { role: "user", content: `工具执行结果:${JSON.stringify(result)}` } ); return chat(messages); }

2.3 这套方案的坑,我一个个帮你踩过

先说最痛的坑:模型总是不老实,输出的 JSON 里混着解释性文字。比如它会输出“好的,我来查询天气,以下是结果:{...}”。你用JSON.parse直接解析必崩。我后来学乖了,每次都写一个tryParseJson函数,先用正则把字符串里第一个{到最后一个}之间的内容抠出来再解析,容错率会高很多。

第二个坑是循环调用失控。如果模型连续五轮都认为需要调用工具,你的循环就会无限跑下去。我一般在循环里加一个maxSteps限制,比如最多允许 3 轮工具调用,超过就强制返回最后一次结果,再加一句“我尝试了多次但未能获取有效结果”。

第三个坑是工具描述写得太含糊。模型对工具的理解完全来自你写的 description,很多初学者只写一句“获取天气”,模型根本不知道该传什么参数、参数单位是什么、默认值是什么。我习惯把参数说明写得像 API 文档一样细,甚至附上一个示例值。

方案一的最大优点是轻量,不依赖任何平台特性,换个模型服务只需要调整提示词风格就行。最大缺点是可靠性偏低,模型的输出格式偶尔会飘,而且随着工具数量变多、逻辑变复杂,提示词会越来越臃肿,维护成本呈指数上升。

3. 方案二:拥抱原生 Function Calling 协议

3.1 协议化带来的质变

如果平台本身就支持 Function Calling(现在主流的大模型服务基本都支持,只是叫法不同——有的叫 tool call,有的叫 function call),那你千万别自己用提示词硬憋。协议化的东西一定要走协议,这是我在生产环境里吃过亏之后才彻底想通的。

方案二跟方案一的本质区别在于:“模型是否要调用工具”这件事,从“让模型自由发挥”变成了“结构化决策”。你通过 API 参数把工具列表传给模型服务,模型会在生成的响应里附带一个结构化的tool_calls字段,里面包含工具名和参数,而且参数是模型侧解析好的 JSON 对象,不用你再去字符串里扒。

我印象最深的是第一次切换方案二时的感受:之前方案一里那些“JSON 里混文字”“参数漏传”“格式多一个逗号”的问题,一下子全部消失了。因为模型服务端为了保证协议可靠,会强制约束输出格式。这就是“平台帮你兜底”和“全靠自己扛”的区别。

3.2 请求与响应的完整数据流转

来看一版标准的示例。首先是请求侧,你要在 messages 之外额外传一个tools数组:

{ "model": "your-model", "messages": [ { "role": "user", "content": "北京今天适合穿短袖吗?" } ], "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称" } }, "required": ["city"] } } } ] }

响应侧,如果模型决定调用工具,返回的消息里会多出tool_calls:

{ "choices": [ { "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } } ] } } ] }

注意arguments是一个字符串,不是对象,你需要自己JSON.parse。拿到之后执行本地函数,再把结果作为一条role: "tool"的消息追加进上下文集,发起第二轮请求:

{ "model": "your-model", "messages": [ { "role": "user", "content": "北京今天适合穿短袖吗?" }, { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\": \"北京\"}" } } ] }, { "role": "tool", "tool_call_id": "call_abc123", "content": "北京今天晴,气温 12~20℃" } ] }

第二轮的响应里,模型会结合工具结果给出最终答案:“北京今天 12~20℃ 偏凉,建议穿长袖或薄外套,短袖可能扛不住。”

3.3 协议细节里的关键索引逻辑

方案二虽然省心,但有一些协议细节你必须搞懂,否则会调不通。

第一,tool_call_id绝对不能错。你回传工具结果的时候,必须把那条 assistant 消息里tool_calls[0].id原样带回。这个 id 是模型服务端用来关联“哪次调用”的唯一索引。我曾经图省事,自己生成一个新 id 填进去,结果服务端直接报错。老老实实从响应里取。

第二,assistant 消息必须原样回传。很多新手在这里栽跟头:第一轮返回了带tool_calls的 assistant 消息,第二轮组装 messages 时,他们只回传了用户消息和工具结果,没回传那条 assistant 消息。这会导致服务端无法理解上下文,逻辑直接混乱。正确的做法是把带 tool_calls 的 assistant 消息完整塞回去。

第三,当轮可以返回多个 tool_calls。如果用户问“北京和上海哪个更热”,模型可能同时发起两次 get_weather 调用。你需要遍历tool_calls数组,逐个执行,然后把所有结果都追加为 tool 消息,再发起新一轮请求。很多初学的代码只处理tool_calls[0],导致多工具并行调用的场景直接漏掉。

第四,系统消息在参数里写 type: function还是直接写对象,取决于不同平台的协议版本。这是我在实际切换平台时被坑得最惨的一次——同一个语法在 A 平台能用,在 B 平台直接 400。正确做法是先查目标平台的 API 文档,再看它的 SDK 封装了哪些类型,别凭着记忆照搬。

方案二目前是生产环境的主流选择,因为可靠、结构化、有生态支持。但它的局限也很明显:工具定义和调度逻辑还是写死在代码里的,没有统一的注册机制,也没有处理“工具报错后模型如何恢复”的通用策略。所以当你面对的工具数量一多、场景一变复杂,就需要进阶到第三套方案。

4. 方案三:自研工具注册表与通用执行循环

4.1 为什么要搞一个“注册表”而不是写一堆 if-else

面试官如果是真在招 Agent 方向的工程师,前两套方案只能体现你的“及格线”,真正能拉开差距的是第三套。这套方案解决的核心问题是:当你的工具数量从 3 个涨到 30 个,代码结构怎么组织才不会烂掉。

如果你写一堆 if-else 去匹配工具名,每加一个工具就要改一次主逻辑,那这套系统很快就会变成没人敢动的“屎山”。正确的做法是引入工具注册表(Tool Registry):每个工具就是一个自带元信息的对象,主动“注册”到全局表里,调度循环只认注册表,完全不关心具体工具的实现。

这就像点菜系统:每道菜(工具)把菜名、做法(执行函数)、需要什么食材(参数 Schema)登记在菜单上。顾客(模型)只需要报菜名,后厨(执行循环)按菜单找厨师做菜。新增一道菜只需要加菜单条目,不需要改后厨的运转流程。

4.2 注册表设计与调度循环实现

工具注册表的核心数据结构如下:

class Tool: def __init__(self, name, description, parameters, handler): self.name = name self.description = description self.parameters = parameters self.handler = handler class ToolRegistry: def __init__(self): self._tools = {} def register(self, tool: Tool): self._tools[tool.name] = tool def get(self, name: str): return self._tools.get(name) def list_specs(self): # 生成给模型看的工具列表 return [ { "type": "function", "function": { "name": t.name, "description": t.description, "parameters": t.parameters } } for t in self._tools.values() ] # 使用示例 registry = ToolRegistry() registry.register(Tool( name="get_weather", description="查询指定城市的实时天气", parameters={...}, handler=lambda args: f"{args['city']} 晴,12~20℃" ))

有了注册表之后,通用执行循环可以统一收敛成一个run_agent函数:

def run_agent(user_input, registry: ToolRegistry, max_steps=5): messages = [{"role": "user", "content": user_input}] step = 0 while step < max_steps: resp = call_model( messages=messages, tools=registry.list_specs() ) msg = resp.choices[0].message # 如果没有工具调用,直接返回 if not msg.tool_calls: return msg.content # 先把 assistant 消息压入上下文 messages.append(msg) # 遍历执行所有工具调用 for tool_call in msg.tool_calls: fn_name = tool_call.function.name fn_args = json.loads(tool_call.function.arguments) tool = registry.get(fn_name) if not tool: result = f"错误:工具 {fn_name} 不存在" else: try: result = tool.handler(fn_args) except Exception as e: result = f"工具执行失败:{str(e)}" messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) step += 1 return "已达到最大调用轮数,请简化问题后重试"

这套循环里,错误处理我特意做了向上抛文字——当工具内部抛异常时,把错误信息作为工具结果返回给模型,模型会基于错误信息调整策略,比如换个参数再试一次。这比直接在代码层中断整个流程要优雅得多,也是 Agent 系统里一个容易被低估的设计细节。

4.3 生产化必做的几个加固点

第三套方案如果真要做到生产可用,以下细节必须处理:

工具 Schema 的严格校验。模型生成的参数有时候会漏字段、多字段、类型不对。我习惯在tool.handler执行前,用 Schema 校验库做一次validate(fn_args),不合法就直接返回“参数校验失败:xxx”,让模型自己改。

工具描述的动态裁剪。当一个 Agent 挂了几十个工具,所有工具描述全部塞给模型,会占用大量上下文窗口,而且模型反而更容易选错工具。正确做法是根据用户问题的关键词做工具召回,只把最相关的几个工具描述放进请求。这一步做得好不好,直接影响用户体验。

工具执行结果的上下文压缩。如果某个工具返回了一个几百行的大 JSON,全部塞进 messages 会给模型增加很大负担。我通常在工具返回后做一步后处理:只截取前 N 个字符,或者用另一个小模型把结果先“压缩”成摘要,再把摘要传给主模型。

幂等与超时控制。工具调用可能触发有副作用的操作(写库、发消息)。我习惯在工具定义里加一个idempotent标记位,非幂等工具在注册表里会额外做确认防护。同时每个工具的执行都要包一层超时控制,避免某个外部 API 卡死整个 Agent。

到这里你会发现,第三套方案已经不止是“手写 Tool Use”了,而是一个微型 Agent 框架的核心骨架。如果面试官看到你能把注册表、错误恢复、上下文压缩这些点都讲清楚,大概率会愿意跟你多聊半小时。

5. 三套方案怎么选,以及面试时怎么答才加分

5.1 决策路径:从场景反推方案

三套方案全掌握之后,真正难的是在合适的场景里选合适的方案。我整理了一个简单的决策思路,也是我在团队里推动工具化建设时一直沿用的标准:

先问自己三个问题。第一,平台支持原生的 Function Calling 吗?如果不支持,认命用方案一,但你得把 JSON 解析和容错做到位。第二,工具的数量和变更频率高吗?如果只有两三个固定工具,方案二最省事;如果工具集在快速增长、需要多人协作维护,直接上方案三的注册表架构。第三,Agent 的链路复杂吗?只需要“查一次工具就回答”的简单场景,“方案二 + 简单循环”就够了;需要多轮工具调用、跨工具协作、失败自恢复的,必须考虑方案三的完整循环。

还有一个我个人的强烈建议:哪怕你只是写个 Demo,也建议从方案三的注册表结构起步。因为从方案二迁移到方案三的成本,远高于一开始就按注册表组织代码的成本。我见过太多项目,前期贪方便写了硬编码分发的工具调用,等工具数量过十个之后,重构的痛感让人只想重写。

5.2 面试官到底想听什么

回到面试的场景。面试官让你现场写 Tool Use,其实他内心有一张隐藏的评分表:

第一层,看你能不能跑通完整闭环——模型决策、函数执行、结果回传,一个都不能少。

第二层,看你有没有协议意识——知不知道原生 Function Calling 和提示词硬控的本质区别,知不知道tool_call_id索引之类重要的协议细节。

第三层,看你有没有工程化嗅觉——工具注册表怎么设计、错误怎么处理、上下文怎么管理、工具多了怎么办。大部分候选人卡在第二层和第三层之间。

所以我的建议是,面试时不要一上来就闷头写最优方案。你可以先说一句:“我有三种方案,取决于平台能力和业务复杂度。我先给你写一个不依赖平台的原生实现,再讲讲为什么生产环境我建议用原生协议和注册表。”这是一个展示技术判断力的过程,远比默默写代码更有记忆点。

我在实际面试别人的时候,最怕遇到一种候选人:一上来就用某个现成框架的 API,把工具一注册,run 一下,完了。这只能说明他会用框架,说明不了他理解 Tool Use 本身。真正区分水平的是:脱离框架之后,你还剩多少底层理解。

6. 我踩过的几个细节坑,写给你避雷

最后单独写一节,都是我在真实项目里花过时间代价才摸清楚的细节。这些内容一般文档里不会写,面试的时候如果你能主动提出来,会是一个非常亮眼的加分项。

坑一:模型返回空字符串 content 不代表异常。在 Function Calling 流程里,当模型决定调用工具时,content字段经常是null或空字符串,而信息都放在tool_calls里。新手代码如果对content做if (!content) return,就会在模型正要调用工具的关键时刻中断流程。正确的判断条件应该是:如果tool_calls非空,无论如何都要进入工具执行分支。

坑二:工具描述里的示例值要“正在使用”。我在写工具描述时,会专门在description里带上一个典型输入示例,比如“查询城市的实时天气,示例:查询北京天气时应传 city='北京'”。别看就这么一句话,模型面对模糊查询时的参数命中率明显提升。原理很简单:模型对模式的模仿能力远强于对抽象规则的理解,给示例本质上是给它一个“参考模板”。

坑三:工具并发执行的返回顺序要保持稳定。当模型一次发起多个工具调用,你并行执行它们时,回传给模型的 tool 消息必须保持与tool_calls数组相同的顺序。如果顺序错乱,模型会把 A 工具的结果当成 B 工具的,结论会完全不可信。.map到Promise.all的经典问题,在 Agent 场景里同样存在。

坑四:别在提示词里写“如果工具不可用就编一个答案”。这条我特别想强调。模型非常容易受到提示词里的负面指令暗示,一旦你写了类似“如果工具调用失败,你可以基于常识回答”,它就会更倾向不调用工具、直接猜答案,整个 Tool Use 体系直接形同虚设。我的做法是:绝不使用“如果失败”类表述,只在系统提示词里写清楚“工具结果未返回前不得回答该问题”。

坑五:日志留痕非常重要。Tool Use 链路涉及模型请求、工具执行、结果回传,任何一环出错都可能交互异常。我在生产环境里一定会在关键节点打结构化日志:请求时间、模型的 tool_calls 原始内容、参数校验结果、工具执行耗时、回传前消息长度、循环轮数。出了问题时,这些日志是唯一能定位到原因的抓手。很多线上 Agent 事故排查半天无果,一看日志发现是某个工具的返回格式把模型带偏了,这类问题没有日志几乎无从下手。

这些坑单拎出来每个都很小,但如果你写 Tool Use 时没有防范意识,它们会在生产环境里轮番轰炸你。

最后分享一点个人体会

我从纯提示词方案起步,到切换原生 Function Calling,再到自研注册表体系,中间经历过不少自我怀疑的时刻——总觉得是不是直接套一个现成框架更省事。但回过头看,恰恰是这一段“手写”的经历,让我对工具调用这件事有了底层层面的理解。框架会迭代,协议会变化,但调用决策、参数流转、执行回填这条链路的基本逻辑,无论平台怎么演进都没变过。

如果你也在准备面试,或者正在做 Agent 相关开发,我的建议是:挑一个周末,别开 IDE 的自动补全,从零写一遍三套方案。写的过程中你会被迫思考很多平时被框架隐藏掉的细节,这些思考才是面试官真正想看到的东西。

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

C# WebAssembly性能优化实战:从卡顿到秒级响应

接手那个用C# WebAssembly写的管理后台时&#xff0c;我一度怀疑自己是不是回到了用IE6写网页的年代。首页加载转三秒圈圈还算好的&#xff0c;最折磨人的是一千行报表的页面&#xff0c;滚动起来整个风扇跟着咆哮&#xff0c;用户原话是&#xff1a;“你们这个页面&#xff0c…

作者头像 李华
网站建设 2026/10/16 4:59:56

文件系统深度解析:从inode到故障恢复实战

文件系统这个词&#xff0c;在技术圈里几乎天天被提及&#xff0c;但说实话&#xff0c;大多数人对它的理解停留在“格式化的时候选个NTFS还是exFAT”这个层面。我自己做过几年存储相关的开发和运维&#xff0c;踩过不少坑&#xff0c;也花时间把底层机制啃过一遍。这篇文章想把…

作者头像 李华
网站建设 2026/10/16 4:59:33

PyTorch深度学习实战:动态图、训练加速与踩坑指南

做深度学习这几年&#xff0c;PyTorch 是我用得最顺手的框架。最早接触它是在一个时间紧、数据集还很乱的图像分类项目里&#xff0c;当时模型结构天天改&#xff0c;参数换来换去&#xff0c;它“定义即运行”的风格让我第一次觉得写模型可以这么顺手。后来这个框架逐渐成了我…

作者头像 李华
网站建设 2026/10/16 4:57:37

pcapsipdump:轻量级SIP信令提取工具实战指南

简介&#xff1a;pcapsipdump-0.2 是一款轻量级开源 SIP 协议网络抓包分析工具&#xff0c;基于 libpcap 实现&#xff0c;专为网络协议分析人员、VoIP 开发者及安全研究人员设计&#xff0c;用于实时监听网卡并自动分离、持久化保存 SIP 信令与关联 RTP 媒体流会话。资源包共1…

作者头像 李华
网站建设 2026/10/16 4:57:31

POJ 3020 天线覆盖:二分图最大匹配与匈牙利算法建模

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华