1. 从单工具到工具注册表:Agent 工具调用综合实践要解决什么
如果你已经跟着前几课把联网搜索、本地文件读写这些单点工具跑通了,大概率会遇到一个很具体的瓶颈:每个工具都写死在一个if/else里,Agent 只能按固定顺序调用,稍微复杂一点的任务就卡住。比如「先在我电脑里找一份销售数据,再联网查行业增速,最后写一份对比报告」这种需求,单工具脚本根本接不住。
这一课要解决的核心问题,就是把散落的工具收进一张工具注册表,让 LLM 在ReAct 循环里自己决定「下一步该调哪个工具、传什么参数、拿到结果后要不要继续」。说白了,Agent 从「只会用一把锤子」升级成「有一个工具箱,还能自己挑工具」。
工具调用(Tool Calling / Function Calling)是 LLM Agent 最核心的能力之一。它让模型不再只是输出文字,而是能输出结构化的调用意图,由外部执行器去真正干活。ReAct 范式则提供了「推理—行动—观察」的循环骨架:模型先想一步,再动手,再看结果,再想下一步。把这两者结合,再加上一个统一的工具注册表,你就能搭出一个能处理多步任务的 Agent。
适合谁看:已经写过至少一个工具函数、懂基本 Python 和 OpenAI 兼容接口调用、想从「玩具 demo」迈向「能编排多工具」的开发者。整篇会交付三样可复制的东西——工具注册表配置、ReAct 提示模板、端到端验证步骤,跟着敲一遍就能跑通完整链路。
我试过把这套结构用在个人助理场景里,最大的感受是:工具注册表一旦标准化,新增工具的成本几乎为零,你只需要写一个函数加一条 Schema,Agent 立刻就能用上。下面从统一 Key 接入开始讲。
2. TaoToken 统一 Key 接入:一个 API 通道管住所有工具调用
多工具 Agent 有个容易被忽略的坑:工具一多,模型调用次数暴涨,如果你每个工具背后都接不同的模型服务商、不同的 Key,管理起来会非常乱。更现实的问题是,ReAct 循环里每一轮都要请求一次模型,延迟和稳定性直接决定 Agent 能不能用。
我的做法是用TaoToken 统一 Key作为唯一的模型调用通道。它提供 OpenAI 兼容的接口,意味着你现有的openaiSDK 代码几乎不用改,只需要把base_url和api_key换掉。这样工具注册表里的所有工具、ReAct 循环里的每一次决策,都走同一个通道,Key 管理、额度查看、模型切换都在一处完成。
先拿到你的 Key:进入控制台创建 API Key,路径是console下的api-keys页面。创建后复制那串以sk-开头的字符串,存到环境变量里,别硬编码进代码。
# .env 文件 TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_BASE_URL=https://taotoken.net/api这里有个细节要注意:base_url填https://taotoken.net/api,不要自己加/v1,OpenAI SDK 会自动拼接路径。很多人第一次接入报 404,就是因为多写了一段。
为什么强调「统一」?因为 ReAct 循环里模型会被调用很多次,如果每次调用都换服务商,你的调试成本会指数级上升。统一通道之后,你只需要在一个地方排查问题:是 Key 失效、模型名写错,还是网络超时。工具本身的逻辑反而变得纯粹——它只管执行,不管模型怎么调。
如果你打算长期跑编码类或 Agent 类任务,可以关注一下 Coding Plan,它更适合高频、长时间的调用场景,比按次计费更划算。但这一课我们先聚焦把链路跑通,计费方式后面再优化。
3. 可复制的工具注册表配置与 ReAct 提示模板
这一节是全文的技术核心,给你可以直接抄的配置。整个 Agent 由四部分组成:工具注册表、ReAct 提示模板、执行器、主循环。我们逐个来。
3.1 工具注册表:用 JSON Schema 描述每个工具
工具注册表的本质是一张「工具清单」,每个工具包含三样东西:名字、功能描述、参数 Schema。LLM 就是靠这份清单来决定调哪个工具的。先定义两个基础工具,一个联网搜索、一个本地文件读取。
# tool_registry.py import json def web_search(query: str) -> dict: """模拟联网搜索,实际项目替换为真实搜索 API""" return {"query": query, "result": f"关于「{query}」的行业数据:2024 年增长率约 18%"} def read_file(path: str) -> dict: """读取本地文件内容""" try: with open(path, "r", encoding="utf-8") as f: return {"path": path, "content": f.read()} except FileNotFoundError: return {"path": path, "error": "文件不存在"} # 工具注册表:名称 -> {函数, Schema} TOOL_REGISTRY = { "web_search": { "func": web_search, "schema": { "type": "function", "function": { "name": "web_search", "description": "联网搜索实时信息,适合查询行业数据、最新动态", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "搜索关键词"} }, "required": ["query"] } } } }, "read_file": { "func": read_file, "schema": { "type": "function", "function": { "name": "read_file", "description": "读取本地文件内容,适合处理用户电脑里的文档", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "文件路径"} }, "required": ["path"] } } } } } def get_tool_schemas(): return [t["schema"] for t in TOOL_REGISTRY.values()] def execute_tool(name: str, args: dict) -> str: if name not in TOOL_REGISTRY: return json.dumps({"error": f"未知工具:{name}"}, ensure_ascii=False) try: result = TOOL_REGISTRY[name]["func"](**args) return json.dumps(result, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}, ensure_ascii=False)这份注册表的关键设计是:Schema 和函数放在一起。新增工具时你只改一个字典,主循环完全不用动。description字段一定要写清楚「什么时候用」,这是 LLM 选工具的主要依据,写得越具体,选错工具的概率越低。
3.2 ReAct 提示模板:让模型先推理再行动
ReAct 的精髓在于把「思考」显式化。我们不直接让模型输出工具调用,而是先让它用一段文字说明「我现在要做什么、为什么」,再输出结构化的调用。这样调试时你能看到它的决策链路。
REACT_SYSTEM_PROMPT = """你是一个会使用工具的智能助手,遵循 ReAct 循环工作。 每一轮你必须按以下格式输出: Thought: 分析当前已知信息,说明下一步需要做什么、为什么。 Action: 如果需要调用工具,输出工具名和参数;如果信息已足够,输出 Final Answer。 可用工具清单: {tool_schemas} 规则: 1. 一次只调用一个工具,拿到结果后再决定下一步。 2. 优先用本地文件工具处理用户本地数据,用联网搜索补充外部信息。 3. 如果工具返回错误,分析原因后决定是否换工具或直接回答。 4. 信息足够时,用 Final Answer 给出整合后的结论。 """把{tool_schemas}用json.dumps(get_tool_schemas(), ensure_ascii=False)填进去。这个模板的作用是给模型一个稳定的输出结构,避免它东一句西一句。实测下来,加了 Thought 步骤之后,多步任务的完成率明显提升,因为模型被迫先规划再动手。
3.3 主循环:串起决策与执行
主循环负责把模型输出解析成工具调用,执行后再把结果喂回去,直到模型给出 Final Answer 或达到最大步数。
# agent.py import os, json, re from openai import OpenAI from dotenv import load_dotenv from tool_registry import get_tool_schemas, execute_tool load_dotenv() client = OpenAI( api_key=os.getenv("TAOTOKEN_API_KEY"), base_url=os.getenv("TAOTOKEN_BASE_URL") ) def run_agent(user_query: str, max_steps: int = 6): system = REACT_SYSTEM_PROMPT.format( tool_schemas=json.dumps(get_tool_schemas(), ensure_ascii=False) ) messages = [ {"role": "system", "content": system}, {"role": "user", "content": user_query} ] for step in range(max_steps): resp = client.chat.completions.create( model="gpt-4o-mini", messages=messages, tools=get_tool_schemas(), tool_choice="auto" ) msg = resp.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for call in msg.tool_calls: name = call.function.name args = json.loads(call.function.arguments) print(f"[Step {step+1}] 调用工具 {name},参数 {args}") result = execute_tool(name, args) print(f"[Step {step+1}] 返回 {result}") messages.append({ "role": "tool", "tool_call_id": call.id, "content": result }) return "达到最大步数,任务未完成"注意tool_choice="auto"让模型自己决定要不要调工具,max_steps是防止死循环的保险丝。工具返回的消息必须带tool_call_id,否则接口会报错,这是很多人第一次写会漏的地方。
4. 端到端验证:跑通一次多工具编排
配置写完了,现在验证。准备一个测试文件,然后提一个需要「本地文件 + 联网搜索」协同的问题。
mkdir -p ./agent_files echo "2023 年公司销售额 500 万,同比增长 12%" > ./agent_files/sales.txt然后运行:
if __name__ == "__main__": query = "读取 ./agent_files/sales.txt 的内容,再联网查一下 2024 年行业平均增长率,对比分析我们是否达标" print(run_agent(query))预期你会看到类似这样的过程输出:
[Step 1] 调用工具 read_file,参数 {'path': './agent_files/sales.txt'} [Step 1] 返回 {"path": "./agent_files/sales.txt", "content": "2023 年公司销售额 500 万,同比增长 12%"} [Step 2] 调用工具 web_search,参数 {'query': '2024 年行业平均增长率'} [Step 2] 返回 {"query": "2024 年行业平均增长率", "result": "关于「2024 年行业平均增长率」的行业数据:2024 年增长率约 18%"}最后模型会输出一段整合结论,大意是「公司 2023 年增长 12%,低于行业平均 18%,存在差距」。到这里,一次完整的 ReAct 多工具编排就跑通了。
验证时重点看三件事:第一,模型是否先读本地文件再联网,顺序合理;第二,每次工具调用的参数是否正确解析;第三,最终回答是否同时用到了两个工具的结果。如果最终回答只提了文件内容、没提搜索数据,说明结果整合环节出了问题,通常是工具返回的 JSON 没被正确塞回对话历史。
想快速验证模型本身是否正常,可以先用模型对话页面发一条简单消息,确认 Key 和通道没问题,再回来跑 Agent。这样能把「模型通道问题」和「Agent 逻辑问题」分开排查。
5. 常见报错排查:401、local proxy failed、reading choices 怎么解
多工具 Agent 的报错大多集中在接入层和解析层,下面按真实遇到的顺序列出来。
401 Unauthorized / invalid api key:九成是 Key 没读到或写错。先确认.env里的TAOTOKEN_API_KEY没有多余空格,再确认load_dotenv()在OpenAI()初始化之前执行。如果你把 Key 写进了系统环境变量又同时有.env,可能读到旧值,建议只保留一处。
local proxy failed / connection error:这类报错通常是base_url写错或网络环境问题。检查base_url是否为https://taotoken.net/api,不要带/v1,也不要带结尾斜杠。如果公司网络有额外限制,换一个网络环境再试。
reading 'choices' of undefined:这个报错说明resp.choices是空的,常见原因是模型名写错,接口返回了错误结构但代码直接取choices[0]。把model换成通道支持的模型 ID,并在取choices前加一层判断:
if not resp.choices: raise RuntimeError(f"接口返回异常:{resp}")tool_calls 解析失败 / arguments 不是合法 JSON:模型偶尔会输出带注释的 JSON。稳妥做法是用json.loads包一层 try,失败时把原始字符串作为错误信息回传给模型,让它重试:
try: args = json.loads(call.function.arguments) except json.JSONDecodeError: args = {} result = json.dumps({"error": "参数解析失败,请重新生成合法 JSON"}, ensure_ascii=False)OAuth / 认证方式冲突:如果你之前用过某些 CLI 工具的 OAuth 登录,环境里可能残留了旧的认证配置,导致 SDK 走了错误的认证路径。清理掉相关环境变量,只保留TAOTOKEN_API_KEY这一条通道。
工具被反复调用、停不下来:这是 ReAct 循环的典型问题,通常是max_steps设太大,或者工具返回的错误信息让模型误以为「再试一次就好」。把max_steps控制在 5 到 8 之间,并在工具返回错误时明确告诉模型「此路不通,请换方案」。
排查时记住一个原则:先隔离通道,再隔离工具,最后看编排逻辑。用模型对话页面确认通道正常,单独调用每个工具函数确认工具正常,剩下的问题一定在 ReAct 循环的解析和消息拼接上。
6. 把工具注册表用起来:从跑通到长期可用
链路跑通只是起点。真正让这套结构产生价值,是把它变成你日常能复用的基础设施。这里给几个我踩过坑之后总结的实用建议。
第一,工具描述要当成 Prompt 来写。description不是注释,是给模型看的说明书。写「读取文件」不如写「读取用户本地指定路径的文本文件,适合处理 CSV、TXT、Markdown,不支持二进制」。描述越精确,模型选错工具的概率越低。
第二,给工具加白名单和超时。文件工具一定要限制可访问目录,搜索工具一定要设超时。Agent 自己决定参数,意味着它可能传进来任何路径,安全边界必须由执行器兜住,不能指望模型自觉。
第三,把 ReAct 的中间过程落盘。每次运行的 Thought、Action、Observation 都写进日志文件,出问题时能完整回放。多工具编排的 bug 往往藏在第三步、第四步,没有日志根本定位不到。
第四,新增工具时先单独测,再进注册表。工具函数本身跑不通,放进注册表只会让 Agent 的报错更难懂。先用一个简单脚本单独调用,确认输入输出符合预期,再补 Schema。
如果你打算把这套 Agent 长期跑在编码或自动化任务上,可以了解一下 Coding Plan,它针对高频调用场景做了优化,适合把工具注册表扩展成几十个工具之后的使用强度。接入文档里有完整的参数说明和示例,遇到通道层面的问题可以直接对照排查。
工具注册表这套结构的真正威力,在于它把「Agent 能做什么」和「Agent 怎么决策」解耦了。你负责往注册表里加工具,模型负责在 ReAct 循环里挑工具,两边互不干扰。今天你跑通的是两个工具,明天加到十个、二十个,主循环一行都不用改。这才是 Agent 区别于普通 LLM 应用的地方——它不只是会说话,而是有一个能持续扩展的工具箱,并且知道什么时候该伸手去拿哪一件。