
前几天去一家公司面试聊到一半面试官忽然把电脑转过来说先不接你已经调过的那套框架咱们手写一个 Tool Use你会怎么设计我愣了一下。这题表面是考 API 用法其实是在考底层理解。Tool Use工具调用现在已经是智能体应用的地基网上到处都是“三步接入 Function Calling”的教程可真让你从头实现一套要考虑模型侧怎么声明工具、请求侧怎么传消息、执行侧怎么安全落地、结果侧怎么往回拼一环扣一环。好在平时我给公司内部做私有化插件系统时正好从最简单的协议一路做到可编排的 Agent所以现场我顺着复杂度从低到高给了三套方案基于正则触发的自研协议、基于结构化 JSON 的 Function Calling、基于 ReAct 循环的 Agent 框架。这篇文章把它们完整整理出来想去面试的同学可以照着理解想给内部系统做轻量接入的也可以直接参考。1. 整体设计先想清楚 Tool Use 的闭环再谈方案1.1 Tool Use 到底在解决什么问题面试官问“手写 Tool Use”本质上不是让你背某个 SDK 的用法而是想确认你是否理解大模型应用里的一个核心闭环用户说出自然语言需求 - 模型判断需要外部数据或动作 - 模型以某种格式表达“我要调用哪个工具、参数是什么” - 我们的程序去执行这个工具 - 把执行结果交还给模型 - 模型把结果整理成用户能听懂的话。这个闭环里藏着四个必须想清楚的问题模型怎么知道系统里有哪些工具对应的是工具注册表Tool Registry简单说就是一份工具名到真实函数的映射表。模型怎么表达“我要调用”对应的是调用协议Calling Protocol这是整个设计的灵魂。谁去执行这个调用对应的是执行器Executor它要做参数解析、函数执行、异常捕获。执行结果怎么让模型看到对应的是消息回填Message History你要把结果拼进对话上下文的正确位置。很多人一上来就研究调用协议写了半天才发现工具注册表没设计好、执行器没有异常兜底结果模型一个参数传错整个会话就断了。所以我建议你手写之前先把这个闭环画在纸上再决定每一层用什么方案。1.2 复杂度从低到高的三条路线我现场没有只给一套答案而是给了一条演进路径方案一正则触发式自研协议。模型输出一行固定格式文本我们用正则把它解析成工具名和参数再映射到本地函数执行。这是最底层的定制方案完全由我们自己控制格式依赖任何特定厂商的模型能力。方案二结构化 JSON 的 Function Calling。模型侧使用主流的函数声明协议在需要工具时返回一个结构化的调用对象我们执行后通过特殊的 tool 消息回填再次请求模型。这是目前生产环境最常见的做法。方案三ReAct 循环。模型不只调用一次工具而是循环执行“思考 - 行动 - 观察”逐步逼近目标。这是从单个工具调用走向 Agent 的关键一步。这三条路线不是互相替代的关系而是不同的复杂度应对不同的场景。正则方案适合轻量、可控、模型能力弱的环境JSON 方案适合标准平台和大多数业务ReAct 方案适合需要多步推理和动态编排的复杂任务。2. 方案一正则触发式自研协议最轻量的可用方案2.1 设计思路让模型说“人话协议”这个方案的核心思想是既然模型直接生成自然语言那我就规定一种特殊的“口令”当模型需要访问外部信息时它输出一行机器可解析的文本。比如tool_call get_weather(city北京)这行文本就是一个“协议”。我们不奢望模型能输出完美的 JSON而是让它在一个严格的模板里填空。这种方式很像早期聊天机器人里的命令式交互你现在去看很多小模型、私有化部署模型的产品仍然能看到这种设计。为什么放着好好的 JSON 不用要用这种原始方式因为现实场景里有很多约束你用的可能是能力较弱的小模型它生成复杂 JSON 很容易语法错误你可能不希望引入特定的模型服务 SDK只想靠提示词加一个轻量解析器你需要完全掌控输入输出不允许模型产生格式漂移。这种方案的优点是透明、可控、依赖少缺点是上限低。一旦任务复杂模型在长对话中很容易把格式写崩而且一次只能调用一个工具无法表达多个并行请求。2.2 最小实现工具注册表 正则解析器我先定义两个工具函数假装是业务里真实存在的import ast import re def get_weather(city: str): # 实际项目里这里应该调天气服务 return f{city} 当前天气晴23℃风力3级 def calculator(expr: str): # 仅允许数字和四则运算防止注入 allowed re.sub(r[^0-9\-*/(). ], , expr) # 这里我临时用 eval 演示生产环境建议使用专门的计算库 return str(eval(allowed, {__builtins__: {}}, {}))然后建立一个最简单的工具注册表TOOLS { get_weather: get_weather, calculator: calculator, }到这里你可能会问工具注册表不就是个字典吗其实它还可以继续扩展。生产级的注册表会保存每个函数的说明、参数信息、权限级别甚至执行时限。只不过在最简方案里一个字典已经足够工作。接下来是解析器这是方案一的核心TOOL_CALL_PATTERN re.compile( rtool_call\s*\s*(\w)\s*\(\s*(.*?)\s*\), re.S ) def parse_tool_calls(text: str): calls [] for match in TOOL_CALL_PATTERN.finditer(text): name match.group(1) raw_args match.group(2) or args {} if raw_args.strip(): # 方案一尝试解析成 keyvalue 形式 # 方案二整体当作 JSON 对象解析 try: args ast.literal_eval({ raw_args }) except Exception: try: args ast.literal_eval(raw_args) except Exception: args {} calls.append((name, args)) return calls这里有一个关键的坑参数解析绝对不能直接用eval()要用ast.literal_eval()。literal_eval只认字面量结构也就是说你只能写字符串、数字、字典、列表这些它不会执行任何表达式。一旦你放开eval一个恶意的city__import__(os).system(rm -rf /)就能让你吃大亏。这个原则在任何方案里都通用。系统提示词也必不可少你是一个能调用工具的助手。当用户需要天气信息或需要四则运算时按以下格式输出 tool_call 工具名(参数名参数值) 不要输出解释文本只要调用行。如果你已经拿到结果再直接回答用户。2.3 运行流程与局限复盘执行层的循环也很简单拿到用户输入后先把整段文本丢给模型然后拿模型输出跑一遍parse_tool_calls如果有匹配结果逐段执行本地函数把结果拼进下一轮的提示词再次调用模型。伪代码如下def run_with_protocol(user_input: str): messages [system_prompt, user_input] for _ in range(3): reply generate_text(messages) calls parse_tool_calls(reply) if not calls: return reply observations [] for name, args in calls: if name not in TOOLS: observations.append(f{name} 工具不存在) continue try: result TOOLS[name](**args) observations.append(f{name} 的结果为{result}) except Exception as e: observations.append(f{name} 执行失败{e}) messages.append(reply) messages.append(执行结果\n \n.join(observations)) return 达到最大轮数我实测下来这种方案有几个绕不开的问题格式漂移。模型在简单任务里很听话但对话一长它可能把tool_call写成Tool Call把引号丢了或者自己加了分析文字。解法是解析时尽量宽容加上re.S匹配换行并且如果一轮解析失败直接把错误信息回灌给模型让它重新输出。单次单工具。正则一次性也只能解析一串文字模型要想表达“先查北京再查上海”就得写两行。执行顺序是串行的无法并行。上下文膨胀。每轮工具结果都是纯文本拼接10 个工具跑下来prompt 里全是“Observation”模型注意力很容易被带偏。如果业务场景只是单个工具、固定触发语这个方案完全够用一旦出现“先做 A 再做 B”的连续任务就必须往上走。3. 方案二结构化 JSON Function Calling行业标准做法3.1 设计思路把决策交给模型服务方案二和方案一最大的区别在于模型服务本身承诺了“我会结构化输出”。你不再需要靠正则去猜而是在请求里附带一份工具声明列表模型服务在决策时直接返回一个组织好的调用对象。这里的核心并不是 JSON 本身而是这套消息协议。我把一次带工具调用理解成四个角色system系统提示词规定模型的整体角色user用户输入assistant模型上一条回复。如果模型决定调用工具这条回复里会携带tool_calls字段里面包含调用 id、函数名、JSON 字符串形式的参数tool工具执行结果。它必须绑定一个tool_call_id对应 assistant 消息中触发它的那个调用。为什么说它是行业标准因为它解决了一个关键问题模型需要环境信息时由模型服务保证格式正确。你要做的只是监听tool_calls字段执行工具回填tool消息再重新请求。拼接消息时一个角色都不能少否则模型会想不起自己刚才说过什么。3.2 手写最小请求与循环先定义工具声明这就是前面提到的“模型怎么知道有哪些工具”tool_defs [ { type: function, function: { name: get_weather, description: 查询某个城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如 北京 } }, required: [city] } } }, { type: function, function: { name: calculator, description: 计算四则运算表达式, parameters: { type: object, properties: { expr: { type: string, description: 数学表达式例如 12*3 } }, required: [expr] } } } ]这里的要点是函数说明越清晰模型选错工具的概率就越低。参数名、描述、必填项每一条都决定模型生成的参数质量。有些模型对description不敏感但对中文语义很敏感所以描述尽量带上“输入值从哪里来”的说明。然后写执行调度def generate(messages, tools): # 这是示意具体接口取决于你用的模型服务 return client.chat.completions.create( modelmodel_name, messagesmessages, toolstools, tool_choiceauto ) def run_function_calling(user_input: str): messages [ {role: system, content: 你是有工具调用能力的助手。}, {role: user, content: user_input}, ] response generate(messages, tool_defs) while True: msg response.choices[0].message # 模型没有要求调用工具说明可以返回最终回答 if not msg.tool_calls: return msg.content # 1. 先把 assistant 这条完整消息放回历史 messages.append(msg) # 2. 逐条执行工具调用 for call in msg.tool_calls: fn_name call.function.name fn_args json.loads(call.function.arguments or {}) try: result TOOLS[fn_name](**fn_args) except Exception as e: result f执行异常: {e} messages.append({ role: tool, tool_call_id: call.id, content: result, }) # 3. 带上工具结果再次请求模型 response generate(messages, tool_defs)这里我特意把消息回填的顺序写在了前面。有一次我在生产环境遇到一个诡异的问题模型总在重复上一轮工具调用排查后才发现 assistant 消息没回填。模型看到的上下文是从 user 直接跳到 tool它根本不知道工具是谁触发的只能重新猜一个。3.3 并发与参数异常这两个高频追问点面试官几乎一定会追问两个场景多个工具可以同时调用吗参数错了怎么办先回答第一个。方案二的tool_calls本身是数组天然支持并行调用。你可以在一个循环里用线程池去并发执行多个工具再把结果按序回填。要注意的是并发工具如果存在副作用必须自己判断执行顺序。比如“下单”和“查库存”同时执行库存结果回来之前单已经下了业务就乱了。我个人的经验是并行只给“无副作用、只读类”的工具用写操作必须串行。再回答第二个。模型返回的arguments大多是一个 JSON 字符串不是对象所以落地时一定要json.loads之后再传给函数。一旦json.loads抛错或者**fn_args时类型不匹配不能直接让程序崩溃。正确做法是把异常格式化成一个字符串通过tool消息回灌给模型让模型根据错误信息自己修正参数。这叫“错误信息也是一种观察结果”。方案二已经是大多数业务里能用到的稳定方案了。但它还有一层天花板模型虽然能调用工具也只是做“一回合决策”。遇到“查了天气 - 根据天气决定是否安排户外活动 - 再查活动场地”这种多步推理你就要写很多层循环条件非常痛苦。4. 方案三ReAct 循环把模型变成能自己编排的 Agent4.1 从“调用一次”到“循环思考”ReAct 这个名字由 Reasoning 和 Acting 组成核心思想是让模型在推理过程中交替输出“思考”和“行动”每一步行动的结果作为下一步思考的依据。它不是靠额外的编排代码而是完全靠提示词循环驱动的。在这一层我不再依赖模型服务的tool_calls字段而是反过来用纯文本协议为主JSON 参数为辅。为什么因为在多步推理场景里模型服务对“思考链工具调用交替出现”的支持能力并不统一反而一个宽松的文本模板能兼容更多模型。我给这套方案设计了一段系统提示词def build_agent_prompt(tools): tool_desc \n.join([ f- {name}: {func.__doc__ or 无描述} for name, func in TOOLS.items() ]) return f你是一个能自主调用工具的智能助手。 可用工具如下 {tool_desc} 当我需要外部信息或执行动作时请输出以下三行 Thought: 你对当前任务的思考 Action: 工具名 Action Input: {{参数名: 参数值}} 当你已经完成所有步骤后请输出 Final Answer: 给用户的最终回答 请严格遵守格式不要输出多余内容。这里我要求模型先写思考再选工具最后给参数。Thought这行文字并不会被程序执行但它给模型自己提供推理空间也方便调试时看清它的决策路径。4.2 极简 ReAct Loop 的落地代码接下来的循环代码是整个 Agent 的引擎import json import re def react_loop(task: str, max_steps: int 10): messages [{role: system, content: build_agent_prompt(TOOLS)}] messages.append({role: user, content: task}) for step in range(max_steps): response generate(messages) content response.choices[0].message.content # 判断任务是否结束 if Final Answer: in content: return content.split(Final Answer:, 1)[1].strip() # 解析 Action 和 Action Input action_match re.search(rAction:\s*(.?)\n, content) input_match re.search(rAction Input:\s*(\{.?\}), content, re.S) if not action_match or not input_match: messages.append({ role: user, content: 格式错误缺少 Action 或 Action Input请重新输出。 }) continue action action_match.group(1).strip() try: action_input json.loads(input_match.group(1).strip()) except json.JSONDecodeError as e: messages.append({ role: user, content: fAction Input 解析失败{e}请使用合法 JSON。 }) continue # 执行工具 if action not in TOOLS: observation f错误工具 {action} 不存在可选工具有 {list(TOOLS.keys())}。 else: try: observation str(TOOLS[action](**action_input)) except Exception as e: observation f执行异常: {e} # 把观察结果追加回对话进入下一轮思考 messages.append({ role: user, content: fObservation: {observation}\n请继续。 }) return 已达到最大步数任务未完成请拆分指令重试。这段代码实现的原理是“把模型输出切回输入”。它在循环里没有硬编码业务逻辑唯一的判断只是“模型是否说它做完了”。如果你去读主流 Agent 框架的源码会发现引擎部分和我这里差不多真正复杂的其实是提示词设计、工具定义、成本控制这些外围的事情。4.3 防失控设计轮数与成本缺一不可ReAct 方案最大的风险不是“跑不起来”而是“跑不停”。模型可能会循环调用同一个工具、反复给出相同的思考、或者在一个任务上花费掉你几十次模型调用的预算。我的防失控设计分三块最大步数限制。max_steps是最基本的保险我通常给内部任务设 8 到 12 次再高就说明提示词没有约束到位。异常回灌与断点重试。解析失败、工具不存在、参数错误这些情况都要像方案二一样反馈给模型而不是让循环静默退出。预算看板。每轮循环都算一次模型调用我在真实项目里会在循环内部累加 token 数和耗时当总成本超过阈值时直接打断把当前的思考链日志保存下来做人工分析。还有一个小技巧在无限循环场景里可以设定一个“无进展检测器”。如果连续三轮的Thought内容高度相似比如都出现了相同的短语说明模型陷入了原地打转这时应该主动终止并提示任务失败。这一步在实际效果上比单纯限制步数更稳。5. 三套方案怎么选以及面试官真正在考什么5.1 三个方案的能力对比表我整理了一张表格面试时可以直接对照着讲对比维度方案一正则协议方案二JSON Function Calling方案三ReAct 循环实现成本低几百行代码中依赖模型服务中高要处理思考链和循环模型要求任意文本模型需要原生工具调用能力需要较强指令跟随和推理能力单次工具调用支持支持且规范支持并行工具调用原生不支持支持但要控制副作用支持但顺序由模型自己决定多步任务编排需要人工拆解需要应用层写循环模型自行编排格式稳定性低容易漂移高模型服务兜底中依赖提示词约束可调试性高日志直观中消息链路清晰中思考链可读性强典型场景私有化部署、弱模型标准生产业务、平台接入复杂推理、动态任务我给出的选型口诀是固定功能用方案一标准问答带工具用方案二任务是“链式探索”用方案三。千万不要一上来就上 ReAct很多业务其实只需要方案二硬上反而在工程链路里多出许多不可控变量。5.2 面试官问手写 Tool Use 背后的三层意图那次面试结束后我复盘觉得“手写一个 Tool Use”这个题眼其实有三层深意第一层考你知不知道工具调用的完整流程。很多人能背出tools怎么传、tool_calls怎么解析但你把model_dump()这一步去掉让它自己跑一遍立刻露馅。整个闭环里消息历史的构建顺序才是真正的分水岭。第二层考你在没有现成框架的情况下怎么拆解一个系统。工具注册、协议设计、执行器、异常处理、上下文管理这些问题放在任何中间件里都成立。你回答时如果只是一口气把代码念完却没有讲清每一层为什么这么设计面试官很快就会打断。第三层考你对模型边界有认知。模型不知道自己调用的工具是否有权限不知道参数是否安全不知道结果是不是幂等的。你设计执行器时有没有拦截敏感工具有没有做沙箱授权有没有给文件操作加白名单这些问题才决定了你是“会用 API”还是“能独立设计接入层”。我当时回答时特意强调了一点Tool Use 的设计本质是“在模型能力与外部世界之间设立一个受控缓冲区”。模型负责决策我们负责控制决策的落地范围。5.3 再往上的扩展方向统一协议与工具路由方案二和方案三有一个共同的问题工具数量变多以后tool_defs里塞几十个函数描述模型就容易选错上下文也越变越长。如果面试官继续追问你可以抛出两个扩展方向工具检索与路由。给工具打标签和语义描述用户请求进来后先用一个轻量分类器选出一批候选工具再把这些候选工具的声明塞给模型。这个做法在中文效果上特别显著工具数量超过二十个时必上。统一协议层。当多个团队各自贡献工具时每个人写一套注册表显然是灾难。可以设计一个中心化注册中心工具方提供name、description、parameters、权限组四个字段应用层统一加载、统一鉴权、统一审计。这就是内部自建平台化 Tool Use 的核心。还有一个更前沿的方向将工具调用进一步拆成“工具发现”和“工具执行”两个阶段第一阶段由模型返回它觉得需要的工具名第二阶段由网关根据权限和策略拦截后再决定执行。这个设计思路能极大减少敏感操作风险我在做企业私有化部署时就把它放在架构图里重点讲解。6. 真实踩坑记录与排查技巧速查6.1 我实际踩过的五个大坑前几轮搭建 Tool Use 的时候我吃过不少亏最典型的五个问题值得写在这里。第一个是arguments不是字典。我最初处理方式很粗暴直接fn_args call.function.arguments就传给函数结果一跑就炸。后来查阅协议头文档才明白这个字段在 API 层经常被序列化成字符串并不是方便你的解析器用的。现在我的统一做法是拿到arguments先json.loads万一解析失败把原始字符串原文放回tool消息让模型自己重新给一次。第二个是 assistant 消息漏回填。正如前面说的执行完工具后如果不把模型带tool_calls的那条assistant消息完整放回上下文模型在下一次请求里就会“失忆”。我见过日志里模型反复输出同一个调用极大概率就是这个问题。第三个是工具结果太长把上下文占满。天气接口返回一个 200 行的 JSON模型一读就懵。经验做法是执行器在做结果回填之前先裁剪只保留最有价值的前几百个字符或者让工具函数本身返回摘要而不返回全量数据。工具的输出设计和工具输入设计同等重要。第四个是参数校验过于宽松。模型传了个city{北京: 1}literal_eval能通过但业务函数根本解析不了。我的解法是给执行器加一层“参数模式校验”用inspect.signature获取函数定义的参数名和类型提前做类型约束检查不匹配就不执行把错误回馈给模型重试。第五个是并发工具副作用。方案二并发调用很快乐但有个真实案例两个工具同时修改同一条配置第二个先执行成功第一个后执行时带着旧数据覆盖了它。所以我在执行器里维护了一张“写操作锁表”同一资源的写操作永远串行。6.2 高频问题与排查速查表现象排查顺序解法模型从来不触发工具调用检查tool_choice是否被设为none检查函数声明是否被正确传入检查系统提示词是否允许使用工具设为auto重新声明函数提示词里加上“如果需要外部信息请使用工具”模型频繁调用错误工具检查函数描述是否含糊检查是不是候选工具太多精细化描述补 few-shot 示例必要时上工具路由Action Input解析失败先看原始字符串确认是不是单引号或缺少括号再确认正则是否支持换行用json.loads前先尝试replace(, \)正则加re.S工具执行了但模型忽略结果检查tool消息是否带上了tool_call_id检查assistant消息是否回填严格按协议拼接消息不省略任何一条反馈错误信息后模型仍然重试原参数说明错误信息不够明确或者模型推理能力弱给模型一个“可参考示例”来回灌比如“上次传的是 XX正确的 city 字段应该是北京”上下文越来越长检查是否每轮都往历史里塞全量 observation改成只保留上一轮的最新观察结果或对历史做摘要6.3 更稳的调试技巧打开思维链日志我强烈建议在开发阶段打开模型的思维链输出。不管用方案二还是方案三把模型的每一轮原始输出、解析后的调用信息、工具返回结果全部按时间顺序落盘做成一份“一次会话的完整审计日志”。这个日志有四两拨千斤的排查价值。举例来说当模型开始循环调用同一个工具时你看审计日志就能立刻发现问题要么是观察结果里没有提供让它“停止”的信息要么是提示词缺少“如果结果已经满足需求请直接结束”的约束。一次添加这种约束可能省掉你一整周的折磨。审计日志还有一个妙用可以作为测试集。我平时会截取一套带工具调用的真实对话把它固化成回归用例每次改完解析器或发布新版本的提示词就跑一遍回归看有没有把原有对话打断。这是我最推荐的稳定性措施没有之一。另外强调一个生产环境细节敏感工具的调用必须有白名单权限控制像文件删除、数据库写入、发消息、下单这类操作不能只靠模型决策必须由执行器里的策略层做二次校验。既要做技术审计还要做业务审计这样才能放心把 Agent 交给真实用户。写在最后的一个体会那天面完这场坐在回去的车上我一直在想这东西看着不算花哨但要是没有平时造轮子的积累现场真不一定能讲得明白。手写 Tool Use 的价值不在于证明你写过代码而在于证明你理解“模型能力”和“真实世界”之间那道边界。面试官想看到的是你怎么在这道边界上建一层既有序又灵活的控制层。我个人最常用的组合现在是“方案二做基础工具接入 方案三做复杂任务编排”中间用一套工具注册表共用。如果你也是做智能体方向强烈建议从方案一先动手哪怕只是玩具项目也能帮你把闭环里每个环节变成肌肉记忆。踩过几次坑之后再回头去看成熟框架你会发现它们底层的原理就是这么一点一点被抽象出来的。