从零手写 AI Agent:ReAct 智能体实战

大模型只会“说话”,而 AI Agent(智能体) 才会“办事”。本文不套用任何 Agent 框架,用纯 Python 从零手写一个 ReAct 智能体:它以“推理(Reason)→ 行动(Act)→ 观察(Observe)”的循环自主调用工具、积累记忆,直到给出最终答案。读完你就能理解 LangChain、AutoGPT 这类产品的底层骨架,并知道生产环境该补哪些保险。

一、ReAct 到底是什么

ReAct 是 “Reasoning + Acting” 的缩写。传统提示词让模型一次生成完整答案,遇到“北京现在几度”这类需要实时数据的问题就只能瞎编。ReAct 智能体 把任务拆成循环:模型先“想”下一步该做什么(Thought),再“做”一个动作(调用工具),拿到“观察”结果后继续想,直到能回答为止。这种“想一步、做一步、看一步”的范式,是当下绝大多数自主 Agent 的通用底座。

它和 Function Calling 实战 里讲的“工具调用”是同一套能力,区别在于:Function Calling 强调“模型如何正确地调用单个函数”,而 ReAct 强调“多步决策与状态流转”的工程编排。理解了 ReAct,你就能在 Spring AI 或 Dify 里更清楚地知道框架替你做了什么。

二、为什么要从零手写,而不是直接用框架

框架(LangChain、CrewAI、AutoGen)能让你 10 行代码跑起来,但也会把核心循环藏进黑盒:你不知道它什么时候停、为什么选这个工具、记忆怎么攒的。手写一遍的好处是——每一个 Token 的决策路径你都看得见,排障、加护栏、控成本都更从容。下面就是一个“最小可运行”的 Agent,不依赖任何第三方 Agent 库,只需一个 OpenAI 兼容的对话接口(本地 Ollama、vLLM 或云端都行)。

三、核心循环:Think → Act → Observe

3.1 先定义工具:函数 + JSON Schema

Agent 的“能力”来自工具。每个工具是一个普通 Python 函数,外加一份给模型看的 JSON Schema 描述。模型不会直接跑代码,它只返回“我想调用哪个工具、参数是什么”,由我们的循环负责真正执行。

import math, os, requests

# 工具 1:简单计算器
def calc(expression: str) -> str:
    # 仅做四则运算,避免 eval 的安全风险
    allowed = set("0123456789+-*/(). ")
    if not set(expression) <= allowed:
        return "error: 非法字符"
    try:
        return str(eval(expression, {"__builtins__": {}}, {}))
    except Exception as e:
        return f"error: {e}"

# 工具 2:模拟“查天气”(真实场景换成天气 API)
def get_weather(city: str) -> str:
    fake = {"北京": 21, "上海": 25, "深圳": 28}
    return f"{city} 当前气温 {fake.get(city, '未知')} 摄氏度"

# 工具注册表:name -> (函数, JSON Schema)
TOOLS = {
    "calc": (
        calc,
        {
            "type": "function",
            "function": {
                "name": "calc",
                "description": "对数学表达式求值,支持 + - * / 和括号",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "expression": {"type": "string", "description": "如 (1+2)*3"}
                    },
                    "required": ["expression"],
                },
            },
        },
    ),
    "get_weather": (
        get_weather,
        {
            "type": "function",
            "function": {
                "name": "get_weather",
                "description": "查询城市当前气温",
                "parameters": {
                    "type": "object",
                    "properties": {"city": {"type": "string"}},
                    "required": ["city"],
                },
            },
        },
    ),
}

3.2 记忆(Memory)设计

Agent 的“智能”很大程度来自记忆:它必须记得自己前面想过什么、工具返回了什么,否则每一步都是“失忆”的。记忆 本质上就是一段不断追加的对话历史,既喂给模型当上下文,也保留工具结果。

class Memory:
    def __init__(self):
        self.turns = []  # 每条记录: {"role":.., "content":..}

    def add(self, role, content):
        self.turns.append({"role": role, "content": content})

    def history(self):
        return self.turns

