1. 面试现场:从“八股文”到“手写 Tool Use”的转变
1.1 为什么 Tool Use 成了面试新宠
这两年技术面试的风向变了。早几年大家还在背“三次握手四次挥手”、“HashMap 扩容原理”,现在面试官更倾向于让你现场写点能跑的东西。尤其是大模型应用开发岗位兴起之后,Tool Use(工具调用)几乎成了绕不开的考点。
所谓 Tool Use,说白了就是让大模型在对话过程中,能够识别出“这个问题我需要借助外部工具才能回答”,然后按照约定的格式输出一个调用请求,由外部系统执行后再把结果喂回给模型,最终生成自然语言回复。听起来不复杂,但真正手写起来,涉及意图识别、参数抽取、格式约束、结果回填、异常兜底一整条链路,非常考验基本功。
面试官让你手写一个 Tool Use,本质上不是看你背了多少 API,而是看你能不能把“模型输出”和“程序执行”这两件事干净地解耦。我前后用过三种方案,各有取舍,下面把每种方案的思路、代码骨架和踩坑点都摊开讲。
1.2 三种方案的适用边界先讲清楚
在动手之前,先给三种方案画个像,方便你判断自己该用哪种:
| 方案 | 核心思路 | 适合场景 | 主要缺点 |
|---|---|---|---|
| 方案一:纯 Prompt 约束 | 靠系统提示词规定输出格式,正则解析 | 快速验证、模型能力较强 | 格式不稳定,易解析失败 |
| 方案二:Function Calling | 用模型原生的函数调用能力 | 生产环境、多工具 | 依赖模型支持,调试黑盒 |
| 方案三:状态机编排 | 自己写解析器+执行器+回填循环 | 复杂多轮、需精细控制 | 代码量大,需处理边界多 |
这三种不是互斥的,实际项目里经常混用。面试时如果你能说清楚“我为什么在这个场景选这个方案”,比闷头写代码加分得多。
2. 方案一:纯 Prompt 约束 + 正则解析
2.1 核心思路与提示词设计
这是最“土”但最通用的办法。核心就一句话:在系统提示词里把输出格式写死,让模型按格式吐,程序用正则去捞。
提示词大概长这样:
你是一个助手,可以使用以下工具: - get_weather(city: string): 查询城市天气 - search_flight(from: string, to: string, date: string): 查询航班 当需要调用工具时,你必须只输出如下 JSON,不要有任何多余文字: {"tool": "工具名", "args": {...}} 当不需要工具时,正常回答用户。模型收到后,如果判断需要查天气,就会输出:
{"tool": "get_weather", "args": {"city": "杭州"}}程序侧用正则或 JSON 解析器提取,执行工具,再把结果拼回对话。
2.2 解析器的健壮性处理
这里第一个坑就来了:模型不一定乖乖只输出 JSON。它可能输出“好的,我来帮你查询:{...}”,也可能在 JSON 后面加一句“请稍等”。所以解析器必须容错。
我的做法是分三层:
- 先尝试
json.loads整体解析,成功就直接用。 - 失败则用正则
\{.*\}贪婪匹配出最外层花括号内容,再解析。 - 再失败就返回“解析失败”,让模型重试一次。
import json, re def parse_tool_call(text): text = text.strip() try: return json.loads(text) except json.JSONDecodeError: pass match = re.search(r'\{.*\}', text, re.DOTALL) if match: try: return json.loads(match.group()) except json.JSONDecodeError: return None return None注意:正则用
re.DOTALL很关键,否则 JSON 里有换行就匹配不到。这个细节我在面试时被追问过,答不上来会显得没实操过。
2.3 这个方案的致命弱点
纯 Prompt 约束最大的问题是不稳定。同一个问题,模型这次输出标准 JSON,下次可能输出带注释的 JSON,再下次可能把参数名写成中文。你没法保证 100% 解析成功。
所以这个方案我只推荐用在两个地方:一是快速原型验证,二是模型能力很强且任务简单的场景。真上生产,解析失败率哪怕只有 5%,用户也会炸锅。
3. 方案二:Function Calling 原生能力
3.1 工具描述的结构化定义
方案二是目前主流做法。模型厂商在训练时就教会了模型“按 schema 输出函数调用”,你只需要把工具定义成结构化格式传进去。
以常见的接口风格为例,工具定义大概是这样:
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"} }, "required": ["city"] } } } ]模型返回的不再是自由文本,而是一个结构化的tool_calls字段,里面直接带name和arguments。程序拿到后json.loads(arguments)就能用,几乎不用做容错。
3.2 多工具并行的处理逻辑
Function Calling 有个很实用的特性:一次可以返回多个工具调用。比如用户问“杭州和北京今天天气怎么样”,模型可能一次返回两个get_weather调用。
处理逻辑要改成循环:
def handle_response(response): tool_calls = response.choices[0].message.tool_calls if not tool_calls: return response.choices[0].message.content results = [] for call in tool_calls: name = call.function.name args = json.loads(call.function.arguments) result = dispatch(name, args) results.append({ "tool_call_id": call.id, "role": "tool", "content": str(result) }) return results这里有个容易忽略的点:每个工具结果必须带上对应的tool_call_id,否则模型不知道哪个结果对应哪个调用。我见过有人图省事只传 content,结果多工具场景下模型直接串台。
3.3 参数校验不能省
别以为模型给的参数就一定对。实测下来,模型偶尔会把数字类型的参数写成字符串,或者漏掉必填字段。所以执行前必须做一层校验:
def validate_args(schema, args): required = schema.get("required", []) for key in required: if key not in args: raise ValueError(f"缺少必填参数: {key}") return True校验失败时,不要直接抛异常给用户,而是把错误信息作为工具结果回填给模型,让它自己修正参数重试。这个“自愈”机制在生产环境非常有用。
4. 方案三:状态机编排 + 自研解析器
4.1 什么时候需要自己造轮子
方案二虽好,但有两个限制:一是依赖模型支持,有些自部署的小模型没有 Function Calling 能力;二是黑盒,你没法精细控制模型在什么条件下必须调用工具。
这时候就得上方案三:自己写一个状态机,把整个对话流程拆成“判断意图 → 抽取参数 → 执行工具 → 回填结果 → 生成回复”几个状态,每个状态用独立的 Prompt 或规则控制。
4.2 状态机的核心状态设计
我一般设计四个状态:
IDLE:等待用户输入PLANNING:判断是否需要工具,需要哪个EXECUTING:执行工具调用RESPONDING:根据工具结果生成最终回复
状态转移靠一个调度器控制:
class ToolUseStateMachine: def __init__(self): self.state = "IDLE" self.context = [] def step(self, user_input): if self.state == "IDLE": self.context.append({"role": "user", "content": user_input}) self.state = "PLANNING" if self.state == "PLANNING": plan = self.plan(self.context) if plan["need_tool"]: self.state = "EXECUTING" return self.execute(plan) else: self.state = "RESPONDING" # ... 后续状态处理这种写法的好处是每一步都可观测、可打断、可重试。比如执行工具超时了,你可以选择重试或者降级,而不是像 Function Calling 那样只能等模型返回。
4.3 参数抽取的兜底策略
自研方案最难的是参数抽取。我的经验是双通道:先用规则(正则、关键词)抽一遍,抽不到再用模型抽。
比如查航班,用户说“帮我看看明天杭州到北京的航班”。规则通道先匹配“从X到Y”和日期词,能抽到就抽;抽不到再把整句话丢给模型,让它输出 JSON。这样既保证了常见 case 的速度,又覆盖了长尾表达。
实操心得:规则通道的优先级要高于模型通道,因为规则是确定性的,模型是概率性的。能用规则解决的,绝不交给模型,这是降低不稳定性的关键。
5. 三种方案的性能与稳定性实测对比
5.1 测试环境与指标定义
为了给面试官一个量化答案,我做过一轮简单对比。测试集是 200 条带工具调用需求的用户 query,指标有三个:
- 解析成功率:程序能否正确提取出工具名和参数
- 端到端延迟:从用户输入到最终回复的耗时
- 多轮稳定性:连续 5 轮对话后是否还能正确调用
5.2 实测数据与结论
| 方案 | 解析成功率 | 平均延迟 | 多轮稳定性 |
|---|---|---|---|
| 纯 Prompt | 87% | 1.2s | 一般 |
| Function Calling | 99% | 1.5s | 好 |
| 状态机 | 96% | 1.8s | 很好 |
数据说明几个问题:纯 Prompt 的解析成功率明显吃亏,那 13% 的失败基本都栽在格式上;Function Calling 成功率最高但延迟略高,因为多了一次模型往返;状态机延迟最高但稳定性最好,因为每步都可控。
面试时如果你能说出“我选 Function Calling 是因为解析成功率比纯 Prompt 高 12 个百分点,延迟只多 0.3 秒,性价比最高”,面试官会觉得你是真的跑过数据,而不是拍脑袋。
5.3 混合方案的落地建议
实际项目里我推荐混合:主链路用 Function Calling,兜底用状态机。具体来说,模型返回的 tool_calls 解析失败时,自动降级到状态机的规则抽取通道。这样既享受了原生能力的稳定,又有了兜底的保险。
6. 面试高频追问与避坑清单
6.1 面试官最爱追问的五个问题
- “模型不调用工具怎么办?”答:在提示词里加 few-shot 示例,或者提高工具描述的清晰度,必要时用强制调用参数。
- “工具执行失败了怎么处理?”答:把错误信息回填给模型,让它决定重试还是换工具,同时设置最大重试次数。
- “怎么防止模型乱调工具?”答:工具描述里写清楚适用条件,参数做严格校验,敏感操作加人工确认。
- “多工具怎么选?”答:靠工具描述的区分度,描述要写“什么时候用”而不只是“是什么”。
- “怎么测试?”答:构造覆盖各类意图的测试集,重点测边界 case 和格式异常。
6.2 我踩过的三个真实坑
第一个坑:工具描述写得太简略。早期我把get_weather的描述写成“查天气”,结果模型经常在用户问“今天热不热”时不调用工具,因为它觉得这不是“查天气”。后来改成“查询指定城市的实时温度、湿度、天气状况”,调用率立刻上来了。
第二个坑:参数类型不匹配。模型把date参数写成“明天”这种自然语言,而我的工具需要2024-01-01格式。解决办法是在参数描述里明确写“格式为 YYYY-MM-DD”,并在执行前做格式转换。
第三个坑:无限循环。工具执行失败后回填给模型,模型又发起同样的调用,来回死循环。必须设置最大迭代次数,比如 5 次,超过就强制返回错误提示。
6.3 一份可直接抄的检查清单
- 工具描述是否写清了“何时使用”
- 参数是否标注了类型和格式
- 是否有参数校验和格式转换
- 是否有解析失败的兜底
- 是否设置了最大重试次数
- 是否记录了完整的调用日志
- 敏感操作是否有确认机制
这份清单我每次上线前都会过一遍,能挡掉八成低级问题。
7. 从手写 Tool Use 看工程思维的考察点
面试官让你手写 Tool Use,表面考的是代码能力,实际考的是工程思维。你能不能把一个模糊的需求拆成清晰的模块,能不能预判边界情况,能不能在多个方案之间做权衡,这些才是真正拉开差距的地方。
我个人的体会是,方案没有绝对的好坏,只有适不适合。纯 Prompt 适合快速验证,Function Calling 适合生产主力,状态机适合复杂可控场景。能把三种方案的取舍讲清楚,比写出任何一种都更能打动面试官。
最后分享一个小技巧:面试时如果时间紧,先把方案二的骨架写出来,跑通主流程,再口头补充方案一和方案三的差异。这样既展示了动手能力,又展示了知识广度,性价比最高。