资讯详情 从零搭建大模型Agent:工具调用、编排与记忆架构实战
📅 2026/10/6 19:22:33
从去年开始我一直在折腾一个叫Agent-Reach的个人项目。简单来说它是一套让大模型真正“干完一整件事”的智能体框架把模型从“聊天的嘴”变成“能干活的手”让它去查数据、调接口、写文件、做决策并且把每一步的结果串成完整工作流。这篇内容不是官方文档而是把我踩过的坑、验证过的方案、以及最终沉淀下来的可复用代码一次性整理出来。无论你是想给自己的 LLM 应用加工具调用能力还是想理解 agent 类项目的核心架构都应该能从里面拿到可以直接抄作业的东西。1. 项目概述Agent-Reach 到底在解决什么问题1.1 一句话说清 Agent-Reach 是什么如果你玩过 ChatGPT 这类产品会发现一个明显的分水岭纯粹的问答模型只能“说”不能“做”。但真实工作场景里用户需要的往往不是一段分析而是一个结果。比如“帮我整理这周的销售数据按区域生成对比表再写一段总结”——这件事拆开来是查数据库、做聚合、生成 Markdown三个环节缺一不可。Agent-Reach 的核心目标就是解决这个“缺的环节”。它基于“编排层 工具注册表 记忆层”的三段式架构让模型在对话过程中可以动态决定调用哪些外部能力并根据工具返回结果继续推理直到任务闭环。这个项目名字里的 “Reach” 就是“触达”的意思让 agent 的触手伸到模型上下文之外的地方比如数据库、文件系统、外部 API、定时任务甚至是另一个 agent。我实测下来的感受是一旦把工具调用做顺模型的实用性会提升一个量级。普通的聊天机器人你问完就完了但带工具能力的 agent 能在真实业务里把事办成这才是它值得投入精力的根本原因。1.2 为什么“有记忆、会调工具”才是关键分水岭很多人在刚接触 agent 时有个误解觉得只要在提示词里写一句“你可以调用工具”模型就会自动学会使用。真这么简单的话就不会有那么多 agent 框架了。实际运行中你会发现模型在自由文本回复和结构化工具调用之间来回切换难度远高于单轮问答。这里有一个非常朴素的类比让模型不借助工具直接回答“深圳今天热不热”它只能凭训练数据猜。但如果你给了它一个get_weather(city)的函数它能明确输出一个 JSON 结构来请求调用。这个“请求调用”的动作本质上就是把模型从“记忆回放”切换成了“实时查询”信息新鲜度、准确度、可解释性全部不一样。还有一层是记忆。模型本身的上下文窗口再大也不可能装下所有历史会话和业务状态。Agent-Reach 里我采用了两级记忆会话级记忆放在消息列表里随请求传递业务级记忆比如用户偏好、任务执行记录存到外部存储需要时再检索出来注入上下文。这个设计的价值在长任务场景特别明显——agent 跑了几十步之后还能记得最初的目标而不是迷失在中间步骤里。1.3 适合谁又不适合谁先说适合人群你已经跑通过大模型 API想给自己的应用加上工具调用和自动化工作流或者你是做内部效率工具、数据分析自动化、客服工单处理这类方向的开发者。Agent-Reach 这种带编排能力的框架对你是直接可复用的底座。不适合谁呢如果只是想在网页里放一个问答机器人那用纯提示词方案就够了没必要引入 agent 编排因为额外的工具调用会带来延迟和成本属于杀鸡用牛刀。另外如果你完全没有编程基础那这个项目上手会有门槛它本质是一个工程框架不是开箱即用的产品。2. 核心设计拆解给 Agent 装上一只“长手”2.1 核心架构Orchestrator Tool Registry MemoryAgent-Reach 的架构初始版本非常朴素就三个模块但每个模块都经过了反复打磨。编排层Orchestrator是整个 agent 的“大脑回路”。它维护一个循环把当前消息发给模型模型决定是直接回答还是输出工具调用请求如果是工具调用编排层去执行对应函数把结果作为一条新的tool消息放回对话然后模型基于新信息继续推理。这个循环一直持续到模型给出最终答案或者达到步数上限。工具注册表Tool Registry是 agent 的“肢体清单”。每新增一个能力只要往注册表里加一个函数定义和一个实际执行函数agent 就多了一只手。不夸张地说这个设计让扩展成本降到了接近零因为注册表本身不关心函数内部逻辑只负责把模型的 JSON 参数安全地转成真实调用。记忆层Memory是 agent 的“长期记忆”。我用 SQLite 做持久化用向量检索做相似度召回两者配合解决跨会话的业务状态问题。后面第三部分我会详细展开。这三层的关系可以这样理解编排层决定“下一步做什么”工具注册表决定“能做什么”记忆层决定“记得什么”。任何一层做薄了整个系统都会显露出短板。2.2 为什么选 Function Calling而不是提示词硬控在设计初期我对比过两条技术路线一是“提示词硬控”也就是在 system prompt 里塞一大段 JSON 格式说明让模型以特定格式输出指令二是标准的 Function Calling函数调用机制让模型在原生层面输出结构化调用参数。最后选了后者根本原因是可靠性和安全性。提示词硬控看起来简单但实际跑起来全是坑模型偶尔会输出多余的说明文字导致 JSON 解析失败参数格式稍微一复杂它就开始自创字段更麻烦的是你根本无法阻止它在回答里混入看似工具调用的文本。而 Function Calling 是模型原生支持的结构化输出返回的就是标准tool_calls对象参数直接是合法 JSON我只需要做一层白名单校验就可以安全执行。当然Function Calling 也不是没有代价它对模型版本有要求老模型不支持它消耗的 token 会比普通对话略多某些非主流 API 虽然兼容这个格式但实现细节有差异需要测试。但这些代价相比“解析自由文本里的命令”这种不可控方案完全值得。2.3 多轮工作流的编排模型单次工具调用解决不了复杂任务。比如“查天气、生成建议、发邮件”三步任务需要 agent 在多个步骤之间保持连贯。Agent-Reach 的编排循环天然支持这种多步执行但我加了一个关键限制每一步调用前必须重新审视上下文。具体做法是在每一轮工具调用结束后我都强制把工具返回结果放回消息列表而不是像某些偷懒实现那样只把最终结果传给模型。这一步非常重要模型需要看到上一步的原始输出才能正确规划下一步。如果只传“处理后的结果”信息会失真如果什么都不传模型就成了无头苍蝇。我在实战中遇到过这样一个典型案例模型先调用了“查询订单状态”的工具拿到了已发货的状态但下一步却基于假设的“已签收”继续推理。排查后发现就是因为在中间环节丢了工具输出。后来我把每条工具结果都原样注入对话这个问题就再没出现过。3. 关键实现细节与实操要点3.1 工具注册表的设计从装饰器到运行时工具注册表我最终用 Python 装饰器实现因为它在代码可读性和扩展性之间取到了最好的平衡。看一个最简版本import json TOOL_REGISTRY {} def register_tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] { type: function, function: { name: name, description: description, parameters: parameters, }, handler: func, } return func return decorator register_tool( nameget_weather, description查询指定城市的实时天气, parameters{ type: object, properties: { city: {type: string, description: 城市名例如深圳} }, required: [city], }, ) def get_weather(city: str) - str: # 实际项目中这里替换成真实天气 API return json.dumps({city: city, weather: 晴, temperature: 26}, ensure_asciiFalse)这个设计的核心是模型看到的 schema 和执行函数绑定在一起注册即生效。你不用维护两份清单也不会出现“定义了但没实现”或者“实现了但没注册”的尴尬情况。这里有个非常值得强调的细节description 一定不要惜字如金。模型是靠描述来理解工具用途的描述越具体调用准确率越高。比如“查询天气”和“查询指定城市的实时天气返回温度、天气状况、湿度”完全是两个效果。我实测过描述从一句短话扩成两句话之后误调用率下降接近一半。3.2 参数校验白名单、格式校验、越权防护工具调用最大的安全隐患就是模型输出了不该输出的参数。虽然 Function Calling 保证 JSON 合法但不保证合法 JSON 里的内容是“合理”的。我在执行任何函数前都做三层校验。第一层是白名单校验只有注册表里存在的函数名才允许执行杜绝“模型突然提升一个不存在的函数”的情况。第二层是参数类型校验比如数值类型的参数模型可能给个字符串这时候强制转换或者直接拒绝。第三层是业务校验比如查询订单号的接口必须确认传入值符合订单号格式否则拦截。这一套校验下来效果相当明显。之前我遇到过模型在查询天气时把city参数传成了Shenzhen我的天气 API 只认中文城市名直接报错加上参数格式校验后这类问题可以提前挡掉。看似是小事但在生产环境里一个未经校验的参数可能导致线上数据被误操作所以这块宁可保守也不可冒进。3.3 记忆层设计短记忆与长记忆的取舍记忆是 agent 项目里最容易被低估的部分。很多初学者以为把历史消息全塞进上下文就是记忆真这样做的话你很快会发现两个问题token 成本指数上升而且模型会因为上下文过长而变得“注意力涣散”反而忘掉关键信息。Agent-Reach 的处理方式是分级。短期记忆就是当前任务会话我用滑动窗口控制只保留最近 10 到 20 条消息超出后做摘要压缩。长期记忆则异步写入 SQLite需要时通过向量检索取回相关片段。举个实际场景用户上周让 agent 整理过一份“华东区门店数据”的报告这周又问“和上周相比有哪些变化”。如果只有短期记忆agent 完全不知道上周的结果这时候长期记忆里的检索结果就能派上用场把上周报告的核心结论注入上下文模型就能做出对比分析。这个能力在真实业务里非常实用也是 agent 从“玩具”走向“工具”的关键。3.4 成本与延迟一个往往被忽视的隐形杀手agent 应用的调用成本不是线性的而是步数乘法的。每多一个工具调用就多一轮完整的模型请求token 消耗自然成倍上涨。我在项目里做了一个非常简单的统计一个三步骤的任务平均消耗的 token 是普通对话的 4 到 6 倍。所以成本控制是我在架构设计阶段就考虑的问题而不是事后补救。目前有效的手段有三类。第一是步数上限每轮对话最多允许执行 8 次工具调用防止模型陷入死循环。这个上限看起来粗暴但非常有效——agent 跑飞的成本远超按量调用的成本。第二是结果截断工具返回内容超过一定长度就截断保存只把摘要或片段传给模型。第三是结果缓存相同参数的调用直接命中缓存避免重复查询。延迟方面有个实测感受国内直连各大模型 API 的平均首字延迟都在可接受范围内但加上工具调用后一轮完整任务可能需要 5 到 15 秒。如果对实时性要求高建议中间过程用流式输出给用户反馈或者做成异步任务等结果出来再推送。4. 实操过程从零搭建 Agent-Reach 最小版本4.1 环境准备与依赖清单动手之前先列一下环境要求。Agent-Reach 最小版本只需要三个依赖一个支持 Function Calling 的大模型 API 客户端、Python 3.9、以及一个 HTTP 客户端库用于调外部接口。我用的是 OpenAI 兼容格式的 SDK市面上大部分主流模型都支持这种调用格式迁移成本很低。安装依赖就两条命令pip install openai pip install python-dotenv然后准备一个.env文件存放你的 API Key 和 Base URL注意这个文件一定要加入.gitignore别问我是怎么知道的。4.2 核心循环代码骨架整个 agent 的核心逻辑其实不到 80 行我把它完整贴出来这是整个项目中复用率最高的一段代码import json import os from dotenv import load_dotenv from openai import OpenAI load_dotenv() client OpenAI( api_keyos.getenv(API_KEY), base_urlos.getenv(BASE_URL), ) MODEL os.getenv(MODEL_NAME, gpt-4o-mini) MAX_STEPS 8 def run_agent(user_query: str, history: list | None None) - str: messages history or [] messages.append({role: user, content: user_query}) # 这里把注册表里的工具定义提取出来给模型用 tools [ {k: v for k, v in tool.items() if k ! handler} for tool in TOOL_REGISTRY.values() ] for step in range(MAX_STEPS): resp client.chat.completions.create( modelMODEL, messagesmessages, toolstools, ) msg resp.choices[0].message messages.append(msg) # 如果模型没有返回工具调用说明它已经生成最终答案 if not msg.tool_calls: return msg.content or # 逐条执行工具调用并把结果以 tool 角色放回上下文 for call in msg.tool_calls: tool_name call.function.name if tool_name not in TOOL_REGISTRY: result json.dumps({error: f未知工具: {tool_name}}, ensure_asciiFalse) else: try: args json.loads(call.function.arguments) handler TOOL_REGISTRY[tool_name][handler] result handler(**args) except Exception as e: result json.dumps({error: str(e)}, ensure_asciiFalse) messages.append({ role: tool, tool_call_id: call.id, content: result, }) return 任务步骤超过上限已停止执行这段代码有几点值得展开说。第一tools列表去掉了handler字段因为模型只需要 schema不需要也不能看到 Python 函数对象。第二工具执行异常被捕获后作为正常消息返回给模型让模型自己决定如何应对——这一步非常重要它给了模型“容错重试”的机会而不是让整个任务崩掉。第三步数上限直接写在循环里防止死循环。4.3 完整运行示例自动收集数据并生成周报光有骨架还看不出价值我拿一个典型任务来演示让 agent 自己决定怎么查询各个区域的门店销售数据再整理成周报。我先注册两个工具一个是查销售额的一个是算环比增长的register_tool( namequery_sales, description查询指定区域在指定日期的销售额金额单位为万元, parameters{ type: object, properties: { region: {type: string, description: 区域名可选华东、华南、华北}, date: {type: string, description: 日期格式 YYYY-MM-DD} }, required: [region, date], }, ) def query_sales(region: str, date: str) - str: data { (华东, 2025-06-02): {sales: 120.5}, (华南, 2025-06-02): {sales: 98.2}, (华北, 2025-06-02): {sales: 87.9}, } result data.get((region, date), {error: 无数据}) return json.dumps(result, ensure_asciiFalse)然后只需运行一句run_agent(请帮我查询6月2日华东、华南、华北三个区域的销售额并按从高到低排序生成周报)。整个执行过程很有意思模型会先调用三次query_sales获取三个区域的数据然后在下一轮推理中基于返回结果生成排序好的周报。这个例子看起来简单但背后揭示了 agent 的真正价值从“用户自己拆任务、自己找数据”变成了“模型自主拆任务、自主执行”。你不需要写任何业务编排代码只需要给模型一把钥匙工具它会自己开门。4.4 部署与日志生产环境少不了的三个细节跑通 demo 之后如果想真上了生产有三件事必须补上。第一是完整的请求日志。每一轮的 messages、tool_calls、工具返回结果都要落盘。没有日志出 bug 时你只能靠猜。我在本地用的是 JSONL 文件逐行追加线上则直接打到日志系统里。第二是重试机制与超时控制。外部 API 会有抖动模型接口偶尔返回 5xx都要加指数退避重试。我统一封装了一个safe_call方法最多重试三次每次间隔 1 秒乘 2 的幂次。第三是并发控制。如果 agent 服务要面对多个用户必须用队列或锁限制并发数避免瞬时请求把下游接口打挂。我这里用的是简单的threading.Semaphore限制同时执行的 agent 任务数不超过 5。5. 常见问题与排查技巧实录5.1 模型就是不调用函数怎么办这是我在项目初期遇到最频繁的问题。排查思路优先级如下先看工具定义是否正确传给了模型 —— 很多人忘了把tools参数放进请求体再看描述是否清晰 —— 有时候模型“觉得”自己知道答案就不会调用工具这时需要把描述改成“必须调用该工具才能获得最新数据”之类强约束话术最后看模型版本 —— 旧版本模型对 Function Calling 支持不稳定换新版本通常立刻改善。还有一个偏门但常见的坑某些 SDK 版本会在messages.append(msg)这一步把tool_calls字段也自动带进下一轮请求这本是标准行为但如果你是手动构造消息列表一定要保证tool_call_id与工具结果的对应关系准确错一个就全乱。5.2 参数幻觉与危险调用“参数幻觉”是我自己发明的词指的是模型生成了看似合理但毫无意义的参数。比如查询订单时填了个不存在的订单号或者调用删除接口时把所有记录都选上了。这类问题的根子在于模型并不理解业务语义它只是在做模式匹配。我的防线有三层注册时严格定义参数类型执行前做白名单和格式校验最关键的是给危险操作加上“人工确认”钩子。比如删除类、写入类工具我要求 agent 先输出一个待确认的临时指令由外部审核通过后再真正执行。安全底线不能交给模型的自觉一定要用工程手段兜住。5.3 任务中途中断怎么恢复状态长任务跑一半可能因为网络超时或进程重启丢掉上下文。Agent-Reach 用task_id做状态恢复。每次启动任务时生成一个唯一 ID把完整的消息列表按 ID 持久化检测到中断后用最新的消息列表接着跑而不是从头开始。这里有个不起眼但非常关键的细节SQLite 写入不要每次循环都做否则高频 I/O 会拖垮性能。我的做法是每隔两轮循环提交一次 checkpoint中断最多丢失两步的状态但换来的是几乎零性能损耗。5.4 费用失控的典型场景与限制我统计过自己项目里费用异常飙升的场景排名前三的分别是工具返回结果过大导致下一轮输入暴增、模型陷入循环调用死磕一个错误、以及多用户的并发请求没有总量控制。针对这三个场景对应方案分别是工具返回内容截断到 800 字以内再回填步数上限从 8 降到 5 并配合“错误超过两次就放弃”给每个用户加独立预算当日用量超过阈值自动熔断。做完这三件事之后平均单任务成本降了差不多六成而且几乎没影响任务完成度。为了方便排查我整理了一张速查表可以直接贴到项目文档里问题现象可能原因排查建议模型从不调用工具tools 参数未传入 / 描述不够具体检查请求参数与工具 description 用词工具参数报错模型输出类型与 schema 不一致执行前做类型强转与格式校验任务突然停止达到步数上限调大 MAX_STEPS 或优化任务拆分回答内容明显错误工具结果未正确回填上下文核对 tool_call_id 与消息角色账单异常上涨工具返回内容超长对工具输出做截断或摘要6. 几个踩坑后的个人体会最后分享一点不写进代码里的东西。我在做 Agent-Reach 的过程中最大的体会是Agent 的瓶颈从来不是模型多聪明而是工程边界画得有多清楚。模型本身像个能力很强但没什么常识的新员工你给它工具、给它流程、给它限制它才能稳定交付反过来如果你什么都不约束它就会在自由发挥中不断制造意外。所以我在项目后期把大量精力从“让模型更聪明”转移到了“让系统更能兜底”。校验参数、限制步数、记录日志、保留状态——这些听起来有点枯燥的工作恰恰是整个 agent 能稳定服务的最重要底座。我自己甚至总结了一个原则agent 项目里百分之八十的代码写的是“它出错时怎么办”而不是“它怎么做对”。如果你也想基于这个思路搭自己的 agent建议从最小的两三个工具开始跑通闭环再逐步扩展工具列表。不要一上来就追求十个工具、复杂记忆、多 agent 协作那会在调试期把你劝退。等核心循环稳定了再往上加东西会发现一切都顺理成章。如果把这段时间的实践浓缩成一句话送给你别让模型替你决定边界边界是工程定义的模型只是在这个边界内做选择。想明白这一点Agent-Reach 这类项目才算真正做透了。