news 2026/10/12 4:52:58

手写Tool Use的三种方案:从正则解析到Agent循环的完整实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写Tool Use的三种方案:从正则解析到Agent循环的完整实战指南

面试官让手写一个 Tool Use,这事我在模拟面试里遇到过不止一次。说实话,第一次听到这个题的时候我愣了一下——不是不会,是没想到对方会把问题压得这么具体。Tool Use 说白了就是让模型调用外部函数,把“只动嘴”的大模型变成“能动手”的执行者。但现在很多同学背了一堆 Agent 概念,真到白板面前让他从零写一个工具调用环路,反而容易卡壳。

这篇文章我就把当时现场写的三种方案完整复盘一遍:从最朴素的提示词加正则方案,到原生 Function Calling 协议,再到带自动恢复能力的 Agent 循环。每种方案我都会讲清楚适用场景、完整代码、以及面试官会追问的细节。内容不依赖任何特定平台,适合正在准备大模型应用方向面试的开发者,也适合刚入门 Agent 开发、想搞懂 Tool Use 底层原理的朋友。

1. 面试官为什么爱考“手写 Tool Use”

先聊点虚的,但也是最关键的部分:这个题到底在考什么。如果只是考“你会不会调模型的 function calling 接口”,那直接问 API 参数就行了,没必要让你手写。面试官真正想看的,是你对 LLM 应用架构的理解深度——模型和外部系统之间到底是怎么协作的,边界在哪里,出了问题怎么兜底。

1.1 一句话讲清 Tool Use 的本质

Tool Use 的本质,是给大模型开一个受限的“外部动作通道”。模型本身是个静态的推理器,它没有手,不能查数据库,不能发请求,不能操作文件系统。但我们可以约定一种协议:模型在生成回复的中间过程中,输出一段结构化的指令,比如“我要调用函数 get_weather,参数是 city: 北京”。宿主程序解析这段指令,替模型执行真实动作,再把结果塞回给模型,让模型基于结果继续推理。

这就好比你是一个只能说话、不能动手的专家,你身边坐了一个助手。你说“帮我把那份报表拿过来”,助手跑过去拿回来给你看,你看完继续说“里面第三页的数据好像不对,你帮我核对一下”。模型是专家,助手就是 Tool Use 的执行层。整个链路里最关键的不是那一次调用,而是“说指令—执行—回传—再决策”这个循环通道是否通畅、是否可靠。

1.2 “手写”三个字的含义:从协议到执行的完整链路

面试题写着“手写”,很多人以为就是把 API 示例抄一遍,其实不是。一个完整的手写实现包含三层:

第一层是协议层,也就是你和模型之间怎么约定调用的格式。是用 JSON 还是用 XML?是让模型输出固定标记还是自由文本?这个格式必须让模型容易生成、让程序容易解析。

第二层是执行层。解析出工具名和参数之后,怎么映射到真实的 Python 函数,怎么做参数校验,工具执行出错时怎么处理。

第三层是循环层。一次工具调用往往不够,模型可能需要根据上一次结果连续调用多个工具,最后才能给出最终回答。这个多轮循环什么时候该停,怎么防止模型“钻牛角尖”死循环,都需要设计。

面试官说“手写一个 Tool Use”,潜台词其实是:“你能否从零把这三层搭出来,并且说清楚每一层为什么这么设计。”接下来三种方案,正好对应从简到繁的三层实现思路。

2. 方案一:提示词约定 + 正则解析,零依赖也能跑

第一种方案是面试中最常被想到的,因为它不需要模型任何特殊接口,只要模型能生成文本就行。核心思路是:把工具调用的格式约定写进 system prompt,让模型在需要调用外部工具的时候输出一段特定格式的 JSON,然后用正则从回复里把这段 JSON 抠出来执行。

2.1 提示词怎么写,模型才愿意配合

这里有个关键技巧:你必须在提示词里告诉模型“什么时候该调用工具”,而不是让它猜。比如:

现在你是一个智能助手,你可以使用以下工具:

  • get_weather(city: string): 查询城市天气
  • calculate(expression: string): 计算数学表达式

当且仅当用户的需求需要外部信息或计算时,你必须输出以下格式的 JSON 作为你的全部回复: {"tool": "get_weather", "params": {"city": "北京"}}

除此之外,正常回复用户即可。

注意“作为你的全部回复”这句话很重要。如果不加,模型往往会先写一段解释再输出 JSON,那样正则解析的复杂度会直线上升。让工具调用指令成为回复的全部内容,解析就简单得多。

2.2 正则解析与执行的最小实现

接下来是执行端。我用 Python 写一个最简版本,思路非常直白:用正则从模型回复中提取 JSON 块,解析出工具名和参数,查表执行。

import re import json import datetime # 工具注册表:名字 -> 函数 TOOLS = { "get_weather": lambda city: f"{city}今天晴,气温18~26℃", "calculate": lambda expr: str(eval(expr, {"__builtins__": {}}, {})) } def parse_tool_call(text: str) -> dict | None: # 匹配一个 JSON 对象,容忍措辞变化 pattern = r"\{.*?\}" match = re.search(pattern, text, re.DOTALL) if not match: return None try: return json.loads(match.group()) except json.JSONDecodeError: return None def execute_tool(call: dict) -> str: name = call.get("tool") params = call.get("params", {}) func = TOOLS.get(name) if not func: return f"错误:未知工具 {name}" try: return func(**params) except Exception as e: return f"工具执行失败: {e}" # 模拟对话 def run_once(model_output: str) -> str: call = parse_tool_call(model_output) if call: return execute_tool(call) return model_output # 没有工具调用,直接作为最终回复

代码本身只有三十行左右,但已经形成了一个完整的“协议—解析—执行”闭环。面试时能从零写出这个结构,说明你理解了 Tool Use 的第一个层面。

2.3 方案一的天花板:为什么只能算“能跑”

这个方案的优点非常明显:零依赖、兼容任何文本模型、代码可控性强,调试起来非常直观。但它的问题也很致命。

首先是格式不稳定。模型偶尔会在 JSON 前后加一些说明文字,或者把键名写成 “Tool”“params” 混用,或者多一个逗号导致解析失败。你会发现做提示词工程的时间比写代码的时间还长。

其次是缺少结构化约束。模型并不知道“函数参数要严格符合 JSON Schema”,它只是模仿你给的例子。换一个场景、换一套工具,就可能出现参数名幻觉——比如实际工具参数是 city,模型输出了 location。

严格来说,方案一适合作为兜底方案,或者在没有 Function Calling 能力的模型上做低成本接入。它的价值在于让你理解“工具调用本质上只是文本协议”这一点。但如果要做得稳,得看方案二。

3. 方案二:原生 Function Calling 协议,让模型自己输出结构化指令

如果面试官只让你“手写”一次,大概率期待的是这个方案:利用模型自带的函数调用能力。相比方案一靠提示词约束,方案二让模型在生成时从工具定义列表里“选一个函数、填参数”,输出已经是结构化数据,不需要正则去碰运气。

3.1 工具定义的本质:一份 JSON Schema 说明书

先说清楚 Function Calling 是怎么运作的。你在请求里传一个 tools 数组,每个工具包含名称、描述和参数 JSON Schema。模型会根据用户问题和这些定义决定是否调用、调用哪个、填什么参数。注意,模型不会真正执行任何函数,它只是输出一个“调用请求”的结构体。

[ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如:北京"} }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "计算数学表达式,支持四则运算", "parameters": { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式,如:12*5+3"} }, "required": ["expression"] } } } ]

这里最核心的字段是 description。它对模型来说就是“函数的函数签名”——写得越清楚,模型越不容易选错工具、填错参数。我在实际项目中甚至看到过这么一个现象:同一个工具,description 里说明“仅当用户明确提到查询天气时使用”,工具误调用率能下降一半左右。

