首页 / 资讯中心 / 文章详情

手写大模型 Tool Use 运行时:从正则解析到 Agent 循环的完整实现

手写大模型 Tool Use 运行时:从正则解析到 Agent 循环的完整实现 ★ FEATURED ARTICLE
拿题板的时候我脑子其实闪了好几帧所谓 Tool Use就是让大模型在对话里不只输出文字而是能输出一个“我想调用某个工具”的动作由外围运行时去真实执行再把结果塞回上下文继续推理。在 Agent 和 Function Calling 相关岗位的面试里手写一个最小 Tool Use 运行时基本是高频关卡。面试官给的时间限制是二十分钟禁用现成的 function calling 框架允许写伪代码和最小可跑逻辑。我当时心里一松因为我日常做 LLM 应用框架的时候这条路走了好几回了。这题考的不是你背没背过某个 SDK而是你对“模型输出怎么变行为、工具怎么注册、结果怎么回填”这条链路有没有完整的肌肉记忆。我按三种方案依次写了出来事后回想三个方案正好覆盖了三个层次的深度也把面试官想看的“从能用、到稳、再到可跑起来”全部展示出来了。这篇文章就是把我手写的过程完整复盘配上代码、踩过的边界和面试时真正的加分点给同样在准备 LLM 应用方向的你一个参考。1. 为什么面试官非要你手写 Tool Use他在看什么先说结论这道题本质上是让你手搓一个极简版函数调用协议的运行时。它考察的是你把大模型输出转化成实际的系统动作的能力而不是让你背 API。很多人在简历上写着“熟练使用大模型开发”但一落到“让模型自己决定调哪个函数、参数怎么传、结果怎么处理”就露馅。1.1 Tool Use 到底解决什么问题大模型本质上是一个文本预测器它没有手也碰不到外部系统。要是只靠模型自己它没法查天气、算结果、查库存、发请求。Tool Use 就是给模型装了一双手和一对眼睛模型可以在生成过程中声明“我想调用某工具”运行时负责实际执行再把执行结果作为新的上下文喂回去让模型继续决策。这个机制拆开看只有三块工具注册、调用请求解析、结果执行与回填。但每块都有隐蔽的工程问题。比如调用请求是以什么格式存在模型输出的文本里怎么精准切出一次调用参数类型错了怎么办工具报错要不要止损这些细节才是真正区分“会调 OpenAI API”和“会做 Agent 基建”的分界线。面试官让你手写不是为了让你展示肌肉记忆而是想看你有没有踩过这些细节的坑。1.2 现场手写的考察维度与隐藏得分点根据我自己复盘面试官观察点通常是这几个维度有没有先想清楚设计还是上来就闷头敲代码解析层是否考虑“模型输出不规整”的容错工具注册表是否好扩展新增工具要不要改主流程有没有安全边界意识比如工具名白名单、参数越界、命令注入有没有循环控制和 Token 预算控制避免 Agent 死循环烧钱。我当时很快定了主基调先用最直接的正则方案把链路跑通再讲结构化 JSON 方案的可靠性最后补一个带循环的 Agent 化执行方案证明自己能处理多轮场景。面试官点头最狠的地方是我主动说出“这里需要考虑安全”然后在方案一里加了一个白名单工具集。这种意识是平时写 Agent 踩过坑的人才会有。2. 方案一正则截取 白名单分发最轻量的跑通面试第一步先把最小可跑的东西亮出来。我没有直接上复杂函数调用协议而是让模型输出一种固定格式“工具名(参数)”比如get_weather(北京, 今天)。然后我用正则把它抓出来再走工具注册表分发。2.1 先搭一个工具注册表不管是哪个方案工具注册表都是最底层的东西。它就是一个 dictkey 是工具名字符串value 是真正的 Python 函数。这样后期想加工具只需要往注册表里注册函数即可分发的逻辑根本不用动。TOOL_REGISTRY { get_weather: lambda city, date: f{city}的{date}天气晴25℃, calculate: lambda expr: f计算结果: {eval_safe(expr)}, } def register_tool(func, nameNone): TOOL_REGISTRY[name or func.__name__] func为了体现设计感我给get_weather和calculate都放进了注册表。这样后面解析完工具名就直接到注册表 lookup。面试官想看的就是你有没有这层抽象而不是把工具调用写死在 if-else 里。2.2 用正则从模型文本里抠工具调用模型的实际输出会是“今天的天气不错我来帮你查一下get_weather(北京, 今天)。”这时候我需要把里面的工具名和参数提取出来。import re, json TOOL_CALL_PATTERN re.compile(r(\w)\s*\(\s*(.*?)\s*\)\s*(?\n|$), re.DOTALL) def parse_tool_call(text): match TOOL_CALL_PATTERN.search(text) if not match: return None tool_name, raw_args match.group(1), match.group(2) try: args json.loads([ raw_args ]) except Exception: args [item.strip().strip(\) for item in raw_args.split(,)] return tool_name, args这里有两个隐藏细节值得说一是正则的(?\n|$)这个写法的目的是防止模型在文本中间说“我帮你调用一下get_weather(北京, 今天)结果如下”时把后面“结果如下”也一起抓进参数里。我实际调试中遇到过很多次参数串着后面的话解析出来全是乱的。二是参数解析的降级思路先用 JSON 把参数当数组解析失败后再走手动 split 清洗。因为模型可能把单引号、中文逗号混在一起直接json.loads会炸。降级解析虽然朴素但在现场那个环境里能快速兜底。2.3 分发的核心流程与容错拿到工具名和参数以后执行分发就很简单了def run_tool_call(text): parsed parse_tool_call(text) if parsed is None: return 没有发现可执行的工具调用。 tool_name, args parsed tool_func TOOL_REGISTRY.get(tool_name) if tool_func is None: return f未知工具: {tool_name} try: result tool_func(*args) return f[工具执行结果] {result} except Exception as e: return f[工具执行出错] {type(e).__name__}: {e}这里白名单的体现就是TOOL_REGISTRY.get(tool_name)查不到直接拒绝不走任何动态导入。我特别强调了这一点如果不做白名单让模型输出直接拼成命令执行那就是把自己系统的洞口打开给模型玩。工程上宁愿不干活也不能让模型任意调用内部函数。我当时写完这个方案面试官问了句“如果模型输出两个工具调用你只处理一个怎么办”我答这个方案本来就是最小形态真要并发多工具调用要落到方案二的 JSON 数组机制上。这个过渡很自然直接引出下一版。2.4 这个方案的致命短板正则方案最大的问题是脆弱。模型不一定听你的格式约束有时它会在工具名附近加上解释性文字或者把参数改成带有嵌套花括号的对象比如get_user_info({name: 张三, age: 18})正则的括号匹配马上出问题。另外你没法准确表达“这个工具接收一个对象参数”因为文本形态下参数边界模糊。所以正则方案适合演示、赛车式验证链路不适合做生产级。我的心态很明确先确认面试官认可链路通再立刻切结构化方案。3. 方案二JSON 协议 Schema 校验带参数结构化的进阶版第二个方案是我真正日常生产里爱用的模式。核心思路是不再让模型输出“工具名(参数)”这种半文本格式而是让模型吐一个带结构的 JSON 调用请求。这样解析层只需要关注 JSON参数自然会结构化避免正则解析的坑。3.1 把协议定义清楚我定义模型输出的工具请求格式为{ tool_calls: [ { id: call_001, type: function, function: { name: get_weather, arguments: {\city\: \北京\, \date\: \今天\} } } ] }这个结构不是随便编的它模仿了常见多模型服务 function calling 的主流返回结构。函数参数arguments特意用字符串包裹是为了模拟真实 API 返回中参数常以 JSON 字符串出现的情况。这种细节在面试中很加分说明你不是只看过文档是真的解析过响应体。3.2 解析器与校验器既然协议定了解析逻辑就清晰了import json def extract_tool_calls(model_output): try: data json.loads(model_output) except json.JSONDecodeError: return [] calls data.get(tool_calls, []) parsed [] for call in calls: fn call.get(function, {}) name fn.get(name) args_raw fn.get(arguments, {}) try: args json.loads(args_raw) if isinstance(args_raw, str) else args_raw except json.JSONDecodeError: args {_parse_error: args_raw} parsed.append({id: call.get(id), name: name, args: args}) return parsed这里有个很值得讲的细节arguments如果解析失败我并没有直接丢弃整条调用而是把原始字符串塞进一个_parse_error字段。执行层遇到_parse_error就返回一个友好的错误信息告诉模型“参数格式不对请重新输出”。这种保留上下文而不是直接打断的做法在多轮 Agent 里可以减少很多无效重试。更深一层我对解析出来的工具名做了 Schema 校验。校验不是简单的类型判断而是检查参数是否在工具声明过的合法字段里。工具注册不再只是一个函数而是一个带声明的数据结构TOOLS [ { name: get_weather, description: 查询指定城市和日期的天气, parameters: { type: object, properties: { city: {type: string}, date: {type: string} }, required: [city, date] } } ]校验逻辑对应参数枚举与必填判断def validate_args(tool_name, args): tool_schema find_tool_schema(tool_name) if not tool_schema: return False, 未知工具 required tool_schema[parameters].get(required, []) missing [k for k in required if k not in args] if missing: return False, f缺少必填参数: {missing} # 类型检查可以继续按 properties 里的 type 递归这里略 return True, 3.3 为什么比方案一稳以及它的代价JSON 协议稳定性高在哪里第一参数嵌套、对象、数组都能完整表达第二解析失败时可以明确知道“坏在哪个字段”第三天然支持一次输出多个工具调用用数组循环分发。代价是需要多写一层 DTO 解析和校验而且模型端需要较强的指令遵循能力否则它不好好吐 JSON整条链路就懵了。所以在现场我多说了一句JSON 方案更适合有明确 API 契约的场景比如企业内部给工具平台定义标准输入输出。要是模型本身输出经常不守规矩可以在提示词里给一个 few-shot 例子实测能把 JSON 格式出错的概率从三成降到个位数。这个方案写完面试官让我再往前走一步除了把结果返回给用户能不能让模型“自动再来一轮”这就是第三个方案要解决的问题。4. 方案三循环执行的 Agent 化 Tool Use能干活不止是回话第三个方案我把 Tool Use 变成了一个循环模型先出调用请求、运行时执行工具并将结果拼进对话历史、模型看到结果后继续生成。这就是一个极简 Agent 基座虽然只有十几行核心逻辑但已经具备“自主解决多步任务”的雏形。4.1 从单次调用到多轮循环关键不再是解析一个请求而是维护一个消息队列。整个过程用一个 while 循环串起来messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: 帮我查一下北京和上海今天的天气然后告诉我哪个适合户外跑步。} ] MAX_STEPS 5 for step in range(MAX_STEPS): output llm_chat(messages) # 模拟模型调用 tool_calls extract_tool_calls(output) if not tool_calls: print(模型最终回答:, output) break messages.append({role: assistant, content: output}) for call in tool_calls: result run_tool_call(call) messages.append({role: tool, name: call[name], content: result})这一步是整场面试里我最有共鸣的地方。因为真正的 Agent 循环就是这样朴素模型说一句你执行工具再把工具结果塞回去模型继续说。没有复杂的记忆模块就是消息队列不断累积。所谓记忆其实就藏在这条不断变长的 messages 里。4.2 工具执行回填与上下文控制的实现回填时我特意为每条工具消息打上了name字段。别小看这个字段它让模型知道“这段内容来自哪个工具”尤其在工具多的时候比没有名字的消息更容易让模型理解上下文。很多人在这一步会犯一个错把工具执行结果和自然语言混在一起塞回 messages结果模型分不清哪是用户输入、哪是工具输出推理质量直线下降。实际回填时还要注意长度控制。工具返回内容可能几百行直接全塞进 messages 会把上下文窗口冲爆。我当时加了一个裁剪逻辑def trim_tool_result(result, max_len800): if len(result) max_len: return result return result[:max_len] ...[结果过长已截断]上限设多少合适要看实际上下文预算现场我给了 800 字估算。正经理由是一个工具返回的核心信息通常集中在前面一段后面大多是明细表或日志裁剪掉不会影响模型决策。这个点说出来面试官会认为你有 Token 成本的敏感度而不只是一个只会写代码的工具人。4.3 死循环保护与 Token 预算管理循环方案激动人心但最坑的地方也是它模型如果一直输出tool_calls不给你最终答案就会在 while 循环里打转。我做了三层保护最大步数限制MAX_STEPS 5到了直接强制退出不再继续调用。连续相同工具调用检测如果模型连续三次都在调同一个工具且参数一样大概率是卡住了直接打断。单步 Token 上限给每一步模型输出设一个最大长度防止模型一口气输出超长回复把成本顶爆。if step 2 and len(set(last_call_sigs[-3:])) 1: print(检测到重复调用自动停止。) break我当时跟面试官开玩笑说没加这个控制之前我曾经跑出过一个 27 步的循环账单比我中午那碗面还贵。虽然是调侃但这类保护逻辑确实是任何 Agent 框架都必须有的安全垫。有了它方案三才不是玩具而是能放在真实业务里给用户试用的底座。5. 面试里的临场经验我提炼的避坑清单与加分细节三种方案写完面试官没有立刻让我停而是跟我聊了十分钟工程化问题。最后复盘的时候我发现真正拉开差距的不是某个 JSON 解析技巧而是几个容易被忽视的习惯。5.1 最重要的第一反应先问边界再动手题面只说“手写一个 Tool Use”但没告诉你模型是什么、工具是什么、调用是同步还是异步。我上来先问了一句“我可以假定工具都是同步阻塞的吗模型输出需要什么格式”这两个问题让面试官迅速意识到你是在做系统设计而不是背代码模板。后面我写代码时每一个方案都有清晰的适用范围也是基于这些前提假设。5.2 写的过程中最容易犯的三个错我这次也差点踩到特意列出来工具名大小写和空格模型可能生成get_Weather或get_weather解析时务必做归一化处理最好统一转小写再匹配注册表否则白名单直接查不到。JSON 参数嵌套的二次转义模型输出的arguments是 JSON 字符串里面还可能有转义引号。我在解析时至少做了两层loads第一层从输出里拆出字符串第二层把字符串本身解析成对象。忘了这层参数永远是个字符串而不是字典。工具执行异常不能静默吞掉我在方案一里用的是except Exception包住整个执行返回错误信息。很多新手会写成直接打印或者pass结果模型永远不知道工具失败了下一轮就开始胡编结果。把错误明明白白写回上下文反而是对模型最友好的行为。5.3 关于安全和权限的那条硬性规则不管用哪个方案都必须禁止动态拼接命令或代码执行。我方案一里把工具函数都收在注册表里、方案二里加 Schema 白名单、方案三里也只是把参数传给已经定义好的函数全程没有把模型输出直接扔给exec()或eval()——除了那个演示用的calculate工具我也刻意用了安全求值封装没有裸奔。安全这条线一定要主动说出来。面试官最担心的不是你不会写循环而是你真把模型输出当成系统指令执行。明确说“我在这里加了白名单和参数校验”这比任何花哨的特性都更能证明你是一个能负责生产系统的工程师。5.4 最后给一个马上能用的小技巧如果你正在准备类似的面试建议自己把手写代码敲一遍然后用三种不同格式的假模型输出去测解析器一种是完美 JSON一种是夹杂解释文字的半结构化文本一种是参数缺了一半的错误请求。把这三种都跑顺你的 Tool Use 基本功就算扎实了。我自己平时迭代 Agent 框架也是拿这三类样本当回归用例省了不知道多少头疼时间。
阅读完成 · 觉得有帮助?
咨询建站