摘要: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 推理收尾。
踩坑记录
真实实现过程中踩的坑,按痛的程度排序:
- 模型自己编 Observation。加 stop="Observation:" 之前,模型经常幻觉出工具结果,整个循环看起来在跑,实际全是模型自导自演。
- 输出格式不稳定。多引号、多空格、Action 和 Action Input 不分行都遇到过。正则解析后必须清洗,解析失败必须回传错误而不是崩掉。
- 死循环检测的粒度。只比工具名会误伤,必须按 (action, action_input) 组合比较。
- 工具描述含糊,模型就选错 。把 calculator 的描述从"计算工具"改成明确写出输入格式后,选错率明显下降。工具的 description 是提示词工程的一部分。
- eval 是代码执行漏洞 。模型输出直接进 eval 等于任意代码执行,字符白名单 + 清空 builtins 是底线。
总结
回看这不到 200 行代码,它就是所有 Agent 框架的骨架:
| 手写版 | 框架里对应的东西 |
|---|---|
| for 循环驱动 | LangGraph 的循环边 |
| messages 列表 | State.messages |
| 调模型决策 | agent 节点 |
| 执行工具函数 | tools 节点 |
| if "Final Answer:" 判断 | 条件边路由 |
区别只在于:框架把正则解析换成了原生 Function Calling (结构化 tool_calls,稳定可靠),把循环换成了图状态机(可观测、可持久化)。
所以下一步自然是:用原生 Function Calling 重构这版手工解析------这正是理解"框架替你解决了什么"的最短路径。