☰
大模型Tool Use手写实战:从字符串解析到工程化调度
2026/10/17 15:16:40 网站建设 项目流程

1. 从一道面试题说起:为什么“手写 Tool Use”成了高频考点

“手写一个 Tool Use”——这六个字最近在技术面试里出现的频率高得离谱。我前后带过几批候选人做模拟面,也自己作为面试官参与过几轮筛选,发现一个很明显的趋势:以前大家考的是手写防抖、手写 Promise、手写发布订阅,现在越来越多的团队把题目换成了“让模型调用一个工具”。原因其实不复杂,大模型应用落地这两年从“聊天”走向“干活”,而“干活”的核心能力就是 Tool Use,也就是让模型在对话过程中判断该不该调用外部函数、调用哪个、传什么参数、拿到结果后怎么继续。

很多人第一次听到这个题目会懵:Tool Use 不是框架里一个参数就搞定的事吗?确实,像一些主流编排框架里,你只要把函数签名和描述丢进去,剩下的它帮你处理。但面试官让你手写,考的根本不是“你会不会用框架”,而是你懂不懂这背后的完整链路——模型输出怎么解析、参数怎么校验、工具怎么执行、结果怎么回灌、多轮怎么收敛、出错怎么兜底。这些东西框架帮你藏起来了,可一旦线上出问题,比如模型返回了非法 JSON、参数类型对不上、工具执行超时、模型陷入无限调用循环,你不懂底层就只能干瞪眼。

这篇文章我打算把这道题彻底拆开讲。我会给出三种不同层次的实现方案,从最朴素的字符串解析,到基于结构化输出的稳健方案,再到带完整调度循环的工程化方案。三种方案不是简单的“由差到好”,而是对应三种不同的面试回答策略和真实业务场景。看完你应该能做到:面试时根据岗位要求灵活选型,工作中能自己搭一套不依赖重型框架的 Tool Use 调度器,并且知道每一步为什么这么设计、坑在哪里。

适合谁看?如果你正在准备大模型应用方向的面试,或者你已经在做 Agent 相关开发但一直停留在“调框架 API”的层面,想搞清楚里面到底发生了什么,那这篇内容应该对你有用。我会尽量用大白话把原理讲透,代码部分给到可以直接跑的思路,参数选择也会说明白为什么。

2. 先搞清楚 Tool Use 到底在解决什么问题

2.1 模型本身不会“执行”,它只会“说”

这是理解一切的前提。大模型本质上是一个文本生成器,你给它一段上下文,它接着往下写。它没有手没有脚,不能真的去查数据库、发请求、读文件。所谓“模型调用工具”,真实发生的过程是:模型输出一段特定格式的文本,你的程序解析这段文本,发现“哦,它想调某个函数”,于是你的程序去执行,再把执行结果拼回上下文喂给模型。模型看到结果后,继续生成下一步。

所以 Tool Use 的本质是一套约定 + 一个调度循环。约定指的是“模型用什么格式表达调用意图”,调度循环指的是“程序怎么解析、执行、回灌、再问”。面试官让你手写,考的就是你对这套约定和循环的理解深度。

2.2 一个完整的 Tool Use 链路包含哪些环节

我把这条链路拆成六个环节,后面三种方案其实就是在这些环节上做不同程度的工程化:

  • 工具定义:把可用的函数整理成模型能看懂的结构,通常包括名称、功能描述、参数 schema。
  • 意图识别:模型根据用户输入和工具描述,判断是否需要调用工具、调用哪一个。
  • 参数生成:模型输出调用所需的参数,这一步最容易出格式问题。
  • 解析校验:程序把模型输出解析成结构化数据,并校验参数合法性。
  • 执行回灌:真正执行函数,把结果转成文本拼回对话历史。
  • 循环收敛:模型基于结果决定继续调用还是给出最终回答,直到结束。

这六个环节里,任何一个处理不好都会导致线上事故。比如参数生成环节,模型可能输出带 markdown 代码块的 JSON,也可能在 JSON 前后加一句“好的,我来调用”,还可能把数字写成字符串。解析校验环节如果太宽松,脏数据就进了函数;如果太严格,正常调用又被拒。循环收敛如果没设上限,模型可能反复调同一个工具停不下来。

2.3 为什么面试官偏爱这道题

