# Agent 实战:不调框架,手写 ReAct 循环,30 行讲清 Agent 是怎么“边想边干活”的
## 痛点:Agent 听起来很玄,其实就是个循环
一提 Agent,很多人的第一反应是 LangChain、AutoGPT、一堆框架概念。但把框架外壳剥掉,Agent 的核心就一个循环,名字叫 ReAct:Thought(想)→ Action(干)→ Observation(看结果)→ 再想 → 再干,直到给出最终答案。
大模型本身不会“干活”,它只会输出文本。ReAct 的 trick 在于:规定模型输出的格式,让程序能从文本里解析出“调哪个工具、传什么参数”,程序真的去执行工具,再把执行结果贴回给模型,让它继续想。模型负责想,程序负责干,分工就这么简单。
这篇文章不调任何框架,用 100 行左右标准库代码把这个循环跑通。为了本地可复现,演示里的“大模型”用确定性规则模拟——它按固定套路输出 Thought/Action;换成真实大模型时,只需要把这一个函数换成一次 API 调用,循环、解析、工具调用的逻辑原样复用。
## 步骤 1:定义工具,工具是 Agent 的“手”
演示给 Agent 两只手:查本地知识库(search_docs)和算数(calculator)。计算器这里有个安全细节:用正则先过滤表达式,只放行数字和加减乘除括号,再用 eval 求值。eval 直接跑模型输出是绝对不行的——模型传一段 __import__('os').system(...) 过来,你的服务器就没了。工具层必须自己设防,这条红线和上一篇 MCP 文章里讲的读文件限制是一回事。
```python
import re
DOCS = {
"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 text
return "本地知识库没有相关内容"
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 是保命参数:模型一旦陷入“想→干→想→干”的死循环,没有步数上限它能一直烧钱烧到天荒地老。
```python
def 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*8+5\nAction: calculator[12*8+5]"
return "Thought: 已有足够信息, 可以回答\nAction: Finish[RAG 是检索增强生成, 先检索再回答, 能减少幻觉; 12*8+5=101, 一共 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(r"Action: (\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(f"Observation: {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*8+5
Action: calculator[12*8+5]
Observation: 101
--- 第3步 ---
Thought: 已有足够信息, 可以回答
Action: Finish[RAG 是检索增强生成, 先检索再回答, 能减少幻觉; 12*8+5=101, 一共 101 条]
最终答案: RAG 是检索增强生成, 先检索再回答, 能减少幻觉; 12*8+5=101, 一共 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 标准库实测跑通,无第三方依赖;演示用规则模拟代替真实大模型调用,循环逻辑与真实调用一致。)