大模型Tool Use手写实战:从提示词约定到状态机调度

发布时间:2026/10/16 4:53:55
大模型Tool Use手写实战:从提示词约定到状态机调度
1. 面试现场为什么“手写 Tool Use”成了高频考题1.1 从“背八股”到“现场造轮子”的转变这两年技术面试的风向变了。前几年大家还在背 Transformer 的注意力公式、手推反向传播现在面试官更倾向于把白板往你面前一推说一句“来手写一个 Tool Use 的调度逻辑。”我第一次遇到这个题的时候脑子里第一反应是——这不就是 Function Calling 吗但面试官紧接着补了一句“不用框架就手写。”那一刻我才意识到他考的不是你会不会调 API而是你脑子里有没有一套完整的“模型决策 → 工具选择 → 参数构造 → 结果回填”的闭环。所谓 Tool Use通俗讲就是让大模型在对话过程中自己判断“这个问题我该不该调用外部工具”如果需要就输出一个结构化的调用请求由外部程序执行后把结果塞回上下文模型再基于结果继续回答。它解决的核心问题是模型的知识是静态的、有截止日期的而真实世界的任务往往需要实时数据、精确计算或者对外部系统的操作能力。适合谁来参考我觉得三类人最需要一是正在准备 AI 应用岗面试的同学二是想自己搭 Agent 但不想一上来就上重型框架的开发者三是想理解 Agent 底层运转机制的产品经理。1.2 面试官到底在考察什么我后来复盘这场面试发现面试官真正想看的维度其实很清晰。第一是协议设计能力你打算用什么格式让模型表达“我要调用工具”是 JSON、是特定标记语言、还是纯文本约定第二是解析鲁棒性模型输出的东西不一定规范你怎么容错第三是循环控制工具调用可能失败、可能返回空、可能模型反复调用同一个工具你怎么防止死循环第四是上下文管理工具返回的结果怎么拼回对话历史token 怎么控制。这四个维度恰好对应了三种不同复杂度的实现方案。我在面试现场就是从最简单的方案开始讲然后逐步加码最后面试官的表情从“还行”变成了“有点东西”。下面我把这三种方案完整拆开讲每一种都给出可运行的代码骨架和我踩过的坑。2. 方案一基于提示词约定的极简 Tool Use2.1 核心思路用自然语言“骗”模型输出结构化内容最朴素的方案不依赖任何模型原生的函数调用能力纯粹靠系统提示词约定一套输出格式。你告诉模型“当你需要计算时请输出toolcalculator/toolargs{expr: ...}/args这样的格式。”然后你在外部用正则去抓这个标记执行完再把结果拼回去。这个方案的优势是通用性极强——任何能对话的模型都能用不挑供应商。劣势也很明显模型不一定会乖乖听话尤其是参数稍微复杂一点它就可能漏掉引号、多写解释文字、甚至自己把工具结果编出来。我实测下来在参数量较小的模型上这个方案的解析失败率能到 20% 以上。2.2 提示词模板与解析代码先看提示词怎么写。关键是要把“什么时候调用”“怎么调用”“调用后怎么办”三件事说清楚并且给一个完整的示例。SYSTEM_PROMPT 你是一个可以使用工具的助手。 可用工具 1. calculator: 计算数学表达式。参数格式 {expr: 数学表达式} 2. search: 查询实时信息。参数格式 {query: 查询关键词} 调用规则 - 需要计算或查询实时信息时必须调用工具不要自己编造结果。 - 调用格式严格为tool工具名/toolargs参数JSON/args - 一次只调用一个工具。 - 收到 result 标签后基于结果继续回答用户。 示例 用户3的5次方是多少 助手toolcalculator/toolargs{expr: 3**5}/args 用户result243/result 助手3的5次方是243。 解析部分我用的是正则加 JSON 双重校验。这里有个细节不要用贪婪匹配否则模型如果输出了两个工具调用你会把中间一大段都吞进去。import re import json TOOL_PATTERN re.compile( rtool(.*?)/tool\s*args(.*?)/args, re.DOTALL ) def parse_tool_call(text): match TOOL_PATTERN.search(text) if not match: return None tool_name match.group(1).strip() raw_args match.group(2).strip() try: args json.loads(raw_args) except json.JSONDecodeError: # 容错尝试修复常见的单引号问题 try: args json.loads(raw_args.replace(, )) except Exception: return {error: 参数解析失败, raw: raw_args} return {tool: tool_name, args: args}2.3 循环控制与结果回填拿到工具调用后执行工具把结果包在result标签里追加到对话历史再请求模型。这里必须设一个最大轮次我一般设 5 轮。为什么是 5因为超过 5 轮还没收敛的任务大概率是模型陷入了某种循环继续下去只是烧 token。def run_agent(user_input, max_turns5): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input} ] for turn in range(max_turns): reply call_llm(messages) messages.append({role: assistant, content: reply}) call parse_tool_call(reply) if not call: return reply # 没有工具调用直接返回 result execute_tool(call[tool], call[args]) messages.append({ role: user, content: fresult{result}/result }) return 达到最大轮次限制任务未完成。注意把工具结果用user角色回填而不是assistant是因为大多数模型的对话模板里工具结果属于“外部输入”放在 user 侧更符合训练分布。我试过放 assistant 侧模型会误以为是自己之前说过的话导致逻辑混乱。2.4 这个方案的适用边界方案一我一般只用在两个场景一是做原型验证快速跑通链路二是对接那些不支持原生函数调用的模型。它的天花板很明显——提示词越长模型越容易“忘记”格式工具越多选择越容易出错。我做过一个测试当工具数量超过 8 个时方案一的工具选择准确率会明显下降因为所有工具描述都挤在系统提示里模型注意力被稀释了。3. 方案二利用模型原生 Function Calling 能力3.1 原生能力的本质模型被专门训练过方案二的核心变化在于工具的描述不再写在系统提示词里而是通过 API 的一个独立字段传入。模型在训练阶段就见过大量“工具定义 调用请求”的样本所以它输出的调用请求天然就是结构化的 JSON不需要你用正则去猜。这个方案的优势是解析成功率极高基本接近 100%而且支持并行调用多个工具。劣势是绑定供应商——不同厂商的字段名、返回结构不完全一样切换成本存在。但从工程角度这点成本完全值得。3.2 工具定义的 schema 设计工具定义本质上是一个 JSON Schema。我见过很多人把 description 写得很敷衍比如“查询天气”结果模型经常在该调用的时候不调用。description 要写清楚“什么时候用”和“参数含义”。tools [ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气。当用户询问天气、温度、是否下雨时使用。, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如 北京、上海 }, unit: { type: string, enum: [celsius, fahrenheit], description: 温度单位默认摄氏度 } }, required: [city] } } } ]3.3 调用循环的完整实现原生方案的循环逻辑和方案一类似但解析部分换成了读取结构化的tool_calls字段。def run_agent_native(user_input, max_turns5): messages [{role: user, content: user_input}] for _ in range(max_turns): response client.chat.completions.create( modelyour-model, messagesmessages, toolstools, tool_choiceauto ) msg response.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for tool_call in msg.tool_calls: fn_name tool_call.function.name fn_args json.loads(tool_call.function.arguments) result execute_tool(fn_name, fn_args) messages.append({ role: tool, tool_call_id: tool_call.id, content: str(result) }) return 达到最大轮次限制。注意tool_call_id这个字段它是把结果和请求对应起来的关键。如果一次返回了多个工具调用你必须为每一个都回填一条tool消息否则下一轮请求会报错。这个坑我在第一次用并行调用时踩过报错信息还特别隐晦排查了半小时。3.4 参数校验与失败重试原生方案虽然解析稳但参数值不一定对。比如模型可能传一个不存在的城市名或者把单位传成C而不是celsius。我的做法是在execute_tool里做一层校验校验失败时不是直接抛异常而是把错误信息作为工具结果返回给模型让它自己修正。def execute_tool(name, args): if name get_weather: city args.get(city) if not city: return {error: 缺少 city 参数请重新调用并传入城市名} unit args.get(unit, celsius) if unit not in (celsius, fahrenheit): return {error: funit 参数 {unit} 不合法只能是 celsius 或 fahrenheit} return real_weather_api(city, unit) return {error: f未知工具 {name}}这种“把错误当结果返回”的模式实测能让模型在第二轮自动纠正参数成功率提升非常明显。比你在外部做复杂的重试逻辑要优雅得多。4. 方案三手写一个带状态机的 Tool Use 调度器4.1 为什么要上状态机前两个方案都是“请求-响应”式的线性循环。但真实任务往往更复杂可能需要先查天气再根据天气决定要不要推荐室内活动推荐完还要查一下活动地点的营业时间。这种有依赖关系的多步任务线性循环容易乱。方案三我用一个显式的状态机来管理整个流程把“规划”“执行”“观察”“判断是否结束”拆成独立状态。这个方案在面试里是最加分的因为它体现了你对 Agent 本质的理解——Agent 不是一次调用而是一个带反馈的决策循环。4.2 状态定义与转移逻辑我用一个枚举定义状态核心状态有四个PLANNING模型思考下一步、EXECUTING执行工具、OBSERVING整理结果、DONE结束。from enum import Enum class State(Enum): PLANNING planning EXECUTING executing OBSERVING observing DONE done class ToolAgent: def __init__(self, tools, max_steps8): self.tools tools self.max_steps max_steps self.history [] self.state State.PLANNING self.step 0 def run(self, user_input): self.history.append({role: user, content: user_input}) while self.state ! State.DONE and self.step self.max_steps: self.step 1 if self.state State.PLANNING: self._plan() elif self.state State.EXECUTING: self._execute() elif self.state State.OBSERVING: self._observe() return self._final_answer()4.3 各状态的实现细节_plan阶段调用模型让它输出下一步动作。这里我要求模型输出一个结构化的“计划对象”包含thought思考、action动作类型、tool工具名、args参数。如果模型认为任务已完成action就是finish。def _plan(self): response call_llm_with_plan_prompt(self.history, self.tools) plan parse_plan(response) self.current_plan plan if plan[action] finish: self.state State.DONE else: self.state State.EXECUTING def _execute(self): plan self.current_plan result execute_tool(plan[tool], plan[args]) self.current_result result self.state State.OBSERVING def _observe(self): self.history.append({ role: assistant, content: f思考{self.current_plan[thought]}\n f调用{self.current_plan[tool]}\n f结果{self.current_result} }) self.state State.PLANNING4.4 状态机的优势与代价优势在于可观测性。每一步的状态转移都是显式的你可以在任意状态插入日志、埋点、人工审核。比如在EXECUTING之前加一道“危险工具确认”在OBSERVING之后加一道“结果质量检查”。这在生产环境里非常重要。代价是代码量翻倍而且对模型的规划能力要求更高。如果模型本身比较弱_plan阶段输出的 JSON 经常不合规你需要在parse_plan里做大量容错。我的经验是方案三适合搭配能力较强的模型使用弱模型上跑状态机反而因为频繁的格式错误导致整体成功率不如方案二。5. 三种方案横向对比与选型建议5.1 关键维度对比表维度方案一提示词约定方案二原生调用方案三状态机调度解析成功率中70%-90%高接近100%中高取决于模型模型兼容性极强任何模型绑定支持厂商较强需规划能力多步任务支持弱中强并行调用不支持支持可扩展支持代码复杂度低中高可观测性低中高适合场景原型验证生产主力复杂任务编排5.2 我的选型决策树实际项目里我不会死守一种方案。我的决策逻辑是这样的如果只是做个 Demo 或者对接的模型不支持原生调用直接上方案一半天就能跑通。如果是正经的生产应用方案二是默认选择稳定压倒一切。只有当任务本身有明显的多步依赖、需要人工介入审核、或者需要精细的成本控制时才上方案三。面试的时候我建议按“方案一 → 方案二 → 方案三”的顺序讲每讲一个就点出它的局限然后自然过渡到下一个。这样面试官能看到你的思考递进而不是一上来就堆最复杂的方案。6. 实操中踩过的坑与排查技巧6.1 常见问题速查表现象可能原因排查方向解决手段模型不调用工具直接编答案提示词没说清“必须调用”检查系统提示加“不要自己编造结果”约束工具调用格式解析失败模型输出多余文字打印原始输出用非贪婪正则JSON容错参数缺失或类型错误schema 描述不清检查 description补充参数含义和示例反复调用同一工具结果没被正确回填检查消息历史确认 tool_call_id 对应达到最大轮次未结束任务本身无法完成看每轮 plan增加终止条件判断并行调用报错漏回填某条结果检查 tool_calls 数量每个 id 都要回填6.2 三个我印象最深的坑第一个坑是消息角色错位。方案一里我把工具结果用 assistant 角色回填结果模型以为那是自己说的话下一轮直接说“根据我之前的回答”完全忽略了真实结果。改成 user 角色后立刻正常。这个坑的教训是一定要理解你所使用模型的对话模板角色不是随便标的。第二个坑是JSON 里的中文引号。模型有时候会输出中文引号“”而不是英文引号导致json.loads直接失败。我的处理是在解析前统一做一次字符替换把中文标点转成英文。这个细节很小但不处理的话线上会偶发解析失败很难复现。第三个坑是工具结果的 token 膨胀。有一次我调用的搜索工具返回了一大段 HTML直接塞进上下文下一轮请求直接超了 token 限制。后来我在execute_tool里加了一层结果截断和清洗只保留关键字段。工具返回的内容一定要做后处理不能原样回填。6.3 一个提升稳定性的小技巧在系统提示里加一句“如果你不确定是否需要调用工具优先调用。”这句话看起来简单但实测能显著降低“该调用却没调用”的概率。因为模型在犹豫时默认倾向是不调用加这句话相当于把默认值翻转了。当然代价是可能多调用一些不必要的工具需要根据你的场景权衡。7. 从面试题到生产系统的延伸思考面试里手写 Tool Use考的是你能不能把一个大问题拆成“协议、解析、循环、容错”四个小问题。但真正把它做成生产系统还有几件事面试官不会问但你迟早要面对。第一是工具的安全边界。不是所有工具都能让模型随便调。涉及写操作、支付、删除的工具必须加人工确认或者权限校验。我的做法是给工具打上dangerous标签状态机在执行前检查这个标签危险工具走单独的确认流程。第二是成本控制。每一轮工具调用都是一次完整的模型请求token 消耗是线性增长的。我一般会设置单次任务的 token 预算超了就强制终止并返回当前最优结果。这个预算值需要根据你的业务场景实测调优。第三是可观测性。生产环境里你必须能回答“这次任务为什么失败”。我的做法是把每一轮的状态、输入、输出、耗时都结构化打日志出问题时能完整回放整个决策链路。这套日志在排查线上问题时救过我好几次。第四是工具的动态注册。工具不是写死的应该支持运行时注册和注销。我用一个注册表管理所有工具每个工具包含名称、描述、schema、执行函数、危险标记。新增工具只需要注册不需要改调度逻辑。这套东西我从面试题开始琢磨后来真的用在了自己的项目里。最开始用方案一跑通然后换成方案二稳定下来最后在几个复杂任务上引入了方案三的状态机。每一步的演进都不是为了炫技而是被真实的问题推着走。如果你也在准备类似的面试我的建议是三种方案都亲手写一遍重点体会它们各自的失败模式。面试官问“如果模型不听话怎么办”的时候你能脱口而出具体的排查路径这比背任何八股都管用。