☰
300行Python手写生产级Coding Agent:从框架回归最小主义
2026/10/22 11:07:39 网站建设 项目流程

这两年只要打开技术社区,Coding Agent 就是一个绕不开的词。有人套着框架三分钟搭一个 demo,也有人把整个仓库塞进上下文让模型全自动写代码。但把这类东西真的放进自己的项目里就会发现:demo 跑得很欢,一旦面对真实代码库,要么在抽象层里翻半天日志,要么改错一个文件把业务代码整个搞坏。我自己的解法是回到最小主义——用 300 行纯 Python,不依赖任何 Agent 框架,手搓一个能用的 Coding Agent。它能自己读代码、定位问题、改文件、跑测试,并且明确知道自己什么时候该停下来。这篇文章是整个系列的终章实战,把模块设计、关键代码、实测日志和踩过的坑一次性交代清楚。

1. 为什么是"300 行",以及生产级到底指什么

1.1 被框架喂大的 Agent 代码,反而更难维护

先说一个反直觉的结论:市面上那些看起来功能齐全的 Agent 编排框架,用在 Coding Agent 场景里,很多时候是负资产。框架帮你抽象了"模型调用、工具调用、内存管理",听起来很省事,可一旦模型返回的 tool call 结构稍微不符合你的预期,你就要钻进框架的源码里搞清楚消息格式被怎么改写了。排查两三次之后你就会发现,真正和你的业务强相关的代码,可能只占 20%,剩下 80% 都是框架的中间层。

我并不是说框架不能用,而是说"核心闭环"值得自己手写。一个 Coding Agent 最核心的能力,无非是让模型通过工具和代码库交互,然后根据观察结果决定下一步动作。这个闭环一旦自己掌控,后续加工具、换模型、改安全策略都只需要动一个文件。300 行这个数字并不是自我设限,而是一种设计约束:逼着你砍掉一切多余的抽象,只保留必须有的部分。

1.2 先给"生产级"下个定义

"生产级"经常被滥用。一堆人把"能调用一个工具"就叫生产级,这是把 demo 和可用混为一谈了。在我这里,"生产级"至少包含四个硬指标:

  • 工具执行是确定性的。同一个工具、同样的参数,执行结果必须一致,不能有隐藏状态。
  • 失败是可看见的。模型调用工具失败时,错误信息必须原样回到模型手里,而不是被吞掉或者被包装得看不出来。
  • 有明确的边界。Agent 只能在指定工作区内活动,不能碰工作区之外的文件。
  • 有终止保证。无论模型怎么绕,循环必须有最大步数限制,不能无限烧 token。

这四个指标,恰恰是那些花哨 demo 最缺的东西。而它们只需要很少的代码就能实现,关键是你要在设计之初就把它们当作硬约束,而不是事后补丁。

1.3 模块划分:五个部件一条循环

整个项目我拆成五个模块,每个模块对应一个文件(严格来说加起来约 300 行):

模块职责大约行数
model_client.py封装模型 HTTP 调用、重试、超时50 行
tools.py工具定义、注册表、沙箱校验110 行
agent.py主循环:决策、执行、观察80 行
workspace_paths.py路径解析与安全校验30 行
main.pyCLI 入口、任务输入、结果输出30 行

一条主循环贯穿所有模块:把任务塞给模型,模型决定调用哪个工具,代码执行工具并把真实结果返回给模型,模型看到结果后决定下一步,如此往复,直到模型给出最终答案或达到步数上限。理解了这条循环,后面所有代码都是围绕它做文章。

2. 第一步:模型封装层,让 Agent 能"看见"和"说话"

2.1 用 OpenAI 兼容协议,而不是绑死某一家模型

最初的版本,我直接调用了某家云端模型的专用 SDK,代码写起来很爽,但第三天就发现痛点:团队希望在某些场景用本地模型跑,避免把代码路径送出内网。如果我继续用那个 SDK,等于把整个 Agent 绑定在单一模型供应商上。现在很多本地推理服务都提供 OpenAI 兼容接口,所以我直接把模型层统一成"任意兼容/chat/completions 协议的端点"。

