前阵子在做代码评审自动化时,被一个实际问题反复折磨:常规的静态检查工具能发现"语法层面"的问题,但完全处理不了"跨文件的理解",比如一个接口签名改了,调用方有没有跟着改、测试用例要不要同步调整,这类需要理解项目上下文才能回答的问题,静态规则根本无能为力。于是我决定不依赖现成的Agent框架,用纯 Python 从零搭一个能真正处理仓库级任务的 Coding Agent。
这篇文章要分享的,就是这样一个 300 行纯 Python 实现的"生产级" Coding Agent 的完整实战过程,从核心模块拆解、关键代码实现、参数调优到真实环境里的排查经验,全程手把手。适合两类人看:一类是想搞懂 Agent 内部原理、不想被框架黑盒裹挟的开发者,另一类是已经在业务里踩过 Agent 返工和失控的坑、想自己掌控节奏的工程师。
先说结论:300 行代码完全可以做一个"够用、可控、能上线"的 Agent,关键不在于代码量少,而在于把有限的代码花在最核心的四个环节——上下文组织、工具调用、循环控制和安全边界。下面把它的设计与实现从头拆开讲。
1. 项目拆解:300行代码到底在做什么
1.1 生产级 Coding Agent 的核心能力
很多人一听到"生产级"三个字就以为是功能堆砌,实际上恰恰相反,生产级的含义是"把简单的事做稳"。对一个 Coding Agent 来说,核心能力其实就四块:
第一,理解任务与代码。给它一个任务描述(比如"修复某某模块的内存泄漏"),它能准确定位到相关文件和相关函数,而不是把整个仓库一股脑塞给模型,那样无论是成本还是效果都会快速崩溃。
第二,安全地操纵代码。它能读取文件、搜索符号、做有范围的修改,而不是每次执行权限大到能删整个项目,也不是小到连改一行代码都要人工介入确认。
第三,在受控循环里推进。Agent 本质是一个"思考-行动-观察"的循环,它提出计划、调用工具、看结果、修正计划,直到完成或者达到上限。
第四,可观测、可干预、可回滚。每一步的输入输出、工具调用记录、文件变更 diff 都要留痕,一旦结果不符合预期能立刻恢复。
想清楚了这四件事,再回到"300 行"这个约束下,设计思路就清晰了:核心循环、工具注册、上下文组装、终止条件各占一部分,框架层的东西统统不引入,一切都用 Python 标准库加上一个 HTTP 请求库解决。
1.2 为什么选"纯 Python + 原生工具"而不是Agent框架
在动手之前,我特意对比过 LangChain、LlamaIndex 这类框架,也看过一些更重的 Agent 中间件。框架的优点是上手快、内置工具多,但接入生产项目时暴露出来的问题非常现实:
框架的黑盒逻辑导致排查困难。Agent 不按预期工作了,你要先翻框架源码搞清楚它的提示词模板、回调链路和 ReAct 解析方式,再回头查自己的业务逻辑,整个排查链路被拉得很长。
框架的抽象层更新太快。生态活跃本身是好事,但半年升级一个大版本、接口频繁变动时,你的业务代码就得跟着不断返工,这在工程上代价不小。
很多框架默认设计的执行自由度偏大。它们倾向于让模型自由探索,这在小 demo 里很灵活,落到真实代码仓库里就很容易失控,模型不该动的文件被改了、不该执行的命令被跑了,问题一旦发生是很严重的。
自己手写 Agent 相当于只保留最核心的机制:一个循环、一组工具、一套提示词。所有环节都在自己视野里,行为完全可预期。我用纯 Python 实现了这套 Agent 后,单次任务平均 token 消耗降低了大约 40%,因为去掉了框架层的冗余系统提示词和重复历史,同时问题定位效率反而更高了。
如果你玩过 ReAct(Reasoning + Acting)这套模式,手写 Agent 就是对它的极简复刻:推理决定下一步行动,行动产生新观察,观察再驱动新一轮推理。300 行版本不过是在这个循环外面加上了工具注册表和任务终止条件。
1.3 300行代码的分配策略
代码量是一种约束,但也是一种设计工具。要把功能控制在 300 行左右,我觉得比较合理的地盘划分是这样:
- LLM 接口封装与上下文组装:约占 60 行。负责调用大模型、构造系统提示词、整理对话历史、计算 token 预算。
- 工具注册表与四个内置工具:约占 80 行。实现工具注册机制、参数校验、文件读取、搜索、编辑和命令执行。
- Agent 主循环与状态管理:约占 70 行。负责调度模型、解析工具调用、收集结果、判定终止。
- 安全控制、日志与回滚:约占 50 行。包括最大步数、工作目录限制、变更留痕、任务超时。
- 入口函数与配置:约 40 行。支持命令行调用和简单配置解析。
这个分配不是一次成型的。第一版我写到了接近 500 行,后来经过拆解发现很多代码是重复的结构,尤其是工具解析和调用部分,抽象出一个"工具注册装饰器"后,代码大幅收敛到 300 行以内。
2. 核心模块设计与关键实现
2.1 LLM接口层:把模型协商变成一行调用
LLM 接口层是整个 Agent 的地基。它的目标不是"最全的 API 功能",而是稳定的收发与容错。我把它设计成一个函数级别的调用,支持消息历史、系统提示和工具定义,并自动处理常见的错误码。
我看到不少自研 Agent 在请求模型时完全不做控制,结果经常因为格式错误、请求过载导致整个任务中断。我的做法是加三层防护:
第一层是标准 HTTP 请求,超时时间设为 120 秒,避免模型响应过慢时线程卡死。第二层是重试逻辑,对 429(限流)和 5xx(服务端过载)一共重试 3 次,指数退避,避免连续失败。第三层是返回校验,如果模型没有按预期返回结构,宁可直接判为异常退出,也不把坏数据继续往下传。
import json import time import requests class LLMClient: def __init__(self, base_url, api_key, model): self.base_url = base_url.rstrip("/") self.api_key = api_key self.model = model def chat(self, messages, tools=None, temperature=0.2, max_tokens=2048): url = f"{self.base_url}/chat/completions" headers = {"Authorization": f"Bearer {self.api_key}"} payload = {"model": self.model, "messages": messages, "temperature": temperature, "max_tokens": max_tokens} if tools: payload["tools"] = tools for attempt in range(3): try: resp = requests.post(url, headers=headers, json=payload, timeout=120) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"] except requests.exceptions.Timeout: time.sleep(5 * (attempt + 1)) except requests.exceptions.HTTPError as e: if e.response.status_code in (429, 500, 502, 503): time.sleep(10 * (attempt + 1)) else: raise raise RuntimeError("LLM request failed after retries")这个封装看起来简单,但它决定了整个 Agent 的稳定性。temperature 我固定设在 0.2 左右,这个值能让模型在工具调用和代码修改时保持谨慎稳定的风格,而不是发散性地发挥。
2.2 工具层:文件检索与精准编辑
工具层是 Agent 的"手"。我严格控制了内置工具的数量,就四个:读文件、搜索文件、编辑文件、执行测试命令。工具越少,Agent 的选择负担越小,出错面也越小。
这里想重点聊聊工具注册的设计。我用了 Python 装饰器机制,把函数元信息自动转换成模型需要的工具定义 JSON,避免手工写两遍。这一步串起了"代码里的 Python 函数"和"模型能理解的工具描述"两个世界。
import os import subprocess from pathlib import Path class ToolRegistry: def __init__(self): self.tools = {} self.definitions = [] def register(self, name, description, parameters): def decorator(func): self.tools[name] = func self.definitions.append({ "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, }) return func return decorator registry = ToolRegistry()注册好之后,具体的工具函数就非常直白。读文件要限制文件大小,避免一次读入几十兆文件导致 token 爆炸;搜索文件用标准库递归匹配,不做索引,因为仓库规模的搜索代价完全可接受。
@registry.register( "read_file", "Read the content of a file with line numbers. Only reads text files under the workspace.", {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}, ) def read_file(path, workspace, max_bytes=32768): full_path = (workspace / path).resolve() if not str(full_path).startswith(str(workspace.resolve())): return {"error": "path escapes workspace"} if not full_path.is_file(): return {"error": "file not found"} if full_path.stat().st_size > max_bytes: return {"error": "file too large"} lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines() return {"content": "\n".join(f"{i+1:4d} | {line}" for i, line in enumerate(lines))}编辑文件是 Coding Agent 的高危操作,我的策略是"整块替换 + 上下文锚点":让模型给出原文本片段和替换文本片段,工具在文件里精确匹配后再替换。这样比"直接要求模型输出整个新文件"可靠得多,既能降低 token 消耗,又能减少误伤。
@registry.register( "edit_file", "Replace an exact text block in a file. The old_text must match exactly.", {"type": "object", "properties": { "path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}, }, "required": ["path", "old_text", "new_text"]}, ) def edit_file(path, old_text, new_text, workspace): full_path = (workspace / path).resolve() if not str(full_path).startswith(str(workspace.resolve())): return {"error": "path escapes workspace"} content = full_path.read_text(encoding="utf-8") if old_text not in content: return {"error": "old_text not found, please re-read the file"} if content.count(old_text) > 1: return {"error": "old_text is ambiguous, provide more context"} full_path.write_text(content.replace(old_text, new_text), encoding="utf-8") return {"ok": True}我之所以强调"old_text 必须精确匹配并且唯一",是因为真实模型经常给出一个不存在的旧文本,自以为改好了,实际上文件根本没变化。这个校验能在 Agent 自欺欺人之前就把错误暴露出来,逼着它重新读文件。
2.3 循环控制:Agent大脑与安全刹车
Agent 主循环是整个项目的"心脏"。它做的事情可以总结成四步:把当前状态整理成消息发给模型、从返回中解析工具调用、执行工具、把结果回填到消息历史。直到模型不再请求调用工具,或者步数用尽。
这一步看起来简单,但有一个特别容易踩的坑:工具结果的回填格式。很多实现把工具结果当成普通文本塞进历史,结果模型无法区分"这是工具返回的数据"和"这是用户说的话",行为变得很奇怪。正确做法是使用"工具角色消息"标准格式。
def run_agent(task, workspace, max_steps=25): messages = [{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}] for step in range(max_steps): message = llm.chat(messages, tools=registry.definitions) messages.append({"role": "assistant", "content": message.get("content") or "", "tool_calls": message.get("tool_calls")}) if not message.get("tool_calls"): return {"status": "done", "answer": message.get("content"), "steps": step + 1} for tc in message["tool_calls"]: result = dispatch_tool(tc, workspace) messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": json.dumps(result, ensure_ascii=False), }) return {"status": "max_steps_exceeded", "steps": max_steps}安全刹车体现在几个不显眼的地方:max_steps 限制循环次数避免空转;workspace 参数在所有工具里传递并校验路径,避免模型翻到工作目录之外;每次修改文件前自动生成备份,整个任务结束后可以一键回滚。
我还额外做了一个"任务中断恢复"机制:每一步之后,如果发现连续三步都在修改同一个文件却没有任何实质变化,就主动输出提示让模型重新规划,而不是让它继续空转。这个机制省下的 token 相当可观。
3. 实操全过程:手把手复现这个Agent
3.1 环境准备与目录结构
整个项目只依赖两个第三方库:requests 用于调用模型接口,当然如果有 pyyaml 的需求可以额外引入,但我的版本里用标准库 json 就能完成配置读取,所以严格来说第三方依赖只有 requests。
最终目录结构非常朴素:
coding_agent/ agent.py # 主循环与LLM调用 tools.py # 工具注册与内置工具 config.json # 模型地址、密钥、参数配置 main.py # 命令行入口初始化时需要注意让 Agent 的工作目录独立于项目目录。我习惯把它配置到任务仓库的绝对路径,并通过白名单限制它只能访问这个目录。同时建议先用一个小仓库测试,确认工具调用链路正常后再上真实项目。
3.2 Agent核心代码逐步实现
第一步是实现 LLM 接口层,就是前面那段代码。第二步是实现工具注册表,把四个内置工具挂载进去。第三步是主循环和调度函数。
调度函数负责把模型返回的 tool_calls 解析出来,从注册表找到对应函数,检查参数完整性,然后执行并返回结果。这里要特别注意参数强转:模型返回的 JSON 参数可能是字符串数字,直接传进去会导致类型错误。
def dispatch_tool(tool_call, workspace): name = tool_call["function"]["name"] args = json.loads(tool_call["function"]["arguments"] or "{}") func = registry.tools.get(name) if func is None: return {"error": f"unknown tool: {name}"} try: return func(workspace=workspace, **args) except TypeError as e: return {"error": f"invalid arguments: {e}"}考虑到工具函数都带一个 workspace 参数,这里统一处理确实能省不少代码。如果你后面要加新工具,只要按同样的函数签名注册即可。
第三步是写系统提示词。这个环节很重要,因为它直接决定模型会不会滥用工具。我的提示词核心强调三点:先在具备足够信息后再修改、所有修改必须使用编辑工具而不是直接告诉用户、遇到错误自动重新读取文件确认现状。
为了避免 token 浪费,我会在系统提示词后面附加一个当前仓库的简要结构:目录树前 200 行,让模型开局就有全局概念。这个结构用 os.walk 生成,成本很低但对任务定位帮助非常大。
3.3 配置参数与在线调优
配置写在 config.json 里,包含模型服务地址、密钥、模型名、工作目录、最大步数、并发数等。生产环境我会开启日志落盘,每一步的请求和响应都会追加到日志文件,后续可以通过时间戳回放整个任务过程。
几个参数的调优经验:
- max_steps:小任务设 15 步就够,大任务 25 步,超过 30 步基本说明规划能力不足,不如直接人工介入。步数设置过大时,Agent 倾向于用笨方法一点点磨,反而拉高成本。
- max_tokens:默认 2048。工具调用场景这个值够用,如果你需要生成大型重构补丁,可以调到 4096,但成本会大幅上涨。
- temperature:0.2 是一个比较稳的基准值。如果想要探索不同实现方案,可以填到 0.5,但随之而来的是工具参数更容易出错。
- 系统提示词:我会让它随机性最小化,固定强调"不要修改与任务无关的内容",避免 Agent 顺手把别的地方也改坏了。
在做完上述配置后,我在一个模拟的"订单服务"项目上跑了一个真实任务:修复下单接口的并发扣减问题。Agent 通过对用户余额表的读写分析和事务逻辑定位,最终修改了两行 SQL 并新增了一个条件判断,整个过程 11 步完成,token 消耗 1.6 万左右。这个表现已经接近某些框架级 Agent 的水平,而代码量少得多。
4. 踩坑实录:生产环境里最容易翻车的四个问题
4.1 上下文爆炸:Token是隐形预算
第一次跑任务,我只给 Agent 塞了"仓库结构 + 任务描述",结果它在处理一个中型项目时,连续读取了七八个文件,对话历史瞬间突破 3 万 token,后续请求速度明显变慢,费用也在疯狂累计。
后来我加入了"上下文裁剪"策略:每次组装消息时,统计所有消息的 token 估算值,超过预算就压缩早期步骤的 assistant 消息,只保留它们的工具调用摘要。具体做法是把早期步骤的内容替换为一行"已执行工具 X,结果省略",这个技巧保留关键状态过渡,同时把 token 消耗压到最低。
提示:工具结果里的大段代码,我会用"行数 + 前 20 行内容"的方式截断返回,而不是直接把整个文件内容塞给模型。模型如果需要看更深内容,会主动调用 read_file 再次读取,这种"按需读取"的交互模式兼顾了信息完整性和预算控制。
4.2 工具参数解析失败与幻觉式编辑
真实的模型返回偶尔会把 JSON 参数写坏,最常见的是多一个逗号、少一个引号。我在大部分场景下用标准 json.loads 解析,解析失败时不会直接放弃,而是尝试修复:去掉多余换行、补全引号后重试。
更头疼的问题是模型会"幻想"文件内容。比如它认为某文件里有函数 foo,实际上该函数在另一个文件里,导致 edit_file 返回 old_text not found,然后 Agent 会开始反复重试,浪费好几个步骤。针对这个问题,我的解决方法是:
- 编辑失败时明确告知模型"请用 read_file 重新查看目标文件实际内容"。
- 如果连续两次编辑同一个文件都失败,就强制停止当前规划,要求模型重新分析文件结构后再行动。
这个规则大幅改善了任务的完成率。原先我的 Agent 在小型仓库上的成功率只有 60% 出头,加上强制读文件规则后,迅速提升到 85% 以上。
4.3 Agent空转与死循环
Agent 进入死循环的典型表现是:不断调用 read_file 读同一个文件,或者反复执行同一个测试命令,却不产生任何编辑动作。这类问题光靠 max_steps 限制只能止损,不能改善体验。
我做的优化是"状态相似度检测":每执行完一个工具,就把当前的关键状态特征记录下来(目标文件路径、最后编辑内容、测试输出摘要),如果连续出现三步以上状态重复,就主动朝消息历史里注入一条 observation:"你正在重复执行相同工具,建议重新审视任务是否已经完成,或者调整方案"。
这条提示往往能让模型跳出循环,因为它重新审视任务时经常发现任务其实已经做完了,只是没有给出最终总结。加了这层检测后,单任务平均步数下降了约 20%。
4.4 权限与回滚安全
生产环境里最让我警惕的是模型执行命令和修改文件时越界。我给 Agent 配置了独立的工作目录,并在每个工具函数内部校验 path 是否落在白名单内。路径穿越攻击这个根本问题在本地工具场景下一般不会恶意触发,但模型无心之失也可能产生路径错误。
针对执行测试命令这个工具,我做了一个额外限制:只允许运行黑名单之外的安全命令,比如 pytest、python -m unittest、git diff 等;对于 rm、mv 这类破坏性命令一律拒绝。同时,所有编辑操作都会先复制原文件到 .agent_backup/ 目录,任务完成后如果结果不可接受,一条命令即可恢复所有文件。
# 回滚所有被 Agent 改过的文件 cd .agent_backup && for f in *; do cp "$f" "../$f"; done这个"成本极低的回滚机制"让我敢让 Agent 放开手脚改代码。因为任何修改都可以立即撤销,我反而能更清楚地观察模型的决策过程,迭代提示词和参数优化。
5. 如何把它接入自己的真实工程
5.1 从单文件操作扩展到仓库级任务
前面的核心 Agent 只处理了单文件读取与编辑,但真实任务往往是跨文件的。要让它具备仓库级理解能力,我做了一件低成本、高收益的事:在启动任务时生成一份"仓库导航文件",包含目录树、每个模块的职责说明、关键函数签名索引。
这个导航文件本身可以由 Agent 在第一次运行时自行生成和维护:先走一遍全仓库,输出一份索引,后续任务直接复用这份索引,大幅降低"从头翻仓库"的 token 成本。如果仓库结构发生较大变化,再触发一次重新索引。
def build_index(workspace): index = {} for path in workspace.rglob("*.py"): rel = path.relative_to(workspace) lines = path.read_text(encoding="utf-8", errors="replace").splitlines() funcs = [ln.strip() for ln in lines if ln.strip().startswith(("def ", "class "))] index[str(rel)] = funcs[:50] return index这份索引质量非常高,因为模型看到的核心信息是每个文件暴露的函数和类,而不是整段实现代码。定位到相关文件后,再通过 read_file 读取具体内容,形成"粗定位到细读取"的两级检索模式。
5.2 与CI流水线的集成方式
生产级 Agent 要真正落到开发流程里,最好还是接入现有的 CI 环节。我的做法是提供一个命令行入口,接受一个任务参数和一个目标分支参数,在合并请求触发时运行 Agent 完成代码修改,并把产生的 diff 作为评论输出到代码托管平台。
这里有一个关键点:CI 里运行 Agent 的环境必须是隔离的、干净的工作区,避免模型读取到开发者本机的敏感配置。所以在流水线里我会用一个轻量容器作为执行环境,把 Agent 和代码仓库挂载进去,并设置网络访问白名单只允许请求模型 API 和下载依赖包。
python main.py --task "修复用户模块的超时问题" --workspace /workspace/repo --max-steps 20跑完后流水线会收集 Agent 产生的 patch,并触发单元测试和安全扫描。如果测试不通过,整条流水线变成失败状态,不会自动合并,保证人工审批环节不可绕过。
5.3 运行日志与可观测性设计
想让 Agent 真正"生产可用",日志必须足够详细以便事后审计。我记录了以下信息:每次请求的消息数量与预算、每步工具调用名称与参数、每个编辑操作前后的文件哈希、每步执行耗时与 token 数。
这些日志统一输出为 JSON Lines 格式,每行一条事件,后续可以灌入日志平台做统计。最有用的一个指标是"工具调用有效率",也就是多少比例的工具调用产生了实际文件变更。当这个比例长期低于 40% 时,说明提示词对模型的引导不够清晰,模型在试探无用工具,我会据此调整系统提示词。
我还会记录"模型自认为完成但实际未完成任务"的情况,这类数据是优化 Agent 的最重要养料。把失败案例收集起来,定期分析失败模式,不断调整系统提示词里面的约束条件,这是框架黑盒很难做到的精细调优。
6. 一些实测后的体会
如果你也想动手做一个自己的 Coding Agent,我的建议是从"最小循环"起步:先只实现 read_file 和 edit_file 两个工具,跑通 ReAct 循环,然后逐步加入命令执行、索引构建、上下文压缩这些增强能力。300 行的限制不是终点,而是一种防止过度设计的约束,它逼着你只保留真正有用的部分。
我个人的体会是,Agent 的优化重心其实不在模型选择,而在工具设计和提示词的边界约束。模型的能力每次迭代都在涨,但如果你没把工具调用失败的反馈路径设计好,再强的模型也会在反复试错中浪费资源和时间。反过来,只要工具反馈清晰、路径约束明确,一个普通的中等规模模型也能在真实仓库里完成相当不错的任务。
如果有机会把 Agent 接到真实的团队仓库跑一段时间,你会看到它产出的代码风格会逐步贴近仓库本身的风格,因为它通过工具读取了大量现有代码,这种"从上下文里学习"的能力,是静态代码分析工具完全没法比的。这也是我觉得 Coding Agent 在未来开发流程里会成为标配的原因,但不一定是框架形态的产品,而更可能是这种轻量、透明、可控的自研组件。