2026/10/21 13:19:45

从零构建AI Agent:核心原理、工具调用与ReAct循环实操

从零构建AI Agent:核心原理、工具调用与ReAct循环实操 1. 先搞清楚Agent和普通聊天机器人之间的那道界线1.1 聊天补全和智能体的本质差异不只会说还会做如果你用过市面上任何一款大模型聊天产品会发现一个共性现象你让它帮我把上周的会议纪要找出来整理成要点发到群里它多半会回复你一大段文字告诉你可以这样做、应该那样做然后……就没有然后了。它不会真的去翻你的会议记录不会真的调起某个发送接口更不会在操作完成后给你一个结果确认。这个场景非常精准地划分了两个物种左边是对话式AILLM Chat右边是智能体AI Agent。前者只有嘴后者有嘴、手、脚、甚至一套完整的工作流程。我在最开始接触这个领域时也误以为给模型加个System Prompt让它扮演个助手就是Agent了直到我自己动手搭了一个能真正调外部工具的Agent才发现中间的差距不是一星半点。一句话总结Agent 大模型 工具调用 循环决策 记忆管理。大模型在里面不是负责输出最终答案而是负责思考下一步该干什么。1.2 ReAct循环Agent的工作模式就是想一步做一步看一步我先讲一个很多人容易绕晕的概念ReAct。ReAct这个词拆开是Reason推理和Act行动。大模型在接到一个复杂任务时如果让它一次性输出最终结果它大概率会在中间某一步编造一个看似合理但实际错误的信息——这就是业内常说的幻觉。而ReAct的思路是不要求模型一步到位而是让它一边推理、一边行动、一边观察结果形成一个闭环。流程是这样的模型基于当前状态推理出我下一步该做什么。模型输出一个工具调用指令比如查询日程表。程序我们写的胶水代码真正执行这个调用。调用结果作为文本/结构化数据返回给模型。模型基于这个新观察到的结果再次推理下一步动作。这个循环不断重复直到模型认为任务已完成或者达到我们设置的最大轮数。我用一个生活化的类比让一个实习生去安排一场会议。他不会一次性把整场会议的所有事项都列完而是先看日历发现周五下午有空档就去订会议室订完之后再通知参会人收到反馈之后再调整时间。每一步都是看一步走一步。Agent也是这样。1.3 先做个判断题你的需求真的需要Agent吗在正式开始搭建之前我建议你先做一个成本评估。因为Agent不是银弹很多场景用传统编程方式反而更稳。我会用这三条标准来判断任务是否需要调用外部系统或数据如果需要查询数据库、调用API、操作文件、发消息Agent有价值如果只是纯知识问答直接调大模型聊天接口就够了。任务是否存在不确定性路径用户输入不同需要走的流程分支也不同传统硬编码很难提前穷举此时Agent的动态决策优势才能发挥出来。容错成本是否可控Agent在执行过程可能调用错工具、传错参数如果你的业务场景不允许任何错误比如直接涉及资金交易那现阶段建议人在环里Human-in-the-loop做审批而不是纯自动。我的建议是当你判断一个任务满足上面第一、第二条且第三条可控才值得上Agent。这也解释了为什么很多人在尝试Agent后会觉得不如硬编码稳定——很可能是场景选错了。2. Agent的四个积木块大脑、手脚、记忆、调度2.1 大脑模型选择要关心的三件事搭建Agent时模型的选型比普通聊天应用要苛刻得多。因为模型不只是回答问题它还要做决策、生成结构化指令、理解工具返回结果。我认为有三个硬指标要重点看。第一个是指令遵循能力。Agent的运转高度依赖模型理解我们写的System Prompt比如如果用户没给时间参数就调用get_current_time先获取当前时间。如果模型在复杂上下文里遵循不了这些规则整个Agent就会开始胡来。第二个是结构化输出能力。Agent的核心机制是让模型输出一段工具调用指令而不是普通文本。模型需要稳定地输出工具名和参数JSON一次都不出差错。有些模型在聊天测试里表现不错但一到Function Calling场景就会漏参数、格式错乱这种就不能用在Agent里。第三个是上下文长度和价格。Agent是多轮循环的每一轮都要把之前的对话历史和工具结果重新发给模型token消耗远高于单次对话。上下文窗口太小的模型会在第三四轮时失忆价格太高的模型会让你的账单很快失控。我个人的起步建议优先选那些在Function Calling能力上有专门评测、且上下文窗口在32K以上的模型同时准备一个便宜的小模型做摘要和路由典型的大小模型搭配策略。2.2 手脚工具就是模型和外部世界之间的桥工具Tool是Agent区别于聊天机器人的关键。你可以把工具理解成给模型准备的、遵循特定格式说明的函数。模型自己不会执行代码它只会输出我想调用xxx工具参数是yyy真正执行代码的是我们写在Agent框架里的函数。设计工具时最核心的不是实现函数本身——函数谁都会写——而是让模型知道这个工具是干什么的、什么时候该用、参数怎么填。这三样信息在代码里对应工具的描述description、参数声明parameters、以及我们精心设计的函数逻辑。举个例子一个获取用户日程的工具它的描述可以是查询某日期范围内的日程安排。当用户询问今天/明天/本周安排时调用此工具。这里的关键词是当……时调用它给了模型一个明确的调用条件。很多新手最容易犯的错就是把描述写得含糊不清比如日程相关功能模型根本不知道何时该触发。2.3 记忆上下文窗口和真正的记忆管理记忆问题在Agent里比想象中更早出现。我用一个简单的数学题说明假设你的模型上下文窗口是64K tokensAgent每跑一轮需要消耗大约5K tokens来做推理输出加上工具调用结果、积累的对话历史大概10轮之后窗口就快满了。而且这里有个容易被忽略的点模型上下文窗口越大并不代表你应该塞得越满。当上下文填充到80%以上时模型对中间部分的关注度会明显下降甚至出现中间丢失现象——它只记得开头和结尾的消息把中间的工具调用结果忘得一干二净。所以Agent的记忆管理本质上是在做有限注意力资源的分配短期记忆当前任务循环内的消息记录通常完整保留但要设上限。长期记忆超出窗口后把早期内容做摘要压缩或者抽取出关键实体用户偏好、任务状态存入外部存储向量数据库或普通数据库后续按需检索回来。2.4 调度控制流决定了Agent是聪明干活还是瞎忙活调度是大部分人忽略、但Agent行为质量最受影响的一环。所谓调度就是循环什么时候继续、什么时候停、出现错误怎么办。一个小实验能立刻让你体会到调度的重要性同一个Agent、同一批工具、同一个任务如果把最大循环轮数从5改成20模型会在任务已完成之后继续表演额外调用一堆没意义的工具。因为模型天然倾向于多做事来讨好用户如果没有强力的终止条件它就会做过犹不及。调度设计里要明确这几件事终止条件模型认为任务完成不再输出工具调用指令或达到最大轮数或出现不可恢复错误。重试策略工具调用失败、参数校验不通过时是直接报错终止还是把错误信息反馈给模型让它自行修正。人机交接点哪些环节必须停下来等用户确认哪些可以自动执行。3. 动手前的选择和准备直接调API还是引入编排框架3.1 一条简单标准最小成本起步现在市面上已经有不少Agent编排框架很多教程直接让你pip装一个库然后跑Demo。我自己的看法是第一次搭建Agent不要一上来就用重型框架先用最少的胶水代码手动实现一个最小循环。原因很朴素——框架会帮你屏蔽掉很多底层细节但当你遇到问题要调试时你连问题出在哪一层都说不清楚。手动实现一遍ReAct循环之后再去用框架你会觉得一切都是透明的原来这个回调是在工具执行后触发的原来这个参数是用来控制上下文裁剪的。所以本文的路线是先用一个虚构的日程管理场景手动写一个可运行的最小Agent在这个基础上我再告诉你哪些地方可以用框架替换、替换后能省什么事。3.2 项目结构和环境准备我习惯用一个很简单的项目结构agent_demo/ ├── main.py # 入口跑起Agent循环 ├── tools.py # 工具函数集合 ├── config.py # 模型配置、最大轮数等参数 ├── prompts.py # 系统提示词 └── logs/ └── trace.log # 每次执行的完整轨迹环境方面只需要一个Python 3.10环境以及能调用大模型API的SDK包。我建议用虚拟环境隔离依赖mkdir agent_demo cd agent_demo python -m venv .venv source .venv/bin/activate # Windows下执行 .venv\Scripts\activate pip install 你的模型SDK包关于模型服务商的SDK各家大同小异核心都是发送消息列表、可能附带工具列表、返回模型输出。我在示例里用一个抽象的model_client对象你可以在应用时替换成实际引入的SDK。3.3 建立一个最小可运行的骨架先把骨架写出来后面再逐层填充。这里我给出最小化的主循环代码刻意保持朴素# main.py from tools import SCHEDULE_TOOLS from config import MAX_ROUNDS, SYSTEM_PROMPT import json def run_agent(user_input: str): messages [{role: system, content: SYSTEM_PROMPT}] messages.append({role: user, content: user_input}) for round_idx in range(MAX_ROUNDS): print(f--- Round {round_idx 1} ---) # 调用模型带上工具定义 response model_client.chat( messagesmessages, toolsSCHEDULE_TOOLS, # 工具声明符合模型服务商的Tool Schema格式 ) message response[choices][0][message] messages.append({role: assistant, content: message[content]}) # 如果模型没有发起工具调用说明任务收尾直接退出 tool_calls message.get(tool_calls) if not tool_calls: print(Agent 判断任务已完成最终回复, message[content]) break # 逐个执行工具调用 for call in tool_calls: tool_name call[function][name] tool_args json.loads(call[function][arguments]) result dispatch_tool(tool_name, tool_args) # 把工具执行结果作为 tool 角色的消息发回模型 messages.append({ role: tool, tool_call_id: call[id], content: json.dumps(result, ensure_asciiFalse), }) if __name__ __main__: run_agent(帮我查一下明天上午有没有空闲时间)只要模型支持工具调用Function Calling接口这段骨架已经能跑通一个最简单的Agent了。注意model_client.chat里的tools参数它在模型眼里不是普通的代码而是一组按键说明书。4. 亲手定义第一个工具让Agent从会聊天变成会干活4.1 工具函数的选择挑一个真正有用的交互触点我选择做一个日程管理Agent工具清单如下get_current_time获取当前日期时间这是日程场景的必需品因为模型不知道自己现在是什么时候。query_schedule(date)查询某天的日程安排。add_schedule(date, time, event)在指定时间添加一条日程。这三个工具覆盖了查时间、读数据、写数据三种类型足够演示Agent的核心动作。先写普通函数部分这部分是最简单的# tools.py from datetime import datetime schedule_db {} def get_current_time(): return {current_time: datetime.now().strftime(%Y-%m-%d %H:%M:%S)} def query_schedule(date: str): items schedule_db.get(date, []) if not items: return {date: date, message: 当天暂无日程安排} return {date: date, schedule: items} def add_schedule(date: str, time: str, event: str): if date not in schedule_db: schedule_db[date] [] schedule_db[date].append({time: time, event: event}) return {status: success, message: f已添加日程{date} {time} {event}}4.2 写好工具的描述和参数声明这才是Agent质量的胜负手函数本身写好了但模型并不知道这些函数存在的意义。我们需要给每个函数配一份元数据这份元数据直接决定了模型在什么时机调用哪个函数。每个工具声明通常包含三部分typefunction、function.name名称、function.description描述、function.parameters参数JSON Schema。下面这段是query_schedule的完整声明query_schedule_schema { type: function, function: { name: query_schedule, description: ( 查询某一天的日程安排。 当用户询问某天有什么安排、某个时间段是否空闲、有没有会议时调用此工具。 date参数必须是YYYY-MM-DD格式。 ), parameters: { type: object, properties: { date: {type: string, description: 要查询的日期格式YYYY-MM-DD} }, required: [date] } } }这里有一个新手最容易忽略的细节描述里的触发条件写得越具体模型误调用的概率越低。你可以把描述理解成给模型看的说明书它需要明确三件事这个工具能做什么、什么时候该用、什么时候不该用。比如query_schedule的描述里写当用户询问……时调用这比干巴巴写查询日程要好得多。另一个细节是date参数的格式说明。模型可能会把用户说的明天直接当作字符串传给工具最常见的结果就是工具查了一个非法日期。我们在描述里明确格式同时让Agent在调用前先通过get_current_time算出确切日期双管齐下。4.3 工具返回值的格式约定以及错误要反馈不要吞掉工具执行完它返回的数据会被拼接进后续的消息里发给模型。这个返回值的格式有一个原则必须是模型能够轻松解析的结构化文本。我的习惯是这样正常结果返回JSON对象如{date: 2025-06-20, schedule: [{time: 10:00, event: 项目评审}]}。查询为空时不要返回None或空列表而是明确说当天暂无日程安排。为什么因为模型看到空列表会困惑而看到一句明确的话就知道没有安排这个状态。出错时参数异常、外部接口超时把错误信息转换成字符串返回让模型能看到发生了什么错它能基于错误自行调整策略。我见过太多人忽略第3点。比如add_schedule因为日期解析失败抛了个异常外层代码直接把异常往上抛Agent循环直接崩溃。正确的做法是把异常捕获住然后作为工具调用结果返回给模型“调用add_schedule失败原因date格式不正确。”模型看到后会尝试修正参数再调一次。5. 把循环编出来ReAct执行链路与终止条件5.1 主循环的完整实现从架子到真循环前面给出的骨架代码还不够完整因为它把dispatch_tool写成了一个假定存在的函数。现在我们把它补齐并且加上一个关键能力多个工具调用结果按顺序依次注入。在真实场景里模型有时候会一口气请求多个工具比如先查当前时间再查日程。不过对于刚起步的Agent我建议你先把最大并行调用数限制为1让模型一次只做一件事。这样轨迹更清晰出错了也更容易定位。# main.py import json from tools import ( get_current_time, query_schedule, add_schedule, get_current_time_schema, query_schedule_schema, add_schedule_schema, ) ALL_TOOLS [ get_current_time_schema, query_schedule_schema, add_schedule_schema, ] TOOL_MAP { get_current_time: get_current_time, query_schedule: query_schedule, add_schedule: add_schedule, } def dispatch_tool(tool_name: str, tool_args: dict): try: func TOOL_MAP[tool_name] return func(**tool_args) except Exception as exc: return {error: f工具执行失败{exc}}对于工具执行结果转成消息回传模型这一步不同的模型SDK格式略有差异但都是role: tool加上tool_call_id以及内容。这里有一个我自己走过的坑要提醒你工具返回内容不要直接塞一个巨大的Python对象一定要json.dumps成字符串。模型服务商对非字符串内容往往有隐式转换但你无法控制它转换的格式显式序列化才是可控的。5.2 最大轮数与终止条件的设计防表演型勤奋终止条件这里我再展开说。一个Agent在以下三种情况下会结束循环模型不再产生工具调用。这是最理想的完成状态。达到最大轮数上限。这是安全兜底。工具调用连续失败超过阈值。避免模型在同一个错误上反复重试。最大轮数我建议先从5开始。你会发现一个有趣的现象在任务已经可以完成时模型有时还会尝试补充做一些事。这是模型在对话历史里看到自己一直在行动形成了一种惯性。针对这种表演型勤奋有两个办法一是把SOCStop On Completion信号放在系统提示词里明确告诉模型一旦获得足够信息完成任务立即输出最终回答不要做额外动作二是降低最大轮数逼模型在有限的行动次数里做出最优决策。以下是带终止判断的完整循环MAX_ROUNDS 5 def run_agent(user_input: str): messages [{role: system, content: SYSTEM_PROMPT}] messages.append({role: user, content: user_input}) for round_idx in range(MAX_ROUNDS): response model_client.chat(messagesmessages, toolsALL_TOOLS) message response[choices][0][message] messages.append({role: assistant, content: message.get(content, )}) tool_calls message.get(tool_calls) if not tool_calls: print(最终答复, message[content]) return if len(tool_calls) 1: print(警告多个工具调用本次只执行第一个) call tool_calls[0] tool_name call[function][name] tool_args json.loads(call[function][arguments]) result dispatch_tool(tool_name, tool_args) print(fRound {round_idx1}: 调用 {tool_name}, 参数 {tool_args}) print(f结果: {result}) messages.append({ role: tool, tool_call_id: call[id], content: json.dumps(result, ensure_asciiFalse), }) print(已达最大轮数强制停止。)5.3 上下文太长怎么办两条腿走路的裁剪方案第3.3节里我提到每轮循环都会往messages里追加消息。当循环轮数上升消息列表膨胀得非常快。我的经验是在第四轮左右就开始干预。第一梯队的做法是滑动窗口始终保留系统提示词、最初的用户问题、最近两轮的所有消息中间的中间过程全部丢弃。这个方案实现最简单适合Demo和工具调用轮次少的场景。第二梯队是摘要压缩用一个便宜的小模型把中间轮的对话历史总结成一段到目前为止Agent已经完成的事情的摘要插入到系统提示词后面。这个方案信息保留更好代价是多了一次摘要的模型调用。我在实际项目里的习惯是两者结合滑动窗口保底超过一定轮数再触发摘要。具体阈值需要根据你的模型上下文窗口和单轮token消耗来实测我通常以总消息数超过25条作为触发摘要的信号。6. 调试Agent的经验之谈你不是在改Bug是在调行为6.1 五种常见的故障模式以及它们为什么必然发生Agent的调试思路和传统代码很不一样。传统代码是确定性的输入相同输出就相同。Agent是非确定性的同样的输入模型可能做出不同的决策。所以调试Agent时你要接受一个事实——你在驯化一个高度随机的系统而不是在修一个线性Bug。我把自己踩过最深的五种故障模式整理成一张表故障模式典型表现根因工具调用幻觉调用了根本不存在的工具名工具描述冲突或模型对工具边界理解差参数幻觉日期传成明天、名字传成用户原话参数描述不严格、缺少格式约束死循环同一个工具反复调用、结果一样缺少连续失败检测终止条件太宽松答非所问明明可以查询却泛泛而谈系统提示词没有强调必须调用工具获取事实上下文漂移做着做着忘了初始需求没有在每轮保持用户目标在上下文里这里的根因有一个共性问题往往不是模型不够聪明而是我们给模型的信息工具描述、提示词、外围防护不够清晰。6.2 日志和轨迹回放Agent调试的两件套传统程序调试你可以在代码里打断点、看堆栈Agent调试的核心手段则是完整记录轨迹并回放。我给自己的Agent加了很简单的日志机制把每次模型请求的完整消息数组、模型返回的原始消息、工具调用结果全部追加到一个JSON Lines文件里。这个日志我看的不是单个字段而是整个思考轨迹模型是在哪一步决定调用query_schedule的工具返回结果后模型是如何理解这个结果的是哪一轮之后模型开始偏离主线的没有轨迹回放你在Agent调试上基本就是盲人摸象。我在每轮调试时都会问自己三个问题它为什么要调用这个工具它拿到结果后为什么这么理解我的工具描述和提示词里有什么信息误导了它6.3 提示词战略把规则写进系统提示词而不是寄希望于模型悟性我在系统提示词里一定会放三类内容这几乎成了我的标配模板第一类角色与目标声明。例如你是一个日程管理助手。你的目标是在最短的行动次数内帮用户查询和安排日程。你必须通过调用工具来获取事实不要编造日程信息。第二类行动边界。例如你只能使用提供的工具。当用户需求不明确时如缺少日期先调用get_current_time获取当前时间再询问用户或基于合理推断处理。第三类终止与输出规范。例如当你已经完成用户需求时直接给出简洁的中文回答不要再调用任何工具。很多新手把提示词写得很长很绕结果模型反而抓不住重点。我自己的体会是用短句列规则每条规则对应一个具体行为。与其写请进行合理的日程管理不如写日期格式一律是YYYY-MM-DD如果用户说明天先调用get_current_time推算日期再查询。6.4 实测一个完整对话的痕迹为了让你更直观地理解上面整套机制是怎么协作的我贴一段我在本地模拟的完整轨迹已做简化用户明天下午四点有什么安排Round 1模型调get_current_time得到当前时间。这是正确的第一步因为明天是相对时间模型必须知道今天几号。Round 2模型调query_schedule参数传了明天的日期。工具返回暂无安排。Round 3模型没有继续调工具直接回复明天下午四点暂无日程安排。这个轨迹看起来平平无奇但它每一步都在正确的位置上先解决时间基准再按格式查询最后确认无任务就收尾。假如Round 2的参数传成了明天而不是具体日期日志里立刻能看到我会去检查query_schedule的参数描述是否写清楚了格式要求。7. 从Demo到能用让Agent真正稳定下来的进阶手段7.1 给Agent装上长期记忆不只是把对话塞进窗口Demo里的Agent每跑一次都是失忆状态下次启动一无所知。真实的Agent需要记忆层。我常用的方案是在add_schedule写入时同时把日程数据落到SQLite或普通数据库。每次启动Agent时把用户的基本偏好如常用会议时长、常用提醒时间从数据库读取出来拼进系统提示词。对于历史对话用摘要模型定期压缩摘要存入数据库需要时再取出来注入上下文。这套机制的成本不高但对体验的提升是巨大的。Agent第一次被问我上次让你加的日程确认了吗时能正确回答——那一刻你会觉得之前的投入都值了。7.2 从单循环到多角色协作当任务太复杂时的破局思路单个Agent能解决的任务有一个隐形的复杂度天花板。任务步骤一多、依赖一深单Agent就会在超长上下文里迷失。这时候有两个选择一是把任务拆成多个子任务用规划Agent负责拆解并分配二是用多个专用Agent协作每个Agent只负责自己擅长的一段流程。我个人的实践建议是不要让只有三五个工具的Agent硬扛复杂任务优先把工具的职责划分清楚。比如日程管理里查天气再决定今天是否适合户外会议这种需求我会直接增加一个天气工具而不是让模型去猜。7.3 没有评测集就不要说自己的Agent好用最后这一点最关键也是我在项目从Demo走向真实环境过程中体会最深的事Agent是随机的所以你必须用数据来评估好坏而不是凭感觉。我会准备一组覆盖典型场景的测试输入每条输入都配上期望的行为描述测试输入期望行为明天下午有空吗先查当前时间再查明天日程帮我约周五上午十点和张总开会日期解析正确调用add_schedule我这周忙碌吗查询多天日程并给出汇总判断每次改动系统提示词或工具描述后我都跑一遍这组用例看成功率是升还是降。没有这套回归测试你做的任何提示词优化都可能是修好了这个case、弄坏了另外三个。把我个人最后一点体会放在这里Agent真正难的从来不是让模型输出一段JSON而是设计一套让不确定的模型在确定的工作流里可靠工作的机制。工具定义、终止条件、记忆策略、错误恢复——这些外围工程才是Agent能不能落地的关键。你一旦手动走过一遍最小循环再去理解市面上任何Agent框架的配置项都会觉得非常通透。这就是从0到1的价值。