2026/10/25 18:21:36

告别调包侠:手写AI Agent,从零实现ReAct循环看清底层原理

告别调包侠:手写AI Agent,从零实现ReAct循环看清底层原理 上周有个同学问我老师我用LangChain跑通了第一个AI Agent看着它自己查天气、自己算数学感觉很神奇但我不明白它内部到底在干什么。我说那你想不想自己手写一个他说那多麻烦框架不香吗。我反问他如果工具返回了报错框架会怎么决定是重试还是放弃他沉默了。这就是典型的“调包侠”状态。用框架没问题问题是框架把Agent的核心循环、工具调度、上下文管理全都封装成了黑盒你跑通了Demo却不知道自己跑的是什么。“零基础手写AI Agent系统课”这个标题我很喜欢因为它直接戳中了这个痛点从“调包侠”到“造轮子的人”差的不是敢不敢拆框架而是有没有亲手把Agent最底层的轮子造一遍。这篇文章我就按我带学员的路径来写不讲玄学不堆概念直接拆解一个最小可运行的Agent是怎么从零写出来的以及写完之后你会撞上哪些框架替你藏好的坑。1. 拆掉LangChain黑盒Agent到底是什么为什么值得从零写一遍1.1 大模型只会说话Agent才会办事先说清楚一个最基本的认知大模型本身不是一个“能办事”的系统它是一个“会说话”的系统。你问它杭州明天天气怎么样它可以给你编一段听起来很合理的回答但它在训练时没见过今天的天气数据所以它本质上不具备查实时天气的能力。换句话说大模型只有一张嘴没有手。Agent做的事情就是给这张嘴装上手脚和记事本。工具Tools让模型能调用外部函数比如查天气、算算术、读文件、发请求。循环Loop让模型不停地“思考→行动→观察结果→再思考”直到解决用户的问题。记忆Memory让模型记得之前说过什么、做过什么避免答非所问。我带的零基础学员里很多人第一次听说这些话都会觉得抽象。我用一个比喻他们马上就懂了大模型是一个刚毕业的实习生脑子很好使但没经验、没工具、没流程。Agent系统就是给这个实习生配了一间办公室——工具是桌上的电话和电脑循环是主管在旁边盯着他“下一步该干什么”记忆是他的工作日志。没有这套办公室实习生再聪明也只能坐着空谈。这也是为什么单纯调API不等于会做Agent。你调通了一个大模型接口只是把实习生招进来了你手写出一套Agent循环才是把整间办公室搭起来了。1.2 调包侠与造轮子的人差的不是代码量是底层认知我把“调包侠”当成一个中性词来用。在现代工程实践里调包是一种非常高效的工作方式没人会蠢到自己去重写HTTP库或者JSON解析器。但问题在于调包的人分两种。一种人知道包里装的是什么另一种人完全不知道。不知道包里是什么的人遇到问题只能靠试错和猜。举个例子。你用LangChain跑了一个Agent它答非所问。你的排查思路大概率是“换一个Prompt试试”“换一个模型试试”。但如果你手写过一遍你的排查路径会变成是模型生成的Action格式解析失败了是工具执行异常后没有把错误喂回上下文是历史消息太长把系统提示词给挤掉了这些层次完全不一样。技术圈里“手写”从来都不是退步。从手写数字识别MNIST到手写Transformer从手写Spring到手写各种框架核心每一代开发者都在用“手写一遍”来对抗黑盒焦虑。AI Agent也一样。现在网上有大量关于“AI Agent搭建”“AI Agent部署”“AI Agent主流架构”的教程但绝大多数都是在教你怎么用别人的轮子真正想理解Agent的底层逻辑你得把轮子从圆木开始削一遍。手写一次你会获得三个很难用钱买到的东西调试定位能力报错时知道往哪看、模型输出理解能力知道它为什么会乱说、架构选择能力知道什么时候该用框架什么时候不该用。2. 手写Agent的四块地基LLM、工具、循环、记忆各司其职2.1 四件套的职责边界一个最简Agent本质上由四部分组成。我拿一个表格把它们拆开后面实现代码时你会反复对照这张表。组件类比手写时要关心的核心问题LLM实习生的大脑怎么把上下文正确地传给模型工具桌上的电话和电脑怎么让模型知道有哪些工具、参数是什么循环盯着流程的主管怎么判断该继续行动还是该给最终答案记忆工作日志哪些历史信息要保留哪些该丢掉这里有个新手很容易误解的点Agent的“智能”不是模型一个人给的而是这四件套相互配合产生的。模型负责最核心的决策但工具决定了它的能力边界循环决定了它能不能把一件事做完整记忆决定了它会不会越做越糊涂。任何一个环节掉链子整个Agent都会翻车。这也是为什么我不建议一上来就上重型框架。框架把这四样东西全给你组装好了你看不见它们之间的接口长什么样。等你手写一遍再回去看LangChain的AgentExecutor源码你会发现自己能看懂它在干什么了——那种感觉非常爽。2.2 LLM接口先让Agent学会“说话”手写Agent的第一步不是写Agent而是写一个能稳定调用大模型的函数。这个函数是所有后续循环的基础它的作用只有一个接收一段对话消息列表返回模型的文本回复。import requests # 以兼容OpenAI协议的模型服务为例BASE_URL / API_KEY / MODEL_NAME # 请换成你实际使用的模型服务商配置 BASE_URL https://your-model-service.example.com/v1 API_KEY your-api-key MODEL_NAME your-model-name def llm_chat(messages): 向大模型发送对话消息返回回复文本。 resp requests.post( f{BASE_URL}/chat/completions, headers{Authorization: fBearer {API_KEY}}, json{model: MODEL_NAME, messages: messages}, timeout60, ) resp.raise_for_status() return resp.json()[choices][0][message][content]这段代码本身没什么神奇的地方但它把Agent和外界的边界切清楚了。整个Agent系统里所有对模型的调用都走这一个函数以后你想换模型服务商、加缓存、记日志只需要改这一个地方。需要提醒的是messages里的消息是有角色的。最基本的角色是system系统提示词定义模型的行为边界、user用户输入和assistant模型的历史回复。后面写循环时我们还要不断往这个列表里追加内容这正是Agent记忆的实现基础。2.3 工具注册表决定Agent能力的“菜单”工具在代码层面就是普通的Python函数。比如查天气本质就是定义一个get_weather(city)函数里面去调用一个天气接口返回一段字符串。但有一个关键点常常被新手忽略模型并不知道这个函数存在更不知道它怎么用。你必须把工具的“使用说明”告诉模型。这就是工具注册表的作用——它是一份给模型看的菜单。TOOLS { get_weather: { description: 查询指定城市的当前天气情况参数city为城市名例如杭州, function: lambda city: f{city}今天晴气温22摄氏度东南风2级 }, calculator: { description: 计算数学表达式参数expr为合法的数学表达式字符串例如(35)*2, function: lambda expr: str(eval(expr)) } }注意模型看到的不是你这段dict而是你从description里拼出来的一段工具使用说明随着系统提示词一起发过去。模型读了以后会在它觉得合适的时机“说”出一句话我想调用get_weather参数是杭州。然后你的代码负责解析这句话、执行对应的函数、把结果拿回来。这是手写Agent最重要的一个心法模型不会主动调用任何函数它只负责“说想调用什么”真正动手执行的一直是你的代码。想通了这一点你就理解了所有Agent框架的底层都在做什么。3. 八十行代码跑通ReAct循环一个最小Agent的手写全过程3.1 ReAct是什么推理与行动交替的循环现在LLM接口有了工具注册表也有了缺的是把两者串起来的循环控制器。目前业界最主流的Agent工作模式之一叫ReAct全称是Reasoning and Acting核心思想就是让模型在推理和行动之间交替推进思考Thought模型分析当前情况判断下一步该做什么。行动Action模型输出一个工具调用指令。观察Observation代码执行工具把结果返回给模型。循环上面三步直到模型认为问题解决了输出最终回答。为什么这个模式会成为行业默认因为它把Agent的每一步决策都显性化了。你可以看到模型每一步在想什么、调了什么工具、拿到了什么结果。出了问题你能像回放录像一样定位是哪一步的判断错了而不是面对一个黑盒干瞪眼。3.2 完整实现提示词设计、输出解析、工具执行、观察回填我直接给出一份最小可运行的代码。为了让教学更清晰这里的工具调用采用纯文本协议模型输出一个以ACTION:开头的行后面跟着工具名和JSON格式的参数当模型准备回答用户时输出以ANSWER:开头的内容。import json import re SYSTEM_PROMPT 你是一个智能助手。你有以下工具可用 - get_weather(city): 查询城市天气 - calculator(expr): 计算数学表达式 当需要调用工具时你必须严格按以下格式输出 ACTION: get_weather({city: 杭州}) 当你不使用工具、准备回答用户时按以下格式输出 ANSWER: 你的最终回答 你每次只能输出一个ACTION或一个ANSWER。 .strip() def run_tool(name, arguments): 根据工具名执行函数返回字符串结果。 if name not in TOOLS: return f错误未知工具 {name} try: return str(TOOLS[name][function](**arguments)) except Exception as e: return f错误工具执行失败原因{e} def run_agent(user_query, max_steps5): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_query}, ] for step in range(max_steps): reply llm_chat(messages) messages.append({role: assistant, content: reply}) # 模型选择直接回答结束循环 if reply.startswith(ANSWER:): return reply[len(ANSWER:):].strip() # 尝试解析ACTION指令 match re.search(rACTION:\s*(\w)\((.*)\), reply, re.DOTALL) if not match: # 解析失败让观察结果带着错误信息回到模型 messages.append({ role: user, content: 格式错误你的输出没有包含可解析的ACTION指令请重新按格式输出。 }) continue tool_name match.group(1) try: arguments json.loads(match.group(2)) except json.JSONDecodeError: arguments {} # 执行工具并把观察结果回填给模型 observation run_tool(tool_name, arguments) messages.append({ role: user, content: f工具 {tool_name} 的返回结果{observation} }) return 已达到最大步骤数任务未完成。 # 使用示例 print(run_agent(帮我算一下 (35)*2 等于多少))核心逻辑就一个循环调模型→解析输出→执行工具→回填观察结果。每次循环的对话历史都会越来越长模型基于新的上下文做出下一步决策直到输出ANSWER或者步骤用尽。这里你可能会问为什么工具的返回结果用user角色塞回去因为在最简实现里模型只需要把这段内容当成“用户额外提供的已知信息”继续推理就行。真实生产环境可以用tool角色来让协议更规范但理解循环本质的话最简单的角色反而更好懂。3.3 解析容错第一道躲不开的坎新手手写Agent时遇到的第一个崩溃点基本都在解析这一步。因为模型输出是自然语言它不可能每次乖乖给你标准格式。常见的翻车现场有三个模型输出了ACTION: get_weather(city杭州)这种带等号的写法不是严格JSON。模型输出了ACTION: get_weather({city: 杭州})但后面跟了一大段解释文字。模型输出了两个ACTION想让你一次执行两个工具。我上面的代码里加了一层最简单的容错格式错误时把错误信息作为观察结果回填给模型让模型自己修正格式。这个思路非常实用——与其用正则去兼容一万种写法不如把问题抛回给模型让它按规矩重新输出。实测下来的效果是大部分模型在收到“格式错误”提示后下一轮就会乖乖按规范输出。你可能会觉得这太折腾了。没错框架之所以有价值就是因为它把这一层烦人的容错封装好了。但你不亲手踩一次这个坑你永远不会理解为什么LangChain里会有OutputParser这个组件也不知道它存在的价值是什么。4. 手写之后才看得见的暗坑Token、上下文失忆与工具报错自救4.1 Token预算每一轮循环都在烧钱“AI Agent token是什么意思”——这是我最常被问到的问题之一。简单说Token是大模型处理文本的最小单位。对中文来说大致一个汉字对应1到2个Token一个英文单词大约1.3个Token。你每次调用模型接口都要按Token数付钱而且重要的是模型是无状态的每次调用都会把整个对话历史重新发送一遍。这意味着什么你的Agent每循环一轮发送给模型的文本就增长一次。假设一轮对话消耗2000个Token10轮就是2万Token。如果工具返回结果特别长增长会更快。循环轮次历史消息Token量示例单轮费用趋势第1轮约800低第5轮约4500中第10轮约10000高控制Token预算是手写Agent必须做的一笔账。我自己的习惯是三板斧第一max_steps必须设上限防止Agent陷入死循环第二工具返回结果要做裁剪只保留关键信息第三历史消息太长时要截断。这三步不做你的Agent跑着跑着就会又贵又慢。4.2 上下文滑动窗口防止Agent“记太多而变笨”你可能会想Agent不是有记忆吗把历史消息全留着不是更好吗现实是上下文窗口是有限的而且历史消息堆积太多以后模型在长文本里找重点的能力会下降反而会忽略最新指令甚至完全忘记系统提示词。最朴素也最有效的方案是滑动窗口只保留最近的N轮对话早期的内容直接丢弃。def trim_messages(messages, max_chars6000): 近似用字符数控制上下文长度保留system消息和最近的对话。 # 保留第一条system消息不被删除 system_msg messages[0] recent_msgs messages[1:] total_chars len(system_msg[content]) kept_msgs [system_msg] # 从后往前保留直到达到近似上限 for msg in reversed(recent_msgs): if total_chars len(msg[content]) max_chars: break kept_msgs.insert(1, msg) total_chars len(msg[content]) return kept_msgs这是一个典型的“牺牲远期记忆换稳定”的方案。代价是如果用户在第1轮提到了一个关键信息到第10轮时这条信息可能已经被丢掉了。进阶方案是用小摘要线程——在滑动窗口丢弃前让LLM把旧对话压缩成一个自然段再塞进上下文。但这个复杂度比较高零基础阶段优先掌握滑动窗口就够了。我提一个实战细节截断时一定要把system消息保住。很多人写的截断函数把列表整个截了结果系统提示词被冲掉Agent立刻失去行为约束开始乱输出。这个坑我至少见过五次。4.3 工具报错自救把错误喂回给模型手写Agent的过程中工具报错处理是最能体现“造轮子思维”的地方。大多数调包侠遇到工具报错只会看框架日志里的红字但手写者会明白一件事工具报错不该直接中断流程而应该变成一个观察结果交给模型处理。举个例子。你的get_weather函数拿到城市名后去请求天气接口如果城市名错了模型可能会第二次调用同一个参数继续错。怎么让它学会自我修正答案是在系统提示词里加一句话并且在工具异常时把异常信息作为观察结果返回。我的代码里已经有这个逻辑了run_tool把异常捕获后返回“错误工具执行失败原因xxx”后面接着把这句话作为观察回填给模型。模型看到后会调整参数或者换一个工具甚至直接向用户说明无法完成。我还习惯在系统提示词里加一句如果工具返回错误信息请先尝试修正参数再次调用确认无法完成后再告知用户失败原因。就这么一句话Agent的“自救能力”会有质的提升。这个技巧在很多框架教程里都不会写但它恰好是手写Agent最有意思的地方——你亲手决定了模型遇到挫折后的行为模式。5. 从“能跑”到“好用”顺着主流架构继续造轮子5.1 从裸ReAct到Function Calling解析这件事交给API写完文本协议的ReAct循环后你会很快发现生产环境里有个更省心的方案主流模型服务商提供的Function Calling也叫工具调用。这个机制让你不再自己写正则解析模型输出而是把工具定义以结构化JSON传给模型模型直接返回一个结构化的工具调用请求。对比项裸ReAct文本解析Function Calling格式稳定性靠模型自觉偶尔乱来API层保证结构化返回对模型的依赖低任意模型都能跑高需要模型原生支持学习价值极高能看清循环本质生产环境更省心上手成本低需要读对应文档我的建议是学习阶段一定要先写裸ReAct理解循环和解析的本质生产阶段再用Function Calling。这个顺序不能反。如果你一上来就调Function Calling你又会把它当成一个新的黑盒——你会用但你不一定理解为什么模型能“直接”返回参数。5.2 单Agent的边界与多Agent协作的演进手写跑通单Agent之后下一个绕不开的话题就是主流架构演进。单Agent能干很多事了但它有明确的天花板一个上下文窗口装不下太多工具定义一个人既当规划者又当执行者容易上下文爆炸职责混杂会让Prompt难以维护。所以现在的AI Agent主流架构里多Agent协作是很重要的一条线。思路也简单像公司分工一样搞一个Planner规划者负责拆解任务再搞若干个Executor执行者各自负责一个具体领域。它们通过消息互相传递任务和结果单个Agent的上下文压力被分流整体的复杂度也上来了。这个阶段离“零基础手写”稍微有点远但方向值得你提前知道。我的经验是先把单Agent的循环写到肌肉记忆再去碰多Agent。否则你连单个Agent为什么会翻车都说不清楚搭一个团队出来只会乱成一锅粥。5.3 上线部署前必须处理的工程细节网上很多教程到“Demo能跑”就结束了但真实项目还得回答一个问题AI Agent怎么部署才不像玩具我踩过不少坑挑三个最关键的提醒你。第一是日志。Agent的每一步决策都是模型输出你不用日志把Thought、Action、Observation全记录下来出了问题根本没法排查。第二是超时和重试。模型接口和工具接口都可能卡住你的循环里必须加超时控制否则一个坏工具会拖死整个Agent。第三是会话隔离。生产环境有多个用户同时用你不能让他们共享同一个消息队列每个会话要有独立的ID和独立的内存状态。这些细节单看都不难但它们决定了一个Agent系统是“能用”还是“能上线”。手写一遍Agent的意义也在这里——你亲手搭过一遍完整链路这些工程问题就会一个个浮现出来逼着你解决。调包的时候它们都被框架藏起来了你会误以为不存在。我自己带的学员里凡是认真手写过一遍最小Agent的回去再碰LangChain、看Agent源码、查Function Calling文档状态完全不一样——不是“照着抄”而是“我知道它底层是怎么设计的”。从“调包侠”到“造轮子的人”这一步跨过去你就再也不会对着框架报错一脸茫然了。轮子自己造过一遍才知道轮子为什么是圆的哪一步磨损该往哪儿查。