200 行 Python 手写一个 Coding Agent:能读项目、改代码、跑测试
普通 AI 只能在对话框里“建议你怎么改”,Coding Agent 则会自己查看项目、定位文件、写入修改、运行测试,并根据报错继续修复。本文不使用 LangChain、AutoGen 等框架,只用 Python 和 OpenAI 兼容接口,手写一个真正能跑起来的极简 Coding Agent。
@TOC
前言:会生成代码,不等于会完成开发任务
把需求发给大模型,让它返回一段代码,这件事已经不新鲜了。
真正让 Coding Agent 变得有用的,并不是“代码写得更长”,而是它能把一个开发任务执行到底:
理解需求
→ 查看项目结构
→ 读取相关文件
→ 修改代码
→ 运行测试
→ 读取报错
→ 继续修复
→ 直到测试通过
例如,我们给它这样一个任务:
给 calculator.py 增加 divide(a, b) 函数。
除数为 0 时抛出 ValueError,并补充对应测试。
普通大模型会返回一段建议代码;本文实现的 Coding Agent 会直接在指定项目目录中完成下面几件事:
-
查看项目中有哪些文件;
-
读取
calculator.py和测试文件; -
写入功能代码与测试代码;
-
执行测试;
-
如果失败,读取错误并继续修改;
-
测试通过后输出任务总结。
<font color="#1E80FF"><b>本文目标</b></font>:不用任何 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,也能复用同一套客户端配置。
<font color="#1E80FF"><b>配置提示</b></font>:本文实测使用的统一模型接口为
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 json
import os
import subprocess
from pathlib import Path
from typing import Any, Callable
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
API_KEY = os.getenv("GENVIS_API_KEY")
if not 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 = 20
MAX_FILE_SIZE = 100_000
ALLOWED_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"]
}
def resolve_path(relative_path: str) -> Path:
"""将相对路径限制在 workspace 内,阻止 ../ 路径穿越。"""
target = (WORKSPACE / relative_path).resolve()
if target != WORKSPACE and WORKSPACE not in target.parents:
raise ValueError("路径超出 workspace 范围")
return target
def list_files(path: str = ".") -> dict[str, Any]:
"""列出目录内容,忽略隐藏目录和缓存目录。"""
target = resolve_path(path)
if not target.exists():
return {"success": False, "error": "目录不存在"}
if not target.is_dir():
return {"success": False, "error": "目标不是目录"}
ignored = {".git", ".idea", ".vscode", "__pycache__", ".pytest_cache"}
items = []
for item in sorted(target.rglob("*")):
if any(part in ignored for part in item.parts):
continue
if item.is_file():
items.append(str(item.relative_to(WORKSPACE)))
if len(items) >= 200:
break
return {"success": True, "files": items}
def read_file(path: str) -> dict[str, Any]:
"""读取 workspace 内的文本文件。"""
target = resolve_path(path)
if not target.exists() or not target.is_file():
return {"success": False, "error": "文件不存在"}
if target.suffix.lower() not in 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 文本"}
def write_file(path: str, content: str) -> dict[str, Any]:
"""创建或完整覆盖 workspace 内的文本文件。"""
target = resolve_path(path)
if target.suffix.lower() not in ALLOWED_SUFFIXES:
return {"success": False, "error": "不允许写入该文件类型"}
if len(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"))
}
def replace_text(path: str, old: str, new: str) -> dict[str, Any]:
"""精确替换文件中的唯一文本片段。"""
target = resolve_path(path)
if not target.exists() or not target.is_file():
return {"success": False, "error": "文件不存在"}
if target.suffix.lower() not in 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}
def run_tests(command: str = "pytest") -> dict[str, Any]:
"""只运行预先允许的测试命令。"""
args = ALLOWED_TEST_COMMANDS.get(command)
if args is None:
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. 没有测试通过时,不要声称任务已完成。
"""
def call_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.content
if not content:
raise RuntimeError("模型返回了空内容")
try:
return json.loads(content)
except json.JSONDecodeError as error:
raise RuntimeError(f"模型没有返回合法 JSON:{content}") from error
def execute_tool(action: dict[str, Any]) -> dict[str, Any]:
"""校验并执行一次工具调用。"""
tool_name = action.get("tool")
arguments = action.get("arguments", {})
tool = TOOLS.get(tool_name)
if tool is None:
return {"success": False, "error": f"未知工具:{tool_name}"}
if not isinstance(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}"}
def run_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 in range(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:
return str(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 = False
elif 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)
})
return f"任务未在 {MAX_STEPS} 步内完成,已停止运行"
if __name__ == "__main__":
print("Mini Coding Agent")
print(f"Workspace: {WORKSPACE}")
print("输入 exit 退出\n")
while True:
user_task = input("任务 > ").strip()
if user_task.lower() in {"exit", "quit"}:
break
if not user_task:
continue
try:
result = run_agent(user_task)
print(f"\nAgent:{result}")
except Exception as error:
print(f"\n运行失败:{error}")
这份代码略多于“玩具 Demo”,但核心 Agent Loop 仍然很短;额外代码主要用于路径隔离、文件限制、命令白名单和异常处理。
这段客户端配置为什么值得单独注意
完整代码中真正与模型服务绑定的部分只有下面三项:
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:
def add(a, b):
return a + b
def subtract(a, b):
return a - b
再创建 test_calculator.py:
from calculator import add, subtract
def test_add():
assert add(2, 3) == 5
def test_subtract():
assert subtract(5, 2) == 3
先手动确认原项目测试正常:
cd workspace
python -m pytest -q
cd ..
预期输出:
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 not in target.parents:
raise ValueError("路径超出 workspace 范围")
这样即使模型尝试传入:
../../important.txt
程序也会拒绝访问。
<font color="#E5484D"><b>风险警告</b></font>:不要把模型输出直接拼接成系统路径,也不要默认“模型不会做危险操作”。权限必须由代码控制,而不是靠提示词保证。
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,比较不同模型完成同一任务的成本。
更多推荐

所有评论(0)