这样的好处是显而易见的:本地用 14B 级别的开源代码模型测试,云端换成更大的商业模型,只需要改一个base_url和model字段,工具层和主循环完全不动。

import json import time import urllib.request class ModelClient: def __init__(self, base_url, api_key, model, temperature=0.1, timeout=120): self.base_url = base_url.rstrip("/") self.api_key = api_key self.model = model self.temperature = temperature self.timeout = timeout def chat(self, messages, tools=None): payload = { "model": self.model, "messages": messages, "temperature": self.temperature, } if tools: payload["tools"] = tools payload["tool_choice"] = "auto" headers = { "Content-Type": "application/json", "Authorization": f"Bearer {self.api_key}", } for attempt in range(3): try: req = urllib.request.Request( f"{self.base_url}/chat/completions", data=json.dumps(payload).encode(), headers=headers, ) with urllib.request.urlopen(req, timeout=self.timeout) as resp: data = json.loads(resp.read().decode()) return data["choices"][0]["message"], data.get("usage", {}) except Exception as exc: if attempt == 2: raise time.sleep(2 ** attempt)

这里没有用 requests,直接拿标准库urllib写的。好处是依赖为零,任何一台有 Python 的机器都能跑。如果你自己用,换成httpx会更顺手,核心逻辑不变。

2.2 四种消息角色,一个都不能漏

模型 API 要求消息必须是四种角色交替:system放总指令,user放任务,assistant放模型自己的回复,tool放工具执行结果。这四者的顺序一旦混乱,模型就会"失忆"。

我在这个环节栽过跟头:早期版本把工具执行结果拼成一段普通文本,当作assistant角色的消息塞回去。小模型还扛得住,换成稍弱的模型就开始胡言乱语,因为它分不清哪句话是它自己说的、哪句话是工具反馈的。后来老老实实改成结构化格式,并使用tool_call_id把工具调用和工具结果绑在一起。

messages.append({ "role": "tool", "tool_call_id": call["id"], "content": result, })

这里的关键是tool_call_id。它是模型在assistant消息里给出的工具调用编号,工具结果的消息必须带上同样的编号,模型才能知道"我刚才发起的那个调用,结果是这个"。

2.3 重试、超时与上下文预算,三件套不能省

模型接口并不总是稳定。本地推理服务偶尔会 5xx,云端接口经常 429 限速。所以我做了简单的指数退避重试,最多三次。测试下来,这个策略能挡住绝大多数瞬时故障。

上下文预算是一个容易被低估的问题。Coding Agent 在长时间任务里,历史消息会以惊人的速度膨胀,尤其是工具输出。我的做法是在模型层不做内置截断,而是在主循环里对工具输出控制长度,同时在系统提示词里告诉模型"输出尽量精简"。更激进的做法会在主循环里检查总 token 数,超了就触发剪枝,这部分第 4 节会详细讲。

3. 第二步:工具层,给 Agent 一双能真正改代码的手

3.1 工具注册表:把 Python 函数翻译成模型能读的接口文档

模型"使用工具"这件事,本质上是模型输出一个 JSON,里面包含工具名和参数,然后你的代码负责执行。所以工具层核心工作有两个:一是把函数描述成模型能理解的 schema,二是收到 JSON 后安全地调用对应的函数。

我用一个极简的装饰器注册表搞定,没有任何魔法:

TOOLBOX = {} def register(name, description, parameters): def deco(fn): fn.meta = {"name": name, "description": description, "parameters": parameters} TOOLBOX[name] = fn return fn return deco

每次往里加工具,只需要写一个普通 Python 函数,用装饰器附上描述和参数 schema。主循环需要发起调用时,遍历TOOLBOX就能拿到所有工具的 schema,拼成一个列表传给模型。

这里有一个容易被忽略的点:描述文本就是工具的 UI。模型只会根据描述来决定什么时候调用哪个工具。描述写得含糊,模型就会乱调用;描述里写清楚"什么时候该用""什么时候不该用""返回什么格式",模型的工具选择准确率会明显提升。比如read_file的描述里,我会注明"当文件可能很大时,请使用 offset/limit 分段读取,而不是一次读完整文件"。

3.2 五个核心工具的取舍:为什么只要这五个

