☰
大模型Tool Use三种实现方案:从提示词约定到状态机编排
2026/10/20 12:42:46 网站建设 项目流程

1. 面试间里的那道手写题,为什么偏偏是 Tool Use

面试官把笔递过来,说“手写一个 Tool Use 吧”,那一刻我脑子里其实闪过好几个念头。Tool Use,也就是让大模型调用外部工具的能力,这两年几乎成了 AI 应用开发岗的必考题。它不像“手写快排”那样有标准答案,也不像“讲讲项目”那样可以自由发挥,它卡在一个很微妙的位置:既考你对大模型交互协议的理解,又考你工程落地的能力,还顺带看看你会不会在约束条件下做取舍。

我后来复盘这场面试,发现面试官真正想看的不是“你能不能背出某个框架的 API”,而是“给你一个模糊的需求,你能不能在有限时间里搭出一个能跑、能扩展、还能讲清楚为什么这么设计的方案”。这跟实际工作中的场景几乎一模一样——产品经理丢过来一句“让 AI 能查天气、能算数、能查数据库”,剩下的全靠你自己拆。

所以这篇文章不打算写成某个 SDK 的使用手册,而是把我当时在纸上(后来又在电脑上验证)写过的三种方案完整摊开:基于提示词约定的轻量方案、基于函数调用协议的标准方案、基于状态机编排的工程化方案。三种方案没有绝对优劣,它们分别对应不同的团队规模、不同的模型能力、不同的迭代阶段。我会把每种方案的代码骨架、关键取舍、以及我踩过的坑都写出来,你拿去就能改,改完就能用。

如果你正在准备 AI 应用方向的面试,或者刚接手一个需要让模型“动手做事”的需求,这篇内容应该能帮你少走不少弯路。下面我按“从轻到重”的顺序讲,每一种都会说清楚它解决什么问题、在什么场景下会崩、以及怎么判断该不该升级到下一层。

2. 方案一:提示词约定式 Tool Use,最快跑通但也最容易翻车

2.1 核心思路:把工具说明写进系统提示词,让模型输出结构化文本

这是最朴素的做法,也是我在面试纸上最先写出来的版本。核心逻辑只有一句话:在系统提示词里告诉模型有哪些工具、每个工具需要什么参数,然后要求模型在需要调用时输出一段约定格式的文本,程序侧解析这段文本并执行,再把结果拼回对话。

听起来很简单对吧?但真正写起来,细节全在“约定格式”这四个字上。我当时的约定是这样的:

你可以使用以下工具: - get_weather(city: string): 查询指定城市的天气 - calculate(expression: string): 计算数学表达式 当你需要调用工具时,请严格输出以下 JSON 格式,不要输出任何其他内容: {"tool": "工具名", "args": {"参数名": "参数值"}}

程序侧拿到模型输出后,用json.loads解析,判断tool字段,分发到对应的处理函数,执行完把结果以{"tool_result": "..."}的形式塞回对话历史,再让模型继续生成。

这个方案最大的好处是零依赖。你不需要任何特定的 SDK,不需要模型支持 function calling,甚至用最基础的对话接口就能跑。我在面试时特意强调了这一点:如果团队用的是自部署的开源模型,或者接口能力受限,这是唯一能快速落地的路径。

2.2 解析环节的容错处理,才是这个方案真正的工程量

纸上写方案的时候,解析逻辑三行就写完了。但我在电脑上实测的时候发现,模型输出根本不会那么听话。它会加 markdown 代码块、会在 JSON 前后加解释性文字、会把单引号当双引号用、会在参数值里塞换行符。我统计过一轮,在没有任何约束强化的情况下,原始输出的可直接解析率大概只有六成左右。

所以这个方案真正的核心不是提示词,而是解析层的鲁棒性。我后来总结了一套“三层剥离”的解析策略:

  1. 第一层:正则提取。用\{[\s\S]*\}把最外层的花括号内容抠出来,忽略前后所有废话。
  2. 第二层:格式修复。把单引号替换成双引号(注意要避开字符串内部的单引号,简单做法是只替换键名周围的),去掉尾随逗号,补全缺失的括号。
  3. 第三层:降级兜底。如果还是解析失败,就把原始输出原样返回给模型,并在提示词里追加一句“你上次的输出格式有误,请只输出合法 JSON”,让它重试一次。
