从零手写 ReAct Agent:不用任何框架,200 行代码跑通 Agent 核心循环

摘要:Agent ≠ 大模型。本文不依赖 LangChain / LangGraph 等任何框架,用纯 Python + 正则解析手写一个完整的 ReAct Agent:循环驱动、三重终止条件、防死循环、错误回传自我纠正,跑通 Agent 最底层的运行逻辑。

背景

现在入行 Agent 开发,大多数人第一个接触的就是 LangChain、LangGraph、Dify 这些框架,跟着教程把组件拼起来就能跑。但框架封装得太好也有代价:你只会调 API,讲不清原理。面试官一句"你手写过 Agent 吗?框架帮你解决了什么?"就能问倒一片。

ReAct 是所有 Agent 的底层原型。把手写版跑通一遍,再回头看框架,你会清楚每一个抽象对应你写过的哪段代码。这就是本文要做的事:只用 while 循环 + 字符串解析,从零实现一个能调用工具、能容错、不会死循环的 Agent

问题:Agent 到底是什么

先回答一个基础问题:Agent 和普通的大模型对话,区别在哪?

普通对话:用户问一句,模型答一句,结束。模型没有"动手"的能力。

Agent 的定义可以浓缩成一个公式:

ini 复制代码
Agent = 大模型 + 工具 + 循环

模型负责"思考"和"决策",工具负责"动手"(查时间、算数、搜网页),循环把两者串起来。ReAct 范式描述了这个循环里每一步长什么样:

yaml 复制代码
Thought:      我要做什么、为什么
Action:       调用哪个工具
Action Input: 传什么参数
Observation:  工具执行结果(由系统回填)
...(循环,直到信息足够)
Thought:      现在能回答了
Final Answer: 最终答案

注意一个关键点:Observation 永远由系统执行工具后回填,模型自己不能编。整个循环由我们的代码驱动,模型只负责每轮输出"下一步干什么"。## 实现步骤

1. 工具注册表:模型"看"描述,代码"跑"函数

工具就是普通 Python 函数,用注册表把"给模型看的描述"和"真正执行的函数"绑在一起:

python 复制代码
from datetime import datetime

def calculator(expression: str) -> str:
    """数学计算器:字符白名单 + 收紧 eval 环境,防止执行任意代码"""
    allowed = set("0123456789+-*/().% ")
    if not set(expression) <= allowed:
        raise ValueError("表达式包含不允许的字符,只支持数字和 + - * / ( ) %")
    return str(eval(expression, {"__builtins__": {}}, {}))

def get_current_time(action_input: str = "") -> str:
    """获取当前日期和时间(不需要输入参数)"""
    return datetime.now().strftime("%Y-%m-%d %H:%M:%S")

TOOLS = {
    "calculator": {
        "description": "数学计算器,执行加减乘除等运算,输入是数学表达式字符串,如 '2 + 3 * 4'",
        "func": calculator,
    },
    "get_current_time": {
        "description": "获取当前的日期和时间。无需输入参数,Action Input 留空即可",
        "func": get_current_time,
    },
}

两个设计要点:

  • 描述是写给模型看的。描述含糊,模型就会选错工具、传错参数------这是后面踩坑记录里的第一条。
  • calculator 必须防注入 。模型输出的表达式直接进 eval 等于把代码执行权交出去,所以先做字符白名单校验,再清空 builtins

2. 系统提示词:定义输出协议

不用框架时,"让模型按固定格式输出"全靠系统提示词约束。这个提示词就是模型和我们代码之间的协议:

