Agent 干活的底层地基:path 路径 + fs 文件系统,一次讲透

51 阅读5分钟

写在前面

上次手写 mini-Cursor,write_file 工具里有一行 path.dirname(filePath) 配 fs.mkdir——当时一笔带过。

但 Agent 读写文件的每一步,底下踩的全是这两个模块:path 管路径,fs 管文件。顺手还牵出了 JS 异步的整部进化史。

今天把这些地基挖开看看。

path.join vs path.resolve:都能拼,性格迥异

俩函数都能把几段路径拼起来,但脾气完全不同:

js

path.join('a', 'b', 'c');          // a/b/c —— 老实拼接
path.resolve('a', 'b', 'c');       // /Users/xxx/a/b/c —— 拼完还给你算成绝对路径

区别的核心:resolve 会做“解析” 。遇到相对路径,它以当前工作目录 process.cwd() 为基准,换算成绝对路径;join 则是无脑字符串拼接。

更狠的一条——resolve 碰到绝对路径参数会直接重置:

调用结果发生了什么
resolve('/hello', 'world', './a', 'b')/hello/world/a/b正常累积
resolve('/hello', 'world', '../a', 'b')/hello/a/b.. 回退一级
join('/hello', 'world', '../a', 'b')/hello/a/bjoin 也认 ..,但不管 cwd
join('a', 'b')a/b相对就相对,不管你
resolve('a', 'b')/当前目录/a/b硬给你算成绝对

当第一个参数就是绝对路径时,俩货返回结果才长得一样。

什么时候用谁:拼 URL 子路径、拼资源目录用 join 足够;要拿到文件在磁盘上的真实位置(比如 Agent 的 write_file 要确认落盘位置),上 resolve。

路径手术三件套:dirname / basename / extname

把一条路径放到手术台上,三个函数各切一刀:

js

const p = '/home/user/dir/file.txt';

path.dirname(p);    // /home/user/dir   → 目录部分
path.basename(p);   // file.txt         → 文件名
path.extname(p);    // .txt             → 扩展名

一个真实场景你就懂了——mini-Cursor 写文件前要先建目录:

js

const dir = path.dirname('/a/b/c/App.tsx');  // 切出 /a/b/c
await fs.mkdir(dir, { recursive: true });     // 先把目录建好
await fs.writeFile('/a/b/c/App.tsx', content); // 再落文件

basename 还有个隐藏技能——去扩展名:

js

path.basename('a/b/c.js', '.js');  // c

配合 path.parse 更是直接拆成对象:

js

path.parse('/home/user/dir/file.txt');
// { dir: '/home/user/dir', base: 'file.txt', ext: '.txt', name: 'file' }
函数切出来典型用途
dirname目录部分写文件前先 mkdir
basename文件名(可去后缀)改名、去重
extname扩展名按类型分发处理
normalize规范化路径清理 //、.. 之类的脏东西
parse整个拆成对象一次性全要

normalize 值得一提:a/b//c/d/e/.. 进去,a/b/c/d 出来——LLM 生成的路径经常脏得没法看,过一遍 normalize 是好习惯。

fs:同步爽一时,异步保平安

fs 是文件系统模块,读写全靠它。它有两副面孔:

js

const syncData = fs.readFileSync('test.txt', 'utf-8');  // 同步:卡住线程,读完才走
fs.readFile('./test.txt', 'utf-8', (err, data) => {});  // 异步:先走别的,读完回调
维度readFileSyncreadFile
执行方式阻塞线程非阻塞,进事件循环
写法直白爽快要回调 / Promise
性能一个文件卡全线程并发处理,互不等待
适用启动时读一次配置高并发场景

为什么 Node 死磕异步?因为 JS 单线程。同步读一个大文件,整个进程原地罚站——服务器上几百个请求全在排队。异步把耗时任务丢给底层(Node 用 C++ 实现 fs),线程继续跑别的,事件循环(event loop)回头再收结果。

这就是 Node 用区区单线程撑起高并发的底气——同样的流量,异步架构能省一大把服务器。

异步进化四部曲:从回调地狱到 async/await

异步是好,但“先读 file1、再读 file2、再读 file3”这种有顺序的活,早期的写法长这样:

第一代:回调地狱

js

fs.readFile('./file1.txt', 'utf-8', (err, data) => {
    fs.readFile('./file2.txt', 'utf-8', (err, data) => {
        fs.readFile('./file3.txt', 'utf-8', (err, data) => {
            // 三层嵌套只是开始,五个文件就是一座金字塔
        })
    })
})

每读一个文件嵌一层,缩进右移一次。文件多了之后,这段代码 sideways-scroll 都拉不完——史称回调地狱。注意回调的第一个参数永远是 err,这是 Node 的老约定:错误优先。

第二代:Promise then 链

js

fs.readFile('./file1.txt', 'utf-8')
  .then(data => {
    console.log('file1', data);
    return fs.readFile('./file2.txt', 'utf-8');  // then 返回新 Promise,链下去
  })
  .then(data => {
    return fs.readFile('./file3.txt', 'utf-8');
  })

地狱拆成了平面链,但 then 一长,跟爬楼梯似的,还是烦。

第三代:async/await(现状最优解)

js

import fs from 'fs/promises';  // 注意:Promise 版要 import 'fs/promises'

(async () => {
  const file1Data = await fs.readFile('./file1.txt', 'utf-8');
  const file2Data = await fs.readFile('./file2.txt', 'utf-8');
  const file3Data = await fs.readFile('./file3.txt', 'utf-8');
})();

长得跟同步代码一模一样,可读性直接拉满。

阶段写法痛点
回调函数嵌套金字塔回调地狱,错误处理稀碎
Promise + then平面链式比 callback 优雅,但链条一长还是爬楼
async/await同步写法异步本质基本无痛

划重点:async/await 只是语法糖,本质还是 Promise、还是异步——它解决的是可读性,不是把异步变同步。该走 event loop 的照样走,一行没少。

5 个踩坑提醒

1. import fs from 'fs' 没有顶层 await 版的 readFile。  想用 await fs.readFile(...),必须 import fs from 'fs/promises'。两个模块同名不同命,写错直接 TypeError。

2. 回调里忘了判 err。  Node 回调第一个参数永远是错误对象,不判直接用 data,报错时 data 是 undefined,崩得莫名其妙。

3. path.join 拼出绝对路径的幻觉。  join('a', 'b') 给你 a/b——它就是个相对路径字符串,没有基于 cwd 计算。要绝对路径用 resolve。

4. resolve 的“重置”特性暗算你。  resolve('/hello', '/world') 结果是 /world——第二个参数是绝对路径,前面全作废。join 不会,join('/hello', '/world') 给你 /hello/world。

5. 顶层 await 不用再写 IIFE。  .mjs 文件里 await fs.readFile(...) 可以直接裸写在顶层,不用 (async () => {})() 包一层。但普通 .js(CJS)里顶层 await 会直接语法报错。

写在最后

Agent 看着高大上,拆到底全是这些老手艺:path 负责算清楚文件在哪,fs 负责把文件真的读写掉,异步模型保证干活时线程不傻等。

mini-Cursor 的每一个工具调用、LangChain 的每一次 invoke,底下都是这套东西在转。上层再花哨,地基打不牢,Agent 写文件照样翻车。