☰
AI Agent触达外部世界:Function Calling工具调用实战指南
2026/10/6 17:41:09 网站建设 项目流程

让AI Agent真正“伸手够到”外部世界:Agent-Reach项目全记录

先说说我为什么要做这个项目。用了大半年各类AI Agent框架之后,最深的感受不是模型不够聪明,而是模型的手太短——它能聊天、能推理、能写代码,但当你让它“查一下订单物流”“调一下数据库里的用户信息”“给某个接口发个请求”时,它就卡住了。这不是模型本身的问题,而是Agent缺少与外界的触达能力。

Agent-Reach这个名字,字面意思就是“智能体的触达”。我把它做成了一个专门解决Agent能力边界问题的实验项目:让Agent通过标准化的工具调用机制,去访问数据库、调用第三方API、操作内部系统。这套机制本质上回答一个问题——你手里的AI助手,到底能不能真正帮你办事,而不只是陪你聊天。

这篇内容适合正在做Agent应用落地、被工具调用和外部系统集成折磨过的开发者。无论你是用现成框架还是自己写编排逻辑,这里面关于工具注册、Function Calling机制、输入输出约束、异常兜底的思路,都可以直接抄作业。

1. 项目整体设计与思路拆解

1.1 为什么要叫“Reach”:Agent的触达能力现状

我最早接触Agent时,觉得只要把模型接上Prompt,告诉它“你可以调用XX接口”,它就能自己完成任务。真跑起来才发现,理想和现实之间隔着一整条数据链路。

拿最简单的一个任务举例:让Agent查询订单状态。模型面对的问题是:订单数据存在哪个数据库?通过什么接口查?参数从哪来?“查询”这个动作模型本身不会做,它只会生成文本。你必须在模型和外部系统之间搭一座桥,让模型输出结构化的指令,再由程序执行指令——这就是Reach,也就是触达能力的核心。

可以理解为:Agent是大脑,它负责“想”;但它没有手,必须由代码当作手脚去“做”。Agent-Reach就是在做这样一副手脚。

它的设计思路有几个核心出发点:

  • 模型输出天然是概率性的,你不能让Agent直接拼SQL或直接调函数,必须有中间层做参数校验和格式约束
  • 工具数量一多,靠Prompt写死是行不通的,需要一套动态注册和发现机制
  • 外部系统的错误五花八门,必须有一层兜底逻辑,不能因为一个接口超时就把整个Agent会话搞崩

1.2 主流技术路线对比:Function Calling与MCP

做Agent工具调用,当前主流有两条技术路线,一种是各模型厂商都支持的Function Calling,另一种是Anthropic带起来、后来社区化的MCP协议(Model Context Protocol)。我选择以Function Calling为底座,但预留了MCP风格的接口抽象。

两条路线的差异我用表格做了对比:

维度Function CallingMCP协议
核心思路模型根据函数schema决定调用哪个工具标准化工具发现与调用协议,类似USB接口标准
依赖条件模型服务需支持该能力需要独立的MCP Server运行时
上手成本低,定义JSON Schema即可高,需要理解和搭建服务端
适用场景单体项目、工具数量可控多Agent、多客户端、工具跨系统共享
灵活性工具变更需随版本迭代工具可热插拔、动态发现

我选择的理由是:对于Agent-Reach这个项目,核心目标是先验证“触达链路”是否通畅。Function Calling收到的是结构化的JSON,天然适合用代码去校验和执行;而MCP的架构更重,适合工程化阶段再迁移。为了兼顾以后扩展,我在设计工具注册表时留了一个协议转换层——将来如果要把工具暴露成MCP服务,只需要写一个适配器,不需要改业务逻辑。

这个决策背后的逻辑是:任何架构优先解决当前90%的问题,同时为未来保留10%的扩展余地。不要在项目一开始就追求完美的抽象。

1.3 项目核心技术栈

Agent-Reach的整体技术栈很简单,但每一层都有明确分工:

  • Python 3.10+:主要开发语言,类型注解生态完善
  • FastAPI:起一个轻量的服务,用来模拟真实的外部业务系统
  • OpenAI兼容的Function Calling接口:用标准chat.completions协议做模型交互,可以对接多个兼容服务
  • JSON Schema:作为工具签名和参数校验的标准
  • SQLite:模拟真实业务数据库,跑通“Agent查单”这类场景