python 复制代码
SYSTEM_PROMPT = """
你是一个按 ReAct 模式工作的助手,通过"思考→调用工具→观察结果"的循环解决问题。

可用工具:
{tools_desc}

严格按以下两种格式之一回复:

格式一(需要使用工具时):
Thought: <你打算做什么、为什么>
Action: <工具名,必须是可用工具之一>
Action Input: <传给工具的输入,一行>

格式二(信息足够,直接回答时):
Thought: <为什么现在能回答了>
Final Answer: <给用户的最终回答>

规则:
- 每次回复只包含一步
- Observation 由系统执行工具后提供,你绝不能自己编写
- 工具调用失败时,根据错误信息修正输入再试
""".format(
    tools_desc="\n".join(f"- {name}: {spec['description']}" for name, spec in TOOLS.items())
)

3. 核心循环:调模型,并在正确的时机"掐断"它

python 复制代码
MAX_STEPS = 8                # 步数上限
MAX_CONSECUTIVE_REPEATS = 3  # 同一动作连续重复次数上限

def run_agent(user_query: str) -> str:
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_query},
    ]
    last_action = None   # 上一次的 (action, action_input)
    repeat_count = 0

    for step in range(1, MAX_STEPS + 1):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            temperature=0,          # Agent 要稳定,不要创意
            stop=["Observation:"],  # 关键:见下文
        )
        llm_output = resp.choices[0].message.content

stop=["Observation:"] 是这里最容易被忽略的一行。模型有个坏习惯:让它输出 Action 之后,它经常顺手把 Observation 也编出来------幻觉一个工具结果。用停止符强制它在写下 Observation 之前停住,把执行权交还给我们的循环

temperature=0 同理:Agent 需要的是行为可复现,不是创意。### 4. 正则解析:从自由文本里抠出结构

python 复制代码
        # 终止条件①:模型给出最终答案
        if "Final Answer:" in llm_output:
            return llm_output.split("Final Answer:")[-1].strip()

        # 正则解析 Action
        action_match = re.search(r"Action: (.*?)(?=\n|$)", llm_output)
        input_match = re.search(r"Action Input: (.*?)(?=\n|$)", llm_output)

        # 格式错误回传:让模型自我纠正,而不是崩溃
        if not action_match:
            messages.append({"role": "assistant", "content": llm_output})
            messages.append({"role": "user", "content": (
                "Observation: 输出格式错误------没有识别到 Action 或 Final Answer。"
                "请严格使用 Thought/Action/Action Input 或 Thought/Final Answer 格式。"
            )})
            continue

        action = action_match.group(1).strip()
        # 参数清洗:去空格、去引号------模型输出格式不稳的经典坑
        action_input = (input_match.group(1).strip() if input_match else "").strip("").strip('')

注意 action_input 的清洗链:.strip() 去空格后再去引号。模型给参数包一层多余引号、前后带空格是家常便饭,不清洗就等着工具报错。

5. 防死循环:三重终止条件

一个只会循环的 Agent 迟早把自己转死,所以要有三层保险:

python 复制代码
        # 终止条件③:重复动作检测
        # 比较的是 (action, input) 组合,不是只比工具名------
        # web_search("天气") 和 web_search("股票") 是不同动作
        current_action = (action, action_input)
        if current_action == last_action:
            repeat_count += 1
        else:
            repeat_count = 1
            last_action = current_action
        if repeat_count >= MAX_CONSECUTIVE_REPEATS:
            return f"[Agent 强制终止] 动作 {action}({action_input}) 连续重复 {repeat_count} 次,疑似死循环"
  • 终止条件①:模型输出 Final Answer,正常结束;
  • 终止条件②:步数达到 MAX_STEPS(8 步),强制熔断;
  • 终止条件③:同一个 (action, action_input) 组合连续出现 3 次,判定死循环。

重复检测必须比较动作 + 参数的组合。只比工具名的话,搜索"天气"和搜索"股票"会被误判成重复。

6. 错误回传:错误信息是写给模型看的

