手写一个最小 ReAct Agent——50 行代码跑起来

这是「Agent 工程化」系列的第二篇。上一篇我们讲了概念:Agent 的核心就是"一个循环"。这篇我们不玩虚的,直接把这个循环写出来——只用一个 Python 文件、一个 OpenAI 兼容接口、零框架,50 行代码让 Agent 真正跑起来。


上一篇结尾的承诺,现在兑现

上一篇我们用一句话定义了 Agent:让 LLM 在一个循环里反复思考、动手、看结果。

口说无凭,这篇我们来真的。目标很简单——写一个能完成下面这个任务的 Agent:

“帮我看看北京明天天气,下雨的话提醒我带伞。”

这个任务看起来一句话,其实要拆成好几步:

  1. 先查北京的城市代码(工具一)
  2. 用城市代码去查明天的天气(工具二)
  3. 看结果里有没有"雨"字,有就提醒带伞,没有就说不用带

它必须"先查城市、再查天气、再判断",多步配合才能完成。正适合用来演示 Agent 的循环。


ReAct 到底是个啥

ReAct 是 Reasoning(推理)+ Acting(行动)的合体:让模型想一步、做一步、看结果,交替进行,直到任务完成。

它的全貌就是上一篇画过的那个循环,这次我们用 mermaid 画(掘金支持):

不能

能

拿到用户的问题

思考 Reason: 下一步该做什么

行动 Act: 调用哪个工具

观察 Observe: 工具返回了什么

能回答用户了吗

输出最终回答

注意一个关键点:循环的每一步都是"模型输出文字 → 我们执行工具 → 结果喂回去"。模型自己不会调工具,它只负责"说话",真正动手的是我们的代码。这个分工一定要搞清楚。


完整代码:就 50 行

# 依赖:pip install openai,并设置 OPENAI_API_KEY(任意 OpenAI 兼容接口的模型均可)
import json
import re
from openai import OpenAI

client = OpenAI()

# ---------- 1. 两个"工具":就是最普通的函数 ----------
def get_city_code(city: str) -> str:
    """城市名 -> 城市代码"""
    table = {"北京": "010", "上海": "021", "广州": "020"}
    return json.dumps({"city_code": table.get(city, "未知城市")})

def get_weather(city_code: str, date: str) -> str:
    """城市代码 + 日期 -> 天气(模拟数据)"""
    mock = {"010": {"明天": "小雨,18~25℃"}, "021": {"明天": "晴,26~33℃"}}
    return json.dumps({"weather": mock.get(city_code, {}).get(date, "查不到")})

TOOLS = {"get_city_code": get_city_code, "get_weather": get_weather}

# ---------- 2. ReAct 循环:核心就一个 while ----------
def run_agent(user_input: str, max_steps: int = 5) -> str:
    system = (
        "你是天气助手。想查天气时,先用 get_city_code 把城市换成代码,"
        "再用 get_weather 查天气。需要调用工具时,只输出两行:\n"
        "Action: 工具名\nAction Input: JSON参数\n"
        "拿到结果、能回答用户时,只输出:Final: 最终回答"
    )
    messages = [{"role": "user", "content": user_input}]

    for step in range(max_steps):
        resp = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "system", "content": system}] + messages,
            temperature=0,
        )
        text = resp.choices[0].message.content
        print(f"--- 第 {step + 1} 轮 ---\n{text}\n")

        # 终止条件 1:模型认为任务完成了
        if text.startswith("Final:"):
            return text[len("Final:"):].strip()

        # 解析 "Action: xxx \n Action Input: {...}"
        m = re.search(r"Action:\s*(\w+)\s*Action Input:\s*(\{.*?\})", text, re.S)
        if not m or m.group(1) not in TOOLS:
            return "模型输出格式不对,循环终止(需要兜底)"
        args = json.loads(m.group(2))
        result = TOOLS[m.group(1)](**args)

        # 把"行动 + 观察"放回对话,进入下一轮
        messages += [
            {"role": "assistant", "content": text},
            {"role": "user", "content": f"Observation: {result}"},
        ]

    # 终止条件 2:步数用完了(需要兜底)
    return "超过最大步数,任务未完成"

if __name__ == "__main__":
    print(run_agent("帮我看看北京明天天气,下雨的话提醒我带伞"))

运行前提就三件事:pip install openai、设好 OPENAI_API_KEY、模型名换成你手上有的任意 OpenAI 兼容模型。不依赖 LangChain、不依赖任何 Agent 框架。


逐块拆解

1. 两个"工具"——就是最普通的函数

def get_city_code(city: str) -> str: ...
def get_weather(city_code: str, date: str) -> str: ...

注意,它俩就是两个普通函数,跟 Agent 一点关系都没有。第一篇说过:工具不神秘,就是函数或 API。Agent 做的只是"让模型来决定什么时候、用什么参数去调用它们"。数据是我 mock 的,你把 mock 换成真的天气 API,它就是生产代码。

TOOLS 是一个注册表:把工具名和函数对应起来。后面模型输出"我要调 get_weather",我们查这张表就能找到函数。

2. ReAct 循环——核心就一个 for

