面试手写Tool Use:从纯手工解析到轻量Agent框架的三种实现方案

发布时间:2026/10/20 3:39:58
面试手写Tool Use:从纯手工解析到轻量Agent框架的三种实现方案
1. 面试现场为什么“手写 Tool Use”成了高频考题1.1 从“背八股”到“现场造轮子”的转变这两年技术面试的风向变了。前几年大家还在背 Transformer 的注意力公式、手推反向传播现在面试官更倾向于把白板推到你面前说一句“来给我手写一个 Tool Use 的调度逻辑。”我第一次遇到这个题的时候脑子里的第一反应是这东西平时不都是调库吗OpenAI 的 function calling、各种 Agent 框架里现成的 tool 注册机制谁还真从零写一遍但面试官显然不是想看你背 API他想看的是你对工具调用Tool Use这件事底层机制的理解——模型怎么知道有哪些工具可用、怎么决定调用哪个、参数怎么传、结果怎么回填、多轮怎么串起来。所谓 Tool Use说白了就是让大模型在对话过程中能够“伸手”去调用外部函数或接口而不是只靠自己的参数记忆硬答。比如你问“今天天气怎么样”模型自己不知道但它可以调用一个get_weather(city)的函数拿到真实数据后再组织语言回答你。这个“决定调用—传参—执行—回填—再生成”的闭环就是 Tool Use 的核心。面试官让你手写考的不是你能不能记住某个框架的写法而是你能不能把这个闭环用最朴素的代码表达清楚。这背后涉及几个关键能力工具描述的结构化设计、模型输出的解析、参数校验、执行调度、结果注入、多轮循环控制。这六件事任何一环没想清楚代码就写不完整。1.2 三种方案的分野从“裸写”到“半自动”我在现场一共给出了三种方案面试官的表情从“嗯”到“有点意思”再到“这个可以聊聊”说明这三种方案的递进关系是成立的。这三种方案分别是方案一纯手工解析 JSON 字符串。模型输出一段约定格式的文本我自己写解析器把它拆成函数名和参数然后手动 dispatch。这是最原始、最暴露细节的做法但最能体现你对流程的掌控。方案二基于 JSON Schema 做参数校验与自动绑定。在方案一的基础上引入 schema 来描述每个工具的参数类型、必填项、默认值解析后自动做类型转换和校验减少手写 if-else。方案三注册表 装饰器 多轮循环的轻量框架。把工具注册、描述生成、调用、结果回填、循环终止条件封装成一套小机制接近真实 Agent 框架的雏形但代码量控制在两百行以内。这三种方案不是互相替代的关系而是层层递进。面试时你先给方案一证明你懂原理再给方案二证明你懂工程最后给方案三证明你懂抽象。面试官要的就是这个递进过程。提示面试时不要一上来就写方案三。很多人觉得写得越“框架”越显得厉害但面试官往往更想看你从零推导的过程。先裸写再优化这个思路本身就是加分项。1.3 这篇文章适合谁看如果你正在准备 AI 应用方向的面试或者你正在自己动手做 Agent 相关的项目又或者你只是好奇“模型调用工具”这件事到底是怎么实现的那这篇内容应该能帮到你。我会把三种方案的完整代码、设计取舍、踩坑经验都摊开讲尽量做到你照着抄就能跑起来。我不打算用某个特定框架的 API 来糊弄过去而是用最通用的 Python 伪代码加真实可运行的逻辑来演示。你不需要装任何特殊的库标准库加上一个假设的模型调用接口就够了。重点是把机制讲透而不是把某个库的用法讲全。2. 方案一纯手工解析把每一步都摊在阳光下2.1 核心思路约定一个模型能稳定输出的格式方案一的出发点很简单模型本质上是一个文本生成器它输出的是一串字符。我要做的第一件事就是和模型约定一个它能稳定遵守的输出格式让我后续能用代码解析。最常见的约定是让模型输出一段 JSON比如{ tool: get_weather, arguments: { city: 杭州 } }但这里有个坑模型不一定每次都输出纯 JSON它可能在前面加一句“好的我来帮你查询”后面加一句“以上就是结果”。所以解析的时候不能直接json.loads整个输出得先把 JSON 片段抠出来。我在现场写的第一版解析逻辑是这样的import json import re def parse_tool_call(model_output: str): # 用正则抠出第一个完整的 JSON 对象 match re.search(r\{.*\}, model_output, re.DOTALL) if not match: return None try: data json.loads(match.group()) except json.JSONDecodeError: return None tool_name data.get(tool) arguments data.get(arguments, {}) if not tool_name: return None return tool_name, arguments这段代码很短但每一行都有讲究。re.DOTALL是为了让.能匹配换行符因为模型输出的 JSON 经常是格式化的多行。re.search而不是re.findall是因为我只要第一个匹配避免模型输出多个 JSON 时选错。try-except是必须的模型输出非法 JSON 的概率不低不能让程序直接崩掉。2.2 工具注册与 dispatch用字典代替 if-else解析出工具名和参数之后下一步是找到对应的函数并执行。最朴素的做法是写一堆 if-elseif tool_name get_weather: result get_weather(**arguments) elif tool_name search_web: result search_web(**arguments)但这样写有两个问题一是工具多了之后代码会膨胀得很难看二是新增工具要改 dispatch 逻辑违反开闭原则。所以更合理的做法是用一个字典做注册表TOOL_REGISTRY {} def register_tool(name): def decorator(func): TOOL_REGISTRY[name] func return func return decorator register_tool(get_weather) def get_weather(city: str): # 模拟真实调用 return f{city}今天晴气温 22 度 register_tool(search_web) def search_web(query: str): return f关于「{query}」的搜索结果...这样 dispatch 就变成了一行func TOOL_REGISTRY.get(tool_name) if func is None: return f错误未找到工具 {tool_name} result func(**arguments)字典注册表的好处是显而易见的新增工具只需要加一个装饰器dispatch 逻辑完全不用动。而且TOOL_REGISTRY本身就可以用来生成工具描述喂给模型形成闭环。2.3 结果回填与多轮循环别忘了终止条件拿到工具执行结果之后不能直接把它当最终答案返回给用户因为模型还没“看到”这个结果。正确的做法是把结果作为一条新的消息追加到对话历史里再让模型基于这个结果生成最终回复。def run_conversation(user_input, max_turns5): messages [{role: user, content: user_input}] for _ in range(max_turns): model_output call_model(messages) parsed parse_tool_call(model_output) if parsed is None: # 模型没有调用工具直接返回它的输出 return model_output tool_name, arguments parsed func TOOL_REGISTRY.get(tool_name) if func is None: tool_result f错误未找到工具 {tool_name} else: try: tool_result func(**arguments) except Exception as e: tool_result f工具执行出错{e} messages.append({role: assistant, content: model_output}) messages.append({role: tool, content: str(tool_result)}) return 达到最大轮次限制未能完成这里有几个关键点。第一max_turns是必须的否则模型可能陷入“调用工具—结果不对—再调用—再不对”的死循环。第二工具执行要包try-except因为参数可能不对、网络可能超时不能让一个工具的错误把整个对话搞崩。第三消息角色要区分清楚assistant是模型的输出tool是工具的结果模型需要看到这两者才能理解上下文。注意很多初学者会忘记把模型的原始输出也追加到消息历史里。如果只追加工具结果模型会不知道自己之前说了什么导致上下文断裂。2.4 方案一的局限为什么不能止步于此方案一能跑通但问题也很明显。首先是参数校验完全靠工具函数自己如果模型传了一个字符串给需要整数的参数函数内部就会报错。其次是工具描述是手写的新增工具要同时改注册表和描述文本容易不一致。最后是解析逻辑对模型输出格式的依赖太强换个模型可能就解析失败了。这些问题在方案二和方案三里会逐步解决。但我要强调的是方案一的价值不在于它能用于生产而在于它把 Tool Use 的每一个环节都暴露出来了。你只有亲手写过这一遍才能真正理解后面那些框架到底在帮你做什么。3. 方案二引入 JSON Schema让参数校验自动化3.1 为什么需要 Schema从“能跑”到“跑得稳”方案一最大的痛点是参数校验。模型可能传错类型、漏传必填项、多传无关参数这些都需要在调用工具之前拦住。如果每个工具函数里都写一遍校验逻辑代码会变得非常冗余。JSON Schema 就是为解决这个问题而生的。它用一种声明式的方式描述数据的结构包括类型、必填项、取值范围、默认值等。我可以为每个工具定义一个 schema然后在调用前用 schema 校验参数校验通过再执行。WEATHER_SCHEMA { type: object, properties: { city: {type: string, description: 城市名称}, unit: {type: string, enum: [celsius, fahrenheit], default: celsius} }, required: [city] }这个 schema 告诉我校验器参数必须是一个对象city是必填的字符串unit是可选的只能是celsius或fahrenheit默认值是celsius。3.2 手写一个轻量校验器不依赖第三方库面试现场不一定能装jsonschema这个库所以我干脆手写了一个简化版的校验器。核心逻辑就是遍历 schema 的properties检查每个字段的类型和必填性。def validate_arguments(arguments: dict, schema: dict): errors [] properties schema.get(properties, {}) required schema.get(required, []) for key in required: if key not in arguments: errors.append(f缺少必填参数{key}) for key, value in arguments.items(): if key not in properties: errors.append(f未知参数{key}) continue expected_type properties[key].get(type) if expected_type string and not isinstance(value, str): errors.append(f参数 {key} 应为字符串) elif expected_type integer and not isinstance(value, int): errors.append(f参数 {key} 应为整数) elif expected_type number and not isinstance(value, (int, float)): errors.append(f参数 {key} 应为数字) enum_values properties[key].get(enum) if enum_values and value not in enum_values: errors.append(f参数 {key} 的值 {value} 不在允许范围内) # 填充默认值 for key, prop in properties.items(): if key not in arguments and default in prop: arguments[key] prop[default] return errors, arguments这个校验器虽然简单但覆盖了最常见的几种情况必填检查、类型检查、枚举检查、默认值填充。返回的errors列表如果非空就说明参数有问题直接把错误信息回填给模型让它重新生成。3.3 把 Schema 和工具绑定注册表的升级有了 schema 之后注册表也要升级不能只存函数还要存 schema 和描述。TOOL_REGISTRY {} def register_tool(name, description, schema): def decorator(func): TOOL_REGISTRY[name] { function: func, description: description, schema: schema } return func return decorator register_tool( nameget_weather, description查询指定城市的天气, schemaWEATHER_SCHEMA ) def get_weather(city: str, unit: str celsius): return f{city}今天晴气温 22 度{unit}这样注册表就变成了一个自描述的工具库。我可以用它生成给模型看的工具列表也可以用它在调用前做校验一举两得。3.4 生成工具描述让模型知道有什么可用模型怎么知道有哪些工具可用靠的是我在 prompt 里塞进去的工具描述。有了注册表之后这个描述可以自动生成不用手写。def build_tool_prompt(): lines [你可以使用以下工具] for name, meta in TOOL_REGISTRY.items(): lines.append(f- {name}: {meta[description]}) lines.append(f 参数{json.dumps(meta[schema], ensure_asciiFalse)}) lines.append(如果需要调用工具请输出 JSON 格式{\tool\: \工具名\, \arguments\: {...}}) return \n.join(lines)这段生成的文本会作为 system prompt 的一部分发给模型。模型看到之后就知道自己有哪些工具、每个工具需要什么参数、输出格式是什么。实操心得工具描述里的参数说明要尽量具体。比如city不要只写“城市”要写“城市名称如杭州、北京”。模型对参数含义的理解越准确传参的准确率越高。3.5 方案二的完整调用流程把上面的部分串起来方案二的调用流程就变成了用户输入问题。系统 prompt 里带上自动生成的工具描述。模型输出工具调用 JSON。解析 JSON拿到工具名和参数。用对应的 schema 校验参数填充默认值。校验通过则执行工具不通过则把错误信息回填。把工具结果追加到消息历史再次调用模型。模型生成最终回复或再次调用工具循环直到终止。这个流程比方案一严谨得多参数错误在调用前就被拦住了不会让工具函数自己报错。而且工具描述是自动生成的新增工具只需要加一个装饰器维护成本大大降低。4. 方案三注册表 装饰器 多轮循环的轻量框架4.1 抽象的目标把重复逻辑收进框架方案二已经能用了但每次写一个新的 Agent 应用都要把解析、校验、dispatch、循环这些逻辑重写一遍。方案三的目标就是把这些重复逻辑封装成一个轻量框架让使用者只需要关心“定义工具”和“写业务逻辑”。这个框架的核心组件有四个工具注册表、消息管理器、调用解析器、循环控制器。我把它命名为MiniAgent代码量控制在两百行以内但足够表达一个 Agent 框架的骨架。4.2 工具注册表支持同步和异步真实场景里工具可能是同步的也可能是异步的比如调用外部 API。所以注册表要能同时支持两种。import inspect class ToolRegistry: def __init__(self): self._tools {} def register(self, name, description, schema): def decorator(func): self._tools[name] { function: func, description: description, schema: schema, is_async: inspect.iscoroutinefunction(func) } return func return decorator def get(self, name): return self._tools.get(name) def all_tools(self): return self._tools def build_prompt(self): lines [你可以使用以下工具] for name, meta in self._tools.items(): lines.append(f- {name}: {meta[description]}) lines.append(f 参数{json.dumps(meta[schema], ensure_asciiFalse)}) lines.append(调用工具时输出{\tool\: \名称\, \arguments\: {...}}) return \n.join(lines)用inspect.iscoroutinefunction判断函数是不是异步的这样执行的时候可以分别处理。4.3 消息管理器统一管理对话历史消息历史的管理看起来简单其实容易出错。比如工具结果要用什么角色、模型的原始输出要不要保留、系统 prompt 放在哪里。我把它封装成一个类对外只暴露add_user、add_assistant、add_tool_result这几个方法。class MessageManager: def __init__(self, system_promptNone): self.messages [] if system_prompt: self.messages.append({role: system, content: system_prompt}) def add_user(self, content): self.messages.append({role: user, content: content}) def add_assistant(self, content): self.messages.append({role: assistant, content: content}) def add_tool_result(self, content): self.messages.append({role: tool, content: str(content)}) def get(self): return self.messages这个类的好处是消息角色的使用被统一了不会出现一会儿用tool一会儿用function的混乱情况。4.4 循环控制器终止条件要设计好循环控制器是整个框架的核心。它要决定什么时候继续调用模型、什么时候终止、最多循环多少次、工具执行失败怎么办。class MiniAgent: def __init__(self, model_call, registry, max_turns5): self.model_call model_call self.registry registry self.max_turns max_turns def run(self, user_input): system_prompt self.registry.build_prompt() messages MessageManager(system_prompt) messages.add_user(user_input) for turn in range(self.max_turns): output self.model_call(messages.get()) parsed parse_tool_call(output) if parsed is None: return output tool_name, arguments parsed meta self.registry.get(tool_name) if meta is None: result f错误未找到工具 {tool_name} else: errors, arguments validate_arguments(arguments, meta[schema]) if errors: result f参数错误{; .join(errors)} else: try: result meta[function](**arguments) except Exception as e: result f工具执行异常{e} messages.add_assistant(output) messages.add_tool_result(result) return 达到最大轮次任务未完成这个run方法就是整个框架的入口。使用者只需要定义工具、注册、传入模型调用函数就能跑起来。4.5 三种方案的对比与选型建议把三种方案放在一起对比能更清楚地看到各自的适用场景。维度方案一纯手工方案二Schema 校验方案三轻量框架代码量最少中等较多参数校验无靠函数自己自动校验自动校验工具描述手写自动生成自动生成扩展性差好很好适用场景面试演示、学习原理中小型项目多工具、多轮对话异步支持无无有调试难度低中中高面试的时候我建议按方案一、二、三的顺序讲让面试官看到你的思考过程。实际项目中如果工具不超过五个、逻辑不复杂方案二就够了如果工具多、需要多轮对话、有异步调用方案三更合适。踩过的坑方案三里我一开始没加max_turns结果模型在某个工具一直返回错误的情况下反复调用同一个工具陷入了死循环。后来加了轮次限制并且在工具连续失败两次后强制终止才解决这个问题。5. 常见问题与排查技巧实录5.1 模型不按格式输出怎么办这是最常见的问题。模型可能输出自然语言、输出不完整的 JSON、输出多个 JSON、或者在 JSON 外面包了 markdown 代码块。我的处理策略是分三层第一层用正则抠 JSON 片段容忍前后有额外文本。第二层如果解析失败把错误信息回填给模型让它重新输出。第三层如果连续两次解析失败就放弃工具调用直接返回模型的原始输出避免无限重试。def parse_with_retry(model_call, messages, max_retry2): for _ in range(max_retry): output model_call(messages) parsed parse_tool_call(output) if parsed is not None: return parsed, output messages.append({role: user, content: 你的输出格式不正确请严格按照 JSON 格式输出工具调用。}) return None, output5.2 参数类型不匹配怎么处理模型经常把数字传成字符串比如{count: 5}而不是{count: 5}。我的做法是在校验器里加一层类型转换如果 schema 要求整数但传了字符串尝试int()转换如果要求数字但传了字符串尝试float()转换。转换失败才报错。def coerce_type(value, expected_type): if expected_type integer and isinstance(value, str): try: return int(value) except ValueError: return value if expected_type number and isinstance(value, str): try: return float(value) except ValueError: return value return value这个转换逻辑要放在校验之前先转换再校验能减少很多不必要的报错。5.3 工具执行超时怎么办真实工具可能调用外部 API网络延迟不可控。如果不加超时整个对话可能卡死。我的做法是给每个工具调用加一个超时限制用concurrent.futures或者asyncio.wait_for实现。import concurrent.futures def call_with_timeout(func, args, timeout10): with concurrent.futures.ThreadPoolExecutor() as executor: future executor.submit(func, **args) try: return future.result(timeouttimeout) except concurrent.futures.TimeoutError: return 工具执行超时超时之后返回一个明确的错误信息让模型知道这次调用失败了它可以决定是重试还是换一个工具。5.4 多轮对话中上下文丢失怎么办有时候模型在第一轮调用了工具第二轮却忘了之前的结果。这通常是因为消息历史没有正确传递。要确保每一轮都把完整的消息历史传给模型包括 system prompt、用户输入、模型的工具调用输出、工具结果。还有一个细节工具结果的内容不要太长。如果工具返回了几千字的文本会占用大量 token还可能把关键信息淹没。我的做法是对工具结果做截断只保留前 500 个字符或者让工具函数自己返回摘要。5.5 常见问题速查表问题现象可能原因排查方向解决方法模型不调用工具工具描述不清晰检查 prompt 里的工具说明补充参数示例和调用格式JSON 解析失败模型输出格式不稳定打印模型原始输出加正则抠取 重试机制参数类型错误模型传参不规范检查 schema 定义加类型转换层工具执行超时外部依赖慢检查工具内部逻辑加超时控制循环不终止缺少轮次限制检查循环条件加 max_turns 和失败计数上下文断裂消息历史不完整检查消息追加逻辑确保 assistant 和 tool 消息都追加5.6 几个容易被忽略的细节第一个细节是工具名的命名。不要用getWeather这种驼峰也不要用get-weather这种带横杠的用get_weather下划线风格最稳因为模型对下划线的处理最一致。第二个细节是 schema 里的description字段。这个字段不只是给人看的模型也会读。所以描述要写清楚参数的语义比如city: {type: string, description: 城市名称如杭州、北京}加上示例能显著提升传参准确率。第三个细节是错误信息的措辞。回填给模型的错误信息要具体不要只说“参数错误”要说“参数 city 缺失”或“参数 count 应为整数实际收到字符串”。模型看到具体的错误信息才能有针对性地修正。第四个细节是工具结果的格式。如果工具返回的是结构化数据最好序列化成 JSON 字符串再回填这样模型更容易理解。如果返回的是自然语言就直接用字符串。6. 从面试题到生产这套逻辑还能怎么用6.1 面试之外的延伸场景手写 Tool Use 这件事面试只是一个触发点。真正有价值的是这套逻辑可以迁移到很多实际场景里。比如你在做一个客服机器人需要查询订单状态、修改收货地址、发起退款这些都可以封装成工具用这套调度逻辑串起来。再比如你在做一个数据分析助手需要执行 SQL 查询、生成图表、导出报表也可以用同样的模式。甚至你在做一个智能家居控制中心开关灯、调温度、查电量本质上都是工具调用。核心模式是一样的定义工具—生成描述—模型决策—参数校验—执行—回填—循环。你把这套模式吃透了换任何场景都只是换工具函数而已。6.2 性能优化的几个方向如果工具数量很多比如超过二十个把所有工具描述都塞进 prompt 会占用大量 token而且模型的选择准确率会下降。这时候可以做工具分组先让模型选择工具类别再在类别内选择具体工具。另一个方向是缓存。如果某个工具调用结果在短时间内不会变化可以加一层缓存避免重复调用。比如天气查询可以缓存十分钟汇率查询可以缓存一小时。还有一个方向是并行调用。如果模型一次输出了多个工具调用可以并行执行而不是串行等待。这需要把执行逻辑改成异步的用asyncio.gather来并发。6.3 我个人的一点体会面完那场之后我最大的感受是手写一遍 Tool Use比看十篇框架文档都有用。以前用现成的框架遇到问题只能猜现在自己写过一遍知道每一步在干什么排查问题的速度快了很多。而且这套逻辑不依赖任何特定框架你可以在任何语言里复现。我用 Python 写了一遍后来用 JavaScript 又写了一遍核心思路完全一样。这种“一次理解到处复用”的感觉是背 API 给不了的。最后分享一个小技巧如果你在面试现场时间不够先把方案一的骨架写出来确保解析、dispatch、回填、循环这四个环节都有然后再口头补充方案二的 schema 校验和方案三的抽象思路。面试官更看重你的思路完整性而不是代码的完美程度。