当前位置:首页>python>200 行 Python 手写 Coding Agent:能读项目、改代码、跑测试

200 行 Python 手写 Coding Agent:能读项目、改代码、跑测试

  • 2026-08-27 16:29:00
200 行 Python 手写 Coding Agent:能读项目、改代码、跑测试

普通 AI 只能在对话框里"建议你怎么改",Coding Agent 则会自己查看项目、定位文件、写入修改、运行测试,并根据报错继续修复。本文不使用 LangChain、AutoGen 等框架,只用 Python 和 OpenAI 兼容接口,手写一个真正能跑起来的极简 Coding Agent。

前言:会生成代码,不等于会完成开发任务

把需求发给大模型,让它返回一段代码,这件事已经不新鲜了。

真正让 Coding Agent 变得有用的,并不是"代码写得更长",而是它能把一个开发任务执行到底:

理解需求 → 查看项目结构 → 读取相关文件 → 修改代码 → 运行测试 → 读取报错 → 继续修复 → 直到测试通过

例如,我们给它这样一个任务:

给 calculator.py 增加 divide(a, b) 函数。除数为 0 时抛出 ValueError,并补充对应测试。

普通大模型会返回一段建议代码;本文实现的 Coding Agent 会直接在指定项目目录中完成下面几件事:

  • 查看项目中有哪些文件;
  • 读取 calculator.py 和测试文件;
  • 写入功能代码与测试代码;
  • 执行测试;
  • 如果失败,读取错误并继续修改;
  • 测试通过后输出任务总结。

本文目标:不用任何 Agent 框架,从零理解"模型决策、工具执行、结果回喂、循环纠错"是怎么连起来的。

这次模型调用没有分别接入多套 SDK,而是直接使用 Genvis 提供的 OpenAI 兼容接口。这样做的好处是:Agent 的文件工具和执行逻辑只写一遍,后面测试不同模型时,只需要修改 MODEL_NAME,不必跟着模型重写客户端代码。

本文使用的实测配置已经完整保留在源码中,API Key 对应的环境变量是 GENVIS_API_KEY。

一、Coding Agent 和代码生成有什么区别

很多人把"让模型写一段代码"也叫 Coding Agent,其实二者差别很大。

能力
普通代码生成
Coding Agent
理解单个问题
支持
支持
查看项目目录
不支持
支持
读取现有代码
需要手动粘贴
主动读取
修改真实文件
不支持
调用工具完成
执行测试
不支持
支持
根据报错继续修复
需要人工追问
自动循环
控制文件和命令权限
无
由运行时控制

因此,Coding Agent 不是某一个"更会写代码"的模型,而是一套运行系统:

Coding Agent = 大模型 + 文件工具 + 测试工具 + 上下文 + Agent Loop

模型负责判断下一步应该做什么,Python 程序负责真正执行文件读取、代码修改和测试命令。

二、先看最终架构

本文实现五个工具:

工具
作用
list_files
查看项目目录和文件
read_file
读取指定代码文件
write_file
创建或完整写入文件
replace_text
精确替换文件中的一段内容
run_tests
执行白名单内的测试命令

完整执行流程如下:

用户输入开发任务        ↓模型选择下一步动作        ↓返回结构化 JSON        ↓Python 调用对应工具        ↓工具结果写回对话        ↓模型继续判断   ┌────┴────┐调用工具   输出完成   └──继续循环

这里有一个非常关键的设计:

模型没有文件权限,也不能直接运行命令。它只能提出工具调用请求,真正的权限由 Python 程序掌握。

这也是 Coding Agent 与"让模型随便生成 Shell 命令并执行"的本质区别。

三、准备运行环境

建议使用 Python 3.10 或更高版本。

1. 安装依赖

pip install openai python-dotenv pytest

2. 创建环境变量

在 Coding Agent 所在目录创建 .env:

GENVIS_API_KEY=替换成你的_API_KEY

