终章实战|300 行纯 Python 代码,手把手带你手搓一个生产级 Coding Agent

终章实战|300 行纯 Python 代码,手把手带你手搓一个生产级 Coding Agent

作 者:吴佳浩(Alben)

微某信公某众号:全栈架构师笔记

系列专栏:《企业级 Agent 全栈架构师实战》· 最终实战篇


导读

通用 ChatBot 还在陪聊,专业的 Coding Agent 已经在帮工程师定位 Bug、修改源码、运行测试并自动提 PR。

很多人迷信动辄数十万行代码的庞大开源框架,认为做 Agent 必须引入一堆复杂的链式抽象;但剥离掉那些花哨的封装,顶级 Coding Agent(如 Claude Code、Hermes Agent)的核心内核惊人地简洁与纯粹。

框架是给写 Demo 的人用的,协议、工具与状态机才是架构师的武器。300 行纯 Python,不依赖任何第三方重量级框架,彻底带你手搓一个真正能跑在生产环境中的 Coding Agent。(筒子们说了点实话,不喜勿喷哈)


在过去一两年的企业级实践中,很多团队在尝试自研 Coding Agent 或 DevOps Agent 时,经常陷入一个误区:一上来就引入 LangChain、CrewAI 或 AutoGen,定义了一大堆复杂的 Agent 类、Task 链和冗余的 Callback。

结果系统上线后,经常出现以下三个令人抓狂的翻车现场:

灾难现象 具体翻车表现 架构根因
1. 幻觉与伪修复 Agent 声称"我已经修复了 Bug", 缺乏确定性的命令行工具闭环,
(Fake Fixes) 但根本没执行测试,代码甚至跑不通 没有把真实测试结果回填到循环中
2. 暴力全文件覆写 只改一行代码,却用全文件覆写, 缺乏基于精确 Diff 的 Patch 工具
(Destructive Write) 导致格式错乱并冲掉他人提交 和局部修改状态机
3. 上下文无限膨胀 读了几个大文件后,Token 瞬间耗尽 缺乏 Token 预算裁剪与滚动摘要,
(Context Explosion) 首字延迟飙升至 10s,服务直接崩溃 原始文件内容无节制堆叠在 Prompt

真正可用的 Coding Agent 绝不是靠堆砌提示词生成的,它依赖的是一套严密的执行状态机(Conversation Loop)与具备确定性反馈的工具集(Deterministic Toolset)。


一、顶级 Coding Agent 的底层本质:三位一体架构

如果我们把 Claude Code、Hermes Agent 的底层执行流抽丝剥茧,会发现它们的核心架构都可以收敛为极度清晰的"三位一体"模型:

(规划、意图与决策中枢)
Runtime Tool Engine
(Bash / Read / Patch / Rg)
Memory & Context Budget
(Token 预算、修剪与状态回写)
  • 🔸 工具必须具备确定性(Deterministic):文件读取必须带行号和截断保护;文件修改必须走 Patch 模式(精准锚定替换),严禁整文件覆写;命令执行必须能捕获真实的退出码(Exit Code);
  • 🔸 执行反馈必须真实回流(Grounding) :Agent 修复完代码后,必须通过命令行运行真实测试(如 pytest / npm test),只有当测试真正输出 PASS 且退出码为 0 时,才允许判定任务完成;
  • 🔸 上下文必须严格控盘(Token Budgeting):大文件的读取和长命令输出必须设置 Hard Cap,防止上下文瞬间被撑爆。

小结:

Coding Agent 的本质不是聊天,而是一个不断尝试、读取真实系统反馈、自我纠错并直到测试全绿的自动化状态机。


二、300 行纯 Python 实现生产级 Coding Agent

以下是完整的、无任何省略号的、可直接运行的 Coding Agent 完整源码(基于标准库与通用 OpenAI 规范接口,支持接入任何兼容 OpenAI / DeepSeek / Claude 协议的 API):

python 复制代码
"""
coding_agent.py - 300 行纯 Python 实现生产级 Coding Agent
包含:ReAct 循环、安全命令执行、行号读取、精准 Patch 补丁、上下文预算裁剪
"""

import os
import sys
import json
import shlex
import subprocess
import urllib.request
import urllib.error
from typing import List, Dict, Any, Optional

# ==================== 1. 核心工具集实现 ====================

