Yankewei
2026

从零做一个 Coding Agent:我这几天真正学到的东西

这段时间我在做一个很小的 coding agent 实验项目。它不是 Cursor,也不是 Codex,只是一个命令行里的 TypeScript 程序:用户输入一个任务,模型决定要不要读文件、搜索代码、修改文件、跑测试,然后一步步把事情做完。

一开始我以为重点会是模型有多聪明。做下来才发现,真正难的地方不在模型,而在工具设计、权限边界和执行流程。模型只是负责判断下一步,系统要负责保证它只能在正确的边界里行动。

这篇文章记录一下这条学习路线。不是完整教程,更像一次从小 demo 慢慢长成 agent runtime 的工程笔记。

第一步:先把 Agent Loop 跑起来

最小的 agent loop 长这样:

用户输入任务
-> 模型生成下一步
-> 如果需要工具,就调用工具
-> 工具返回结果
-> 模型继续判断
-> 直到输出最终结果

在代码里,对应的是:

streamText({
  model,
  system,
  prompt,
  tools,
  stopWhen,
});

这里最重要的不是写了多少工具,而是先看到模型和工具之间的闭环。

比如用户说:

请分析这个项目

模型可能会先调用 listFiles,再调用 readFile 读取 package.json,然后根据真实文件内容回答。

这一步要先记住一个原则:不要让模型凭空猜项目结构。 它需要信息,就让它调用工具拿真实信息。

第二步:从只读工具开始

一开始不要急着让 agent 改代码。先做只读能力:

listFiles
readFile
searchFiles

这三个工具能覆盖很多基础任务:

看项目结构
读配置文件
找某个函数在哪里用到
理解入口文件
解释项目怎么运行

为什么不直接让模型用 lscatrg

因为原生命令太灵活。灵活意味着难控制。比如 cat package.json 看起来没问题,但同一个能力也可以变成:

cat ~/.ssh/id_rsa

所以更好的设计是:

读文件 -> readFile
列文件 -> listFiles
搜索代码 -> searchFiles

专用工具的好处是输入结构清楚,输出结构稳定,也更容易加安全检查。

第三步:项目路径沙箱

只读工具做出来以后,很快会遇到一个问题:如果 readFile 可以读任意路径,那 agent 就可能读到项目外的敏感文件。

所以文件工具必须限制在当前项目内。

核心实现思路是:

1. 获取项目根目录真实路径
2. 把用户传入的路径转成真实绝对路径
3. 计算它相对于项目根目录的位置
4. 如果结果以 .. 开头,说明跑出项目了,拒绝

代码大概是:

const root = await realpath(process.cwd());
const absolutePath = await realpath(path.resolve(root, inputPath));
const relativePath = path.relative(root, absolutePath);

if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
  throw new Error("Path must stay inside the current project.");
}

这里用了 realpath,不是只用 path.resolve。原因是符号链接可能绕过普通路径检查。比如项目里有个链接指向项目外文件,字符串上看起来在项目内,真实路径已经跑出去了。

这个细节很重要。coding agent 的安全边界不能只看路径字符串。

第四步:限制 runCommand

有了文件沙箱后,我发现另一个绕过方式:模型可以不走 readFile,直接用命令读文件。

比如:

runCommand({ command: "cat /Users/xxx/.ssh/id_rsa" })

这说明只限制文件工具不够。只要 runCommand 还能执行 catrgnodepnpm exec,它就可能变成后门。

所以 runCommand 最后被收紧成只跑固定验证命令:

pwd
pnpm test
pnpm typecheck
pnpm --version

这一步学到的是:不要把 shell 当成万能工具交给模型。

更合理的分层是:

文件读取 -> readFile
代码搜索 -> searchFiles
目录查看 -> listFiles
项目验证 -> runCommand

每个工具只负责一类事情。

第五步:加入 editFile

只读能力稳定以后,才开始做写文件能力。

我没有一开始做 writeFilewriteFile 太粗暴,给一个路径和完整内容,直接覆盖整份文件。模型只要漏掉一段,文件就坏了。

先做的是 editFile

{
  path: string;
  oldText: string;
  newText: string;
}

它的逻辑很简单:

1. 检查 path 是否可写
2. 读取文件
3. 确认 oldText 出现且只出现一次
4. 替换成 newText
5. 写回文件

为什么要求 oldText 只出现一次?

因为如果一个文件里有很多个:

console.log("done");

模型说“把它替换掉”,系统并不知道该改哪一个。要求唯一匹配,可以逼模型提供更精确的上下文。

写操作还要比读操作更严格。项目内文件也不是都能写:

不能写 .env
不能写 .git/
不能写 node_modules/
不能写 dist/
不能写 build/
不能写 .next/
默认不写 pnpm-lock.yaml

到这一步,agent 已经有了最小修改闭环:

readFile
-> editFile
-> runCommand("pnpm typecheck")

第六步:补测试

做到这里,安全边界已经不少了。如果没有测试,后面每次改工具都很容易把边界弄破。

最先加的测试覆盖这些行为:

runCommand 只能跑固定命令
项目路径不能逃逸
符号链接不能逃逸
敏感文件不能写
editFile 只能唯一替换