个人体会是,不需要一上来就上重型框架。先用最快路径把链路跑通,让Agent真的能查到数据、成功调用一次API,之后再考虑框架化、工程化。链路不通之前,一切架构讨论都是空的。

2. 核心机制拆解:工具注册、路由与约束

2.1 工具注册表:让Agent知道“你有什么”

Agent能调用什么工具,不能靠模型自己编,必须在请求模型前告诉它有哪些可用。这就是工具注册表要做的事。我把每一个工具定义成一个标准结构,包含名称、描述、参数Schema、执行函数四要素。

看一段核心代码,这是我项目里工具注册的具体实现:

# tool_registry.py from typing import Callable, Any, Dict import json class ToolRegistry: def __init__(self): self._tools: Dict[str, Dict[str, Any]] = {} def register(self, name: str, description: str, parameters: dict, func: Callable): """注册一个工具,parameters是JSON Schema格式的参数定义""" self._tools[name] = { "name": name, "description": description, "parameters": parameters, "func": func, } def get_openai_tools(self) -> list: """生成符合OpenAI Function Calling协议的工具列表""" tools = [] for tool in self._tools.values(): tools.append({ "type": "function", "function": { "name": tool["name"], "description": tool["description"], "parameters": tool["parameters"], } }) return tools def call(self, name: str, arguments: dict) -> Any: """根据工具名,动态调用真正的Python函数""" if name not in self._tools: raise ValueError(f"未知工具: {name}") tool = self._tools[name] return tool["func"](**arguments)

这里有几个细节容易被忽略:

第一,description必须写得足够具体。模型是依据description来决定要不要调用这个工具。如果你的描述写的是“查询订单”,模型能理解,但不够;应该写成“根据订单ID查询订单基本信息,包括订单状态、商品名称、金额、下单时间。订单ID是一串UUID格式的字符串”。描述越具体,模型选错的概率越低。

第二,parameters必须是严格的JSON Schema。特别是必填字段和类型约束。模型会根据Schema生成参数,如果你的类型写错了,比如把order_id写成了integer,而实际系统用的是字符串,那么即便模型成功生成了参数,执行时也会失败。

第三,注册表本身是动态的。我项目里是启动时统一注册,但你可以扩展成热加载,比如从配置文件读取工具定义,这样新增工具不用改代码。

2.2 让模型输出可靠的调用请求:Function Calling的关键

工具注册只是基础,真正核心的环节是让模型输出结构化的调用请求。以OpenAI兼容协议为例,模型在收到tools参数后,如果判断需要调用工具,会返回一个tool_calls字段,里面包含工具名和参数。

但这里面有一个新手容易踩的大坑:你以为模型会返回纯JSON,实际上函数的参数是一个JSON字符串,你需要二次解析。看下面这段处理逻辑:

# agent_core.py import json def parse_tool_calls(response): """解析模型返回的tool_calls,提取工具名和参数""" message = response["choices"][0]["message"] tool_calls = message.get("tool_calls", []) parsed_calls = [] for call in tool_calls: function = call.get("function", {}) name = function.get("name") try: arguments = json.loads(function.get("arguments", "{}")) except json.JSONDecodeError as e: # 模型生成的JSON偶尔会有残缺,需要兜底 raise ValueError(f"参数解析失败: {e.msg}, 原文: {function.get('arguments')}") parsed_calls.append({ "tool_call_id": call.get("id"), "name": name, "arguments": arguments, }) return parsed_calls

单独跑一遍完整的对话流程,看起来是这样的:

  1. 用户提问:“订单202402011730001234是什么状态?”
  2. 把用户消息和tools列表发给模型
  3. 模型返回tool_calls,内容是调用query_order,参数为{"order_id": "202402011730001234"}
  4. 程序解析参数,执行query_order函数,查到结果
  5. 把工具结果作为一条role=tool的消息,连同之前的对话历史再发给模型
  6. 模型基于工具结果,生成最终回答:“该订单状态为已发货,预计3天内送达。”

整个链路中最容易出问题的是第一步到第三步。模型偶尔会生成残缺的JSON,比如缺少右花括号,或者参数名和Schema不完全一致,所以在解析处做异常兜底是必须的。