def tool_run_bash(command: str, timeout: int = 60) -> str:
    """在当前工作目录下执行 Shell 命令,捕获真实输出与退出码"""
    # 安全红线拦截
    dangerous_patterns = ["rm -rf /", ":(){ :|:& };:", "mkfs", "dd if=/dev"]
    if any(p in command for p in dangerous_patterns):
        return json.dumps({"error": "Security Alert: Command blocked by safety guardrails", "exit_code": -1})
    
    try:
        proc = subprocess.run(
            command,
            shell=True,
            capture_output=True,
            text=True,
            encoding="utf-8",        # 强制 UTF-8 解码(Windows 默认 GBK 会炸中文输出)
            errors="replace",        # 非法字节替换为占位符,永不崩溃
            timeout=timeout,
            cwd=os.getcwd()
        )
        output = proc.stdout if proc.stdout else proc.stderr
        # 截断过长输出防止冲垮 Context
        if len(output) > 8000:
            output = output[:4000] + "\n... [OUTPUT TRUNCATED DUE TO LENGTH] ...\n" + output[-4000:]
        return json.dumps({"output": output, "exit_code": proc.returncode})
    except subprocess.TimeoutExpired:
        return json.dumps({"error": f"Execution timed out after {timeout} seconds", "exit_code": 124})
    except Exception as e:
        return json.dumps({"error": str(e), "exit_code": -1})


def tool_read_file(path: str, offset: int = 1, limit: int = 300) -> str:
    """带行号与分页读取文本文件,格式为 '行号| 内容'"""
    if not os.path.exists(path):
        return json.dumps({"error": f"File '{path}' not found."})
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
        
        total_lines = len(lines)
        start_idx = max(0, offset - 1)
        end_idx = min(total_lines, start_idx + limit)
        
        numbered_lines = [f"{i+1:4d}| {lines[i]}" for i in range(start_idx, end_idx)]
        return json.dumps({
            "total_lines": total_lines,
            "showing_lines": f"{start_idx+1}-{end_idx}",
            "content": "".join(numbered_lines)
        })
    except Exception as e:
        return json.dumps({"error": str(e)})


def tool_patch_file(path: str, old_str: str, new_str: str) -> str:
    """精准局部替换文件内容,要求 old_str 具备唯一性,严禁全量覆写破坏代码"""
    if not os.path.exists(path):
        return json.dumps({"error": f"File '{path}' does not exist."})
    try:
        with open(path, "r", encoding="utf-8") as f:
            content = f.read()
        
        count = content.count(old_str)
        if count == 0:
            return json.dumps({"error": "Target 'old_str' not found in file. Check line numbers and indentation."})
        if count > 1:
            return json.dumps({"error": f"Target 'old_str' matched {count} times. Please include more surrounding context lines for uniqueness."})
        
        new_content = content.replace(old_str, new_str, 1)
        with open(path, "w", encoding="utf-8") as f:
            f.write(new_content)
        
        return json.dumps({"status": "success", "message": f"Successfully patched '{path}'."})
    except Exception as e:
        return json.dumps({"error": str(e)})


def tool_search_files(pattern: str, base_dir: str = ".") -> str:
    """按文件名查找或关键词正则查找文件路径"""
    matches = []
    for root, _, files in os.walk(base_dir):
        if any(ignored in root for ignored in [".git", "node_modules", "__pycache__", ".venv"]):
            continue
        for f in files:
            if pattern.lower() in f.lower():
                matches.append(os.path.relpath(os.path.join(root, f), base_dir))
    return json.dumps({"matched_files": matches[:50]})

# ==================== 2. 工具契约定义 ====================

TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "run_bash",
            "description": "Execute a shell command (e.g. pytest, git, ls, npm test) in the workspace.",
            "parameters": {
                "type": "object",
                "properties": {"command": {"type": "string", "description": "The shell command to run"}},
                "required": ["command"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read file content with line numbers. Use offset/limit for large files.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "Relative file path"},
                    "offset": {"type": "integer", "description": "Start line (1-indexed)"},
                    "limit": {"type": "integer", "description": "Max lines to return"}
                },
                "required": ["path"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "patch_file",
            "description": "Replace a unique exact string in a file with new string. Always include context lines.",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string", "description": "File path"},
                    "old_str": {"type": "string", "description": "Exact text to find and replace (must be unique)"},
                    "new_str": {"type": "string", "description": "Replacement text"}
                },
                "required": ["path", "old_str", "new_str"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_files",
            "description": "Find files by name pattern in the codebase.",
            "parameters": {
                "type": "object",
                "properties": {"pattern": {"type": "string", "description": "File name substring to search"}},
                "required": ["pattern"]
            }
        }
    }
]

