资讯详情

手写工具调用:三种实现方案从提示词到智能体循环

📅 2026/10/16 11:02:40 | 华诺云谱 👁 阅读
手写工具调用:三种实现方案从提示词到智能体循环
上周帮朋友做模拟面试他正好抽到一道让他发怵的问题“能不能手写一个 Tool Use”我当时就在旁边看他愣了五秒然后开始从 Prompt 层面一点点拆。这件事给我的触动挺大的Tool Use 这个概念在 Agent 开发里已经快变成标配了但很多人只会调框架一让手写就卡壳。那天模拟结束后我把三种手写方案完整过了一遍三条路线各有各的适用场景和坑。今天把它整理出来给同样被这道题问住的人做个参考。这篇文章适合三类人看一是准备大模型相关岗位面试、想把手写能力补上的开发者二是已经在用各类 Agent 框架但不想只停留在“调包”层面的工程同学三是想自己从零搭一个带工具调用能力的小应用、不想被框架黑盒束缚的爱好者。我会把三种方案的代码骨架、设计思路、踩坑点全部铺开讲。1. 面试题背后究竟在考什么与其纠结“Tool Use 怎么写”不如先琢磨面试官问这道题的真实意图。能把原理讲清楚的人才是他们真正想找的。1.1 Tool Use 是什么为什么面试会问Tool Use翻译过来就是“工具调用”或者“工具使用”指让大语言模型在回答问题时能够调用外部函数、API、数据库拿到实时数据或者执行某个动作然后再基于结果组织最终回复。打个比方模型像一个知识渊博但双手被绑住的顾问它脑子里有大量常识但它碰不到外部世界。Tool Use 就是给它配一个传话的助手——模型说“我需要查一下某地的天气”助手负责去天气接口把数据拿回来递给它。整个过程里模型本身不真正执行代码它只负责“决定调用哪个工具”以及“生成调用参数”实际执行的是外部程序。面试官爱考这道题是因为它能一杆子捅到底你懂不懂 LLM 的调用本质懂不懂结构化输出懂不懂解析和校验懂不懂多轮对话中的状态管理懂不懂防死循环、防上下文膨胀一道题能把工程素养问个七七八八远比背几个 API 名称有价值。1.2 三个方案的整体思路这道题没有唯一答案。我平时习惯把实现思路分成三个层次第一层把工具说明写进 System Prompt让模型按约定的 JSON 格式输出调用请求再用代码解析执行。完全不依赖平台特性任何模型都能跑。第二层使用模型服务商原生提供的 Function Calling / Tool Calling 接口把工具定义成结构化 JSON Schema让模型在输出层直接返回标准化的 tool_calls。第三层自己实现一个 ReAct 循环让模型先思考、再行动、观察结果、再思考不断迭代直到给出最终答案。本质上就是一个手写的 Agent 雏形。这三个方案层层递进正好对应面试官的三连追问“你只会用框架吧”“那不用框架你会不会”“能不能让它连续解决多个问题”下面逐个展开。2. 方案一Prompt 约定格式 JSON 解析这是最朴素、也最能体现理解深度的一条路。面试官让手写如果时间紧张我一般会从它起步。2.1 实现原理大模型本身并不知道外面有什么工具你需要用一个它能看懂的方式把工具信息告诉它。最直接的办法就是在 System Prompt 里描述工具列表和调用格式要求模型严格输出 JSON。核心就三步定义工具清单、拼接提示词、解析模型输出。工具清单里每一项至少包含工具名、功能描述、参数结构。这里有个容易被忽略的点描述一定要写得足够细因为模型是靠文字理解工具的你写“获取天气”模型可能不知道要传城市名还是传日期你写“根据城市名获取当前实时天气输入参数为城市中文名例如北京”模型才能稳定产出正确参数。输出格式上我习惯强制约定成一个 JSON 对象{action: get_weather, params: {city: 北京}}参数里再用 required 字段标记必填项。注意解析时不能只做简单 json.loads因为真实场景下模型偶尔会夹带语气词、markdown 代码块标记或者多余的解释文字需要提前做清洗。2.2 手写代码骨架下面这份代码是我在模拟面试中常用的最小实现去掉了平台 SDK只保留核心逻辑你可以直接替换成任意模型接口import json TOOLS [ { name: get_weather, description: 根据城市名查询当前天气输入城市中文名如 北京, parameters: { type: object, properties: { city: {type: string, description: 城市中文名} }, required: [city] } } ] def build_system_prompt(tools): tool_lines [] for tool in tools: tool_lines.append( f- {tool[name]}: {tool[description]} f参数: {json.dumps(tool[parameters], ensure_asciiFalse)} ) system_prompt ( 你是一个智能助手。当需要查询实时信息时请使用工具。\n 可用工具列表\n \n.join(tool_lines) \n\n请严格输出 JSON不要输出任何其他内容格式如下\n {action: 工具名, params: {}} ) return system_prompt def call_llm(messages): # TODO: 替换成你实际使用的大模型 API 调用 # 这里只演示结构返回模型输出字符串 pass def execute_action(action, params): if action get_weather: # 真实场景替换为天气 API return {city: params.get(city), weather: 晴, temperature: 25} return {error: f未知工具: {action}} def extract_json(text): text text.strip() # 去掉可能的 markdown 代码块标记 if text.startswith(): text text.strip() if text.startswith(json): text text[4:] start text.find({) end text.rfind(}) if start -1 or end -1: return None try: return json.loads(text[start:end1]) except json.JSONDecodeError: return None messages [ {role: system, content: build_system_prompt(TOOLS)}, {role: user, content: 北京今天冷吗需要带外套吗} ] resp call_llm(messages) parsed extract_json(resp) if parsed and parsed.get(action): result execute_action(parsed[action], parsed.get(params, {})) # 把工具结果放回对话让模型基于真实数据作答 messages.append({role: assistant, content: resp}) messages.append({role: user, content: f工具返回结果{result}请根据结果回答。}) final_resp call_llm(messages) print(final_resp) else: print(模型没有输出合法工具调用直接返回, resp)注意 extract_json 这个函数它专门解决模型输出不干净的问题。真实调用里模型很可能给你回一个带说明的 JSON。去掉首尾空白、剥掉代码块标记、截取最外层大括号这一套清洗流程能把解析成功率从勉强可用拉到比较稳的程度。2.3 方案一的优点与明显短板先说优点。它是真正的“不挑食”方案不依赖任何模型服务商的特殊接口只要有普通文本对话能力就能跑。也因此特别适合讲原理你把整个过程摊开模型的每一步都是透明的出问题能直接定位是提示词的问题还是解析的问题。面试时先用这个方案开头能让面试官看到你对底层机制的理解。但短板也很明显。模型对 JSON 格式的遵循度不稳定尤其当工具数量超过五六个、参数嵌套层级变深之后它偶尔会丢字段、写错类型、传个 undefined你只能靠各种兜底逻辑去补。另一个问题是每轮调用都要把工具描述重复塞进上下文工具多了占用 token 很厉害。所以它更适合工具种类少、对稳定性要求不极端的场景以及作为理解原理的教学样例。3. 方案二原生 Function Calling 接口如果你直接在第一轮就写出这个方案面试观感通常会更好——因为它说明你不只懂 Prompt还知道生产环境最主流的做法。3.1 核心思路主流的模型服务商已经提供原生工具调用能力。你在请求里传一个 tools 参数把每个工具描述成标准 JSON Schema模型在需要时会在返回结果里带上结构化的 tool_calls 标记而不是靠你死磕 Prompt 让它输出 JSON。这个设计最大的意义是“把不确定性从 Prompt 层移除”。用方案一模型以为自己在聊天你靠提示词硬掰它的输出格式用方案二模型在训练和服务层就专门学过“何时调用工具、如何生成参数”输出稳定性天差地别。整个链路变成四步定义工具、模型返回调用请求、执行真实函数、把结果作为 tool 消息回传。3.2 一份可以直接跑的调用骨架下面代码用抽象 SDK 示意换成你用的具体客户端即可核心流程完整import json from llm_sdk import Client # 替换为实际 SDK client Client(api_keyyour-key) tools [ { type: function, function: { name: get_weather, description: 根据城市名查询当前天气, parameters: { type: object, properties: { city: {type: string, description: 城市中文名如 北京} }, required: [city] } } } ] messages [{role: user, content: 北京今天冷吗}] # 第一轮模型决定是否调用工具 resp client.chat.completions.create( modelyour-model, messagesmessages, toolstools, ) msg resp.choices[0].message if msg.tool_calls: # 拿到工具名和参数 tool_call msg.tool_calls[0] fn_name tool_call.function.name args json.loads(tool_call.function.arguments) # 执行真实工具 result execute_action(fn_name, args) # 回传工具结果 messages.append({role: assistant, content: None, tool_calls: [tool_call]}) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) # 第二轮模型基于工具结果给出最终回答 final_resp client.chat.completions.create( modelyour-model, messagesmessages, toolstools, ) print(final_resp.choices[0].message.content) else: print(msg.content)这里有几个细节必须强调。assistant 消息里要把 tmodel_calls 原样回传不能只写文字否则平台校验上下文时可能报错。tool 消息必须带 tool_call_id把“这次结果是对哪一次调用的回答”绑定清楚。contentNone 是因为这条 assistant 消息没有普通文字内容只有工具调用指令硬塞内容反而可能破坏协议。# 如果不只一个工具调用记得要循环处理 if msg.tool_calls: messages.append({role: assistant, content: None, tool_calls: msg.tool_calls}) for tc in msg.tool_calls: result execute_action(tc.function.name, json.loads(tc.function.arguments)) messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse) })这段多调用逻辑意义很大模型有时一次会返回多个 tool_calls比如“查北京天气”和“查上海天气”一起发出。如果你只处理第一个另一半数据就丢了。正确处理方法是把 assistant 消息的 tool_calls 列表完整追加再逐个执行并逐个回传 tool 消息最后一次性交给第二轮。3.3 方案二的关键细节用这个方案有三个参数/行为值得现场讲出来加分。一是 tool_choice。它可以取 auto让模型自己决定要不要调用、required强制必须调用一次工具、或者指定某个具体函数名。比如做客服系统时希望每通对话都先查用户订单就可以把 tool_choice 设为对应函数。这个参数在面试中提一下能体现出你对控制力的理解。二是参数校验不能省。模型生成的参数仍然可能出现类型错误、缺字段。我不止一次看到模型把数值参数传成字符串导致下游接口崩溃。建议在 execute_action 入口做一层 schema 校验不合法就返回错误信息给模型让它自己修正。三是工具描述的 token 占用。tools 数组每轮请求都要传十几个工具描述可能吃掉上千 token。生产环境我会做“工具路由”先让一个轻量模型或关键词分类器筛选出最相关的三五个工具再带着它们去请求主模型。这个优化面试官通常很爱听。4. 方案三ReAct 循环一个 Agent 的雏形如果面试官继续追问“能不能处理更复杂的任务比如连续查三次数据再回答”第二方案就不够了得进入循环结构。这段写出来基本就是全场高光。4.1 ReAct 的四段式ReAct 是 Reason Act 的组合思想核心是让模型交替进行“思考”和“行动”。Prompt 里约定一个固定格式Thought这一步的推理过程说明为什么需要调用工具。Action要调用的工具名。Action Input给工具的参数JSON 格式。Observation工具返回的观察结果。真实运行里模型输出“Thought Action Action Input”代码执行工具后把结果写成 Observation拼回上下文模型看到 Observation 后再产出一轮新的 Thought。如此循环直到模型输出“Final Answer”循环结束。这套写法的启发性在于它不需要平台级 function calling 支持任何能生成文本的模型都能跑只要你的 Prompt 和解析器写得够好。它也在结构上比方案一更接近 Agent你等于自己实现了一个最小化的自主决策循环。4.2 循环代码手稿import json import re REACT_PROMPT 你是一个能调用工具的智能助手。请严格按如下格式输出 Thought: 你的推理过程 Action: 工具名注意必须来自工具列表不知道就说不知道 Action Input: {参数名: 参数值} Observation: 工具结果由系统填充 当你已经得到答案时请输出 Thought: 我已经得到答案 Final Answer: 给用户的最终答案 可用工具 {tool_descriptions} MAX_STEPS 5 def call_llm(messages): # TODO: 替换为真实模型调用 pass def parse_react(text): if Final Answer: in text: answer text.split(Final Answer:, 1)[1].strip() return {type: final, answer: answer} action_match re.search(rAction:\s*(.), text) input_match re.search(rAction Input:\s*(\{.*\}), text, re.S) if action_match and input_match: try: return { type: action, action: action_match.group(1).strip(), params: json.loads(input_match.group(1)) } except json.JSONDecodeError: return None return None def execute_action(action, params): if action get_weather: return {city: params.get(city), weather: 晴, temperature: 25} elif action add: return params.get(a, 0) params.get(b, 0) return {error: f未知工具: {action}} messages [ {role: system, content: REACT_PROMPT.format(tool_descriptions- get_weather(city)\n- add(a, b))}, {role: user, content: 北京和上海哪个更热两地温差几度} ] for step in range(MAX_STEPS): resp call_llm(messages) parsed parse_react(resp) if parsed is None: messages.append({role: user, content: 格式错误请严格按 Thought/Action/Action Input 格式输出。}) continue if parsed[type] final: print(最终回答:, parsed[answer]) break result execute_action(parsed[action], parsed[params]) print(fStep {step1}: 调用 {parsed[action]} - {result}) messages.append({role: assistant, content: resp}) messages.append({role: user, content: fObservation: {result}}) else: print(达到最大步数强制结束)这段代码另一个值得注意的地方是 else 分支Python 的 for-else 语义在循环正常结束没有 break 时触发用来兜底“模型一直没给出最终答案”的情况防止死循环。这个细节写出来面试官会知道你考虑过真实系统的失控风险。4.3 从 ReAct 到完整 Agent 的扩展方向手写 ReAct 只是起点。你可以在它上面叠加三样东西直接演进成生产级 Agent记忆管理每轮 Observation 都拼进上下文轮数一多 token 爆炸。可以加一个 summarizer把前面历史总结成若干条高密度记忆只保留最近两轮完整内容。规划器ReAct 是边做边想效率偏低。可以加一个“先列计划再执行”的阶段让模型先拆解任务、再按计划调用工具这就是 Plan-and-Execute 模式。工具注册表把工具统一登记在字典里自动从函数签名生成 JSON Schema 描述新增工具时不用手写两遍文档。跟面试官聊到这里基本已经超出“手写 Tool Use”的范畴进入架构设计层面了。对方想考证的东西——模型行为理解、循环控制、资源管理——你全打到了。5. 三种方案对比与现场选型建议方案都知道关键是怎么选。这一节给你一张表和一个决策思路。5.1 横向对比表维度方案一Prompt JSON 解析方案二Function Calling方案三ReAct 循环输出稳定性中低靠提示词约束高平台原生保证中依赖模型推理能力平台依赖无任何模型可跑有需服务商支持无任意文本模型可跑多轮连续调用需手动拼接繁琐平台原生支持但需逐轮回传循环天然支持实现成本低低中可控性高全部透明中细节被平台封装高循环完全自控上下文开销工具描述每次全量塞入工具定义以结构化传输开销优化好历史累积需要额外管理典型场景教学演示、极简工具生产级单次调用、客服问答复杂任务、多步推理、Agent 原型5.2 不同处境怎么选面试手写场景我推荐先用方案一打开场面因为它逻辑最直观、能展示原理理解。面试官追问“如果模型不按格式输出怎么办”你就顺势展开清洗逻辑和容错设计。追问“生产环境也这样写吗”再引入方案二。追问“多个工具连续调用呢”再落到方案三的循环。三个方案串起来是一个完整的递进故事比上来直接背 Function Calling API 更容易让人记住。真实业务场景只有一两个工具、对时延敏感直接方案二稳定且省心。需要让模型自主决策调多个工具、做多步推理方案三更灵活但你要自己承担循环控制、上下文管理、错误恢复的复杂度。成熟团队可以组合用方案二的稳定输出做工具调用解析用方案三的循环结构做任务编排两层叠加既能压住模型乱报参数的毛病又保留自主决策能力。6. 现场手写最容易踩的坑手写 Tool Use 时真正让人翻车的往往不是“不会写核心逻辑”而是没预料到模型在边缘情况下的各种表现。把这些坑提前排掉你写出来的代码就是另一个水平。6.1 高频坑点避坑清单模型不输出纯 JSON。方案一里这是头号问题。模型可能回答“好的我帮你查一下{...}”也可能用 包起来。光 json.loads 必炸。 解法是按我前面写的 extract_json 思路剥代码块标记、取首尾大括号、再用正则兜底。宁可解析失败让模型重来也不要硬接一个错对象。JSON 不合法。缺少逗号、单引号、undefined 值都是常见情况。特别留意参数里有 URL、长文本时转义问题会放大。 解法是做二次清洗把单引号替换成双引号遇到 undefined 替换成 null再做加载。再不行就把错误信息拼进下一轮提示词让模型自己修。工具名凭空捏造。模型用词漂移你定义的是 get_weather它偏要输出 weather没有连字符或者 query_weather。 解法执行前先做名称归一化模糊匹配或小写去符号比对完全未知的工具不要硬执行返回错误信息让模型重新选择。不设最大轮数导致死循环。尤其方案三模型可能反复调用同一个工具、拿到同样的 Observation永远不给 Final Answer。 解法必须设 MAX_STEPS循环内没结果就强制终止。还可以加一个“相同调用去重”检测到重复工具和相似参数就打断提示模型换策略。不校验工具副作用。面试题可能让你写查询函数没问题但如果真实场景是发邮件、转账、删文件工具一旦执行就不可回滚。 解法执行前区分“只读工具”和“副作用工具”副作用工具加人工确认或强制二次问答。6.2 一份“见招拆招”的回答节奏最后给一个可以直接照着练的现场节奏。面试官说“手写一个 Tool Use”你给自己分四段时间前 3 分钟讲清 Tool Use 的本质。模型不能直接执行函数只能生成调用意图真正的执行在你的代码里。这句话开门见山比闷头写代码强太多。中间 15 分钟写方案一一定要带上 extract_json 和未知工具报错处理。中途可以自言自语说“模型偶尔不按格式出牌所以解析这层必须稳”让面试官跟上你的思路。接着 5 分钟主动说“如果生产环境我会用方案二”把 tools 定义和 tool 消息回传写出来强调 tool_call_id 绑定的作用。最后 5 分钟如果时间允许补一个 ReAct 循环骨架强调 MAX_STEPS 和 for-else 兜底。然后收尾总结方案一讲原理方案二讲稳定性方案三讲 Agent 化演进。这样走一轮你不会给面试官留下“背题机器”的印象而是一个真的踩过坑、知道系统如何在边缘情况下运转的工程师。我个人在带队面试时最看重的不是候选人写了哪个方案而是他能不能说出每个方案“在什么情况下会挂”。Tool Use 看起来是几行代码的事真正难得的是意识到模型会乱输出、上下文会膨胀、调用会失败、循环会失控。你能把这四件事的应对办法写进代码里这道题就算过了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