2.3 参数提取与映射:把模型生成的东西变成代码能用的东西

模型生成的参数不会总是符合你的预期。它可能把“今天”翻译成具体日期,也可能用不精确的字符串去匹配ID。这里就需要一套参数提取与映射的逻辑。

我在项目里给每个工具的参数Schema添加了一些约束规则。举个例子,如果参数需要枚举值,就把枚举写清楚:

{ "type": "object", "properties": { "status": { "type": "string", "enum": ["pending", "paid", "shipped", "completed", "cancelled"], "description": "订单状态,可选值:待支付、已支付、已发货、已完成、已取消" } }, "required": ["status"] }

这样做的好处是,模型不会凭空编造一个状态值。它只能在枚举范围内选择,这就保证了参数的正确率。

对于日期这类模糊输入,我还会在代码层做一次归一化。比如用户说“查一下最近三天的订单”,模型可能生成start_date和end_date,但也可能只生成一个days参数。我的处理方式是,让工具函数本身支持灵活入参,在函数内部做转换:

def query_recent_orders(days: int = 7, start_date: str = None, end_date: str = None): """查询近期订单,支持按天数或起止日期""" if start_date and end_date: start = datetime.strptime(start_date, "%Y-%m-%d") end = datetime.strptime(end_date, "%Y-%m-%d") else: end = datetime.now() start = end - timedelta(days=days) # ... 查询逻辑

灵活的参数设计可以让Agent在信息不足时也能完成任务,而不是因为参数对不上就报错。这是实际落地时很关键的一点,因为用户不会总把话说得那么完整。

3. 实操过程:从零到一跑通Agent-Reach

3.1 环境准备与目录规划

开始动手之前,先把环境搭好。用到的依赖只有几个:

pip install openai fastapi uvicorn sqlite3

项目目录结构我规划成下面这样,保持清晰:

agent-reach/ ├── main.py # 入口程序,编排整个对话流程 ├── tool_registry.py # 工具注册表 ├── agent_core.py # Agent核心循环 ├── tools/ │ ├── order_tools.py # 订单查询相关工具 │ ├── user_tools.py # 用户信息相关工具 │ └── system_tools.py # 系统类工具 ├── mock_server/ │ ├── app.py # 模拟外部业务系统的FastAPI服务 │ └── data.sqlite # 测试数据库 └── config.py # 模型配置等

个人建议,从第一步就把代码按工具模块拆开,不要把所有工具都写在一个几百行的文件里。Agent项目迭代速度快,工具越界不清,后面维护会非常痛苦。

3.2 搭建模拟业务系统

为了让演示真实、可复现,我先用FastAPI搭建了一个模拟订单系统,内置了一些测试数据。这个系统的角色是“外部服务”,Agent要做的就是去访问它。

# mock_server/app.py from fastapi import FastAPI, HTTPException import sqlite3, json app = FastAPI() # 初始化SQLite测试库 def init_db(): conn = sqlite3.connect("data.sqlite") conn.execute("""CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, user_id TEXT, product_name TEXT, amount REAL, status TEXT, created_at TEXT )""") conn.execute("INSERT OR IGNORE INTO orders VALUES (?, ?, ?, ?, ?, ?)", ("202402011730001234", "U1001", "机械键盘", 399.00, "shipped", "2024-02-01 17:30:00")) conn.commit() conn.close() @app.get("/orders/{order_id}") def get_order(order_id: str): conn = sqlite3.connect("data.sqlite") row = conn.execute("SELECT * FROM orders WHERE id=?", (order_id,)).fetchone() conn.close() if not row: raise HTTPException(status_code=404, detail="订单不存在") return {"id": row[0], "user_id": row[1], "product_name": row[2], "amount": row[3], "status": row[4], "created_at": row[5]}

虽然这只是个模拟服务,但它决定了Agent-Reach里“工具”这一层的边界。工具函数自己不操作真实数据库,而是去请求外部服务接口。这个设计模拟的是真实场景——Agent的能力边界不应该越过服务层,否则权限、安全、日志审计都无从谈起。

3.3 定义真实可用的Agent工具

接下来在tools目录下编写实际的Agent工具。这里以订单查询和用户信息查询两个工具为例:

# tools/order_tools.py import httpx def query_order(order_id: str) -> str: """查询订单的API,返回订单字符串描述""" resp = httpx.get(f"http://127.0.0.1:8000/orders/{order_id}", timeout=5) if resp.status_code == 404: return "未找到该订单,可能订单号有误" resp.raise_for_status() data = resp.json() return json.dumps(data, ensure_ascii=False)

核心注意点是:工具函数最终返回的一定要是字符串。为什么?因为这条字符串要被拼进消息历史里,重新发给模型。如果返回的是Dict,你需要在传给模型前手动序列化,很容易漏掉。统一在工具内部做json.dumps,后续的流程就简单了。

工具的描述和Schema也同样重要。看下query_order的完整注册代码:

registry = ToolRegistry() registry.register( name="query_order", description="根据订单ID查询订单信息。订单ID是系统生成的唯一编号。" "返回内容包括订单状态、商品名称、金额、下单时间。" "如果用户没有提供订单ID,先向用户询问。", parameters={ "type": "object", "properties": { "order_id": { "type": "string", "description": "订单完整ID,例如202402011730001234" } }, "required": ["order_id"] }, func=query_order, )

这里有一个小技巧:description里可以写“如果用户没有提供订单ID,先向用户询问”。这相当于给模型一个行为准则,让它学会在信息不明确时主动澄清,而不是自作主张用空字符串去调用工具。

3.4 编排Agent主循环

Agent的主循环可以看作一个简单的“感知-行动-反馈”循环。核心逻辑如下:

# agent_core.py def run_agent(user_query: str) -> str: messages = [{"role": "user", "content": user_query}] for step in range(5): # 最大循环5次,防止死循环 response = client.chat.completions.create( model=model_name, messages=messages, tools=registry.get_openai_tools(), tool_choice="auto", ) message = response.choices[0].message # 如果没有tool_calls,说明模型准备直接回答,结束循环 if not message.tool_calls: return message.content # 把当前消息追加到历史 messages.append(message) # 逐个执行工具调用 for call in message.tool_calls: function_name = call.function.name function_args = json.loads(call.function.arguments) try: result = registry.call(function_name, function_args) except Exception as e: result = f"工具调用出现错误: {str(e)}" messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(result), }) return "已达到最大调用次数,无法完成你的请求"

这个循环有几个设计上的细节值得琢磨。

第一,最大循环次数的限制。模型有概率在几个工具之间来回跳,如果不在循环层做硬限制,会导致请求数量失控,既浪费时间也浪费费用。

第二,工具调用一旦报错,把错误消息作为tool消息返回给模型。这样模型能看到错误原因,并且有机会自我修正——比如换个参数再试,或者承认无法完成任务。这比直接崩溃体验好得多。

第三,模型返回的message对象要原样追加到对话历史里,不能只追加text字段。因为其中包含tool_calls结构,后续请求需要完整上下文。

3.5 完整实测:一次对话的完整链路

启动模拟服务和Agent程序后,我做了几轮测试,其中最有代表性的是下面这个片段。

用户输入:“帮我看一下这张订单表到了没有,订单号是202402011730001234。”

第一步,程序把用户消息发给模型,附带工具列表。模型判断需要查询订单,返回tool_calls调用query_order。第二步,程序解析参数、执行HTTP请求,拿到订单数据:“当前订单状态为shipped,商品为机械键盘,金额399元”。第三步,把工具结果回传模型,模型组织语言回答:“您的订单202402011730001234已发货,商品是机械键盘,金额399元,请留意查收。”

这一轮完整跑通,意味着Agent真正做到了“懂业务”——它不只是一个文本生成模型,而是能通过工具去查询真实数据,再基于数据回答用户。应用场景也自然展开:客服助手、工单系统、内部数据问答,甚至IoT设备管理,核心链路都是一样的。

4. 踩坑实录与排查技巧

4.1 高频问题:工具调用不正常,问题出在哪

做Agent-Reach的过程中,我踩过的坑真不少。把最高频的几个整理成了一张排查速查表:

现象常见原因解决思路
模型完全不调用工具描述写得不清楚;工具列表没传进请求强化description中的触发条件,比如“当用户询问订单状态时必须调用”
模型调用了错误的工具多个工具描述相互重叠区分描述边界,明确各自场景;减少同名或相似工具
参数总是缺漏Schema没有标required字段把必填字段显式标记required;在description里说明参数获取方式
工具报错导致整个对话失败异常没有被捕获在工具调用处加try/except,把错误作为消息回传模型
参数JSON解析失败模型偶尔生成残缺JSON用strict模式或加正则提取;解析失败时让模型重新生成
Agent陷入工具调用死循环缺少循环次数限制设置max_steps,达到上限强制结束
工具执行时间太长外部接口慢或网络超时设置HTTP超时时间,工具内部做好耗时控制

4.2 一个让我印象深刻的排查案例

最让我头疼的一个问题是:模型明明拿到了正确的订单结果,却在最终回答时编造了不存在的物流信息。比如订单状态是“已发货”,模型就自己脑补“您的订单将在3天内送达,快递单号SF123456789”。实际我们根本没有物流接口。

这类幻觉问题的根源是模型在整合信息时会“习惯性补全”。我的解决办法是在工具返回的内容里显式留一个字段叫shipping_tracking_no,如果没有物流信息就返回“null”,并且在工具描述里写明“如果返回字段为null,请如实告知用户暂时没有物流信息,不要自行编造”。

这个经验对我很有启发:不要指望模型自己很诚实,你得在工具设计上帮它建立边界。

4.3 提升稳定性的独门经验

  • 工具描述里的动词要具体。与其写“获取数据”,不如写“调用接口查询最新的实时数据,每次查询都会发起真实HTTP请求”。模型对“实时”这个词敏感,愿意去调用工具,而不是凭记忆回答。

  • 外部服务端要能做故障模拟。我在FastAPI服务里加了一个环境变量开关,可以随机返回500错误,用来测试Agent的容错能力。测试发现,没有兜底时对话直接崩了,加上错误回传机制后,模型会说“暂时无法获取订单信息,请稍后再试”。

  • 把工具的权限和功能分开。比如订单模块有查询和退款两个功能,退款工具的description里要加一句“此操作不可逆,执行前必须向用户二次确认”。模型在调用前会询问用户,这个安全护栏很管用。

5. 后续扩展:再往前走一步

Agent-Reach目前跑通的是一条基础的“外部触达链路”,但围绕这个骨架,能够继续扩展的方向不少。

第一个是接入MCP协议。工具注册表本身已经抽象了“name + description + parameters + func”这四元组,把它包装成一个MCP Server对外的能力是现成的。到时候Agent-Reach就可以被任何MCP客户端复用,工具不再局限于某一个Agent实例。

第二个是多Agent协同。当一个工具需要多个Agent配合完成时,比如一个Agent负责查数据,另一个Agent负责分析并生成报告,主循环的消息历史就需要做区分。可以把消息体增加agent_id维度,让工具调度更精细。

第三个是可观测性。生产环境里审计Agent的行为很有必要。我给每一条工具调用都写入了日志,包括调用时间、工具名、参数、返回结果摘要、耗时,这样可以回溯Agent每一步做了什么。

第四个是把流式输出跑通。现在的实现是等Agent完整回答后才返回,体验上顿挫感比较强。改成返回前先推事件流,用户可以看到工具被调用的过程,交互体验会好很多。类似“正在调用订单接口...”,虽然技术不复杂,但对用户的掌控感提升非常大。

最后一个想提的,是这个项目与人的关系。Agent-Reach本质上是一套“让模型做事”的脚手架。真正有价值的不是代码本身,而是你对业务边界的理解——模型什么能做、什么不能做、应该怎么做,最终都藏在你写的工具描述和执行约束里。这套脚手架越扎实,Agent能真正独当一面的场景就越多。后续我计划把Agent-Reach的代码整理成开源模板,把工具注册、参数校验、异常兜底做成可配置化的模式,让更多人不用从零踩一遍重复的坑。

从我的个人实践看,Agent落地最难的不是算法,而是这些看似琐碎的工程细节。把工具边界划清楚,把失败路径全部覆盖,把用户预期管理好,Agent离好用就更近了一步。这些经验也不该只存在我本地,欢迎折腾过类似项目的朋友一起交流踩过的坑。

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

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

立即咨询