AI 编码 Agent 从原理到可运行代码

1. 概述

过去五年,开发者与 AI 的关系被「Tab 键」定义:模型猜下一行,人决定接不接受。GitHub Copilot 把这件事做到了极致,也把很多人锁在一个错误心智模型里——以为 AI 写代码的上限,就是更准的补全、更长的提示词、更大的上下文窗口。

2024 到 2026 年真正发生的变化,不是模型突然「更会写代码」,而是产品形态从生成器变成了执行器。Claude Code、Cursor Agent、Codex CLI、Devin、Aider、Cline 这些工具表面长得不一样:有的住在终端,有的住在 IDE,有的住在云端虚拟机。底层却收敛到同一件事:

给大模型一双手(工具),再给它一个不停转的循环(观察 → 推理 → 行动 → 再观察),让它在真实代码仓库里自己把任务做完。

这个循环有一个学术名字:ReAct(Reason + Act)。2022 年提出时,它只是让 LLM 交替输出「思考」和「动作」。2026 年,它已经变成几乎所有编码 Agent 的操作系统内核。模型本身仍然是无状态的:每一次 API 调用都看不到上一轮之外的世界。真正让 Agent「像工程师一样干活」的,是循环外面那一层工程系统——上下文怎么拼、工具怎么派发、编辑怎么落地、失败怎么回收、权限怎么卡死。

这篇文章面向两类读者。一类是刚接触 Agent 的开发者:你不需要先啃完论文,也能顺着比喻和流程图把原理看懂。另一类是准备自己做、或准备把现有工具用到极限的工程师:你会看到生产级架构怎么分层、主流产品怎么取舍、以及一份可以直接跑的实现。

读完你应该能回答五个问题:

  1. 编码 Agent 和 Copilot、ChatGPT 写代码,差在哪一层?
  2. 那个「会自己干活」的循环,到底在转什么?
  3. 为什么同样一个模型,套上不同 Harness(驾驭层)表现天差地别?
  4. 自己从零写一个能改文件、跑命令、根据测试结果自我修复的 Agent,最少要哪些代码?
  5. 接下来两年,开发者的工作会变成编排、审核和定标准,而不是和 Tab 键较劲。

一句话先行结论:模型是大脑,工具是手,循环是意志,上下文工程是视力,安全沙箱是良心。缺任何一块,你得到的都只是会说话的补全,而不是能交付的工程师。


2. 内容

简要介绍:这一节先把概念立住,再把原理拆开。先用三个日常场景区分「补全 / 聊天写代码 / 编码 Agent」,再把 ReAct 循环、五件套(规划、记忆、工具、执行、反思)、上下文工程、Function Calling 与 CodeAct、以及代码编辑策略一层层摊开。读这一节时,请始终记住一个事实:大模型不会「记住」仓库,也不会「执行」代码;它只是在每一轮里,根据你塞给它的 token,决定下一句话或下一次工具调用。Agent 的全部魔法,都发生在这一轮和下一轮之间。

2.1 从 Copilot 到 Agent:一次被低估的范式跃迁

先用一个具体任务把三种形态钉死。假设你接到一张工单:

「用户登录接口在 address 为空时会 NPE。补上空值保护,补回归测试,确认 mvn test 全绿。」

形态 A:行内补全(Copilot Tab / Cursor Tab)。
你打开 UserController.java,光标停在 address.getStreet() 前面。模型猜出 if (address == null) return;。你按 Tab。测试还是你自己写,构建还是你自己跑,相邻的 DTO 和异常处理还是你自己找。它加速的是击键,不加速任务闭环

形态 B:聊天生成(ChatGPT / Claude 对话框)。
你把文件贴进去,模型吐出一段修好的代码。你复制、粘贴、跑测试、发现测试没覆盖空地址、再回去问模型、再复制。模型能推理,但没有手。仓库、测试、报错,全靠你当中间人。

形态 C:编码 Agent。
你只给一句话目标。Agent 自己 grep 空指针调用点,读相关测试,改实现,跑 mvn test,看到 3 个用例过了但缺空地址用例,再补测试,再跑,4 个全绿,最后给出 diff 或直接开 PR。中间可能要 8 到 30 轮工具调用。你审核的是结果,不是每一行击键。

三种形态可以画成一条能力阶梯:

flowchart LR A["补全<br/>下一行 token"] --> B["聊天生成<br/>一段代码"] B --> C["单轮工具调用<br/>读一个文件"] C --> D["Agent 循环<br/>改-测-修直到完成"] D --> E["多 Agent / 云端<br/>并行子任务 + PR"]

2021 到 2026,业界其实走的就是这条阶梯,只是每次换名字:

阶段 大致时间 核心问题 工程对象 失败形态
代码补全 2021–2022 下一行对不对 模型 + 局部上下文 建议不准
提示词工程 2022–2023 这一轮有没有说清楚 指令措辞、角色、示例 模型听错约束
上下文工程 2024–2025 模型有没有看到该看的东西 检索、仓库地图、历史压缩 自信地改错地方
Harness 工程 2025–2026 多步之后还能不能做对 工具、权限、验证器、循环 循环空转或改崩仓库
工作流工程 2026 起 这件事能不能周期性自动完成 目标、调度、验收标准 无人值守后质量漂移

对初学者最重要的分界线是这一句:

Chatbot 回答问题;Agent 追求目标。
Chatbot 的停止条件是「模型说完了」。Agent 的停止条件是「环境证明目标达成了」——测试绿了、lint 干净了、PR 描述写好了,或者触发了最大步数 / 人工叫停。

所以,编码 Agent 不是「更聪明的 Copilot」。Copilot 优化的是 token 预测;Agent 优化的是在有副作用的环境里,把一个软件工程目标收敛到可验证的完成态。前者是语言模型问题,后者是控制系统问题。大脑可以共用,控制系统不能省。

再补一个容易混的词:Harness(驾驭层)。2026 年这个词被用滥了,但意思很具体:模型之外、让模型能在仓库里安全地连续行动的那一层软件。Claude Code、Cursor Composer、Aider、Codex CLI 卖的主要不是模型(很多还允许换模型),而是 Harness:循环怎么转、上下文怎么拼、工具白名单、编辑格式、git 回滚、子 Agent、Hooks。同一颗 Claude Opus,塞进「只读聊天框」和塞进「带测试闭环的终端 Agent」,SWE-bench 分数可以差出一个时代。

2.2 ReAct 循环:编码 Agent 的心脏

ReAct 来自 Yao 等人 2022 年的论文 ReAct: Synergizing Reasoning and Acting in Language Models。想法朴素到近乎无礼:不要让模型一次性空想出最终答案,而让它像人一样,边想边做边看。

三拍循环:

Thought(思考) → Action(行动) → Observation(观察)
        ↑                                    │
        └────────────────────────────────────┘
                     直到任务完成

翻译成编码场景:

观察:  UserController.java 第 47 行,address.getStreet() 没有空判断
规划:  在调用前加 null check
行动:  写入修复
观察:  mvn test —— 3 通过,0 失败
规划:  还要确认空地址路径有没有测试
行动:  读 UserControllerTest.java,发现没有 null 用例
规划:  补回归测试
行动:  写入测试
观察:  mvn test —— 4 通过,0 失败
结束:  可以开 PR

用流程图画生产级循环,会比论文里的三拍多两步,因为工程上必须处理「拼上下文」和「要不要停」:

flowchart TD Start(["收到任务"]) --> Assemble["拼装上下文<br/>系统提示 / 仓库摘录 / 历史 / 工具结果"] Assemble --> LLM["调用 LLM"] LLM --> Decide{"返回了什么?"} Decide -->|"纯文本,无工具调用"| Done(["回复用户,结束本轮任务"]) Decide -->|"一个或多个工具调用"| Dispatch["工具分发与安全校验"] Dispatch --> Exec["执行:读文件 / 写文件 / grep / bash / 测例"] Exec --> Observe["把 stdout、stderr、退出码、diff 变成 Observation"] Observe --> Append["追加到对话历史"] Append --> Limit{"达到最大步数<br/>或用户中断?"} Limit -->|否| Assemble Limit -->|是| Stop(["安全停止并汇报现状"])

请务必把下面这句话刻进脑子:模型是无状态的。 所谓「Agent 记得刚才读过 UserController.java」,并不是模型内部有一块内存芯片,而是编排器把那次 read_file 的返回值,原样(或压缩后)塞进了下一轮 messages。Agent 的「记忆」,在最小实现里就是一个不断变长的 messages 数组。

伪代码几乎短到尴尬——所有生产系统都是在这六行外面堆工程:

while not done and steps < MAX_STEPS:
    context = assemble(system_prompt, history, tool_results)
    response = llm.complete(context, tools=TOOLS)
    if not response.tool_calls:
        return response.text          # 模型认为做完了
    results = [dispatch(call) for call in response.tool_calls]
    history.append(response, results) # 观察写回
    steps += 1

初学者常有三个误判,这里提前拆掉:

误判 1:循环越长越聪明。
不是。每多一轮就多一次推理误差、多一截噪声上下文、多一笔 token 账单。优秀 Harness 的目标是用更少的高信号步骤完成任务,而不是放任模型在仓库里散步。Sourcegraph 在 2026 年的 CodeScaleBench 里给过一个刺眼对比:同样的跨文件重构,基线 Agent 用本地 grep 走了 96 次工具调用、84 分钟;换成代码图谱检索后只需 5 次调用、4.4 分钟,奖励分数还翻倍。循环次数是成本,不是能力。

误判 2:Thought 必须用自然语言写出来。
早期 ReAct 示范里,模型会先说「我应该先读测试文件」。现代 Function Calling 里,Thought 常常折叠进模型内部的 chain-of-thought / extended thinking,对外只暴露结构化的 tool_calls。对编排器来说,工具调用就是行动,工具结果就是观察。你在 Claude Code 终端里看到的「正在读取 xxx」,是产品把内部状态翻译给你看,不是循环本身需要向你播报。

误判 3:模型会在循环中途「学会」新技能。
单次任务循环里的学习,只是上下文里多了几条 Observation。关掉会话,权重不会变。真正跨会话的改进,要靠后文要讲的记忆文件、经验库、评测回流,而不是幻想「它用过一次 pytest 就永远更懂 pytest」。

一次真实任务通常 5 到 50 轮。每轮至少一次 LLM 调用。这就是为什么编码 Agent 比聊天贵,也是为什么上下文管理会成为 2026 年最关键的工程问题:你不是在付「写一段代码」的钱,你是在付「一个初级工程师坐在你电脑前面,连续读报错、改文件、再跑 20 分钟」的钱。

2.3 五件套:规划、记忆、工具、执行、反思

ReAct 只是骨架。骨架上不焊部件,Agent 走不远。业界把这些部件叫法不一,但功能稳定,可以记成五件套:

组件 它在循环里干什么 没有它会怎样 编码场景里的典型形态
Planning 规划 把「修登录 NPE」拆成可执行步骤 走一步看一步,复杂重构必跑偏 Plan 模式、todo.md、Architect/Editor 分工
Memory 记忆 跨步骤、跨会话保住状态 第 20 步忘掉第 2 步的约束 messages、CLAUDE.md、向量索引、scratchpad
Tools 工具 把外部世界变成可调用函数 只能吐文本,不能碰仓库 read/write/grep/bash、MCP Server
Executor 执行器 真正跑命令、改磁盘 工具调用停在 JSON 里 本地 subprocess、Docker、微虚拟机、git worktree
Reflection 反思 根据失败改策略,而不是原地重试 同一处报错循环撞击 Reflexion 笔记、测试失败分析、自评 rubric

它们不是五层蛋糕,而是焊在循环不同相位上的插件:

flowchart LR subgraph loop ["单次循环"] P["Planning<br/>必要时先出计划"] --> T["Thought"] T --> A["Action = Tools"] A --> E["Executor"] E --> O["Observation"] O --> M["写入 Memory"] M --> R["Reflection<br/>要不要改方向"] R --> T end

对编码 Agent,每一件都有非常具体的样子。

规划。
纯 ReAct 是近视的:每步只优化「此刻最合理的下一个动作」。修一个 20 文件的重构,近视循环会在中途改口、重复劳动、甚至把刚修好的东西改回去。所以生产工具几乎都加了 Plan-then-Execute:先产出一份「改哪些文件、什么顺序、完成标准」,人批准后再执行。Aider 更进一步,用强模型当 Architect 出方案,用另一个(可更小的)模型当 Editor 出补丁。计划还有一个隐藏价值:上下文压缩之后,早期对话细节会丢,但计划可以当作锚点留在窗口里,避免 Agent 失忆后改去做另一件事。

记忆。
至少分五层,初学者不要一上来就上向量数据库:

  1. 工作记忆:当前 messages。寿命 = 这一次会话。最贵,也最准。
  2. 项目记忆CLAUDE.mdAGENTS.md.cursorrules。人写的「这个仓库怎么干活」:构建命令、目录约定、不许碰的目录。
  3. 过程记忆:Agent 自己写的 todo.md / memory.md / scratchpad。长任务中途用来对抗失忆。
  4. 语义记忆:仓库向量索引、符号图谱。用来回答「认证逻辑在哪」这种自然语言问题。
  5. 经验记忆:跨任务的失败案例、团队规范摘要。2026 年部分系统开始做定期「复盘」(有的称为 Dreaming):从历史会话里提炼模式,写回编排记忆。

Anthropic 给过一个很朴素但极其有效的模式:让模型把笔记写到上下文窗口外面的文件里,需要时再读回来。 这比幻想无限上下文便宜得多,也稳定得多。

工具。
工具是 Agent 的手。手太少,它只能空想;手太多、描述含糊,它会在「该用 grep 还是该用 codebase_search」之间烧轮次。后文架构部分会专门讲 MCP 和工具膨胀。这里先记一条经验法则:人如果都说不清此刻该用哪一个工具,模型更说不清。第一版工具集,永远应该比你想的更小。

执行器。
JSON 工具调用只是意图。真正改磁盘、跑测试的是执行器。执行器决定 Agent 的「身体」:没有它,Agent 是嘴;有了 bash 和文件系统,Agent 才是工程师;有了浏览器,它才能自己查文档、点 UI;有了隔离的 git worktree 或云虚拟机,它才能并行,而不把你正在改的分支改炸。

反思。
最便宜的反思是把 stderr 原样丢回模型。更好的反思是强制它先写三行:什么失败了、根因假设、下一步不重复的策略。Reflexion(Shinn 等,2023)把这三行叫做「反思笔记」,贴进下一次 prompt。很多「Agent 很笨、一直重试同一条命令」的现场,缺的不是更强模型,而是这一段强制转向。

缺任何一件的后果可以记成口诀:

没有规划就是聊天机器人;没有记忆就忘事;没有工具就只会写字;没有执行器就空想;没有反思就一条路撞死。

2.4 上下文工程:决定 Agent 成败的真正战场

