04-ReAct 文本协议对照版:用正则解析实现 Agent 心跳

不借助 function calling,用纯文本协议(Thought/Action/Observation)手写 ReAct 循环。与原生 tool_calls 版对照阅读,彻底看清工具调用协议这一层的本质,以及 agent 心跳是如何用一个 while 循环转起来的。

2026/6/15
8 分钟阅读
目录

直接看代码

"""练习 3(对照版·经典文本协议):从零手写 ReAct 循环 —— Agent 的“心跳”

【这是对照版本】配套文件 04-react-from-scratch.py 是对齐官方仓库的“原生
tool_calls”写法;本文件保留的是 ReAct 论文最原始的“文本协议”写法。
两者都是 from-scratch 的手动 while 循环,区别只在“工具调用怎么传”:
    · 本文件:模型输出 Thought/Action 文本,我们自己用正则解析;
    · from-scratch 版:用 API 原生的 tool_calls / role=tool / finish_reason。
建议两个一起读,能彻底看清“工具调用协议”这层到底是什么。

ReAct = Reasoning(推理)+ Acting(行动)。它的循环是:
    Thought(想)→ Action(做)→ Observation(看结果)→ Thought(再想)→ …
    直到模型认为任务完成,输出 Final Answer。

【为什么不用 function-calling,而是自己解析文本?】
    练习 1/2/6 用的是模型的“原生工具调用”,循环其实被 SDK 藏起来了。
    本练习刻意退回到最朴素的方式:让模型用纯文本按固定格式回复,
    我们自己解析、自己执行、自己把结果拼回去——这样你能亲眼看到
    “循环”是我们用一个 while 转起来的,agent 的心跳就是这段代码。

整个循环(run 函数)核心约 50 行,不依赖任何 agent 框架。
"""

import json
import os
import re

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()

client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url=os.environ["DEEPSEEK_BASE_URL"],
)
MODEL = os.environ["MODEL"]


# ── 工具:我们的“手”。注意这里没有用任何 schema,纯 Python 字典登记 ──────
def get_weather(city: str) -> str:
    fake = {"北京": 26, "上海": 24, "广州": 30}
    temp = fake.get(city)
    return f"{city}当前气温 {temp} 度" if temp is not None else f"{city}气温未知"


def calculator(expression: str) -> str:
    # 只允许数字和基本运算符,避免 eval 执行危险代码(安全意识,见本阶段“致命三角”警示)
    if not re.fullmatch(r"[\d\s+\-*/().]+", expression):
        return "错误:表达式含非法字符"
    return str(eval(expression))


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

# ── 系统提示:教模型按固定格式说话,这样我们才能解析 ──────────────────────
# 这份 prompt 就是 ReAct 的“协议”:规定模型要么给 Action,要么给 Final Answer。
SYSTEM_PROMPT = """你是一个会使用工具的助手。请严格按以下格式逐步思考:

Thought: 你的推理
Action: 要调用的工具名(只能是 get_weather 或 calculator)
Action Input: 传给工具的参数(JSON,如 {"city": "北京"} 或 {"expression": "30-26"})

我会把工具执行结果作为 Observation 返回给你。
你可以重复 Thought/Action/Action Input 多轮。
当你已经能回答用户时,改为输出:

Thought: 你的推理
Final Answer: 给用户的最终回答

一次只输出一个 Action,不要自己编造 Observation。"""


def run(question: str, max_steps: int = 6) -> str:
    """ReAct 主循环。这就是 agent 的心跳。"""
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": question},
    ]

    # max_steps 是“熔断”:万一模型陷入循环,也不会无限调用下去(重的要安全护栏)
    for step in range(1, max_steps + 1):
        # ① 想 + 决定做什么:让模型产出 Thought/Action 或 Final Answer
        reply = client.chat.completions.create(
            model=MODEL, messages=messages, temperature=0
        ).choices[0].message.content
        print(f"\n──── 第 {step} 步 ────\n{reply}")

        # ② 终止条件:模型给出 Final Answer,循环结束(agent 自己决定何时停)
        if "Final Answer:" in reply:
            return reply.split("Final Answer:", 1)[1].strip()

        # ③ 解析模型选的工具和参数
        action = re.search(r"Action:\s*(\w+)", reply)
        action_input = re.search(r"Action Input:\s*(\{.*\})", reply, re.DOTALL)
        if not action or not action_input:
            # 模型没按格式来,把要求重申一遍,让它纠正(轻量纠错)
            messages.append({"role": "assistant", "content": reply})
            messages.append({"role": "user", "content": "请严格按 Action / Action Input 格式输出。"})
            continue

        tool_name = action.group(1)
        args = json.loads(action_input.group(1))

        # ④ 做:真正执行的是我们的代码,不是模型
        if tool_name in TOOLS:
            observation = TOOLS[tool_name](**args)
        else:
            observation = f"错误:没有名为 {tool_name} 的工具"
        print(f"Observation: {observation}")

        # ⑤ 看结果 → 拼回对话,进入下一轮“想”。循环就这样转起来了。
        messages.append({"role": "assistant", "content": reply})
        messages.append({"role": "user", "content": f"Observation: {observation}"})

    return "(达到最大步数仍未得出最终答案)"


if __name__ == "__main__":
    # 这个任务需要循环转好几圈:查北京气温 → 查广州气温 → 相减 → 给答案
    task = "北京和广州现在的气温相差多少度?"
    print(f"【任务】{task}")
    answer = run(task)
    print(f"\n【最终答案】{answer}")