不要把真实 API Key 写进源码,也不要把 .env 提交到公开仓库。

这里的 Key 使用统一模型入口,而不是和某个模型永久绑定。后续想比较不同模型在"读项目、修改代码、修复测试"上的表现,只需要更换模型名称,Agent 主循环和工具代码都不用动。

3. 为什么本文使用统一模型接口

Coding Agent 和普通聊天不一样。它完成一次任务,往往需要连续请求多轮:先读目录,再读文件,修改代码,运行测试,失败后还要继续修复。

如果每测试一个模型都重新配置 SDK、鉴权方式和请求格式,时间很容易浪费在接口适配上。因此本文直接使用 Genvis 的兼容接口:

  • 使用熟悉的 OpenAI Python SDK;
  • 一个 Key 可以切换不同的兼容模型;
  • 更换模型时通常只改 MODEL_NAME;
  • 可以查看每次任务实际消耗的 Token 和对应成本;
  • 后续增加代码审查、测试生成等 Agent,也能复用同一套客户端配置。

配置提示:本文实测使用的统一模型接口为 base_url=https://genvis.xyz/v1。复制代码时不要漏掉这段客户端配置。

4. 准备目录

项目结构如下:

mini-coding-agent/├── .env├── coding_agent.py└── workspace/    ├── calculator.py    └── test_calculator.py

其中,workspace 是 Agent 唯一允许操作的目录。

四、完整代码

新建 coding_agent.py,写入下面的代码:

import jsonimport osimport subprocessfrom pathlib import Pathfrom typing importAny, Callablefrom dotenv import load_dotenvfrom openai import OpenAIload_dotenv()API_KEY = os.getenv("GENVIS_API_KEY")ifnot API_KEY:raise RuntimeError("未读取到 GENVIS_API_KEY,请检查 .env 文件")client = OpenAI(    api_key=API_KEY,    base_url="https://genvis.xyz/v1")MODEL_NAME = "gpt-5.6-sol"WORKSPACE = Path("workspace").resolve()MAX_STEPS = 20MAX_FILE_SIZE = 100_000ALLOWED_SUFFIXES = {".py", ".json", ".toml", ".yaml", ".yml",".md", ".txt", ".html", ".css", ".js", ".ts"}ALLOWED_TEST_COMMANDS = {"pytest": ["python", "-m", "pytest", "-q"],"unittest": ["python", "-m", "unittest", "discover", "-v"]}defresolve_path(relative_path: str) -> Path:"""将相对路径限制在 workspace 内,阻止 ../ 路径穿越。"""    target = (WORKSPACE / relative_path).resolve()if target != WORKSPACE and WORKSPACE notin target.parents:raise ValueError("路径超出 workspace 范围")return targetdeflist_files(path: str = ".") -> dict[str, Any]:"""列出目录内容,忽略隐藏目录和缓存目录。"""    target = resolve_path(path)ifnot target.exists():return {"success": False, "error": "目录不存在"}ifnot target.is_dir():return {"success": False, "error": "目标不是目录"}    ignored = {".git", ".idea", ".vscode", "__pycache__", ".pytest_cache"}    items = []for item insorted(target.rglob("*")):ifany(part in ignored for part in item.parts):continueif item.is_file():            items.append(str(item.relative_to(WORKSPACE)))iflen(items) >= 200:breakreturn {"success": True, "files": items}defread_file(path: str) -> dict[str, Any]:"""读取 workspace 内的文本文件。"""    target = resolve_path(path)ifnot target.exists() ornot target.is_file():return {"success": False, "error": "文件不存在"}if target.suffix.lower() notin ALLOWED_SUFFIXES:return {"success": False, "error": "不允许读取该文件类型"}if target.stat().st_size > MAX_FILE_SIZE:return {"success": False, "error": "文件过大"}try:        content = target.read_text(encoding="utf-8")return {"success": True, "path": path, "content": content}except UnicodeDecodeError:return {"success": False, "error": "文件不是 UTF-8 文本"}defwrite_file(path: str, content: str) -> dict[str, Any]:"""创建或完整覆盖 workspace 内的文本文件。"""    target = resolve_path(path)if target.suffix.lower() notin ALLOWED_SUFFIXES:return {"success": False, "error": "不允许写入该文件类型"}iflen(content.encode("utf-8")) > MAX_FILE_SIZE:return {"success": False, "error": "写入内容过大"}    target.parent.mkdir(parents=True, exist_ok=True)    target.write_text(content, encoding="utf-8")return {"success": True,"path": path,"bytes": len(content.encode("utf-8"))    }defreplace_text(path: str, old: str, new: str) -> dict[str, Any]:"""精确替换文件中的唯一文本片段。"""    target = resolve_path(path)ifnot target.exists() ornot target.is_file():return {"success": False, "error": "文件不存在"}if target.suffix.lower() notin ALLOWED_SUFFIXES:return {"success": False, "error": "不允许修改该文件类型"}    content = target.read_text(encoding="utf-8")    count = content.count(old)if count == 0:return {"success": False, "error": "没有找到待替换内容"}if count > 1:return {"success": False, "error": "待替换内容不唯一,请提供更多上下文"}    updated = content.replace(old, new, 1)    target.write_text(updated, encoding="utf-8")return {"success": True, "path": path, "replacements": 1}defrun_tests(command: str = "pytest") -> dict[str, Any]:"""只运行预先允许的测试命令。"""    args = ALLOWED_TEST_COMMANDS.get(command)if args isNone:return {"success": False, "error": "测试命令不在白名单中"}try:        result = subprocess.run(            args,            cwd=WORKSPACE,            capture_output=True,            text=True,            timeout=30,            check=False        )except subprocess.TimeoutExpired:return {"success": False, "error": "测试执行超时"}    output = (result.stdout + "\n" + result.stderr)[-12_000:]return {"success": result.returncode == 0,"returncode": result.returncode,"output": output    }TOOLS: dict[str, Callable[..., dict[str, Any]]] = {"list_files": list_files,"read_file": read_file,"write_file": write_file,"replace_text": replace_text,"run_tests": run_tests}SYSTEM_PROMPT = """你是一个运行在受限 workspace 中的 Coding Agent。你的任务是理解需求、查看项目、修改代码并运行测试。可用工具:1. list_files参数:{"path": "."}2. read_file参数:{"path": "相对路径"}3. write_file参数:{"path": "相对路径", "content": "完整文件内容"}4. replace_text参数:{"path": "相对路径", "old": "原文本", "new": "新文本"}5. run_tests参数:{"command": "pytest"} 或 {"command": "unittest"}需要调用工具时,只输出一个 JSON 对象:{  "type": "tool_call",  "tool": "工具名称",  "arguments": {}}任务完成时,只输出一个 JSON 对象:{  "type": "final",  "answer": "完成了什么、修改了哪些文件、测试是否通过"}规则:1. 开始修改前先查看目录和相关文件;2. 不要猜测未读取过的文件内容;3. 修改后必须运行测试;4. 测试失败时阅读错误并尝试修复;5. 只能输出合法 JSON,不要添加 Markdown 代码块;6. 不得要求执行白名单以外的命令;7. 没有测试通过时,不要声称任务已完成。"""defcall_model(messages: list[dict[str, str]]) -> dict[str, Any]:"""调用模型并解析结构化动作。"""    response = client.chat.completions.create(        model=MODEL_NAME,        messages=messages,        temperature=0.1    )    content = response.choices[0].message.contentifnot content:raise RuntimeError("模型返回了空内容")try:return json.loads(content)except json.JSONDecodeError as error:raise RuntimeError(f"模型没有返回合法 JSON:{content}") from errordefexecute_tool(action: dict[str, Any]) -> dict[str, Any]:"""校验并执行一次工具调用。"""    tool_name = action.get("tool")    arguments = action.get("arguments", {})    tool = TOOLS.get(tool_name)if tool isNone:return {"success": False, "error": f"未知工具:{tool_name}"}ifnotisinstance(arguments, dict):return {"success": False, "error": "arguments 必须是对象"}try:return tool(**arguments)except TypeError as error:return {"success": False, "error": f"工具参数错误:{error}"}except Exception as error:return {"success": False, "error": f"工具执行异常:{error}"}defrun_agent(task: str) -> str:"""运行 Coding Agent 主循环。"""    WORKSPACE.mkdir(parents=True, exist_ok=True)    tests_passed = False    messages = [        {"role": "system", "content": SYSTEM_PROMPT},        {"role": "user", "content": task}    ]for step inrange(1, MAX_STEPS + 1):print(f"\n[Step {step}/{MAX_STEPS}] 模型正在决策...")        action = call_model(messages)        action_type = action.get("type")if action_type == "final":if tests_passed:returnstr(action.get("answer", "任务结束,但模型没有提供总结"))            tool_result = {"success": False,"error": "尚未在最后一次代码修改后通过测试,不能结束任务"            }elif action_type != "tool_call":            tool_result = {"success": False,"error": f"无法识别的动作类型:{action_type}"            }else:print(f"[Tool] {action.get('tool')}{action.get('arguments', {})}")            tool_result = execute_tool(action)print(f"[Result] {json.dumps(tool_result, ensure_ascii=False)[:500]}")if action.get("tool") in {"write_file", "replace_text"}:                tests_passed = Falseelif action.get("tool") == "run_tests"and tool_result.get("success"):                tests_passed = True        messages.append({"role": "assistant","content": json.dumps(action, ensure_ascii=False)        })        messages.append({"role": "user","content": "工具执行结果:\n" + json.dumps(tool_result, ensure_ascii=False)        })returnf"任务未在 {MAX_STEPS} 步内完成,已停止运行"if __name__ == "__main__":print("Mini Coding Agent")print(f"Workspace: {WORKSPACE}")print("输入 exit 退出\n")whileTrue:        user_task = input("任务 > ").strip()if user_task.lower() in {"exit", "quit"}:breakifnot user_task:continuetry:            result = run_agent(user_task)print(f"\nAgent:{result}")except Exception as error:print(f"\n运行失败:{error}")