TOOL_MAP = {
    "run_bash": tool_run_bash,
    "read_file": tool_read_file,
    "patch_file": tool_patch_file,
    "search_files": tool_search_files
}

# ==================== 3. LLM API 调用客户端 ====================

def call_llm(messages: List[Dict[str, Any]], api_key: str, base_url: str, model: str) -> Dict[str, Any]:
    """通过标准 HTTP 请求调用兼容 OpenAI 的大模型接口"""
    url = f"{base_url.rstrip('/')}/chat/completions"
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {api_key}"
    }
    payload = {
        "model": model,
        "messages": messages,
        "tools": TOOLS_SCHEMA,
        "tool_choice": "auto",
        "temperature": 0.1
    }
    req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        error_msg = e.read().decode("utf-8")
        raise RuntimeError(f"LLM API HTTP Error {e.code}: {error_msg}")

# ==================== 4. Agent 核心执行循环 ====================

class CodingAgent:
    def __init__(self, api_key: str, base_url: str = "https://api.openai.com/v1", model: str = "gpt-4o"):
        self.api_key = api_key
        self.base_url = base_url
        self.model = model
        self.system_prompt = (
            "You are an expert autonomous Software Engineering Agent.\n"
            "Working Directory: " + os.getcwd() + "\n"
            "Guidelines:\n"
            "1. Inspect before modifying: Use 'search_files' and 'read_file' to understand codebase structure.\n"
            "2. Precision edits: Always use 'patch_file' with sufficient unique context lines. NEVER overwrite files completely.\n"
            "3. Ground truth verification: Always execute relevant unit tests via 'run_bash' after making changes.\n"
            "4. Keep working iteratively until tests pass (exit_code=0). Only provide your final answer once verified."
        )

    def run(self, user_goal: str, max_turns: int = 15) -> str:
        messages: List[Dict[str, Any]] = [
            {"role": "system", "content": self.system_prompt},
            {"role": "user", "content": user_goal}
        ]
        
        print(f"\n🚀 [Agent Started] Goal: {user_goal}")
        print("=" * 70)

        for turn in range(1, max_turns + 1):
            print(f"\n🔄 [Turn {turn}/{max_turns}] Thinking...")
            
            # 上下文预算控制 (超过 30 轮时剔除早期冗余工具输出)
            if len(messages) > 25:
                messages = [messages[0]] + messages[-20:]

            resp = call_llm(messages, self.api_key, self.base_url, self.model)
            choice = resp["choices"][0]
            message = choice["message"]
            messages.append(message)

            # 1. 纯文本回答 (意味着任务完成或需要用户介入)
            if not message.get("tool_calls"):
                content = message.get("content", "")
                print(f"\n✅ [Agent Finished]:\n{content}")
                return content

            # 2. 工具调用处理
            for tool_call in message["tool_calls"]:
                tool_name = tool_call["function"]["name"]
                tool_args = json.loads(tool_call["function"]["arguments"])
                call_id = tool_call["id"]
                
                print(f"  🛠️  Tool Call: {tool_name}({json.dumps(tool_args, ensure_ascii=False)})")
                
                # 执行真实工具
                fn = TOOL_MAP.get(tool_name)
                if fn:
                    tool_result = fn(**tool_args)
                else:
                    tool_result = json.dumps({"error": f"Tool '{tool_name}' not implemented."})
                
                # 截断单次工具回包预览
                preview = tool_result[:150] + "..." if len(tool_result) > 150 else tool_result
                print(f"  📥  Result: {preview}")

                messages.append({
                    "role": "tool",
                    "tool_call_id": call_id,
                    "content": tool_result
                })

        return "Task stopped: Max iterations reached without completion."

# ==================== 5. CLI 启动入口 ====================

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python coding_agent.py '<task_goal>'")
        sys.exit(1)
        
    api_key = os.getenv("OPENAI_API_KEY", "your-api-key")
    base_url = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
    model = os.getenv("AGENT_MODEL", "gpt-4o")
    
    agent = CodingAgent(api_key=api_key, base_url=base_url, model=model)
    agent.run(sys.argv[1])

三、关键设计剖析:为什么这 300 行代码能真正干活?

仔细阅读俺上面这段代码,会发现它在工程上解决了自研 Agent 最容易暴毙的几个关键问题:

1. 为什么不用 write_file,而是坚持 patch_file?

