那轮面试聊到一半面试官停下来指着我简历里的 Agent 项目问了句“你平时写 Agent 都在用现成的框架如果现在让你不依赖任何框架纯手写一个 Tool Use 出来你会怎么写”我愣了两秒不是因为不会而是因为这个问题太“去皮见骨”了。Tool Use 这两年几乎是 AI 应用开发的标配能力大家习惯叫它 Function Calling主流模型厂商都在支持。很多刚上手的人以为它就是 SDK 里填一个参数的事但面试官要看的显然不是你会背接口而是你懂不懂它背后的完整链路——模型怎么产生调用意图、系统怎么解析结果、工具执行完怎么把数据回灌给模型。这题问的是底层理解。好在我当时给自己备了三套递进的解法从最原始的文本解析到结构化输出再到原生工具调用协议。这篇就把三套方案的完整代码、设计思路还有我在现场踩过的坑一起聊透。适合准备 AI 应用方向面试的人也适合不想被框架绑架、想自己搭一套 Agent 骨架的人。1. 先拆题这道手写题真正考的是什么1.1 Tool Use 到底解决什么问题先对齐一个基本概念。大语言模型本质上是一个文本生成器它能回答“北京冬天一般多少度”这种基于训练知识的问题但如果你问“北京现在实时温度多少”它就无能为力了因为它没有感知外部世界的能力也没有执行操作的能力。Tool Use工具调用就是给模型装上一双“手”系统预先声明一批工具告诉模型“你能用这些函数每个函数是干什么的、参数长什么样”模型在回答过程中判断用户诉求超出自己的能力范围时就输出一条结构化的“调用指令”指定工具名和参数程序拿到指令后执行真实的函数把结果以消息形式回发给模型模型基于真实数据生成最终回答。这一整套链路就是 Function Calling 的本质。拆开看是四个环节工具声明、意图决策、参数提取、结果回灌。对应到代码上就是“写 tools 参数、判断是否有 tool_calls、解析参数、执行函数并追加 tool 消息”。面试官问“手写 Tool Use”本质上就是问你对这四个环节的理解深度。1.2 面试官真正想验证的三层能力后来我复盘这道题其实在考三层东西。第一层是理解力。你能否脱离框架讲清楚链路知道“模型输出的工具调用本质上就是一段结构化文本系统要负责解析和执行”。张嘴就背 SDK 参数、说不出底层发生了什么的人这一层就过不去。第二层是工程力。真实环境下模型输出会带 Markdown 代码块、参数会写错、函数会抛异常、调用会死循环这些边界你怎么处理。面试官想看的不是“能跑的最小 demo”而是你面对脏数据时有没有防御性编程意识。第三层是判断力。三个方案的演进逻辑、成本差异、适用场景你能不能讲清楚。这本质上是架构决策能力。所以我没有一上来就写代码而是先跟面试官对齐我准备依次讲“Prompt 约束加正则解析”“JSON 模式结构化输出”“原生 Function Calling”三种方案一条主线是模型能力越来越强、你手写的解析代码越来越少但每一层的适用边界不同。对齐之后双方都轻松面试官知道自己要听什么我也知道按什么顺序讲。1.3 三个方案是一条推导链三个方案不是并列的更像一条推导链。方案一最朴素它恰好证明了你的底层认知——所谓工具调用不过是让模型按格式输出你用正则去解析方案二引入模型端的格式保证解决了方案一最痛的“输出格式漂移”方案三把整个决策与格式生成交给模型训练好的原生能力工程上最省心。这条链条讲下来面试官对你的思考过程是一目了然的。2. 方案一Prompt 约束加正则解析最原始的 Tool Use2.1 最小实现让模型按协议输出再解析方案一的思路三句话在系统提示词里定义一套输出协议要求模型在需要调用工具时输出固定格式程序用正则提取这段文本并解析成工具名和参数。它不依赖任何模型厂家的特殊能力本地部署的开源模型也能用算是最“手写”的方式。最小实现长这样import json import re SYSTEM_PROMPT 你是一个智能助手你可以使用以下工具 - get_weather(city: str): 查询指定城市的实时天气 - search_web(query: str): 搜索互联网获取最新信息 当用户的问题需要调用工具时你必须严格输出下面的格式不要输出其他内容 TOOL_CALL: {tool: get_weather, params: {city: 北京}} 如果不需要调用工具直接回答用户即可。 def parse_tool_call(text: str): # 在文本里定位 TOOL_CALL 标记提取后面的 JSON pattern rTOOL_CALL:\s*(\{.*\}) match re.search(pattern, text, re.DOTALL) if not match: return None try: data json.loads(match.group(1)) return data.get(tool), data.get(params, {}) except json.JSONDecodeError: return None text TOOL_CALL: {tool: get_weather, params: {city: 北京}} print(parse_tool_call(text)) # 输出: (get_weather, {city: 北京})这里正则的re.DOTALL很关键它让.能匹配换行避免模型在多行输出时把匹配折断。这套最小实现能跑通理想情况但距离“能用”还有十万八千里。2.2 踩坑实录模型根本不按格式出牌方案一最大的坑是“Prompt 写清楚”不等于“模型会遵守”。同一个提示词换一个模型行为可能就完全不同。我实际踩过的坑有三个你们大概率也会遇到。第一个坑是模型输出带 Markdown 代码块。你明明规定只输出 TOOL_CALL 行它偏要把 JSON 包在三个反引号里。正则一旦匹配不到就返回 None整个链路就断了。我的解法是先剥离代码块再走解析逻辑def strip_code_block(text: str): pattern r(?:json)?\s*(.*?) match re.search(pattern, text, re.DOTALL) return match.group(1) if match else text第二个坑是 JSON 里嵌套花括号。参数里如果还有字典结构(\{.*\})这种贪婪匹配会把不该吞的内容也吞进去。正则处理嵌套结构非常吃力更稳的做法是手动数括号配对。我在白板上写过一个版本规则是从第一个{开始扫描遇到字符串里的括号要跳过、遇到转义字符要跳过括号深度归零时结束def extract_balanced_json(text: str): start text.find({) if start -1: return None depth 0 in_string False escape False for i in range(start, len(text)): ch text[i] if escape: escape False continue if ch \\ and in_string: escape True continue if ch : in_string not in_string continue if not in_string: if ch {: depth 1 elif ch }: depth - 1 if depth 0: return text[start:i 1] return None这段扫描代码在现场写出来面试官的关注点会有明显变化——他知道你是真的被 JSON 解析坑过的人。第三个坑是解析失败后的兜底。模型偶尔会输出带多余逗号的 JSONjson.loads直接抛异常。这时候不能干瞪眼正确姿势是把错误信息和模型原输出一起塞回对话让模型自己修复重试。这个自我纠错循环后面还会反复用到。2.3 这个方案的定位兜底但别贪恋方案一的价值是“零依赖、全兼容、完全透明”。缺点是稳定性差你要花大量精力维护解析规则换一个模型就要调一版提示词工具一多 Prompt 也越来越臃肿。真实项目里我会在三种情况下回退到它本地部署的老模型不支持结构化输出、只需要一个短平快的 demo、教学场景想让新人看清底层原理。生产环境长期用它维护成本会让你怀疑人生。3. 方案二JSON 模式结构化输出把格式问题交给模型3.1 核心思路让模型从源头保证 JSON 合法方案一最痛的点是“正则解析模型的不靠谱输出”。顺着这个痛点往下一层方案二的想法就出来了能不能让模型自己保证输出合法 JSON主流模型服务商基本都提供了结构化输出能力在接口里开启 JSON 模式后模型被约束为输出合法的 JSON 文本。这样json.loads不会因为“多一个换行”“夹带一段废话”而失败程序里最脏的一环被模型端解决了。注意这里的细节各家 JSON 模式通常要求提示词里包含“json”字样否则接口直接报错这个要求藏得很隐蔽属于官方文档不写透、实际一调就中招的坑。实现的核心代码from llm_sdk import Client client Client() def chat_with_tool_use(user_input: str, tool_desc: str): response client.chat.completions.create( modelMODEL_NAME, messages[ {role: system, content: f 你是一个智能助手遇到需要调用工具的场景时请输出合法 JSON格式如下 {{need_tool: true, tool: get_weather, params: {{city: 北京}}}} 如果不需要调用工具输出{{need_tool: false, answer: 你的回答}} 可用的工具{tool_desc} }, {role: user, content: user_input}, ], response_format{type: json_object}, ) content response.choices[0].message.content try: data json.loads(content) except json.JSONDecodeError: return f模型输出非法 JSON理论上不该发生{content} if data.get(need_tool): tool_name data[tool] params data.get(params, {}) result execute_tool(tool_name, params) # 把工具结果回传给模型让它生成最终回答 final client.chat.completions.create( modelMODEL_NAME, messages[ {role: system, content: 你是智能助手基于工具返回结果回答用户。}, {role: user, content: user_input}, {role: assistant, content: content}, {role: user, content: f工具调用结果{result}请基于此结果回答用户。}, ], response_format{type: json_object}, ) return final.choices[0].message.content else: return data.get(answer, content)JSON 模式只能保证“格式合法”不保证“结构符合预期”。模型可能给你一个字段名都对的 JSON但params里少了必填参数也可能干脆输出一个结构完全不同的合法 JSON。所以这个方案里校验逻辑依然要写只是从“防格式坏”降级成了“防业务错”血压能降一大截。3.2 用 JSON Schema 做更强的结构约束如果业务上对输出结构要求很严格可以在接口里传 JSON Schema把字段名、字段类型、必填项全部声明清楚。模型会按这个 Schema 生成结果相当于你从“要求它输出 JSON”升级到“要求它输出符合预期的 JSON”。response client.chat.completions.create( modelMODEL_NAME, messagesmessages, response_format{ type: json_schema, json_schema: { name: tool_call_result, strict: True, schema: { type: object, properties: { need_tool: {type: boolean}, tool: {type: string}, params: {type: object}, answer: {type: string} }, required: [need_tool, tool, params, answer], additionalProperties: False } } } )strict: true加additionalProperties: false的效果是模型不能输出 schema 以外的字段不能漏掉 required 字段。这层就比裸 JSON 模式强很多了。不过要提醒一句JSON Schema 结构化输出不是所有模型都支持尤其是部分开源模型用之前先查文档。3.3 重试与校验策略方案二依然保留了自我纠错但顺序反过来了不再先修格式而是先验业务。json.loads成功后我会做一个轻量校验检查 tool 字段是否存在、参数是否齐全。校验失败就构造一条错误消息把模型的原始输出原样带回重试def call_with_retry(messages, max_retry2): for _ in range(max_retry): response client.chat.completions.create( modelMODEL_NAME, messagesmessages, response_format{type: json_object}, ) content response.choices[0].message.content try: data json.loads(content) validate_tool_call(data) return data except (json.JSONDecodeError, ValueError) as e: messages.append({ role: user, content: f你上次的输出不合法{e}\n原始输出{content}\n请重新输出合法且符合要求的 JSON。 }) raise RuntimeError(重试次数已用尽)我的经验是两次重试能覆盖绝大多数偶发错误三次以后收益趋近于零不如直接走失败降级。这个阈值是在多个项目里试出来的不是拍脑袋。4. 方案三原生 Function Calling工程上的最终答案4.1 回归框架把脏活交给模型厂商聊到这里面试官追问得很自然“第三套方案是什么”我说第三套是“不手写”——直接使用模型厂商提供的原生 Function Calling 协议。在原生协议里模型已经通过训练学会了“遇到需要工具的场景时输出结构化 tool_calls”而你只需要把工具以 JSON Schema 的形式通过tools参数传进去。模型返回的不是文本而是一个规范的tool_calls数组每个元素带工具名和参数字段。你连正则都不需要写解析这一步被完全省掉了。完整闭环def run_agent(user_input: str): messages [{role: user, content: user_input}] tools [get_weather_schema, search_web_schema] # 实际由注册表生成 for step in range(5): response client.chat.completions.create( modelMODEL_NAME, messagesmessages, toolstools, ) msg response.choices[0].message # 模型没有产生工具调用 - 直接回答 if not msg.tool_calls: return msg.content # 先把 assistant 消息追加进历史协议要求不能省 messages.append(msg) # 逐个执行工具把结果以 tool 角色回传 for tool_call in msg.tool_calls: fn_name tool_call.function.name try: args json.loads(tool_call.function.arguments) result execute_tool(fn_name, args) except Exception as e: result f工具调用失败{e} messages.append({ role: tool, tool_call_id: tool_call.id, content: str(result), }) return 已达最大迭代次数提前终止请人工介入。这个代码里最容易被忽略的就是messages.append(msg)这一行。模型输出tool_calls之后你必须把这条 assistant 消息原样放入历史再紧跟tool角色消息而且tool_call_id必须一一对应。漏掉任何一环接口都会以莫名其妙的报错响应你。我第一次写的时候漏了这行排查了整整一下午。4.2 工具注册表与执行器设计工具一多就不能在代码里写一堆 if-else 分发那会把人写疯。我用的模式是装饰器注册表把“挂载工具”和“执行工具”彻底解耦TOOL_REGISTRY {} def register_tool(name: str | None None): def decorator(func): func_name name or func.__name__ TOOL_REGISTRY[func_name] func return func return decorator register_tool() def get_weather(city: str): # 实际项目中接真实天气接口 return f{city} 的实时天气晴25℃东南风2级 register_tool() def search_web(query: str): # 实际项目中接搜索引擎接口 return f关于 {query} 的最新信息... def execute_tool(tool_name: str, params: dict): if tool_name not in TOOL_REGISTRY: return f错误未知工具 {tool_name} try: return TOOL_REGISTRY[tool_name](**params) except TypeError as e: return f错误工具参数不匹配{e} except Exception as e: return f错误工具执行失败{e}用这个模式有三个好处。新增工具只需写一个函数加一行装饰器分发逻辑永远不用动执行器统一拦截“未知工具”“参数类型错误”“执行异常”三类错误把异常转成普通字符串回传给模型让模型继续兜底将来要加权限控制、限流、审计也都在执行器这一层统一加。我在白板上画到执行器时面试官的问题已经变成“多个工具按顺序执行你如何设计调度层”——这代表他从考实现转向考架构能力了。4.3 多轮调用与循环控制原生协议还有一个隐藏福利支持一次返回多个 tool_calls也支持多轮工具调用。比如用户要“查北京天气再搜一下明天北京有什么展览”模型可能第一轮只调天气根据结果再决定搜索关键词这就是 while 循环存在的意义。但循环必须设上限。我在调试 Agent 时见过模型在工具调用里反复横跳、自己和自己对话、甚至陷入幻觉循环的情况。设置 5 次是最低要求复杂的多步任务可以放到 10 次但上限一定要有否则一个 bug 能让你的账单在深夜以惊人的速度膨胀。我还会在每个工具执行后打印一条结构化日志包含工具名、参数、耗时、结果摘要。Agent 项目出问题最难受的就是没法定位是“模型选错工具”“工具本身报错”还是“结果回传格式有问题”没有日志这三者几乎无法区分。5. 三套方案的横向对比与选型逻辑5.1 一张可以直接抄的对比表面试时我把三个方案画成了一张对比表基本保证了这轮面试的下限。这张表我建议所有做 AI 应用开发的同学都自己画一遍对比维度方案一Prompt 正则方案二JSON 模式方案三Function Calling实现复杂度低中中高输出稳定性差依赖模型自觉较好保证 JSON 合法好原生结构化对模型要求任意模型需支持 JSON 模式需支持原生 tools解析代码量多中几乎为零上下文消耗低中略高可观测性高全程透明中低黑盒化典型场景本地模型、DEMO、教学跨厂商兼容方案生产级 Agent这张表的意义不在于“谁最好”而在于告诉你每个维度的取舍在哪里。面试官听完这张表基本就知道你不只是会调接口还能做技术选型。5.2 生产环境我默认选方案三但有几个前提纯粹从工程效率讲生产环境我默认选方案三。理由很简单工具调用这件事模型厂商在训练阶段已经强化过无数遍了我再手写解析器属于重复造轮子。原生协议还附带几个看不见的好处——模型对“该不该调用工具”的判断更准对参数的提取更稳多工具并行调用天然支持。但方案三有两个代价必须心里有数。第一是 tools 参数要消耗 token工具一多每次请求的上下文就膨胀。我的习惯是动态裁剪工具列表根据用户意图只传可能用到的几个工具超过十个之后这一招能让响应速度和成本优化三成以上。第二是黑盒化模型为什么调用这个工具、参数为什么这么填你只能通过日志猜测排障的难度比方案一高不少。5.3 哪些场景反而该回退方案三并不是“正确答案”。本地部署的开源小模型很多不支持原生 tools模型能力偏弱时原生协议反而可能乱调工具不如 Prompt 协议可控短平快的 demo 和教学场景里方案一的透明度和最低依赖反而是优点。所以三个方案之间不是“新旧替代”而是互相兜底的关系。你心里要有一杆秤工具数量、模型能力、成本预算、排障需求四个变量共同决定选哪个。6. 面试复盘加分细节与踩坑经验6.1 三个让我拿到主动权的细节现场我主动讲了三个细节明显让面试官觉得我“是真的写过 Agent 的人”。第一个是把错误交给模型自己看。不管方案一解析失败还是方案三工具异常我都把错误信息拼成消息放回对话让模型下一轮自修复。这个“把错误回流给模型”是 Agent 系统提升任务成功率的关键比在代码里硬编码一堆补救逻辑管用得多。第二个是主动处理上下文长度。工具结果可能非常长尤其搜索接口返回大量内容如果整段塞回 messages上下文马上爆炸。解法是截断结果到前 N 个字或者让工具返回摘要而不是原文。这个话题涉及 token 成本和上下文管理属于你“真正部署过”才想得到的细节。第三个是强调工具结果必须回流给模型二次加工而不是直接拼字符串返回。很多 demo 图省事执行完工具直接拿结果拼字符串模型根本没“看见”工具结果导致回答缺乏常识推理。这个点讲透面试官就知道你不是写玩具 demo 的人。6.2 现场容易翻车的几个点我在复盘时也整理了一些候选人常见的翻车姿势。一是只背接口不写逻辑tools 参数的字段能倒背如流一问到“返回的 tool_calls 怎么执行”就卡壳。二是不按协议补消息要么漏 assistant 消息要么 tool_call_id 对不上一调用就报错本质是没真正跑通过。三是把三套方案讲成“高级替代低级”其实面试官想听的是每个方案为哪个场景而生。还有一个实战建议面试写代码时间有限别追求一次写完完整版。先写核心的循环逻辑口头补充容错方案。我那轮白板上最后只留了四块内容正则一行、JSON 模式的 response_format 片段、tool_calls 的 while 循环、一张对比表。其余全是口头讲。6.3 这题还能往深里延伸如果时间允许我准备了一些延伸方向这里也分享给你们多 Agent 场景下怎么给不同 Agent 分配不同的工具集工具参数一样时怎么缓存结果避免重复调用敏感工具发邮件、改数据库怎么加权限和二次确认流式输出下 tool_calls 怎么增量解析工具调用和 RAG 怎么配合让模型先检索再决策。这些方向其实都在说同一件事——Tool Use 是一个 Agent 工程体系的入口把它吃透后面路由、记忆、编排、评估全都有同一个底座。面试结束后我又把三个方案从头到尾各写了一遍。写的过程中我意识到这道题的精妙不在于标准答案而在于它逼着你思考一个核心问题在你手里模型到底是一个听话的文本生成器还是一个可以被工程化的执行单元方案一里我把它当文本生成器用正则去应付它的不靠谱输出方案二里我通过约束输出格式哄它配合方案三里我接受了一个事实——工具调用这件事模型厂商比我更懂怎么做。这三套方案正好对应了我做 AI 应用这三年在认知上的三个阶段。如果你最近也在准备类似面试我的建议是把这三段代码真的跑一遍而不是背下来。方案一写到解析函数被嵌套括号搞崩的那一刻方案二看到 JSON 模式把乱格式彻底拦截的那一刻方案三看到模型自己决定连续调用两个工具的那一刻这套链路才算真正长在你自己身上。那些踩出来的坑和想通的道理比任何标准答案都值钱。