我总结下来有三个原因。第一,它同时考察工程能力和对模型行为的理解,不是纯八股。第二,它有明显的层次,候选人可以从简单实现讲到复杂实现,面试官能看出深度。第三,它贴近真实业务,答得好说明你确实做过落地,不是背题。所以准备这道题,光背概念没用,得真的动手写过、踩过坑,讲出来的东西才有细节。

3. 方案一:基于字符串解析的极简实现

3.1 方案思路与适用场景

第一种方案最朴素:约定模型用某种固定格式输出调用意图,程序用字符串匹配或正则把它抠出来。比如约定模型输出这样的文本:

[TOOL_CALL] get_weather {"city": "杭州"} [/TOOL_CALL]

程序检测到[TOOL_CALL]标记,就截取中间内容,前半段是工具名,后半段是 JSON 参数。这种方案实现快、依赖少、逻辑透明,适合面试时快速写出一个能跑通的原型,也适合一些对稳定性要求不高、工具数量很少的内部脚本。

它的优势在于“所见即所得”。模型输出什么、程序怎么解析,全在明面上,调试起来非常直观。你打印一下模型原始输出,一眼就能看出问题在哪。相比之下,结构化输出方案有时候模型返回的东西被底层库处理过,反而不容易定位。

但它的缺点也很明显:脆弱。模型不一定会严格按你的格式来。它可能把标记写成小写,可能在中括号里加空格,可能在 JSON 里用单引号,可能一次输出多个调用,也可能在标记外面多写一句话。你得写一堆容错逻辑,而且永远有想不到的边界情况。

3.2 核心代码结构与解析逻辑

先定义工具。我用一个字典把工具名映射到函数和参数说明:

import json import re def get_weather(city: str) -> str: # 模拟查询,真实场景替换为实际调用 data = {"杭州": "晴,26度", "北京": "多云,22度"} return data.get(city, f"未找到{city}的天气数据") TOOLS = { "get_weather": { "fn": get_weather, "desc": "查询指定城市的天气,参数 city 为城市名", } }

解析部分用正则匹配标记:

CALL_PATTERN = re.compile( r"\[TOOL_CALL\]\s*(\w+)\s*(\{.*?\})\s*\[/TOOL_CALL\]", re.DOTALL, ) def parse_tool_call(text: str): match = CALL_PATTERN.search(text) if not match: return None name = match.group(1) raw_args = match.group(2) try: args = json.loads(raw_args) except json.JSONDecodeError: # 容错:尝试把单引号替换成双引号 try: args = json.loads(raw_args.replace("'", '"')) except json.JSONDecodeError: return None return name, args

调度循环就是“问模型 → 解析 → 执行 → 回灌 → 再问”:

def run(user_input: str, max_turns: int = 5): messages = [ {"role": "system", "content": build_system_prompt()}, {"role": "user", "content": user_input}, ] for _ in range(max_turns): reply = call_model(messages) messages.append({"role": "assistant", "content": reply}) parsed = parse_tool_call(reply) if not parsed: return reply # 没有调用意图,直接返回 name, args = parsed if name not in TOOLS: result = f"错误:未知工具 {name}" else: try: result = TOOLS[name]["fn"](**args) except Exception as e: result = f"错误:{e}" messages.append({ "role": "user", "content": f"[TOOL_RESULT] {result} [/TOOL_RESULT]", }) return "达到最大轮次,未收敛"

build_system_prompt里要把格式约定写清楚,比如“当你需要调用工具时,必须严格输出[TOOL_CALL] 工具名 {参数JSON} [/TOOL_CALL],不要输出其他内容”。这一步很关键,格式约定写得越明确,模型越容易遵守。

3.3 这个方案的坑与面试话术

坑一:模型不守格式。我实测下来,即使 system prompt 写得很死,模型还是有概率在标记前后加解释性文字,或者把 JSON 写成多行带缩进。正则里的.*?配合DOTALL能覆盖多行,但遇到嵌套大括号就会截断错误。所以参数里如果有嵌套对象,这个正则就不够用了,得换成括号配对扫描。

坑二:一次多个调用。模型可能一口气输出两个[TOOL_CALL]块。上面的search只取第一个,后面的被忽略。要支持并行调用,得用findall然后逐个执行。

