1. 先认清"黑箱"里到底有什么:Coding Agent 的最小组成
很多人第一次接触 Coding Agent,都会产生一种感觉:这东西像个黑箱,丢一个需求进去,它自己读代码、改文件、跑测试,最后吐出一个 PR。中间到底发生了什么,谁也说不清。我刚开始也是这么想的,直到自己动手搭了一个,才发现所谓的"黑箱",拆开看就是几个非常朴素的组件,只是被包装得看起来很高端。
1.1 一次 LLM 调用和一个 Agent 的本质区别
先说一个最容易被绕晕的点。普通 LLM 调用,比如你问 GPT"帮我看看这段代码有什么问题",是一次性的:输入一个 prompt,输出一段文本,完事。就算你让它"再想想",它也没有能力自己打开你的项目文件、实际跑一遍测试、根据报错改代码,它只能基于你塞进上下文里的信息继续编。
Coding Agent 不一样的地方在于,它把那次性的调用变成了一个循环:模型输出一个"意图"(比如我想读某个文件),程序替它执行这个动作,把执行结果(文件内容、测试输出)再塞回给模型,模型根据新信息决定下一步动作。这个循环一直转,直到模型认为任务完成了。
所以 Agent 的本质不是"更聪明的 LLM",而是"LLM + 一套能动手的工具 + 一个替它反复决策的循环"。理解这一点,后面所有设计都有了着落。
1.2 四个核心部件:模型、工具、循环、记忆
我搭完第一个可用版本后,回头总结,一个 Coding Agent 跑起来只需要四样东西:
- 模型:负责推理和决策,也就是"大脑"。它不需要真的会写代码,它只需要知道该调什么工具、该看什么信息。
- 工具:模型实际动手的"手脚"。对编程场景来说,最基础的三件就是读文件、写文件、执行命令。
- 循环:把模型输出转成工具调用、把工具结果喂回模型、判断何时终止。这是 Agent 的"脉搏"。
- 记忆:这里特指消息历史。模型本身没有状态,所有已经读到的信息、已经做的修改、测试结果,都得靠消息记录保存在上下文里,模型才能"记得"自己刚才干了什么。
下面我就拿 Python 和 OpenAI 的函数调用协议,从零把这个骨架搭出来。整个代码量不大,跑通之后你会觉得,哦,原来所谓 Agent,就是一层循环加几个函数,压根没有魔法。
2. 从零搭骨架:Python 实现 ReAct 循环
ReAct(Reasoning + Acting)是目前绝大多数 Agent 的基础范式:模型先推理,再行动,然后根据观察到的结果继续推理。这个模式在 Coding Agent 里格外好用,因为写代码本身就是一个试错过程。
2.1 用函数调用协议声明工具能力
现在主流的大模型 API 都支持 function calling,也就是你在请求里声明一批工具的 JSON Schema,模型在需要的时候会返回一个结构化指令,告诉你"我要调用哪个工具、传什么参数"。这是 Agent 的"标准电压",比早期那种让模型输出特定文本再正则解析的方式可靠得多。
先定义三个最基础的工具:
TOOLS = [ { "type": "function", "function": { "name": "read_file", "description": "读取指定路径的文件内容,返回文件文本。如果文件较大,只返回前 200 行。", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "相对于项目根目录的文件路径"} }, "required": ["path"] } } }, { "type": "function", "function": { "name": "write_file", "description": "将完整内容写入指定文件,会覆盖原有内容。写入前请确保已经 read_file 看过当前内容。", "parameters": { "type": "object", "properties": { "path": {"type": "string", "description": "相对于项目根目录的文件路径"}, "content": {"type": "string", "description": "要写入的完整文件内容"} }, "required": ["path", "content"] } } }, { "type": "function", "function": { "name": "run_command", "description": "在项目根目录执行 shell 命令,返回标准输出和标准错误。适合运行 pytest、python 脚本等。", "parameters": { "type": "object", "properties": { "command": {"type": "string", "description": "要执行的 shell 命令"} }, "required": ["command"] } } } ]注意我在 description 里写了不少"约束":比如 read_file 说只返回前 200 行,write_file 说写入前必须先看原文件。这些约束不是废话,模型真的会读这些描述来约束自己的行为。工具描述写得越清楚,Agent 的"精神状态"就越稳定。
2.2 execute_tool 分发器与循环主体
工具声明好了,接下来写一个分发函数,把模型的工具调用请求转成真实的 Python 函数执行:
import subprocess import json from pathlib import Path ROOT = Path("/path/to/your/project") def execute_tool(tool_call): name = tool_call.function.name args = json.loads(tool_call.function.arguments) if name == "read_file": path = ROOT / args["path"] if not path.exists(): return f"错误:文件不存在 {path}" lines = path.read_text(encoding="utf-8").splitlines() body = "\n".join(lines[:200]) if len(lines) > 200: body += f"\n... (共 {len(lines)} 行,已截断)" return body if name == "write_file": path = ROOT / args["path"] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(args["content"], encoding="utf-8") return f"已写入 {path},共 {len(args['content'])} 字符" if name == "run_command": result = subprocess.run( args["command"], shell=True, cwd=ROOT, capture_output=True, text=True, timeout=30 ) output = result.stdout + "\n" + result.stderr # 截断超长输出,防止上下文爆炸 if len(output) > 4000: output = output[-4000:] return output return f"未知工具: {name}"然后是整个 Agent 的心脏——循环主体:
from openai import OpenAI client = OpenAI() SYSTEM_PROMPT = "你是一个运行在本地代码仓库中的 AI 编程助手。……(见第 4 节)" def run_agent(task: str, max_iter=15) -> str: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}, ] for step in range(max_iter): response = client.chat.completions.create( model="gpt-4o", messages=messages, tools=TOOLS, tool_choice="auto", temperature=0.2, ) msg = response.choices[0].message messages.append(msg) # 没有工具调用,说明模型觉得任务结束了 if not msg.tool_calls: return msg.content # 逐个执行工具调用,把结果追加进消息历史 for tool_call in msg.tool_calls: result = execute_tool(tool_call) messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": result, }) return "达到最大迭代次数,任务未完成"到这里,一个能跑的 Agent 已经有了。它拿到任务后,会自己读文件、改代码、跑测试,然后根据测试结果继续修,直到所有测试通过,或者迭代次数耗尽。
2.3 我把循环参数调成了什么值
几个参数我实际调过的经验值,直接给你参考:
| 参数 | 我用在 Demo 上的值 | 说明 |
|---|---|---|
| max_iter | 15 | 太少容易完不成任务,太多会累积大量 token 成本 |
| temperature | 0.2 | 编程任务要确定性,不能用默认的 0.7 或 1.0 |
| 工具输出截断 | 4000 字符 | 测试日志动辄几千行,必须截断,否则上下文很快炸 |
| command 超时 | 30 秒 | 防止模型写出死循环命令或卡住的测试 |
15 次迭代听起来不多,但每次迭代里模型可能一次调用多个工具,实际能完成的操作远超 15 次。以修 bug 的场景来说,一个典型的任务大概会经历:读 2~3 个文件、改 1 次代码、跑 2~3 次测试,差不多 6~8 个工具调用,所以 15 次迭代是够用的。
3. 工具设计是"能不能用"的分水岭
同样一个 LLM,工具设计得好不好,决定了 Agent 是"聪明的实习生"还是"只会复读的聊天机器人"。这一节说说我在工具粒度上的取舍。
3.1 read_file / write_file / run_command 的粒度取舍
一开始我贪多,给 Agent 准备了很多工具:search_symbol、find_file、list_directory、edit_line、insert_code……结果模型在选择工具上浪费了大量决策,而且经常选错。
后来我砍到只剩三个工具,效果反而好了。原因很简单:工具越少,模型的决策负担越小,每个工具被调用的频率越高,模型对工具行为的预期越准确。
- read_file承担所有"看"的需求,包括看目录结构也行(直接读目录会返回错误或列表,模型能接受)。
- write_file承担所有"改"的需求,用整文件覆盖而不是行级编辑。行级编辑看起来省 token,但要求模型精确计算行号,一旦文件被并发修改就全乱了。整文件覆盖虽然每次可能多传几百行,但对于小项目来说完全够用,而且逻辑简单、不容易出错。
- run_command承担所有"验证"的需求。模型写完代码,自己跑 pytest,看到失败信息再改,这是 Coding Agent 区别于"代码生成器"的核心。
3.2 工具执行结果的截断与格式化
工具返回的结果,最终都要拼进 messages 里重新发给模型。这段内容的质量,直接决定了模型的判断。
我有两个习惯。第一,所有输出都截断,read_file 截到 200 行,run_command 截到 4000 字符。截断比不截断好,因为满屏的日志反而会让模型抓不住重点。第二,在结果里加上必要的元信息,比如文件路径、总行数、字符数。这些信息帮助模型建立对项目的"空间感"。
有个反直觉的发现:在工具结果前面加一个简短的状态描述,比如"命令执行成功,但测试有 3 个失败项",会让模型的理解准确很多。因为模型是文本推理的,给它一个"摘要 + 原始输出"的格式,相当于给它配了个前额叶。
3.3 为什么我没让 Agent 直接用终端跑任意命令
有些项目会让 Agent 直接连上终端,想跑什么跑什么。我明确不这么做,至少在第一版不这么做。原因不是技术上的,而是安全上的:模型可能跑出rm -rf,也可能因为一个拼写错误把环境搞坏。
折中方案是把 run_command 做成一个白名单命令转发器,只放行安全的指令前缀:
SAFE_PREFIXES = ("pytest", "python", "ls", "cat", "grep", "git diff", "pip install -r") def run_command(command: str) -> str: if not any(command.startswith(p) for p in SAFE_PREFIXES): return f"错误:命令 '{command}' 不在白名单中,只允许执行 {SAFE_PREFIXES}" ...这样 Agent 依然能运行测试、执行脚本,但没法做太出格的事。等到你对 Agent 的信任度上来了,再逐步放宽,比如加一个人工确认机制,放行任何命令前弹个确认框。Demo 阶段,白名单就够了。
4. 系统提示词和参数:最容易抄错的地方
很多教程把这部分一笔带过,但我实测下来,系统提示词对 Agent 行为的影响不亚于工具设计。同一个模型、同一套工具,换个提示词,成功率能从 40% 提到 85%。
4.1 我给系统提示词定的五条铁律
系统提示词我最终收敛成了五条规则。每条都是踩过坑之后才加的:
你是一个运行在本地代码仓库中的 AI 编程助手。你的任务是帮助用户完成代码修改和问题修复。 工作流程: 1. 先使用 read_file 阅读相关代码,理解项目结构和现有逻辑 2. 定位问题,想清楚修改方案后再动手 3. 使用 write_file 修改代码,修改后立即用 run_command 运行测试 4. 根据测试输出判断是否完成;测试失败则继续修复 铁律: - 修改任何文件前,必须先 read_file 查看当前内容 - 一次只修改一个文件,改完立刻验证 - 不要修改与任务无关的代码 - 如果连续两次修改都没有通过测试,停止修改,重新 read_file 阅读代码再思考 - 任务完成时,用简短的中文汇报你做了什么修改、测试是否通过第五条"连续两次失败就停下来重新读代码"是解决死循环的关键。没有这条的时候,模型会像一个倔强的实习生,同一个方案改了三次还继续改。加上这条之后,它会主动退回一步,去看看是不是自己理解错了代码。
4.2 温度、迭代上限、上下文窗口的搭配
模型参数这块,我的建议是:温度一定要往低调。我用 0.2,有人用 0,效果都不错。写代码和写诗不一样,不需要发散,需要的是稳定。
迭代上限上面说的是 15,但这个值要配合上下文窗口看。gpt-4o 的 128k 窗口看着很大,但每次工具调用结果都累积在消息里,几轮下来就占掉一半。如果任务本身很大(比如跨多个文件的重构),就要么提高 max_iter,要么做上下文裁剪,二选一,不能两个都省。
我的判断标准是:如果 Agent 在 10 次迭代内还没有迈出第一步(比如一直读文件、没写过任何代码),大概率是它卡住了,这时候调大迭代次数没有意义,应该去查是工具定义有问题还是提示词让它困惑了。
5. 实测中的三个翻车现场与修复过程
光讲原理不够,我把实际跑的时候遇到的三个最典型的翻车场景拉出来,每个都是"卡死"级别的,而且都有通用的解法。
5.1 翻车一:同一个错误反复修不好,陷入死循环
场景:我让它修一个 Python 脚本里的逻辑 bug。Agent 第一次改完,pytest 报AssertionError,它看了一眼错误信息,又改了同一行,还是同样的报错,第三次又改了同一行,依然报错。直到 15 次迭代耗尽。
我分析日志后发现,问题出在报错信息给的信息太"局部"了:只报assert result == 5失败,但 Agent 不知道result是怎么算出来的。它每次都在猜,而且每次都猜同一个方向。
修复办法有两个,我都用了:
一是系统提示词里的"连续两次失败就重新读代码"。 二是给 run_command 结果加一条后处理逻辑:如果测试失败,自动把测试文件内容附在输出后面,让模型看到"测试到底在测什么"。
if "FAILED" in output and "test" in args["command"]: # 找到测试文件,读出来放在报错后面 ...加了第二个措施之后,Agent 不再瞎猜了,它能看到测试的断言逻辑,理解目标值是怎么来的,修复准确率明显上升。
5.2 翻车二:读文件太多,上下文窗口溢出
场景:任务涉及一个多文件项目,Agent 为了搞清逻辑,一口气 read_file 了七八个文件,每个文件几百行。到了第五轮迭代,API 直接返回错误,说消息长度超过上下文限制。
这个问题的本质是:Agent 没有"遗忘"机制。所有读过的内容都在消息历史里,模型只能被动接受。
我的解法是做一个简单粗暴的"滑动窗口":
def trim_messages(messages, max_len=30000): total = sum(len(m.get("content", "")) for m in messages) if total <= max_len: return messages # 保留 system 和最近的若干条消息 head = messages[:2] # system + 原始任务 tail = messages[-10:] # 最近 10 条,含最新工具结果 return head + [{"role": "system", "content": "注意:部分较早的对话内容已被截断,请基于现有信息继续。"}] + tail滑动窗口不是完美的方案,因为它会丢失早先读到的文件内容,模型可能"失忆"。但在 Demo 阶段,这是性价比最高的方案。真要做得优雅,得引入摘要记忆,把读过的文件内容压缩成要点存起来,需要时再恢复,这就属于进阶优化了。
5.3 翻车三:工具调用 JSON 解析失败导致中断
场景:某一次运行,模型返回的tool_calls里,某个参数的 JSON 出现了畸形,json.loads直接抛异常,Agent 当场崩溃。
这类问题在小模型上尤其常见,但是用大模型也可能偶发,比如参数值里包含特殊字符时。
我在分发器里加了容错:
try: args = json.loads(tool_call.function.arguments) except json.JSONDecodeError as e: return f"工具参数 JSON 解析失败: {e}。请重新检查参数格式。"关键在于:解析失败时不要把错误变成异常抛出,而是把错误信息作为工具执行结果返回给模型。这个错误会进到下一轮消息里,模型看到后会自己修正格式,重新生成一个合法的调用。实测下来,90% 的情况模型都能在下一轮自我纠正。这是 Agent 容错设计的通用思路:让错误成为观察的一部分,而不是中断循环的理由。
6. 从"能跑"到"敢用":安全边界和进阶方向
最后聊点务实的。Demo 跑通是一回事,真正敢让它在你项目里干活是另一回事。安全边界和进阶能力,是两条绕不开的路。
6.1 目录白名单、命令黑名单与人工确认机制
我的第一版 Agent 是往项目根目录一扔,让它自由活动。后来我意识到,一旦 Agent 能写文件、能执行命令,它就有能力破坏你的开发环境。所以安全这块,我建议按三层来搭:
- 路径层:write_file 写入前校验路径是否在项目根目录内,想写出去直接拒绝并返回错误信息。比如有人让它把日志写到 /tmp 或者家目录,直接拦截。
- 命令层:上面提到的白名单前缀机制。另外对
rm、sudo、curl | sh这类高危命令,无条件拒绝。 - 人工确认层:对写操作弹确认框,尤其是覆盖已有文件和执行非白名单命令时。这一步会打断 Agent 的自动化,但它能让你在 Agent 犯大错之前踩住刹车。你自己跑着玩的时候可以不开,真要对正式仓库动手,必须开。
这三层做完,Agent 就像一个"戴着镣铐的实习生":能干很多活,但干不出太出格的事。
6.2 进阶路线:记忆、规划器、多智能体协作
最后一个路线图,给想继续深入的人指个方向,按难度排序:
- 摘要记忆:工具结果太长时,用一次额外的 LLM 调用把结果压缩成结构化摘要,再存进上下文。这比粗暴截断聪明得多。
- 规划器:让一个高配模型先拆解任务,生成一个带检查点的计划,然后由执行模型按计划逐步执行。相当于给 Agent 配了个 Project Manager。DeepResearch 类产品基本就是这个思路。
- 多工具并行:模型一次可以发多个工具调用,先并行执行再统一汇总,能显著减少往返次数。
- 多智能体协作:一个"分析员"角色只读代码、出诊断报告,另一个"程序员"角色只负责改代码,还有一个"评审员"角色专门挑毛病。角色分离能让每个模型的上下文更干净,出错率更低。
我在实际使用中的体会是,Coding Agent 的每一次"智能"表现,背后都是工程设计的功劳。所谓黑箱,其实是你没看见中间那层循环和工具。把它拆开、搭一遍、跑一遍、修一遍,你对 Agent 的理解会比看十篇综述都深。这个从零搭建的过程,本身就是最好的学习方式。