到 2025 年中,有经验的人已经承认:提示词不再是主瓶颈。主瓶颈是——每一轮推理时,到底往窗口里塞什么。 这件事现在有名字:上下文工程(Context Engineering)。

Anthropic 的定义很干净:在推理时,策展并维持那一组「刚好够用」的 token。提示词工程管的是一句话怎么说;上下文工程管的是这句话周围整条管道:系统指令、检索到的代码、工具定义、历史、记忆、输出格式。

对编码 Agent,这一点被放大到残酷。聊天机器人一轮就结束;编码 Agent 可能在第 47 步做决策,而第 1 到 46 步的残渣还堆在窗口里。token 预算有限,注意力预算更有限。Chroma 等机构的研究表明,上下文质量从窗口大约 25% 开始就会下降,而不是等到 100% 才崩。这叫 context rot:塞得越多,模型越记不住中间那截真正重要的东西。2023 年那篇 Lost in the Middle 已经证明,关键信息放在开头或结尾时表现最好,埋在长上下文中间会显著变差。所以「我有 100 万 token 窗口,把整个仓库灌进去」不仅贵,而且常常更蠢。

编码 Agent 的上下文,通常由四块拼出来:

flowchart TB subgraph budget ["有限的上下文窗口"] S["系统提示 + 项目约定<br/>CLAUDE.md / 安全规则"] T["工具定义<br/>越少越好、描述必须互斥"] H["对话与工具历史<br/>可压缩、可摘要"] R["本轮检索到的代码<br/>文件 / 仓库地图 / 符号定义"] end S --> LLM T --> LLM H --> LLM R --> LLM LLM["模型本轮推理"]

四根柱子对应四个工程问题:指令、检索、记忆、工具。对编码任务,检索这一柱最容易出事故。一个典型失败是:在百万行单体里 grep 某个符号,返回 4000 条命中,Agent 把窗口烧在无关文件上,真正的根因从头到尾没进过上下文。模型不是不会修,是没看见该修的地方。

业界大概形成了三种互补的检索策略:

仓库地图(Aider 代表)。
用 tree-sitter 把仓库解析成「类名、函数签名、引用关系」的缩略图,函数体先不放进来。一份大仓库可以被压成几千 token 的结构说明书。真正要改某个文件时,再把该文件全文塞进去。两级视野:地图负责定向,全文负责动手。适合中小型仓库,实现简单,token 效率极高。

向量 / 语义索引(Cursor 代表)。
把代码块做成 embedding,用「认证逻辑在哪」这种自然语言去搜。对说人话的查询很友好,但对「这个符号的唯一定义点」不如编译器级索引稳。索引还会过期:上季度 embed 的块,可能已经不是生产里跑着的函数。

代码图谱 / SCIP 式精确导航(Sourcegraph 等)。
问的是 RecordAccumulator 的定义,返回的就是定义文件加三处调用点,而不是 50 个碰巧包含这个字符串的文件。在企业级多仓场景,这往往是从「超时」到「几分钟内完成」的差别。

无论哪一种,上下文装配都必须有裁剪纪律:

  • 工具输出截断(一个 2 万行的测试日志,只留失败附近)。
  • 旧对话压缩成「已做决策 + 关键路径 + 未完成项」。
  • 检索结果设相关度阈值和 top-k。
  • 高信号内容放窗口两端,不要堆在中间。

还有一个 2026 年被反复打脸的坑:工具定义本身就会吃窗口。 有报告称,三个 MCP Server 的工具描述就能吃掉 20 万窗口里的 14 万 token——用户问题还没进场。工具选择准确率会随着工具集膨胀显著下降。后文会把「工具要少、描述要互斥、按需加载」写成硬约束。

所以,把上下文工程记成四句口诀已经够用:

取什么,何时取,如何压,何时扔。
改提示词是在调措辞;改这四句,才是在调 Agent。

2.5 Function Calling、CodeAct 与代码怎么被改进仓库

循环决定 Agent「还干不干」,工具调用格式决定它「怎么动手」,编辑策略决定「改动能不能稳稳落到文件上」。这三件事经常被混成一句「模型会改代码」,必须拆开。

Function Calling:给行动一个 JSON 插座

现代模型并不在文本里自由发挥「我要调用 read_file」。它们在训练和对齐阶段就学会了输出结构化的 tool_calls:名字 + 参数。编排器校验参数,执行,把结果以 role=tool 的消息写回。这是 ReAct 在 API 层的实现。

一次典型往返:

{
  "role": "assistant",
  "tool_calls": [
    {
      "id": "call_1",
      "type": "function",
      "function": {
        "name": "read_file",
        "arguments": "{\"path\": \"src/auth/login.py\"}"
      }
    }
  ]
}

执行器返回:

{
  "role": "tool",
  "tool_call_id": "call_1",
  "content": "def login(user):\n    ..."
}

模型下一轮看到这份 Observation,再决定是继续读测试,还是直接 edit_file

Function Calling 的优点是约束强、编排器好写、便于权限拦截。缺点是每一步都要出一次 JSON、中间结果必须回到上下文,多步数据变换会反复烧 token。

CodeAct:让行动变成一段 Python

2024 年 Wang 等人提出 CodeAct:不要每次只调一个被 schema 绑死的工具,让模型直接写一段可执行代码,工具以函数形式存在于这段代码的运行时里。

JSON 工具调用:

{"tool": "search_menu", "args": {"restaurant_id": 42}}

CodeAct:

menu = search_menu(restaurant_id=42)
cheap = [d for d in menu if d.price < 100]
return sorted(cheap, key=lambda d: d.rating, reverse=True)[:3]

循环、过滤、错误处理走的是 Python 语义,而不是外层编排器的 if-else。对数据分析、批量改文件、需要把三次工具结果捏在一起的任务,token 可以少一个数量级。2026 年,Anthropic 的 Programmatic Tool Calling、以及部分沙箱产品的 Code Mode,走的就是这条路:在一个可复用的容器里跑模型写的代码,状态可以跨步保留。

代价也清楚:执行面更大,沙箱必须更硬;模型写出的代码本身可能有 bug;调试「模型写的胶水代码」比调试一次 JSON 调用更麻烦。编码 Agent 的日常路径仍然是 Function Calling 为主、CodeAct 为辅——读改跑测用工具调用更可控,需要批量处理中间数据时再切到代码即行动。

四种把补丁打进文件的方法

这是编码 Agent 最有「手感」的架构决策,直接影响贵不贵、稳不稳、烂不烂。

1. 整文件重写。
模型输出改完后的全文,直接覆盖。无歧义,但改一行也要付 500 行输出 token。只适合短文件,或当模型搞不定 diff 格式时的回退。

2. 搜索替换块(Aider 默认)。
模型产出成对的「旧文本 / 新文本」。省 token,但空白、缩进、过期内容会导致匹配失败。Aider 用模糊匹配兜底。

<<<<<<< SEARCH
    return address.getStreet();
=======
    if (address == None):
        return ""
    return address.getStreet();
>>>>>>> REPLACE

3. 结构化 Edit 工具(Claude Code 一类)。
参数是 old_string + new_string,并带唯一性约束:旧串必须在文件中恰好出现一次。匹配 0 次或多次都返回错误,模型据此收紧上下文再试。错误发生在落盘前,比「写进去才发现改错了」安全。

4. AST 感知编辑(IDE 型 Agent,如 Cursor)。
按语法节点(函数、class、import)改,而不是按纯文本。对重命名、移动符号、保证括号匹配最稳,但要语言级解析器,实现成本最高。

可以记一张对照表:

