ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写一个最小Agent:拆解AgentLoop与ReAct循环的工程实践

从零手写一个最小Agent:拆解AgentLoop与ReAct循环的工程实践

1. 为什么我要自己动手写一个 Agent

市面上讲 Agent 的文章,十篇里有八篇在画架构图,剩下两篇在推销框架。真正把AgentLoop拆开、把ReAct的每一步 token 流向讲清楚的,少之又少。我最初也是被各种"智能体""自主容错""多智能体编排"绕得云里雾里,直到我决定不再看别人的图,而是照着pi-agent这个极简实现的思路,自己从零撸一个能跑起来的小 Agent,才算真正把这件事想明白。

这篇东西写给两类人:一类是刚接触LLM应用开发、被agent开发学习路线搞得焦虑的新手;另一类是已经用过dify、coze这类编排平台,但总觉得"黑盒太厚、想自己掌控每一行"的开发者。我会把Agent的核心——AgentLoop、ReAct循环、工具调用、记忆管理——全部拆到能直接抄作业的程度。你不需要Rust,不需要Hermes agent,甚至不需要 GPU,一台能跑 Python 的机器就够了。

先说结论:一个最小可用的 Agent,本质就是"一个 while 循环 + 一次 LLM 调用 + 一个工具分发器"。听起来简单得离谱对吧?但 90% 的坑都藏在这三样东西的衔接处。我踩过的坑包括但不限于:模型死活不输出工具调用格式、循环停不下来烧光 token、工具报错后模型开始胡言乱语、上下文越滚越长最后爆窗口。这些我都会在后面的章节里一个个拆给你看。

我参照的pi-agent思路,核心哲学是"薄封装"——不搞复杂的抽象层,不引入重型依赖,把 Agent 的每个环节都暴露出来让你看得见。这跟现在流行的"框架帮你搞定一切"是反着来的,但对学习者来说,看得见比自动化重要一百倍。你只有亲手处理过一次"模型返回了非法 JSON"的崩溃,才会真正理解为什么工程上要做容错。

2. 先把 Agent 的骨架想清楚再动手

2.1 Agent 和普通 LLM 调用的本质区别

很多人分不清"调用大模型"和"Agent"的界限。我打个比方:普通 LLM 调用就像你问一个博学但失忆的朋友一个问题,他答完就完了,下次问他啥都不记得。而 Agent 是给这个朋友配了一双手(工具)、一个笔记本(记忆)和一个"再想想"的机制(循环),让他能自己查资料、自己算数、自己纠错,直到把事办完。

技术上讲,区别就三点。第一是循环:普通调用是单次请求-响应,Agent 是"思考-行动-观察"反复迭代。第二是工具:Agent 能调用外部函数,比如搜索、计算器、读文件。第三是状态:Agent 维护一个不断增长的对话历史,每次循环都把它喂回模型。这三点合起来,就是ReAct(Reasoning + Acting)范式的核心。

ReAct这个词你可能在react 面经里见过,但那个 React 是前端框架,跟这里的 ReAct 完全是两码事,别搞混了。Agent 领域的 ReAct 出自 2022 年的一篇论文,核心思想是让模型在每一步都显式输出"我现在在想什么(Thought)"和"我要做什么(Action)",然后环境返回"结果(Observation)",如此循环。这个显式化的过程,就是 Agent 能"自主"的根源。

2.2 为什么选"薄封装"而不是重型框架

我试过dify的agent 编排,也看过hermes agent的文档,它们确实开箱即用。但问题在于,当你遇到一个诡异 bug——比如模型突然不调用工具了——在重型框架里你根本不知道是哪一层出了问题。是 prompt 模板的问题?是工具描述的问题?还是框架偷偷改了你的消息格式?

pi-agent这类薄封装的思路,是把所有中间状态都打印出来。我自己的实现里,每次循环都会把完整的 messages 数组 dump 到日志。这样当模型行为异常时,我能一眼看出是历史里混进了脏数据,还是工具返回的格式让它困惑。这种"可观测性"在学习阶段比任何自动化都值钱。

另一个理由是可控的 token 成本。重型框架往往有隐藏的 prompt 注入,你以为你只发了 500 token,实际框架给你塞了 2000 token 的系统提示。自己写的话,每一分钱花在哪你都清清楚楚。对于要长期跑agent项目的人来说,这是刚需。

2.3 最小 Agent 的组件清单