这段客户端配置为什么值得单独注意

完整代码中真正与模型服务绑定的部分只有下面三项:

API_KEY = os.getenv("GENVIS_API_KEY")MODEL_NAME = "gpt-5.6-sol"base_url = "https://genvis.xyz/v1"

也就是说,文件读取、文本替换、测试执行和 Agent Loop 都与具体模型解耦。想测试另一个模型时,不需要重新搭建项目,只需确认接口支持对应模型名称,再调整 MODEL_NAME。

对 Coding Agent 来说,这一点很实用:同一个任务可以分别交给不同模型执行,再结合最终测试结果、执行步数和 Token 成本做对比,而不是只凭聊天体验判断模型是否适合写代码。

五、准备一个测试项目

在 workspace 中创建 calculator.py:

defadd(a, b):return a + bdefsubtract(a, b):return a - b

再创建 test_calculator.py:

from calculator import add, subtractdeftest_add():assert add(2, 3) == 5deftest_subtract():assert subtract(5, 2) == 3

先手动确认原项目测试正常:

cd workspacepython -m pytest -qcd ..

预期输出:

2 passed

六、让 Agent 完成第一次代码修改

启动程序:

python coding_agent.py

输入任务:

给 calculator.py 增加 divide(a, b) 函数。除数为 0 时抛出 ValueError,并在 test_calculator.py 中补充正常除法和除零测试。修改完成后运行 pytest,测试通过再结束。

一次典型的执行过程如下:

