Agent 实战:不调框架,手写 ReAct 循环,30 行讲清 Agent 是怎么“边想边干活”的
# Agent 实战不调框架手写 ReAct 循环30 行讲清 Agent 是怎么“边想边干活”的## 痛点Agent 听起来很玄其实就是个循环一提 Agent很多人的第一反应是 LangChain、AutoGPT、一堆框架概念。但把框架外壳剥掉Agent 的核心就一个循环名字叫 ReActThought想→ Action干→ Observation看结果→ 再想 → 再干直到给出最终答案。大模型本身不会“干活”它只会输出文本。ReAct 的 trick 在于规定模型输出的格式让程序能从文本里解析出“调哪个工具、传什么参数”程序真的去执行工具再把执行结果贴回给模型让它继续想。模型负责想程序负责干分工就这么简单。这篇文章不调任何框架用 100 行左右标准库代码把这个循环跑通。为了本地可复现演示里的“大模型”用确定性规则模拟——它按固定套路输出 Thought/Action换成真实大模型时只需要把这一个函数换成一次 API 调用循环、解析、工具调用的逻辑原样复用。## 步骤 1定义工具工具是 Agent 的“手”演示给 Agent 两只手查本地知识库search_docs和算数calculator。计算器这里有个安全细节用正则先过滤表达式只放行数字和加减乘除括号再用 eval 求值。eval 直接跑模型输出是绝对不行的——模型传一段 __import__(os).system(...) 过来你的服务器就没了。工具层必须自己设防这条红线和上一篇 MCP 文章里讲的读文件限制是一回事。pythonimport reDOCS {RAG: RAG 是检索增强生成, 先检索再回答, 能减少幻觉,MCP: MCP 是模型上下文协议, 让大模型调用外部工具,FAISS: FAISS 是 Meta 的向量检索库, 支持近似最近邻搜索,}def search_docs(query: str) - str:for key, text in DOCS.items():if key.lower() in query.lower():return textreturn 本地知识库没有相关内容def calculator(expr: str) - str:if not re.fullmatch(r[\d\s\-*/().], expr):return 错误: 只支持数字和 -*/()try:return str(eval(expr, {__builtins__: {}}))except Exception as e:return f计算错误: {e}TOOLS {search_docs: search_docs, calculator: calculator}## 步骤 2写循环核心就 20 行循环的逻辑按顺序走调“模型”拿输出 → 正则解析 Action → 是 Finish 就结束 → 查工具表执行 → 把 Observation 追加到 scratchpad就是模型的“工作记忆”→ 下一轮。max_steps 是保命参数模型一旦陷入“想→干→想→干”的死循环没有步数上限它能一直烧钱烧到天荒地老。pythondef mock_llm(question: str, scratchpad: str) - str:确定性模拟: 第一轮搜资料, 第二轮算数, 第三轮给答案。换成真实大模型时, 只需把这个函数换成一次 API 调用。if Action: not in scratchpad:if 多少 in question or 计算 in question:return Thought: 这个问题需要先查资料再算数\nAction: search_docs[RAG]return Thought: 先查本地知识库\nAction: search_docs[RAG]if calculator not in scratchpad and (多少 in question or 计算 in question):return Thought: 资料查到了, 现在算一下 12*85\nAction: calculator[12*85]return Thought: 已有足够信息, 可以回答\nAction: Finish[RAG 是检索增强生成, 先检索再回答, 能减少幻觉; 12*85101, 一共 101 条]def run(question: str, max_steps: int 5) - str:scratchpad for step in range(1, max_steps 1):out mock_llm(question, scratchpad)print(f--- 第{step}步 ---\n{out})m re.search(rAction: (\w)\[(.*?)\], out)if not m:return 解析失败: LLM 输出格式不对name, arg m.group(1), m.group(2)if name Finish:return f最终答案: {arg}if name not in TOOLS:return f未知工具: {name}obs TOOLS[name](arg)print(fObservation: {obs})scratchpad f\n{out}\nObservation: {obs}return 超出最大步数, 未完成if __name__ __main__:print(run(RAG 是什么? 如果有 12 组数据每组 8 条再加 5 条, 一共多少条?))注意解析那行正则Action: (\w)\[(.*?)\]。真实场景里模型输出格式经常跑偏——多一个空格、少一个括号、Action 名字拼错。生产级的解析要比演示宽容先规范化空白再做模糊匹配解析失败时要把错误信息作为 Observation 喂回去让模型“自己纠错”而不是直接崩掉。## 步骤 3实测循环长这样--- 第1步 ---Thought: 这个问题需要先查资料再算数Action: search_docs[RAG]Observation: RAG 是检索增强生成, 先检索再回答, 能减少幻觉--- 第2步 ---Thought: 资料查到了, 现在算一下 12*85Action: calculator[12*85]Observation: 101--- 第3步 ---Thought: 已有足够信息, 可以回答Action: Finish[RAG 是检索增强生成, 先检索再回答, 能减少幻觉; 12*85101, 一共 101 条]最终答案: RAG 是检索增强生成, 先检索再回答, 能减少幻觉; 12*85101, 一共 101 条三步走完第一步查资料第二步算数第三步收尾。模型演示里是规则模拟每一步的 Thought 都用到了上一步的 Observation——这就是“边想边干活”的全部含义。没有魔法只是一个把中间结果不断追加进上下文的循环。## 避坑4 个从演示到生产必须补的课1. **解析失败要喂回去**模型输出格式一跑偏就崩掉是最蠢的死法。把“解析失败”当成一条 Observation 告诉模型让它自己修正格式成功率会高很多。2. **max_steps 是保命参数**没有步数上限死循环能把你的 API 账单烧穿。演示设 5 步生产按任务复杂度设 10-30 步超了就停并返回中间结果。3. **工具必须自己设防**计算器过滤表达式、读文件限制目录——模型传进来的任何参数都不可信工具层是最后一道防线。4. **scratchpad 会越滚越长**多步任务的上下文会线性膨胀长任务要定期摘要压缩否则不是超 token 上限就是越想越慢。## 一句话总结Agent 模型出 Thought/Action 程序执行工具 结果贴回上下文这个循环转起来就是 ReAct。框架帮你省的是解析、重试、记忆管理这些脏活但循环本身100 行代码就讲清楚了。先把这个循环跑通再去看框架源码你会发现它们只是在这个循环外面包了更多的工程化。本文代码全部用 Python 3.12 标准库实测跑通无第三方依赖演示用规则模拟代替真实大模型调用循环逻辑与真实调用一致。