面试这事儿有时候真挺奇妙。上周去某家做 AI 应用的公司面试,前面聊 Agent 架构聊得挺顺畅,结果面试官突然说了一句:"咱们不聊概念了,你现场给我手写一个 Tool Use 出来看看。"我当时先是一愣,紧接着心里其实有点暗喜——这年头人人都能聊 Tool Use,但真正自己动手写过完整闭环的人不多。Tool Use 说白了就是让大模型在对话之外具备调用外部函数、工具和 API 的能力,它是 Agent 从"会聊天"走向"能干活"的分水岭。我调整了一下思路,用了三种方案依次递进,从最朴素的正则匹配讲到 Function Calling 再讲到 ReAct 循环。面试官看完之后明显话多了起来,后面聊了将近四十分钟。这篇我就把这三种方案完整拆开,连同当时的思考过程、踩过的坑一起分享出来,希望对准备面试或正在做 Agent 落地的你有点帮助。
1. 面试的开场:他真正想看的是什么
1.1 一个典型到不能再典型的手写题
当时面试官给我的要求其实很简单:不限制语言,不要求接真实大模型,只要实现一个"模型说想调工具,系统就能把工具结果拿回来继续对话"的最小闭环。看起来是道开放题,但真正考察的东西藏在细节里。
他随后补充了几个约束:要有工具注册机制,新增工具不需要改主流程;模型返回的结果要经过解析和校验;工具执行完的结果要能喂回给模型继续生成。听完这几个约束我就明白了,他考察的并不是某种框架或者某个平台的 API,而是三个底层能力:协议设计、执行路径、以及循环控制。这基本上就是 Agent 运行时最核心的部分了。
我当场先给了一个简单方案,然后逐步升级到三种。整个过程他用白板和我一起推演,我每写一段代码他就会追问一句"这样设计的理由是什么"。说实话,这种面试方式比背概念舒服太多了,因为不需要表演,代码会替你说实话。
1.2 Tool Use 拆到底就三件事
很多人把 Tool Use 想得很玄乎,其实拆开看就三件事:意图识别、参数提取、结果回填。意图识别是判断"用户这句话要不要调用工具",参数提取是确定"调用哪个工具、传什么参数",结果回填则是把工具返回的数据拼进对话上下文,让模型基于真实结果继续推理。
理解了这三件事,再看市面上各种方案就清楚了:正则匹配解决的是意图识别和参数提取的一个窄版本,Function Calling 解决的是让模型自己完成意图识别和参数提取,ReAct 循环解决的是多轮决策、工具编排和结果回填的完整闭环。三种方案并不是竞品关系,而是递进关系,应对的复杂度和落地场景完全不同。
面试官考手写,本质上就是看你能不能在上面的抽象层次之间自如切换,而不是只会调用封装好的库。我后来自己也带过新人,发现能把这三件事讲清楚的人,写出来的 Agent 代码通常边界感都很强,不太会出现那种"工具调用链烂成一锅粥"的情况。
1.3 先说结论:我最后用的是什么组合
如果你赶时间,想直接知道面试该怎么答,我的结论是:先用方案一证明你理解最小链路,再用方案二展示工程化能力,最后用方案三拔高。三个方案之间要有清楚的演进理由,不要一上来就写最复杂的 ReAct,那样反而容易暴露你在边界处理上的薄弱。
我在现场的实际顺序是:十分钟完成方案一,十五分钟升级到方案二,最后二十分钟把方案三跑通并讨论安全边界。时间分配看起来平均,但重点其实放在方案二的协议设计和方案三的循环控制上,因为这两个才是工业级 Tool Use 的命门。
下面我把三个方案一个一个完整讲透,代码是我整理后的版本,现场手写会比这个简化一些。
2. 方案一:正则驱动的意图匹配——最简单但思路完整
2.1 核心思路:把工具调用变成模式匹配
第一种方案不需要大模型参与,直接把用户的输入文本拿来做正则规则匹配,命中某个规则就调用注册好的工具。它的价值不在于工程可用性,而在于帮你把"工具调用链路"的最小骨架搭出来:用户输入进来、解析器判断意图、执行器调用函数、结果返回给用户。
这个方案的设计逻辑很简单——我把工具当成一张注册表,每个工具包含名称、描述、处理函数,解析器里维护一组规则,规则负责从文本中抽出工具名和参数。
它的优点是链路清晰,没有任何黑盒逆操作,面试时讲起来特别顺畅。缺点是极其脆弱,用户换一种说法就可能匹配不上,而且参数稍微复杂一点就处理不了。但你得先有了这个骨架,后面往上加东西才不慌。
2.2 最小可运行 Demo
我用 Python 写了三个文件结构上合并成一段。第一步定义一个工具注册表,第二步定义规则表,第三步写主循环。注意第一步我用了一个装饰器风格的注册方式,第二步规则用元组维护。
import re # 工具注册表 TOOLS = {} def register_tool(name, description): def decorator(func): TOOLS[name] = { "handler": func, "description": description, } return func return decorator @register_tool("get_weather", "查询城市天气") def get_weather(city: str): return f"{city} 今天晴,气温 25℃" @register_tool("calc", "计算算术表达式") def calc(expression: str): # 演示用,生产环境绝不要直接 eval return str(eval(expression)) # 规则表:一组正则,匹配到哪个就调哪个 RULES = [ (re.compile(r"(?:查询|查一下|看看)?(.{2,6}?)的?天气"), "get_weather", "city"), (re.compile(r"[计算算下]?\s*([0-9+\-*/().\s]+)"), "calc", "expression"), ] def parse_intent(text): for pattern, tool_name, arg_name in RULES: m = pattern.search(text) if m: return tool_name, {arg_name: m.group(1)} return None, None def main(): while True: user_input = input("请输入: ") if user_input.strip() in ("exit", "quit"): break tool_name, args = parse_intent(user_input) if tool_name is None: print("没有匹配到工具,请换个说法") continue result = TOOLS[tool_name]["handler"](**args) print("工具返回:", result) if __name__ == "__main__": main()运行一下就明白了,输入"查询北京天气"会命中第一条规则,输出"北京 今天晴,气温 25℃"。输入"计算 3 * 7 + 1"会命中第二条规则,调用 calc 函数。
这套代码最完整的部分是注册表的抽象,新增工具只需要写一个函数加装饰器,主流程一行不动。面试官看这个代码时会觉得你有工程意识,而不是临时堆逻辑。
2.3 翻车现场:用户一换说法就崩
方案一看起来能跑,但经不起细问。我当时在面试现场简单测试了一下,翻车场景很多。比如用户说"北京今天冷吗",这个请求明显需要查天气还要结合温度判断,但正则没法做语义理解,直接没命中。又比如"3乘7加1等于多少",里面是中文数字,正则就抓不住了。
更尴尬的是参数耦合问题。规则表里的正则模式必须和后端 handler 的参数名严格对齐,一旦工具参数改名,规则也要跟着改。而且两个正则之间的优先级很难控制,如果用户说"帮我算一下北京今天多少度",两条规则可能同时匹配,最后调用哪个完全取决于规则顺序。
这个方案暴露的问题恰好引出了方案二要解决的核心矛盾:自然语言的意图种类是无限的,纯靠人去枚举规则写正则,永远跟不上。必须把"意图识别和参数提取"这部分决策交给模型来做,而不是交给死板的字符串匹配。
3. 方案二:函数注册表加 JSON 协议——标准 Function Calling 的迷你复刻版
3.1 设计思路:让模型自己说"我要调用哪个工具"
方案二的跃迁点在职责分配:我不再试图写规则去理解用户的话,而是把工具列表和参数约束告诉模型,让模型在生成回复时输出一个结构化的工具调用请求。代码这边只负责解析、校验、执行和回填。
这就带来一个协议设计问题,也是面试官最关注的部分。我选择的协议是:模型在需要调用工具时,只输出一行严格的 JSON,包含 tool 和 args 两个字段。例如{"tool": "get_weather", "args": {"city": "北京"}}。为了让模型稳定输出这个格式,系统提示词里要给出工具清单,每个工具都要有 name、description、parameters 三个部分,parameters 要符合基本的 JSON Schema 语法。
整个闭环是:用户消息进来,拼上系统提示词,发给模型;模型返回 JSON 字符串;代码解析 JSON,查注册表,校验参数,执行 handler;把执行结果作为一条新的 assistant 消息拼回对话历史;再一次调用模型,这次模型就能基于工具的真实返回结果生成最终回复。
3.2 工具 Schema 应该怎么设计
很多人在这一步偷懒,直接让模型输出一段自由文本然后用正则抠,结果就是线上各种解析失败。我的习惯是每个工具都维护一套 parameters 描述,严格声明属性名、类型、是否必填和含义。现场手写不用搞完整 JSON Schema,但至少要写出结构和校验逻辑。
TOOLS = { "get_weather": { "name": "get_weather", "description": "查询指定城市的实时天气,适合回答与天气、温度、出行相关的问题", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如 北京、上海" } }, "required": ["city"] } }, "calc": { "name": "calc", "description": "计算数学表达式,适合算术运算。不要把中文数字传进来", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,例如 3 * 7 + 1" } }, "required": ["expression"] } } }为什么 description 要写得那么具体?因为模型是靠字符串理解工具的,描述越模糊,模型就越可能传错参数。比如 calc 工具如果不强调"不要把中文数字传进来",模型很可能真的把"三乘七"当作字符串传进来,后面直接报错。
3.3 核心闭环代码
为了演示方便,我这里用一个 mock 的模型接口,真实场景替换成模型 API 调用即可。
import json import jsonschema def call_llm(messages): # 真实环境替换为模型 provider 的调用 # 演示:模型经过"思考"决定调用 get_weather return json.dumps({"tool": "get_weather", "args": {"city": "上海"}}) def execute_tool(tool_call): tool_name = tool_call["tool"] args = tool_call.get("args", {}) tool = TOOLS.get(tool_name) if tool is None: raise ValueError(f"未知工具: {tool_name}") # 参数校验是关键一步 jsonschema.validate(args, tool["parameters"]) result = tool["handler"](**args) return result def run_single_tool_cycle(user_input): messages = [{"role": "user", "content": user_input}] system_prompt = ( "你是智能助手。当你认为需要查询工具时,只输出如下JSON:" '{"tool": "工具名", "args": {"参数名": "值"}}不要输出其他文本。' ) full_messages = [{"role": "system", "content": system_prompt}] + messages response = call_llm(full_messages) # 容错:尝试解析 JSON try: tool_call = json.loads(response) except json.JSONDecodeError: return "模型没有返回合法JSON,需要重试或上报失败" result = execute_tool(tool_call) # 把结果回填到对话 messages.append({"role": "assistant", "content": response}) messages.append({"role": "tool", "content": str(result)}) final_response = call_llm(full_messages) return final_response print(run_single_tool_cycle("上海天气怎么样?"))这段代码的核心动作是:把模型的 JSON 响应当成一种"远程过程调用",解析、校验、执行、回填。和方案一相比,它多了一层协议解析和参数校验,但换来的是模型能理解任意说法的用户请求,不再依赖人写正则。
3.4 参数校验和失败重试:这个环节必须暴露给模型看
我特意在代码里加了jsonschema.validate,这是方案二最重要的安全边界。模型再智能也可能会输出错误参数名、错误类型或者不完整参数,如果直接调 handler,轻则拿到异常,重则可能触发不安全的操作。
参数校验失败时的策略也值得讲一下。不要直接吞掉错误或者把异常抛给用户,而是把校验失败的完整信息当作一次工具结果,拼接回对话里,让模型自己修正重来。比如校验器抛出一句"city 是必填字段,但你只传了 temperature",模型看到这句话之后,下一轮往往会自动补上城市名。
这个"把错误反馈给模型"的模式是 Agent 工程里常见的手段,面试时主动说出来,会比你闷头写代码要加分得多。它体现的不是你会写一行校验,而是你理解模型和代码之间如何协作纠错。
3.5 实测体会:它已经能干活,但还不是 Agent
面试现场我把方案二跑通之后,问了一句"北京天气多少",模型输出了 JSON,代码调用了天气工具,再把结果喂回给模型,模型顺利给出了自然语言回答。到这里,"Tool Use"的最小闭环已经成立。
但面试官马上追问:如果用户的问题是"上海和北京哪个更冷,我要不要穿羽绒服",这个闭环会怎样?单次循环只能支持一次工具调用,虽然可以让模型输出多个 JSON,但这属于低级编排,遇到需要多次查询、条件判断、中间结果比较的任务就做不了了。这个追问顺理成章地把我推向方案三。
4. 方案三:ReAct 循环加动态执行器——进化成迷你 Agent
4.1 ReAct 的核心:让模型在思考与行动之间交替
ReAct 这个思路现在已经是 Agent 领域的事实标准了,核心就是把"思考"和"行动"显式交替。每轮让模型输出一段文本,里面包含 Thought(当前推理)和 Action(要调用的工具及参数)。代码解析到 Action 就执行工具,把结果拼成 Observation(观察结果),再喂回给模型,直到模型认为信息足够了,输出 Final Answer。
和方案二最大的区别是,方案二只做一次工具调用就结束,方案三则是一个循环,循环体内反复执行"模型出 Action、代码执行工具、回填 Observation"的流程。多步编排能力就来自这里:模型可以在第一轮查到城市 A 的天气,第二轮刷新城市 B 的天气,第三轮再比较两者。
4.2 一个能跑的多轮循环
下面这段是我现场手写的精简版,去掉了很多边界细节,但保留了控制核心。注意解析 Action 时我用了一个简单的 JSON 提取函数,专门应对模型偶尔在 Action 前后夹杂其他文本的情况。
import json import re # 复用前面的 TOOLS 注册表 SYSTEM_PROMPT = """ 你是一个具备工具调用能力的智能助手。 请严格遵循以下格式输出: Thought: 你的推理过程 Action: {"tool": "工具名", "args": {"参数名": "值"}} 当收集到足够信息后,输出: Thought: 我可以回答用户问题了 Final Answer: 你的最终回答 """.strip() def extract_action(response): match = re.search(r"Action:\s*(\{.*?\})", response, re.DOTALL) if match: return json.loads(match.group(1)) return None def run_react_agent(user_input, max_steps=5): history = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] for step in range(max_steps): response = call_llm(history) print("模型输出:", response) action = extract_action(response) if action is None: # 没有 Action,检查是否有 Final Answer if "Final Answer" in response: return response history.append({"role": "assistant", "content": response}) continue result = execute_tool(action) observation = f"Observation: {result}" history.append({"role": "assistant", "content": response}) history.append({"role": "user", "content": observation}) return "达到最大迭代次数,强制终止" print(run_react_agent("北京和上海哪个更冷?"))这段代码的精髓在循环控制条件。我设了一个最大迭代次数,避免模型陷入无休止的工具调用。每轮把模型输出和 Observation 都拼进历史,模型就能在下一轮看到之前的思考链和工具返回结果,这是它继续推理的依据。
4.3 终止条件、上下文长度和死循环:三个绕不开的大坑
多轮循环看起来酷,但实际跑起来才会暴露问题。第一个坑是终止条件。如果模型一直不输出 Final Answer 怎么办?除了 max_steps 之外,我还会检查连续两轮 Action 是否完全一样、参数是否相同,如果一样就判定模型在原地打转,直接打断并给出提示。
第二个坑是上下文长度。ReAct 每轮都要把思考和 Observation 追加进历史,十轮下来 token 消耗非常快。实际上我见过最夸张的情况,一个复杂的任务跑二十轮,历史里堆积了大量工具返回的冗长 JSON,最后模型反而被噪声干扰。解法也很实在:Observation 在拼进历史前做截断,只保留前 200 到 500 个字符,或者做一次摘要压缩。
第三个坑是工具本身报错引发的"错误循环"。比如天气接口超时,Observation 里放了一个异常对象,模型看到异常之后可能会尝试重新调用同一个工具,越试越僵。我后来在 execute_tool 外面套了一层 try,把异常类型和简短原因格式化成Observation,同时要求模型换一种方案或直接告诉用户暂时不可用,而不是无限重试。
4.4 安全边界:白名单、超时和权限控制
讲到这里面试官一定会问安全问题。越开放的工具调用能力意味着越大的攻击面。我在现场提了三个控制手段,你可以直接拿去用。
工具白名单是默认强制项,Agent 只能调用注册表里显露过的工具,拒绝任何动态加载的未知函数。超时控制方面,每个工具执行都要包一层超时机制,避免某个工具卡死拖垮整个循环。权限控制要区分"只读工具"和"写工具",比如查询类工具允许模型自主调用,但发送消息、删除数据这类敏感操作必须二次确认或者限制在特定上下文中。
还有一个很少人提但很重要的点:日志和可审计性。每轮调用都记录模型输出、Action、Observation、耗时,出了事故可以回溯。线下开会时大家经常吐槽 Agent 不可靠,其实很多问题靠日志都能快速定位,就是初期没人愿意多写几行记录。
5. 面试复盘:三种方案怎么选、怎么答、怎么说
5.1 横向对比:复杂度不一,分工不同
| 维度 | 方案一:正则匹配 | 方案二:函数注册表加JSON协议 | 方案三:ReAct循环加动态执行器 |
|---|---|---|---|
| 意图识别 | 人工规则 | 模型判断 | 模型推理加多步决策 |
| 参数提取 | 正则子串提取,脆弱 | 模型输出 JSON,校验后执行 | 模型逐步输出,可以修正 |
| 多步编排 | 完全不支持 | 支持一次性多个调用,但编排弱 | 支持完整的多步规划与修正 |
| 工程复杂度 | 很低 | 中等 | 偏高 |
| 落地场景 | 客服话术匹配、演示Demo | 单轮工具调用、垂直场景 | 复杂任务、数据分析、多工具编排 |
| 安全风险 | 风险较小但规则死板 | 需要参数校验和权限控制 | 需要白名单、超时、日志审计 |
这张表建议背熟。面试时被问"三个方案选哪个",不要直接说第三个最好,而是说取决于任务复杂度、可接受的工程成本和控制力要求。大多数生产环境 Team 会把方案二的严谨协议和方案三的循环编排混合使用。
5.2 面试官高频追问 Top 5
我复盘了一下当天被追问的问题,整理成五个最值得提前准备的。
第一个:如果模型输出非法 JSON 怎么办。答要拆两层,第一层增强提示词,第二层把错误信息回传给模型让它修正,但必须设置最大修正次数防止死循环。
第二个:工具调用结果很长,怎么处理。答截断、摘要、或者结构化提取关键字段再拼进上下文。重点是不能直接放任不管,上下文长度是硬约束。
第三个:多个工具调用结果有先后依赖怎么办。答可以在 Action 里约定一个 task_id,Observation 里带对应 id,也可以让模型拆分子任务逐步执行。ReAct 天然适合这种模式。
第四个:如何保证 Agent 不执行危险操作。答白名单、权限分级、敏感操作人工确认、审计日志。这四件套一口气说出来基本就过了。
第五个:你如何验证你的 Tool Use 是可靠的。答用例集和回归测试,比如准备五十条多轮任务输入,每次跑完对比关键输出,同时用日志追踪每一轮 Action 是否符合预期。这比口头说"我测过没问题"要可信得多。
5.3 现场手写的时间分配建议
如果你也遇到类似的面试题,我建议按三步走。先讲一遍整体设计:输入、协议、执行器、循环、回填,这五部分描述完大约需要两分钟。然后先写注册表和协议定义,这部分能展示你的工程化底座,再写执行循环。最后一定留五分钟跑测试用例,哪怕跑一个最简单的"查询天气再比较"用例,也能证明代码是可运行的。
不要一上来就埋头写代码。面试官想知道的是每个方案背后的 trade-off,先给结论再给细节,比你默默敲半天代码再解释要有说服力得多。说实在的,手写代码只是载体,整个面试过程真正在考察的是你的系统设计直觉和边界意识。工具调用链路并不复杂,复杂的是在有限资源、有限可靠性下把链路管理好。所以记住一个原则:代码要干净,协议要显式,边界要说清。
6. 我踩过的坑和给你的几点实战建议
6.1 第一个坑:把工具描述写得太随意
我早期做 Agent 时,工具描述随手写一句"查询天气",结果模型经常传错参数或者把省份和城市混到一起。后来我把描述改成"查询中国任意地级市及直辖市的实时天气,优先使用常用城市名简称,如北京、上海",准确率立刻上去了。模型的工具选择依赖文本描述,描述就是工具的说明书,写详细一点完全值得。
6.2 第二个坑:所有东西都让模型做,不做防御
有一段时间我认为模型很强,参数不需要校验,结果生产环境经常出现类型错误、缺字段、甚至工具名拼错。后来我强制引入 JSON Schema 校验,还必须把校验错误拼回对话。整体失败率降了一截。这也解释了为什么方案二里校验是核心,它不是可有可无的加分项,而是保命项。
6.3 第三个坑:ReAct 循环不加约束
我最早写 ReAct 循环只设了一个最大轮数,结果某个工具连续执行了差不多十轮还没结束,浪费了时间也把上下文撑爆了。后来我加了三个约束:最大轮数、相同 Action 连续重复检测、每轮 Observation 截断。这个组合拳基本解决了多轮失控问题。整体来看,Tool Use 手写题考的不只是你会不会调大模型,而是你有没有能力在一个又小又紧张的环节里做完整的工程思考。就算你平时用成熟的框架,也建议自己动手把这三个方案依次实现一遍。当你亲手写过正则、JSON协议、ReAct循环之后,再看任何 Agent 框架都会觉得透明很多,因为它们本质上就是这三个方案的工程化、产品化和体量放大版。
最后再分享一个小技巧:面试手写代码前,先在纸上或编辑器里画出那个闭环流程图,不用画给面试官看,画给自己看就够了。很多人在现场卡住,不是因为不会写某个函数,而是没想清楚消息在模型、解析器、执行器之间怎么流转。只要把"输入—解析—执行—回填—再生成"这条主线理顺了,后面的代码基本是水到渠成。那天我的面试结尾,面试官点评了一句话让我印象很深:他说 Tool Use 要写好,最需要的不是聪明,而是克制——知道让模型做什么、让代码做什么、让规则守什么边界。这句话也是今天我最想告诉你的总结。