手写 Tool Use 全复盘:从提示词正则到原生 Function Calling 的三种实现方案

发布时间:2026/10/22 10:13:45
手写 Tool Use 全复盘:从提示词正则到原生 Function Calling 的三种实现方案
前几天面试面试官突然甩了一道题让我手写一个 Tool Use。说实话第一反应不是紧张而是觉得这道题特别会挑人——背过 API 示例的人能说出函数调用就是给模型传 tools 参数但真让你从 0 写出来你会发现要处理的全是协议细节、边界情况和小概率失败。Tool Use工具调用这两年几乎是 Agent 开发的底座技能它的核心价值一句话就能讲完让只会生成文本的大模型能够决定去调用外部函数并把结果带回来继续推理。这篇文章我会把当天现场写的 3 种方案完整复盘一遍从最朴素的提示词加正则提取到结构化输出约束再到原生工具调用加代理循环把每种方案的思路、代码、局限和适用场景都摊开讲。不管你是准备 Agent 方向面试还是要自己从零搭一套工具调用模块这套从土到讲究的演进路线都值得过一遍。1. Tool Use 到底在解决什么问题1.1 模型是参谋代码才是手脚很多教程把 Tool Use 解释成让模型能联网、能查数据库、能操作 API这个说法没错但只说了一半。Tool Use 真正解决的是两件事第一大模型的知识和上下文有边界训练完成那一刻它的世界就冻结了它不知道今天的实时天气、不知道某个服务的当前状态更无法替用户执行下单这种操作第二模型本身不是执行者它是一个意图生成器。它能用自然语言表达我想查一下订单状态但真正发起查询、解析响应、把结果整理好再展示给用户的只能是你写的代码。所以一个最朴素的类比是模型像一个只能动嘴的参谋它知道该做什么、该调什么工具、该用什么参数但没有手没有脚你的代码是手脚负责把参谋的指令变成真实动作。Tool Use 就是把这两者连接起来的那套流程和协议。想明白这一点后面所有方案都很好理解了——你做的所有事情无非就是三件事让模型知道有什么工具可用把模型的调用意图解析出来然后在你的代码里安全地执行。1.2 一次完整工具调用的七个环节把工具调用的完整闭环拆开看一共是 7 个环节定义工具 Schema给每个工具写清楚名称、功能描述、参数结构。把 Schema 注入消息让模型知道当前任务里有哪些工具可以选。模型产出调用意图模型在回复中返回一个包含工具名和参数的结构化结果。解析与校验检查工具名是否在白名单里、参数是否合法完整。执行工具真正去调用接口、查询数据库、发送指令。结果回填把工具的返回结果以观察消息的形式交回给模型。循环直到结束模型基于观察结果继续推理或输出最终答案。面试让手写 Tool Use基本上就是看你能不能把这 7 步的每一环都落到代码里而不是背一个接口调用示例。尤其第 4 步和第 6 步是大多数人容易漏掉的——很多人在演示里只写了模型返回一个函数名我执行一下完事完全没考虑解析失败怎么办、工具异常怎么办、多轮调用怎么回填这就是为什么同样的题有人能拿方案讲一刻钟有人两分钟就讲完了。1.3 面试官到底在考察什么在这道题上面试官真正想看的不是你记得多少 API 参数名而是三件事第一你有没有系统的建模思维。工具调用不是一个接口、一次调用而是一个有状态的循环涉及消息历史的维护、回合数的控制、上下文的清理。第二你有没有失败处理的肌肉记忆。模型输出永远是概率性的它可能把参数名写错、可能把 JSON 截断、可能调用了不存在的工具面对这些情况你的程序能不能优雅恢复这直接反映工程经验。第三你有没有安全边界意识。工具执行意味着你的代码会被外部输入间接驱动如果你不假思索地把用户输入拼进命令去执行那这个功能上线就是事故现场。我在现场给出的 3 种方案恰好就是沿着能力从低到高、可靠性从弱到强的路线走的每一种方案都是对前一种的补强。2. 方案一提示词注入 正则提取最朴素的原型2.1 思路把工具说明写进提示词剩下交给解析第一种方案最直白也是理解 Tool Use 本质的最佳切入点。思路就是把工具的名称、描述、参数结构用固定格式写进 System Prompt然后要求模型当需要调用工具时只输出一个 JSON 对象。代码这边做的事情只有两件从模型回复里把 JSON 抠出来然后去执行。这个方案没有任何取巧完全依赖模型的指令跟随能力和文本生成规律但它的好处也很明显不要求模型有原生工具调用能力任何能输出文字的模型都能跑。所以它特别适合三类场景快速验证想法的 PoC、内部小工具、或者你手上的模型确实不支持函数调用协议时兜底用。2.2 最小实现完整代码与解析先看工具描述部分我一般把每个工具都整理成一个字典后续几种方案都能复用TOOL_DESCRIPTIONS [ { name: get_weather, description: 查询某城市的实时天气返回温度、天气状况和风速, parameters: { type: object, properties: { city: {type: string, description: 城市名称} }, required: [city] } }, { name: get_stock_price, description: 查询指定股票的最新成交价格, parameters: { type: object, properties: { symbol: {type: string, description: 股票代码} }, required: [symbol] } }, ] def build_tool_prompt(): lines [ 你是一个会调用工具的助手。需要调用工具时只输出一个 JSON格式如下, {name: 工具名, arguments: {参数对象}}, 可用工具, ] for t in TOOL_DESCRIPTIONS: desc t[description] params json.dumps(t[parameters], ensure_asciiFalse) lines.append(f- {t[name]}: {desc}参数定义: {params}) return \n.join(lines)解析这一步是最容易出问题的。实际运行中模型返回的内容往往不是干净的 JSON最常见的两种情况是把 JSON 包在 markdown 的 json 代码块里以及在 JSON 前后夹带了几句解释。所以解析不能只做一次简单的 json.loads要做一个容错提取def extract_robust(text): # 先把代码块围栏去掉 text re.sub(r(?:json)?, , text).strip() # 取第一个 { 到最后一个 } 之间的内容兼容前后有废话 start, end text.find({), text.rfind(}) if start -1 or end -1: return None try: return json.loads(text[start:end 1]) except json.JSONDecodeError: return None接下来就是主循环了。我当初写的时候没有只做一次调用就结束而是允许模型在拿到工具结果后继续最多循环 3 轮def tool_use_naive(user_input: str) - str: messages [ {role: system, content: build_tool_prompt()}, {role: user, content: user_input}, ] max_rounds 3 for _ in range(max_rounds): reply llm_text(messages, temperature0) # 工具调用场景温度一定要低 call extract_robust(reply) if call is None: return reply # 没有工具调用意图直接当最终答案返回 result execute_tool(call[name], call[arguments]) messages.append({role: assistant, content: reply}) messages.append({role: user, content: f工具返回{result}\n请基于结果继续推理并回答。}) return messages[-1][content]这里有个容易忽略的细节我把模型上一轮的输出和后端返回结果都追加到 messages 里让模型能感知对话历史。很多人做简单实现时只把工具结果拼成一句话塞进下一轮短期能跑但遇到多工具连续调用时模型会丢失自己之前说过的上下文很快绕晕。2.3 这个方案的坑与边界方案一属于能用但你得做好它随时翻车的阶段。我实际踩过的坑有三个第一模型不按格式输出。你明明说了只输出 JSON它偏要先来一段好的我来帮您查询此时 extract_robust 虽然能把 JSON 抠出来但那些废话会污染下一轮的消息历史。我的对策是约定一个特殊 token 表示不需要调用工具比如让模型输出一个固定的 NO_TOOL 字符串解析时优先处理这种情况而不是把非 JSON 回复一律当成最终答案。第二JSON 参数是嵌套结构时朴素正则容易误判。上面 extract_robust 用的是第一个 { 到最后一个 }对嵌套 JSON 是安全的但如果你图省事用了匹配最内层花括号的正则遇到 arguments 里面还有对象时就会拆出碎片。建议直接用起止括号截取别用贪婪匹配。第三工具调用的成功率完全依赖模型。模型能力越强越听话成功率越高模型能力弱时你会看到它把参数名改了、把枚举值写错、甚至自己发明一个不存在的工具名。这就是为什么我坚持在 System Prompt 里把参数定义原文用 json.dumps 打出来——让模型照着抄而不是靠它理解后转述。3. 方案二结构化输出约束 字段级校验更可靠的手写实现3.1 思路升级从看运气到给模型一条硬轨道方案一最大的问题在于模型输出格式没有被强约束成功率靠的是提示词威慑。方案二的思路转变很关键把允许模型自由发挥再解析改成让输出结构变成一个可验证的契约。具体做法是定义一层输出契约Output Contract它本身也是一个 JSON Schema但约束的不是工具参数而是模型这次回复的整个结构。比如我让模型必须返回一个包含 thought 和 calls 两个字段的对象。如果模型服务端支持结构化输出或 JSON Mode就直接把这份契约传给服务端让解码阶段就保证格式正确如果不支持也可以把契约写进提示词配合字段级校验兜底。这背后的道理值得多说一句模型生成文本本质是逐个 token 采样如果有解码层面的约束让它只能在合法结构里生成那错误率会降一个数量级。市面上主流模型服务最近都把这类能力包装成了 JSON Mode 或结构化输出但底层原理就是约束解码。你自己手写的时候如果正好用的模型支持这个能力一定要用这是性价比最高的可靠性提升手段。3.2 输出契约 字段级校验的代码结构定义输出契约OUTPUT_CONTRACT { type: object, properties: { thought: { type: string, description: 解释这一步为什么要调用工具或说明不需要调用工具的原因 }, calls: { type: array, items: { type: object, properties: { name: {type: string, description: 工具名必须是可用列表之一}, arguments: {type: object, description: 工具参数对象} }, required: [name, arguments] } } }, required: [thought, calls] }这层包裹的价值在于它强迫模型先给出推理依据再给调用计划。我见过很多实现只让模型输出工具调用结果模型在需要回答根本不需要工具的问题时不知所措或者连续调用多轮工具却不说为什么。加了 thought 字段后模型的每一步都有迹可循调试时你能直接看到它的决策链路。执行和校验那边我把工具注册表做成一个统一结构并且加入了字段级校验TOOL_REGISTRY {} def register_tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] {func: func, description: description, parameters: parameters} return func return decorator def check_args(schema, args): 对参数做基本类型和必填项检查不依赖外部库 props schema.get(properties, {}) required schema.get(required, []) for key in required: if key not in args: raise ValueError(f缺少必填参数: {key}) for key, value in args.items(): if key in props: expected props[key].get(type) if expected string and not isinstance(value, str): raise ValueError(f参数 {key} 应为字符串) if expected number and not isinstance(value, (int, float)): raise ValueError(f参数 {key} 应为数字) return True def validate_and_execute(parsed): observations [] if not isinstance(parsed.get(calls), list): return [错误calls 字段必须是数组] for i, call in enumerate(parsed[calls]): name call.get(name) args call.get(arguments) or {} if name not in TOOL_REGISTRY: observations.append(f调用 {i} 失败未知工具 {name}请从可用列表中选择) continue try: check_args(TOOL_REGISTRY[name][parameters], args) result TOOL_REGISTRY[name][func](**args) observations.append(f调用 {i} 成功{result}) except Exception as exc: observations.append(f调用 {i} 失败{exc}) return observations这段代码里面工具名白名单校验和参数类型校验是最核心的两道防线。工具名不在注册表里绝不能执行参数类型不对也绝不能执行。你或许会觉得这是常识但真正上线的时候模型偶尔会生成一个合法 JSON 却塞进非法参数比如一个接受整数枚举的工具被传了字符串这时候如果没有字段级校验你的后端函数就会收到一堆莫名其妙的异常。3.3 让错误观察参与自纠正方案二比方案一强的地方不只是解析更稳而是它把执行失败当成正常事件来处理。注意上面 validate_and_execute 返回的 observations 里失败和成功都被统一转换成了观察文本这些观察文本会原样回填给模型def tool_use_structured(user_input: str) - str: messages build_messages(user_input, output_contractOUTPUT_CONTRACT) for _ in range(4): reply llm_text(messages, temperature0, response_formatOUTPUT_CONTRACT) parsed extract_robust(reply) if not parsed or not parsed.get(calls): return parsed.get(thought, reply) observations validate_and_execute(parsed) messages.append({role: assistant, content: reply}) messages.append({role: user, content: \n.join(observations)}) return messages[-1][content]当你把调用失败未知工具 xxx请从可用列表中选择这类信息回传给模型时模型下一轮很可能会自我纠正老老实实选一个合法工具。这个自纠正机制在方案一的朴素循环里也存在但方案二的失败信息更结构化、更精确所以纠正成功率明显更高。我在实际测试中观察到同一个中等能力的模型方案一的工具选择准确率大概在七成加上契约约束和错误回传之后能提高到九成上下。4. 方案三原生工具调用 执行器 Agent 循环生产级完整闭环4.1 原生 Function Calling 是借力不是甩锅前两种方案本质上都是绕过原生能力、自己造轮子。但如果你使用的模型服务本身支持原生工具调用Function Calling那第三种的正确做法是直接使用它。原生工具调用的最大优势是服务端在解码阶段就做了约束模型输出的 tool_calls 是结构化的、可靠的 JSON而不是让你从自然语言里碰运气抠。但这里有一个常见的误区很多人一听说原生支持就觉得 Tool Use 不用自己写了。实际上原生工具调用只解决模型产出结构化调用意图这一个环节剩下的解析、校验、执行、结果回填、循环控制、上下文管理全部还得你自己写。面试的时候如果能把这条边界讲清楚会比单纯说我调了一下 tools 参数高出一个段位。4.2 主循环代码与消息协议细节一个最小但完整的原生工具调用主循环长这样def run_agent(user_input: str, max_steps: int 8) - str: messages [ {role: system, content: 你是一个能调用工具解决任务的智能助手。}, {role: user, content: user_input}, ] tools [TOOL_REGISTRY[name][schema] for name in TOOL_REGISTRY] for _ in range(max_steps): resp llm_chat(messagesmessages, toolstools, tool_choiceauto) msg resp.choices[0].message if not msg.tool_calls: return msg.content # 关键点 1assistant 消息必须原样回传且带上 tool_calls 字段 messages.append({ role: assistant, content: msg.content or , tool_calls: [ { id: call.id, type: function, function: {name: call.function.name, arguments: call.function.arguments}, } for call in msg.tool_calls ], }) # 关键点 2并行调用时每个工具结果都要用 tool_call_id 对齐 for call in msg.tool_calls: try: args json.loads(call.function.arguments) except json.JSONDecodeError as exc: args {} result_text f参数解析失败: {exc} else: result_text execute_tool(call.function.name, args) messages.append({ role: tool, tool_call_id: call.id, content: str(result_text), }) return 已达最大推理步数任务未完成。这段代码里有几个协议细节是只背 API 的人永远不会注意到的第一assistant 消息回传时必须把它原先输出的 tool_calls 原封不动带上。很多模型服务要求消息历史里assistant 的那条消息和随后一组 tool 结果消息必须完整成对出现你要是把 tool_calls 字段丢了服务端会报错或者模型会混乱。第二tool 消息的 tool_call_id 必须对上。尤其是模型一次输出多个并行工具调用时比如同时查天气和查股票每个执行结果要挂到它对应的 tool_call_id 上不能打乱顺序。我见过有人把并行结果合并成一条 tool 消息塞回去结果模型根本分不清哪个结果对应哪个调用。第三execute_tool 内部仍然要保留注册表白名单和参数校验。这一步和方案二完全一致只是解析来源从自己抠 JSON变成了服务端给结构化字段但安全防线不能因此省掉。4.3 生产化三件套自纠正、上下文压缩与安全护栏方案三写到这里只是一个能跑的循环真正上生产还要补三块自纠正方面我在 execute_tool 里统一包装了异常让所有失败都以文本形式进入观察消息。模型拿到这个工具调用失败了原因是 xxx后可以自动决定换个工具或修正参数重试。我在一个实际项目中还专门注册了一个叫 report_failure 的调试工具用来把多次失败后的最终状态汇报给用户避免 Agent 陷入无限重试。上下文压缩方面每轮工具调用的 results 都会被追加进消息历史一旦工具返回长文本几轮下来 token 消耗会非常快。我的习惯是保留 System 提示和最初的用户问题把中间的 thought 过程保留但对工具结果做截断只保留前 N 个字符尤其是那些工具本身返回 JSON 大对象的先缩成摘要再回填。这样既保留推理线索又控制成本。安全护栏方面有三条铁律。一是工具白名单只允许执行注册表里的函数绝不能动态执行模型生成的代码字符串二是参数校验在接线进真实函数前必须过一遍类型和取值范围检查三是对危险操作做二次确认比如支付、删除、发送消息这类操作设计成模型先输出意图再由用户确认后才真正执行。这三条放在 Agent 面试里聊面试官通常会眼前一亮因为这说明你想的不是 demo而是线上事故。5. 三种方案横向对比与选型决策5.1 一张表看清差异把三种方案放在同一张表里对比各自的定位就很清楚了对比维度方案一提示词正则方案二结构化约束校验方案三原生调用Agent循环实现成本低两三个函数搞定中需要输出契约和校验逻辑偏高协议细节多依赖模型能力无特殊要求建议支持结构化输出/JSON Mode必须支持原生工具调用输出可靠性60% - 80%靠提示词撑85% - 95%契约校验双重保障90% - 99%解码层已有约束调试友好度差解析失败原因模糊中错误信息可回传自纠正好tool_call_id 全程可追踪多轮/并行支持需自己处理需自己处理协议原生支持适用场景PoC、临时脚本、弱模型兜底生产环境、自托管模型、对格式敏感的业务复杂 Agent、多工具协作、对稳定性要求高的业务从这张表能看出一个结论方案一适合先跑通方案二是可靠性的分水岭方案三是复杂度上去之后的必然选择。它们不是竞争关系而是同一件事在不同约束条件下的不同答案。5.2 我的选型习惯分享几个实际项目里的选型经验你可以直接抄作业。如果项目只是内部工具、demo 或者给非技术同事用的脚本我会直接上方案一省事、不依赖任何特殊能力翻车了大不了重来。如果模型是自托管的开源模型不支持原生的函数调用协议那方案二是最稳妥的选择因为结构化输出通常还能通过提示词勉强控住配合严格校验够用。如果用的是主流商用模型服务并且任务本身需要多轮推理、多个工具配合完成我毫不犹豫选方案三因为我只需要把协议细节写好可靠性由服务端的约束解码来兜底。还有一条个性化建议即使最终决定用方案三也建议先在本地把方案二完整写一遍。原因很简单方案二强迫你实现校验和错误回传这两块能力在方案三里同样需要只是换了数据来源。先把自己解析的苦吃过一遍你才会真正理解原生工具调用帮你省掉了哪些坑将来出了问题也能快速定位是协议问题、解析问题还是执行问题。5.3 怎么客观评估一个 Tool Use 模块的好坏面试官很可能会接着问你怎么知道你的实现是好的。我习惯用四层指标来评估第一层是工具选择准确率也就是模型在所有需要调用工具的轮次里选对工具的比例。第二层是参数提取成功率工具选对了但参数填错的错误率要单独统计。第三层是任务级完成率也就是一个包含多步工具调用的完整任务最终是否得到了用户满意的答案。第四层是成本指标包括平均每任务消耗的 token 数和调用轮数。我自己的做法是准备一个二三十条的小评测集覆盖单工具、多工具、需纠错、无需工具这四类情况每条都写清楚标准答案。跑模型时逐条记录算出一个基线分数。后续每次改提示词、调节点参数都用同一份评测集回归。这套方法在面试里也可以直接说它向面试官传递的信息是你的工作方式是有数据支撑的而不是靠感觉调参。6. 面试官追问清单从能写出来到能聊明白6.1 六个高频追问与我的回答框架写完之后面试官通常会抛出几个追问这些问题往往比手写代码更关键。我把常见的六个列出来附上我认为比较有深度的回答方向。第一个问题如果模型在 arguments 里传了 schema 里不存在的参数怎么办我的回答是两道防线第一道是注册表里的参数校验不存在的 key 直接忽略或报错第二道是把报错信息回传给模型让它基于错误观察重新生成参数。核心思想是不执行非法调用、给模型纠错机会。第二个问题多个工具都能满足用户需求怎么让模型选对我会把每个工具 description 写得足够具体尤其是把什么时候该用、什么时候不该用直接写进描述里同时提供一两个典型 few-shot 示例。很多工具被选错不是模型笨是描述写得太模糊。第三个问题工具执行报错了要不要把原始错误信息直接返回给模型我的建议是返回脱敏后的摘要错误比如把异常类型和简短的错误消息拼进去但要走白名单和统一格式化避免把堆栈等敏感信息暴露给模型。第四个问题怎么防止模型把用户输入的恶意内容当成工具参数执行这是一个安全底线问题回答要强调三层白名单决定了能调什么参数校验决定了能不能调操作确认决定了危险操作要不要真正执行。尤其是涉及执行命令、发请求、改数据之类的工具必须做二次确认。第五个问题连续多轮工具调用把上下文撑爆了怎么办我会说上下文压缩策略系统提示和用户原始问题常驻中间工具结果截断或摘要化超过阈值后做滑动窗口丢弃最早的历史。还可以设计一个 summarizer 工具在上下文接近上限时自动总结历史再继续。第六个问题怎么评估工具调用模块的准确率直接对应 5.3 小节的四层指标来讲即可。最好再补一句我准备了离线评测集每次改动都会回归这句在面试里是加分项。6.2 三处平时不太会写进文档的加分细节现场写代码容易只顾着主流程我复盘后觉得有三处细节如果当时能主动展示出来会更完整。第一处是给工具调用加元数据。我在执行结果回填时除了工具返回的业务内容还会附带一个形如[statusok, cost_ms120]的元信息后缀。别小看这个动作线上排查问题时你能一眼看出哪次调用超时、哪次调用失败重试过而不是靠肉眼看文本里的报错。第二处是为不需要工具的情况预留显式路径。很多实现里模型只要没输出工具调用就直接把回复当答案。但实际问题可能是模型该调工具却忘了调。我习惯在 System Prompt 里明确写一句如果可以通过已知信息回答请直接回答并说明你未调用工具的原因这样即使用户问题刁钻模型也不会为了调工具而调工具。第三处是区分确定性工具和生成性工具。查询天气、查库存这类结果是确定性的可以放心执行但生成一段文案、总结一篇文档这类本身也是模型生成的工具执行结果还要过一层质量判断避免 Agent 自己给自己制造噪音结果。这个意识在复杂 Agent 里特别重要。6.3 最后说一点我自己实践下来的体会手写 Tool Use 这件事最有价值的不是那几行代码而是你对模型输出是不可靠的这个事实有多深的敬畏。第一版方案跑通的时候我觉得 Tool Use 不过如此等我真正把错误回传、参数校验、上下文压缩都加上去之后我才意识到一个合格的 Tool Use 模块七成代码都在处理模型没按你预期干活的情况。如果你也想在面试前练手我的建议是别去背框架的源码静下心把方案二从零写一遍再对着方案三把消息协议捋一遍这个过程比看十篇教程都管用。这 3 种方案本身没有高下之分你能根据场景选对、并且说清楚为什么选它这才是手写这道题真正想考察的东西。