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#1E80FFb本文目标/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 pytest2. 创建环境变量在 Coding Agent 所在目录创建.envGENVIS_API_KEY替换成你的_API_KEY不要把真实 API Key 写进源码也不要把.env提交到公开仓库。这里的 Key 使用统一模型入口而不是和某个模型永久绑定。后续想比较不同模型在“读项目、修改代码、修复测试”上的表现只需要更换模型名称Agent 主循环和工具代码都不用动。3. 为什么本文使用统一模型接口Coding Agent 和普通聊天不一样。它完成一次任务往往需要连续请求多轮先读目录再读文件修改代码运行测试失败后还要继续修复。如果每测试一个模型都重新配置 SDK、鉴权方式和请求格式时间很容易浪费在接口适配上。因此本文直接使用 Genvis 的兼容接口使用熟悉的 OpenAI Python SDK一个 Key 可以切换不同的兼容模型更换模型时通常只改MODEL_NAME可以查看每次任务实际消耗的 Token 和对应成本后续增加代码审查、测试生成等 Agent也能复用同一套客户端配置。font color#1E80FFb配置提示/b/font本文实测使用的统一模型接口为base_urlhttps://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_keyAPI_KEY, base_urlhttps://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(encodingutf-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(parentsTrue, exist_okTrue) target.write_text(content, encodingutf-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(encodingutf-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, encodingutf-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, cwdWORKSPACE, capture_outputTrue, textTrue, timeout30, checkFalse ) 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( modelMODEL_NAME, messagesmessages, temperature0.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(parentsTrue, exist_okTrue) 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_asciiFalse)[: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_asciiFalse) }) messages.append({ role: user, content: 工具执行结果\n json.dumps(tool_result, ensure_asciiFalse) }) return f任务未在 {MAX_STEPS} 步内完成已停止运行 if __name__ __main__: print(Mini Coding Agent) print(fWorkspace: {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.pydef add(a, b): return a b def subtract(a, b): return a - b再创建test_calculator.pyfrom 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()紧接着检查目标路径是否仍然位于workspaceif target ! WORKSPACE and WORKSPACE not in target.parents: raise ValueError(路径超出 workspace 范围)这样即使模型尝试传入../../important.txt程序也会拒绝访问。font color#E5484Db风险警告/b/font不要把模型输出直接拼接成系统路径也不要默认“模型不会做危险操作”。权限必须由代码控制而不是靠提示词保证。2. 为什么用 replace_text而不只用 write_filewrite_file适合创建新文件但修改已有文件时模型必须返回完整内容。文件越长越容易发生以下问题遗漏原有代码改坏无关部分浪费上下文和 Token难以审查具体改了什么。replace_text要求旧内容在文件中只出现一次相当于一个极简补丁工具。匹配不到或匹配多次时它会拒绝修改让模型读取更多上下文后重试。3. 为什么不开放任意 Shell最简单的 Coding Agent 往往会提供下面这种工具subprocess.run(command, shellTrue)这也意味着模型生成什么电脑就执行什么。删除文件、读取环境变量、上传数据都可能发生。本文只允许ALLOWED_TEST_COMMANDS { pytest: [python, -m, pytest, -q], unittest: [python, -m, unittest, discover, -v] }同时使用参数数组而不是shellTrue减少 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_NAMEAPI 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比较不同模型完成同一任务的成本。