"""练习 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}")