资讯动态

从零实现Tool Use:三版方案拆解多轮Agentic Loop核心逻辑

发布时间:2026/10/22 10:27:30 来源:尧图企业网站定制
1. 面试题拆解这道手写题到底在考什么先说场景。我去那家公司面的是一个偏应用层的算法岗聊完项目、聊完八股面试官话锋一转“你现在手头没有框架也没有现成的 Agent SDK能不能给我把 Tool Use 的核心逻辑从零写出来”当时我脑子里的第一反应不是慌而是意识到——这道题考察的根本不是我会不会调某个现成库而是我对“大模型工具”这条链路本质的理解程度。如果你只会用现成的 Function Calling 接口把 OpenAI 的 tools 参数一填、等着返回 JSON那你只能算个 API 调用员而不是真正理解 Tool Use 的人。面试官需要的无非是三件事第一你知不知道模型本身是不会调用工具的关键在于“决策中点”——是外部代码在判断“什么时候调工具、调哪个工具、参数是什么”而不是模型自己在执行代码。第二你知不知道该让模型输出什么样的结构才能安全地、稳定地被程序解析。第三你有没有想过多轮调用的闭环——工具结果返回后模型还要能消化结果并继续推理这中间的循环体怎么写迭代终止条件怎么定。这三种能力恰好对应了我在白板上从零写的三版方案。它们不是三条平行的路线而是一个层层递进的思考过程从纯规则到半智能再到完整闭环每一步都比上一步多解一个问题也多踩几个坑。下面我把三版方案的核心逻辑、代码骨架、以及我在写的过程中踩到的问题完整回放出来。2. 第一份答卷正则匹配驱动的硬编码工具路由2.1 方案思路把“识别意图”这件事留给规则我写的第一版是纯粹的函数注册 关键词/正则匹配。核心逻辑很简单一个工具字典key 是工具名value 是函数实现一个意图路由函数拿着用户输入去遍历每个工具预设的匹配规则一旦命中就执行对应函数。下面是我当时写的核心代码# 第一版纯规则路由 def get_weather(city: str) - str: # 模拟天气接口调用 return f{city}今天多云气温 24℃ def calc(expression: str) - float: # 模拟计算器 return eval(expression) # 注意仅演示生产环境严禁直接 eval TOOL_REGISTRY { weather: { func: get_weather, matcher: lambda text: 天气 in text and 温度 in text, arg_extractor: lambda text: text.split(天气)[0].strip() }, calculator: { func: calc, matcher: lambda text: any(ch in text for ch in [, -, *, /]) } } def route_and_execute(user_input: str) - str: for tool_name, tool_cfg in TOOL_REGISTRY.items(): if tool_cfg[matcher](user_input): if tool_name weather: city tool_cfg[arg_extractor](user_input) return tool_cfg[func](city) elif tool_name calculator: return str(tool_cfg[func](user_input)) return 未匹配到任何工具这套东西的优点极其明显快、稳、便宜。整个路由逻辑不涉及任何模型调用零延迟零失败率所有行为都是确定性的——用户说“北京天气”就绝不可能给你触发计算器。当你的场景只有三五个工具且用户输入高度规范化的时候它甚至比后面两版方案都要好用。2.2 面试官追问的第一个问题边界在哪面试官看完第一版紧接着就问“如果用户说‘北京明天会不会下雨顺便帮我算一下 23*19’你的正则怎么办”我当场就意识到这个方案的致命缺陷多个工具叠加的复合意图以及口语化的语义变体靠人工写 matcher 是永远追不完的。而且更隐蔽的问题是arg_extractor 这套逻辑极其脆弱。“北京天气”和“北京的天气怎么样”提取出来的城市参数完全不一样你可能要多写七八种正则分支才能勉强覆盖主流说法。数据一多你就发现维护成本高到离谱最终会变成一坨不可维护的 if-else 泥潭。但这并不代表第一版被淘汰了相反我觉得它仍然是整个链路里的一个重要组件只不过它的定位应该是前置的路由过滤器或者简易场景的快速通道。在 AI 网关场景里我见过不少团队把这套硬规则放在最前面先做一轮兜底如果规则能命中就直接返回不用白白消耗一次模型调用的 token只有当规则覆盖不到时才往下一层走。事实上现在很多人都在谈 Agent 的成本控制方案一就是成本控制的第一道闸门。我对这套混排方案的评估是能覆盖 60% 的高频高频固定场景就足以让整体调用成本下降一个数量级。这个思路我在最后一部分还会展开讲它是我后来在实际项目里反复用的架构。3. 第二份答卷用结构化语义解析服务和 JSON 输出约束实现半智能路由3.1 方案思路既然模型天然懂语义就把语义判断交给模型既然人工规则追不上自然语言的无限变体那最自然的下一步就是把“从文本中挑工具、填参数”这件事委托给大模型。这正是今天主流 Tool Use 框架背后的核心思想——工具选择本质是一个受限的生成任务你要让模型在一个严格约束的格式空间里输出函数名和参数 JSON而不是输出自由文本。所以第二版方案我做的是构造一个“函数说明”列表把它和用户问题一起塞进 Prompt让模型输出一段严格的 JSON再靠程序去解析和执行。# 第二版格式化输出 结构化工具描述 TOOL_SCHEMAS [ { name: get_weather, description: 查询指定城市的当前天气和气温。当用户提到某城市的天气、温度、下雨概率时使用。, parameters: { type: object, properties: { city: {type: string, description: 城市名如北京、上海} }, required: [city] } }, { name: calculator, description: 进行数学运算。当用户给出一个算式或要求计算时使用。, parameters: { type: object, properties: { expression: {type: string, description: 数学表达式如23*19} }, required: [expression] } } ] SYSTEM_PROMPT 你是函数调用路由助手。根据用户输入从下列函数中选择一个并输出 JSON。 输出格式必须严格保持为 {name: 函数名, arguments: {参数名: 参数值}} 如果没有匹配的函数输出 {name: null, arguments: null} 不要输出任何解释性文字。 def parse_and_execute(user_input: str, schemas: list, llm_generate_fn) - str: # llm_generate_fn 是注入的大模型生成函数返回文本 raw_output llm_generate_fn(SYSTEM_PROMPT, schemas, user_input) # 规范化模型输出提取 JSON result extract_json(raw_output) if result[name] is None: return 无法理解你的请求请换个说法 if result[name] get_weather: return TOOL_REGISTRY[weather][func](result[arguments][city]) elif result[name] calculator: return str(TOOL_REGISTRY[calculator][func](result[arguments][expression])) return 未知工具面试官看到这版的时候明显点了点头因为我把方案一的缺陷补上了语义理解能力直接从大模型那里继承来用户说“北京明天下雨吗”也能准确映射到 get_weather参数也能被正确抽出。同时我还是保留了程序员的确定性控制权——模型输出的 JSON 必须被严格校验schema 任意字段缺失就拒绝执行并反馈错误不让模型输出直接触碰真实系统。3.2 关键陷阱工具描述的组织远比你想象的重要写到这里必须多插一句几乎所有在真实项目里用过 Tool Use 的人都会在工具描述上踩坑。你以为把函数名和参数列表丢给模型就行实际效果一塌糊涂。我的经验是description 要按用户意图写而不是按函数实现写。不要只写“查询天气数据”而是写清楚“当用户提到××城市的天气、温度、风力、降水等信息时使用”。模型是在做意图匹配不是在读你的代码注释。语义越贴近用户真实说法召回率越高。参数描述要写取值范围和单位。比如城市参数注明“可接受北京、上海、广州等县级以上城市名”模型会少很多凭空造参数的情况。宁可少传一个参数也不要让模型瞎编。有些工具调用失败就崩坏在这模型生成了一个当前会话里根本不存在的对象 ID。这不算模型的错是参数约束不够或者你忘了先把候选 ID 列表拼进 prompt 里。比如用户在文档 App 里说“把刚才那篇笔记设为私密”“刚才那篇”需要你先把用户的笔记列表放进上下文模型才知道笔记 ID 具体是什么。这一版方案算是半智能天平的正中间模型做语义决策代码做刚性执行既灵活又可控。但即便做到了这样事情还是没完——面试官盯着我的 JSON 输出看了几秒抛出了一个更刁钻的问题“如果用户先问‘北京天气怎么样’我回答完他又说‘那上海呢’你的模型还知道‘那上海呢’指的是天气查询吗”4. 第三份答卷带状态记忆的多轮 Agentic Loop4.1 单次调用模型的致命伤没有对话状态方案二看起来完整了但它实际上是一个一次性的输入输出函数用户每句输入进来单独做一次“语义解析”单独输出一个 JSON。问题在于真实对话不是单发的用户说“那上海呢”如果你不把上文的“北京天气”这个背景带进去工具路由根本不知道“那上海呢”指代的是什么。这也是为什么方案二三在真实对话里表现不佳的直接原因——模型本身无状态状态的载体是上下文窗口你必须把多轮历史有选择地喂回去才能让“工具调用”这个行为具备跨轮记忆。4.2 Loop让工具调用变成一条往返链路所以第三版方案的核心是把“用户输入 → 模型 → JSON → 执行工具”这条链路包进一个循环里并且在循环中持续维护对话上下文直到模型判定“该交付最终回答了”才跳出循环。我把当时写的循环结构复原一下class ToolUseAgent: def __init__(self, llm_generate_fn, schemas, max_iterations5): self.llm_generate_fn llm_generate_fn self.schemas schemas self.max_iterations max_iterations # 防死循环 self.history [] # 保存消息记录这是状态的核心 self.tool_result_map {} def register_tool_result(self, key: str, value: str): self.tool_result_map[key] value def run(self, user_input: str) - str: self.history.append({role: user, content: user_input}) for i in range(self.max_iterations): response self.llm_generate_fn(self.history, self.schemas) parsed extract_json(response) # 情况一模型判断需要调用工具 if parsed[name] is not None: tool_name parsed[name] args parsed[arguments] # 执行工具并把结果拼成一条“工具结果”消息追加进历史 result_value self.execute_tool(tool_name, args) self.history.append({ role: tool, tool_name: tool_name, content: result_value }) continue # 情况二模型觉得信息已经足够直接给出最终答案 else: return parsed.get(answer, response) return 已达到最大迭代轮数请尝试换个问法或拆分指令这个结构里最关键的设计是两点一是消息历史中多了 roletool 的消息类型。模型不是直接丢弃工具结果而是把结果回填进上下文。下一轮生成它就能参考上一步的工具输出继续推理或调整参数。这相当于给模型配了一个“笔记本”每次工具执行的结果都会被写上去。二是 max_iterations 的硬兜底。真实场景里模型经常犯轴比如把参数用错之后反复用同样的错误参数试了五六遍也发现不了如果循环没有边界你的生产流水线就被卡死了。我给这个案例设的是最多 5 轮实践中拉到 8 最多超过 8 轮还出不来结果的大概率是 prompt 或者工具描述本身就是有问题的。为了让你更直观地理解这条循环是怎么流动的我拿一个上面对话场景举例用户北京天气怎么样 Agent → 模型需要调用 get_weather(city北京) 模型 → Agent{name: get_weather, arguments: {city: 北京}} Agent → 执行工具得到多云24℃ Agent → 把该结果追加进历史 Agent → 模型历史里有用户问题、工具调用结果现在请生成最终回答 模型 → Agent北京今天多云气温 24℃。 Agent → 把北京今天多云气温 24℃作为 assistant 消息追加进历史 用户那上海呢 Agent → 模型历史里有上文模型理解那上海呢查上海天气你看如果没有最后一条“把执行完工具的 assistant 结果追加回历史”下一次用户说“那上海呢”时模型就丢失了前面对话的语义上下文。这也就是为什么方案三才是我心中标准 Tool Use 的最小闭环。4.3 版本三的工程化细节你真的会把循环写到生产吗面试的时候白板上写完这个循环逻辑基本对了但面试官如果继续往工程方向刨你大概率会被问到以下几个细节。我这里提前帮你把答案理顺第一上下文膨胀问题。每多跑一轮对话历史里的 tool 消息长度就膨胀一点。如果是查询类工具还好如果工具返回的是几 K 甚至几十 K 的长文本比如搜出来的文档片段不用几轮你的上下文窗口就爆了。我实际用的处理办法有两种一种是只保留最近 N 轮消息更早的内容做摘要压缩另一种是让工具在执行前先做一次“精简返回”只返回核心字段详细内容单独存到外部存储模型想看可以再调检索工具。这两种方式叠加使用线上效果比硬吃完整历史好得多。第二工具幂等性与副作用控制。如果工具的调用是“产生副作用”的——比如发邮件、下单、写入数据库——循环里执行工具时的幂等设计就必须在描述里写清楚。我踩过最痛的坑是模型因为第一次工具调用超时没拿到回执于是在第二第三轮里重复调用了同一个“发送邮件”工具结果客户收到了三封一模一样的邮件。解决办法是服务端做 idempotency-key 校验Prompt 里注明“同一个任务 ID 只允许执行一次工单”。尤其业务系统接入 Tool Use 时一定要把工具的行为语义写进描述这个工具是“可重复查询”还是“只执行一次”最好在工具名上就直接区分。第三模型输出的结构化兜底。不管提示词里说得多么严厉生产中总有个别请求会吐出不合法 JSON或者把函数名里的字母拼错。所以第三版方案一定要加容错组件对模型输出先做归一化——把最外层花括号抠出来、把换行空格压缩掉、字段名做模糊匹配再不济就对整段输出做个人工规则兜底匹配不上就直接回退请求用户换种说法。不要把希望寄托在“模型这次一定会听话”上。5. 三种方案的复盘什么场景用哪版、怎么组合最稳5.1 用一张表说明它们的适用边界很多人在面试中学完了第三版转头到项目里无脑套用这反而容易翻车。我更愿意把三版方案看成一套三层过滤的架构而不是互斥的选项。方案核心依赖优点缺点最适合的场景1. 正则/规则路由人工编写匹配规则零延迟、零费用、行为完全可控语义泛化能力差、维护成本随工具数量爆炸高频固定话术、关键词指令、前置兜底过滤2. 单轮语义格式化输出LLM 非开放生成 JSON 约束语义理解能力强、实现简单、单次往返即可完成无跨轮记忆、上下文关联弱单轮工具调用、问答型外设集成3. 多轮 Agentic LoopLLM 工具执行闭环支持多轮推理、具备上下文记忆、可处理复合任务成本高、有循环失控风险、需要额外兜底机制复杂业务编排、多步操作、需要外部数据辅助推理要知道方案一和方案三并不是只能二选一。一个比较成熟的 Agent 网关我最推荐的编排方式是一层规则直通覆盖高频确定场景→ 二层模型路由兜底语义判断→ 三层闭环扩展复杂任务再走到 Loop。这也是很多实际生产中“混合路由”的雏形既照顾了成本和延迟又保留了扩展能力。5.2 一个真实踩坑当工具结果是“表格”时怎么办最后说一个我在第三版方案落地过程中印象最深的坑。我当时给某项目做了一个数据问答 Agent其中有个工具是“查最近 30 天订单统计”返回值是一个 Pandas DataFrame。我没做序列化直接丢给了模型结果当然是彻底崩了——那个库的数据结构模型根本不认。后来我把工具返回统一强制成字符串普通 JSON 用json.dumpsDataFrame 用to_json(orientrecords)然后限制单个工具返回内容的长度过长就摘要化。这一步看似不起眼是工具执行闭环里最容易忽略的一环但恰恰决定了一个 Agent 的可用性。工具返回值永远要用大模型能够理解、也愿意继续处理的纯文本表示。提示这句话是经验不是书本知识。等你真的写过几百轮工具循环之后你会发现大量离奇的现象都是从这里冒出来的——模型答非所问、上下文爆炸、把上一次工具输出误当成用户指令根源都在工具返回值的“序列化表达”没有设计好。6. 面试复盘这道题考察的不只是 Coding 能力回过头看我给的三种方案其实已经把 Tool Use 这条链路拆透了方案一告诉你懂规则与确定性的价值方案二告诉你懂模型与代码协作的分工边界方案三告诉你懂对话状态的维持与工程闭环。本质上面试官看的是你有没有“系统级”的思维框架——工具调用不是往 API 里塞一个参数那么简单它是模型能力与外部世界连接的那条血管。我个人在整个面试过程的体会是白纸板前你不必装“我全会”但一定要展示“我清楚每种设计选择的理由和代价”。把三版方案讲清楚之后接下来无论面试官追问工程落地细节、分布式链路、还是异常兜底你都有底气接着聊。如果你正准备类似方向的面试建议把这三版方案的代码反复写几遍一版一版地增加约束和闭环弄懂每一步为什么不可缺少。纸上得来终觉浅你踩过一遍就知道那个循环控制、消息回填、约束解析到底有多重要了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