在动手前,我把需要的组件列成一张表,你可以对照着检查自己的实现有没有漏。

组件作用最小实现方式
LLM 客户端调用模型,拿到回复任意 OpenAI 兼容接口
消息历史维护对话上下文一个 list,存 role/content
工具注册表告诉模型有哪些工具可用字典,name 映射到函数
工具分发器根据模型输出执行对应函数解析 JSON,查表调用
循环控制器决定何时继续、何时停止while + 最大轮数限制
输出解析器从模型回复里提取工具调用正则或 JSON 解析

这六样东西,加起来不到 200 行代码就能跑起来。别被agent架构那些花里胡哨的图吓到,骨架就这么点东西。剩下的复杂度,全在"怎么让模型稳定地按格式输出"和"怎么处理各种异常"上。

3. 核心细节:ReAct 循环到底怎么转

3.1 消息格式的设计是成败关键

Agent 能不能跑起来,80% 取决于你的消息格式设计。我一开始图省事,直接让模型输出自然语言,然后用正则去抠"Action: xxx"。结果模型稍微换个说法,比如"我打算调用 xxx",正则就匹配不上了,整个循环直接卡死。

后来我改成强制 JSON 输出,让模型每次回复都是一个结构化的对象:

{ "thought": "我需要先查一下今天的天气", "action": "get_weather", "action_input": {"city": "北京"} }

这个格式的好处是解析极其稳定。但代价是模型有时候会"忘记"输出 JSON,尤其是上下文变长之后。我的应对策略是在系统提示里反复强调格式,并且在解析失败时,把错误信息作为一条新的 user 消息塞回去,让模型"看到自己犯的错"再重试。这个技巧我称之为"错误回灌",实测能把格式错误率从 30% 降到 5% 以下。

注意:错误回灌不能无限循环。我一般设置最多重试 2 次,超过就直接终止并返回错误,否则模型可能陷入"我错了-我改-我还是错"的死循环,白白烧 token。

3.2 工具描述怎么写模型才爱用

工具调用失败的另一个重灾区,是模型"不知道该用哪个工具"或者"用了但参数传错"。这几乎全是工具描述(tool description)没写好的锅。

我踩过的坑:给一个搜索工具写了描述"搜索互联网",结果模型经常在不需要搜索的时候也去搜。后来我把描述改成"当且仅当需要查询实时信息或你不确定的事实时使用,不要用于常识问题",误调用率立刻下降。工具描述不是写给人看的文档,是写给模型看的"使用说明书",必须包含三个要素:什么时候用、什么时候不用、参数怎么填。

参数描述同样重要。我有个计算器工具,参数是expression,一开始描述就写"数学表达式"。模型经常传"1+1="这种带等号的,导致eval报错。后来我改成"纯数学表达式,不要包含等号,例如 1+1 或 2*3",错误率骤降。这种细节,任何框架文档都不会告诉你,只能自己踩。

3.3 循环终止条件的三种设计

AgentLoop最容易出问题的地方是"什么时候停"。我见过太多新手写的 Agent,要么停不下来,要么该继续的时候停了。我的实现里用了三重保险:

第一重是模型主动终止。当模型认为任务完成,它输出一个特殊的 action,比如"action": "finish",循环就结束。这是最理想的情况。

第二重是最大轮数限制。我一般设 10 轮。超过就强制终止,返回当前最好的结果。这是防止死循环的硬保险,必须有。

第三重是无进展检测。如果连续两轮模型调用了同一个工具、传了同样的参数、拿到了同样的结果,说明它卡住了,直接终止。这个检测能省下大量 token。

if len(history) >= 2: last, prev = history[-1], history[-2] if last["action"] == prev["action"] and last["input"] == prev["input"]: return "检测到重复动作,终止循环"

这三重保险加起来,我的 Agent 基本没出现过失控烧钱的情况。尤其是第三重,很多人不知道要加,结果模型在某个死胡同里反复撞墙,一轮几毛钱,十轮就是几块钱。

4. 动手实现:从零到能跑通的完整流程

4.1 环境准备与依赖选择

我不建议一上来就上Rust或者复杂的llm框架。学习阶段,Python 是最优解,因为生态成熟、调试方便。你需要的依赖少得可怜:

pip install openai

就这一个。如果你要用本地模型,可以换成ollama的客户端,或者任何 OpenAI 兼容的接口。我实测下来,deepseek这类模型的工具调用能力已经足够跑通整个流程,成本还低。