坑三:参数类型漂移。模型可能把{"count": 5}写成{"count": "5"}。字符串解析方案里,你得在调用前做类型转换,或者干脆在函数内部做兼容。

面试时如果选这个方案,话术可以这样组织:“我先给一个最小可用版本,用约定标记加正则解析,优点是透明可控、依赖为零,适合快速验证。但它对模型输出格式的鲁棒性有限,所以如果要求生产级稳定性,我会升级到结构化输出方案。”这样既展示了动手能力,又体现了你对局限性的清醒认知,比一上来就堆复杂方案更容易拿分。

4. 方案二:基于结构化输出的稳健实现

4.1 为什么要用结构化输出

方案一的根本问题是“把格式约束寄托在模型的自觉上”。而结构化输出(Structured Output)的思路是:让模型在生成阶段就受约束,直接产出符合 schema 的 JSON,而不是先自由生成再靠正则去抠。现在主流模型服务大多支持某种形式的 JSON 模式或函数调用协议,底层通过受限解码保证输出一定是合法 JSON。

这个方案在面试里是加分项,因为它说明你知道“与其事后解析,不如事前约束”这个工程原则。而且它天然解决了方案一的大部分格式问题:不用写正则、不用容错单引号、不用处理标记外的废话。

4.2 工具 schema 的设计要点

结构化输出方案里,工具定义通常长这样:

tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的天气情况", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如 杭州", } }, "required": ["city"], }, }, } ]

这里有几个设计要点值得展开。description 要写“什么时候用”而不只是“是什么”。很多人只写“查询天气”,模型在用户问“明天出门要带伞吗”时可能不确定该不该调。写成“当用户询问某地天气、气温、是否下雨等出行相关问题时使用”,命中率会明显提升。

参数 description 要带示例。city字段写“城市名称,例如 杭州”,比只写“城市名称”效果好,因为模型会模仿示例的粒度。如果参数有枚举值,一定要在 schema 里列出来,比如"enum": ["celsius", "fahrenheit"],否则模型可能自创单位。

required 要精确。把可选参数误标为必填,模型会硬编一个值;把必填参数漏标,模型可能不传导致执行报错。这个细节在面试里能体现你是否真的调过。

4.3 解析与执行的稳健写法

结构化输出下,模型返回的调用意图通常在响应对象的一个专门字段里,不再是纯文本。伪代码大概是这样:

def run_structured(user_input: str, max_turns: int = 5): messages = [{"role": "user", "content": user_input}] for _ in range(max_turns): response = call_model(messages, tools=tools) tool_calls = response.get("tool_calls") if not tool_calls: return response["content"] messages.append(response) # 保留 assistant 的调用意图 for call in tool_calls: name = call["function"]["name"] try: args = json.loads(call["function"]["arguments"]) except json.JSONDecodeError: args = {} result = execute_tool(name, args) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": str(result), }) return "达到最大轮次"

注意tool_call_id这个字段。当模型一次发起多个调用时,回灌结果必须带上对应的 id,模型才能把结果和调用对应起来。漏掉 id 或者 id 对不上,模型会困惑,可能重复调用或者给出错误结论。这是很多人第一次写多工具调用时踩的坑。

execute_tool里要做参数校验。即使 schema 约束了类型,模型仍可能传超出范围的值,比如把温度单位传成 schema 里没有的字符串。所以函数内部该做的边界检查一个都不能少:

def execute_tool(name, args): if name not in TOOLS: return f"错误:未知工具 {name}" fn = TOOLS[name]["fn"] # 简单校验必填参数 required = TOOLS[name].get("required", []) for key in required: if key not in args: return f"错误:缺少必填参数 {key}" try: return fn(**args) except TypeError as e: return f"错误:参数不匹配 {e}" except Exception as e: return f"错误:执行失败 {e}"

4.4 结构化输出也不是银弹

这里要泼一盆冷水。结构化输出解决了格式问题,但没解决语义问题。模型可能调用了一个语法完全正确、但逻辑上不该调的工具。比如用户问“今天天气怎么样”,模型却调了get_weather但把 city 传成了“今天”。schema 校验通过,执行却返回“未找到”,然后模型可能基于这个错误结果编一个答案。