3.2 完整调用环路:从发起到执行

看代码。以某主流模型的接口风格为例,整个环路分两步:发起带工具定义的对话请求,收到 tool_calls 后解析执行,然后把结果回传。

import json def chat(messages, tools=None): # 这里封装 API 请求,返回响应对象 # 真实实现需替换为具体 SDK 调用 resp = api_client.chat(messages=messages, tools=tools) return resp # 步骤1:发请求,带上工具定义和用户问题 messages = [ {"role": "user", "content": "北京今天适合穿短袖吗?"} ] resp = chat(messages, tools) # 步骤2:判断响应是不是工具调用 if resp.tool_calls: tool_call = resp.tool_calls[0] name = tool_call.function.name args = json.loads(tool_call.function.arguments) # arguments 是字符串,需要二次解析 # 步骤3:执行本地工具 result = TOOLS[name](**args) # 步骤4:把工具结果以 tool 角色消息回传,继续对话 messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) final_resp = chat(messages, tools) print(final_resp.content) # 模型基于工具结果回答 else: print(resp.content) # 没有工具调用,直接回答

注意几个容易被忽略的细节。第一,arguments是个 JSON 字符串,必须二次解析,直接当对象用会报错。第二,回传时必须带tool_call_id,否则模型不知道这条结果对应哪个调用。第三,如果同时有多个tool_calls并行,必须逐条回传结果,保持 ID 对应关系。

3.3 面试加分细节:并行调用与参数校验

方案二还有一个隐藏考点:模型可能在一个回复里发起多个工具调用。比如用户问“北京和上海明天天气分别是多少”,模型可能同时输出两个 get_weather 调用、ID 各不相同。实现时最好循环执行所有调用,而不是只处理第一个。每次执行结果都以独立的 tool 角色消息回传。

另外,参数校验要做到执行层。永远不要信任模型输出的参数——它可能把数字传成字符串,把必填字段遗漏。我在生产环境里踩过这么个坑:模型在测天气时输出了一个不存在的城市名,函数查不到数据直接抛异常。后来我给每个工具加了一层 Pydantic 模型做入参校验,非法参数先报错给模型,模型看懂了会自己纠正,这个叫“错误回传让模型自我修正”。

方案二比方案一稳定得多,但它只解决了“单次调用”的问题。复杂任务需要模型连续调用多个工具,并根据前面的结果决定下一步动作。这就要进入第三种方案——完整的 Agent 循环。

4. 方案三:带自动恢复能力的 Agent 循环,生产级闭环

第三种方案才是我面试时的重头戏。面试官要的是“Tool Use”,但真正的 Agent 应用就是在这个方案上演进的。核心差别在于:方案二只管一次工具调用就结束,方案三则把“调用—回传—再决策”变成循环,直到模型给出最终回答。

4.1 用 while 循环把工具调用包起来

我先给一个最简的 Agent 循环实现。整体结构就是一个 while 循环,每次迭代都检查模型这次是不是需要调用工具,需要就执行、回传、继续下一轮;不需要就把内容作为最终答案返回。

import json def agent_loop(messages, tools, max_iterations=5): for i in range(max_iterations): resp = chat(messages, tools) if not resp.tool_calls: # 模型没有调用工具,说明思考完毕,直接返回 return resp.content # 有调用,逐个执行 for tc in resp.tool_calls: name = tc.function.name try: args = json.loads(tc.function.arguments if tc.function.arguments else "{}") result = TOOLS[name](**args) except Exception as e: # 关键:工具报错也要回传给模型,让它决定怎么处理 result = {"error": str(e)} messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False) }) # 继续下一轮,模型会看到所有工具结果 # 达到最大迭代限制,返回提示 return "已达最大迭代次数,请简化问题或补充信息。"

这个代码看起来很短,但已经覆盖了生产级 Agent 的所有核心要素:最大迭代限制、异常回传、多工具调用支持。面试时能写出这个结构,面试官基本就会开始追问细节了。