策略 Token 成本 可靠性 适用
整文件重写 很高 高(无 diff 歧义) 小文件、模型不擅长 diff
搜索替换 中(怕空白和过期) 通用、跨模型
结构化 Edit 高(唯一性检查) 工具调用型 Agent
AST 编辑 最高 IDE 内、语言确定

到这里,原理层可以收口:

编码 Agent = 无状态 LLM + 有状态循环 + 被严格预算管理的上下文 + 一组少而清的工具 + 一种可靠的编辑格式 + 一个能跑测试的执行器。
模型提供判断;其余全部是软件工程。

下一节把这些零件装进生产级分层,并对照 2026 年的主流产品。


3. 架构剖析

简要介绍:原理告诉你循环为什么能转;架构告诉你转起来之后,怎样才不会把用户的仓库转穿。这一节给出一个足够用的分层模型,然后分别深入工具与 MCP、安全沙箱、多 Agent 编排,最后用同一套坐标对比 Claude Code、Cursor、Codex、Devin、Aider。你可以把这一节当成「自己做一套」或「选一套现成工具」时的检查清单。

3.1 生产级编码 Agent 的分层架构

一个能交付的编码 Agent,建议按六层来看。层与层之间有稳定接口:上面的层不应该直接操作磁盘,下面的层不应该理解「用户想修登录 NPE」这种业务目标。

┌──────────────────────────────────────────────────────────┐
│  L6  体验与工作流层  终端 / IDE / 云看板 / 定时任务 / PR    │
├──────────────────────────────────────────────────────────┤
│  L5  编排与策略层    循环、计划、子 Agent、停止条件、Hooks │
├──────────────────────────────────────────────────────────┤
│  L4  协议层          MCP(Agent↔工具) A2A(Agent↔Agent)  │
├──────────────────────────────────────────────────────────┤
│  L3  上下文与记忆层  仓库地图、索引、压缩、项目约定文件     │
├──────────────────────────────────────────────────────────┤
│  L2  工具与执行层    read/write/grep/bash/浏览器 + 沙箱    │
├──────────────────────────────────────────────────────────┤
│  L1  模型层          推理、工具调用、(可选)extended thinking│
└──────────────────────────────────────────────────────────┘

L1 模型层。
2026 年的公开评测里,真实 GitHub issue 修复(SWE-bench Verified)已经从 2024 年的「能做几十个百分点」走到了「头部模型 80%–90% 区间」。例如 Claude Opus 4.8 在 2026 年 5 月公开口径下约 88.6%。必须同时记住三件事:分数强烈依赖 Harness(同样模型,迷你 Agent 和全量 Claude Code 差一截);SWE-bench Verified 已被认为接近饱和、存在污染争议,更难的 SWE-bench Pro 分数明显更低;你仓库里的单体、私有框架、脏 git 状态,比任何榜单都更接近真实。选模型看三轴:工具调用稳定性、长程不跑偏、成本延迟。编码 Agent 不是做阅读理解,是做 30 步之后还记得「不要改 migration 文件」的那种稳定性。

L2 工具与执行层。
最小充分集其实只有五个:list_dir / globread_fileedit_filegreprun_command。有了这五个,Agent 就能探索、修改、验证。再往上加 git、浏览器、LSP 诊断、包管理,自主性上升,爆炸半径也上升。执行层要处理超时、大输出截断、非零退出码、并行工具调用。Claude Code 一类实现会在同一模型回合里并行跑互不依赖的 grep 和 glob,探索阶段的墙钟时间会少一截。

L3 上下文与记忆层。
这一层是产品差距最大的地方。它回答:系统提示里写什么硬约束;项目级 CLAUDE.md 何时注入;仓库地图或索引何时更新;窗口快满时压缩算法保留什么(文件路径、已做决策、测试命令、未完成 todo,必须留;冗长的成功日志,必须扔)。压缩算法写不好,长任务会在第 20 步人格分裂。

L4 协议层。
2024 年底 Anthropic 开源 MCP(Model Context Protocol)之前,每个 Agent 都在自己的 main 函数里重写 GitHub、文件系统、数据库适配器。MCP 把「工具集成」从写代码变成写配置:Agent 当 client,工具当 server,中间 JSON-RPC。2026 年它已经覆盖数据库、SaaS、浏览器、内部系统。旁边还有 A2A(Agent 之间发现与交接)和 AG-UI(把「我正在干什么」流给前端)。对编码 Agent,MCP 是目前最值得接的那一层——但接的时候必须管住工具膨胀。

L5 编排与策略层。
就是 2.2 节那个 while 循环的工业化版本。要决策的包括:单循环还是计划后执行;要不要子 Agent;失败 N 次是否强制反思或升级模型;何时向人请求批准;Hooks 在「将要写文件 / 将要跑 bash」时能否拦截。Claude Code 把大量产品能力做成生命周期 Hooks 和 Skills,本质上都是在这一层插桩,而不是改模型。

L6 体验与工作流层。
同一套 L1–L5,装进终端就是 Claude Code / Codex CLI / Aider;装进 IDE 就是 Cursor / Windsurf(后并入 Devin Desktop 路线);装进云虚拟机就是 Devin / Copilot coding agent。2026 年的趋势是同一家产品同时占多层:Cursor 既有 Tab(毫秒级补全),也有 Composer(分钟级多文件),还有 Cloud Agents(小时级隔离虚拟机)。选工具越来越不是选能力,而是选你希望人站在哪一个自主性档位上。

把一次「修 bug」请求在六层里走一遍:

sequenceDiagram participant U as 用户 participant L6 as 体验层 participant L5 as 编排层 participant L1 as 模型 participant L3 as 上下文 participant L2 as 工具/沙箱 U->>L6: "修复登录 NPE 并保证测试通过" L6->>L5: 创建会话,注入目标与停止条件 L5->>L3: 装配系统提示、CLAUDE.md、仓库地图 L5->>L1: messages + tools L1-->>L5: tool_call: grep("getStreet") L5->>L2: 安全校验后执行 L2-->>L5: 命中 3 处文件 L5->>L3: 写入 Observation,必要时裁剪 L5->>L1: 下一轮 L1-->>L5: tool_call: edit_file + run_command("pytest") L2-->>L5: 测试失败日志 L5->>L1: 带失败日志再推理 L1-->>L5: 再编辑 + 再测 L2-->>L5: 全绿 L1-->>L5: 无工具调用,输出摘要 L5->>L6: diff / 提交说明 L6->>U: 请审核

自己做 Agent 时,不要从 L6 的花活开始。先让 L1+L2+L5 在一个目录里跑通「写文件 + 跑测试 + 根据失败修复」,再加 L3 压缩,再加 L4 MCP,最后才是 UI。倒过来做,你会得到一个看起来很 Agent、实际上只是套了皮肤的聊天框。

3.2 工具系统、MCP 与安全沙箱

这一小节解决两个会让系统当场死亡的问题:工具怎么接,以及工具跑起来后怎么防止把机器打穿。

工具设计的三条硬约束

约束 1:默认工具集要小。
最小充分集:globread_fileedit_filegreprun_command。这五个覆盖了 80% 的软件工程动作。搜索类工具(grep/glob)让上下文装配变成 Agent 驱动的,而不需要你预先指定文件。shell 让测试、构建、git、包管理不必各做一套 API。

约束 2:描述必须让人能做单选题。
如果 search_codegrep 的说明都是「搜索代码」,模型会掷骰子。正确做法是写清边界:grep 是正则精确匹配,适合符号和报错字符串;semantic_search 是自然语言问「逻辑在哪」,适合你不知道符号名的时候。Anthropic 有一句被反复验证的观察:人说不清该用哪个工具时,不要指望 Agent 能说清。