第一版我塞了十一个工具,包括 git diff、静态检查、依赖分析等等。后来在实际任务里统计调用频率,发现 80% 的有效调用集中在五个工具上,剩下的要么是模型根本不会主动用,要么是可以通过组合其他工具实现的。做减法之后,工具少了,模型的选择成本低了,成功率反而高了。

核心五件套:

  • read_file:按行段读取文件,带 offset/limit。
  • edit_file:精确字符串替换。
  • write_file:整文件覆盖写入,用于新建文件或大幅重构。
  • run_command:在工作区根目录执行白名单命令。
  • search:按关键字在文件树中搜索,返回匹配文件和行号。

edit_file的实现是整个工具层最需要小心的地方。我坚持用"精确字符串匹配 + 有且仅出现一次"的约束,而不是让模型给出行号来替换:

@register( "edit_file", "精确字符串替换。要求 old_text 在文件中恰好出现一次,替换区域越小越好,建议包含上下文行。", { "type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string", "description": "要替换的原文"}, "new_text": {"type": "string", "description": "替换后的新文本"}, }, "required": ["path", "old_text", "new_text"], }, ) def edit_file(path, old_text, new_text): full_path = resolve_path(path) content = full_path.read_text(encoding="utf-8") count = content.count(old_text) if count == 0: return "错误:没有找到待替换文本。可能该段已被修改,或存在行尾/空白差异。" if count > 1: return "错误:old_text 在文件中出现多次,请补充上下文使其唯一。" full_path.write_text(content.replace(old_text, new_text, 1), encoding="utf-8") return "替换成功,共修改 1 处。"

这个设计的原理是:行号是不可靠的,模型算错行号是家常便饭;而"唯一字符串"是内容层面的约束,只要模型看到的文本是真实的,它给出的 old_text 来自文件本身,匹配率就非常高。当匹配失败时,错误信息会引导模型去重新读取文件,这本身就是一个纠错闭环。

3.3 沙箱与执行底线:绝对不让 Agent 越界

工具层最容易出事的不是功能,而是边界。我见过一个 demo 版的 Agent 在工作区外把用户家目录的文件改掉的真实事故。所以路径校验必须零妥协:

def resolve_path(path): candidate = (Path(WORKSPACE) / path).resolve() if not candidate.is_relative_to(Path(WORKSPACE).resolve()): raise PermissionError(f"路径越界:{path}") return candidate

用resolve()把相对路径和符号链接都解析成绝对路径,再用is_relative_to判断是否还在工作区内部。这一步把../穿越、软链接指向外部目录等常见攻击路径全部挡死。

run_command的沙箱我做得更保守:命令白名单 + 工作目录锁定 + 超时 + 输出截断。白名单初始只有pytest、python -m pytest、python -m unittest、make check几项。实际使用中需要什么命令再加,原则是宁可让模型多调用几次run_command,也不能让它通过 shell 做任意事。

4. 第三步:主循环,从一次对话变成反复"思考-行动-观察"

4.1 一个没有工具调用的对话不是 Agent

很多初学者会把 Agent 做成"一次问答",也就是把系统提示词写得特别长,指望模型一口气给出最终代码。这在简单任务里能跑通,但一旦任务复杂,模型就会开始编造文件内容,因为它根本没有看到真实状态。

真正的 Agent 主循环只有十几行,但它是整个系统的心脏:

def run_agent(task, max_steps=8): messages = [{"role": "system", "content": SYSTEM_PROMPT}] messages.append({"role": "user", "content": task}) schemas = [fn.meta for _, fn in TOOLBOX.items()] for step in range(max_steps): message, _ = client.chat(messages, tools=schemas) messages.append(message) if not message.get("tool_calls"): if message.get("content"): return message["content"] return "Agent 既没有调用工具也没有给出答案,提前终止。" for call in message["tool_calls"]: fn = TOOLBOX.get(call["function"]["name"]) if not fn: result = f"未知工具:{call['function']['name']}" else: try: args = json.loads(call["function"]["arguments"] or "{}") result = fn(**args) except Exception as exc: result = f"工具执行异常:{exc}" messages.append({ "role": "tool", "tool_call_id": call["id"], "content": result, }) return "达到最大迭代步数,任务未完成。"