mem = Memory()
mem.add("system", "你是一个会调用工具的助手,遇到不会的问题就用工具查。")
mem.add("user", "北京现在多少度?如果比上海热,告诉我温差是多少。")

四、动手实现 ReAct 循环

下面是整个 Agent 的心脏。我们用 OpenAI 兼容客户端发起对话,并在返回里检查是否包含 tool_calls;有就执行工具、把结果写回记忆,没有就说明模型已给出最终答案,循环结束。

from openai import OpenAI

client = OpenAI(
    api_key=os.getenv("OPENAI_API_KEY", "sk-xxx"),
    base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)

MAX_STEPS = 6  # 防止无限循环

def run_agent(question: str) -> str:
    mem.add("user", question)
    tool_schemas = [v[1] for v in TOOLS.values()]

    for step in range(MAX_STEPS):
        resp = client.chat.completions.create(
            model=os.getenv("MODEL", "gpt-4o-mini"),
            messages=mem.history(),
            tools=tool_schemas,
            tool_choice="auto",
        )
        msg = resp.choices[0].message
        mem.add("assistant", msg.content or "")

        # 没有工具调用 = 模型认为可以收尾了
        if not msg.tool_calls:
            return msg.content

        # 逐个执行模型请求的工具
        for call in msg.tool_calls:
            fn_name = call.function.name
            args = json.loads(call.function.arguments)
            fn, _ = TOOLS[fn_name]
            observation = fn(**args)          # 真正执行
            mem.add("tool", f"[{fn_name}] -> {observation}")
    return "超过最大步数,仍未给出答案。"

五、让 Agent 真正“会思考”的提示词

ReAct 的效果高度依赖提示词。一个有效写法是要求模型按固定格式输出,让“思考”和“动作”分离,方便我们解析。下面这段 system 提示让模型先写 Thought 再决定 Action:

SYSTEM_PROMPT = """你是一个 ReAct 智能体。每步严格按以下格式输出:
Thought: 你现在的推理
Action: 要调用的工具名(calc / get_weather)
Action Input: 工具参数(JSON)
当你能回答时,输出:
Final Answer: 最终回答

你只能使用提供的工具,不要编造结果。"""

六、跑一次完整对话

把上面的零件拼起来,问“北京比上海热多少”时,Agent 的记忆会这样生长:

user      : 北京现在多少度?比上海热多少?
assistant : Thought: 需要先查两座城市的气温。
            Action: get_weather  Input: {"city":"北京"}
tool      : [get_weather] -> 北京 当前气温 21 摄氏度
assistant : Thought: 再查上海。
            Action: get_weather  Input: {"city":"上海"}
tool      : [get_weather] -> 上海 当前气温 25 摄氏度
assistant : Thought: 上海 25 度比北京 21 度热,应纠正用户。
Final Answer: 实际上上海(25℃)比北京(21℃)热 4℃,北京并不更热。

可以看到,Agent 自己纠正了问题里的错误前提——这正是“推理+行动”闭环比单次问答强的地方。想让模型能力更扎实,配合 Embedding 选型 做检索、用 RAG 评估 量化效果,是常见的进阶路线。

七、生产环境必须加的 4 道保险

Demo 跑通只是开始。把 Agent 放线上,下面这些风险必须提前兜住,否则轻则烧钱、重则“跑飞”调用了不该调的接口。

风险表现对策
无限循环模型反复调用工具不收尾设 MAX_STEPS,超限强制返回
工具越权调用了未注册/危险函数白名单校验 fn_name 必须在 TOOLS
提示词注入用户输入诱导泄露系统指令工具结果做沙箱隔离与长度裁剪
成本失控长对话 token 暴涨记忆摘要化、对历史做窗口截断

八、它和 MCP、Dify 是什么关系

手写 Agent 让你吃透原理,但真实项目里往往会“借力”:用 MCP 协议 把外部工具标准化成可插拔服务,让 Agent 动态发现能力;用 Dify 这类平台把编排、记忆、评估做成可视化流水线。底层循环仍是本文讲的 ReAct——区别只是工具从“本地函数”变成了“远程 MCP Server”,记忆从“列表”变成了“向量库 + 摘要”。

九、把示例工具换成真实 API

