Coding Agent这个词,最近几个月都快被说烂了。从OpenAI的Codex上线开始,那句“welcome to codex”几乎成了AI编程圈子的接头暗号——大家突然意识到,让AI自己读代码、改文件、跑命令、看报错再修正,已经不是Demo阶段的东西,而是真能塞进日常开发工作流里的生产力工具。但热闹归热闹,真去打开一个Coding Agent的开源项目(比如我们今天用来当解剖样本的Pi),很多人大概率会有点懵:又是工具注册、又是Agent Loop、又是流式输出,还有一堆配置项,根本不知道从哪下手。
这篇文章想换个思路来聊。先带大家从0手写一个最小的Coding Agent,代码量控制在200行以内,能跑通“读代码—改代码—执行命令—看结果”这整条闭环;然后再拿这个小玩具当参照系,去拆解Pi这种生产级Agent的骨架,看看它到底比我们的玩具多了哪几根骨头,以及为什么要多。适合看的人有两类:一是想搞清楚Agent底层原理、准备自己动手搞AI编程工具的开发者,二是已经在用各种Coding Agent但想知道背后工程逻辑的同学。我先把话说清楚,这文章不涉及模型调优,也不涉及分布式训练,只讲工程结构——只要你会Python、能调通任意一个大模型的API,就能完全看懂。
1. 动手之前,先拆穿Coding Agent的三个底层概念
1.1 Coding Agent不是ChatGPT套壳,核心是“Agent Loop”
很多同学第一次接触Coding Agent,脑子里第一反应是“这不就是拿ChatGPT生成代码然后复制粘贴吗”。如果只是这样,它根本不配叫Agent。真正让Coding Agent有“智能感”的,是一个循环:Agent把任务拆成步骤,每一步调用一个或多个工具(读文件、写文件、执行命令),然后把工具返回的结果交给模型,模型再决定下一步做什么。这个循环的专业叫法是Agent Loop,本质上就是“思考”和“行动”交替进行。
打个比方。普通AI编程助手像一个只会出主意的顾问,坐在你旁边说“你应该改这个文件”“你应该跑一下那个测试”,但动手的还是你自己。而Coding Agent是一个真正能上手干活的实习生,他不仅能给出方案,还能自己打开编辑器改代码、自己开终端跑测试、看到报错自己修,你只在关键节点检查一下结果。“读工具结果→模型决策→再调工具→再看结果”这条链路,就是Agent的闭环。只要闭环在,哪怕模型本身并不顶尖,也能靠不断试错把任务磨完;闭环一旦断了,模型再强也只是纸上谈兵。
1.2 最小闭环里必须有哪几块积木
一个最小的Coding Agent,拆到不能再拆,至少需要四块积木。第一是模型接口,负责把系统提示、工具结果、对话历史打包成上下文,让模型输出“下一步动作”。第二是工具定义,给模型提供一组可调用函数的描述,包括函数名称、参数结构和用途说明——模型本身不执行代码,它只负责决定调哪个工具、传什么参数。第三是工具执行器,真正去执行模型指定的函数,并把执行结果返回给模型,所有文件读写、命令执行都发生在这层。第四是循环控制,判断对话是否继续、是否要发起新一轮工具调用,以及什么时候该终止。
这里有个新手极其容易搞混的认知点:模型不是一个能随便执行代码的运行时,它只是一个“决策器”,输出的是结构化指令。比如说模型输出“我要调用read_file这个工具,参数是src/main.py”,你的程序必须解析出这个意图,然后自己把文件读出来,再把内容塞回给模型。这套“决策与执行分离”的设计,是Agent能稳定跑起来的根基,理解了它,后面所有代码都好懂了。
1.3 为什么拿Pi当解剖对象
市面上Coding Agent相关项目并不少,但我选Pi当解剖样本,是因为它的代码结构足够“标准”。Pi不是一个玩具项目,它拥有完整的生产级特征:有配置系统、有工具注册中心、有会话状态管理、有流式输出,甚至还有并发处理和重试机制。对一个想理解Agent骨架的人来说,Pi就像一本写得比较工整的教科书——它的设计思路和你在生产环境里自己搭Agent时会遇到的问题几乎一一对应。后面我会把Pi的工程结构和我们写的那个最小实现逐条对比,让你清楚地看到:从一个200行的玩具到一个能上生产的框架,中间到底加了些什么、为什么加。
2. 从0实现最小Coding Agent:一步步跑通第一个闭环
2.1 技术选型:Python + OpenAI兼容协议,让模型可替换
考虑到代码要尽量短、你还要能直接跑起来,我选了Python。模型调用方面,现在几乎所有大模型厂商都提供了OpenAI兼容接口,所以我们就直接用一个标准的ChatCompletion调用,base_url换一下就能接不同模型。顺带提一下,Pi的配置项里也有base_url这个参数,它的作用就是配置模型服务地址——你可以用vLLM或者Ollama在本地起一个模型推理服务,把base_url指到本地,Pi就能跑在完全离线的环境里。这其实是生产级Agent的一个重要设计思想:模型供应商是可替换的,Agent的代码不应该绑定在某一家厂商的SDK上,而是通过标准协议去对接。
工具方面先实现三个基本的:read_file读文件、write_file写文件、run_command执行shell命令。这三个工具已经足够完成“读代码—改代码—跑测试—看输出”的完整闭环。后面如果你想扩展,比如加一个web_search工具,也完全不影响架构,只是往工具清单里多塞一个条目而已。
2.2 工具定义与执行器:给模型一份“能力说明书”
工具定义的本质,是给模型一张菜单。在OpenAI兼容协议里,工具格式是JSON Schema,包含名称(name)、描述(description)、参数结构(parameters)。描述信息非常关键,因为模型就是靠它来判断什么时候该用这个工具的。描述写得太模糊,模型容易在不需要的时候乱调用;写得太具体,又可能把模型的发挥空间限制死。我见过不少人在description里写“读取文件内容,返回文件内容”,这其实是句废话,好的描述应该说明“什么场景下用这个工具、边界是什么”。
比如读文件工具的描述,我会写成“读取文件内容,适合查看已有代码、配置或日志”,这样模型看到“这个函数是干嘛的、里面有什么”这类需求时,自然会优先想到它。执行命令工具的描述则会强调“适合运行脚本、测试、查看命令输出”。这些细节在文档里不显眼,但实际跑起来差别很大——工具描述写得差,模型会频繁选错工具,表现出来的就是Agent行为很蠢。
工具实际执行的函数也很直接。read_file就打开文件读内容,write_file就写文件,run_command就用subprocess跑命令并捕获stdout和stderr。我特意在返回结果里带上退出码,因为模型需要知道命令是成功还是失败,这直接决定它下一步是继续还是修正。
2.3 搭Agent Loop:决定什么时候该收手
Agent Loop的核心逻辑非常朴素。第一步,把系统提示、历史消息和工具描述拼成请求发给模型;第二步,拿模型的回复;第三步,检查回复里有没有tool_calls字段;第四步,有工具调用就执行工具、把结果追加到消息列表,然后回到第一步继续循环;第五步,如果模型没有输出tool_calls,说明它认为任务已经完成,直接退出循环。
但这里有个细节值得展开:到底什么时候算“任务完成”。模型可能前面好几轮都在调工具,突然某一轮不再输出tool_calls,而是输出一段总结,这个信号就是循环终止条件。还有一种情况是模型陷入死循环,比如反复读同一个文件却不做任何修改,这时候必须设置最大迭代次数作为安全阀,我习惯把它配成10轮,任务复杂可以调大,但一定要有上限。另一个我踩过的坑是工具结果太长——一个文件几千行,全塞回上下文直接就把模型的上下文窗口撑爆了。生产级方案是截断,我后来的习惯是把返回内容截到2000个字符,多余部分省略,必要时提示“内容过长,已截断”。
2.4 完整代码:能跑的200行最小Coding Agent
下面是一版完整的Python实现,注释写得比较详细。为了让你看清楚Agent Loop这副骨架,我刻意没加任何花哨功能:
#!/usr/bin/env python3 # 最小Coding Agent,约150行(含注释) import json import subprocess import sys from openai import OpenAI # 换成你实际用的模型服务地址,本地vLLM/Ollama或其他兼容OpenAI协议的服务都行 client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") SYSTEM_PROMPT = """ 你是一个Coding Agent。你可以使用以下工具: - read_file: 读取文件内容 - write_file: 写入/创建文件 - run_command: 执行shell命令 规则: 1. 每次只调用一个工具,根据工具结果决定下一步。 2. 当任务真正完成时,直接输出最终答案,不要再调用工具。 """ TOOLS = [ { "type": "function", "function": { "name": "read_file", "description": "读取文件内容,适合查看已有代码、配置文件或日志。", "parameters": { "type": "object", "properties": {"path": {"type": "string"}}, "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": "run_command", "description": "在shell中执行命令,适合运行脚本、测试、查看输出。", "parameters": { "type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"], }, }, }, ] def read_file(path: str) -> str: with open(path, "r", encoding="utf-8") as f: return f.read()[:2000] def write_file(path: str, content: str) -> str: with open(path, "w", encoding="utf-8") as f: f.write(content) return f"已写入 {path}" def run_command(command: str) -> str: result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10) return f"exit_code={result.returncode}\n{result.stdout[-2000:]}{result.stderr[-2000:]}" TOOL_MAP = { "read_file": read_file, "write_file": write_file, "run_command": run_command, } def run_agent(task: str, max_steps: int = 10) -> str: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}, ] for _ in range(max_steps): resp = client.chat.completions.create( model="gpt-4o-mini", # 换成你服务的模型名 messages=messages, tools=TOOLS, tool_choice="auto", ) msg = resp.choices[0].message messages.append({ "role": "assistant", "content": msg.content, "tool_calls": msg.tool_calls, }) if not msg.tool_calls: return msg.content or "任务完成" for tc in msg.tool_calls: func = TOOL_MAP[tc.function.name] args = json.loads(tc.function.arguments) result = func(**args) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": result, }) return "已超过最大步骤数,任务未完成" if __name__ == "__main__": task = sys.argv[1] if len(sys.argv) > 1 else "用Python写一个斐波那契函数,保存到fib.py,然后运行并打印结果" print(run_agent(task))运行方式很简单:
pip install openai python agent.py "用Python写一个斐波那契函数,保存到fib.py,然后运行并打印结果"跑起来之后,你会看到它执行的大致流程:模型先决定调用write_file创建fib.py,然后用run_command执行文件,最后输出结果。整个过程大概在30秒到1分钟之间,具体取决于模型响应速度。如果用的是本地模型,可能还会慢一点,但逻辑是一样的。
2.5 代码里藏着的三个关键细节
第一个细节是系统提示词不是摆设。在这版实现里,SYSTEM_PROMPT承担了“告诉模型你有哪些工具、该怎么用”的职责。我最初调试时吃过亏,发现如果把提示词写成“你是一个AI助手”,模型就真的只答话不调工具——它根本不知道自己有工具可用。后来把规则明确写在提示词里,尤其是“根据工具结果决定下一步”“完成后直接输出最终答案”,整个行为立刻正常了。
第二个细节是工具执行的错误处理。我在handle_tool_call里用了try/except,把异常信息作为结果返回给模型——虽然上面代码示例为了简洁没写出异常捕获,但实战中这一步不能省。这个设计极其重要,因为模型“看到”报错信息后才会自己修正。碰到文件不存在或命令退出码非0,最好不要中断循环,而是当作正常的工具结果喂回给模型,让它自己决定怎么改。只有JSON解析错误或配置错误这种系统级问题,才应该直接终止。
第三个细节是消息历史的结构。每轮循环都要把模型回复追加进messages,然后工具执行结果以role=tool追加一条,并且必须带上对应的tool_call_id。OpenAI的Python SDK对消息顺序和role有严格校验,一旦role顺序不对,请求直接400。这也是我见过最多人卡住的地方——不是模型不行,是消息结构不对。
3. 读懂Pi的生产级骨架:对照最小实现拆解五大模块
3.1 Pi整体架构:模块多,但有一条清晰主线
Pi的代码比我们的小实现复杂得多,但如果你顺着数据流去看,会发现它其实就是把我们那几块积木——工具、模型、循环——放大成了一整套系统。打开Pi的源码,一眼扫过去,目录被拆成好几个package:core、tools、agent、config、session、ui。虽然模块多,但它有一条主线:用户的任务从入口进来,流向config模块完成初始化,进入agent引擎开始循环;循环里通过tools分发器调用具体工具,session管理器在背后记录上下文,ui层把每一步进展实时渲染出来。
Pi在架构上有一个让我印象很深的点:它把“模型调用”和“工具分派”之间做了一层抽象,这层抽象类似消息总线和事件流。简单说,Pi不是直接“调用函数再返回值”,而是把工具调用动作包装成事件,经过统一的分发器去执行。这样做的直接好处是:可以在工具执行前后插入日志、权限检查、重试逻辑,而且不会污染核心循环代码。我们的小实现如果想要升级,第一个值得做的重构就是往这个方向走。
3.2 第一根骨头:配置系统,让Agent换环境不用改代码
咱们的最小实现里,配置就是代码顶部的几个常量:模型名、base_url、max_steps。够用归够用,但生产环境不会有这么简单的事。Pi的配置系统支持命令行参数、环境变量、配置文件三种注入途径,且带有优先级——命令行大于环境变量,环境变量大于配置文件。这种设计对部署非常友好:本地用默认配置跑,到服务器上可以用环境变量覆盖模型地址,在CI/CD里再用参数覆盖具体任务类型,全程不用动一行代码。
配置项本身也做了分层。模型相关一组、工具相关一组、行为相关一组。比如base_url就属于“模型提供者配置”这一层,它允许你在同一个Agent里配置多个模型来源,甚至做模型路由。还有一个安全细节:API Key绝对不应该硬编码在代码里,生产级Agent的配置系统必须支持从环境变量读取密钥,并且确保密钥不出现在日志中。这一点看着不起眼,但它决定了框架能不能多人协作、多环境部署。一个没有配置系统的Agent,基本只能活在作者自己的电脑上。
3.3 第二根骨头:工具注册与管理,从if-else到可插拔
我们的小实现里,工具就是一个字典加一个分支判断,每新增一个工具就要改代码、改TOOLS列表、改TOOL_MAP。Pi把这一层做成了注册中心:每个工具是一个独立的类或函数对象,通过装饰器或者配置文件注册进框架。框架维护一份工具清单,包括工具名称、描述、参数Schema、权限级别、运行环境等信息。
注册中心的第一个好处是可插拔。你想给Pi加一个数据库查询工具,不用碰核心代码,只要新写一个工具类再注册进去。这符合工程上的“开闭原则”——对扩展开放,对修改关闭。第二个好处是统一管控。所有工具都要经过注册中心才能被模型调用,所以框架可以在工具执行前做一系列检查:文件操作是否限定在工作目录内、命令是否命中危险指令黑名单、网络请求是否走代理等。生产环境对这类权限管理的要求很高,因为我们没办法保证模型永远不“调皮”。
Pi还有个小细节:工具描述会占用上下文,所以它要求工具作者必须把description写得精炼。工具一多,这事的收益特别明显——描述太长的工具会挤占上下文窗口,让模型注意力被稀释。如果你以后自己搭Agent,工具描述能短则短。
3.4 第三根骨头:状态管理与会话上下文,断点续传是底线
我们的最小实现里,状态就是messages这个列表,会话结束就丢。Pi不一样,它有完整的会话生命周期管理:会话可以暂停、恢复、存档,还能把历史会话当作新任务的起点。这在长任务场景下价值巨大。
举个例子:本地调试项目时,Agent跑着跑着,模型服务突然挂了。最小实现里上下文全丢,下次只能从头开始;而Pi里会话状态被持久化,重启之后可以从上次进度继续跑。这就是生产级的“断点续传”。
会话管理还会牵扯到上下文压缩。Agent跑久了,历史消息越来越长,模型上下文很快就会顶到天花板。Pi提供了摘要和裁剪机制:历史消息超过阈值时,把前面的对话总结成一小段摘要放到最前面,然后清理原始消息。这个策略在长对话里几乎必不可少——不压缩的话,任何Agent都撑不过30轮工具调用。另外,Pi把session状态抽象成了独立数据结构,而不是裸的messages数组,这也让它能同时跑多个会话而不互相干扰。对生产级服务化部署来说,并发会话隔离是底线。
3.5 第四根骨头:流式输出与实时交互,反馈延迟决定体验
最小实现是憋到循环结束才一次性返回结果,命令行里还能忍,要做成交互式工具就完全不行。Pi默认使用流式输出:模型每生成一个token就推送到终端,工具执行的每一步也实时打印。用户能“看着”Agent工作,而不是干等一把梭。
流式输出的实现比非流式复杂,因为它牵涉到事件实时推送和界面刷新。Pi的做法是把事件流分成两类:一类是模型的token流,给用户“打字机”体验;另一类是Agent自身的事件流,比如“正在读取文件”“正在执行命令”“发现测试失败”。两者合在一起,就是现在主流Coding Agent产品都有的交互效果。
别小看流式输出的意义。对任何工具类产品,反馈延迟直接决定用户的心理感受。一个3秒内就开始输出的工具,和一个15秒空白后一次性吐出一大段结果的工具,用户会坚定地认为前者“快很多”。这不是玄学,是实实在在的体验差距。
3.6 第五根骨头:错误恢复与重试策略,把不确定性变成可控事件
最小实现遇到模型API超时会直接抛异常,整个程序就没了。生产级Agent必须考虑错误恢复,Pi在多个层面做了容错。首先是对临时故障的重试:模型接口偶尔返回限流(429)或5xx错误,这些通常过几秒就恢复,Pi会按指数退避策略自动重试,而不是立刻报错。其次,区分“可恢复错误”和“不可恢复错误”:可恢复的如测试用例失败、文件格式问题,会作为上下文继续循环;不可恢复的如目标文件不存在、模型输出格式非法,会终止或切换策略。
第三个层是模型输出解析容错。模型有时候不回合法的JSON,甚至夹带额外文本。Pi的解析器会尝试提取JSON块、修正缩进错误。我们的最小实现里用的是json.loads一步到位,格式一坏就完蛋,这在生产环境远远不够。写到这里你会发现,生产级Agent的骨架,本质上就是一套把各种不确定性变成“可处理事件”的工程机制:模型行为不确定、工具执行不确定、网络服务不确定,框架的责任就是把这些不确定性一个个装进可控的轨道。
4. 从玩具到生产级,中间还差这四步升级
4.1 并发与会话隔离
生产级的Agent服务,很少只服务一个用户。Pi支持同一时刻多个会话并发执行,每个会话有自己的上下文、状态和工具环境。这对我们小实现的启示是:不要把会话数据挂在全局变量上,否则两个请求一交错,数据就串了。实际开发里,我习惯把会话对象封装成一个类,每个请求对应一个独立实例。
并发还会带来资源控制问题。多个Agent同时执行run_command,会争抢服务器CPU和内存。生产环境一般会在工具执行层加一个信号量限制最大并发指令数,甚至给每个工具单独配置并发上限,比如最多同时跑3个shell命令。
4.2 可观测性与日志
我一直觉得可观测性是Agent框架最容易被忽略却又最重要的模块。最小实现里顶多print几行结果,生产环境里这是灾难。Pi会为每次交互生成结构化日志,记录模型请求参数、token用量、工具调用耗时、错误堆栈,方便事后排查。如果你想让小实现走向生产级,建议至少加一个中间件层,把每轮循环的请求和响应都落盘。之后再跑Agent,一旦发现结果不对,翻日志就能定位到是哪一步决策出了问题。这个习惯能帮你省掉大量“玄学排障”时间。
4.3 安全与权限边界
Coding Agent本质上是一个“能替你在电脑上执行命令的AI”,天然拥有高权限。生产级的Agent,安全边界必须提前设计。Pi的思路是工具级权限控制:给每个工具标注作用域,比如文件工具只能在项目白名单目录内操作、命令工具默认禁止高危指令。从最小实现出发,最简单的安全增强是给run_command加一道命令校验,把rm -rf、sudo这类危险命令挡在外面,或者在执行前打印即将执行的命令并要求人工确认。虽然会牺牲一些自动化程度,但安全永远第一。
4.4 上下文压缩与成本控制
每次工具调用都会撑大上下文,token成本水涨船高。在长任务里,上下文压缩不只是“能不能塞下”的问题,更是“成本能不能接受”的问题。Pi的上下文管理器会根据当前token量自动决定是否压缩历史消息。对我们的小实现来说,一个简单可行的成本控制手段是限制工具返回内容的长度,尤其是run_command的输出。有些命令的stdout动辄几百KB,全部塞回上下文,一次就能烧掉几千token。我习惯做尾部截断:只保留前几百行和最后几十行,因为报错信息通常集中在最后。
5. 实操心得与常见问题排查:踩过的坑和避坑清单
5.1 我踩过的两个大坑
写最小Agent的过程中,我遇到最典型的问题有两个。第一个是工具返回消息的格式不对。OpenAI的Python SDK要求工具结果以role=tool的消息追加,content必须是字符串,并且要带上tool_call_id。我当时把工具结果直接当普通assistant消息塞回去,结果模型完全看不懂这些内容,后续行为整个失控。排查了很久才发现是role和tool_call_id没对齐。
第二个坑是循环终止条件写得太松。最初我只在模型不输出tool_calls时结束循环,结果有一次模型连续输出十几轮工具调用,而且每次去改同一个文件,来回拉扯。后来加了max_steps上限,还发现一个更聪明的辅助判断:如果模型连续三轮都是同一个动作、参数也没变,基本可以判定它卡住了,主动终止并提示用户。这个“卡住检测”在生产级Agent里也是重要机制。
5.2 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 模型不调用工具,直接输出代码 | 工具描述不规范,或系统提示词缺少引导 | 强化工具描述;在提示词中明确“必须调用工具执行任务” |
| 工具调用报400错误 | role=tool消息缺失或tool_call_id不正确 | 检查消息结构,确保工具结果以role=tool返回并携带正确ID |
| Agent陷入死循环 | 工具结果不满足模型预期,或循环终止条件缺失 | 设置max_steps;检测重复工具调用并主动退出 |
| 上下文溢出 | 工具返回内容过长 | 对长输出截断;超过阈值时改用摘要 |
| 模型接口超时 | 模型服务未启动或负载过高 | 先检查服务健康状态;再配置超时重试 |
5.3 给想上手的同学几点建议
第一,别急着上框架。先用200行代码把Agent Loop跑通一遍,你对“工具、模型、循环”三者关系的理解会立刻不一样。第二,把一个工具打磨扎实,好过堆十个半残的工具——工具质量直接决定了Agent能完成的任务上限。第三,上生产之前先写好日志和权限控制,不要裸奔。你永远猜不到模型在下一条消息里会请求执行什么命令。
最后说点我自己的心得。很多人以为Coding Agent的比拼重点是模型智商,工程做久了会发现,真正拉开差距的其实是工程骨架——谁能把不确定性管理好、把工具打磨得顺手、把成本控制在合理范围,谁的Agent才真正能落地。模型是引擎,骨架是底盘,底盘不稳,引擎马力再大也白搭。我自己写完最小实现、再对照Pi过完整个生产级框架之后,最大的感觉是:这个领域真还没到拼模型智商的时候,大家比的是谁先把脚手架搭稳。如果你顺着文章把最小实现跑通了,建议下一步给它加个“会话存档”功能——这个小练习会逼着你把消息结构和状态管理想清楚,做完之后你再回头看Pi的session模块,一定会会心一笑。