import json import re def parse_tool_call(text): # 第一层:提取花括号内容 match = re.search(r'\{[\s\S]*\}', text) if not match: return None raw = match.group() # 第二层:尝试直接解析 try: return json.loads(raw) except json.JSONDecodeError: pass # 第三层:修复常见格式问题 fixed = raw.replace("'", '"') fixed = re.sub(r',\s*([}\]])', r'\1', fixed) try: return json.loads(fixed) except json.JSONDecodeError: return None

这段代码看着不起眼,但它是我实测下来能把解析成功率从六成拉到九成五以上的关键。面试的时候我把这段讲出来,面试官明显点了点头——他要的就是这种“知道哪里会出问题”的细节。

2.3 这个方案什么时候必须放弃

提示词约定式方案有一个致命缺陷:它把“是否调用工具”和“调用哪个工具”完全交给了模型的自由生成。模型心情好的时候一切正常,心情不好的时候它会自己编一个工具名,或者把参数名写错,甚至直接编造一个工具执行结果告诉你“今天北京晴,25 度”。

我在测试里遇到过最离谱的一次,是模型在没有任何工具调用需求的情况下,主动输出了一个{"tool": "get_weather", "args": {"city": "北京"}},然后自己接着编了一段天气结果。这种“幻觉调用”在提示词方案里几乎无法根治,只能靠后置校验去拦。

所以我的判断标准很明确:如果工具调用涉及真实副作用(写数据库、发请求、扣费),或者对准确性要求高,这个方案只能用来做原型验证,不能上生产。它适合的场景是:内部工具、Demo 演示、模型能力受限时的临时方案。

3. 方案二:函数调用协议,把“格式约束”交给模型厂商

3.1 Function Calling 到底解决了什么问题

方案一最大的痛点是“模型不听话”,而函数调用协议的本质,是把格式约束从提示词层面下沉到模型训练层面。你在请求里传入一个tools数组,里面用 JSON Schema 描述每个工具的名称、参数、类型,模型在生成时会被约束只能输出符合这个 schema 的调用结构,而不是自由发挥。

我用的是最常见的函数调用格式,请求体大概长这样:

tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称,如北京"}, "unit": {"type": "string", "enum": ["celsius", "fahrenheit"]} }, "required": ["city"] } } } ] response = client.chat.completions.create( model="your-model", messages=messages, tools=tools, tool_choice="auto" )

返回结果里会有一个tool_calls字段,里面是结构化的调用请求,你直接解析就行,不需要再做任何正则和修复。这一步的体验提升是巨大的——解析成功率从九成五直接变成接近百分之百。

3.2 多轮工具调用的消息拼接,是最容易写错的地方

函数调用协议真正麻烦的地方不在单次调用,而在多轮。当模型决定调用工具后,你需要:

  1. 把模型的assistant消息(含tool_calls)原样追加到消息历史;
  2. 执行工具,拿到结果;
  3. 构造一条role: "tool"的消息,带上tool_call_id和结果内容;
  4. 再次请求模型,让它基于工具结果生成最终回复。

这四步里,第三步的tool_call_id必须和模型返回的 id 严格对应,否则接口会直接报错。我见过不少人在这一步翻车,尤其是当模型一次返回多个tool_calls的时候,必须为每一个都构造对应的tool消息,顺序还不能乱。

# 假设 response 是模型返回的 assistant 消息 messages.append(response.choices[0].message) for tool_call in response.choices[0].message.tool_calls: func_name = tool_call.function.name args = json.loads(tool_call.function.arguments) result = dispatch(func_name, args) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) }) # 再次请求,拿到最终自然语言回复 final = client.chat.completions.create(model="your-model", messages=messages)

注意:tool_call.function.arguments是一个 JSON 字符串,不是字典,必须再json.loads一次。这个细节我在面试时特意提了,因为它是实际写代码时第一个会踩的坑。

3.3 并行调用与串行调用的取舍

函数调用协议默认支持模型一次返回多个tool_calls,也就是并行调用。比如你问“北京和上海今天天气怎么样”,模型可能一次性返回两个get_weather调用。这时候你有两个选择:

  • 并行执行:用线程池或异步同时跑两个工具,速度快,但要求工具之间没有依赖;
  • 串行执行:一个一个跑,慢但逻辑简单,适合工具有状态依赖的场景。

