这是 AI 开发入门系列 的第 3 篇,用 ReAct 架构从零手写一个 LLM Agent。相关文章还有:
先简单回顾一下 Agent 是什么:LLM 外面包一层循环,每一步让它自己判断该调哪个工具、调完之后下一步做什么。更完整的架构全景可以看第一篇。
这篇文章不聊概念了,直接动手写。30 行左右,你能拿到一个能跑的 ReAct Agent。
最简 Agent:30 行跑通核心循环
先不引入任何框架。假设你有一个 LLM 调用入口 call_llm(messages),它能接收对话历史、返回一段文本。我们要让这段文本里包含 LLM 的决策——它是想直接回答用户,还是想调用某个工具。
import json
def run_agent(user_query, llm_call, tools):
messages = [{"role": "user", "content": user_query}]
max_turns = 10
for turn in range(max_turns):
response = llm_call(messages)
action = parse_action(response)
if action["type"] == "answer":
return action["content"]
if action["type"] == "tool_call":
tool = tools.get(action["tool_name"])
if not tool:
messages.append({"role": "user", "content": f"Error: tool '{action['tool_name']}' not found."})
continue
try:
result = tool(action["tool_args"])
messages.append({"role": "user", "content": f"Tool result: {result}"})
except Exception as e:
messages.append({"role": "user", "content": f"Tool error: {e}"})
return "Agent stopped: max turns reached."
parse_action 做的事情就是从 LLM 的返回里提取出它想执行的动作。最简单的做法是让 LLM 返回 JSON:
def parse_action(text):
try:
return json.loads(text)
except json.JSONDecodeError:
return {"type": "answer", "content": text}
上面的代码里有一个函数还没给出实现:call_llm(messages)。它就是把对话历史发给 LLM,拿到 LLM 的文本回复。最简单的方式是直接调 OpenAI 兼容的 API:
from openai import OpenAI
client = OpenAI() # 自动读 OPENAI_API_KEY 环境变量
def call_llm(messages):
resp = client.chat.completions.create(
model="gpt-4o",
messages=messages,
temperature=0 # Agent 场景建议用 0,让决策更稳定
)
return resp.choices[0].message.content
这个函数不是重点——你可以换成 Anthropic、本地 Ollama 模型、或者任何你熟悉的 LLM 入口。重点只有一件事:它接收 messages 数组,返回一段文本。 只要这个约定成立,循环就能跑。
然后你需要告诉 LLM 它可以调用哪些工具,以及工具的参数格式——这就是 tool calling 要做的事。不依赖任何框架的最直接做法,就是在 system prompt 里写清楚:
你是一个可以调用工具的 AI。请始终用 JSON 格式回复。
如果你需要调用工具,返回:
{"type": "tool_call", "tool_name": "calculator", "tool_args": {"expression": "3 + 5 * 2"}}
如果你可以直接回答,返回:
{"type": "answer", "content": "你的回答"}
这段 prompt 的思路很简单:不要求 LLM 自己理解"什么时候该用工具"这个抽象概念,而是直接给它一个输出格式,让它学会在这个格式里做选择。
下面是一个完整的对话轨迹——用户问天气,Agent 做了几轮决策,最终给出回答:
# 第 1 轮
→ LLM 收到: "北京明天热不热"
← LLM 返回: {"type": "tool_call", "tool_name": "get_weather", "tool_args": {"city": "北京"}}
→ 执行 get_weather("北京") → 返回: {"temp": 26, "condition": "晴"}
# 第 2 轮
→ 把工具结果喂回 LLM: "Tool result: {"temp": 26, "condition": "晴"}"
← LLM 返回: {"type": "answer", "content": "北京明天晴,气温 26 度,不算热。"}
→ 循环结束,返回给用户
这个轨迹看起来很简单,但这正是 Agent 的核心价值:LLM 自己判断了"什么时候需要调工具、调哪个工具、拿到结果后怎么组织答案"——这三步没有一个是你硬编码的逻辑,全由 LLM 在循环中自主决策。
工具调用的真正难点不是"调用",是"描述"
很多人第一次写 Agent,会把注意力全放在工具的实现上——写一个天气查询函数、写一个计算器、写一个网页搜索。这些函数本身不难写,难的是让 LLM 知道什么时候该用它。
一个问题:用户问"北京明天热不热",LLM 怎么知道要调用 get_weather 而不是 search_web,或者直接回答?
答案是工具描述。每个工具需要一段清晰定义输入输出和使用场景的元信息。这段元信息要回答三个问题:
- 这个工具是干什么的(一句话)
- 输入参数是什么(名字 + 类型 + 含义)
- 什么时候应该用、什么时候不该用(边界)
比如说工具定义(不是实现):
tool_definitions = {
"get_weather": {
"description": "查询指定城市的当前天气。只在用户明确询问天气时使用。",
"parameters": {
"city": {"type": "string", "description": "城市名称,如 'Beijing'"}
}
},
"search_web": {
"description": "搜索互联网获取实时信息。只在 LLM 训练数据中不包含该信息时使用。",
"parameters": {
"query": {"type": "string", "description": "搜索关键词"}
}
},
"calculator": {
"description": "执行数学计算。只在用户请求数值计算时使用。",
"parameters": {
"expression": {"type": "string", "description": "数学表达式,如 '3 + 5 * 2'"}
}
}
}
把这些定义注入 system prompt,LLM 就能自己判断"用户问天气 → 用 get_weather,参数是 city"。具体做法是把工具描述转成一段文本,拼在 system prompt 里作为第一条消息:
def build_system_prompt(tool_defs):
tool_desc = "\n".join(
f"- {name}: {info['description']} 参数: {info['parameters']}"
for name, info in tool_defs.items()
)
return f"""你是一个可以调用工具的 AI。请始终用 JSON 格式回复。
可用工具:
{tool_desc}
如果需要调用工具,返回:
{{"type": "tool_call", "tool_name": "工具名", "tool_args": {{...}}}}
如果可以直接回答,返回:
{{"type": "answer", "content": "你的回答"}}
"""
# 在每次调用 LLM 时,把 system prompt 放在 messages 的最前面
messages = [{"role": "system", "content": build_system_prompt(tool_definitions)}]
messages.append({"role": "user", "content": user_query})
关键是:工具描述的质量直接决定 LLM 选工具的准确率。 比如 get_weather 的描述写"查询天气"还是"查询指定城市的当前天气。只在用户明确询问天气时使用",LLM 的表现差异会很大——后者约束了使用边界,避免 LLM 在无关场景也试图调这个工具。
你可能会想:"这跟 tool calling API 有什么区别?"确实没有本质区别。OpenAI 的 tool calling(早期叫 function calling)和 Anthropic 的 tool use 就是把这个 process 标准化了——你传 JSON Schema,它们保证返回的 JSON 格式正确,而且帮你处理了 JSON 解析和参数校验。但如果你用的 LLM 不支持 tool calling(比如很多开源模型),自己写 prompt + 格式约束就是唯一的办法。而且这条路让你理解 Agent 是怎么工作的,不只是"调一个 API"。
记忆(Memory)不是存聊天记录那么简单
Agent 的记忆通常拆成三种:
对话上下文(Short-term Memory)。就是 messages 数组,包含了本轮对话里 LLM 的所有输入和它自己的输出、以及工具调用的返回值。这部分不需要额外设计——LLM 的 context window 天然就是短期记忆。
工作状态(Working Memory)。在循环中 Agent 可能需要记住一些中间结果,比如"刚才搜了天气,温度是 26 度,现在要把这个信息和别的数据做对比"。最简单的做法是让 LLM 自己在回复里记录状态作为下一轮的上下文,或者维护一个独立的 state 字典。
长期记忆(Long-term Memory)。当前对话结束之后,下次用户再回来,Agent 还能不能记得上次说了什么。这就不是 context window 能解决的了。最简单的方案是把对话历史存到数据库里,下次对话开始前把最近几轮信息注入 system prompt。但如果对话很长(几十轮),你不可能把所有历史都塞进 context window——这时就需要 RAG 来按需检索相关记忆。这是下一篇要展开讲的内容。
class AgentMemory:
def __init__(self):
self.conversations = []
self.scratchpad = {}
def add_message(self, role, content):
self.conversations.append({"role": role, "content": content})
def get_context(self, max_messages=50):
return self.conversations[-max_messages:]
def note(self, key, value):
self.scratchpad[key] = value
为什么 Agent 经常"停在半路"
做 Agent 的人都会碰到一个问题:LLM 不是每一次决策都对。
最常见的情况是 LLM 调了一个工具,拿到了结果,然后把结果原封不动抛给用户,没有进一步的处理。比如用户问"北京和上海哪个热",Agent 调了两次 get_weather 拿到了两个温度,然后直接把两个 JSON 丢回去。
这不是 bug,是 prompt 没告诉它要做一步"对比"。
解决方案也直接:在 system prompt 里加一条规则。
拿到工具返回的结果后,不要直接返回给用户。请先分析结果,提取关键信息,然后给出用户需要的答案。
同理,LLM 有时会反复调用同一个工具不停止(死循环),也需要在 prompt 里加约束:
如果某个工具已经连续被调用 3 次,请停止并给出你能给的最好回答,不要说"我再试一次"。
写 Agent 的过程中你会反复碰到一类问题:LLM 不知道该停下。它不会天然理解"我已经尽力了,再试也不会更好"。所以你需要在 prompt 和代码里给它设置边界。
什么时候不用自己写 Agent 循环
如果你已经理解了上面的核心循环,下一步可以关注几个重量级框架:LangChain 的 AgentExecutor、OpenAI 的 Assistants API、Anthropic 的 Tool Use。这些框架做的事情,本质上就是把上面那些循环、工具调度、错误重试、格式约束都产品化了。如果你只是做一个简单的个人工具,自己写循环就够了;但如果项目涉及复杂的状态管理、多 Agent 协作、流式输出控制,框架带来的收敛会很明显。
一篇还没讲完的事
这篇文章把 Agent 的核心循环、工具定义和记忆都跑通了。但有一个东西一直没碰:检索。
你会发现不管你用哪种记忆方案,最终都会撞上一个墙——context window 是有上限的。几十轮对话、几百页文档、几十个工具的完整描述,你不可能全部塞进 prompt 里。
这时候你需要的就是 RAG(检索增强生成):每次调 LLM 之前,先从海量信息里搜出和当前问题最相关的几条,只把这几条放进 context。
下一篇 RAG 是怎么工作的 会把这件事从零讲清楚:文档怎么切块、向量怎么存、怎么搜、怎么排序。读完你会能把 Agent 循环里"手动注入记忆"的那一步,替换成"先检索 → 再注入"。