[Step 1/20] 模型正在决策...[Tool] list_files {'path': '.'}[Result] {'success': true, 'files': ['calculator.py', 'test_calculator.py']}[Step 2/20] 模型正在决策...[Tool] read_file {'path': 'calculator.py'}[Step 3/20] 模型正在决策...[Tool] read_file {'path': 'test_calculator.py'}[Step 4/20] 模型正在决策...[Tool] replace_text {...}[Step 5/20] 模型正在决策...[Tool] replace_text {...}[Step 6/20] 模型正在决策...[Tool] run_tests {'command': 'pytest'}[Result] {'success': true, 'returncode': 0, 'output': '4 passed'}Agent:已在 calculator.py 中增加 divide 函数,补充正常除法与除零测试,pytest 全部通过。

注意,模型每一轮只决定一个动作。它不是一次生成完整计划后盲目执行,而是根据最新工具结果继续判断。

七、核心代码拆解

1. 为什么必须限制工作目录

下面这行代码看似普通,却是整个工具层最重要的安全边界:

target = (WORKSPACE / relative_path).resolve()

紧接着检查目标路径是否仍然位于 workspace:

if target != WORKSPACE and WORKSPACE notin target.parents:raise ValueError("路径超出 workspace 范围")

这样即使模型尝试传入 ../../important.txt,程序也会拒绝访问。

风险警告:不要把模型输出直接拼接成系统路径,也不要默认"模型不会做危险操作"。权限必须由代码控制,而不是靠提示词保证。

2. 为什么用 replace_text,而不只用 write_file

write_file 适合创建新文件,但修改已有文件时,模型必须返回完整内容。文件越长,越容易发生以下问题:

  • 遗漏原有代码;
  • 改坏无关部分;
  • 浪费上下文和 Token;
  • 难以审查具体改了什么。

replace_text 要求旧内容在文件中只出现一次,相当于一个极简补丁工具。匹配不到或匹配多次时,它会拒绝修改,让模型读取更多上下文后重试。

3. 为什么不开放任意 Shell

最简单的 Coding Agent 往往会提供下面这种工具:

subprocess.run(command, shell=True)

这也意味着模型生成什么,电脑就执行什么。删除文件、读取环境变量、上传数据都可能发生。

本文只允许:

ALLOWED_TEST_COMMANDS = {"pytest": ["python", "-m", "pytest", "-q"],"unittest": ["python", "-m", "unittest", "discover", "-v"]}

同时使用参数数组而不是 shell=True,减少 Shell 注入风险。

这会牺牲一部分自由度,却更适合作为能在本机运行的教学版本。

4. 测试结果为什么要回喂模型

run_tests 返回三项关键信息:

{"success":false,"returncode":1,"output":"AssertionError ..."}

Agent 将这段结果追加到消息历史,下一轮模型就能根据真实错误继续修复。

如果没有这一步,模型只是"写了代码";加入测试结果回喂之后,它才具备最基本的闭环纠错能力。

5. 为什么要设置 MAX_STEPS

模型可能反复读取同一个文件,也可能在测试失败后不断尝试。下面的限制可以防止无限循环:

MAX_STEPS = 20

达到最大步数后,程序会停止任务,避免持续消耗时间和 Token。

6. 为什么不能只靠提示词要求测试

提示词写着"测试通过才能结束",并不代表模型一定遵守。因此,主循环还维护了一个真实状态:

tests_passed = False

只有 run_tests 成功后,它才会变为 True;如果测试通过后又调用 write_file 或 replace_text,状态会重新变回 False。模型提前输出 final 时,程序也会拒绝结束并要求它继续测试。

这体现了一个重要原则:

能用代码强制执行的规则,就不要只写在提示词里。

八、这个 Agent 还不等于 Claude Code 或 Codex

本文实现的是用于理解原理的最小 Coding Agent,不是成熟产品的平替。