我的经验是,默认走并行,但要在工具注册表里给每个工具标记一个parallel_safe字段。像查天气、算数这种无副作用的工具可以并行;像“先创建订单再扣库存”这种有依赖的,必须串行。这个标记看起来多余,但当你工具数量超过十个之后,它会帮你省掉大量排查时间。

另外还有一个容易被忽略的点:并行调用时,工具执行结果的消息顺序必须和tool_calls的顺序一致。虽然协议上说是靠tool_call_id匹配,但有些模型实现里对顺序敏感,乱序会导致模型理解错乱。我一般会用一个列表按顺序收集结果,最后统一 append。

4. 方案三:状态机编排,把 Tool Use 当成一个可观测的流程

4.1 为什么前两种方案在复杂场景下会失控

方案二已经能覆盖大部分场景了,但我在实际项目里遇到过一类需求,它让函数调用协议也显得力不从心:多步骤、有条件分支、需要人工介入、且每一步都要可追溯。

举个例子,一个“智能报销助手”需要:先查员工差旅标准,再根据发票金额判断是否需要审批,如果需要审批就暂停等待人工确认,确认后再写入报销单。这种流程里,“调用哪个工具”不是模型一次决策能搞定的,它需要状态——当前走到哪一步、上一步结果是什么、下一步的候选动作有哪些。

这时候如果还让模型自由决定,就会出现“跳步”“重复调用”“忘记上一步结果”等问题。我当时的解法是引入一个轻量状态机,把 Tool Use 从“模型自由决策”变成“在状态约束下的受控决策”。

4.2 状态机的三个核心要素:状态、转移、工具绑定

我设计的结构很简单,用一个字典描述所有状态:

STATES = { "start": { "allowed_tools": ["query_travel_standard"], "next": "check_amount" }, "check_amount": { "allowed_tools": ["check_invoice_amount"], "next": "need_approval" }, "need_approval": { "allowed_tools": ["request_human_approval"], "next": "write_record" }, "write_record": { "allowed_tools": ["create_reimbursement"], "next": "end" } }

每一轮请求模型时,我只把当前状态允许的工具传给tools参数,模型的选择空间被大幅压缩,出错概率自然下降。同时,每次状态转移都记录一条日志,包含当前状态、模型决策、工具结果、时间戳。这套日志后来成了我们排查线上问题的核心依据——当用户说“它怎么没走审批”时,我直接翻日志就能定位到是哪一步的模型决策偏了。

4.3 人工介入点的设计,比想象中更需要克制

状态机方案里最微妙的是“人工介入”。我一开始的设计是:只要金额超过阈值就暂停,等人工点确认。结果上线后发现,人工确认的等待时间不可控,而模型侧的超时和重试逻辑又很复杂,经常出现“人工还没点,模型已经重试了三次”的混乱。

后来我改成异步挂起 + 事件驱动:状态机走到审批节点时,把当前上下文序列化存起来,直接返回一个“等待审批”的响应给前端;人工确认后,通过一个独立的事件触发状态机从断点恢复。这样模型侧完全不感知等待,逻辑清晰很多。

def resume_from_checkpoint(checkpoint_id, approved): ctx = load_checkpoint(checkpoint_id) if approved: ctx["state"] = STATES[ctx["state"]]["next"] else: ctx["state"] = "rejected" return run_state_machine(ctx)

这个改动看起来只是工程细节,但它直接决定了这套方案能不能在真实业务里跑通。我在面试时讲到这里,面试官问了一句“那你怎么保证恢复时上下文没丢”,我把序列化方案和幂等设计讲了一遍,这轮基本就稳了。

4.4 三种方案的横向对比与选型建议

把三种方案放在一起看,它们的差异其实非常清晰:

维度提示词约定式函数调用协议状态机编排
实现成本极低低中高
解析可靠性依赖容错,约九成接近百分之百接近百分之百
多步流程支持弱中强
可观测性差中强
人工介入难难原生支持
适用阶段原型验证常规生产复杂业务