一开始用的是 Node 内置 test runner,后来迁移到了 Vitest。这个选择本身不关键,关键是测试要覆盖行为,而不是实现细节。

比如测试 editFile,不需要知道内部怎么 count occurrences,只需要验证:

oldText 不存在会失败
oldText 出现多次会失败
.env 不能写
正常唯一匹配能改成功

这些测试就是 agent 的护栏。

第七步:实现 applyPatch

editFile 适合小改动,但复杂改动会很难用。比如同时改多个文件、新增文件、删除文件、移动几段代码,这些都更适合 patch。

所以后面加了 applyPatch

它支持一个受限格式:

*** Begin Patch
*** Update File: src/example.ts
@@
-old line
+new line
*** End Patch

也支持:

*** Add File: new.txt
+hello
+world

以及:

*** Delete File: old.txt

applyPatch 最关键的点不是怎么替换文本,而是执行顺序:

1. 先解析 patch
2. 找出所有会被改的文件
3. 先验证所有文件是否可写
4. 全部验证通过后,再开始写入

这能避免半成功状态。

比如一个 patch 同时修改:

index.ts
.env

如果 .env 被拒绝,index.ts 也不能提前被改掉。否则系统会进入一种很难处理的中间状态。

这一步开始,agent 已经能做真正的多文件修改了。

第八步:审批机制

后来我测试了一个任务:

使用 Vitest 替换现在的测试方案

agent 能改 package.json,也能改测试文件,但它不能执行:

pnpm install

因为 runCommand 被限制了。

这暴露了一个真实问题:有些操作不是绝对不能做,但也不能让模型静默执行。比如:

安装依赖
删除文件
改 lockfile
执行 git 命令
访问网络

所以加了一个新的工具:

runApprovedCommand

它只允许少数可审批命令:

pnpm install
pnpm add ...
pnpm remove ...

执行前会问用户:

Approval required
Command: pnpm add -D vitest
Reason: install test framework
Allow this command? [y/N]

用户输入 y 才执行。

这样权限模型就变成三层:

低风险:自动执行
中风险:用户批准
高风险:直接拒绝

这是 coding agent 很重要的一步。没有审批机制,agent 要么太弱,什么都不能做;要么太危险,什么都能做。

第九步:让 Agent 能看自己的 diff

agent 改完代码以后,还需要知道自己到底改了什么。

如果只靠模型记忆,很容易漏掉文件。更可靠的方式是提供一个只读工具:

getDiff

支持三种模式:

stat      -> git diff --stat
name-only -> git diff --name-only
full      -> git diff

它不会开放通用 git,只允许看 diff。

这样修改后的流程更完整:

读代码
-> 改代码
-> 跑测试
-> getDiff
-> 总结改动

getDiff 的意义不是功能多强,而是让 agent 的最终总结有事实依据。

第十步:拆测试

随着工具越来越多,测试文件也开始变大。最初所有测试都堆在:

tests/tools.test.ts

后来拆成:

tests/
  helpers/
    temp-project.ts
  apply-patch.test.ts
  edit-file.test.ts
  get-diff.test.ts
  project-path.test.ts
  run-approved-command.test.ts
  safety.test.ts

这个拆分不改变行为,只是让测试结构跟源码模块对齐。

这也是一个很普通但很重要的工程动作:代码一开始可以简单放一起,等边界长出来,再按职责拆。

这条路线的主线

回头看,这条路线其实很清楚:

agent loop
-> read tools
-> path sandbox
-> restricted command
-> editFile
-> tests
-> applyPatch
-> approval workflow
-> getDiff
-> modular tests

每一步都不是为了堆功能,而是为了解决前一步暴露出来的问题。

比如:

有 readFile,就要有路径沙箱
有 runCommand,就要防 shell 绕过
有 editFile,就要有写权限策略
有 applyPatch,就要先验证所有 touched files
有依赖安装需求,就要审批机制
有代码修改,就要 getDiff
测试变大了,就要拆文件

这比一开始设计一个庞大的 agent framework 更稳。

我现在对 Coding Agent 的理解

coding agent 不只是模型加几个工具。

更准确地说,它是一个受控执行系统:

模型负责判断下一步
工具负责执行具体动作
runtime 负责权限、日志、审批和停止条件
测试负责保证边界不会退化

模型可以不稳定,但工具边界不能不稳定。

如果 agent 能跑 shell、读文件、改代码,那安全限制就不是附加功能。没有边界的 agent,很容易把一次普通任务变成事故。

这也是这个小项目最有价值的地方。它没有复杂 UI,也没有花哨功能,但把 coding agent 最核心的几件事都摸了一遍:

怎么让模型调用工具
怎么限制工具
怎么让工具组合成工作流
怎么让修改可验证
怎么让高风险操作经过人

后面可以继续做的东西还有很多:

更好的审批 UI
自动 diff summary
git commit workflow
PR workflow
任务日志
更完整的 patch parser
工具调用状态跟踪

但到这里,一个 coding agent 的骨架已经出来了。它还很小,不过方向是对的。