在大模型写代码时,最危险的操作就是"整文件覆写"。如果一个文件有 500 行,模型只需要改第 20 行,全文件覆写会导致:

  • 🔸 消耗海量输出 Token,速度极慢;
  • 🔸 模型可能在第 300 行"自作主张"省略一部分代码,导致源码损坏;
  • 🔸 冲掉 Git 中其他人的并发修改。 patch_file 强制模型提供包含上下文的 old_str 和 new_str,并在代码中严格校验匹配次数(必须严格等于 1)。如果有多次匹配,直接拒绝并要求模型补充更多上下文,从而在数学上保证了修改的原子性与确定性。

2. 为什么需要命令输出的物理截断保护?

当 Agent 运行 pytest 或 npm run build 时,有时会输出长达数万行的依赖报错或堆栈日志。如果不加节制地把全部 stdout 灌入 Prompt,一次 Tool Call 就会把上下文窗口打满,导致后续推理直接崩溃。代码中采用"保留头部 4000 字符 + 保留尾部 4000 字符"的切片策略,既保留了初始命令参数,又保留了最终的 Exit Code 与核心报错栈,信息密度最高。

3. 上下文滑动窗口的软衰减策略

在第 20 轮交互之后,历史中间过程里的很多"中间调试尝试"已经失去了即时价值。代码中的 messages = [messages[0]] + messages[-20:] 实现了极简的记忆窗口截断------保留全局 System 指令,仅保留最近的 20 轮上下文,从而将 Token 开销和首字延迟(TTFT)稳定在一个恒定的可控区间。


四、生产实战演练:让它自动排查并修复一个真实 Bug

我们可以用一个真实的 Python 项目来检验这 300 行代码的实战威力。

1. 构造一个有 Bug 的业务文件 (calculator.py)

python 复制代码
def calculate_tax(income: float, rate: float) -> float:
    # 错误的税率计算逻辑:忘记转换为小数且未处理负数
    if income < 0:
        return income * rate
    return income * (rate / 10)

2. 配套的单元测试 (test_calculator.py)

python 复制代码
import pytest
from calculator import calculate_tax

def test_positive_tax():
    assert calculate_tax(10000, 0.2) == 2000.0

def test_negative_income():
    with pytest.raises(ValueError):
        calculate_tax(-100, 0.2)

3. 运行 Coding Agent

执行命令:

bash 复制代码
python coding_agent.py "运行 pytest 查看测试失败原因,并修复 calculator.py 中的错误,直到所有测试通过"

4. 控制台真实交互轨迹

以下是在中文 Windows 11 + Python 3.12 + GLM-5.2-CL(OpenAI 兼容协议)环境下的真实运行日志,任务输入与上节命令逐字一致:

text 复制代码
🚀 [Agent Started] Goal: 运行 pytest 查看测试失败原因,并修复 calculator.py 中的错误,直到所有测试通过
======================================================================

🔄 [Turn 1/15] Thinking...
  🛠️  Tool Call: search_files({"pattern": "calculator"})
  📥  Result: {"matched_files": ["calculator.py", "test_calculator.py"]}
  🛠️  Tool Call: search_files({"pattern": "test"})
  📥  Result: {"matched_files": ["test_calculator.py"]}

🔄 [Turn 2/15] Thinking...
  🛠️  Tool Call: read_file({"path": "calculator.py"})
  📥  Result: {"total_lines": 5, "showing_lines": "1-5", "content": "   1| def calculate_tax(income: float, rate: float) -> float:..."}
  🛠️  Tool Call: read_file({"path": "test_calculator.py"})
  📥  Result: {"total_lines": 9, "showing_lines": "1-9", "content": "   1| import pytest\n   2| from calculator import calculate_tax..."}

🔄 [Turn 3/15] Thinking...
  🛠️  Tool Call: run_bash({"command": "cd \"G:\\tmp\\coding-agent-test\" && python -m pytest test_calculator.py -v"})
  📥  Result: {"output": "FAILED test_calculator.py::test_positive_tax - assert 200.0 == 2000.0 ... 2 failed ..."}

🔄 [Turn 4/15] Thinking...
  🛠️  Tool Call: patch_file({"new_str": "if income < 0:\n        raise ValueError(\"收入不能为负数\")\n    return income * rate", "old_str": "if income < 0:\n        return income * rate\n    return income * (rate / 10)", "path": "calculator.py"})
  📥  Result: {"status": "success", "message": "Successfully patched 'calculator.py'."}

🔄 [Turn 5/15] Thinking...
  🛠️  Tool Call: run_bash({"command": "cd \"G:\\tmp\\coding-agent-test\" && python -m pytest test_calculator.py -v"})
  📥  Result: {"output": "... 2 passed in 0.01s ..."}