for step in range(max_steps):
    resp = client.chat.completions.create(...)   # 让模型说话
    text = resp.choices[0].message.content       # 模型说了什么
    if text.startswith("Final:"):                # 说完了吗?
        return ...
    # 否则:解析 Action,调工具,把结果喂回去
    messages += [
        {"role": "assistant", "content": text},
        {"role": "user", "content": f"Observation: {result}"},
    ]

代码里是 for,逻辑上就是第一篇那个 while 任务未完成。每一轮做四件事:

  1. 把完整对话历史发给模型(包括之前所有"行动"和"观察")
  2. 模型输出文字:要么是想调工具(Action),要么是最终回答(Final)
  3. 我们解析 Action,自己动手执行工具
  4. 把工具结果以 Observation: xxx 的形式放回对话,让模型看到

第 4 步是关键:模型必须能看到上次行动的结果,才能决定下一步。这就是"闭环"。

3. 终止条件——循环必须能停下来

一个 Agent 最怕"停不下来"。代码里有两个终止条件:

终止方式代码位置含义
模型主动结束text.startswith("Final:")模型认为可以回答用户了,返回最终答案
步数用尽for step in range(max_steps)兜底:就算模型一直想调工具,最多 5 轮强制结束

第二个条件在真实项目里尤其重要——永远假设模型会失控。后面第 9 篇我们会讲完整的护栏体系(预算、白名单、重试),这里先记住:循环一定要有上限。


跑起来看看:完整的一次对话

假设模型很配合,这段代码运行时的真实输出大概是这样的:

--- 第 1 轮 ---
Action: get_city_code
Action Input: {"city": "北京"}

--- 第 2 轮 ---
Action: get_weather
Action Input: {"city_code": "010", "date": "明天"}

--- 第 3 轮 ---
Final: 北京明天有小雨,记得带伞。

画成表格就是上一篇见过的 thought / action / observation:

轮次模型想干什么它输出的指令我们执行的观察结果
1得先知道北京的城市代码Action: get_city_code {"city": "北京"}调 get_city_code(“北京”){"city_code": "010"}
2用代码查明天天气Action: get_weather {"city_code": "010", "date": "明天"}调 get_weather(“010”, “明天”){"weather": "小雨,18~25℃"}
3看到"小雨",可以回答了Final: 北京明天有小雨,记得带伞。——

三行输出,三次循环,一个任务完成。这就是 Agent 的全部本质:模型负责想和说,代码负责动手,循环负责把两者串起来。


生产级考虑:循环怎么停、怎么兜底

Demo 能跑,但离"能用"还差三个必须处理的问题:

① 模型不按格式输出怎么办?

ReAct 靠"模型输出 Action: 工具名 这种固定格式"来工作,但 LLM 是概率模型,偶尔会多写几个字、换个格式,比如:

我打算调用 get_weather 工具来查询明天的天气情况,参数是城市代码010和日期明天。

上面的正则 Action:\s*(\w+) 匹配不到,程序直接返回"格式不对"。真实项目里要让模型输出严格 JSON({"action": "...", "args": {...}}),或者用第 3 篇要讲的 Function Calling,让模型直接输出结构化调用,从根上规避格式问题。

② 工具报错了怎么办?

如果 get_weather 真的去请求第三方 API 然后超时了,json.loads 或函数内部抛异常,整个循环就崩了。生产里要把每次工具调用包上 try/except,把错误信息也当 Observation 喂回模型,让它自己决定重试还是放弃:

try:
    result = TOOLS[m.group(1)](**args)
except Exception as e:
    result = f"调用失败:{e}"

③ 步数上限设多少?

设 3,复杂的任务可能不够;设 100,模型可能真的会"玩"满 100 轮烧掉你的钱。上线时一般先设一个偏大的上限 + 预算监控,用真实流量数据再调。我们第 9 篇会专门讲。


踩坑:工具返回一大坨,上下文被撑爆

这个坑很快就能踩到。假设 get_weather 不返回 {"weather": "小雨"},而是返回:

{"status": "ok", "data": {"temp_max": 25, "temp_min": 18, "humidity": 0.87,
 "wind": {"direction": "北风", "level": 3, "gust": 6}, "aqi": 32,
 "precipitation": {"type": "雨", "probability": 0.92, "amount_mm": 8.5},
 "forecast_3days": [...一堆数组...], ...几百个字段...}

每次工具返回都往对话里塞,塞三轮上下文就爆了,模型开始"失忆",忘了一开始的任务是什么。这是 Agent 落地的第一天敌——上下文窗口是有限的。

怎么办?这就是整个系列的第四篇:上下文管理。滑动窗口、摘要压缩、截断策略,把"重要信息留住、废话删掉"。这篇先记住症状,下一篇系列文见。


下篇预告

这篇我们让模型输出"Action + Action Input"两行文本来驱动工具调用,简单但脆弱——格式稍微不对就挂了。

下一篇讲更正规的做法:Function Calling 原理与工具设计。让模型直接输出结构化的工具调用(JSON 对象),顺便聊聊怎么把工具描述写清楚——描述写得好不好,直接决定模型会不会选错工具。

本系列路线(从 0 到 1): Agent 是什么 → 手写最小 ReAct → Function Calling 与工具设计 → 上下文管理 → Memory 记忆系统 → RAG 知识库 → Skill 自学习 → 编排模式与多 Agent → 给 Agent 装护栏 → 生产部署与可观测 → 评测与回归

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