约束 3:结果必须对模型友好。
工具失败不要抛给编排器当未处理异常,而要返回可读的错误字符串:路径不存在、old_string 匹配到 3 处、命令超时、退出码 1 加 stderr 尾部 80 行。模型靠这些 Observation 转向。静默失败或返回巨大二进制,等于弄瞎它。

MCP:把工具从代码变成插座

MCP 的心智模型非常像 LSP(Language Server Protocol)对编辑器做的事:编辑器不必为每种语言重写智能,语言把能力做成 server。Agent 不必为每个 SaaS 重写适配器,SaaS 把能力做成 MCP Server。

flowchart LR Agent["编码 Agent<br/>MCP Client"] -->|"JSON-RPC<br/>tools/list, tools/call"| S1["filesystem"] Agent --> S2["github"] Agent --> S3["postgres"] Agent --> S4["playwright"] Agent --> S5["公司内部工具"]

对编码场景,一组高价值 server 通常是:

  • 文件系统 / git / GitHub:读仓、开 PR、看 CI。
  • 当前文档(避免模型用过期 API)。
  • 数据库只读查询(看真实数据长什么样)。
  • Playwright:改完 UI 自己点一遍。

接入 MCP 时有一个反直觉的生产经验:不要把所有 server 的全部工具一开场就塞进系统提示。tools/list 拿到名字和一句话描述,真正要调用时再拉完整 schema。有的实现宣称这样能把工具占用的上下文砍掉大半。这叫按需披露(progressive disclosure),和「Skills 用 Markdown 写、用到再加载」是同一设计哲学。

安全不是售后附件,是架构决策

编码 Agent 拥有的权限,接近「一个能执行任意命令的实习生」。安全模型一旦事后补,一定补不住。主流几条路,各有代价:

模型 代表 做法 优点 代价
白名单 + 当场批准 Claude Code 不在 allowlist 里的命令弹确认;可「本次 / 本会话 / 永久」 灵活,熟仓库可放开 要设计好默认拒绝策略
每步人批 Cline 每个工具调用先过眼睛 最保守 复杂任务会点到手酸
Git 即撤销 Aider 每次编辑自动 commit 回滚是一条命令 历史会被小提交污染
IDE 沙箱 + diff 审 Cursor 终端进沙箱,改动以未保存 diff 呈现 符合 IDE 手感 关了窗口,后台任务怎么算要单独设计
云虚拟机隔离 Devin / Cloud Agents 在别人的 VM 里折腾,PR 打回来 爆炸半径不在你笔记本上 环境还原、密钥注入、异步等待

无论选哪条,执行器里至少要有这些机械检查(它们不依赖模型「听话」):

  1. 路径规范化后必须落在项目根内,拒绝 .. 穿越。
  2. 禁止或二次确认高危命令:rm -rfgit push --forcedrop table、改 .ssh、改仓库外路径。
  3. 命令超时(30–120 秒起步),stdout/stderr 截断。
  4. 密钥不进提示词:用沙箱侧的注入或受限环境变量,而不是让模型「记得把 token 写进命令」。
  5. 网络默认最小权限。需要查文档再白名单域名。

2026 年隔离技术的默认选项正在从「普通容器」走向 microVM(Firecracker 一类):启动几百毫秒,内存开销远小于传统虚拟机,隔离性又强于共享内核容器。Claude Code 本地还有一个更轻的并发隔离:git worktree——每个子 Agent 一份工作树、一个分支,合流前互不踩踏。你在单机上复现多 Agent,优先用 worktree,而不是上来就上 Kubernetes。

最后补一条常被忽略的安全边界:模型输出不是可信输入。 即便工具名是 read_file,参数也要当攻击者输入来看。提示词注入可以从 README、issue、网页抓取结果里来。生产级 Harness 会把「来自外部的 Observation」标成不可执行指令,只当数据。这不是偏执,这是 2024 年以来真实出过的事故模式。

3.3 多 Agent、Plan-Execute 与 2026 年主流产品对照

单循环在「改一个函数、补一个测试」时非常漂亮。到「把 20 个文件的认证中间件换掉,同时改文档和 CI」时,它会在两方面同时破产:窗口装不下全过程;错误会沿步骤链累积。于是出现两类演化。

Plan-then-Execute。
先完整规划,再逐步执行。计划本身是一份可批准、可压缩后仍保留的契约。Cline 的 Plan / Act 双模式、Claude Code 的 Plan 模式,都是这个思路。对人的价值是:你审核的是方案,不是 40 次工具调用现场。

子 Agent 委派。
主 Agent 把独立子任务(「迁移数据库层」和「改 HTTP handler」)交给拥有独立上下文窗口的子 Agent,子 Agent 只回摘要,不把全部中间日志打回主窗口。Cursor 允许并行后台 Agent;Claude Code 的 Dynamic Workflows 把这件事推到「一次会话里拉起大量并行子 Agent」;实践者经验是:人还能认真审核的并行度大约是 3–5 个,而不是几百个。 吞吐上限不在模型,在人的注意力和合流冲突。

Addy Osmani 把这种工厂式流程写成六步,很适合当团队 SOP:

Plan → Spawn agents → Monitor → Verify → Integrate → Retrospective

对应到 git,就是:主仓只接受经过验证的 PR;每个 Agent 在自己的 worktree / 分支上工作;合流靠测试和人工审核,不靠信任模型的「我做完了」。

用同一套坐标看主流产品

不要再问「哪个最强」。问「人站在回路的哪一截」。

维度 Claude Code Cursor Codex CLI Devin 类 Aider
主表面 终端(也可进 IDE) AI 原生 IDE 终端 / 云 云端虚拟机 + 看板 终端
循环 单线程主循环 + 子 Agent Tab / 行内编辑 / Composer / Cloud 多层 Agent 循环 + 云端并行环境 高自主、异步 Architect + Editor
上下文 项目 md + 即时检索 + 压缩 仓库语义索引 + 多模型 仓内工具 + 云端副本 完整 VM 工作区 tree-sitter 仓库地图
编辑 结构化 Edit,唯一性校验 多文件 + AST / diff 审阅 补丁 / 文件级 自主改,PR 回来 搜索替换,git 每步提交
安全 白名单 + 批准 + Hooks IDE 沙箱 + diff 沙箱执行 云隔离 git 回滚
最擅长 深重构、长程调试、可编程 Harness 日常结对、可视化 diff、快速切换模型 异步清 backlog、PR 流 规格清楚的后台任务 便宜、可复现、模型无关
主要代价 token 猛、终端心智 订阅贵、上下文漂移 沙箱边界、会话衔接 规格糊就空转、费用按量 终端能力不如前两者完整

2026 年中的一个经验配置是组合拳,而不是信仰单一品牌:

  • 日常击键和局部重构:Cursor Tab + Composer。
  • 跨模块、要跑测试、要看 git 历史的深活:Claude Code。
  • 规格清楚的 issue 清扫:云端 Codex / Copilot coding agent / Devin。
  • 想把每次改动都留在 git 里、或离线用开源模型:Aider。

它们能共存,是因为都作用在「普通文件 + git」上。冲突来自同时写同一分支。并发时请分支隔离,这不是 AI 问题,这是 1970 年代以来的配置管理问题。

