2026年6月22日2约 2363 字8 分钟阅读

Coding Agent实战:从零构建一个能写代码的AI助手

把前5篇积累的零件——Agent Loop、ReAct推理、Tool Protocol、System Prompt方法论、ToolResult——全部组装起来,构建一个能读代码、写代码、跑测试、自动修bug的Coding Agent。

系列:从LLM到Agent · 第6篇 / 共10篇 阅读本文建议先了解第5篇的ToolResult与MCP协议第4篇的L1-L4防御框架

想象一下这个场景:你对 Agent 说"帮我给 calculator.py 写单元测试"。Agent 自动读取源码、分析方法签名、生成测试代码、运行 pytest、发现一个测试失败(浮点精度问题)、理解错误原因、修复断言、再次运行——全部通过。5 轮对话,4 次工具调用,1 次自动修复,你全程没有动手。

这不是假想。本篇我们就来构建这个 Agent。

前 5 篇积累的零件——Agent Loop(第1篇)、ReAct 推理(第2篇)、Tool Protocol v2(第3、5篇)、System Prompt 方法论(第4篇)、ToolResult 与跨厂商适配(第5篇)——今天全部组装到一起。

NOTE

Agent 还是 Harness? 严格来说,"Coding Agent"这个名字有歧义。最近的研究在讨论编码基准测试时指出了一个重要区分1——Agent(LLM,负责决策)和 Harness(代码框架,负责工具调度和状态管理)是两个不同的角色。我们这篇构建的其实是 Harness——但业界已经习惯用"Agent"统称两者,本系列沿用这个惯例。

四个核心工具

第3篇我们说过,工具数量直接影响模型的选择可靠性——超过 8 个工具时,选择准确率显著下降。翻看主流 Coding Agent 的开源源码——Codex CLI 4 个工具、OpenHands 4 个核心工具、Aider 4 个命令模式——行业已经收敛到一个最小有效集:读文件、写文件、跑命令、搜代码。覆盖了 Coding Agent 95% 的操作需求。

第5篇我们给 Tool Protocol 加上了 parameters 属性。现在用它来实现第一套真实工具。

前两个工具解决"看懂代码"的问题:

python
import os
import subprocess
from typing import Any

class ReadFile:
    @property
    def name(self) -> str:
        return "read_file"

    @property
    def description(self) -> str:
        return "Read the contents of a file at the given path."

    @property
    def parameters(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "Absolute or relative file path to read"
                }
            },
            "required": ["path"]
        }

    def execute(self, **kwargs: Any) -> str:
        path = kwargs["path"]
        try:
            with open(path, "r") as f:
                content = f.read()
            if len(content) > 10000:
                content = content[:10000] + "\n... (truncated)"
            return content
        except FileNotFoundError:
            return f"ERROR: File not found: {path}"
        except PermissionError:
            return f"ERROR: Permission denied: {path}"

还记得第2篇 ReAct 的 Observe 阶段吗?read_file 就是 Observe 的具体实现——Agent 必须先看懂代码,才能动手修改。

python
class SearchCode:
    @property
    def name(self) -> str:
        return "search_code"

    @property
    def description(self) -> str:
        return "Search for a pattern in files using grep. Returns matching lines with file paths and line numbers."

    @property
    def parameters(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "pattern": {
                    "type": "string",
                    "description": "Search pattern (supports regex)"
                },
                "path": {
                    "type": "string",
                    "description": "Directory to search in (default: current directory)"
                }
            },
            "required": ["pattern"]
        }

    def execute(self, **kwargs: Any) -> str:
        pattern = kwargs["pattern"]
        path = kwargs.get("path", ".")
        try:
            result = subprocess.run(
                ["grep", "-rn", "--include=*.py", pattern, path],
                capture_output=True, text=True, timeout=10
            )
            output = result.stdout[:5000]
            if not output:
                return f"No matches found for pattern: '{pattern}'"
            return output
        except subprocess.TimeoutExpired:
            return "TIMEOUT: search exceeded 10 seconds"

后两个工具解决"修改和验证"的问题:

python
class WriteFile:
    @property
    def name(self) -> str:
        return "write_file"

    @property
    def description(self) -> str:
        return "Write content to a file. Creates parent directories if needed."

    @property
    def parameters(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "File path to write to"
                },
                "content": {
                    "type": "string",
                    "description": "Content to write to the file"
                }
            },
            "required": ["path", "content"]
        }

    def execute(self, **kwargs: Any) -> str:
        path = kwargs["path"]
        content = kwargs["content"]
        try:
            os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
            with open(path, "w") as f:
                f.write(content)
            return f"Successfully wrote {len(content)} characters to {path}"
        except PermissionError:
            return f"ERROR: Permission denied: {path}"
python
class RunCommand:
    BLOCKED_PATTERNS = [
        "rm -rf /", "mkfs", "dd if=", "> /dev/",
        ":(){ :|:& };:", "chmod -R 777 /",
    ]

    @property
    def name(self) -> str:
        return "run_command"

    @property
    def description(self) -> str:
        return "Execute a shell command and return its output. Dangerous commands are blocked."

    @property
    def parameters(self) -> dict[str, Any]:
        return {
            "type": "object",
            "properties": {
                "command": {
                    "type": "string",
                    "description": "Shell command to execute"
                }
            },
            "required": ["command"]
        }

    def execute(self, **kwargs: Any) -> str:
        cmd = kwargs["command"]
        for pattern in self.BLOCKED_PATTERNS:
            if pattern in cmd:
                return f"BLOCKED: command contains forbidden pattern '{pattern}'"
        try:
            result = subprocess.run(
                cmd, shell=True, capture_output=True, text=True,
                timeout=30
            )
            output = result.stdout[:5000]
            if result.returncode != 0:
                output += f"\nSTDERR: {result.stderr[:2000]}"
            return output
        except subprocess.TimeoutExpired:
            return "TIMEOUT: command exceeded 30 seconds"

注意 RunCommand 的三层防护:BLOCKED_PATTERNS 拦截最明显的破坏性命令,timeout=30 防止命令无限执行,输出截断 [:5000] 防止上下文窗口被撑爆。

WARNING

L1 影响概率,L2 守住底线 System Prompt 告诉模型"不要执行破坏性命令"——这是第4篇的 L1 Prompt Guidance,它影响概率但不保证行为。BLOCKED_PATTERNS 是 L2 Code Defense——无论模型怎么决策,黑名单里的命令永远不会被执行。还记得第4篇的分层防御吗?这就是 L1 和 L2 在同一个 Agent 里协同工作的样子。

另一种流派:CodeAct

我们用 Tool Calling(结构化 JSON)来表达 Agent 的动作,但 OpenHands(原 OpenDevin)选择了另一条路—— ,让模型直接写 Python 代码作为动作,省去了预定义工具的步骤。

代码即动作意味着灵活性极高——你不需要提前定义工具,任何 Python 能做的事 Agent 都能做。但代价是 Agent 的行为空间几乎无限,安全约束的难度成倍增加。

Tool Calling 的好处是工具边界清晰、每次调用可审计——这在生产环境中往往比灵活性更重要。

给 Agent 一个大脑:System Prompt 设计

第4篇我们讨论了 L1 Prompt Guidance 的设计原则——角色边界、行为约束、安全红线。现在是它们的第一次实战。

text
You are a coding assistant that helps developers write, test, and debug code.
You work inside a project directory and have access to tools for reading files,
writing files, searching code, and running commands.

Before making changes, always read the relevant files first to understand the
existing code style, patterns, and conventions. Never guess at code structure.

When writing code, match the style of the existing codebase — indentation,
naming conventions, import patterns. Do not introduce new dependencies without
being asked.

After writing or modifying code, run the relevant tests to verify your changes
work. If tests fail, read the error output carefully and fix the issue before
reporting success.

Never execute destructive commands (rm -rf, drop database, etc.) unless the
user explicitly asks you to.

5 段 Prompt,每一段都对应一个 L1 设计原则:

段落做了什么L1 原则
① 角色 + 工具边界定义身份和可用工具范围角色边界约束
② 先读后写强制 Agent 在修改前先理解现有代码行为序列引导(呼应第2篇 ReAct Observe-first)
③ 匹配风格要求输出符合现有代码风格Coding 场景特有——新增约束
④ 写完要测修改代码后必须运行测试验证验证循环引导
⑤ 安全红线禁止破坏性命令L1 Guidance(与 L2 BLOCKED_PATTERNS 联动)
为什么用英文写 System Prompt?