另一个问题是并行调用的顺序依赖。模型可能同时发起“查天气”和“查温度”两个调用,但如果第二个依赖第一个的结果,并行执行就会出错。这种情况需要在工具描述里写清楚依赖关系,或者干脆在调度层做串行化。我一般建议:除非工具之间确实独立,否则默认串行执行,稳字当头。

面试话术上,这个方案可以这样讲:“结构化输出把格式约束前置到生成阶段,大幅降低了解析成本,但它只保证语法合法,不保证语义正确,所以参数校验和业务兜底仍然要做。另外多工具调用时要注意 id 对应和顺序依赖。”这段话能体现你对方案边界的理解,比单纯说“这个方案更好”有说服力得多。

5. 方案三:带完整调度循环的工程化实现

5.1 工程化方案要解决哪些额外问题

前两个方案能跑通 demo,但离生产还有距离。工程化方案要额外处理这些问题:循环控制(防止无限调用)、超时与重试(工具执行可能慢或失败)、上下文管理(对话历史越来越长怎么办)、可观测性(出问题怎么排查)、并发与幂等(同一请求重复执行怎么办)。面试官如果问“上线要注意什么”,答的就是这些。

5.2 循环控制与终止条件设计

最危险的情况是模型陷入死循环:调用工具 → 结果不理想 → 再调用 → 再不理想。我见过模型连续十几次调用同一个查询工具,每次参数微调一点点。所以必须有硬性终止条件。

我的做法是三层控制。第一层是最大轮次,比如 8 轮,超过就强制返回当前最好结果或报错。第二层是重复调用检测,记录最近几次调用的工具名和参数哈希,如果连续两次完全相同,直接中断并提示模型“你刚刚已经调用过这个工具且参数相同,请基于已有结果回答”。第三层是无进展检测,如果连续几轮工具返回的都是错误,就停止调用,让模型基于错误信息给出解释。

def should_stop(history, max_turns): if len(history) >= max_turns: return True, "达到最大轮次" recent = history[-2:] if len(recent) == 2: sig0 = (recent[0]["name"], json.dumps(recent[0]["args"], sort_keys=True)) sig1 = (recent[1]["name"], json.dumps(recent[1]["args"], sort_keys=True)) if sig0 == sig1: return True, "检测到重复调用" return False, ""

5.3 超时、重试与错误回灌策略

工具执行必须设超时。一个查数据库的函数如果卡住 30 秒,整个请求就废了。用线程池或者异步超时机制包一层:

from concurrent.futures import ThreadPoolExecutor, TimeoutError executor = ThreadPoolExecutor(max_workers=4) def execute_with_timeout(fn, args, timeout=5): future = executor.submit(fn, **args) try: return future.result(timeout=timeout) except TimeoutError: return "错误:工具执行超时" except Exception as e: return f"错误:{e}"

重试要谨慎。查询类工具重试通常安全,写入类工具重试可能导致重复下单、重复扣款。所以重试策略要按工具分类,写操作要么不重试,要么带幂等键。这个点在面试里提一句,面试官会知道你考虑过真实业务风险。

错误回灌的方式也很讲究。不要把 Python 的完整堆栈直接丢给模型,那会污染上下文还可能泄露内部信息。应该转成人类可读的简短描述,比如“查询失败:目标城市不存在”。模型拿到这种信息,更容易给出合理的用户回复,而不是被堆栈吓到乱答。

5.4 上下文管理与可观测性

对话历史会随着工具调用不断增长。一个复杂任务跑下来,上下文可能几千 token。工程上要做裁剪:保留 system prompt、最近几轮完整对话、以及所有工具调用的关键结果,中间的过程性内容可以摘要或丢弃。简单做法是设一个 token 上限,超了就从头删最早的非 system 消息。

可观测性方面,每次调用都要记录:请求 id、轮次、模型原始输出、解析后的工具名和参数、执行耗时、执行结果、是否命中终止条件。这些日志在排查“为什么模型答错了”时是救命稻草。我一般会把它们结构化输出成 JSON 行,方便后续检索。

import logging, time def log_step(req_id, turn, name, args, result, elapsed): logging.info(json.dumps({ "req_id": req_id, "turn": turn, "tool": name, "args": args, "result": str(result)[:200], "elapsed_ms": int(elapsed * 1000), }, ensure_ascii=False))

5.5 三种方案对比与选型建议