再补一条评测读写方法,避免被营销带跑。SWE-bench Verified 测的是「给定 issue 和仓库快照,生成能过测试的 patch」。它有用,因为它用真实测试而不是人工偏好。它不够用,因为:任务偏 Python 开源热门仓;Harness 差异巨大;高分模型可能见过类似数据;它几乎不测你公司那种 20 个仓互相依赖、构建要 15 分钟、规范写在已经离职的人口头上的现场。把榜单当筛选漏斗,把你仓库里的 20 个真实 issue 当验收场。

架构部分的收口:

生产级编码 Agent 的差异,90% 不在模型商标,而在:上下文怎么选、工具怎么少而清、编辑怎么可逆、失败怎么转向、人站在哪一层审核。
你如果只能优化一件事,优化上下文和停止条件,而不是再换一个「更强」的模型。


4. 实践

简要介绍:前面把原理和架构讲完。这一节做三件实事:说明编码 Agent 现在真正能扛的工作类型;给出一份从零可运行的实现(先最小循环,再补上生产级零件);最后谈失败模式、以及开发者在接下来几年应该把自己放在哪一个位置。代码刻意不绑死某一家云,使用 OpenAI 兼容接口,你可以把 base_url 指到自家网关、开源模型或任何提供 Function Calling 的服务。

4.1 编码 Agent 现在真正能干什么

先划能力圈,避免把 Agent 当成万能实习生。

圈内:闭环短、验证硬、规格清的任务。

  • 修复带复现路径的 bug:有测试或有明确报错。
  • 补测试、补类型、补文档、做机械式重构(改名、拆文件、升级依赖)。
  • 按现有目录约定脚手架:新 CRUD 接口、新页面、新 GitHub Action。
  • 解释一段陌生代码:先 grep 再读,比人肉翻快。
  • CI 失败后的「读日志 → 改 → 再跑」循环。
  • 规格清楚的迁移:例如「把错误处理统一换成我们的 AppError」,有 lint 或测试当裁判。

圈边:能做,但必须人盯着。

  • 跨 10+ 文件的架构改动。Agent 可以出计划和第一轮补丁,合流和取舍必须人做。
  • 性能优化。它能改,但「快了没有」要靠你给基准,而不是靠它的自我感觉。
  • 涉及密钥、支付、权限的改动。可以让它写,不允许它自己合。

圈外:现在仍不该全权委托。

  • 目标含糊:「把代码写得更好看一点」。
  • 没有验证器:改完无法跑测试、无法预览、无法 diff 审。
  • 需要原创产品判断:「我们该不该做这个功能」。
  • 安全敏感的生产操作:对真实用户数据跑迁移、强推 git、改基础设施销毁策略。

把用途映射到工作流,推荐三条,从保守到激进:

flowchart TB subgraph A ["结对模式:人在键盘上"] A1["人写意图"] --> A2["Agent 改当前分支"] A2 --> A3["人看 diff,跑一遍自己关心的路径"] end subgraph B ["工单模式:人在审核位"] B1["Issue 写清验收标准"] --> B2["Agent 在独立分支 / worktree 干完"] B2 --> B3["CI + 人审 PR"] end subgraph C ["值班模式:人在规则位"] C1["定时或 CI 失败触发"] --> C2["Agent 尝试修复"] C2 --> C3{"验证器通过?"} C3 -->|是| C4["自动开 PR"] C3 -->|否| C5["失败上报,不落主分支"] end

给 Agent 写任务时,把「给实习生的工单」标准套上去就对了。一份合格的任务至少有四段:

  1. 目标:一句话,可判定真假。
    「给 parse_config 补上缺失键时抛 ConfigError,而不是返回 None。」
  2. 范围:哪些目录可以动,哪些绝对不能动。
    「只改 src/config/tests/config/,不要动 src/legacy/。」
  3. 验证:Agent 自己能跑的命令。
    pytest tests/config -q 必须全绿。」
  4. 完成态:怎样算停。
    「测试绿、不新增依赖、最后用 5 行中文总结改了什么。」

你不写这四段,Agent 就会优化一个你没说出口的目标:看起来忙、输出很长、改动很多。那不是智能,那是无目标系统的默认行为。

4.2 从零手写一个可运行的编码 Agent

下面这份实现刻意保持在「一个文件能讲完」的体量,但已经包含生产循环的全部关键零件:

  • OpenAI 兼容的 原生 Function Calling(不要让模型在文本里手写 JSON,解析会碎)。
  • 五个工具:列目录、读文件、写入、搜索、跑命令。
  • 项目根沙箱:路径穿越直接拒绝。
  • 命令超时与输出截断。
  • 最大步数。
  • 把每一轮工具调用打印出来,方便你观察循环。

先安装依赖:

pip install openai
export OPENAI_API_KEY=sk-your-key
# 可选:指向兼容网关
# export OPENAI_BASE_URL=https://your-gateway/v1
# export OPENAI_MODEL=gpt-4o

把下面存为 mini_coding_agent.py。它不是玩具演示用的伪代码,而是可以在一个真实目录里改文件、跑 pytest 的最小 Harness。

#!/usr/bin/env python3
"""最小可运行编码 Agent:ReAct 循环 + 沙箱工具 + Function Calling。"""

from __future__ import annotations

import json
import os
import subprocess
from pathlib import Path
from typing import Any

from openai import OpenAI

ROOT = Path(os.environ.get("AGENT_ROOT", ".")).resolve()
MODEL = os.environ.get("OPENAI_MODEL", "gpt-4o")
MAX_STEPS = int(os.environ.get("AGENT_MAX_STEPS", "20"))
CMD_TIMEOUT = int(os.environ.get("AGENT_CMD_TIMEOUT", "30"))
MAX_OUTPUT = 8000  # 防止单次工具结果撑爆窗口