4.2 停止条件设计:怎么防止模型死循环

手写 Agent 循环最容易暴露的设计问题就是停止条件。如果没有终止机制,模型遇到矛盾信息可能会无限调用工具,烧掉几十次 API 费用。我在设计时一般设置三层保险:

第一层是模型自己说“结束”。即这一轮返回的tool_calls为空,或者finish_reason为正常的停止状态,循环自然退出。这是最理想的情况。

第二层是最大迭代轮数。我习惯设置为 5 轮,既能覆盖绝大多数多步工具任务,又不会让单次请求失控。遇到需要更多步的场景,可以提示模型“一次请求最多调用 5 次工具,请尽量合并查询”。

第三层是超时控制。在完整的工程实现里,我会给整个循环加一个总超时时间,比如 60 秒。超过时间直接抛出异常,由上层任务系统接管重试或降级。

4.3 错误恢复机制:让模型自己“擦屁股”

这是方案三最值得展开讲的部分。很多初学 Agent 的人把工具执行失败当成 fatal error 直接终止,但生产环境里,错误信息本身就是非常有价值的上下文。你把错误信息回传给模型,模型通常能自己调整参数或更换策略。

举个我实际遇到的例子:查天气时传的城市名是英文 “beijing”,但我写的天气查询工具只认中文或城市编码,返回了 “未知城市”。如果不回传错误,用户就会得到一句“抱歉我查不到”。但我在 tool 回传里带上了{"error": "未知城市,请用中文城市名重试"},模型下一轮自动改成“北京”,正常拿到结果。

这个“失败—回传—重试”的自我纠错模式,是人机交互和 Agent 交互最大的思维差异。在服务端代码里,错误不是用来异常抛出的,而是作为上下文喂回给模型的。

4.4 生产级还要补什么:鉴权、日志与并发控制

面试写到方案三,我通常会顺带提两句生产化改造,哪怕不写完整代码,也让面试官知道你见过真实场景。改造点主要有四个。

第一是工具注册表加元数据。每个工具不该只是一个函数,而应该有超时时间、权限级别、调用频率限制。比如某个工具只能由高权限用户触发,这个判断要在执行层拦截,不能指望模型自觉。

第二是完整日志追踪。每轮循环的输入输出、工具名、参数、结果耗时都要记录。出问题时靠日志还原整个决策链路。我在项目里把这套日志叫做“思考轨迹”,查线上事故的时候价值极大。

第三是并发控制。多用户同时调用同一个工具,要在服务端做限流和队列,防止外部接口被打爆。

第四是成本控制。每次循环都是一次模型调用,都要计费。上线前根据任务复杂度调整 max_iterations,避免“能用但太贵”。

5. 三种方案放在一起:怎么选、怎么跟面试官聊

三种方案都写完了,面试官大概率会问:你觉着哪个更好?这时候最忌讳的回答是“方案三最好”,因为脱离场景谈优劣没有意义。我把它们的核心差异整理一下,你现场可以直接用。

维度方案一:正则解析方案二:Function Calling方案三:Agent 循环
协议来源提示词约定模型原生结构化输出基于方案二做循环
依赖条件任何文本模型需支持工具调用接口需支持工具调用接口
稳定性一般,靠提示词调教高,结构约束强高,但结构更复杂
多步能力不支持单步,需自行扩展原生支持循环决策
错误处理解析失败直接放弃可回传错误,但只一轮支持自我纠错与重试
适用场景低成本演示、老模型接入普通工具问答复杂多步任务、Agent 应用

面试官想听的答案不是“哪个好”,而是你“基于什么条件做选型”。我的口头禅是:没有最好的方案,只有当前场景下的最优选。

5.1 一条完整的回答路径:从分层到选型

如果现场时间宽裕,我建议按三步来组织你的回答。

