面试官把题目亮出来的时候,我愣了一下:“现在让你手写一个 Tool Use,你打算怎么实现?”注意,是“手写”,不是“调用某个厂商平台的现成 SDK”。Tool Use,也就是让大模型学会使用外部工具:模型负责判断该调用什么、参数是什么,系统负责真实执行,再把结果喂回给模型。这个能力几乎是所有 Agent 应用的地基——查天气、订机票、执行代码、搜索内部系统,掰开揉碎都是这一件事。我当时没有直接丢一套代码,而是把三条路线挨个讲了一遍,面试官最后点头的关键,恰恰是这三条路线背后的取舍逻辑。这篇文章就把当时讲的三种方案完整复盘一遍,适合正在准备大模型应用岗位面试的开发者,也适合想把 Agent 功能做扎实的工程同学。
1. Tool Use到底在考什么
1.1 工具调用的完整闭环长什么样
很多人一提 Tool Use 就想到“给模型传一个 tools 参数”,其实那只是表格里的一行配置。面试官说要手写,本质是让你亲手搭出下面这个闭环:
- 用户提出问题,模型判断自己缺哪块信息。
- 模型生成一个“调用请求”,包括工具名和参数。
- 外部系统接收请求,执行真实逻辑(查数据库、调接口、跑代码)。
- 执行结果作为新的上下文,重新交回模型。
- 模型基于结果组织语言,给用户最终回答。
这个闭环里最核心的三个东西,不是某个 API 的特殊字段,而是:工具如何描述、调用请求用什么协议传输、谁来负责执行和回填。你把这三个东西想明白,无论底层是哪个模型平台,都能搭出 Tool Use;想不明白,换个平台就得抓瞎。面试官考的就是这个“想明白”的过程。
还有一个容易被忽略的细节:模型本身没有“行动”能力。它本质上是一个文本生成器,不会真的去访问天气接口、也不会执行代码。Tool Use 做的事情,是把“文本生成”和“真实行动”之间那座桥架起来。桥的一端是意图,另一端是执行。理解这一点,后面三种方案你就能串起来了。
1.2 为什么面试官让你“手写”
直接答“我用某平台的 function calling 接口”也不是不行,但大概率会迎来追问:如果平台不支持怎么办?如果输出格式不标准怎么办?如果要在私有化环境部署、只能用一个小模型怎么办?
面试题里的“手写”不是在刁难你,而是在测试你理解的是“接口用法”还是“机制原理”。接口用法是一层皮,机制原理才是内核。所以我当场给出了三条路线:
| 路线 | 依赖程度 | 实现成本 | 稳定程度 |
|---|---|---|---|
| 方案一:原生 Tool Calling | 高,依赖平台专用能力 | 最低 | 最高(限该平台) |
| 方案二:JSON Mode 结构化输出 | 中,依赖平台支持 JSON 输出 | 中等 | 中高 |
| 方案三:Prompt + 文本协议 + 解析器 | 极低,几乎不依赖平台 | 最高 | 中等,但最可控 |
这三条路线不是互相替代的关系,而是层层兜底、层层递进的关系。讲的时候把顺序反过来:先讲最省事的,再讲更通用的,最后讲最底层的。面试官要看的,不只是你会不会写代码,而是你会不会根据场景做技术选型。
2. 方案一:直接吃原生 Tool Calling
2.1 思路与实现步骤
方案一的思路非常直白:很多商用模型平台在训练阶段就专门加了“工具调用”能力,你只要按照它规定的 schema 声明工具列表,模型输出时就会返回一个结构化的 tool_calls 对象,不用你自己解析协议。
先定义工具。几乎所有平台的 schema 都兼容 JSON Schema 风格,我习惯这样写:
tools = [ { "type": "function", "function": { "name": "weather_search", "description": "查询指定城市的实时天气情况", "parameters": { "type": "object", "properties": { "location": { "type": "string", "description": "城市名称,例如:北京、上海" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认 celsius" } }, "required": ["location"] } } } ]注意 description 一定要写清楚触发条件和参数语义。模型靠这段文字判断“什么时候调用”“参数该填什么”,写得太笼统,它就会在不需要查天气的时候也调用。
调用模型时把 tools 传进去,拿到结果后判断有没有 tool_calls:
resp = client.completions.create( model="某模型", messages=[ {"role": "user", "content": "北京今天多少度?"} ], tools=tools, tool_choice="auto" ) msg = resp.choices[0].message if getattr(msg, "tool_calls", None): for tc in msg.tool_calls: fn_name = tc.function.name fn_args = json.loads(tc.function.arguments) result = weather_search(**fn_args) # 把带 tool_calls 的 assistant 消息原样回传 messages.append(msg) # 回填工具结果 messages.append({ "role": "tool", "tool_call_id": tc.id, "content": json.dumps(result, ensure_ascii=False) }) # 把追加后的 messages 再次交给模型,得到最终回答 final_resp = client.completions.create( model="某模型", messages=messages, tools=tools ) print(final_resp.choices[0].message.content)关键是第二步:带 tool_calls 的 assistant 消息必须原样放进下一轮上下文,并且每条 tool 消息要对应正确的 tool_call_id。漏掉任何一个字段,平台就会报错或者模型上下文错乱。这一步不复杂,但最容易被忽略。
2.2 这个方案的边界和坑
方案一体验最好,因为结构化输出是模型平台自己保证的,你几乎不用写容错解析。但它的边界也很明显:完全绑定平台能力。私有化部署模型、开源自建模型,大概率不支持这种专用字段;就算支持,不同平台的返回字段名可能还不一样。
实际踩过的坑主要有三个。
第一个是参数幻觉。模型偶尔会编造一些 schema 里没有的参数,比如把“unit”写成“units”,因为模型看到“单位”就顺手补了个复数。解决方式是在解析 arguments 时做严格校验,只取 schema 中声明过的字段,不认识的字段一律丢弃。
第二个是工具执行异常。真实工具调用大概率会失败,网络不通、接口返回非 200、参数校验不过。很多初学者直接把异常抛出去导致整个对话崩溃。正确做法是捕获异常,把错误信息包装成工具结果回填给模型,让模型自己根据错误决定换参数重试还是更换工具。
第三个是上下文顺序。多轮对话时,messages 的拼接顺序必须是“用户消息 -> 带 tool_calls 的 assistant 消息 -> 对应 tool 结果消息 -> 再问模型”。我见过有人把 tool 结果直接塞到最前面,结果模型完全不知道这个结果对应哪一次调用。
提示:如果面试只答了这个方案,容易被追问“平台不支持怎么办”。所以方案一适合作为开场,但绝不能作为全部。
3. 方案二:JSON Mode 结构化输出
3.1 用“格式约束”代替专用通道
很多主流模型平台支持强制 JSON 输出的模式,也就是让模型“无论如何都要输出合法 JSON”。这个能力比专用 tool calling 更通用。思路是把工具调用协议搬到普通文本里:你告诉模型,需要调用工具时输出一个 JSON,不需要调用工具时输出另一个 JSON,然后用代码解析 JSON 并分发执行。
我当时的实现大致是这样的:
SYSTEM_PROMPT = """ 你是智能助手,可以通过调用工具完成用户请求。可选工具如下: - weather_search: 查询指定城市的实时天气。参数: {"location": "城市名", "unit": "celsius 或 fahrenheit"} 你的输出必须是严格的 JSON 对象,不能包含任何其他文字。 如果需要调用工具,输出格式为: {"action": "weather_search", "arguments": {"location": "北京", "unit": "celsius"}} 如果不需要调用工具,直接回答用户,输出格式为: {"action": "answer", "content": "你的回答内容"} """调用侧使用结构化输出模式:
resp = client.completions.create( model="某模型", response_format={"type": "json_object"}, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": "上海明天需要带伞吗?"} ] ) raw = resp.choices[0].message.content obj = json.loads(raw) if obj["action"] == "weather_search": result = weather_search(**obj["arguments"]) # 把结果作为普通文本回填,让模型基于结果组织答案 messages.append({"role": "user", "content": f"工具返回结果:{result},请据此回答用户。"}) final_resp = client.completions.create( model="某模型", response_format={"type": "json_object"}, messages=messages )方案二的核心魅力在于:把“工具调用”从平台特殊字段降级成了“一段有约定的文本”,模型只要会生成 JSON 就能配合你完成工具调用。跨平台迁成本低,自部署模型只要支持 JSON 输出,这套就能跑。
3.2 JSON Mode 的适用场景与缺陷
这个方案的稳定性上限取决于两件事:模型对 JSON 格式的遵从程度,以及你的提示词写得是否足够死。
先说遵从程度。强约束模型基本不会输出乱码,但弱约束模型偶尔会在 JSON 前后夹带解释性文字,比如“好的,根据您的要求,输出如下:{...}”。这种情况 json.loads 会直接抛异常。我当时写了一个预处理函数,先把所有中英文括号对换成半角,再尝试截取第一个“{”到最后一个“}”之间的子串,然后再去解析。
再说提示词。提示词里不能留太多“自由发挥”空间。“你可以根据需要输出”这种话千万不要加,要写“必须输出 JSON 对象,不能包含任何其他文字”。模型遵循的是规则而不是商量。
第三个缺陷是视觉上的“脏”:用户看到模型中间过程里的 JSON 会困惑。生产环境一般会先把 action 分类,如果是 answer 就渲染给用户;如果是工具调用就不渲染,等结果回来之后显示最终答案。这块要在产品层处理好。
不要指望 JSON Mode 帮你解决参数校验。模型输出的 arguments 里字段缺失、类型错误依然常见。我的习惯是在执行工具前加一层“字段白名单 + 必填项检查”,不满足就返回一个固定的错误 JSON 给模型,让它自行修正。这比在代码里堆防御逻辑更有效。
4. 方案三:纯 Prompt + 文本协议 + 解析器
4.1 不依赖任何平台的最朴素实现
如果面试官继续追问“如果模型也没有 JSON Mode 呢”,那就该上方案三了。这个方案我在开源自建模型的私有化项目里用过,核心逻辑只有一句话:用文本协议模拟工具调用,用正则和 JSON 解析从文本中提取调用指令。
工具描述部分和方案二类似,只是彻底回到普通文本风格。系统提示词长这样:
SYSTEM_PROMPT = """ 你是智能助手。你可以使用以下工具帮助用户: 工具列表: - weather_search: 查询指定城市的实时天气。参数说明:location 为城市名;unit 可选 celsius 或 fahrenheit。 当你需要调用工具时,必须严格输出如下格式,前后不要出现多余内容: <tool_call>{"name": "weather_search", "arguments": {"location": "北京", "unit": "celsius"}}</tool_call> 之后你会收到工具执行结果,请基于结果继续回答用户。 若不需要调用工具,直接正常回答即可。 """之所以用<tool_call>...</tool_call>这样的标签包裹,是为了给解析器一个明确的边界。模型有时候会在 JSON 前后加一些啰嗦话,标签能把真正需要解析的部分圈出来,正则提取后解析的成功率会高很多。
4.2 核心代码:解析器与执行循环
下面的代码是我在项目中实际用过的简化版本,用纯 Python 实现:
import json import re TOOLS = { "weather_search": { "description": "查询指定城市的实时天气", "handler": lambda location, unit="celsius": f"{location} 当前 26 摄氏度,晴", } } TOOL_PROMPT = "\n".join( f"- {name}: {meta['description']}" for name, meta in TOOLS.items() ) CALL_PATTERN = re.compile(r"<tool_call>(.*?)</tool_call>", re.S) def extract_tool_calls(text: str): calls = [] for match in CALL_PATTERN.finditer(text): raw = match.group(1).strip() try: obj = json.loads(raw) calls.append(obj) except json.JSONDecodeError: # 解析失败时把原文带出来,便于后续让模型修正 calls.append({"parse_error": True, "raw": raw}) return calls def execute_call(call: dict): name = call.get("name") if name not in TOOLS: return f"错误:工具 {name} 不存在,可选工具为:{list(TOOLS.keys())}" fn = TOOLS[name]["handler"] args = call.get("arguments", {}) return fn(**args) def run_agent(user_query, chat_fn, max_rounds=5): messages = [ {"role": "system", "content": SYSTEM_PROMPT + TOOL_PROMPT}, {"role": "user", "content": user_query}, ] for _ in range(max_rounds): reply = chat_fn(messages) calls = extract_tool_calls(reply) if not any("parse_error" not in c for c in calls): # 没有合法工具调用,视为正常回答 return reply messages.append({"role": "assistant", "content": reply}) for call in calls: if "parse_error" in call: result = "解析工具调用失败,请严格按照 <tool_call>{...}</tool_call> 格式重新输出。" else: try: result = execute_call(call) except Exception as exc: result = f"工具执行异常:{exc},请调整参数后重试。" messages.append({ "role": "user", "content": f"工具 {call.get('name', '未知')} 返回结果:{result}" }) return "已达到最大迭代轮数,请重新描述需求。"这里的 chat_fn 是一个抽象函数,实际项目里替换成任何模型平台的调用接口即可。把工具结果拼成“user 消息”而不是专门的 tool 消息,是因为纯手写方案的目标是兼容一切平台——没有 platform 支持,那就用最普通的角色通信。
4.3 容错设计:解析失败、模型不调用、工具报错
方案三最考验工程经验的地方在于容错,因为在没有专用结构化保证的情况下,模型输出“合法”的概率并不是 100%。
先说解析失败。模型有可能输出一个尾逗号的 JSON,或者把标签写成了<tool_call >(多了空格)。我的处理思路是:不追求一次性解析成功,而是把“解析失败”的信息回填给模型,让它重新输出。在上面的代码里,parse_error 分支就是这么设计的。这比你在解析器里写一百个正则补丁要稳健得多。
再说模型不按约定调用工具。比如用户问“北京天气”,模型直接回答“我不知道”。这时候可以在下一轮上下文里加一条补充消息:“请使用 weather_search 工具查询后再回答。”相当于给模型一个“纠偏”信号。这个要结合具体场景,不能无脑加,否则会阻塞正常的自由对话。
最后是工具执行报错。千万不要让异常直接抛出让整个进程崩溃。把异常文本当成工具结果返回给模型,模型就会基于文本自行调整。我见过模型在发现参数非法后自动把 location 标准化改成拼音,这种“模型自己纠错”的场面,只有在容错做好之后才会出现。
注意:循环必须设上限。模型一旦陷入反复调用同一个工具的死循环,token 消耗会非常快。我一般默认上限 5 轮,超过就终止并返回兜底话术。
5. 三种方案横向对比与选型
5.1 核心维度对比
三种方案都实现过之后,我用一张表把它们的差距表整理出来:
| 对比维度 | 方案一:原生 Tool Calling | 方案二:JSON Mode | 方案三:Prompt + 解析器 |
|---|---|---|---|
| 平台特殊能力依赖 | 依赖专用字段 | 依赖 JSON 输出能力 | 不依赖,纯文本可跑 |
| 输出结构化程度 | 最高,平台保证 | 高,但偶尔夹带杂质 | 中,需要自行清洗 |
| 开发量 | 低 | 中 | 高 |
| 解析容错成本 | 极低 | 中 | 高 |
| 跨模型迁移 | 差 | 中 | 最好 |
| 可控性 | 中 | 中 | 最高 |
| 上下文占用 | 低 | 中 | 中等偏高 |
不要只盯着“稳定程度”最高就选方案一。生产环境里经常面临的是“私有化部署 + 小模型 + 不能上公网”的组合,这时候方案一直接出局;而方案三虽然多写一些代码,但几乎不受外部条件制约,模型换掉、平台迁走,核心逻辑都还在。
5.2 面试与生产中怎么选
面试时我的回答顺序是这样的:先讲方案一,证明你了解行业主流做法;再讲方案二,证明你会在平台能力受限时找替代;最后落到方案三,证明你理解 Tool Use 的底层机制。这个顺序本身就是递进的,能引导面试官看到你“从应用层深入到实现层”的思考路径。
真实项目选型,我一般按这个经验判断:
- 项目基于某个商用大模型 API,且没有私有化部署需求,优先方案一,省时省力,稳定性最好。
- 需要同时兼容多家模型服务,或者偶尔切到开源模型,方案二是性价比最高的平衡点。
- 做 Agent 框架、对接自建模型、或者需要深度控制每一轮行为和输出格式,方案三才是正解。
还有一个容易被忽略的维度:上下文 token 成本。方案三因为要在系统提示词里维护一长串工具描述和协议说明,固定开销会比方案一大不少。如果工具数量超过二十个,工具描述本身就可能把上下文吃掉一小半。解决思路是“动态工具选择”——先把所有工具描述用向量检索或者关键词匹配筛一遍,只把最相关的 3 至 5 个工具塞进 prompt。这个优化我后面单独写一篇,但选型时心里要清楚这个成本差异。
6. 面试复盘:高频追问和避坑清单
6.1 面试官最爱追问的几个问题
讲完三种方案,面试官几乎必然会追问细节。这几个问题我整理成了“标准答案”:
追问一:模型想调用一个不存在的工具怎么办?在解析器里做注册表校验,遇到未注册工具,返回的错误信息不是“我不懂”,而是“可选工具列表是 X、Y、Z,请重新选择”。命名的错误也要反馈给模型,这样可以避免模型连续生成同一个无效调用。
追问二:工具执行很慢或者超时怎么办?执行器要加超时控制,不能无限等。超时后把超时信息作为工具结果返回,让模型判断是否需要换一个工具或换一种回答方式。外部接口永远不可靠,这一点是面试官最喜欢考察的工程意识。
追问三:如果模型需要同时调用多个工具呢?方案一平台层支持并行返回多个 tool_calls;方案二和三在协议里允许模型输出多条调用记录(比如写多个<tool_call>标签或者让 action 成为一个数组),解析器循环执行后统一回填。多工具并行能明显降低 Agent 的往返延迟。
追问四:流式输出场景怎么做?方案一平台层会流式返回 tool call 片段,需要自己攒到完整再执行;方案二和三则建议在检测到工具调用标签前静默缓存,等调用标签闭合后再执行工具,避免用户看到一半的 JSON。这点在工程落地时很关键。
追问五:工具结果不安全怎么办?工具内容可能包含用户可控的数据,绝不能直接当指令回灌给模型。工具结果一律当作数据处理,必要时加内容过滤。这块我在实际项目中吃过亏,后来统一加了一层“脏数据清洗”再回填上下文。
6.2 我踩过的真实坑
第一个坑是描述写得太长。最初我为了“让模型理解得更清楚”,把每个工具描述写成作文级长文本。结果模型确实理解了,但上下文 token 开销暴涨,单轮对话成本翻了好几倍。后来我把描述精简成“触发条件 + 参数说明”两段式,效果没降,成本降了,建议工具描述卡在 50 字以内。
第二个坑是忽略参数枚举约束。写 JSON Schema 的时候,凡是取值可列举的字段,比如单位、排序方式、语言类型,一定要用 enum 写死。不写枚举,模型就会自由发挥,unit 你能收到 “C”、“摄氏度”、“摄氏度 (°C)” 各种奇异形式。对模型说“你可以自由选择”永远是灾难,给它一个封闭选项列表才是正路。
第三个坑是重复工具调用。模型在某些情况下会反复调用同一个工具、传同样的参数。可能是上下文没有正确包含上一次的调用记录,也可能是模型在“迟疑”。我的解决办法是把已经执行过的调用摘要压缩进上下文,让模型看到“这个信息你已经查过了,请直接基于结果回答”,大部分死循环都能终止。
第四个坑是没有给工具执行器做幂等。真实生产环境里,一个工具可能会因为网络重试被执行两遍,比如“创建订单”这种有副作用的工具,重复调用就是事故。我的建议是所有写操作工具必须带幂等键,幂等键存在,第二次相同调用直接返回第一次的结果,不再真正执行。
回到面试本身。我个人在实际操作中的体会是:面试官抛出“手写 Tool Use”这道题时,他真正想看到的不是一个标准答案,而是你能不能在几分钟内把一个看似复杂的能力拆成“协议 + 解析 + 执行 + 回填”这四个普通环节。技术在迭代,模型在换,但 Tool Use 这个骨架在未来很长一段时间里不会变。最后再分享一个小技巧:面试手写代码时,不要把方案三写在最前面,先从方案一讲到方案三,这本身就是一种“架构能力”的展示。你越是被追问到细节,越要稳住——追问越多,说明面试官越认可你前面的回答。