🔄 [Turn 6/15] Thinking...
✅ [Agent Finished]:
所有测试已通过!
1. 税率计算:将 income * (rate / 10) 改为 income * rate(0.2 表示 20%,无需再除以 10);
2. 负数收入处理:当 income < 0 时抛出 ValueError("收入不能为负数");
修复后两个测试均通过:2 passed in 0.01s

三个值得注意的真实工程细节:

  • 🔸 模型没有照剧本走 :它先用了两轮 search_files + read_file 主动侦查,第三轮才跑测试------先理解、再验证、后修改,这正是 System Prompt 里 "Inspect before modifying" 约束真实生效的证据;
  • 🔸 Patch 是全函数级替换:模型提供了完整的新旧函数体做唯一锚点,我们的代码校验匹配次数后一次性替换------严禁全文件覆写的约定被模型严格遵守;
  • 🔸 第一次运行时暴露过一个真实 Bug :中文 Windows 下 subprocess 默认 GBK 解码,命令输出含中文时解码线程直接抛 UnicodeDecodeError(这正是本文代码中 encoding="utf-8", errors="replace" 两个参数的由来)------单元测试测不出编码问题,只有真实模型会话踩到中文输出才会炸。

整个过程 6 轮交互,无需人工介入,自动完成了"侦查 → 执行测试 → 捕获报错 → 定位源码 → 打 Patch 修复 → 回归测试 → 验证终态"的完整闭环。


五、从 300 行原型到企业级 Agent 的演进路线

当然,这 300 行代码是 Coding Agent 的最小可工作微内核。要将其推向支撑上千研发团队的企业级基础设施,还需要在以下四个维度进行工业化演进:

Memory OS
跨项目记忆
经验沉淀
  • 🔸 接入 Memory Runtime:挂载我们在本专栏前四篇构建的 Memory Service,让 Agent 能够记住用户的代码规范偏好与历史排错经验;
  • 🔸 工具总线协议化(MCP):将单机 Python 函数升级为支持跨网络、标准化的 Model Context Protocol,方便对接 GitHub、Jira、CI/CD 外部系统;
  • 🔸 多 Agent 并行派发(Subagents):引入后台子代理(Batch Fan-out),主 Agent 负责规划拆解,并行派发多个子 Agent 同时在独立沙箱中进行模块编写;
  • 🔸 安全沙箱隔离(Security Sandbox) :将命令执行由宿主机 subprocess 替换为 Docker 或 gVisor 轻量级隔离沙箱,杜绝越权破坏风险。

总结

  • 🔸 抛弃复杂的框架迷信:现代 Agent 的本质就是带有确定性反馈的 Conversation Loop 与状态机;
  • 🔸 确定性工具链是成败核心:严禁全量覆写(必须用 Patch),严禁只说不做(必须运行真实测试);
  • 🔸 代码越纯粹,架构越可控:用最精炼的 300 行代码理解核心,才是迈向企业级全栈 Agent 架构师的最佳起点。

绑友们,至此,阿拉各《企业级 Agent 全栈架构师实战》专栏完成了从理论演进、分层记忆设计、主流框架剖析、微服务选型到源码级落地的完整闭环。

最后希望认真阅读完本专栏的童鞋们,都可以写好代码,跑通测试,构建真正有状态的智能体系统。咱们下一个专栏见!

相关推荐
loulanyue_3 小时前
体验的Scaling时刻:读淘天首席科学家郑波2026云栖演讲
人工智能·计算机视觉·大模型·多模态
掘金酱3 小时前
[稀土掘金 × 火山引擎] AI用量周榜冲刺赛|获奖名单公示
前端·人工智能·后端
阿里云云原生3 小时前
阿里云 AgentBridge:为生产级 Agent 构建多源实时上下文
agent
FII工业富联科技服务3 小时前
2026年AI技术发展趋势分析:从模型能力突破到工业AI的真实应用
大数据·人工智能·深度学习·机器学习·制造·ai智能体·agentic ai
还卿一钵无情泪3 小时前
Docker 部署Unsloth 绕开环境配置难题
运维·人工智能·docker·ai·容器·nlp·干货
万联WANFLOW3 小时前
从Agent到Agentic Collaboration:企业AI工作流背后的技术架构
llm·agent·mcp
johnsong3 小时前
AI前沿日报 2026-10-07:信任崩塌
人工智能
孟健3 小时前
200 美元订阅实测:从 Agent 吞吐与缓存机制,算清 Claude 与 OpenAI 的算力账
人工智能·llm·ai编程
小范的技术工坊3 小时前
MCP协议:Agent工具调用的新标准
大模型·agent