1. 项目缘起:为什么我们需要一个“编程Agent”?
最近几年,AI编程助手的概念火得一塌糊涂,从GitHub Copilot到Cursor,再到各种层出不穷的本地模型工具,它们确实极大地提升了开发效率。但用久了,我总感觉有点“隔靴搔痒”。这些工具要么是云端服务,对代码库的全局理解有限,响应速度受网络影响;要么是本地模型,能力又往往局限于代码补全,缺乏自主规划和执行复杂任务的能力。
我想要的,是一个能真正理解我意图、能拆解任务、能调用工具、能自我验证并循环迭代的“智能体”(Agent)。它应该像一个不知疲倦的初级程序员,我只需要给出一个模糊的需求,比如“给这个Flask应用加个用户登录功能”,它就能自己去查文档、写代码、跑测试、修Bug,直到功能可用。这听起来很科幻,但基于现有的一些开源工具,我们完全可以从零开始,搭建一个属于我们自己的、轻量级的“Mini-Cursor”。
这个项目的核心,就是利用四个关键工具,构建一个能够自主循环工作的编程智能体。它不是要替代程序员,而是成为一个强大的“副驾驶”,处理那些繁琐、重复但又有明确模式的开发任务,让我们能更专注于架构设计和核心逻辑。接下来,我就带你一步步拆解这个想法,看看如何用有限的资源,实现一个具备“思考-行动-观察-迭代”能力的编程Agent。
2. 核心架构:理解“四个工具一个循环”的设计哲学
在动手之前,我们必须先厘清整个系统的设计思路。所谓“四个工具一个循环”,并不是指只能用四个软件,而是一种高度抽象的核心组件模型。这个模型确保了Agent的自主性和有效性。
四个核心工具分别承担了智能体的不同心智能力:
“大脑” - 大型语言模型(LLM):这是Agent的决策核心。它负责理解自然语言指令、拆解任务、规划步骤、生成代码、分析执行结果并决定下一步行动。我们通常需要一个具备较强代码理解和生成能力的模型,例如DeepSeek-Coder、CodeLlama或Qwen-Coder系列。它的提示词(Prompt)工程是灵魂,决定了Agent的“性格”和能力边界。
“手” - 代码执行器(Code Executor):Agent不能只“空想”,必须能“动手”。这个工具负责在安全的沙箱环境中运行生成的代码片段(如单个函数、脚本),并捕获输出、错误信息以及执行状态(成功/失败)。Python的
subprocess、exec,或者更安全的Docker容器、Firejail沙箱,都是可选方案。关键在于隔离性与反馈的完整性。“眼” - 文件系统观察器(File System Watcher):Agent需要感知环境变化。当它修改了项目文件后,需要能“看到”修改的内容,以便进行自我验证或作为下一步决策的输入。例如,它写了一个新的
config.py文件,后续生成测试代码时就需要读取这个文件的内容。watchdog(Python库)或操作系统的文件事件监听机制可以充当这双“眼睛”。“记忆与工作台” - 项目上下文管理器(Project Context Manager):Agent不能失忆。它需要维护一个持久的“工作记忆”,包括:原始用户需求、已完成的步骤、当前代码库的状态(如关键文件的内容)、历史执行结果和错误日志。这通常通过一个结构化的数据库(如SQLite)、向量数据库(如Chroma,用于代码片段检索),或者简单地维护一个精心设计的JSON状态文件来实现。
一个循环,指的是驱动Agent运行的ReAct(Reasoning and Acting)循环。这不是一个工具,而是一个工作流程引擎。其核心步骤是:
- 思考(Think):LLM根据当前任务状态和上下文,分析下一步应该做什么。例如:“用户要求添加登录功能。我已创建了用户模型。下一步需要创建登录视图函数。”
- 行动(Act):LLM生成具体的行动指令,通常是调用一个工具(Tool)。例如:“调用
write_file工具,在app/auth.py中写入登录视图函数代码。” - 观察(Observe):系统执行该行动(如写入文件),并收集结果(如文件写入成功,或执行代码后返回了错误
ImportError)。 - 更新(Update):将观察到的结果反馈给LLM,更新上下文。然后,循环回到第1步“思考”。
这个循环会一直持续,直到LLM判断任务已经完成(生成最终答案),或者达到最大迭代次数、遇到无法解决的错误为止。整个系统的架构图,可以想象成LLM作为中央处理器,不断与另外三个工具(执行器、观察器、上下文管理器)进行交互,形成一个闭环。
3. 工具选型与实战配置:搭建我们的智能体工作台
理论清晰后,我们开始动手选型和配置。这里我会给出一个基于Python技术栈的、高性价比的实操方案。你可以根据自己的偏好替换其中的组件。
3.1 “大脑”的安装与接入:本地LLM服务化
云端API(如OpenAI GPT-4、Claude)虽然强大,但考虑到成本、延迟和对代码库的隐私性,我们优先选择本地部署的开源模型。
方案选择:Ollama + DeepSeek-CoderOllama是目前最方便的本地大模型运行和管理的工具,它简化了模型下载、加载和提供API的全过程。
操作步骤:
- 安装Ollama:前往官网(https://ollama.com)根据你的操作系统(Windows/macOS/Linux)下载安装。
- 拉取代码模型:打开终端,运行
ollama pull deepseek-coder:6.7b。这里选择6.7B参数的版本,在大多数消费级显卡(如RTX 3060 12GB)上可以流畅运行,且代码能力足够强。如果你的硬件更强,可以尝试deepseek-coder:33b。 - 运行模型服务:
ollama run deepseek-coder:6.7b会进入交互模式。但我们更需要它的API服务。通过ollama serve命令,Ollama会在本地11434端口启动一个兼容OpenAI API格式的接口。这样,我们的Agent程序就可以像调用ChatGPT一样调用本地模型了。
关键配置与验证:
# 测试Ollama API是否通畅 import openai # 需要安装openai库 client = openai.OpenAI( base_url='http://localhost:11434/v1', api_key='ollama', # ollama的API key可以任意填写,非空即可 ) response = client.chat.completions.create( model='deepseek-coder:6.7b', messages=[{'role': 'user', 'content': '用Python写一个快速排序函数。'}] ) print(response.choices[0].message.content)如果能成功打印出代码,说明你的“大脑”已就位。
注意:首次运行或切换模型时,Ollama需要从硬盘加载模型到显存,可能会有几十秒的等待时间,这是正常的。确保你的系统有足够的GPU内存或大的交换空间(Swap)。
3.2 “手”的锻造:安全且反馈详细的代码执行器
让AI直接在你的主系统上运行代码是极其危险的。我们必须构建一个沙箱。
方案选择:Docker容器作为执行沙箱Docker能提供完美的环境隔离和资源限制。我们预先准备一个包含项目所需基础环境(如Python, Node.js)的镜像。
操作步骤:
- 创建Dockerfile:在项目根目录创建
Dockerfile.executor。FROM python:3.11-slim WORKDIR /workspace # 复制当前项目代码到容器内(在运行时动态绑定挂载更灵活,此处为示例) COPY . . # 可以预先安装一些常用库 RUN pip install --no-cache-dir pytest black isort CMD ["tail", "-f", "/dev/null"] # 保持容器运行 - 构建镜像:
docker build -f Dockerfile.executor -t code-executor . - 在Agent中调用:我们的Agent需要能动态创建容器、复制代码进去、执行命令、获取结果并清理容器。
核心执行函数示例:
import docker import tempfile import os class DockerCodeExecutor: def __init__(self): self.client = docker.from_env() self.image_name = "code-executor" def execute_code(self, code: str, command: str = "python -c") -> dict: """ 在Docker容器中执行一段代码或命令。 :param code: 要执行的代码字符串 :param command: 执行命令,如 `python -c` 或 `bash -c` :return: 包含 stdout, stderr, returncode 的字典 """ # 创建临时目录存放代码文件 with tempfile.TemporaryDirectory() as tmpdir: code_path = os.path.join(tmpdir, 'script.py') with open(code_path, 'w') as f: f.write(code) # 创建并运行容器 container = self.client.containers.run( self.image_name, f'{command} "{code}"', volumes={tmpdir: {'bind': '/tmp/workspace', 'mode': 'ro'}}, working_dir='/tmp/workspace', detach=True, stdout=True, stderr=True, remove=False # 不自动删除,方便查看日志 ) # 等待执行完成并获取日志 result = container.wait() stdout = container.logs(stdout=True, stderr=False).decode() stderr = container.logs(stdout=False, stderr=True).decode() container.remove() # 清理容器 return { 'stdout': stdout, 'stderr': stderr, 'returncode': result['StatusCode'], 'success': result['StatusCode'] == 0 }这个执行器不仅运行代码,还捕获了成功/失败状态以及完整的输出和错误信息,这些信息对于LLM进行下一步“思考”至关重要。
3.3 “眼”与“记忆”的实现:上下文感知与状态持久化
文件观察和上下文管理相对轻量,我们可以用Python库和简单的数据结构来实现。
文件系统观察器:使用watchdog
from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler import time class CodeChangeHandler(FileSystemEventHandler): def on_modified(self, event): if not event.is_directory and event.src_path.endswith('.py'): print(f"检测到文件变更: {event.src_path}") # 这里可以触发一个回调,通知Agent上下文管理器更新文件内容缓存 # 在Agent主循环中启动观察者 observer = Observer() observer.schedule(CodeChangeHandler(), path='./your_project', recursive=True) observer.start() # 主循环结束后 observer.stop()在实际的Agent中,我们不一定需要实时监听。更简单的策略是:在每次ReAct循环的“观察”阶段,主动去读取被修改文件的最新内容,更新到上下文中。
项目上下文管理器:一个增强的Python类我们设计一个ProjectContext类来充当Agent的“工作记忆”。
import json import os from pathlib import Path class ProjectContext: def __init__(self, project_root: str): self.root = Path(project_root) self.original_task = "" # 原始用户需求 self.history = [] # 记录每一步的思考、行动、观察 self.file_cache = {} # 缓存关键文件内容,避免频繁IO self.current_state = "idle" # 任务状态:planning, coding, testing, debugging, done def update_file_cache(self, file_path: str): """更新单个文件的缓存""" full_path = self.root / file_path if full_path.exists(): with open(full_path, 'r', encoding='utf-8') as f: self.file_cache[file_path] = f.read() else: # 文件可能被删除 self.file_cache.pop(file_path, None) def get_relevant_context(self, current_step: str) -> str: """ 根据当前步骤,组装相关的上下文信息,作为LLM Prompt的一部分。 这是提升Agent表现的关键! """ context_lines = [] context_lines.append(f"原始任务: {self.original_task}") context_lines.append(f"当前任务阶段: {current_step}") context_lines.append("最近几步操作历史:") for h in self.history[-5:]: # 只保留最近5步,防止Token过长 context_lines.append(f"- Think: {h.get('think')}") context_lines.append(f"- Act: {h.get('act')}") context_lines.append(f"- Observe: {h.get('observe')}") context_lines.append("\n当前项目关键文件内容:") for file, content in list(self.file_cache.items())[-3:]: # 缓存最近3个相关文件 context_lines.append(f"--- File: {file} ---") context_lines.append(content[:500] + "..." if len(content) > 500 else content) # 截断长文件 return "\n".join(context_lines) def add_history(self, think: str, act: str, observe: str): self.history.append({'think': think, 'act': act, 'observe': observe}) # 保存到JSON文件,实现持久化 with open(self.root / 'agent_history.json', 'w') as f: json.dump({'history': self.history, 'task': self.original_task}, f, indent=2)这个上下文管理器负责组织信息,它决定了在每一步,哪些信息会被送入LLM的“脑海”中。精心设计的get_relevant_context方法能显著提高Agent的任务完成率。
4. ReAct循环的工程实现:让Agent真正“动”起来
有了工具,我们需要一个“主循环”来驱动一切。这是整个项目的核心逻辑。
4.1 定义Agent可用的工具集(Tools)
首先,我们要告诉LLM它能“用手”做什么。我们将工具定义为函数,并用一个统一的描述格式来声明。
# 定义工具 def write_file(filepath: str, content: str) -> str: """将内容写入指定文件路径。如果文件存在则覆盖。""" full_path = Path(filepath) full_path.parent.mkdir(parents=True, exist_ok=True) full_path.write_text(content, encoding='utf-8') return f"文件 {filepath} 写入成功。" def read_file(filepath: str) -> str: """读取指定文件路径的内容。""" try: return Path(filepath).read_text(encoding='utf-8') except FileNotFoundError: return f"错误:文件 {filepath} 不存在。" def execute_python_script(code: str) -> str: """在隔离环境中执行一段Python代码,并返回输出或错误。""" result = executor.execute_code(code, command="python -c") if result['success']: return f"执行成功。输出:\n{result['stdout']}" else: return f"执行失败。错误:\n{result['stderr']}" def run_shell_command(cmd: str) -> str: """在项目根目录运行一个shell命令(如 `pip install` 或 `pytest`)。""" # 注意:这里需要在Docker容器内运行,或做严格的安全限制 result = executor.execute_code(cmd, command="bash -c") return f"命令 `{cmd}` 执行完毕。返回码:{result['returncode']}。输出:\n{result['stdout']}\n错误:\n{result['stderr']}" # 工具描述列表,用于构造给LLM的Prompt TOOLS = [ { "name": "write_file", "description": "将内容写入文件。参数:filepath(文件路径), content(内容)。", "parameters": ["filepath", "content"] }, { "name": "read_file", "description": "读取文件内容。参数:filepath(文件路径)。", "parameters": ["filepath"] }, { "name": "execute_python_script", "description": "执行一段Python代码并返回结果。参数:code(代码字符串)。", "parameters": ["code"] }, { "name": "run_shell_command", "description": "运行一个shell命令(如安装依赖、运行测试)。参数:cmd(命令字符串)。", "parameters": ["cmd"] } ]4.2 构造驱动LLM思考与行动的Prompt
Prompt是Agent的“灵魂指令”。它需要清晰地定义角色、约束、可用工具和输出格式。
def build_agent_prompt(task: str, context: str) -> str: tools_description = "\n".join([f"- {t['name']}: {t['description']}" for t in TOOLS]) return f"""你是一个自主的编程AI助手(Agent)。你的目标是通过思考、使用工具、观察结果并循环,最终完成用户的任务。 # 当前任务 {task} # 当前项目上下文 {context} # 你可以使用的工具 {tools_description} # 你的工作流程(ReAct循环) 1. 思考(Think):分析当前情况和任务,决定下一步做什么。如果需要使用工具,请明确说明工具名和参数。 2. 行动(Act):严格按照以下JSON格式调用工具: ```json {{"action": "工具名", "args": {{"参数1": "值1", "参数2": "值2"}}}}- 观察(Observe):我会将工具执行的结果返回给你。
- 重复1-3步,直到任务完成或无法继续。
输出格式要求
你的每次回复必须且只能包含一个JSON对象,格式如下:
{{ "thought": "你的思考过程,解释为什么选择这个行动。", "action": {{"action": "工具名", "args": {{...}}}} // 或者,当任务完成时,设为 null }}当任务彻底完成时,将action设为null,并在thought中总结最终成果。
现在,开始你的第一次思考。 """
这个Prompt明确要求LLM以固定的JSON格式回复,这极大简化了后续的解析逻辑,使得程序能够稳定地从LLM的输出中提取“思考”和“行动”。 ### 4.3 实现主循环控制器 最后,我们将所有部分串联起来,形成主循环。 ```python import json import re class ProgrammingAgent: def __init__(self, llm_client, context: ProjectContext, max_steps=20): self.llm = llm_client self.ctx = context self.max_steps = max_steps self.tools_map = { 'write_file': write_file, 'read_file': read_file, 'execute_python_script': execute_python_script, 'run_shell_command': run_shell_command, } def run(self, task: str): self.ctx.original_task = task print(f"开始处理任务: {task}") step = 0 while step < self.max_steps: step += 1 print(f"\n=== 步骤 {step} ===") # 1. 获取当前上下文,构建Prompt current_context = self.ctx.get_relevant_context(f"step_{step}") prompt = build_agent_prompt(task, current_context) # 2. 调用LLM进行“思考” response = self.llm.chat.completions.create( model='deepseek-coder:6.7b', messages=[{'role': 'user', 'content': prompt}], temperature=0.1 # 低温度保证输出稳定,减少随机性 ) llm_output = response.choices[0].message.content print(f"LLM原始输出:\n{llm_output}") # 3. 解析LLM的回复 try: # 使用正则表达式提取JSON部分,提高容错性 json_match = re.search(r'```json\s*(.*?)\s*```', llm_output, re.DOTALL) if json_match: json_str = json_match.group(1) else: json_str = llm_output.strip() agent_response = json.loads(json_str) except json.JSONDecodeError as e: print(f"解析LLM输出失败: {e}") self.ctx.add_history("LLM返回了无法解析的格式", llm_output, "解析错误") break thought = agent_response.get('thought', '') action_data = agent_response.get('action') # 4. 判断是否结束 if action_data is None: print(f"任务完成!最终思考: {thought}") self.ctx.add_history(thought, "FINISH", "任务完成") break # 5. 执行“行动” action_name = action_data['action'] args = action_data['args'] print(f"思考: {thought}") print(f"执行动作: {action_name}, 参数: {args}") if action_name not in self.tools_map: observe = f"错误:未知工具 '{action_name}'。" else: try: tool_func = self.tools_map[action_name] # 动态调用工具函数 observe = tool_func(**args) except Exception as e: observe = f"工具执行异常: {e}" print(f"观察结果: {observe}") # 6. “更新”上下文:记录历史,并更新文件缓存(如果涉及文件操作) self.ctx.add_history(thought, f"{action_name}({args})", observe) if action_name == 'write_file': self.ctx.update_file_cache(args['filepath']) # 可选:如果执行的是读文件操作,将内容也主动更新到上下文中 if action_name == 'read_file': # 假设read_file返回的是文件内容 self.ctx.file_cache[args['filepath']] = observe if "错误" not in observe else "" if step >= self.max_steps: print(f"达到最大步数限制 ({self.max_steps}),任务未完成。") # 启动Agent if __name__ == "__main__": llm_client = openai.OpenAI(base_url='http://localhost:11434/v1', api_key='ollama') context = ProjectContext('./my_python_project') executor = DockerCodeExecutor() agent = ProgrammingAgent(llm_client, context) agent.run("在项目根目录创建一个名为 `utils.py` 的文件,并在其中编写一个函数 `calculate_average(numbers: list) -> float`,用于计算列表的平均值。然后写一个简单的测试脚本来验证它。")这个主循环清晰地体现了ReAct的流程:构建Prompt -> LLM思考并计划行动 -> 解析并执行行动 -> 观察结果 -> 记录并进入下一轮。
5. 实战演练与效果调优:让Agent完成真实任务
让我们用一个更复杂的任务来测试这个Mini-Cursor。假设我们有一个简单的Flask项目骨架,现在要求Agent为其添加一个简单的RESTful API端点。
初始项目结构:
my_flask_project/ ├── app.py (已有基础Flask app) ├── requirements.txt (已有Flask依赖) └── ...给Agent的任务指令:“在现有的Flask应用中,添加一个/api/books的GET端点,返回一个固定的图书列表JSON,例如[{"id": 1, "title": "Python编程"}]。请确保代码符合Flask规范。”
Agent的执行过程推演(简化版):
- 步骤1:LLM思考后,决定先读取
app.py了解现有结构。调用read_file工具。 - 观察:获取到现有
app.py内容。上下文更新。 - 步骤2:LLM思考,决定在
app.py中添加新的路由。调用write_file工具,写入修改后的完整app.py代码。 - 观察:文件写入成功。上下文更新(文件缓存刷新)。
- 步骤3:LLM思考,为了验证端点是否工作,需要启动服务并测试。但启动服务是阻塞操作。LLM可能决定先写一个简单的测试脚本。调用
write_file工具,创建test_api.py,使用requests库测试本地端点。 - 观察:测试文件创建成功。
- 步骤4:LLM思考,需要安装
requests库(如果未安装)。调用run_shell_command工具,执行pip install requests。 - 观察:安装成功。
- 步骤5:LLM思考,运行测试脚本。调用
execute_python_script工具,运行test_api.py。 - 观察:测试失败,因为Flask应用没有运行。错误信息被捕获。
- 步骤6:LLM思考,需要先启动Flask应用(在后台),再运行测试。这涉及到进程管理,可能超出当前工具能力。LLM可能会在思考中表示无法完成,或者尝试更复杂的方案(如使用
subprocess启动服务)。最终可能因为复杂度而停止,或给出需要人工干预的建议。
从演练中看到的调优点:
- 工具能力的增强:我们的工具集还比较基础。可以增加
start_flask_server、curl_api等更高级、更贴合特定场景的工具,降低LLM规划路径的难度。 - Prompt工程的优化:在Prompt中更明确地引导。例如,加入:“如果涉及启动Web服务器,请先编写测试代码,然后提示用户手动启动服务器后再运行测试。” 让Agent学会在边界处与用户协作。
- 上下文的精炼:随着步骤增多,历史记录和文件缓存会膨胀,导致Prompt超长。需要实现更智能的上下文摘要(Summarization)功能,只保留最关键的信息。
- 错误处理的引导:当工具执行失败时,观察结果(错误信息)需要被充分结构化,并提示LLM如何分析。例如,在
execute_python_script的返回中,可以固定格式:“STDOUT: ...\nSTDERR: ...\nERROR_TYPE: ImportError(如果可识别)”。这能帮助LLM更好地“诊断”问题。
6. 避坑指南与进阶思考:从玩具到可用的关键一跃
构建出能跑通的Demo只是第一步。要让这个Mini-Cursor真正有用,还需要解决一系列工程化问题。
坑一:LLM的“幻觉”与不稳定输出即使要求输出JSON,LLM有时也会在JSON前后添加多余的解释文字,导致解析失败。解决方案:像上面代码一样,使用正则表达式re.search(r'```json\s*(.*?)\s*```', ...)进行提取,这比单纯json.loads健壮得多。同时,在Prompt中强烈强调“必须且只能包含一个JSON对象”。
坑二:无限循环与原地打转Agent可能会陷入死循环,比如反复修改同一行代码,或者在“写文件-读文件-再写文件”中循环。解决方案:
- 设置最大步数:这是最后的防线。
- 在上下文中检测循环:在
ProjectContext.add_history中,检查最近N步的(think, act)组合是否重复出现,如果重复则中断并提示。 - Prompt引导:在Prompt中加入:“请避免重复执行完全相同的操作。如果遇到错误,请尝试新的解决方案,而不是重复失败的操作。”
坑三:安全性与破坏性操作虽然用了Docker沙箱,但write_file工具可以覆盖任何项目文件,run_shell_command更是危险。解决方案:
- 实施路径白名单:限制
write_file和read_file只能操作项目根目录下的文件。 - 命令黑名单/白名单:对
run_shell_command执行的命令进行严格过滤,禁止rm、format、dd等危险命令,或只允许pip install、pytest等少数安全命令。 - 操作确认(人工在环):对于关键文件(如
app.py)的写入或安装依赖等操作,可以设计为Agent生成建议,等待用户确认后再执行。
进阶思考:如何提升Agent的智能?
- 更丰富的工具库:集成Git操作(clone, commit, diff)、数据库操作、调用外部API等工具,让Agent能力更强。
- 分层规划与子任务分解:让LLM先输出一个高层计划(如:1. 分析项目结构;2. 修改主应用文件;3. 编写测试;4. 运行验证),再将每个步骤展开为具体的ReAct循环。这能处理更复杂的任务。
- 集成专业代码知识:将项目代码库建立向量索引(用Chroma + 代码分割),当Agent需要了解项目特定逻辑时,可以优先检索相关代码片段作为上下文,而不是盲目猜测。
- 多Agent协作:可以设计“架构师Agent”、“开发Agent”、“测试Agent”角色,让它们通过共享上下文进行协作,模拟真实的开发流程。
构建一个实用的编程Agent是一个持续的迭代过程。从这个“四个工具一个循环”的最小可行产品(MVP)出发,你可以根据实际需求,不断打磨Prompt、增加工具、优化上下文管理。它最终会成为你编程工作流中一个极具潜力的助手,帮你承担起那些脉络清晰但细节繁琐的“体力活”,让你能更聚焦于创造性的部分。