Agent触达外部世界:Function Calling与多智能体协作实战

发布时间:2026/10/6 10:21:14
Agent触达外部世界:Function Calling与多智能体协作实战 1. 名字里的设计意图Agent-Reach 到底解决了什么问题最近在折腾多智能体协作的时候我自己搭了一个小项目名字就叫 Agent-Reach。说白了这个名字拆开看就是“让 Agent 具备触达能力”。触达什么触达外部世界——包括远程资源、第三方 API、本地命令行工具以及部署在其他节点上的协作 Agent。为什么非要强调“触达”因为单机跑一个 LLM 模型能做的只有“基于已有知识生成文字”。但真实业务场景里我需要它去帮我看实时行情、抓取一个网页、调用公司内部的接口提交工单甚至把任务分派给另一台机器上的 Agent 去执行。这些能力统统不在模型参数里需要有中间层帮它完成“感知”和“行动”。Agent-Reach 就是扮演这个中间层的角色。这个项目的适用人群很明确不是普通聊天用户而是正在搭建 Agent 工作流、做 AI 自动化落地的人。无论你用的是云厂商的大模型 API还是本地部署的开源模型只要有“让 Agent 真正干活”的需求Agent-Reach 这套思路就值得参考。它能解决的问题有三类让 Agent 能对外发起 HTTP 请求把网页内容、API 返回值加工成结构化数据让 Agent 能把任务拆解后派发给多个子 Agent实现多节点、多角色的协同让 Agent 能够调用本地脚本、命令行工具和定时任务补足自动化闭环的最后一段。下面我会按实际搭建的顺序把设计思路、核心代码、踩坑记录和调优经验逐一写清楚代码可以直接拉下来改着用。2. 核心架构与关键环节拆解让 Agent 真正“触达”所需资源2.1 统一工具协议Function Calling 是整条链路的地基Agent-Reach 的第一版只是个单 Agent 循环模型收到用户指令后判断需要调用哪个工具生成一段结构化调用参数中间层解析后真正执行外部动作再把结果拼进上下文返回给模型继续推理。这个模式大家可能很熟悉就是 Function Calling 的标准玩法。但真正决定项目上限的是“工具注册表”的设计质量。我用的协议是 OpenAI 的 tools 规范——JSON Schema 描述函数名、功能描述、参数结构。然后中间层维护一个执行器映射表把“函数名”映射到 Python 的 async 函数。这里有一个所有初学者几乎都会踩的坑功能描述写得太泛。比如“Get 天气”这种描述看起来没问题但模型真正被调用时会无从判断“什么时候用这个工具、传入什么参数格式”。我实际测试下来描述里至少要包含三部分触发条件、参数含义、返回值格式说明。拿一个抓取网页正文的例子来说。我的工具注册表里是这样定义的tools [ { type: function, function: { name: fetch_webpage, description: 抓取指定URL的HTML内容并提取正文文本。适用于需要获取网页实时信息的场景例如新闻、文档、论坛帖子。如果目标页面需要登录或者存在反爬限制请优先考虑search_web工具。, parameters: { type: object, properties: { url: {type: string, description: 目标网页的完整URL必须包含协议头例如 https://example.com/doc.html}, max_chars: {type: integer, description: 返回内容的截断长度默认5000避免污染上下文窗口, default: 5000} }, required: [url] } } } ]描述里“如果目标页面需要登录或者存在反爬限制请优先考虑 search_web”这句话不是废话而是给模型的决策提示。多智能体协作链路里模型最怕的不是没有工具而是工具太多不知道选哪个。清晰的触发边界和兜底方案能大幅降低误调用率。我实测下来加了这类决策提示后工具调用的准确率能从七成多提升到九成以上。2.2 本地执行器把“模型想做的事”翻译成“系统能做的事”工具协议只是契约真正的执行体在本地异步函数里。Agent-Reach 里的执行器分为基础型和复合型两类。基础型就像四肢直接对操作系统或网络协议做操作复合型则是多个基础动作的组合比如“先抓取列表页再逐个抓取详情页”。我的首批执行器选了四个基础动作覆盖绝大多数日常需求工具名作用底层实现适用场景fetch_webpage抓取单个网页并提取正文httpx trafilatura新闻、文档、文章采集search_web搜索关键词并返回结果列表DuckDuckGo 搜索 API实时信息检索run_script执行本地命令行脚本subprocess 超时控制数据处理、文件操作、部署命令send_webhook向指定地址推送 JSON 负载httpx通知、触发下游工作流每个执行器都必须有超时机制。fetch_webpage 我给的是 15 秒超时run_script 给的是 30 秒超时超时直接抛错误文本回传给模型让模型决定是重试还是换方案。这一步很重要否则某个外部接口卡住了整个 Agent 线程就被拖死多智能体协同时的级联故障就是这么来的。2.3 多节点触达跨机器的子 Agent 任务派发Agent-Reach 的第二个核心设计是多节点“触达”。单机上跑 Agent能触达的资源始终有限但如果能在内网里把任务派发给其他节点上的 Agent这个系统就变成了一个分布式的 AI 任务网格。我实现的方式很轻量每个节点跑一个 FastAPI 服务暴露 /agent/task 接口。主 Agent 的发送端伪代码如下async def dispatch_to_agent(node_url: str, task_payload: dict) - dict: async with httpx.AsyncClient(timeout60) as client: response await client.post( f{node_url}/agent/task, jsontask_payload, headers{X-Node-Token: NODE_TOKEN} ) response.raise_for_status() return response.json()这里有两个细节值得展开。第一节点之间的认证必须用预共享密钥不能裸奔在内网否则任意主机都可以往你的节点塞恶意指令。第二子 Agent 执行完毕后要返回结构化的结果摘要千万不能返回几十 KB 的原始日志。主 Agent 的上下文窗口本来就金贵塞一大堆无效 token 进去多轮交互后模型推理质量会断崖式下跌。为了控制跨节点传递的信息量我给每个子 Agent 返回结果加了一条硬性约束必须是一个字典包含 status、summary、data 三个字段。data 里的内容控制在 200 行以内。summary 用一句话概括任务产出主 Agent 拿到后可以决定是否要展开 detail。这种“摘要优先、按需展开”的信息传递策略是解决长链路 Agent 上下文爆炸的关键手法。3. 完整实操流程从零搭出一条可复用的 Agent 触达链路3.1 第一站定场景别一上来就写代码我在动手写 Agent-Reach 之前第一件事不是装环境而是把目标场景写在了纸上。最终我选择了一个很能说明问题的组合任务让它自动抓取我订阅的行业周报页面提炼核心观点生成摘要后推送到团队群机器人。这个场景之所以好是因为它完整覆盖了 Agent-Reach 的三类核心能力网络资源触达抓网页、信息加工模型总结、第三方系统触达webhook 推送。而且它的技术门槛不高很适合做系统验证等这条路走通了再扩展成跨节点任务分发就只是水到渠成的事。如果你准备自己在类似场景里复刻建议按照下表检查自己是否具备前置条件前置项说明缺失时的替代方案LLM API 或本地模型服务需要支持 Function Calling 或兼容 OpenAI tools 协议可先用国产支持函数调用的模型过渡Python 3.10异步生态和类型标注更完善3.9 也能跑但部分语法需调整可访问的目标网站用于测试 fetch_webpage 的抓取能力用 httpbin.org 这类公共测试站群机器人或 webhook 地址用于测试 send_webhook 的推送能力用 webhook.site 临时接收请求3.2 第二站搭出最小闭环用 20 行代码验证循环传统思路会先写复杂的 Agent 状态机我的建议相反——先跑通最小闭环再逐步加复杂度。最小闭环就是一条用户指令 - 模型判定调用工具 - 执行器真正执行 - 结果回填给模型 - 模型生成最终回复。我用 langchain 的 create_openai_fn_agent 快速搭了一版但后来发现依赖太重改成自己维护循环逻辑代码反而好控制。核心循环里的关键片段如下async def run_agent_loop(user_instruction: str, max_iterations: int 8): messages [{role: system, content: SYSTEM_PROMPT}, {role: user, content: user_instruction}] for i in range(max_iterations): response await chat_completion_with_tools(messages, tools) msg response.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for tool_call in msg.tool_calls: result await execute_tool(tool_call.function.name, tool_call.function.arguments) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) return 迭代次数超限任务未完成这段代码有一个很微妙的点每次循环都把完整的 messages 列表重新发给模型包括所有历史工具调用记录。这是保证模型推理连续性的必要条件。代价是消息越来越长所以我在每次追加工具结果时都会做一步裁剪——如果单条工具结果超过 3000 字符就截取头尾并标注“[内容已截断]”让模型知道信息不完整需要时再调工具补取。这里我强烈建议你关注一下max_iterations这个参数。单 Agent 处理一个复杂任务很容易陷入“工具调用-结果回填-再调用”的循环里出不来。8 次是一个经验值既能覆盖大多数任务又不至于让单次调用耗时过长。如果你的任务类型偏研究分析可以放宽到 12 次如果是简单查询限制到 4 次就够了省 token 还能倒逼 Agent 精简调用路径。3.3 第三站调参实录附实测数据调参是让 Agent-Reach 从“能跑”变成“好用的分水岭。我最开始直接抄了一个公共 prompt 模板效果惨不忍睹工具误调用率高、抓回来的正文全是 HTML 标签、模型经常把字符串截断当成功。我花了两个晚上逐项排查最终调定的关键参数如下温度 temperature 调到 0.1。Agent 调用工具的环节需要的是确定性不是创造力。温度过高会导致模型在生成 JSON 参数时“自由发挥”把 url 字段写成自然语言描述而不是完整链接。实测温度 0.7 时工具参数格式错误率高达 15%降到 0.1 后基本归零。正文提取必须用 trafilatura 而不是正则。第一版我用 BeautifulSoup 抓 p 标签结果页面里的导航栏、广告文案全被当成正文。换成 trafilatura 后它不仅会自动识别正文区块还能保留标题、段落结构准确率提升非常明显。下面是提取器的实际用法import trafilatura async def fetch_webpage(url: str, max_chars: int 5000) - dict: async with httpx.AsyncClient(timeout15, follow_redirectsTrue) as client: response await client.get(url, headers{User-Agent: Mozilla/5.0 (compatible; Agent-Reach/1.0)}) response.raise_for_status() content trafilatura.extract(response.text, include_commentsFalse, include_tablesTrue) if not content: return {status: error, reason: 页面无法提取正文可能为JS渲染页面} truncated content[:max_chars] (...[已截断] if len(content) max_chars else ) return {status: success, content: truncated, original_length: len(content)}注意我在返回值里塞了 original_length 字段。这个字段特别有用模型拿到后能判断信息是否完整知道“哦原文有一万多字我看到的是截断后的五千字”需要更详细时它会主动再调用工具。这种“元信息回传”的设计能让 Agent 的行为更接近人类处理信息的方式。用户代理字符串必须伪装成浏览器。这里说的不是去对抗网站的反爬而是避免被服务器当异常流量直接拒绝。比较多的一类网站直接设置安全组非浏览器 UA 一律拒绝。加上这个 header 后抓取成功率从一半左右提升到九成以上。3.4 GO 版改造异步并发对企业级吞吐量的价值跑通 Python 版的 Agent-Reach 之后我意识到 Python 的异步模型虽然写起来方便但在高并发场景下有天然瓶颈——GIL 限制、解释器切换开销、asyncio 事件循环的上下文开销。如果你想把 Agent 能力做成公司级内部服务吞吐量迟早会成为瓶颈。我后来用 Go 重构了中间层核心思路是把“Agent 循环”和“工具执行器”解耦。Go 协程的调度开销远小于 Python 协程实测单机并发处理 50 个 Agent 实例时Go 版的 CPU 占用大约只有 Python 版的一半P99 延迟也从 2.1 秒降到了 1.3 秒。工具执行器本身还是调 HTTP 接口所以迁移成本并不高。Go 版的关键结构是这样设计的type AgentRuntime struct { tools map[string]ToolExecutor clients map[string]*http.Client } type ToolExecutor func(ctx context.Context, args json.RawMessage) (json.RawMessage, error) func (r *AgentRuntime) RegisterTool(name string, executor ToolExecutor) { r.tools[name] executor }每个工具自带独立的 http.Client可以设置不同的超时和连接池大小。比如 fetch_webpage 用 15 秒超时、10 个连接send_webhook 用 5 秒超时、50 个连接。分开配置的好处是不会因为某个慢接口占满连接池而导致其他工具排队。如果你只做个人工具Python 版完全够用但当你开始考虑多团队共享 Agent 能力时建议直接上 Go 版骨架后续加流控、加熔断、加监控都顺畅得多。别等到流量大了再重构那种痛苦我替你先踩过了。4. 踩坑实录细节问题排查与性能调优速查表4.1 上下文爆炸一句话让 Token 消耗翻三倍第一次跑完整链路时我检查账单差点以为 API 出 bug 了。后来定位到问题模型每次生成工具调用时会把完整的工具定义 JSON 重复发送到上下文里。四个工具的定义加起来约 2500 token如果 Agent 循环调用 8 次工具光工具定义就占了 20000 token。这个问题有两个解法。第一精简工具描述把每个工具的描述控制在 100 字以内只保留触发条件、必要参数、返回格式。第二只在第一轮请求时传 tools 参数后续轮次如果模型没有新增工具需求就不重复传。实测后者能省下约 60% 的 token 消耗不过要确认你的模型服务商是否支持在一次会话中途不传 tools。实际效果对比方案单任务平均 token 消耗工具调用准确率备注每轮都传完整 tools约 4500092%浪费严重首轮传 tools后续不传约 18000同前API 需支持中途省略 tools精简工具描述 首轮传约 1200091%最推荐方案4.2 工具调用死循环当 Agent 以为自己在“干活”最让人头疼的故障是 Agent 陷入工具调用死循环。表现为模型不停调用同一个工具每次都拿到相似的结果然后继续调用直到 max_iterations 把资源耗尽。我遇到的一个典型案例是让 Agent 搜索“最近一周的行业新闻”search_web 返回的结果里没有最新新闻模型不死心换个关键词再搜还是返回一样的结果再换个关键词…… 最后的解法和 Google 的搜索自动补全没什么关系而是我在系统提示词里加了这样一段话如果同一工具在连续两次调用中返回的结果没有信息量提升请终止该工具的调用转而生成一条“需要人工介入”的说明或者更换完全不同的工具策略。实际效果立竿见影。模型在第三次搜索仍无新信息时会果断放弃转而告诉用户“公开渠道暂时没有该周期内的信息建议手工核实”。这才是 Agent 该有的行为——知道自己不知道而不是装作很努力。4.3 AskAgent 综合症工具返回“成功”但内容无效第三个高频坑是假成功。工具返回 status 为 success但真正有用的内容少得可怜。最常见的就是 fetch_webpage 抓了个 404 页面trafilatura 提取出来的正文是“页面未找到5 秒后跳转首页”。执行器认为抓取成功模型也就把这条垃圾信息当成了回答依据。我的修法是在执行器内部做一次内容质量校验。抓取后的正文如果少于 50 个字符或者包含常见的 404/301 提示关键词直接把状态置为 error 并附上原因。这样模型能及时转向其他工具或方案而不是被假成功带偏。类似的校验清单我建议至少包含以下几条返回的正文长度低于 50 字符视为失败返回内容里出现“验证码”“访问受限”提示可能需要人工处理返回的 JSON 中 status 字段为 error但模型仍继续基于该结果推理应强制纠正。4.4 API 抖动与重试策略别让你的 Agent 被网络卡死Agent-Reach 的中间层大量依赖外部 API而外部 API 的稳定性永远不归你管。我踩过最痛的一次是 LLM API 连续 20 分钟超时所有 Agent 任务全部堆积在重试队列里下游 webhook 平台差点被挤爆。我给到的最终方案是三段式熔断与退避第一次失败等待 1 秒重试第二次失败等待 5 秒重试第三次失败直接放弃任务把状态标记为 failed 并记录错误原因不再重试。熔断判断要按工具维度做隔离。举个例子LLM 服务抖动不能影响 search_web 工具的并发流量所以每个工具维护独立的熔断器不能全局一刀切。A 工具失败三次进入熔断状态只影响该工具其他工具继续正常运行。另外还有一个细节发往 LLM API 的请求必须设时长限制尤其是在链路上还有子 Agent 派发的情况下。整体任务耗时严重超过预期多半是被最外层的 API 响应拖住了这时候你需要的是超时策略不是耐心。4.5 分布式任务的价值与成本什么场景才值得跨节点跨节点派发不是银弹我劝你在决定上多节点之前先想清楚。内网多节点派发适合的场景是任务之间数据隔离要求严格、每个节点有专属的本地工具集、节点所在网络环境不同。代价则是调试变难了——链路一长问题定位成本是指数级上升的。我实践中基本遵循一条原则能用单 Agent 工具链完成的不拆多节点必须拆的按“数据不进中间层”的优先级设计。也就是说大数据量的加工尽量放在子节点完成后只回传摘要别把所有原始数据都聚到主节点来。跨节点调通后我建议加一个最简单的心跳检测。主节点定时 ping 子节点的 /health 接口连不上就直接把该节点标记为离线调度时绕开避免把任务派发给一个已经挂掉的 Agent。5. 把 Agent-Reach 变成你的日常工具实测数据与扩展建议5.1 一套可复用的完整代码骨架前面的部分讲到很多模块这里给出一份可运行的最小骨架。我建议直接复制到本地把 API key 替换掉就能跑通整个链路。这份骨架完整覆盖了工具注册、Agent 循环、结果回填、错误处理是我实践后整理出的最稳定版本。import asyncio import json import os import trafilatura import httpx from openai import AsyncOpenAI client AsyncOpenAI(api_keyos.environ.get(OPENAI_API_KEY)) SYSTEM_PROMPT ( 你是一个能够调用外部工具的智能助手。请优先使用工具获取实时信息。 如果同一工具连续两次未产生新信息停止调用并说明情况。 工具结果必须基于事实不要杜撰。 ) TOOLS [ { type: function, function: { name: fetch_webpage, description: 抓取指定URL的网页正文。适用于新闻、文档、维基页面。如果目标页面需要登录或存在访问限制提示用户绕过策略时请直接说明无法访问。, parameters: { type: object, properties: { url: {type: string, description: 完整URL必须带协议头}, max_chars: {type: integer, description: 返回最大字符数, default: 5000} }, required: [url] } } }, { type: function, function: { name: send_webhook, description: 向指定webhook地址推送JSON数据。用于触发下游通知或工作流。, parameters: { type: object, properties: { url: {type: string, description: webhook完整地址}, payload: {type: object, description: 要推送的JSON负载} }, required: [url, payload] } } } ] async def execute_tool(name: str, arguments: dict): if name fetch_webpage: async with httpx.AsyncClient(timeout15, follow_redirectsTrue) as c: r await c.get(arguments[url], headers{User-Agent: Mozilla/5.0 (compatible; Agent-Reach/1.0)}) r.raise_for_status() text trafilatura.extract(r.text, include_commentsFalse) if not text or len(text) 50: return {status: error, reason: 无法提取有效正文} cut arguments.get(max_chars, 5000) return {status: success, content: text[:cut], original_length: len(text)} if name send_webhook: async with httpx.AsyncClient(timeout5) as c: r await c.post(arguments[url], jsonarguments[payload]) r.raise_for_status() return {status: success, response_code: r.status_code} return {status: error, reason: f未知工具: {name}} async def run(user_input: str, max_iterations: int 8): messages [{role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}] for _ in range(max_iterations): response await client.chat.completions.create( modelgpt-4o-mini, messagesmessages, toolsTOOLS, temperature0.1 ) msg response.choices[0].message if not msg.tool_calls: return msg.content messages.append(msg) for tc in msg.tool_calls: try: args json.loads(tc.function.arguments) result await execute_tool(tc.function.name, args) except Exception as e: result {status: error, reason: str(e)} messages.append({role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse)}) return 迭代次数超限任务未完成 if __name__ __main__: # 用法示例python agent_reach.py text asyncio.run(run(请抓取 https://httpbin.org/html 的正文并总结其主要内容)) print(text)这份骨架里我故意把 tool 数量和循环逻辑压缩到最小原因是让你可以在此基础上逐个往上加自己的工具。想扩展时只需要两步在 TOOLS 列表里加描述在 execute_tool 里加执行分支。5.2 实测数据Agent-Reach 跑一轮完整任务需要多久我拿上述代码跑了一轮“抓取一个网页并总结”的任务记录的数据如下环节耗时模型第一次推理判断需要调用工具0.8 秒fetch_webpage 抓取并提取正文0.9 秒模型第二次推理阅读正文并生成总结1.2 秒合计约 2.9 秒这个数字在可接受范围内主要瓶颈在模型推理环节。如果你换成本地部署的量化模型推理时间可能会到 5 至 10 秒但好处是没有 API 费用。我给的建议是追求稳定延迟用云 API追求隐私和数据不出域用本地模型。5.3 后续还能怎么扩展Agent-Reach 目前只是我内部工具链里的一块拼图。接下来我计划扩展的方向有几个你如果有类似的场景可以参考着一起做把工具注册表改成动态加载。现在每个新增工具都要改代码加分支太不优雅。理想状态是写一个工具目录每个工具一个 Python 文件启动时自动扫描目录并注册。这样负责运营的人只需要新增文件不需要动主循环代码。加入事件驱动的 Agent 能力。目前是用户发指令才触发一轮任务后续可以接消息队列。比如收到某个系统的告警事件自动触发 Agent 去查询事件详情并生成处置建议推送到工单系统。这种“事件触达”比“被动查询”高一个层次。增加本地知识库检索触达。对团队来说Agent 最有价值的动作之一就是查询内部文档、往期工单、操作手册。可以通过嵌入模型把内部知识向量化Agent 需要时先检索再回答。这个方向不需要大改架构只需新增一个 RAG 检索工具注册进 Agent-Reach 就行。5.4 一些最终想重点强调的细节这几条在正文里都散着提过但值得集中再说一遍。我用这个项目的过程里印象最深的教训全都藏在细节里。工具描述写得越具体模型误调用越少返回结果里的元信息字段original_length 之类能救回很多场接近翻车的对话超时和熔断必须按工具维度隔离不然一个慢接口能拖死整个 Agent每次循环把完整 messages 回传是必要的但必须边回传边裁剪给上下文留出呼吸空间最后跨节点派发任务之前先想想是不是单 Agent 就能解决——能不多节点就别多节点分布式带来的复杂度超过你想象。说到底Agent-Reach 并不是什么高深莫测的框架它是一整套关于“让 Agent 摸到真实世界”的工程实践积累。这套思路的价值不在于某一段代码写得多漂亮而在于当你面对一个新任务时能快速地判断出这条路该不该走这一步该不该让 Agent 自动完成这堵墙该不该由 Agent 手动来撞。把这些问题想清楚了你手头的任何 Agent 项目都会顺手很多。