逻辑一句话说清:模型返回的message里如果没有tool_calls字段,说明它准备输出最终答案了,直接返回;如果带了tool_calls,就依次执行工具,把结果以tool角色消息追加进对话,然后进入下一轮。每一轮循环,模型拿到的都是"更新之后的完整上下文",它随时可以反悔上一轮的决定。

4.2 停止条件和最大迭代:防止它自己跟自己吵起来

没有终止保证的 Agent 就是一只揭开盖子的烤箱。一些模型的"自我修正"机制会在同一个错误上反复打转,如果没有步数上限,任务会无限烧钱。

我默认max_steps=8。这个数字是拍脑袋定的?不,是从失败统计里来的。我记录过大约 40 个真实修复任务,大多数成功任务在 3 到 5 步内完成。超过 7 步还没有收敛的任务,再给它 10 步成功率也上不去,反而更容易在错误路径上越走越远。所以我把上限设在 8,宁可让它认输,也不要让它顽固地烧 token。

另外,循环里还有一个容易忽略的终止分支:模型既没有输出tool_calls,也没有输出content。这种情况虽然少见(通常是 API 异常返回空消息),但一旦发生,代码会立刻终止并返回一段明确提示,而不是把空消息继续塞进对话。

4.3 上下文剪枝策略:当历史记录塞不下时

Coding Agent 干久了,上下文膨胀难以避免。每次read_file都可能带回几百 token 的内容,十几个来回之后,最老的几十轮消息还在占用宝贵的上下文空间。

我的剪枝策略很简单,但很有效:

  1. 工具输出在生成时就截断,run_command只保留最后 2000 字符,read_file默认一次最多 200 行。
  2. 主循环每次迭代前估算消息总长度,超过阈值(我设为 6000 token)就触发剪枝。
  3. 剪枝规则是:保留system提示词和第一条user任务,然后只保留最近的 6 轮交互,中间的早期消息全部丢弃。

这个"掐头去尾保中间"的做法,比让模型做摘要更可靠。模型做摘要本质上是重写历史,很容易把关键的操作约束改丢。剪枝之后,任务原始描述还在,最近状态还在,丢掉的无非是中间的探索过程,整体影响可控。

5. 实战走查:让 Agent 修一个真实接口 bug 的全过程

5.1 构造一个可控的实验任务

为了验证 Agent 不是花架子,我搭了一个模拟项目,包含两个文件:

# stats.py def summary(numbers): if not numbers: return {"count": 0, "sum": 0, "mean": None} total = sum(numbers) count = len(numbers) return { "count": count, "sum": total, "mean": total / (count - 1), # 此处有 bug,应为 total / count }

以及一个测试文件test_stats.py,用pytest断言summary([1, 2, 3])的 mean 等于 2.0。这个 bug 属于典型的"一眼能看出来但模型未必一次改对"的类型,适合观察 Agent 的完整决策链路。

5.2 逐步日志:模型是怎么读文件、定位、改代码的

我把任务设置为一句:"项目 stats.py 中有个 bug,请修复并确保测试通过。"下面是 Agent 的实际执行日志(精简版):

步骤模型动作工具结果状态
1read_file("stats.py")返回文件全文,含 bug 行正常
2run_command("pytest -q")测试失败,显示 mean expected 2.0, got 3.0正常
3edit_file("stats.py", old="total / (count - 1)", new="total / count")替换成功正常
4run_command("pytest -q")通过,3 passed正常
5输出最终答案附上改动描述与测试结果完成

整个过程 5 步,消费 token 约四千多,耗时不到半分钟。可以看出,模型没有瞎猜,而是先读文件拿真实内容,再跑测试确认症状,然后做精准替换。这个顺序本身就是我系统提示词里反复强调的"先读、再跑、后改"。

5.3 现场翻车:工具报错之后的重试逻辑发挥了作用

上面那次是顺利的版本,但我在测试中还抓到一个很有价值的失败案例。同样任务,Agent 第一步就跑了测试,直接看到失败信息,然后调用edit_file时把一个错误文本作为 old_text:

错误:没有找到待替换文本。可能该段已被修改,或存在行尾/空白差异。

