用 200 行 Python 理解 Agent 的本质

6 阅读6分钟

Cursor、Claude Code、Manus 都是 Agent。那 Agent 到底是什么?最好的理解方式是:自己写一个。这篇文章用 ReAct 模式,从零构建一个能写代码、读文件、执行命令的最小 Agent。


Agent 的定义

Agent 的本质可以用一个公式表达:

Agent = LLM + 工具 + 循环执行
  • LLM:提供推理和决策能力
  • 工具:突破 LLM 的纯文本边界(读文件、写文件、执行命令)
  • 循环:不是一次性的问答,而是多步骤迭代直到任务完成

Claude Code 能帮你写一个完整的项目,是因为它在一次任务中可能循环执行几十次:读文件 → 分析 → 写代码 → 运行测试 → 修 Bug → 再运行...


ReAct 模式:Agent 的基本运行框架

ReAct(Reasoning + Acting)是目前最主流的 Agent 运行模式:

用户输入
   
┌─────────────────────────────┐
  Thought: 我需要先读一下文件     模型先思考
  Action: read_file("main.py")   决定行动
└─────────────────────────────┘
   
执行 read_file
   
Observation: 文件内容...          观察结果
   
┌─────────────────────────────┐
  Thought: 我看到了问题所在...    继续思考
  Action: write_to_file(...)     下一步行动
└─────────────────────────────┘
   
... 循环直到任务完成 ...
   
Final Answer: 任务已完成          输出最终结果

开始实现:工具定义

首先定义 Agent 能使用的工具。我们实现三个最基础的工具:

# tools.py
import subprocess
from pathlib import Path

def read_file(path: str) -> str:
    """
    读取文件内容
    
    Args:
        path: 文件路径(相对于工作目录)
    
    Returns:
        文件内容字符串,如果文件不存在返回错误信息
    """
    try:
        file_path = Path(path)
        if not file_path.exists():
            return f"错误:文件 '{path}' 不存在"
        return file_path.read_text(encoding="utf-8")
    except Exception as e:
        return f"读取文件失败:{e}"


def write_to_file(path: str, content: str) -> str:
    """
    写入内容到文件(如果目录不存在会自动创建)
    
    Args:
        path: 文件路径
        content: 要写入的内容
    
    Returns:
        成功或失败的信息
    """
    try:
        file_path = Path(path)
        file_path.parent.mkdir(parents=True, exist_ok=True)
        file_path.write_text(content, encoding="utf-8")
        return f"成功写入文件:{path}{len(content)} 字符)"
    except Exception as e:
        return f"写入文件失败:{e}"


def run_terminal_command(command: str, working_dir: str = ".") -> str:
    """
    在终端执行命令
    
    Args:
        command: 要执行的 shell 命令
        working_dir: 命令执行的工作目录
    
    Returns:
        命令的标准输出和标准错误输出
    """
    try:
        result = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            timeout=60,
            cwd=working_dir
        )
        output = ""
        if result.stdout:
            output += f"[stdout]\n{result.stdout}"
        if result.stderr:
            output += f"[stderr]\n{result.stderr}"
        if not output:
            output = f"命令执行完成,退出码:{result.returncode}"
        return output
    except subprocess.TimeoutExpired:
        return "命令执行超时(60秒)"
    except Exception as e:
        return f"命令执行失败:{e}"


# 工具注册表(名称 → 函数映射)
AVAILABLE_TOOLS = {
    "read_file": read_file,
    "write_to_file": write_to_file,
    "run_terminal_command": run_terminal_command,
}

核心实现:ReAct Agent

# agent.py
import re
import anthropic
from tools import AVAILABLE_TOOLS

# System Prompt:告诉 LLM 它是什么、能用什么工具、怎么用
SYSTEM_PROMPT = """你是一个强大的 AI 编程助手,能够通过使用工具来帮助完成各种编程任务。

## 可用工具

### read_file
读取指定文件的内容
用法:
<tool_call>
<tool_name>read_file</tool_name>
<path>文件路径</path>
</tool_call>

### write_to_file
将内容写入文件
用法:
<tool_call>
<tool_name>write_to_file</tool_name>
<path>文件路径</path>
<content>文件内容</content>
</tool_call>

### run_terminal_command
在终端执行命令
用法:
<tool_call>
<tool_name>run_terminal_command</tool_name>
<command>要执行的命令</command>
</tool_call>

## 工作方式

1. 收到任务后,先在 <thinking> 标签中分析任务,制定计划
2. 按计划使用工具,每次只调用一个工具
3. 根据工具返回的结果继续推理
4. 任务完成后,输出 <final_answer> 标签

## 注意事项

- 写代码时先规划文件结构,再逐一写入
- 每次工具调用后等待结果,再决定下一步
- 遇到错误时分析原因,尝试修复
- 不要假设工具执行的结果,必须等实际返回
"""

MAX_ITERATIONS = 20  # 防止无限循环