示例里的 get_weather 是写死的。真实项目里工具就是普通的 HTTP 接口——只要遵循“函数签名 + JSON Schema”的契约,上面那段 ReAct 循环一行都不用改。下面把天气换成一次真实调用(端点与 Key 换成你自己的即可):

def get_weather(city: str) -> str:
    try:
        r = requests.get(
            "https://api.example.com/weather",
            params={"city": city, "token": os.getenv("WX_TOKEN")},
            timeout=5,
        )
        r.raise_for_status()
        return f"{city} 当前气温 {r.json()['temp']} 摄氏度"
    except Exception as e:
        return f"查询失败: {e}"

TOOLS["get_weather"] = (get_weather, TOOLS["get_weather"][1])

关键在于工具函数保持“纯输入输出”:所有网络细节、异常、重试都在函数内部消化,对外只返回一段干净的字符串。这样模型拿到的永远是可读结果,不会因为 JSON 格式错乱而“理解崩坏”。这也是为什么在 Function Calling 实战 里我们反复强调“工具描述要准确、参数要可校验”。

十、记忆太长怎么办:摘要化与窗口截断

多轮工具调用后,messages 会迅速膨胀,既烧 token 又可能撑爆上下文窗口。常见做法是给 Memory 加一个“压缩”开关:当估算 token 超过阈值,就把最早的若干轮交给模型 summarise 成一段摘要,再替换原内容。

def estimate_tokens(turns):
    return sum(len(t["content"]) // 2 for t in turns)  # 中文约 2 字/token

def compress_memory(mem, client, model, threshold=3000):
    if estimate_tokens(mem.turns) <= threshold:
        return
    old, mem.turns = mem.turns[:-4], []
    prompt = "用一句中文概括以下对话要点:\n" + "\n".join(
        f"{t['role']}: {t['content']}" for t in old
    )
    summary = client.chat.completions.create(
        model=model, messages=[{"role": "user", "content": prompt}]
    ).choices[0].message.content
    mem.turns = [{"role": "system", "content": "历史摘要: " + summary}] + old[-4:]

另一种更轻量的策略是滑动窗口:只保留最近 N 轮,外加一条持久化的“任务目标”摘要。对大多数客服、问答类 Agent 已经够用,且几乎零额外成本。当对话涉及长期偏好时,再把摘要定期写入外部存储(如 向量库)做长期记忆。

十一、让 Agent 更稳:重试、超时与流式

工具调用本质是 I/O,必然会失败。给每个工具包一层超时与重试,能显著降低“跑飞”概率;同时务必限制单工具最大耗时,避免一个慢接口拖垮整轮循环。

import functools, requests

def with_timeout(seconds=5):
    def deco(fn):
        @functools.wraps(fn)
        def wrap(*a, **k):
            return requests.get  # 占位:实际用 concurrent 或 signal 设超时
        return wrap
    return deco

# 更稳妥:直接在请求层设 timeout,并在循环里捕获后重试一次
def safe_call(fn, *a, retries=1, **k):
    for i in range(retries + 1):
        try:
            return fn(*a, **k)
        except Exception as e:
            if i == retries:
                return f"工具执行失败: {e}"
            time.sleep(1)

如果面向终端用户,建议把 Thought / Action 以流式方式逐步回传,让用户看到 Agent “正在思考”,体验远好于等几秒后一次性吐字。配合支持流式的对话接口,每个中间步骤都是一个 SSE 事件,前端按需渲染即可。

十二、小结

一个能干活儿的 AI Agent 并不神秘:它 = 一个会 Function Calling 的模型 + 一份工具注册表 + 一段不断追加的 记忆 + 一个“想—做—看”的循环。本文用不到 80 行 Python 跑通了核心,你接下来可以加上流式输出、并行工具调用、错误重试,甚至把工具换成真实 API。理解了 ReAct,再去读任何 Agent 框架,都会变成“原来它只是把我手写的循环封装了一层”。

上一篇 K8s 节点 NotReady 排障:kubelet 与网络故障定位
下一篇 Docker Compose 实战:用 docker-compose.yml 编排多容器服务
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]