2026/10/22 15:56:21

手写Tool Use三种方案:从字符串解析到完整执行框架

手写Tool Use三种方案:从字符串解析到完整执行框架 1. 面试现场一道题背后的真实意图面试官推过来一张白纸说“手写一个 Tool Use 吧”。很多人第一反应是懵的——Tool Use 不是大模型 API 里配个 JSON Schema 就完事了吗怎么还要手写我当时的判断是这道题考的根本不是“你会不会调接口”而是考你对函数调用协议、参数校验、执行调度、结果回填这一整条链路的理解深度。换句话说面试官想看的是你能不能从零搭出一个让模型“知道有哪些工具可用、该在什么时候用、用完怎么把结果接回去”的闭环。这个能力在实际项目里非常关键。比如你做一个智能客服系统模型需要查订单、改地址、发起退款这些动作都不能靠模型“编”必须落到真实函数上执行。再比如做数据分析助手模型要能调用 SQL 查询、画图、导出文件。Tool Use 就是模型和真实世界之间的那根管子管子接得好不好直接决定系统能不能用。我前后用了三种方案来回答这道题从最朴素的字符串解析到结构化协议再到带调度和重试的完整框架。三种方案对应三种不同的工程成熟度也对应面试官不同层次的追问。下面我把每种方案的思路、代码骨架、踩坑点和适用场景都拆开讲你拿去就能复现也能在面试里按需展开。2. 方案一字符串约定加正则解析最土但最能讲清原理2.1 为什么先从这个方案入手面试现场时间有限如果你一上来就写复杂框架面试官反而看不清你的思路。我选择先用最原始的方式把“模型输出文本程序解析文本执行函数把结果拼回对话”这个核心循环跑通。这个方案的本质是用自然语言约定格式用正则做解析。它不依赖任何 SDK 的高级特性任何模型都能用缺点是脆弱、容易解析失败。具体做法是在系统提示词里告诉模型当你需要调用工具时请输出[TOOL: 工具名] [ARGS: {key: value}]这样的格式。程序拿到模型回复后用正则去匹配这个模式提取工具名和参数然后在本地的函数注册表里找到对应函数执行最后把执行结果以[RESULT: ...]的形式追加到对话历史里再让模型继续生成。2.2 核心代码骨架与参数校验import re import json TOOL_REGISTRY {} def register_tool(name): def wrapper(fn): TOOL_REGISTRY[name] fn return fn return wrapper register_tool(get_weather) def get_weather(city: str, unit: str celsius): # 模拟真实调用 return {city: city, temp: 26, unit: unit} TOOL_PATTERN re.compile(r\[TOOL:\s*(\w)\]\s*\[ARGS:\s*(\{.*?\})\], re.S) def parse_and_execute(model_output: str): match TOOL_PATTERN.search(model_output) if not match: return None tool_name, args_str match.group(1), match.group(2) try: args json.loads(args_str) except json.JSONDecodeError: return {error: 参数不是合法 JSON} if tool_name not in TOOL_REGISTRY: return {error: f工具 {tool_name} 未注册} fn TOOL_REGISTRY[tool_name] # 这里要做参数白名单校验不能直接 **args allowed fn.__code__.co_varnames[:fn.__code__.co_argcount] safe_args {k: v for k, v in args.items() if k in allowed} return fn(**safe_args)这段代码里有两个细节值得在面试时主动说出来。第一参数不能直接**args展开否则模型如果输出了恶意参数名可能覆盖函数内部变量或者触发意外行为必须做白名单过滤。第二正则用了非贪婪匹配.*?并且加了re.S因为模型输出的 JSON 里可能有换行。这两个点一说面试官就知道你写过真实代码不是背的。2.3 这个方案的致命短板字符串解析最大的问题是模型不听话。你约定用方括号它有时候用圆括号有时候 JSON 里带注释有时候干脆把工具名写成中文。我在实际项目里做过统计纯靠提示词约束格式解析成功率大概在七成到八成之间剩下两成需要重试或者兜底。所以这个方案只适合做原型验证或者模型能力极强、输出极其稳定的场景。面试时如果你只讲这个方案面试官大概率会追问“解析失败怎么办”这时候你要能自然过渡到方案二。注意用正则解析模型输出时一定要给匹配结果做超时和长度限制防止模型输出超长文本导致正则回溯爆炸。3. 方案二结构化协议加 JSON Schema工程上最常用3.1 从“约定格式”升级到“声明式协议”方案一的痛点是格式靠嘴约定方案二的思路是把工具定义成结构化数据让模型按照 Schema 来填参数。现在主流的大模型接口基本都支持tools参数你传一个数组进去每个元素包含工具名、描述、参数 JSON Schema模型返回的就不再是自由文本而是一个结构化的tool_calls字段。这一步的跨越在于解析工作从“写正则”变成了“读字段”可靠性大幅提升。我当时的回答是如果面试官允许我用现成接口我会直接上这套如果不允许我会手写一个简化版的 Schema 校验器来证明我懂原理。下面这个校验器是我手写的核心部分不依赖第三方库。3.2 手写 JSON Schema 校验器的关键逻辑def validate_schema(value, schema, pathroot): errors [] t schema.get(type) if t object: if not isinstance(value, dict): return [f{path} 应为对象] required schema.get(required, []) for key in required: if key not in value: errors.append(f{path}.{key} 缺失必填字段) props schema.get(properties, {}) for key, sub_schema in props.items(): if key in value: errors.extend(validate_schema(value[key], sub_schema, f{path}.{key})) elif t string: if not isinstance(value, str): errors.append(f{path} 应为字符串) elif enum in schema and value not in schema[enum]: errors.append(f{path} 取值不在枚举范围内) elif t number: if not isinstance(value, (int, float)): errors.append(f{path} 应为数字) else: if minimum in schema and value schema[minimum]: errors.append(f{path} 小于最小值) if maximum in schema and value schema[maximum]: errors.append(f{path} 大于最大值) elif t array: if not isinstance(value, list): errors.append(f{path} 应为数组) else: item_schema schema.get(items, {}) for i, item in enumerate(value): errors.extend(validate_schema(item, item_schema, f{path}[{i}])) return errors这个校验器覆盖了对象、字符串、数字、数组四种最常用的类型支持必填、枚举、最大最小值。面试时我特意强调了递归校验和路径追踪因为当参数嵌套很深时只告诉调用方“参数错了”没有意义必须精确到哪个字段错了才能让模型自我修正。这一点是区分“能用”和“好用”的关键。3.3 工具描述怎么写才不容易被模型误用结构化协议解决了格式问题但解决不了语义问题。模型选错工具、参数填错值根源往往在工具描述写得含糊。我总结了几条写描述的硬规矩第一工具名用动词开头比如search_orders而不是order_tool第二描述里写清楚“什么时候用”和“什么时候不要用”比如“当用户询问订单状态时使用不要用于查询物流轨迹”第三参数描述里给例子比如city字段写“城市名例如杭州”。这些细节在面试里主动讲出来面试官会觉得你踩过坑。描述写法模型误用率实测经验值原因只有工具名无描述很高模型靠猜一句话描述中等边界不清描述含使用场景和反例较低语义明确描述含参数示例最低填参有参照提示工具数量超过十个以后模型选错的概率会明显上升。实际项目里我会按业务域分组每组只暴露当前对话相关的工具而不是一次性全塞进去。4. 方案三带调度、重试与状态机的完整执行框架4.1 为什么需要状态机前两个方案解决的是“单次调用”但真实场景里模型可能连续调用多个工具中间某个失败要重试某个结果要作为下一个工具的输入。这时候就需要一个执行状态机来管理整个生命周期。我在面试时画了一个简单的状态流转IDLE - PARSING - VALIDATING - EXECUTING - FEEDBACK - DONE每个状态都有明确的进入条件和退出条件失败时根据错误类型决定是回到PARSING让模型重新生成还是直接进入FEEDBACK把错误告诉模型。这个设计的核心价值在于把“模型决策”和“程序执行”解耦。模型只负责决定调什么工具、传什么参数程序负责校验、执行、重试、记录。两者通过一个标准化的消息结构通信任何一方出问题都不会把整个系统搞崩。4.2 重试策略与幂等性处理重试不是简单地把同样的请求再发一遍。我在框架里把错误分成三类参数错误模型填错了重试时把校验错误信息回传给模型让它改、瞬时错误网络抖动、限流直接重试指数退避、业务错误比如余额不足重试无意义直接反馈给用户。这三类错误的处理路径完全不同如果混在一起重试会造成大量无效调用。import time def execute_with_retry(fn, args, max_retries3): last_error None for attempt in range(max_retries): try: return {ok: True, data: fn(**args)} except ValueError as e: # 参数类错误不重试直接返回让模型修正 return {ok: False, type: param_error, msg: str(e)} except TimeoutError as e: last_error e time.sleep(2 ** attempt) # 指数退避 except Exception as e: return {ok: False, type: business_error, msg: str(e)} return {ok: False, type: timeout, msg: str(last_error)}这里有个容易被忽略的点写操作必须做幂等。比如“创建订单”这种工具如果因为超时重试了两次可能生成两笔订单。我的做法是在参数里带一个request_id服务端根据这个 ID 去重。面试时我把这一点讲出来面试官明显点了点头因为这是真实生产环境才会考虑的问题。4.3 多工具并行调用的调度设计当模型一次返回多个tool_calls时如果这些工具之间没有依赖关系可以并行执行来降低延迟。我用一个简单的依赖分析来判断如果工具 B 的参数里引用了工具 A 的输出那就串行否则丢进线程池并行跑。并行执行时要注意结果回填的顺序必须按照模型请求的顺序把结果拼回消息列表否则模型会混淆哪个结果对应哪个调用。from concurrent.futures import ThreadPoolExecutor def dispatch_tool_calls(tool_calls): # 简化版无依赖则并行 results [None] * len(tool_calls) with ThreadPoolExecutor(max_workers4) as pool: futures {} for idx, call in enumerate(tool_calls): futures[pool.submit(execute_with_retry, TOOL_REGISTRY[call[name]], call[args])] idx for fut in futures: idx futures[fut] results[idx] fut.result() return results这段代码在面试里不用全写出来但你要能说清楚“为什么用线程池而不是协程”——因为大部分工具调用是 IO 密集型的线程池足够如果工具里有 CPU 密集操作那就要换进程池。这种取舍能体现你的工程判断力。5. 三种方案横向对比与选型建议5.1 从可靠性、开发成本、适用场景三个维度对比维度方案一字符串解析方案二结构化协议方案三完整框架解析可靠性低依赖模型听话高字段级解析高带校验和重试开发成本极低半天可跑通中等需定义 Schema高需设计状态机多工具调度不支持基础支持完整支持错误处理基本没有有校验无重试分类重试加幂等适用场景原型验证、Demo大多数业务系统高并发、强一致场景面试展示价值讲清原理展示工程规范展示架构能力我的建议是面试时三种都要能讲但重点放在方案二和方案三的衔接上。方案一用来开场快速建立共同语言方案二用来展示你对协议和校验的理解方案三用来展示你对生产环境的认知。如果面试官只给二十分钟讲完方案二就够了如果时间充裕方案三的状态机设计是加分项。5.2 一个容易被追问的点模型不调用工具怎么办这是面试高频追问。模型有时候会直接用自己的知识回答而不去调用工具。我的处理方式是在系统提示词里加一条硬约束“涉及实时数据、私有数据、需要执行动作的问题必须先调用工具禁止直接回答。”同时在代码层面做一个检测如果用户问题命中了预设的“必须调用工具”的意图分类但模型没有返回tool_calls就强制重新生成一次并在提示词里强调“你刚才没有调用工具请重新回答”。这个兜底逻辑在真实项目里能显著降低漏调用率。注意强制重试要有次数上限一般两次就够了否则会陷入死循环浪费 token 也拖慢响应。6. 实操复盘我在真实项目里踩过的坑6.1 参数类型不匹配导致的静默失败有一次线上出现一个诡异问题用户问“帮我查一下北京明天的天气”模型正确调用了天气工具参数也传了但返回结果永远是空的。排查了半天发现模型把城市名传成了北京市而我们的工具函数内部用的是北京去匹配城市库匹配不上就返回空。这个问题在测试环境没暴露因为测试用例里用的都是标准城市名。后来我在工具函数入口加了一层参数归一化把“市”“省”这类后缀去掉再做匹配。这个坑告诉我永远不要相信模型传的参数是“干净”的入口处必须做清洗。6.2 工具返回结果过大撑爆上下文另一个坑是工具返回的数据太大。比如查询订单列表一次返回了五百条记录序列化成 JSON 有几十 KB直接塞回对话历史下一轮模型调用时上下文直接超限。我的解决方案是在工具层做结果截断和摘要只返回前 N 条并附带总数和分页信息如果单条记录字段太多只保留模型决策需要的字段。这个处理放在工具函数内部而不是依赖模型去总结因为模型总结会引入额外的不确定性和延迟。6.3 并发调用时的上下文污染方案三里我用了线程池并行执行工具结果遇到一个隐蔽的 bug两个工具同时往同一个对话历史列表里写结果导致顺序错乱。后来改成每个工具的结果先写到独立的槽位等所有工具执行完再按顺序合并。这个教训是并行执行可以但共享状态的写入必须串行化。在面试里如果你能主动提到这一点说明你真的写过并发代码而不是纸上谈兵。6.4 常见问题速查表现象可能原因排查方向解决手段模型不调用工具提示词约束弱检查系统提示词加硬约束加意图检测参数解析失败格式不稳定看原始输出换结构化协议工具选错描述含糊检查工具描述补使用场景和反例结果为空参数不匹配打印入参入口做归一化上下文超限返回数据过大统计 token 数截断加摘要重复执行重试无幂等查调用日志加 request_id 去重7. 面试表达技巧怎么讲才能让面试官满意7.1 先讲边界再讲实现我回答这道题的开场白是“Tool Use 的核心是四个环节——工具注册、意图识别、参数校验、结果回填。我按复杂度从低到高有三种实现方式您想先听哪种”这句话的作用是把主动权拿回来同时展示你有体系化的认知而不是零散地背代码。面试官通常会选“从简单的开始”那你就顺着方案一往方案三讲节奏完全在你手里。7.2 主动暴露权衡而不是只讲优点讲每个方案时我都会主动说它的缺点。比如方案一“解析成功率只有七八成”方案二“需要模型接口支持 tools 参数”方案三“开发成本高小项目没必要”。这种表达传递的信号是你不是在推销方案而是在做技术选型。面试官最怕的是候选人只会一种方案还觉得它万能主动暴露权衡反而增加可信度。7.3 用真实数字支撑观点“解析成功率七八成”“工具超过十个误用率上升”“重试两次就够了”这些数字不一定精确但它们是经验值比“一般来说”“通常情况下”这种模糊表达有说服力得多。面试官追问“你怎么知道的”你就说“之前项目里统计过调用日志”这个回答既真实又体现你有数据意识。7.4 留一个钩子让面试官追问我讲完方案三后通常会补一句“其实还有一个更进阶的问题就是工具调用的权限控制不同用户能调用的工具集合不一样这个在框架里怎么做隔离我当时是用了一个中间件。”这句话是故意留的钩子面试官如果感兴趣就会追问那接下来的对话就进入了我准备好的领域。面试本质上是一场引导你要让面试官问出你擅长的问题。8. 从这道题延伸出的能力地图手写 Tool Use 这道题表面考的是函数调用实际考的是把不确定的自然语言输出转化为确定的程序执行这一整套能力。这套能力可以迁移到很多场景做 Agent 系统需要它做工作流自动化需要它做 RPA 需要它甚至做智能家居的语音控制也需要它。核心永远是那四步定义能力边界、解析意图、校验参数、执行并反馈。我在实际项目里最大的体会是Tool Use 的难点从来不在“调用”本身而在“边界”。模型该在什么时候调用、不该在什么时候调用参数该信到什么程度结果该返回多少这些边界划不清楚系统就会在线上出各种幺蛾子。三种方案的区别本质上就是边界清晰度的区别。字符串解析几乎没有边界结构化协议有参数边界完整框架有全链路边界。你选的方案越重边界越清晰但开发成本也越高。最后分享一个我常用的判断标准如果这个工具调用错了会造成资损或者数据污染那就必须上方案三带校验、带幂等、带审计日志如果只是查个天气、算个加法方案二足够了。技术选型没有银弹只有匹配。面试时你能把这个判断标准讲清楚比写出多漂亮的代码都管用。