关于模型选择,我的经验是:别用太小的模型做 Agent。7B 以下的模型在工具调用格式上极不稳定,你会把大量时间浪费在调 prompt 上,而不是理解 Agent 本身。建议至少用 14B 以上,或者直接用 API。这不是歧视小模型,是学习阶段要减少无关变量。

4.2 工具注册表的实现

工具注册表我设计成一个字典,key 是工具名,value 是一个包含函数和描述的字典:

TOOLS = {} def register_tool(name, description, func): TOOLS[name] = { "description": description, "function": func } def get_weather(city): # 实际项目里这里调真实 API return f"{city}今天晴,25度" register_tool( "get_weather", "查询指定城市的天气。仅当用户明确询问天气时使用。参数 city 为城市名,如'北京'。", get_weather )

这个结构的好处是,生成给模型的工具列表时,我可以直接从TOOLS里遍历,把 name 和 description 拼成 prompt。执行时,用 name 查表调用function。整个流程闭环,没有魔法。

4.3 系统提示的模板设计

系统提示(system prompt)是 Agent 的"人格设定",我把它拆成四块:角色定义、工具清单、输出格式、行为准则。

SYSTEM_PROMPT = """你是一个能使用工具的助手。 可用工具: {tool_list} 输出格式要求: 每次回复必须是如下 JSON,不要输出任何其他内容: {{ "thought": "你的思考过程", "action": "工具名,或 finish 表示任务完成", "action_input": {{"参数名": "参数值"}} }} 行为准则: 1. 需要外部信息时才调用工具,常识问题直接回答 2. 调用工具前先在 thought 里说明为什么 3. 任务完成时 action 用 finish,action_input 里放最终答案 4. 如果工具返回错误,分析原因后重试,不要重复同样的调用 """

这个模板我改了十几版才稳定。关键点是"不要输出任何其他内容"这句必须加,否则模型总喜欢在 JSON 前后加"好的,我来帮你"之类的废话,导致解析失败。

4.4 主循环的完整实现

把上面所有东西串起来,主循环长这样:

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 = call_llm(messages) messages.append({"role": "assistant", "content": response}) parsed = parse_response(response) if parsed is None: messages.append({ "role": "user", "content": "你的输出不是合法 JSON,请严格按照格式重新输出。" }) continue if parsed["action"] == "finish": return parsed["action_input"] tool_result = execute_tool(parsed["action"], parsed["action_input"]) messages.append({ "role": "user", "content": f"工具返回:{tool_result}" }) return "达到最大轮数,任务未完成"

这段代码不到 30 行,但包含了 Agent 的全部核心逻辑。你可以直接抄去改。我建议你第一次跑的时候,把每次循环的messages完整打印出来,亲眼看着上下文怎么一步步变长,模型怎么一步步逼近答案。这个过程比看任何架构图都有用。

4.5 一次完整的运行记录

我拿"北京今天天气怎么样,如果超过 20 度就提醒我带防晒"这个任务跑了一遍,记录如下:

第一轮,模型输出thought: 需要查北京天气,action: get_weather,action_input: {"city": "北京"}。工具返回"北京今天晴,25度"。

第二轮,模型输出thought: 25度超过20度,需要提醒防晒,action: finish,action_input: "北京今天晴,25度,超过20度,记得带防晒"。循环结束。

整个过程两轮,耗时约 3 秒,token 消耗不到 500。这就是一个最小 Agent 的完整生命周期。你会发现它一点都不神秘,就是"模型决定调工具-工具返回-模型再决定"的循环。

5. 常见问题与排查技巧实录

5.1 模型不输出 JSON 怎么办

这是最高频的问题。排查顺序是:先看系统提示里格式要求够不够强硬,再看是不是上下文里有"坏例子"污染了模型。我遇到过一次,历史里有一条工具返回的错误信息里带了自然语言,模型就学着用自然语言回复了。解决办法是把工具返回也包装成结构化格式。

如果 prompt 层面搞不定,就上"错误回灌":解析失败时,把"你的输出不合法"作为新消息塞回去。实测这个技巧对 90% 的格式问题有效。剩下 10%,换个模型就好了,别死磕。

5.2 工具调用参数错误的处理