第一步,先给全貌。Tool Use 的实现本质上分协议层、执行层、循环层。我这三种方案正好是每一层不同成熟度的组合。

第二步,讲边缘情况。方案一适合模型不支持结构化输出时兜底;方案二适合单轮工具问答;方案三适合真正的 Agent 工作流。选型的判断标准只有一个:你的任务需不需要多轮决策。需要就得上方案三,不需要就用方案二,别为了炫技过度设计。

第三步,说改进方向。我会主动提一句:如果要把方案三真正部署上线,还要补监控、鉴权、成本控制这些工程化组件。这样显得你不只懂代码,还懂落地。

5.2 面试官常用的追问方向

据我观察,围绕这个题的高频追问不出下面这几类。

第一类是“循环怎么终止”。听到这个问题要开心,因为说明你写到了循坏方案。直接答三层保险:模型自主结束、最大轮数限制、超时熔断。

第二类是“工具返回出错怎么办”。这个我已经写进代码了,把错误作为 tool 消息回传。当场可以补充一个“模型自我纠错”的例子增强说服力。

第三类是“多个工具并行调用怎么处理”。答循环遍历所有 tool_calls,每个结果回传时带各自的 tool_call_id。

第四类是“如何防止模型注入恶意参数”。这个要郑重回答:工具执行层必须做白名单校验、参数类型校验和权限校验,永远不要直接执行模型传进来的代码。我在上面的calculate工具里特意限制了eval的内置函数,只允许纯表达式计算,这就是安全边界的考虑。

6. 实战踩坑与排查技巧实录

三种方案在面试里写得再漂亮,真正跑起来还是会遇到各种问题。我把平时调 Tool Use 时最容易翻车的几个坑集中拉一遍,附带排查思路,你可以直接当速查表用。

6.1 JSON 解析失败:模型永远不按格式出牌

症状:方案一场景下,正则抠出来的 JSON 块 json.loads 直接报错,有时多一个逗号,有时键名带了双引号转义符。处理思路是别挣扎,让解析器更宽容一点。我用过两个技巧:一是把正则匹配从“匹配 JSON”改成“匹配三引号代码块”,让模型输出更规范;二是解析失败时模板化重试一次,提示模型“上次输出格式有误,请重新输出严格的 JSON”。

不过规律是:这些技巧治标不治本。只要模型不支持结构化输出,格式稳定性永远靠运气。所以做工程选型时,我通常把方案一当作降级备用,而不是主线。

6.2 参数幻觉:模型硬编了一个不存在的城市

症状:工具定义里明确写了 city 参数接受城市编码,模型却输出了城市中文名,查询接口返回空。这种情况处理最稳的办法是参数校验环节把错误包装成合法结果回传给模型,让它自己纠正。我在某些场景还会给工具函数加上同义词映射,比如把常见的英文地名转成中文编码,模型出错概率明显降低。

6.3 上下文越搅越乱:工具结果太长,模型开始胡说八道

症状:某工具返回了一大段日志,回传后模型下一轮回答开始引用无关内容,甚至编造日志里不存在的字段。原因很简单,上下文长度超了模型的有效注意力窗口。

解决办法之一是对工具结果做截断,只保留关键字段;办法之二是让工具本身支持参数化输出,比如加一个limit参数,只返回最新 N 条;办法之三是顶层做摘要,用一次模型调用把长结果压缩成两三句话,再喂回主循环。日志查询、日报生成这类应用里,我用第三种办法最多,压缩后模型决策准确率明显回升。

6.4 模型不上路:明明该调用工具,它非要硬答

症状:用户问“现在上海气温怎么样”,模型直接回答“上海位于中国东部,气候温和”之类正确的废话。常见原因有两个:工具定义里的 description 写得太抽象,或者提示词没有强约束“当需要实时数据时必须调用工具”。我在部署阶段会在 system prompt 里加一条硬规则:凡是涉及天气、股票、新闻等动态信息的问题,必须先调用工具,再基于工具结果回答。加上这条规则之后,工具触发率能从五成拉到九成以上。

