从零做一个 Coding Agent:我这几天真正学到的东西
这段时间我在做一个很小的 coding agent 实验项目。它不是 Cursor,也不是 Codex,只是一个命令行里的 TypeScript 程序:用户输入一个任务,模型决定要不要读文件、搜索代码、修改文件、跑测试,然后一步步把事情做完。
一开始我以为重点会是模型有多聪明。做下来才发现,真正难的地方不在模型,而在工具设计、权限边界和执行流程。模型只是负责判断下一步,系统要负责保证它只能在正确的边界里行动。
这篇文章记录一下这条学习路线。不是完整教程,更像一次从小 demo 慢慢长成 agent runtime 的工程笔记。
第一步:先把 Agent Loop 跑起来
最小的 agent loop 长这样:
在代码里,对应的是:
这里最重要的不是写了多少工具,而是先看到模型和工具之间的闭环。
比如用户说:
模型可能会先调用 listFiles,再调用 readFile 读取 package.json,然后根据真实文件内容回答。
这一步要先记住一个原则:不要让模型凭空猜项目结构。 它需要信息,就让它调用工具拿真实信息。
第二步:从只读工具开始
一开始不要急着让 agent 改代码。先做只读能力:
这三个工具能覆盖很多基础任务:
为什么不直接让模型用 ls、cat、rg?
因为原生命令太灵活。灵活意味着难控制。比如 cat package.json 看起来没问题,但同一个能力也可以变成:
所以更好的设计是:
专用工具的好处是输入结构清楚,输出结构稳定,也更容易加安全检查。
第三步:项目路径沙箱
只读工具做出来以后,很快会遇到一个问题:如果 readFile 可以读任意路径,那 agent 就可能读到项目外的敏感文件。
所以文件工具必须限制在当前项目内。
核心实现思路是:
代码大概是:
这里用了 realpath,不是只用 path.resolve。原因是符号链接可能绕过普通路径检查。比如项目里有个链接指向项目外文件,字符串上看起来在项目内,真实路径已经跑出去了。
这个细节很重要。coding agent 的安全边界不能只看路径字符串。
第四步:限制 runCommand
有了文件沙箱后,我发现另一个绕过方式:模型可以不走 readFile,直接用命令读文件。
比如:
这说明只限制文件工具不够。只要 runCommand 还能执行 cat、rg、node、pnpm exec,它就可能变成后门。
所以 runCommand 最后被收紧成只跑固定验证命令:
这一步学到的是:不要把 shell 当成万能工具交给模型。
更合理的分层是:
每个工具只负责一类事情。
第五步:加入 editFile
只读能力稳定以后,才开始做写文件能力。
我没有一开始做 writeFile。writeFile 太粗暴,给一个路径和完整内容,直接覆盖整份文件。模型只要漏掉一段,文件就坏了。
先做的是 editFile:
它的逻辑很简单:
为什么要求 oldText 只出现一次?
因为如果一个文件里有很多个:
模型说“把它替换掉”,系统并不知道该改哪一个。要求唯一匹配,可以逼模型提供更精确的上下文。
写操作还要比读操作更严格。项目内文件也不是都能写:
到这一步,agent 已经有了最小修改闭环:
第六步:补测试
做到这里,安全边界已经不少了。如果没有测试,后面每次改工具都很容易把边界弄破。
最先加的测试覆盖这些行为:
一开始用的是 Node 内置 test runner,后来迁移到了 Vitest。这个选择本身不关键,关键是测试要覆盖行为,而不是实现细节。
比如测试 editFile,不需要知道内部怎么 count occurrences,只需要验证:
这些测试就是 agent 的护栏。
第七步:实现 applyPatch
editFile 适合小改动,但复杂改动会很难用。比如同时改多个文件、新增文件、删除文件、移动几段代码,这些都更适合 patch。
所以后面加了 applyPatch。
它支持一个受限格式:
也支持:
以及:
applyPatch 最关键的点不是怎么替换文本,而是执行顺序:
这能避免半成功状态。
比如一个 patch 同时修改:
如果 .env 被拒绝,index.ts 也不能提前被改掉。否则系统会进入一种很难处理的中间状态。
这一步开始,agent 已经能做真正的多文件修改了。
第八步:审批机制
后来我测试了一个任务:
agent 能改 package.json,也能改测试文件,但它不能执行:
因为 runCommand 被限制了。
这暴露了一个真实问题:有些操作不是绝对不能做,但也不能让模型静默执行。比如:
所以加了一个新的工具:
它只允许少数可审批命令:
执行前会问用户:
用户输入 y 才执行。
这样权限模型就变成三层:
这是 coding agent 很重要的一步。没有审批机制,agent 要么太弱,什么都不能做;要么太危险,什么都能做。
第九步:让 Agent 能看自己的 diff
agent 改完代码以后,还需要知道自己到底改了什么。
如果只靠模型记忆,很容易漏掉文件。更可靠的方式是提供一个只读工具:
支持三种模式:
它不会开放通用 git,只允许看 diff。
这样修改后的流程更完整:
getDiff 的意义不是功能多强,而是让 agent 的最终总结有事实依据。
第十步:拆测试
随着工具越来越多,测试文件也开始变大。最初所有测试都堆在:
后来拆成:
这个拆分不改变行为,只是让测试结构跟源码模块对齐。
这也是一个很普通但很重要的工程动作:代码一开始可以简单放一起,等边界长出来,再按职责拆。
这条路线的主线
回头看,这条路线其实很清楚:
每一步都不是为了堆功能,而是为了解决前一步暴露出来的问题。
比如:
这比一开始设计一个庞大的 agent framework 更稳。
我现在对 Coding Agent 的理解
coding agent 不只是模型加几个工具。
更准确地说,它是一个受控执行系统:
模型可以不稳定,但工具边界不能不稳定。
如果 agent 能跑 shell、读文件、改代码,那安全限制就不是附加功能。没有边界的 agent,很容易把一次普通任务变成事故。
这也是这个小项目最有价值的地方。它没有复杂 UI,也没有花哨功能,但把 coding agent 最核心的几件事都摸了一遍:
后面可以继续做的东西还有很多:
但到这里,一个 coding agent 的骨架已经出来了。它还很小,不过方向是对的。