参数错误分两类:一类是格式错(比如该传数字传了字符串),一类是语义错(比如城市名写错)。格式错可以在工具函数里做类型转换兜底,语义错则要让工具返回清晰的错误信息,引导模型修正。

我的经验是,工具函数永远不要抛异常,而是返回一个描述错误的字符串。因为异常会中断整个循环,而返回错误字符串能让模型"看到"错误并自我修正。这符合识的llm智能体自主容错控制的思路——容错不是靠外部捕获,而是靠把错误变成模型能理解的信息。

5.3 上下文爆炸的应对

跑长任务时,messages 会越来越长,最后超出模型窗口。我的应对是"滑动窗口 + 摘要"。保留最近 N 轮完整对话,更早的内容用一句话摘要替代。摘要可以让模型自己生成,也可以规则化生成(比如"已完成天气查询")。

另一个技巧是把工具返回的长结果截断。比如搜索返回 5000 字,我只保留前 500 字塞进上下文,剩下的存到外部,需要时再让模型用工具去取。这个思路在agent记忆的设计里很常见,本质是"上下文是稀缺资源,要省着用"。

5.4 常见问题速查表

现象可能原因解决方向
模型不调工具,直接回答工具描述不清或 prompt 没强调强化工具描述,加"必须用工具"约束
输出非 JSON格式要求不够强硬错误回灌 + 换更强模型
循环停不下来缺最大轮数限制加硬性轮数上限
重复调用同一工具模型卡住加无进展检测
参数传错参数描述不清补充参数示例和格式说明
上下文超长历史无节制增长滑动窗口 + 摘要 + 结果截断

这张表我贴在显示器边上,每次 Agent 出问题就对照着查,基本能覆盖 95% 的故障。

6. 从能跑到好用:几个进阶方向

6.1 多工具协同的编排

单工具跑通后,下一步是让 Agent 能组合多个工具。比如"查天气并推荐穿搭",需要先调天气工具,再调一个穿搭建议工具。这里的关键是让模型理解工具之间的依赖关系。我的做法是在工具描述里写明前置条件,比如穿搭工具的描述里加"需要先获取天气信息"。

更复杂的场景可以引入agent框架与编排的思路,但我的建议是:先把两个工具的串联跑稳,再考虑引入编排层。很多项目死在"一上来就搞复杂编排",结果基础的工具调用都不稳定。

6.2 记忆的持久化

现在的实现里,记忆只存在于单次会话。要让 Agent 跨会话记住东西,需要把 messages 存到数据库或文件。我一般用 SQLite,简单够用。存的时候按 session_id 分组,加载时只取最近 N 条。

这里有个坑:持久化的记忆里可能包含过期的工具返回(比如昨天的天气)。我的处理是给每条消息加时间戳,加载时把超过一定时间的工具返回标记为"可能过期",让模型自己判断要不要重新查。这个细节在presonl agent这类长期陪伴场景里特别重要。

6.3 安全边界的设计

Agent 能调工具就意味着它能"做事",这带来安全风险。我的原则是:所有有副作用的工具都要加确认机制。比如删除文件、发送消息这类操作,不能让它自动执行,必须返回一个"待确认"状态,由人确认后再继续。

另一个边界是工具的能力范围。搜索工具要限制域名,文件工具要限制目录,计算工具要限制执行时间。这些约束不是限制 Agent 的能力,而是防止它在出错时造成不可逆的损害。agent安全这个话题很大,但对小项目来说,把上面这几条做到就够用了。

6.4 性能优化的几个实测技巧

最后分享几个我实测有效的优化点。第一,把系统提示缓存起来,别每次循环都重新拼,能省一点是一点。第二,工具调用可以并行的地方就并行,比如同时查多个城市天气,用asyncio一把梭。第三,给模型设置合理的temperature,Agent 场景我一般用 0 到 0.3,太高了格式容易飘。

还有一个反直觉的经验:别过度优化 prompt。我见过有人为了省 token 把系统提示压到极短,结果模型行为变得极不稳定,反而要更多轮次才能完成任务,总 token 消耗更高。prompt 该长就长,清晰比简短重要。

我自己在实际操作中的体会是,写 Agent 最难的从来不是代码,而是"理解模型在想什么"。每次它行为异常,我都会把完整的 messages 打印出来,逐字读一遍,想象自己是模型,看到这些内容会怎么反应。十次有九次,问题就出在某条我以为"无关紧要"的消息上。这个调试习惯,比任何框架和工具都值钱。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进