6.5 死循环:模型反复调用同一个工具,结果没变化

症状:Agent 循环里模型多次调用同一个查询工具拿到相同结果,然后继续调用,跟复读机一样。除了设置最大迭代轮数,我还会做一层“结果缓存去重”:如果某一轮工具参数和上一轮完全相同,直接把上次结果原样返回,并追加一条“你已经查过这个数据了,请基于现有信息继续”。这个技巧能在不改模型的情况下显著减少无效循环,用户侧感知也更好。

6.6 一个快速排查清单

把上面的坑汇总成一张图,线上排查时可以按这个顺序过一遍:先确认有没有“该调用工具却硬答”,再确认“调用的工具名和参数是否合法”,再确认“工具结果是否正确回传并且和 tool_call_id 对应”,最后确认“循环有没有终止”。绝大多数问题都出在第二和第三环节,不是模型不行,是回传链路上丢了信息。

收尾:我的几点体会

面试这个题之后,我自己又把三种方案在本地跑了好几轮,有些体会想多说两句。

第一,Tool Use 的复杂度永远藏在细节里。表面上看就是“模型输出一个 JSON,程序执行一下”,但真正稳定运行需要协议设计、参数校验、错误回传、循环终止这一整套机制。手写一遍,你对“为什么 Agent 框架那么复杂”的理解会深很多。

第二,选方案的本质是选“你把多少控制权交给模型”。方案一里所有格式靠提示词管,控制权在开发者;方案二里格式靠模型原生能力管,控制权在模型;方案三里连“下一步做什么”都交给模型决策。控制权越多,系统越灵活,但也越不可控。生产环境一定是混合的——常规路径走方案二,复杂分支才进方案三的循环,简单降级走方案一。

第三,面试时手写代码不是目的,展示“边界感”才是。你能写出来工具调用,说明会 API;你能说清楚每种方案的局限和适用前提,说明在真实项目里踩过坑。这个分寸感,比代码本身更能打动面试官。

这几种方案绝大多数场景下是够用的。如果后面遇到特别复杂的需求——比如工具数量超过几十个、工具之间还有依赖关系——那会涉及更重的语义路由和任务编排,就是另一篇文章要聊的话题了。

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

小白程序员转行Agent开发,抓住2026年高薪就业窗口期!

2026年Agent开发岗位需求激增,薪资高,竞争小,是程序员转行的好时机。文章分析了岗位爆发、人才供给不足、新兴岗位认知差等因素,建议程序员学习Agent开发。 国庆假期一过,2026 年就只剩最后三个月了。 有人忙着收心上班…

作者头像 李华
网站建设 2026/10/12 4:50:38

Agent记忆技术演进,从“结构化笔记本“到认知系统

本文探讨了AI Agent记忆技术的发展历程,从第一代的向量记忆(如LangChain Memory)到第二代的结构化记忆(如MemGPT/Letta和Graphiti),再到第三代的记忆即基础设施(如Mem0 Cloud)。文章…

作者头像 李华
网站建设 2026/10/12 4:50:11

JavaWeb图书馆管理系统源码实战:技术选型、借阅逻辑与避坑指南

简介:在JavaWeb开发中,图书馆管理系统是经典的综合实战项目,涵盖前端展示、业务逻辑与数据持久化等关键环节。理解Servlet/JSP或Spring Boot这类技术栈的协作原理,是构建稳定系统的前提。通过合理的分层架构与数据库设计&#xff…

作者头像 李华
网站建设 2026/10/12 4:46:06

开源算力地基:大模型多机多卡训练工程方案解析

有人看到“开源”这个词,第一反应往往是“又放出个模型权重”或者“聊天机器人换了个新皮”。但真正成天跟大模型训练打交道的人,看到“开源”两个字时,注意力的落点完全相反:我们更关心的是,到底有没有一套能让人觉得…

作者头像 李华