系列中所有 System Prompt 使用英文,因为主流模型在英文指令上的遵从率更高——这在代码相关任务中尤为明显。

TIP

Context Engineering 实践 System Prompt 定义了 Agent 的通用行为,但每个项目还有自己的约定——目录结构、测试命令、代码风格。越来越多的开源项目用 AGENTS.md 或类似文件记录这些项目级上下文,让 Agent 在不同仓库之间无缝切换。这就是 Context Engineering 的工程落地。

组装 CodingAgent

工具有了,System Prompt 有了,现在把它们组装成一个完整的 Agent。这个 CodingAgent 类实现了第4篇定义的 Agent Protocol——run(messages) -> Message

python
from openai import OpenAI
import json
from shared.types import Message, ToolResult, Tool
from shared.prepare_tools import prepare_tools_for_openai

SYSTEM_PROMPT = """You are a coding assistant that helps developers write, test, and debug code.
You work inside a project directory and have access to tools for reading files,
writing files, searching code, and running commands.

Before making changes, always read the relevant files first to understand the
existing code style, patterns, and conventions. Never guess at code structure.

When writing code, match the style of the existing codebase — indentation,
naming conventions, import patterns. Do not introduce new dependencies without
being asked.

After writing or modifying code, run the relevant tests to verify your changes
work. If tests fail, read the error output carefully and fix the issue before
reporting success.

Never execute destructive commands (rm -rf, drop database, etc.) unless the
user explicitly asks you to."""


class CodingAgent:

    def __init__(self, tools: list[Tool], model: str = "gpt-4o"):
        self.client = OpenAI()
        self.model = model
        self.tools = {t.name: t for t in tools}
        self.tool_schemas = prepare_tools_for_openai(tools)

    def run(self, messages: list[Message]) -> Message:
        conversation = [
            {"role": "system", "content": SYSTEM_PROMPT},
            *[{"role": m.role, "content": m.content} for m in messages],
        ]

        while True:
            response = self.client.chat.completions.create(
                model=self.model,
                messages=conversation,
                tools=self.tool_schemas,
            )
            choice = response.choices[0]

            if choice.finish_reason == "stop":
                return Message(
                    role="assistant",
                    content=choice.message.content,
                )

            assistant_msg = choice.message
            conversation.append(assistant_msg)

            for tool_call in assistant_msg.tool_calls:
                name = tool_call.function.name
                args = json.loads(tool_call.function.arguments)

                tool = self.tools.get(name)
                if not tool:
                    result_str = f"ERROR: Unknown tool '{name}'"
                else:
                    result_str = tool.execute(**args)

                tool_result = ToolResult(
                    content=result_str,
                    is_error=any(
                        k in result_str
                        for k in ["ERROR", "BLOCKED", "TIMEOUT"]
                    ),
                )

                conversation.append({
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": tool_result.to_str(),
                })

几个关键设计决策值得解释:

json.loads(tool_call.function.arguments)——OpenAI 的 arguments 返回 JSON 字符串,必须解析;Anthropic 的 input 直接是 dict。这个跨厂商差异我们在第3篇和第5篇都处理过,prepare_tools 双函数(第5篇)正是为此而生。

ToolResult 在 Agent Loop 侧构造,不在工具侧——注意 ToolResult 是在循环里构造的,不是工具返回的。工具只管执行并返回字符串——这保持了 Tool Protocol 的 execute(**kwargs) -> str 签名不变。is_error 的判断通过字符串匹配实现:任何包含 ERRORBLOCKEDTIMEOUT 的输出都标记为错误。细心的读者可能已经发现:如果工具输出的正常内容恰好包含"ERROR"字样(比如读一个 error handler 的源码),is_error 就会误判。这正是字符串匹配的局限——第9篇的 ToolResult.status 字段会用枚举值彻底解决这个问题。