成熟的编码 Agent 通常还包含:

  • Git 状态检测和差异审查;
  • 按需搜索大型代码库;
  • 上下文压缩与缓存;
  • 命令沙箱和权限审批;
  • 流式输出与任务进度;
  • 补丁应用与回滚;
  • 项目级规则文件;
  • MCP、Skills 和子 Agent;
  • 任务中断与恢复。

但无论功能多复杂,最底层仍然是同一个循环:

观察项目 → 选择工具 → 执行动作 → 获取结果 → 继续判断

理解这个循环后,再看任何 Coding Agent 的架构都会清晰很多。

九、五个最值得继续升级的方向

1. 增加 Git Diff

修改完成后自动展示差异,让用户知道具体改了哪些行,并在确认后保留修改。

2. 用补丁替代完整写入

可以继续实现 unified diff 工具,让模型输出标准补丁,再由程序校验和应用。

3. 增加用户审批

在写文件或运行命令前显示动作:

Agent 准备修改 src/app.py,是否允许?[y/N]

这比单纯依靠系统提示词更可靠。

4. 增加项目规则文件

让 Agent 启动时读取项目中的规则文件,例如:

  • Python 使用 Ruff 格式化
  • 新功能必须补充测试
  • 禁止修改 migrations 目录
  • 所有公开函数必须有类型注解

这相当于给 Coding Agent 一份项目级开发规范。

5. 增加上下文压缩

任务执行步骤变多后,文件内容和测试日志会快速占满上下文。可以对旧工具结果生成摘要,只保留最近几轮的完整信息。

十、常见问题

1. 模型没有返回合法 JSON 怎么办

降低 temperature、强化输出约束,并增加有限次数重试。生产环境建议使用模型支持的原生工具调用或结构化输出能力。

2. 为什么不让 Agent 自动安装依赖

自动安装依赖涉及网络访问、供应链风险和环境污染。教学版本只负责修改项目与运行既有测试,更容易控制风险。

3. 能不能用其他模型

可以。本文采用统一兼容接口的目的,就是让模型切换与 Agent 工具层解耦。只要接口支持对应模型和当前请求格式,通常只需要修改 MODEL_NAME;API Key、文件工具、测试工具与 Agent Loop 都可以继续复用。

4. 为什么模型修改成功却一直不结束

通常是系统提示词中的完成条件不明确。本文明确要求"修改后必须运行测试,测试通过才能结束",同时用 MAX_STEPS 提供最终兜底。

5. 这套代码可以直接用于生产吗

不建议。生产环境至少还需要容器沙箱、细粒度审批、资源限制、审计日志、版本控制和可回滚机制。

十一、总结

本文用 Python 手写了一个能够操作真实项目的极简 Coding Agent,它已经具备完整的最小闭环:

  • 主动查看项目结构;
  • 按需读取代码文件;
  • 创建或修改代码;
  • 执行测试;
  • 根据报错继续修复;
  • 测试通过后输出总结。

真正重要的并不是这两百多行代码,而是背后的工程边界:

模型负责提出动作,程序负责校验权限;工具负责执行,测试负责验证;失败结果重新进入上下文,Agent 才能继续纠错。

当你理解这套机制后,就能继续加入 Git Diff、人工审批、项目规则、上下文压缩和 MCP,把这个最小版本逐步扩展为真正可用的 Coding Agent。

如果运行时需要切换模型,只需要调整客户端配置和 MODEL_NAME,文件工具、测试工具与 Agent Loop 都可以继续复用。

需要直接运行的读者,将 base_url 设置为 https://genvis.xyz/v1,再把申请到的 Key 写入 GENVIS_API_KEY 即可测试。后台还能查看每个 Coding Agent 任务实际消耗的 Token,比较不同模型完成同一任务的成本。


想直接拿到可运行的完整代码和最新模型配置?公众号菜单栏已放好入口,复制即可测试。

最新文章

随机文章