python 复制代码
        if action not in TOOLS:
            observation = f"错误:不存在工具 {action}。可用工具:{", ".join(TOOLS)}"
        else:
            try:
                observation = TOOLS[action]["func"](action_input)
            except Exception as e:
                observation = (
                    f"工具执行失败:{type(e).__name__}: {e}。"
                    f"请检查 Action Input 是否符合该工具要求,修正后重试"
                )

        # 本轮输出和观察写回历史,进入下一轮
        messages.append({"role": "assistant", "content": llm_output})
        messages.append({"role": "user", "content": f"Observation: {observation}"})

这是手写 Agent 里最重要的工程思想:错误不是抛给用户看的异常,而是回传给模型的 Observation。错误信息要包含"错在哪 + 怎么改",让模型下一轮自我纠正。工具不存在就告诉它有哪些可用;参数非法就提示它检查格式。一个能从错误里恢复的 Agent,才谈得上可用。## 运行效果

拿一个需要两步推理的问题测试(先取时间,再做推算):

yaml 复制代码
用户问题:现在几点了?3 小时前我开始学习,我是几点开始的?

===== 第 1/8 步 =====
Thought: 需要先获取当前时间
Action: get_current_time
Action Input:

Observation: 2026-09-08 15:19:37

===== 第 2/8 步 =====
Thought: 已经知道当前时间,减去 3 小时即可
Final Answer: 你是 12:19 开始学习的。

循环真正转起来了:第一步调工具,第二步基于 Observation 推理收尾。

踩坑记录

真实实现过程中踩的坑,按痛的程度排序:

  1. 模型自己编 Observation。加 stop="Observation:" 之前,模型经常幻觉出工具结果,整个循环看起来在跑,实际全是模型自导自演。
  2. 输出格式不稳定。多引号、多空格、Action 和 Action Input 不分行都遇到过。正则解析后必须清洗,解析失败必须回传错误而不是崩掉。
  3. 死循环检测的粒度。只比工具名会误伤,必须按 (action, action_input) 组合比较。
  4. 工具描述含糊,模型就选错 。把 calculator 的描述从"计算工具"改成明确写出输入格式后,选错率明显下降。工具的 description 是提示词工程的一部分
  5. eval 是代码执行漏洞 。模型输出直接进 eval 等于任意代码执行,字符白名单 + 清空 builtins 是底线。

总结

回看这不到 200 行代码,它就是所有 Agent 框架的骨架:

手写版 框架里对应的东西
for 循环驱动 LangGraph 的循环边
messages 列表 State.messages
调模型决策 agent 节点
执行工具函数 tools 节点
if "Final Answer:" 判断 条件边路由

区别只在于:框架把正则解析换成了原生 Function Calling (结构化 tool_calls,稳定可靠),把循环换成了图状态机(可观测、可持久化)。

所以下一步自然是:用原生 Function Calling 重构这版手工解析------这正是理解"框架替你解决了什么"的最短路径。


相关推荐
明月_清风1 小时前
AI 时代,为什么架构师又开始谈"本体论"?
人工智能·后端·agent
阿里云大数据AI技术3 小时前
闲鱼「鱼卖卖」的长期记忆实践:用阿里云 Milvus 记住卖家的经营偏好
人工智能·agent
ch8563 小时前
别再让 RAG 翻车在「入库」这一步!1200 行保姆级离线入库全流程(PDF → 可检索)
agent
DreamLife☼4 小时前
Agent的典型应用场景深度解析
自动驾驶·agent·iot·工业·智慧交通·医疗健康
liulilittle4 小时前
为什么需要回程闲置保护?
ai·llm·prompt·agent·tools·subagent·opencode
七牛开发者5 小时前
HarnessDev:让 LLM 自己创建并迭代 Agent Harness
chatgpt·llm·agent
梦想不只是梦与想5 小时前
大模型系列(三):提示工程、RAG 与 Agent
大模型·agent·rag
知无涯者6 小时前
Agent 007 - Parallel Tool Use
agent
面向Google编程6 小时前
Kafka 成了 Agent 的「共享内存」,Flink 成了它的「大脑」
flink·kafka·agent