☰
从零手写AI Agent:深入ReAct循环与工具调用原理
2026/10/29 7:46:46 网站建设 项目流程

1. 为什么我不建议你从框架开始学 AI Agent

1.1 一个让我走了半年弯路的认知陷阱

刚接触 AI Agent 那会儿,我跟大多数人一样,上来就找框架。LangChain、AutoGPT、MetaGPT,哪个火就装哪个,跑通一个 Demo 就觉得自己“会了”。结果呢?线上稍微换个模型、改个 Prompt 结构,整个链路就崩,排查半天连问题出在哪一层都定位不到。后来我才想明白一件事:框架帮你屏蔽了细节,但恰恰是这些细节,决定了 Agent 到底能不能扛住真实场景。

这个项目标题里有个词特别戳我——“手写”。不是“基于某某框架搭建”,而是“手写”。这意味着我们要从最底层开始,把 Agent 的每一个零件都自己拧一遍。为什么值得这么干?因为当你亲手写过一遍 ReAct 循环、亲手实现过工具调用的解析逻辑、亲手处理过上下文超限的截断策略之后,再回头看那些框架,你会发现它们不过是把你写过的东西封装得更漂亮而已。到那个时候,你用框架是“选择”,而不是“依赖”。

这篇文章适合谁?如果你已经会用 Python 写点脚本,调过 OpenAI 或者千问的 API,但总觉得 Agent 这东西“知其然不知其所以然”,那这篇就是写给你的。如果你完全零基础,也没关系,我会把每个关键概念用生活化的方式讲清楚,代码部分尽量给全,你可以直接抄作业。

1.2 手写 Agent 到底在写什么

很多人以为 Agent 就是“让大模型调用工具”,这话对了一半。完整的 Agent 至少包含四个核心模块:推理循环(Reasoning Loop)、工具注册与调用(Tool Registry & Invocation)、上下文管理(Context Management)、结果解析与回传(Output Parsing)。框架帮你做的,无非是把这四个模块用一套抽象接口串起来。

手写的意义在于,你会被迫面对每一个脏活累活。比如:模型返回的 JSON 格式不对怎么办?工具调用超时了怎么重试?多轮对话之后上下文爆了怎么截断?这些问题在框架里可能就是一个参数,但手写的时候你得自己想清楚策略。而正是这些策略,决定了你的 Agent 在生产环境里是“能用”还是“好用”。

我个人的经验是,手写一遍之后,你对 Agent 的理解会从“调 API”跃升到“设计系统”。这个跃升,才是从“会调用”到“懂原理”的真正分水岭。

2. 动手之前,先把这几个核心概念钉死

2.1 Agent 和普通 Chatbot 的本质区别

普通 Chatbot 的逻辑是:用户输入 → 模型生成 → 返回结果。一条直线,没有分支。Agent 的逻辑是:用户输入 → 模型思考 → 决定是否调用工具 → 执行工具 → 把结果喂回模型 → 模型再思考 → 循环直到得出最终答案。这是一个带反馈的闭环。

这个闭环里最关键的是“决定是否调用工具”这一步。模型怎么知道该调用哪个工具?靠的是我们在 System Prompt 里写的工具描述。所以工具描述写得好不好,直接决定了 Agent 的智商。我见过太多人工具描述就写一句“查询天气”,结果模型根本不知道什么时候该用它。正确的写法应该是:“当用户询问某个城市的天气、温度、是否下雨等信息时,调用此工具。输入参数为城市名称,输出为当前天气状况。”

2.2 ReAct 模式:手写 Agent 的骨架

ReAct(Reasoning + Acting)是目前最主流的 Agent 推理模式。它的核心思想是让模型在每一步都输出“思考”和“行动”两部分。思考部分是模型对当前状态的分析,行动部分是模型决定下一步做什么——要么调用某个工具,要么给出最终答案。

手写 ReAct 循环的伪代码大概长这样:

while not done: response = model.generate(context) thought, action = parse(response) if action.type == "tool_call": result = execute_tool(action.name, action.args) context.append(result) elif action.type == "final_answer": done = True return action.content