run(messages) -> Message——这就是第4篇结尾预告的 Agent Protocol 的第一次完整实现。一个 CodingAgent 实例可以像普通函数一样被调用——传入消息,返回结果。第7篇我们会看到,这个统一接口让单个 Agent 可以无缝编入多 Agent 系统。

使用方式很简单:

python
agent = CodingAgent(
    tools=[ReadFile(), WriteFile(), SearchCode(), RunCommand()]
)
reply = agent.run([
    Message(role="user", content="帮我给 calculator.py 写单元测试")
])
print(reply.content)

实战:帮我写单元测试

光看代码不够——来看 Agent 怎么真正工作。

假设项目里有一个 calculator.py

python
class Calculator:
    def add(self, a: float, b: float) -> float:
        return a + b

    def subtract(self, a: float, b: float) -> float:
        return a - b

    def multiply(self, a: float, b: float) -> float:
        return a * b

    def divide(self, a: float, b: float) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

用户发送:"帮我给 calculator.py 写单元测试"。接下来的对话中,Agent 会犯一个错误——然后自己修复它。这不是"生成完美代码"的展示,而是读→写→验证→修复→再验证闭环的完整演示。

Round 1 — 先读,后动手

text
🤖 Thought: 用户要我写单元测试。先读 calculator.py 了解代码结构。

📤 Action: read_file(path="calculator.py")

📥 Observation:
class Calculator:
    def add(self, a: float, b: float) -> float:
        return a + b

    def subtract(self, a: float, b: float) -> float:
        return a - b

    def multiply(self, a: float, b: float) -> float:
        return a * b

    def divide(self, a: float, b: float) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return a / b

System Prompt 第②段的"先读后写"驱动了这一步——Agent 没有直接凭空生成测试,而是先读取源码理解结构。

Round 2 — 写测试

text
🤖 Thought: Calculator 有4个方法,divide 有除零保护。
   我需要覆盖正常路径和边界情况。

📤 Action: write_file(path="test_calculator.py", content="import pytest\nfrom calculator import Calculator\n\nclass TestCalculator:\n    def setup_method(self):\n        self.calc = Calculator()\n\n    def test_add(self):\n        assert self.calc.add(2, 3) == 5\n\n    def test_subtract(self):\n        assert self.calc.subtract(10, 4) == 6\n\n    def test_multiply(self):\n        assert self.calc.multiply(3, 4) == 12\n\n    def test_divide(self):\n        assert self.calc.divide(1, 3) == 0.33\n\n    def test_divide_by_zero(self):\n        with pytest.raises(ValueError, match=\"Cannot divide by zero\"):\n            self.calc.divide(1, 0)\n")

📥 Observation: Successfully wrote 502 characters to test_calculator.py

看到 test_divide 了吗?assert self.calc.divide(1, 3) == 0.33——这里埋了一个坑。

Round 3 — 跑测试,发现失败

text
🤖 Thought: 测试写完了,跑一下验证。

📤 Action: run_command(command="python -m pytest test_calculator.py -v")

📥 Observation:
============================= test session starts ==============================
test_calculator.py::TestCalculator::test_add PASSED
test_calculator.py::TestCalculator::test_subtract PASSED
test_calculator.py::TestCalculator::test_multiply PASSED
test_calculator.py::TestCalculator::test_divide FAILED
test_calculator.py::TestCalculator::test_divide_by_zero PASSED

FAILED test_calculator.py::TestCalculator::test_divide
    assert self.calc.divide(1, 3) == 0.33
    AssertionError: assert 0.3333333333333333 == 0.33
STDERR: 1 failed, 4 passed in 0.03s

System Prompt 第④段"写完要测"驱动了 Round 3——Agent 没有写完就报告成功,而是主动运行测试验证。

Round 4 — 理解错误,修复

text
🤖 Thought: 失败原因是浮点精度——1/3 = 0.333...,不等于 0.33。
   Python 浮点数不能用 == 做精确比较,应该用 pytest.approx。

