☰
从零实现Tool Use:三版方案拆解多轮Agentic Loop核心逻辑
2026/10/22 10:27:17 网站建设 项目流程

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 Loop

4.1 单次调用模型的致命伤:没有对话状态

方案二看起来完整了,但它实际上是一个一次性的输入输出函数:用户每句输入进来,单独做一次“语义解析”,单独输出一个 JSON。问题在于真实对话不是单发的,用户说“那上海呢”,如果你不把上文的“北京天气”这个背景带进去,工具路由根本不知道“那上海呢”指代的是什么。

这也是为什么方案二三在真实对话里表现不佳的直接原因——模型本身无状态,状态的载体是上下文窗口,你必须把多轮历史有选择地喂回去,才能让“工具调用”这个行为具备跨轮记忆。

4.2 Loop:让工具调用变成一条往返链路

所以第三版方案的核心,是把“用户输入 → 模型 → JSON → 执行工具”这条链路包进一个循环里,并且在循环中持续维护对话上下文,直到模型判定“该交付最终回答了”才跳出循环。我把当时写的循环结构复原一下:

class ToolUseAgent: def __init__(self, llm_generate_fn, schemas, max_iterations=5): 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 "已达到最大迭代轮数,请尝试换个问法或拆分指令"

这个结构里最关键的设计是两点:

一是消息历史中多了 role=tool 的消息类型。模型不是直接丢弃工具结果,而是把结果回填进上下文。下一轮生成它就能参考上一步的工具输出,继续推理或调整参数。这相当于给模型配了一个“笔记本”,每次工具执行的结果都会被写上去。

二是 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.dumps,DataFrame 用to_json(orient="records"),然后限制单个工具返回内容的长度,过长就摘要化。这一步看似不起眼,是工具执行闭环里最容易忽略的一环,但恰恰决定了一个 Agent 的可用性。

工具返回值,永远要用大模型能够理解、也愿意继续处理的纯文本表示。

提示:这句话是经验,不是书本知识。等你真的写过几百轮工具循环之后,你会发现大量离奇的现象都是从这里冒出来的——模型答非所问、上下文爆炸、把上一次工具输出误当成用户指令,根源都在工具返回值的“序列化表达”没有设计好。

6. 面试复盘:这道题考察的不只是 Coding 能力

回过头看我给的三种方案,其实已经把 Tool Use 这条链路拆透了:

  • 方案一告诉你懂规则与确定性的价值;
  • 方案二告诉你懂模型与代码协作的分工边界;
  • 方案三告诉你懂对话状态的维持与工程闭环。

本质上,面试官看的是你有没有“系统级”的思维框架——工具调用不是往 API 里塞一个参数那么简单,它是模型能力与外部世界连接的那条血管。

我个人在整个面试过程的体会是:白纸板前,你不必装“我全会”,但一定要展示“我清楚每种设计选择的理由和代价”。把三版方案讲清楚之后,接下来无论面试官追问工程落地细节、分布式链路、还是异常兜底,你都有底气接着聊。

如果你正准备类似方向的面试,建议把这三版方案的代码反复写几遍,一版一版地增加约束和闭环,弄懂每一步为什么不可缺少。纸上得来终觉浅,你踩过一遍就知道那个循环控制、消息回填、约束解析到底有多重要了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询