class ReActAgent:
    def __init__(self, working_dir: str = "."):
        self.client = anthropic.Anthropic()
        self.working_dir = working_dir
        self.messages = []
    
    def run(self, task: str) -> str:
        """
        执行任务,返回最终结果
        """
        print(f"\n{'='*60}")
        print(f"任务:{task}")
        print('='*60)
        
        self.messages = [{"role": "user", "content": f"<task>\n{task}\n</task>"}]
        
        for iteration in range(MAX_ITERATIONS):
            print(f"\n--- 第 {iteration + 1} 轮推理 ---")
            
            # 调用 LLM
            response = self.client.messages.create(
                model="claude-3-5-sonnet-20241022",
                max_tokens=4096,
                system=SYSTEM_PROMPT,
                messages=self.messages
            )
            
            assistant_message = response.content[0].text
            print(f"\n[模型输出]\n{assistant_message[:500]}{'...' if len(assistant_message) > 500 else ''}")
            
            self.messages.append({
                "role": "assistant",
                "content": assistant_message
            })
            
            # 检查是否完成
            if "<final_answer>" in assistant_message:
                final_answer = self._extract_tag(assistant_message, "final_answer")
                print(f"\n{'='*60}")
                print(f"[任务完成]\n{final_answer}")
                return final_answer
            
            # 解析工具调用
            tool_call = self._parse_tool_call(assistant_message)
            
            if tool_call is None:
                # 没有工具调用也没有最终答案,让模型继续
                self.messages.append({
                    "role": "user",
                    "content": "请继续执行任务,或者输出 <final_answer> 表示完成。"
                })
                continue
            
            # 执行工具
            tool_result = self._execute_tool(tool_call)
            print(f"\n[工具执行结果]\n{tool_result[:300]}{'...' if len(tool_result) > 300 else ''}")
            
            # 把工具结果作为用户消息返回
            self.messages.append({
                "role": "user",
                "content": f"<tool_result>\n{tool_result}\n</tool_result>"
            })
        
        return "任务执行超过最大轮次限制,未能完成"
    
    def _parse_tool_call(self, text: str) -> dict | None:
        """解析 XML 格式的工具调用"""
        if "<tool_call>" not in text:
            return None
        
        tool_name = self._extract_tag(text, "tool_name")
        if not tool_name:
            return None
        
        tool_call = {"name": tool_name.strip()}
        
        # 根据工具名提取参数
        if tool_name == "read_file":
            tool_call["path"] = self._extract_tag(text, "path", "")
        elif tool_name == "write_to_file":
            tool_call["path"] = self._extract_tag(text, "path", "")
            tool_call["content"] = self._extract_tag(text, "content", "")
        elif tool_name == "run_terminal_command":
            tool_call["command"] = self._extract_tag(text, "command", "")
        
        return tool_call
    
    def _execute_tool(self, tool_call: dict) -> str:
        """执行工具调用"""
        tool_name = tool_call["name"]
        
        if tool_name not in AVAILABLE_TOOLS:
            return f"错误:未知工具 '{tool_name}'"
        
        print(f"\n[执行工具] {tool_name}")
        
        try:
            if tool_name == "read_file":
                return AVAILABLE_TOOLS[tool_name](tool_call.get("path", ""))
            elif tool_name == "write_to_file":
                return AVAILABLE_TOOLS[tool_name](
                    tool_call.get("path", ""),
                    tool_call.get("content", "")
                )
            elif tool_name == "run_terminal_command":
                return AVAILABLE_TOOLS[tool_name](
                    tool_call.get("command", ""),
                    self.working_dir
                )
        except Exception as e:
            return f"工具执行出错:{e}"
    
    def _extract_tag(self, text: str, tag: str, default: str = "") -> str:
        """从 XML 格式文本中提取标签内容"""
        pattern = f"<{tag}>(.*?)</{tag}>"
        match = re.search(pattern, text, re.DOTALL)
        return match.group(1).strip() if match else default


# 入口
if __name__ == "__main__":
    import sys
    
    if len(sys.argv) < 3:
        print("用法:python agent.py <工作目录> <任务描述>")
        sys.exit(1)
    
    working_dir = sys.argv[1]
    task = sys.argv[2]
    
    agent = ReActAgent(working_dir=working_dir)
    result = agent.run(task)

实际运行效果

# 让 Agent 写一个贪吃蛇游戏
python agent.py ./snake "写一个贪吃蛇游戏,用 HTML、CSS 和 JavaScript 实现,
代码分别放在 index.html、style.css、game.js 三个文件中"

Agent 会自动执行:

  1. 分析任务,制定文件结构
  2. index.html(骨架)
  3. style.css(样式)
  4. game.js(游戏逻辑)
  5. run_terminal_command 检查文件是否创建成功
  6. 输出完成报告

ReAct vs Plan-and-Execute

除了 ReAct,还有一种 Plan-and-Execute 模式:

ReAct(适合单步任务):
每一步根据上一步的结果动态决定下一步

Plan-and-Execute(适合复杂任务):
1. Planner:生成完整执行计划 ["步骤1", "步骤2", ...]
2. Executor:执行每一步(可以是 ReAct Agent)
3. Re-planner:根据执行结果更新计划

Manus 这类复杂 Agent 用的是 Plan-and-Execute 变体——先做整体规划,再动态调整。


总结

200 行代码,实现了一个能工作的最小 Agent。它和 Claude Code 的差距在于:

  • 没有完善的错误恢复机制
  • 没有 Context 管理(长任务会超出 Context Window)
  • 工具集非常有限
  • 没有用户交互(权限确认、进度展示)

核心逻辑是完全一样的:LLM + 工具 + ReAct 循环。

理解了这个最小实现,再去用 Claude Code,你会发现它的每个设计决策都有迹可循。