ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Agent 实战:不调框架,手写 ReAct 循环,30 行讲清 Agent 是怎么“边想边干活”的

Agent 实战:不调框架,手写 ReAct 循环,30 行讲清 Agent 是怎么“边想边干活”的

# 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 标准库实测跑通,无第三方依赖;演示用规则模拟代替真实大模型调用,循环逻辑与真实调用一致。)

RELATED READING

延伸阅读

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