📤 Action: write_file(path="test_calculator.py", content="import pytest\nfrom calculator import Calculator\n\nclass TestCalculator:\n    def setup_method(self):\n        self.calc = Calculator()\n\n    def test_add(self):\n        assert self.calc.add(2, 3) == 5\n\n    def test_subtract(self):\n        assert self.calc.subtract(10, 4) == 6\n\n    def test_multiply(self):\n        assert self.calc.multiply(3, 4) == 12\n\n    def test_divide(self):\n        assert self.calc.divide(1, 3) == pytest.approx(0.333, abs=1e-3)\n\n    def test_divide_by_zero(self):\n        with pytest.raises(ValueError, match=\"Cannot divide by zero\"):\n            self.calc.divide(1, 0)\n")

📥 Observation: Successfully wrote 518 characters to test_calculator.py

Agent 没有简单地把 0.33 改成 0.333333,而是用了 pytest.approx——这是 Python 测试中处理浮点比较的正确方式。更高效的做法是只修改出错的那一行(用 edit_fileapply_diff),但全量写入的逻辑更直接,这里我们保持简单。

Round 5 — 再次验证

text
🤖 Thought: 修复完了,再跑一次确认。

📤 Action: run_command(command="python -m pytest test_calculator.py -v")

📥 Observation:
============================= test session starts ==============================
test_calculator.py::TestCalculator::test_add PASSED
test_calculator.py::TestCalculator::test_subtract PASSED
test_calculator.py::TestCalculator::test_multiply PASSED
test_calculator.py::TestCalculator::test_divide PASSED
test_calculator.py::TestCalculator::test_divide_by_zero PASSED
============================= 5 passed in 0.03s ===============================

最终回复:test_calculator.py 已创建,包含 5 个测试用例,覆盖 Calculator 的所有方法以及除零边界情况。全部测试通过。

5 轮对话,4 次工具调用,1 次自动修复。Round 3 暴露的浮点精度问题不是 Agent 的 bug——而是 Agent 发现并自主修复了一个 Python 新手常犯的错误。这正是 Coding Agent 的核心价值:不是生成完美代码,而是写→测→发现问题→理解错误→修复→再验证的闭环。System Prompt 的每一行都在影响这个行为序列——第②段驱动了 Round 1 的"先读后写",第④段驱动了 Round 3 的"写完必测",第⑤段让 Agent 始终远离破坏性操作。L1 Prompt Guidance 不只是文字——它塑造了 Agent 的行为模式。

安全:字符串匹配不是终点

DANGER

没有安全防护的 Coding Agent 可能造成什么后果?

  • 一个 AI 编码 Agent 的操作失误导致 13 小时宕机
  • 另一个 Agent 在开发者的 Mac 上执行了 rm -rf ~/,删除了整个用户目录
  • PocketOS 的 9 秒丢库事件:AI Agent 在未经确认的情况下执行了清除操作

这些都是真实案例2

我们的 BLOCKED_PATTERNS 能拦住最明显的破坏性命令——但字符串匹配永远跑不赢创造力。rm -rf / 的变体有无数种:find / -deleteperl -e 'use File::Path; rmtree("/")'、甚至 python -c "import shutil; shutil.rmtree('/')" 都能绕过黑名单。

字符串匹配是 L2 Code Defense 的最小实现。真正的生产方案需要进程级隔离——把 Agent 的所有命令放进 Docker 容器,即使执行了 rm -rf /,删除的也只是容器内的文件系统。这是第9篇的内容。

从单兵到协作

Anthropic 对约 400,000 个 Coding Agent 会话的研究发现了一个有趣的结论3领域专业知识比编码技能更重要。最成功的 Coding Agent 用户不是最好的程序员,而是最懂自己业务领域的人——他们知道要让 Agent 做什么。

今天我们构建了一个能独立工作的 Coding Agent。但真实场景往往需要多个 Agent 协作——一个负责写代码,一个负责审查,一个负责测试。CodingAgent.run() 返回的 Message 对象,可以直接传给另一个 Agent 的 run() 方法——这就是 Agent Protocol 统一接口的威力。下一篇我们来看它如何支撑多 Agent 编排。

参考文献

  1. Position: Coding Benchmarks Are Misaligned with Agentic Software EngineeringGorinova et al. · arXiv, 2026 · 论文
  2. AI Coding Agent Horror StoriesDocker · Docker Blog, 2026 · 博客
  3. Agentic coding and persistent returns to expertiseHitzig, Massenkoff, Lyubich et al. · Anthropic Research, 2026 · 论文