普通 AI 只能在对话框里"建议你怎么改",Coding Agent 则会自己查看项目、定位文件、写入修改、运行测试,并根据报错继续修复。本文不使用 LangChain、AutoGen 等框架,只用 Python 和 OpenAI 兼容接口,手写一个真正能跑起来的极简 Coding Agent。
前言:会生成代码,不等于会完成开发任务
把需求发给大模型,让它返回一段代码,这件事已经不新鲜了。
真正让 Coding Agent 变得有用的,并不是"代码写得更长",而是它能把一个开发任务执行到底:
理解需求 → 查看项目结构 → 读取相关文件 → 修改代码 → 运行测试 → 读取报错 → 继续修复 → 直到测试通过
例如,我们给它这样一个任务:
给 calculator.py 增加 divide(a, b) 函数。除数为 0 时抛出 ValueError,并补充对应测试。
普通大模型会返回一段建议代码;本文实现的 Coding Agent 会直接在指定项目目录中完成下面几件事:
本文目标:不用任何 Agent 框架,从零理解"模型决策、工具执行、结果回喂、循环纠错"是怎么连起来的。
这次模型调用没有分别接入多套 SDK,而是直接使用 Genvis 提供的 OpenAI 兼容接口。这样做的好处是:Agent 的文件工具和执行逻辑只写一遍,后面测试不同模型时,只需要修改 MODEL_NAME,不必跟着模型重写客户端代码。
本文使用的实测配置已经完整保留在源码中,API Key 对应的环境变量是 GENVIS_API_KEY。
一、Coding Agent 和代码生成有什么区别
很多人把"让模型写一段代码"也叫 Coding Agent,其实二者差别很大。
因此,Coding Agent 不是某一个"更会写代码"的模型,而是一套运行系统:
Coding Agent = 大模型 + 文件工具 + 测试工具 + 上下文 + Agent Loop
模型负责判断下一步应该做什么,Python 程序负责真正执行文件读取、代码修改和测试命令。
二、先看最终架构
本文实现五个工具:
完整执行流程如下:
用户输入开发任务 ↓模型选择下一步动作 ↓返回结构化 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 的兼容接口:
- 可以查看每次任务实际消耗的 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 适合创建新文件,但修改已有文件时,模型必须返回完整内容。文件越长,越容易发生以下问题:
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 通常还包含:
但无论功能多复杂,最底层仍然是同一个循环:
观察项目 → 选择工具 → 执行动作 → 获取结果 → 继续判断
理解这个循环后,再看任何 Coding Agent 的架构都会清晰很多。
九、五个最值得继续升级的方向
1. 增加 Git Diff
修改完成后自动展示差异,让用户知道具体改了哪些行,并在确认后保留修改。
2. 用补丁替代完整写入
可以继续实现 unified diff 工具,让模型输出标准补丁,再由程序校验和应用。
3. 增加用户审批
在写文件或运行命令前显示动作:
Agent 准备修改 src/app.py,是否允许?[y/N]
这比单纯依靠系统提示词更可靠。
4. 增加项目规则文件
让 Agent 启动时读取项目中的规则文件,例如:
这相当于给 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,比较不同模型完成同一任务的成本。
想直接拿到可运行的完整代码和最新模型配置?公众号菜单栏已放好入口,复制即可测试。