SYSTEM_PROMPT = f"""你是一个谨慎的编码 Agent,工作目录是:{ROOT}

规则:
1. 先探索再修改。不确定文件在哪时,使用 glob 或 grep。
2. 每次修改后,只要存在测试或可以运行的命令,就必须验证。
3. 优先做最小改动,不要引入新依赖,不要重构无关代码。
4. 任务真正完成后,直接用自然语言总结,不再调用工具。
5. 如果你连续两次用同一命令得到同一错误,必须改策略或向用户说明阻塞点。
"""

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "glob",
            "description": "按 glob 模式列出工作目录内的文件。适合找文件,不适合搜文件内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {"type": "string", "description": "例如 **/*.py"}
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "读取工作目录内一个文本文件的内容。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string"},
                    "offset": {"type": "integer", "description": "从第几行开始,从 1 计,可选"},
                    "limit": {"type": "integer", "description": "最多读多少行,可选"},
                },
                "required": ["path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": "写入(覆盖)工作目录内一个文本文件。目录不存在时会创建。",
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {"type": "string"},
                    "content": {"type": "string"},
                },
                "required": ["path", "content"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "grep",
            "description": "在工作目录内用正则搜索文件内容,返回 path:line:匹配文本。适合定位符号和报错。",
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {"type": "string"},
                    "glob": {"type": "string", "description": "可选,例如 *.py"},
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "run_command",
            "description": "在工作目录内执行一条 shell 命令,返回退出码、stdout、stderr。用于测试、构建、运行脚本。禁止删除仓库外文件或强推 git。",
            "parameters": {
                "type": "object",
                "properties": {"cmd": {"type": "string"}},
                "required": ["cmd"],
            },
        },
    },
]


def safe_path(raw: str) -> Path:
    """拒绝路径穿越:解析后必须仍在 ROOT 内。"""
    path = (ROOT / raw).resolve() if not Path(raw).is_absolute() else Path(raw).resolve()
    try:
        path.relative_to(ROOT)
    except ValueError as exc:
        raise PermissionError(f"path escapes workspace: {raw}") from exc
    return path


def clip(text: str, n: int = MAX_OUTPUT) -> str:
    if len(text) <= n:
        return text
    return text[:n] + f"\n...[truncated {len(text) - n} chars]"


def tool_glob(pattern: str) -> str:
    matches = sorted(str(p.relative_to(ROOT)) for p in ROOT.glob(pattern) if p.is_file())
    return "\n".join(matches[:400]) or "(no matches)"


def tool_read_file(path: str, offset: int | None = None, limit: int | None = None) -> str:
    text = safe_path(path).read_text(encoding="utf-8")
    lines = text.splitlines()
    start = (offset - 1) if offset else 0
    end = (start + limit) if limit else len(lines)
    sliced = lines[max(0, start):end]
    numbered = [f"{i + 1 + max(0, start)}|{line}" for i, line in enumerate(sliced)]
    return clip("\n".join(numbered))


def tool_write_file(path: str, content: str) -> str:
    p = safe_path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content, encoding="utf-8")
    return f"wrote {len(content)} bytes -> {p.relative_to(ROOT)}"


def tool_grep(pattern: str, glob: str | None = None) -> str:
    cmd = ["rg", "-n", "--hidden", "--glob", "!.git", pattern]
    if glob:
        cmd.extend(["--glob", glob])
    try:
        proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=20)
        out = proc.stdout or proc.stderr or "(no matches)"
        return clip(out)
    except FileNotFoundError:
        # 没有 ripgrep 时退回 Python,慢但能跑
        import re

        rx = re.compile(pattern)
        hits: list[str] = []
        for file in ROOT.rglob(glob or "*"):
            if not file.is_file() or ".git" in file.parts:
                continue
            try:
                for i, line in enumerate(file.read_text(encoding="utf-8", errors="ignore").splitlines(), 1):
                    if rx.search(line):
                        hits.append(f"{file.relative_to(ROOT)}:{i}:{line}")
                        if len(hits) >= 200:
                            return clip("\n".join(hits))
            except Exception:
                continue
        return clip("\n".join(hits)) or "(no matches)"


DANGEROUS = ("rm -rf /", "git push --force", "git reset --hard", "mkfs", "sudo ", ":(){")


def tool_run_command(cmd: str) -> str:
    lowered = cmd.strip().lower()
    if any(bad in lowered for bad in DANGEROUS):
        return f"blocked dangerous command: {cmd}"
    proc = subprocess.run(
        cmd,
        shell=True,
        cwd=ROOT,
        capture_output=True,
        text=True,
        timeout=CMD_TIMEOUT,
    )
    return clip(
        f"exit={proc.returncode}\nstdout:\n{proc.stdout}\nstderr:\n{proc.stderr}"
    )


DISPATCH = {
    "glob": lambda **kw: tool_glob(kw["pattern"]),
    "read_file": lambda **kw: tool_read_file(**kw),
    "write_file": lambda **kw: tool_write_file(kw["path"], kw["content"]),
    "grep": lambda **kw: tool_grep(kw["pattern"], kw.get("glob")),
    "run_command": lambda **kw: tool_run_command(kw["cmd"]),
}


def run_agent(task: str) -> str:
    client = OpenAI()
    messages: list[dict[str, Any]] = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": task},
    ]

    for step in range(1, MAX_STEPS + 1):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
            temperature=0.2,
        )
        msg = resp.choices[0].message
        messages.append(msg)

        if not msg.tool_calls:
            print(f"\n[done in {step} llm calls]\n")
            return msg.content or "(empty)"

        for call in msg.tool_calls:
            name = call.function.name
            args = json.loads(call.function.arguments or "{}")
            print(f"[{step}] {name} {args if name != 'write_file' else {'path': args.get('path')}}")
            try:
                result = DISPATCH[name](**args)
            except Exception as exc:  # 工具错误也要变成 Observation,供模型转向
                result = f"TOOL_ERROR: {type(exc).__name__}: {exc}"
            messages.append(
                {"role": "tool", "tool_call_id": call.id, "content": result}
            )

    return f"stopped at MAX_STEPS={MAX_STEPS}, last tools already executed"


if __name__ == "__main__":
    import sys

    user_task = " ".join(sys.argv[1:]) or (
        "在当前目录创建一个 calculator.py,实现 add/sub/mul/div。"
        "div 在除零时抛 ZeroDivisionError。"
        "再写 test_calculator.py,用 pytest 覆盖这四个函数和除零。"
        "运行 pytest 直到全绿,最后用中文总结。"
    )
    print(run_agent(user_task))

运行:

mkdir -p /tmp/agent-demo && cd /tmp/agent-demo
cp /path/to/mini_coding_agent.py .
python mini_coding_agent.py

你在终端里会看到类似:

[1] glob {'pattern': '*.py'}
[2] write_file {'path': 'calculator.py'}
[3] write_file {'path': 'test_calculator.py'}
[4] run_command {'cmd': 'pytest -q'}
[done in 5 llm calls]

已创建 calculator.py 与 test_calculator.py,pytest 全绿……

这就是 2.2 节那张图的肉体:模型没有直接「生成一个项目给你看」,它是在循环里自己摸索、写入、用 pytest 当验证器,验证器说通过才停。把 AGENT_ROOT 指到你的真实仓库,再换一句带验收标准的任务,它就已经能做有限但真实的活。

一份项目约定文件,立刻提升「像团队成员」的程度

在仓库根放 AGENTS.md(或 CLAUDE.md),启动时读进来,拼到 SYSTEM_PROMPT 后面。这是成本最低、收益最高的上下文工程:

# AGENTS.md

## 构建与测试
- Python 3.12,包管理用 uv
- 单测:`pytest -q`
- 不要提交 `.venv/` 和 `__pycache__/`

## 架构约定
- 业务逻辑放 `src/`,HTTP 层放 `src/api/`,不要在 handler 里写 SQL
- 错误统一抛 `AppError`

## 禁止
- 不要改 `src/legacy/`
- 不要新增生产依赖,除非任务明确要求
- 不要运行 `git push`

生产工具里这件事已经标准化。你自己的 200 行 Agent 只要在 run_agent 开头加:

guide = ROOT / "AGENTS.md"
if guide.exists():
    messages[0]["content"] += "\n\n项目约定:\n" + guide.read_text(encoding="utf-8")[:6000]

模型就会少问很多「测试命令是什么」,也会少碰 legacy

4.3 从最小循环走到能上内部试用的 Harness

80 到 200 行的 Agent 能跑通演示,距离给同事用还有四道必须补的工序。下面给出可直接粘贴的增强,而不是口号。

(1)结构化编辑,避免整文件覆盖

write_file 留着当创建新文件用,日常修改改成 edit_file。唯一性校验能把大量「改错地方」挡在落盘前。

def tool_edit_file(path: str, old_string: str, new_string: str) -> str:
    p = safe_path(path)
    text = p.read_text(encoding="utf-8")
    count = text.count(old_string)
    if count != 1:
        return (
            f"EDIT_REJECTED: old_string matched {count} times in {path}. "
            "It must match exactly once. Read the file again and narrow the snippet."
        )
    p.write_text(text.replace(old_string, new_string, 1), encoding="utf-8")
    return f"edited {p.relative_to(ROOT)}"

对应的 tool schema 把三个参数写清楚,并在 description 里写上「old_string 必须唯一」。模型第一次匹配失败后,通常会自己 read_file 再收紧片段。这就是 Claude Code 那类 Edit 工具的迷你版。

(2)强制反思,打断无限重试

记录最近几次命令签名。相同失败出现两次,就向 messages 注入一条系统级提醒:

from collections import deque

recent_failures: deque[str] = deque(maxlen=4)

def remember_cmd_result(cmd: str, result: str) -> str:
    if "exit=0" in result:
        return result
    sig = f"{cmd}::{result[:200]}"
    recent_failures.append(sig)
    if list(recent_failures).count(sig) >= 2:
        return result + (
            "\nREFLECTION_REQUIRED: 同样的命令已经失败两次。"
            "请先用三句话写出:失败现象、根因假设、下一步不重复的策略。"
            "禁止再次执行完全相同的命令。"
        )
    return result

tool_run_command 返回前包一层即可。这是 Reflexion 论文在工程上最便宜的落地。

(3)上下文压缩,让长任务活过第 20 步

最朴素有效的压缩:当 messages 超过 N 条,把最早的工具结果替换成摘要,保留系统提示、用户任务、最近 K 轮原文。

def compact(messages: list[dict], keep_last: int = 8) -> list[dict]:
    if len(messages) < 24:
        return messages
    head = messages[:2]  # system + user task
    tail = messages[-keep_last:]
    middle = messages[2:-keep_last]
    files, cmds = set(), []
    for m in middle:
        content = m.get("content") or ""
        if m.get("role") == "tool":
            if "wrote " in content or "edited " in content:
                files.add(content.split()[-1])
            if content.startswith("exit="):
                cmds.append(content.splitlines()[0])
    summary = {
        "role": "system",
        "content": (
            "以下是被压缩的早期过程摘要,细节已丢弃:\n"
            f"- 改过的文件: {', '.join(sorted(files)) or '无'}\n"
            f"- 早期命令结果: {'; '.join(cmds[-6:]) or '无'}\n"
            "请以用户原始任务和最近的工具结果为准,不要重复已经成功的编辑。"
        ),
    }
    return head + [summary] + tail

每一轮 LLM 调用前 messages[:] = compact(messages)。这不是学术级压缩,但足以让「修一个带测试的 bug」这种 15 步任务不在中途失忆。更进一步可以让模型自己写 scratchpad.md,压缩时保留「去读这个文件」而不是把笔记散落在历史里。

(4)测试闭环作为停止条件,而不是相信模型说「做完了」

编排器可以在模型宣称完成之后,再跑一次用户指定的验证命令。失败就不要把控制权交还用户,而是把失败当作新的 Observation 继续循环。

VERIFY_CMD = os.environ.get("AGENT_VERIFY", "pytest -q")

def verify_or_continue(messages: list[dict]) -> str | None:
    result = tool_run_command(VERIFY_CMD)
    if "\nexit=0\n" in f"\n{result}" or result.startswith("exit=0"):
        return None  # 真的做完了
    messages.append({
        "role": "user",
        "content": (
            f"你声称任务完成,但验证命令 `{VERIFY_CMD}` 失败了:\n{result}\n"
            "请继续修复,直到验证通过。不要向用户汇报完成。"
        ),
    })
    return result

「模型说完了」是 chatbot 的停止条件;「验证器说完了」才是 Agent 的停止条件。这一点单独拎出来,是因为 80% 的演示 Agent 都停在了前一个。

(5)git worktree:给并行和回滚留后路

即使你还没有多 Agent,也建议每次任务在独立 worktree 里跑:

git worktree add ../repo-agent-login -b agent/fix-login-npe
AGENT_ROOT=../repo-agent-login python mini_coding_agent.py "..."
# 满意再合并;不满意 git worktree remove

这是 Aider「每次提交都可回滚」和 Claude Code「子 Agent 隔离」在单机上的最小公约数。

补齐这五件后,你手里的东西已经具备内部试用资格:循环、工具、沙箱、编辑约束、失败转向、压缩、外部验证器、git 隔离。再往上才是 MCP、IDE 插件、云虚拟机——那是产品化,不是原理缺口。

常见失败模式对照表

失败 你在终端里看到什么 真正原因 先做的修复
无限循环 同一 pytest 命令失败 8 次 错误信息含糊,又没有强制反思 失败两次注入 REFLECTION_REQUIRED;设最大步数
过度设计 你要一个解析器,它做了插件系统 任务没写「最小改动」 系统提示 + 任务里都写死范围
幻觉 API import 了一个不存在的包 知识截止或记混了相邻框架 写代码前 python -c "import X";接入当前文档 MCP
上下文崩塌 第 20 步开始撤销第 5 步的正确改动 窗口被日志淹没,早期约束丢失 compact;scratchpad;计划锚点
改错文件 同名函数改到了 legacy 检索太宽或没读约定 AGENTS.md 禁止目录;grep 后先 read 再 edit
工具选择抖动 在 grep 和 search 之间来回 工具描述重叠 删掉一个,或把边界写成单选题
看起来忙却没完成 很长的总结,测试没跑 停止条件是模型主观判断 外部 VERIFY_CMD 拦截「假完成」

这些不是模型性格问题,是 Harness 缺口。换更贵的模型可以掩盖一部分,但掩盖得很贵,而且一换任务就复发。


5. 总结

编码 Agent 看起来像魔法,拆开是一台很老派的机器。

机器的心脏是 ReAct 循环:模型看当前世界,决定一个动作,执行器真的去改世界,观察写回上下文,再看。机器的手是少而清的工具,通过 Function Calling 或 CodeAct 接到文件系统、shell、测试和外部系统。机器的眼睛是上下文工程:仓库地图、索引、压缩、项目约定,决定模型每一轮到底看见什么。机器的良心是权限、沙箱和 git 隔离。机器的裁判不是模型自己说「我做完了」,而是 pytest、lint、CI、diff 审核这些外部验证器。

所以:

  • 不要再用 Copilot 的心智去理解 Claude Code。Tab 键解决击键;循环解决任务。
  • 不要把「换更强模型」当成第一种优化。先写清目标、范围、验证、停止条件。
  • 不要一上来接 30 个 MCP、上多 Agent 编队。五个工具 + 沙箱 + 最大步数 + 测试闭环,已经能做真实工作。
  • 不要相信演示。用你自己仓库里的真实 issue 当评测,用 git 当悔棋盘。
  • 不要把人从回路里删掉。把人挪到回路中更贵的位置:定目标、定边界、定验证、承担风险。

本文给出的 mini_coding_agent.py 不是玩具,它是把上述句子翻译成可执行状态的最小集合。你可以从它开始,加上 edit_file、反思注入、上下文压缩、AGENTS.md、git worktree,在自己的仓库里跑通第一个「修 bug 直到测试全绿」的闭环。等你亲眼看过模型在第 4 步读到失败日志、在第 5 步改策略,而不是在聊天框里自信地撒谎,编码 Agent 就不再是一个营销词,而是你工具箱里一台可以理解、可以限制、可以改进的机器。

6. 结束语

这篇博客就和大家分享到这里,如果大家在研究学习的过程当中有什么问题,可以加群进行讨论或发送邮件给我,我会尽我所能为您解答,与君共勉!

另外,博主出新书了《Hadoop与Spark大数据全景解析》、同时已出版的《深入理解Hive》、《Kafka并不难学》和《Hadoop大数据挖掘从入门到进阶实战》也可以和新书配套使用,喜欢的朋友或同学, 可以在公告栏那里点击购买链接购买博主的书进行学习,在此感谢大家的支持。关注下面公众号,根据提示,可免费获取书籍的教学视频。

posted @ 2026-08-30 13:11  哥不是小萝莉  阅读(172)  评论(0)    收藏  举报