这是因为它在读文件时看到的文本和它提交的 old_text 有细微空白差异。如果是早期版本,工具库在这里直接抛异常,整个 Agent 就挂了。现在错误信息回到模型手里后,模型重新调用了read_file,这次它多读了几行上下文,然后提交了包含函数名和上下文的更长的精确片段,替换成功。

这个案例恰好说明两个点:第一,工具必须把错误反馈给模型而不是吞掉;第二,模型遇到失败后要有重新观察的能力。把这两点做进循环里,"生产级"三个字才算落地了一半。

6. 上线前必须处理的五个边界问题(经验篇)

6.1 精确替换 vs 正则替换:宁可让模型多读一次文件

有人建议用正则替换提高容错性,我强烈不建议。正则的匹配语义是"模糊的",一旦模型给出的模式和你预期不一致,它可能会替换掉你根本没想动的地方。最糟糕的案例是模型想改函数名,结果把它在另一个模块里的同名方法也改了。

精确匹配虽然死板,但它有一个巨大的好处:如果匹配不到,模型得到的是一个明确的失败信号,它会去重新读文件、重新定位。失败的成本远低于静默改错位置的成本。如果你希望放宽匹配,也请采用"精确匹配失败后再尝试去除行尾空白的宽松匹配"这种显式回退逻辑,而不是一上来就模糊。

6.2 测试命令超时:一个死循环测试能把整轮任务拖垮

我实测时碰到过一次:模型修改了一个函数,引入死循环,然后调用run_command("pytest")跑单元测试。测试进程挂起,接口迟迟不返回,整个 Agent 卡死在等待里。从那以后run_command强制加了timeout=60。

要注意的是,subprocess.run超时后抛出TimeoutExpired,这个异常必须捕获并转成一段普通文本返回给模型,让模型意识到"测试卡住了,可能是我引入了死循环",而不是让异常直接冒出循环把进程打死。这类异常是本系统最重要的信号之一。

6.3 剪枝别剪掉系统指令

我最初实现剪枝时,是把所有消息放进一个列表从中间切一刀。结果模型忘了自己"必须先跑测试才能报告完成"的指令,开始在没有任何验证的情况下声称修复完成。原因很简单:系统指令被剪掉了。

剪枝无论如何设计,system消息必须始终保留。另外工具定义不是消息,而是每次请求时通过tools参数传过去的,不占对话历史,所以不会被剪掉,这也是为什么不把工具说明写在 system 提示词里的原因——写进去反而会有被剪掉的风险。

6.4 防止两个 Agent 同时写一个文件

如果你只在本地单跑一个任务,这个问题不存在。但一旦把 Agent 作为服务端提供,多个任务可能并发操作同一个代码仓库,两个 Agent 同时修改同一个文件就可能互相覆盖。

我加了一个最粗糙的解决方式:进程级文件锁,同一个工作区同时只允许一个 Agent 写操作。实现也就十几行,真正适合多数场景。如果你一定要并发,那就按目录拆分,每个 Agent 只负责自己名下的子目录,从根上杜绝冲突。

6.5 模型"自信"错了:验证兜底不能省

最后一个坑来自模型本身。代码模型在回答问题时非常容易出现"自信的幻觉"——它认为某个函数行为是 A,实际上代码是 B。如果你的 Agent 在模型口头说"完成"时就收工,你迟早会被坑。

我的兜底原则是:最终答案不是验收依据,测试输出才是。系统提示词里明确要求"必须提供 run_command 的测试通过证据,否则不得声称完成"。更进一步,Agent 外部还应该有一个强制的复核入口,比如由模型生成最终的 git diff,供人来审查。我在实际项目中做的是,Agent 跑完后自动打印 diff 和测试日志,我不看 diff 的最后一句话,只看这两个客观事实。

最后再分享一个小技巧:把上面这些边界规则全部写进系统提示词,不如把其中一部分变成硬编码逻辑。靠模型自觉遵守的规则会偶尔失效,靠代码强制执行的规则永远不会。所以"不能越界""必须有输出截断""必须超时"这类安全约束,我一律放在工具层和主循环里写死,提示词只保留任务性指引。这 300 行代码最后沉淀下来的,其实就是这条原则:模型负责聪明,代码负责兜底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询