维度方案一 字符串解析方案二 结构化输出方案三 工程化调度
实现复杂度低中高
格式鲁棒性差好好
语义正确性保障无参数校验校验+循环控制
多工具支持弱强强
生产可用性仅原型中小规模可用大规模可用
面试展示价值体现基础体现工程意识体现落地经验

选型上我的建议很直接:面试时间紧、要求快速出活,用方案一讲清原理再点出局限;面试官追问稳定性,切到方案二讲 schema 和校验;如果岗位是 Agent 方向或者要求讲上线经验,直接上方案三。真实项目里,除非有特殊约束,我一般从方案二起步,按需补方案三的循环控制和可观测性。

6. 面试现场的高频追问与排查技巧

6.1 常见追问与应对

追问一:模型不调用工具怎么办?先检查工具描述是否清晰,尤其是“什么时候用”有没有写。其次检查 system prompt 有没有引导模型优先考虑工具。还可以在用户输入里加提示,比如“请使用可用工具回答”。如果都不行,可能是模型能力问题,换一个指令遵循更强的模型。

追问二:模型调用错工具怎么办?通常是工具描述之间边界模糊。比如同时有“查天气”和“查气候”,模型容易混。解决办法是在描述里写清区别和适用场景,必要时在工具名上做区分。

追问三:参数总是缺字段怎么办?检查 required 是否标对,参数 description 是否给了示例。有时候是模型把可选参数当必填,或者反过来。可以在 system prompt 里强调“只传必要的参数”。

追问四:怎么测试这套逻辑?我会构造三类用例:正常调用、边界参数(空值、超长、特殊字符)、异常路径(工具报错、超时、未知工具)。每类至少覆盖几个典型场景,确保解析、执行、回灌、终止都走到。

6.2 排查速查表

现象可能原因排查方向
模型不调用工具描述不清/提示不足检查工具描述和 system prompt
解析失败格式不符/正则太严打印模型原始输出
参数类型错误schema 不严/模型漂移加类型校验和转换
无限循环无终止条件/结果不理想加轮次上限和重复检测
结果对不上tool_call_id 缺失检查回灌消息的 id
执行超时工具本身慢加超时和异步

6.3 几个我踩过的坑

第一个坑:把工具结果直接 str() 后回灌,遇到复杂对象会很难看。比如返回一个嵌套字典,str 出来是一大坨,模型解析起来费劲。更好的做法是转成紧凑 JSON 或者提炼关键字段。

第二个坑:system prompt 里工具描述和 tools 参数里的描述重复且不一致。模型收到两份矛盾的信息会犯迷糊。统一在一处维护,另一处引用。

第三个坑:忘了处理模型直接给出最终答案但没调工具的情况。这在方案一的循环里如果判断逻辑写错,会把正常回答当成“没解析到调用”而直接返回,看似没问题,但如果模型其实是想先解释再调用,就会漏掉调用。所以判断“是否结束”要看模型是否明确给出最终回答,而不是简单看有没有解析到调用。

第四个坑:多轮对话里历史工具结果越积越多。有一次一个任务跑了六轮,上下文里塞了六份工具结果,模型开始混淆哪份是哪份。后来我在回灌时给每个结果加上工具名和轮次标记,模型就清楚多了。

7. 写在最后的一点个人体会

这道题我前前后后在不同场合被问过、也问过别人很多次。我的体会是,面试官真正想看的不是你写出多花哨的代码,而是你能不能把“模型输出文本 → 程序解析 → 执行 → 回灌 → 再问”这条链路讲清楚,并且知道每个环节可能出什么问题、怎么兜底。三种方案与其说是三个答案,不如说是三个深度层次,你可以根据面试节奏逐步展开。

如果你正在准备这类面试,我的建议是至少亲手把方案一和方案二各写一遍,跑通一个真实的小工具,比如查天气或者算数。跑的过程中你自然会遇到格式、类型、循环这些问题,这些亲身踩过的坑,讲出来比背任何八股都生动。至于方案三的那些工程细节,平时做项目时多留意日志和异常处理,面试时自然有话可说。

最后一个实用小技巧:面试时如果一时想不起完整实现,可以先把六个环节画出来(工具定义、意图识别、参数生成、解析校验、执行回灌、循环收敛),然后说“我按这个链路逐个实现”,面试官会觉得你思路清晰,比闷头写代码强得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询