看起来简单,但魔鬼在细节里。parse函数怎么写?模型不按格式输出怎么办?工具执行报错了怎么处理?这些才是手写 Agent 的真正难点。

2.3 工具调用的三种实现路径

目前让模型调用工具,主流有三种方式:

方式原理优点缺点
Prompt 解析在 Prompt 里约定输出格式,自己解析不依赖模型能力,通用性强格式不稳定,解析容易出错
Function Calling模型原生支持,返回结构化调用信息稳定、准确需要模型支持,部分模型不兼容
混合模式优先用 Function Calling,失败降级到 Prompt 解析兼顾稳定性和通用性实现复杂度高

我个人的建议是:如果你用的模型支持 Function Calling(比如千问、GPT-4 系列),优先用原生方式。如果不支持,或者你想深入理解原理,那就从 Prompt 解析开始手写。这个项目里我会两种都讲,你可以根据自己的模型情况选择。

3. 从零搭建:手写 Agent 的完整实操

3.1 环境准备与项目结构

先把环境搭起来。Python 3.10 以上,装几个必要的库:

pip install openai requests pydantic

项目结构我建议这样组织:

my_agent/ ├── agent.py # 核心推理循环 ├── tools.py # 工具注册与实现 ├── parser.py # 输出解析 ├── context.py # 上下文管理 └── main.py # 入口

为什么要把 parser 和 context 单独拆出来?因为这两个模块是最容易出问题的地方,拆出来之后方便单独测试和替换。我试过把所有逻辑塞在一个文件里,后期改起来简直是灾难。

3.2 工具注册:让模型知道它能干什么

工具注册的核心是两件事:定义工具的执行逻辑,以及生成给模型看的工具描述。先看代码:

import json class Tool: def __init__(self, name, description, parameters, func): self.name = name self.description = description self.parameters = parameters self.func = func def to_schema(self): return { "name": self.name, "description": self.description, "parameters": self.parameters } def execute(self, **kwargs): return self.func(**kwargs) TOOL_REGISTRY = {} def register_tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] = Tool(name, description, parameters, func) return func return decorator @register_tool( name="get_weather", description="当用户询问某个城市的天气状况时调用此工具", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"} }, "required": ["city"] } ) def get_weather(city): # 实际项目中这里调用天气 API return f"{city}今天晴,气温 25 度"

这里有个关键点:description的写法。我踩过的坑是,描述写得太简略,模型根本不知道什么时候该调用。后来我总结了一个模板:“当用户询问 XXX 时调用此工具,输入为 XXX,输出为 XXX”。把触发条件、输入、输出都说清楚,模型的调用准确率会明显提升。

3.3 核心推理循环:Agent 的心脏

这是整个项目最核心的部分。我用的是 ReAct 模式,让模型每一步都输出思考和行动:

import json from openai import OpenAI client = OpenAI(api_key="your-key", base_url="your-base-url") SYSTEM_PROMPT = """你是一个智能助手,可以使用工具来帮助用户解决问题。 你可以使用以下工具: {tool_descriptions} 请严格按照以下格式回复: 思考:你对当前问题的分析 行动:你要执行的操作,格式为 JSON: {{"type": "tool_call", "name": "工具名", "args": {{...}}}} 或 {{"type": "final_answer", "content": "最终答案"}} """ def build_system_prompt(): tools_desc = "\n".join([ f"- {t.name}: {t.description}" for t in TOOL_REGISTRY.values() ]) return SYSTEM_PROMPT.format(tool_descriptions=tools_desc) def parse_response(text): # 提取行动部分的 JSON action_start = text.find('{"type"') if action_start == -1: return None, text action_str = text[action_start:] try: action = json.loads(action_str) thought = text[:action_start].strip() return thought, action except json.JSONDecodeError: return None, text def run_agent(user_input, max_turns=10): messages = [ {"role": "system", "content": build_system_prompt()}, {"role": "user", "content": user_input} ] for turn in range(max_turns): response = client.chat.completions.create( model="qwen-plus", messages=messages, temperature=0 ) reply = response.choices[0].message.content thought, action = parse_response(reply) if action is None: return reply if action["type"] == "final_answer": return action["content"] if action["type"] == "tool_call": tool_name = action["name"] tool_args = action.get("args", {}) if tool_name not in TOOL_REGISTRY: result = f"错误:工具 {tool_name} 不存在" else: try: result = TOOL_REGISTRY[tool_name].execute(**tool_args) except Exception as e: result = f"工具执行错误:{str(e)}" messages.append({"role": "assistant", "content": reply}) messages.append({"role": "user", "content": f"工具执行结果:{result}"}) return "达到最大轮次限制,未能完成任务"

这段代码有几个设计决策值得说明。第一,temperature 设为 0。Agent 场景下我们需要的是稳定和可预测,不是创意。温度高了模型容易输出格式混乱的内容。第二,max_turns 限制。这是防止死循环的安全阀,我见过模型陷入“调用工具→结果不对→再调用同一个工具”的死循环,没有这个限制能把 API 额度跑光。第三,错误处理。工具执行失败不能直接崩,要把错误信息喂回模型,让它自己决定下一步怎么办。

3.4 上下文管理:别让对话爆掉

多轮对话之后,messages 列表会越来越长,最终超过模型的上下文窗口。这时候就需要截断策略。最简单的做法是保留 System Prompt 和最近 N 轮对话:

def truncate_messages(messages, max_tokens=6000): if len(messages) <= 3: return messages system_msg = messages[0] recent = messages[-6:] # 保留最近 3 轮 # 粗略估算 token 数(中文约 1.5 字/token) total = sum(len(m["content"]) for m in [system_msg] + recent) / 1.5 if total < max_tokens: return [system_msg] + recent # 还是超了,进一步缩减 return [system_msg] + messages[-4:]

这个策略很粗糙,但实用。更精细的做法是用 tokenizer 精确计算,或者做摘要压缩。不过对于入门来说,先跑通再说。我个人的经验是,上下文管理是 Agent 从 Demo 到生产最大的鸿沟之一,值得单独花时间研究。

4. 进阶:让 Agent 真正能干活

4.1 多工具协作与任务规划

单个工具调用只是起点。真实场景里,用户的问题往往需要多个工具配合。比如“帮我查一下北京和上海今天的天气,然后告诉我哪个更适合户外活动”,这就需要先调用两次天气工具,再让模型做比较。

我试过两种方案。一种是让模型自己规划,在 ReAct 循环里逐步调用。另一种是先让模型生成一个任务计划,再按计划执行。前者更灵活,后者更可控。实际用下来,对于步骤明确的场景,先规划再执行的成功率更高;对于需要根据中间结果动态调整的场景,ReAct 循环更合适。

4.2 工具调用的容错与重试

模型调用工具时,参数格式错误是家常便饭。比如该传字符串的传了数字,该传数组的传了单个值。我的做法是在工具执行前加一层参数校验:

def validate_args(tool, args): schema = tool.parameters required = schema.get("required", []) for key in required: if key not in args: return False, f"缺少必需参数:{key}" return True, None

校验失败时,把错误信息喂回模型,让它重新生成参数。实测下来,大部分格式问题模型都能在第二次修正。如果连续三次都失败,就放弃并返回错误提示。

4.3 从单 Agent 到多 Agent 的演进思路

当你手写完单 Agent 之后,多 Agent 其实就是把多个单 Agent 用消息传递串起来。比如一个“研究员 Agent”负责搜索信息,一个“写作 Agent”负责整理成文,一个“审核 Agent”负责检查质量。每个 Agent 有自己的 System Prompt 和工具集,通过一个调度器协调。

但我要泼一盆冷水:多 Agent 的复杂度是指数级上升的。调试难度大,成本高,而且很多场景下单 Agent 加多工具就能解决。我的建议是,先把单 Agent 写到极致,确实遇到瓶颈了再考虑多 Agent。

5. 踩坑实录与排查速查表

5.1 那些让我熬夜的典型问题

问题一:模型不按格式输出。明明在 Prompt 里写了要输出 JSON,模型偏偏给你加一段解释文字。解决办法是在 Prompt 里加 few-shot 示例,并且用temperature=0。如果还不行,就在解析函数里做容错,用正则提取 JSON 部分。

问题二:工具调用死循环。模型反复调用同一个工具,因为每次返回的结果它都觉得“不对”。这通常是因为工具返回的信息不够明确,或者 Prompt 里没有告诉模型“什么情况下应该停止调用”。解决办法是在工具返回结果里加上明确的状态标识,比如“查询成功,结果为 XXX”或“查询失败,原因 XXX”。

问题三:上下文超限。长对话之后模型开始胡言乱语,因为上下文被截断了但模型不知道。解决办法是在 System Prompt 里明确告诉模型“如果之前的对话被截断,请基于当前可见信息回答”。

5.2 常见问题速查表

现象可能原因排查方向解决方案
模型不调用工具工具描述不清晰检查 description 是否说明触发条件补充“当用户询问 XXX 时调用”
调用参数错误参数 schema 不明确检查 parameters 定义加 description 和示例值
格式解析失败模型输出不稳定检查 temperature 和 Prompt加 few-shot,降温度
死循环缺少终止条件检查工具返回和 Prompt加 max_turns,明确停止条件
上下文超限对话轮次过多检查 messages 长度实现截断或摘要策略

5.3 几个让我受益匪浅的实操心得

心得一:先写测试用例,再写 Agent。我现在的习惯是,先把期望的输入输出写成测试用例,然后再去实现。这样每次改 Prompt 或改工具,跑一遍测试就知道有没有退化。

心得二:日志要打全。Agent 的调试难度在于中间步骤多,如果不打日志,出了问题根本不知道是哪一步错了。我现在的做法是每一步的输入输出都记下来,包括模型的原始回复、解析后的 action、工具的执行结果。

心得三:别迷信框架。手写一遍之后,你会发现很多框架的设计其实有过度抽象的问题。理解原理之后,你可以更理性地选择用不用框架、用哪个框架。

6. 从手写 Agent 到生产级应用的差距

6.1 并发与性能:Demo 和线上的分水岭

手写 Agent 跑通之后,你可能会想“这不挺简单的吗”。但一旦要扛并发,问题就来了。每次 Agent 调用可能涉及多轮模型请求和工具执行,耗时从几秒到几十秒不等。如果同时来一百个请求,你的服务直接崩。

我试过的方案是异步化 + 队列。用 FastAPI 做接口层,把 Agent 执行放到后台任务队列里,前端轮询结果。这样虽然不能降低单次延迟,但能保证服务不崩。另一个优化是缓存,对于相同或相似的查询,直接返回缓存结果,省掉模型调用。

6.2 可观测性:出了问题怎么查

生产环境的 Agent 必须可观测。我建议至少记录这几个指标:每次请求的总耗时、模型调用次数、工具调用次数、失败率、平均轮次。这些数据能帮你快速定位问题——比如失败率突然上升,可能是模型服务不稳定;平均轮次增加,可能是 Prompt 需要优化了。

6.3 安全边界:让 Agent 知道什么不能做

Agent 能调用工具,就意味着它能产生实际影响。如果工具里有“发送邮件”“删除文件”这类操作,必须加权限控制。我的做法是在工具注册时加一个dangerous标记,执行前检查用户权限。另外,System Prompt 里要明确写清楚 Agent 的能力边界,防止它被诱导执行危险操作。

7. 后续可以怎么继续深入

手写 Agent 只是一个起点。如果你已经跟着走完了这一遍,接下来有几个方向可以继续挖。一是深入 Function Calling 的底层机制,看看模型是怎么决定调用哪个工具的,这对优化 Prompt 很有帮助。二是研究上下文压缩技术,比如用模型自己做摘要,把长对话压缩成短摘要,这样能支持更长的对话。三是尝试多 Agent 协作,但记得我前面说的,先把单 Agent 写到极致。

我个人在实际操作中的体会是,手写 Agent 最大的价值不是让你“会写 Agent”,而是让你对“模型能做什么、不能做什么、怎么让它做得更好”有了肌肉记忆。这种理解,是看多少篇教程都换不来的。踩过几次坑之后,你再看那些框架的文档,会有一种“哦,原来它是在解决这个问题”的顿悟感。这个感觉,挺爽的。

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

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

立即咨询