我的选型建议是:先用方案一验证需求是否成立,确认要上生产就切方案二,当流程步骤超过三步或者需要人工介入时,再引入方案三。不要一上来就上状态机,那会让简单问题复杂化;也不要明明需要审批流还硬用函数调用,那是在给自己挖坑。

5. 面试官真正在意的几个细节,以及我踩过的坑

5.1 工具描述的质量,直接决定模型选得对不对

这一点我在三种方案里都反复强调:工具描述不是写给人看的,是写给模型看的。我见过太多人把description写成“查询天气”,然后抱怨模型总是选错工具。正确的写法应该包含:这个工具做什么、什么情况下用、什么情况下不要用、参数的含义和格式。

比如get_weather的描述我会写成:“查询指定城市的实时天气状况,包括温度和天气现象。当用户询问当前天气、温度、是否下雨时使用。不要用于查询历史天气或未来预报。” 后面这句“不要用于”特别关键,它能显著降低误调用率。

5.2 参数校验不能省,模型给的参数不一定合法

即使有 JSON Schema 约束,模型也可能给出语义上不合法的参数。比如city传了一个不存在的城市名,或者unit传了枚举外的值。我的做法是在工具执行函数入口做一层校验,不合法就返回一个结构化的错误信息给模型,让它自己纠正。

def get_weather(city, unit="celsius"): if city not in SUPPORTED_CITIES: return {"error": f"不支持的城市:{city},请从支持列表中选取"} if unit not in ("celsius", "fahrenheit"): return {"error": "unit 只能是 celsius 或 fahrenheit"} # 正常逻辑

这个错误信息会作为tool消息回传给模型,模型看到后通常会重新发起一次正确的调用。这比直接抛异常让整个流程崩掉要优雅得多。

5.3 超时和重试,是线上稳定性的生命线

工具调用本质上是外部依赖,外部依赖就会超时。我在方案二和方案三里都加了超时控制:单个工具执行超过 5 秒就中断,返回超时错误给模型;模型侧如果连续两次调用同一个工具都失败,就强制结束流程并返回兜底话术。

重试策略上,我区分了“可重试错误”和“不可重试错误”。网络抖动、临时限流属于可重试,参数错误、权限不足属于不可重试。可重试的最多重试两次,且要加退避;不可重试的直接返回错误让模型处理。这套逻辑写起来不复杂,但它是区分“玩具”和“产品”的分水岭。

5.4 我踩过的最大的一个坑:消息历史膨胀

这个坑我在方案二里踩得最狠。多轮工具调用会把每一次的assistant消息、tool消息都追加到历史里,几轮下来消息列表就变得非常长,不仅 token 消耗飙升,模型还容易“迷失”在历史里,忘记当前该做什么。

我的解法是对工具结果做摘要压缩。工具返回的原始结果可能很长(比如一个查询返回了 50 条记录),但模型真正需要的往往只是其中几个关键字段。我在工具执行层加了一个summarize函数,把结果压缩成模型能理解的最简形式再回传。这一招把平均 token 消耗降了将近一半,效果立竿见影。

6. 如果让我重新答一次这道题

面试结束之后我复盘了很久,如果再来一次,我会在纸上先画一张图,把“模型决策层”和“工具执行层”分开,然后明确告诉面试官:Tool Use 的本质是一个协议设计问题,模型负责决策,程序负责执行和校验,两者之间需要一个稳定的契约。三种方案的区别,只是这个契约的严格程度不同。

然后我会按“契约强度”来组织回答:提示词约定是最弱的契约,靠模型自觉;函数调用是中等契约,靠 schema 约束;状态机是最强契约,靠流程约束。契约越强,可靠性越高,但灵活性越低。选哪种,取决于你的业务能容忍多少不确定性。

最后再分享一个我实际工作中总结的小技巧:在工具注册表里给每个工具加一个example字段,写一个典型的调用示例。这个示例会在构造提示词或 tools 描述时一并带上,实测能显著提升模型首次调用就选对的概率。尤其是参数格式比较复杂的工具,一个示例胜过十句描述。

这道题没有标准答案,但面试官想看到的,是你有没有真正把模型当成一个“会犯错但可以约束的组件”来对待。想清楚这一点,三种方案你都能讲得头头是道。

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

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

立即咨询