☰
手写 Tool Use 三种方案:从字符串解析到 ReAct 循环
2026/10/22 4:24:32 网站建设 项目流程

面试官让我手写一个 Tool Use,我用了 3 种方案

去年秋招面试某家做智能体平台的公司,前半场聊项目经历都很顺利,直到面试官冷不丁抛出一个问题:“给你 20 分钟,手写一个 Tool Use 的完整流程,你怎么设计?”我当时愣了一下——Tool Use 这个词我天天挂在嘴边,但真要我从零开始实现一版,脑子里还得现捋逻辑。幸运的是,那天我在白板上先后写出了三个版本的实现思路,面试官的表情从严肃慢慢变成了“有点意思”。这不仅是面试题,更是每个做大模型应用开发的同行都绕不过去的核心能力。今天把三种方案完整拆开聊聊,从最简单的字符串硬解析,到生产可用的结构化输出,再到带推理循环的 ReAct 框架,全程附代码和踩坑记录,正在准备面试或者工作中要做工具调用的朋友可以直接参考。

1. Tool Use 到底在解决什么问题

先厘清一个概念:我们常说的 Tool Use、Function Calling、工具调用,本质上都是同一件事——让大模型在对话过程中不仅仅输出文字,还能“发起动作”。模型本身是个文本生成器,你问它“北京今天适不适合穿羽绒服”,它只能凭训练时的知识回答一个大概率不准确的猜测;但如果它能调用一个天气查询工具,拿到实时的温度、风力、空气质量数据,再结合这些数据组织回答,准确率会完全不同。

一套完整的 Tool Use 流程,一般包含四个环节:模型根据用户输入理解意图、决定调用哪个工具、生成对应的调用参数、系统把参数拿去执行真实函数、把执行结果返回给模型继续生成答案。换句话说,模型是“大脑”,负责决策;系统是“手脚”,负责执行。这种分工早在没有大模型的时代就有类似实现——比如聊天机器人里的意图识别模块加 API 网关,只不过现在决策层换成了大模型,能力和泛化性都上了一个台阶。

我在实际项目中经常被问到“为什么要搞 Tool Use,直接让模型把答案都写在回复里不就行了”。答案很简单:模型的知识有截止日期,没有权限访问私有数据或第三方系统,更无法完成“创建订单”“发送邮件”“更新数据库”这类真实世界的副作用操作。Tool Use 是连接模型能力和外部世界的桥梁,没有它,大模型就永远只能纸上谈兵。

理解了这一点,面向面试或者面对设计需求时,你的框架就不再是“背 API”,而是“设计一套决策与执行的接口”。下面三个方案,就是从简单到完善的三个演进阶段,每个方案解决上一版的问题。

2. 方案一:Prompt 约定 + 字符串解析

2.1 实现原理:把工具调用当成一种特殊“语言”

第一种方案是最直白的:在系统提示词里约定好工具列表和目标输出格式,然后让模型严格按照该格式输出一段文本,代码里再解析这段文本,提取工具名和参数并执行。

比如我有两个工具,一个查天气、一个算日期差值,那么在 System Prompt 里我会写:

你是一个智能助手。当用户需要查询天气时,请输出: TOOL_CALL: get_weather ARGS: {"city": "北京", "date": "2025-06-03"} 当用户需要计算两个日期之间的天数时,请输出: TOOL_CALL: calc_date_diff ARGS: {"start": "2025-01-01", "end": "2025-06-03"} 如果不需要调用工具,请直接回答用户。

模型收到这样的约束后,如果用户问“北京明天天气怎么样”,大概率会输出上述固定格式。后端代码只需要识别TOOL_CALL和ARGS这两行,提取工具名称和 JSON 参数,然后路由到对应函数。

2.2 核心代码骨架

定义一个简单的工具注册表和解析器:

import json import re def get_weather(city: str, date: str) -> str: # 模拟真实天气查询 return f"{city} {date}:晴,-2℃ 到 11℃" def calc_date_diff(start: str, end: str) -> str: from datetime import datetime d1 = datetime.strptime(start, "%Y-%m-%d") d2 = datetime.strptime(end, "%Y-%m-%d") return f"相差 {(d2 - d1).days} 天" tools = { "get_weather": get_weather, "calc_date_diff": calc_date_diff, } def parse_and_execute(text: str): tool_match = re.search(r"TOOL_CALL:\s*(\w+)", text) args_match = re.search(r"ARGS:\s*(\{.*?\})", text, re.DOTALL) if not tool_match: return None tool_name = tool_match.group(1) args = json.loads(args_match.group(1)) if args_match else {} if tool_name not in tools: return f"未知工具: {tool_name}" return tools[tool_name](**args)

调用过程很简单:拿到模型输出,直接丢进parse_and_execute,把返回值拼到下一轮消息里即可。完整的对话循环大概是:

messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": "北京明天天气如何?"}] response = llm.chat(messages) tool_result = parse_and_execute(response) if tool_result: messages.append({"role": "assistant", "content": response}) messages.append({"role": "tool", "content": tool_result}) final_answer = llm.chat(messages)

2.3 这个方案的核心问题

如果用这个方案撑一个小 Demo,完全没问题;但放到生产环境或者面试深挖时,破绽很明显。

第一是格式脆弱。模型不是确定性程序,同一个问题换一种问法、换一个温度参数,生成的格式可能就直接不匹配了。你要是把TOOL_CALL写成小写、在ARGS里多了个空格、参数顺序换了一下,正则一崩,整个链路就断了。

第二是参数质量无法保证。模型可能有幻觉,用户问“北京明天下雨吗”,它可能生成{"city": "北京市", "date": "明天"},你的日期计算函数根本接不住“明天”这种自然语言日期。而且json.loads遇到格式稍微不规范的 JSON 就会抛异常。

第三是多工具、多轮调用的复杂度是失控的。当工具数量超过十个、一个用户问题需要连续调用多个工具时,单纯的字符串格式约定根本维护不了。

这个方案面试时能体现你理解“模型输出其实是文本”这个本质,但一定要主动说出它的局限,否则面试官会觉得你想得太浅。我当年讲完这个方案后,直接补了一句:“这个方案最大的问题是把格式安全押注在模型输出上,工程上不可控,所以我会考虑第二种方案。”

3. 方案二:结构化输出 + 参数校验

3.1 核心思路:把解析依赖从“文本”升级为“约束”

第二种方案解决的是格式和参数的可靠性问题。我们不赌模型一定会输出某种字符串,而是从模型侧和后端侧两端同时下手:模型侧让 API 返回一个结构化的 JSON,后端侧对返回 JSON 做严格校验,两项双保险。

现在主流的大模型 API 都支持某种形式的“结构化输出”。有的厂商叫 JSON Mode、有的叫 JSON Schema 约束、有的叫 Function Calling。无论名字如何,核心能力一致:你可以告诉模型“必须返回一个符合我指定 JSON Schema 的对象”,然后 API 会在解码层就强制保证输出是一个可解析的 JSON,并且尽量符合字段类型约束。

3.2 两种落地路径对比

路径工作原理适用场景注意点
JSON Mode / 结构化输出在请求参数里声明目标 JSON Schema单轮工具调用、参数明确的任务只保证语法合法,不保证字段值合理,需后端再校验
Function Calling API声明可用工具,模型自行决定是否调用并返回 tool_calls多工具、意图模糊需模型决策的场景依赖具体 API 实现,不同厂商字段结构不一致

我用得最多的是 Function Calling API。流程大概是先在请求里声明一组工具:

{ "type": "function", "function": { "name": "get_weather", "description": "查询指定城市和日期的天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "maxLength": 20}, "date": {"type": "string", "format": "date"} }, "required": ["city", "date"] } } }

模型收到用户消息后,会决定是否需要调用工具。如果需要,API 返回的响应里会多出一个tool_calls数组,里面是规范的结构化参数。后端拿到这个数组,直接执行对应函数,再把结果以tool角色消息传回模型。

3.3 后端参数校验不能省

就算有 API 的结构化约束,我依然会在执行工具前加一层挂载校验。原因很简单:JSON Schema 只能保证类型正确,不能保证值合理。模型可能返回{"city": "北京市北京市", "date": "2025-13-99"}——表面上是个字符串,实际完全没法用。所以校验逻辑我会写得比较严格:

def validate_and_execute(tool_name: str, args: dict): # 必填字段检查 required = schema_registry[tool_name]["required"] missing = [field for field in required if field not in args] if missing: return {"error": f"缺少必填参数: {missing}"} # 类型和取值校验 for field, rule in schema_registry[tool_name]["properties"].items(): if field in args: if rule["type"] == "string" and not isinstance(args[field], str): return {"error": f"参数 {field} 应该为字符串"} if rule.get("format") == "date": from datetime import datetime try: datetime.strptime(args[field], "%Y-%m-%d") except ValueError: return {"error": f"参数 {field} 日期格式应为 YYYY-MM-DD"} return tools[tool_name](**args)

3.4 边角情况要主动处理

结构化输出方案已经能扛住绝大多数生产场景,但有几个边角问题容易被忽略,我在项目里吃过亏。

第一是同一个工具出现多次时,API 返回的tool_calls可能是一个数组,得按顺序逐个执行,不要只取第一个。第二是工具执行结果非常长时,可能需要截断后才会传回给模型,否则远超上下文窗口。第三是即便有 JSON Schema,不同厂商对支持的约束类型不一致(比如有的不支持format枚举),跨平台需要做一层配置适配。

面试时,这个方案值得重点讲。我会先讲清楚“为什么要结构化”——纯粹靠模型自觉不可控;再讲清楚“结构化也不是万能的”——还要配后端校验和兜底。这一套下来,面试官通常会觉得你有系统工程经验。

4. 方案三:ReAct 推理-行动循环

4.1 从单次调用到多轮决策

前两个方案本质是“一次决策一次执行”,适合单工具任务。但现实中很多问题需要拆解:用户问“这周六和几个朋友去户外烧烤,你帮我看看天气行不行,顺便提醒我们要带什么装备”,这同时涉及天气查询、紫外线指数、装备推荐三个子任务,甚至要先查地点再查天气。模型需要边看结果边调整计划,这就是 ReAct 模式。

ReAct 的灵感来自人的思考方式:先想(Thought),再动手(Action),然后观察(Observation),根据观察再想下一步,直到有足够信息才给出最终回答。它要求模型输出结构包含四个字段——Thought、Action、Action Input、Observation,其中前三项由模型生成,Observation 是工具的真实返回。

4.2 循环框架代码

一个最简的 ReAct 循环大概三十行代码:

def run_react_loop(user_query: str, max_rounds: int = 5): messages = [ {"role": "system", "content": SYSTEM_PROMPT_WITH_TOOL_LIST}, {"role": "user", "content": user_query} ] for round_idx in range(max_rounds): response = llm.chat(messages) # 判断是最终回答还是需要调用工具 if "Final Answer:" in response: return response.split("Final Answer:", 1)[1].strip() # 解析 Action 与 Action Input action = extract_action(response) action_input = json.loads(extract_action_input(response)) observation = validate_and_execute(action, action_input) # 把本轮的思考、动作、观察结果拼回对话 messages.append({"role": "assistant", "content": response}) messages.append({"role": "tool", "content": f"Observation: {observation}"}) return "已达到最大循环轮数,无法完成推理。"

系统提示词里需要清楚告诉模型:如果认为信息不足,就输出Action和Action Input;如果认为可以回答,就输出Final Answer,不要同时出现。我在提示词里还会加一句“观察结果可能返回错误,请根据错误调整你的参数重新尝试”,这能显著减少一次失败就卡死的情况。

4.3 循环的三个守门员

多轮循环最大的风险是死循环。我上线 ReAct 服务时踩过一次坑:某个查询天气的任务,模型因为参数反复报错,连续调了十几个来回,不仅延迟爆炸,还烧了大量 token。后来我给循环加了三个守门员,大家在实现时最好直接抄作业:

  • 轮数上限:max_rounds = 5,超过直接终止并返回当前已收集信息。
  • 重复动作检测:记录最近三组(Action, Action Input),如果完全相同就打断,强制模型换思路。
  • 时间上限:整段循环超过 30 秒直接返回超时文案,避免上游请求堆积。

4.4 三方案如何配合使用

ReAct 不是替代前两个方案,而是将它们的核心组件当作“行动执行器”。我在生产项目里通常是这么搭配的:模型决策层跑 ReAct 循环,循环里的 Action 执行层用方案二的结构化校验机制,再在极简场景(比如单工具 Demo)保留方案一的轻量解析作为兜底。三者不是互斥,而是层层升级。

这个方案面试时特别加分,因为它表明你不只是会调 API,还理解“模型推理”和“外部执行”的闭环如何协作。不过要做好心理准备,面试官大概率会追问“循环如果卡住了怎么办”,把上面那套守门员机制讲清楚基本就稳了。

5. 实战踩坑与问题排查速查表

5.1 高频问题清单

我把这三年做 Tool Use 真实遇到的问题整理成了一张表,新项目调试时照着排查,能省很多时间:

现象根因解决方案
模型输出无法解析Prompt 约束不具体;模型温度过高示例少、格式约束写死、调低 temperature
JSON Schema 校验通过但执行报错参数值不合法,如日期格式传入非法字符串后端做业务级二次校验,format 规则查漏
工具返回结果太长,模型上下文溢出工具响应未截断对工具输出做截断或摘要,不完整的细节拼接进去
循环到第 4 轮还在调工具模型“不愿意”收敛检查是否缺失 Final Answer 格式示例,强制约束
调用不存在的工具名模型幻觉或工具名描述歧义校验工具名白名单,多模型时统一配置映射
并行调用多个工具时参数串了每个 tool_call 各自独立但执行结果未正确关联按 tool_call 的下标一对一回填 observation

5.2 关于参数校验的独家习惯

很多教程只教“格式校验”,但实际项目中最容易出问题的是“业务校验”。什么叫业务校验?比如你的工具要求city必须是国内城市列表中的一个;要求date必须在今天到未来 7 天之间;要求product_id必须存在于商品表中。这类校验和模型无关,是纯后端逻辑。

我习惯在工具注册表里给每个工具配一个独立的validate_args函数,而不是写一个通用的 JSON Schema 校验器。原因是不同工具的业务规则差异太大,通用 schema 写到最后往往是又长又难维护。每个工具自己负责自己的参数合法性,错误信息也直接准确,代码脏腑分离,排查时只需要看对应的validate_args。

5.3 工具返回错误时,要不要告诉模型

很多初学的小伙伴遇到工具报错就断层了。我的建议是:区分错误类型。如果是参数错误(比如日期格式不对),把错误作为 Observation 回传给模型,让模型自己纠正重试;如果是鉴权失败、服务不可用这类不可重试错误,直接向用户返回,不让模型空转。前者有助于模型学习输出正确格式,后者避免了无限重试带来的延迟。

另外,工具返回里尽量附带结构化状态码,比如{"status": "error", "code": "AUTH_FAILED"},比一串纯文本错误更适合模型理解。这个细节我问过不少候选人,大多数只说“把错误返回给模型”,能讲清楚“什么样的错误适合返回”的很少,属于真正的经验分。

6. 面试回答的加分享受与个人体会

把这三种方案完整过一遍,最后聊几点面试和实操心得。

面试官问“手写 Tool Use”,绝不是在考你能不能默写某个 API 的curl请求,他更想看你在一个开放性设计问题上如何做技术选型、如何拆解风险、如何做兜底。所以回答时不要只给最终方案,而是把你从方案一演进到方案三的思考轨迹展示出来——说明你是在权衡稳定性、复杂度、延迟、成本之后做出的决策,而不是“背了一个最优答案”。我实际面试时是直接在白板画了一条演进线:字符串解析(快速验证)→ 结构化输出(生产可用)→ ReAct 循环(复杂任务),每层都主动说出层级的代价是什么。

如果你是正在准备面试,建议动手把三个方案各实现一遍。有几个工具选典型的就行,比如get_weather、calc_date_diff、get_stock_price,把注册、解析、校验、执行、回填这条链路走通,比单纯刷题记概念扎实得多。代码量加起来不到两百行,但逻辑闭环对理解整个 AI 应用架构帮助极大。

最后再分享一个小技巧:Tool Use 实现完成后,一定要用“坏输入”来压测——输入乱七八糟的、多轮连续调用的、工具名不存在的、参数恶意注入的。你会发现压测过程中暴露的问题,比正常流程多得多。能在压测中稳定运行的实现,面试和上线才都算真正过关。

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

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

立即咨询