一次要调 3 个工具,结果写坏了文件:工具系统的管线的读写锁
前言
设想这样一个场景。
你让 Agent 干:把 app.js 里的端口从 3000 改成 8080,顺手把超时也调到 8000 ms。
它一口气吐了三个工具调用:edit_file 改端口、edit_file 改超时、read_file 读一眼 package.json。
跑完之后你打开文件------超时倒是改对了,端口还是 3000。
它没报错,也没提示失败,你的层层防御如同虚设、它那么的无辜。它自己都不知道丢了一次修改。
之前我们聊过 Function Calling,Tool System可不止这样,我的agent还需要一条完整的管线。把这套工具系统真正搭起来:这条管线在代码里长什么样,以及它中间为什么必须塞进一把锁。
一轮里的多个工具调用,是并发跑的
模型在一轮回复里可以吐出多个 tool_call。
我还没做权限系统,那么多个tool会被我引入的 AI SDK包全部调用掉。
不过这里有个问题我顺带提一嘴:Node 通常是单线程的,为什么说通常呢?我之前面试的时候,面试官问我难道不能是多线程吗?我一下懵了。后来去了解了一下,特殊手段下确实可以开多个线程,具体我就不赘述了。
Node 的单线程只保证同一时刻只有一段代码在跑,它不保证你这行和下一行之间没有别人插进来。中间只要出现一个 await,控制权就交出去了,别的东西能趁着这段时间跑完。
而一次读-改-写,天然不止一个 await。
两个 edit_file 撞在一起
把 edit_file 拆开看,它其实是四步:
javascript
async function patch(path, key, value) {
const before = disk[path] // ① 读出整个文件
const line = before.split('\n').find(l => l.startsWith(`const ${key} `))
await sleep(20) // ② 干点活(解析、校验、调 API)
const patched = before.replace(line, `const ${key} = ${value}`)
await sleep(20) // ③ 再干点活
disk[path] = patched // ④ 写回:用的是 before 那份快照
}
关键在于第 ④ 步写回去的,是基于第 ① 步那份快照算出来的结果。
如果两个 edit_file 各自读到了同一份 before,那么后写的那个人,就是把整份旧快照又写了回去------先写的那个人改过的地方,全部被抹平了。
跑一遍就知道了。下面这个 demo 让三个工具同时启动,其中一个读 package.json,另外两个改 app.js:
yaml
=== 场景一:没有锁,3 个调用同时跑 ===
PORT 写回: 8080
TIMEOUT 写回: 8000
最终 app.js:
const PORT = 3000
const TIMEOUT = 8000
两个都报告自己"写回成功"了,但文件里 PORT 还是 3000。
这就是丢失更新(lost update)。它的恶劣之处在于没有报错。两个工具都认为自己干完了,模型看到两个成功的结果,接着往下走,没有人知道文件被写坏了。
那就加一把锁
解法是计算机里用了五十年的老东西:读者-写者锁(read-write lock)。
它把工具分成两类:
| 类别 | 例子 | 拿什么锁 | 能不能同时跑 |
|---|---|---|---|
| 只读工具 | read_file、grep、glob、web_search |
共享锁 | 能,多个读可以同时在跑 |
| 会改东西的工具 | write_file、edit_file、bash |
独占锁 | 不能,必须等所有人让开 |
规矩就三条:读和读可以并存,读和写不能并存,写和写不能并存。
所以在工具定义里只需要标一个开关,剩下的事情交给注册表:
dart
export const readFileTool = {
name: 'read_file',
isConcurrencySafe: true, // 只读,拿共享锁
isReadOnly: true,
execute: async ({ path }) => readFileSync(resolve(path), 'utf-8'),
}
export const editFileTool = {
name: 'edit_file',
isConcurrencySafe: false, // 要改东西,拿独占锁
isReadOnly: false,
execute: async (input) => { /* 读-改-写 */ },
}
写工具的人只管标这一行,不用管锁怎么实现。这是分层带来的好处:并发策略集中在注册表里,工具实现保持干净。
三十行,但中间不能有 await
锁本身不复杂。三个状态变量加一个等待队列就够:
javascript
class ReadWriteLock {
exclusive = false // 有没有独占锁的持有者
readers = 0 // 当前共享锁的持有数
queue = [] // 等待队列:存被阻塞住的 resolve
async acquireShared() {
while (this.exclusive) await new Promise(r => this.queue.push(r))
this.readers++
}
releaseShared() {
this.readers--
if (this.readers === 0) this.wakeAll() // 最后一个读者走了,叫醒等待者
}
async acquireExclusive() {
while (this.exclusive || this.readers > 0) await new Promise(r => this.queue.push(r))
this.exclusive = true
}
releaseExclusive() {
this.exclusive = false
this.wakeAll()
}
// 唤醒全部等待者,让他们各自重新抢一次;抢不到的会再排回队里
wakeAll() {
this.queue.splice(0).forEach(r => r())
}
}
代码短,但里面有两处不能一眼带过。
先是 acquireExclusive 的最后两行:while 条件判断通过了,紧接着就是 this.exclusive = true,中间没有任何 await。
这是整个实现能成立的前提。因为中间没有让出时机,这两个动作在别人看来就是一瞬间完成的------不可能出现两个人同时判断"里面没人",然后同时走进去。
换句话说,这里没有原子指令、没有 CAS、没有自旋锁,吃的是"单线程 + 不 await"的红利。反过来说,要是哪天有人在这两行中间插一个 await(比如顺手去查一下别的锁的状态),这把锁当场就废了,废得还很隐蔽。
另一处是 wakeAll:把队列里的 resolve 全部唤醒,每个人重新走一遍 while 判断,抢不到就再排回队里。
这里可以问一句:为什么不只叫醒第一个?
因为醒来的那个人未必能拿到锁。一个读者被叫醒,结果发现独占锁还在,它只能回去接着等。
要是只叫醒一个,这一轮的唤醒机会就白白浪费了,剩下的等待者得等下一次释放才有机会。全叫醒再各自重抢,逻辑最简单,代价是有一批"白醒"的开销。
加上锁之后,同一个场景跑出来是这样的:
ini
=== 场景二:加一把读写锁 ===
read_file(package.json) 请求共享锁
[独占] PORT 拿到锁
PORT 写回: 8080
[独占] TIMEOUT 拿到锁
TIMEOUT 写回: 8000
[共享] read_file(package.json) 拿到锁,当前读者有 1 个
最终 app.js:
const PORT = 8080
const TIMEOUT = 8000
两个改动都保住了。
注意日志的顺序:读 package.json 的那个请求排在了最前面,却是最后一个拿到的。因为两个写者占着独占锁,它只能等。这就是并发的代价------为了不写坏文件,一个本来跟写操作毫无关系的读,也得跟着排队。
(demo 里只放了一个读,所以你看到的读者数一直是 1。如果一轮里有四个读,它们会一起进 readers,那才是"共享"两个字的含义。)
锁只是管线中间的一环
到这儿锁是能用了,但还有个问题没回答:这把锁该放在哪儿?
答案是放在工具注册表里,包在真正执行的外面。整个注册表从注册到执行分这么几层:
| 层 | 干什么 | 什么时候跑 |
|---|---|---|
| 注册层 | register / registerMCPServer,MCP 工具统一命名 mcp__服务器名__工具名 |
启动时一次 |
| 过滤层 | getActiveTools,延迟加载没被搜到的丢掉,角色权限不给的丢掉 |
每次拼请求 |
| 格式层 | toAISDKFormat,参数包成 JSON Schema,外面挂一层执行包装 |
每次拼请求 |
| 执行层 | 风险检查 → 前置 Hook → 拿锁 → 执行 → 截断 → 后置 Hook → 释放锁 | 每次调用 |
执行层展开就是这张图:
对应的代码就是 toAISDKFormat 里的那个 execute:
javascript
execute: async (input) => {
// 1. bash 风险分类:危险的直接拒
if (toolName === 'bash') { /* classifyBashCommand */ }
// 2. 前置 Hook:可以拦截,也可以改输入
const preResult = await hookPipeline.runPre(toolName, input)
if (preResult.action === 'block') return `[Hook 拦截] ${preResult.reason}`
if (preResult.action === 'modify') input = preResult.modifiedInput
// 3. 拿锁
if (isSafe) await registry.acquireConcurrent()
else await registry.acquireExclusive()
try {
const raw = await executeFn(input)
let output = truncateResult(raw, maxChars) // 4. 截断
output = await hookPipeline.runPost(...) // 5. 后置 Hook
return output
} finally {
isSafe ? registry.releaseConcurrent() : registry.releaseExclusive() // 6. 一定释放
}
}
锁的位置是这里唯一需要动脑的地方,因为它决定了"被保护的是哪一段"。
风险检查和前置 Hook 放在锁外面。理由是它们不碰任何共享状态,没必要串行;而且前置 Hook 有可能修改输入,让它改完再拿锁,锁保护的才是"最终要执行的那个形态"。
截断和后置 Hook 放在锁里面。截断是纯 CPU 的活,其实不需要保护;但后置 Hook 不一定------它可能去写日志、写审计文件。把它们一起圈进来,代价是持有独占锁的时间被拉长了一点,换来的是结果处理过程的一致性。
最后那句 finally 不能省。工具抛异常、Hook 抛异常、截断抛异常,锁都得还回去。一把没释放的独占锁会让后面所有工具调用永久挂住------这种 bug 在现场看就是"Agent 突然不动了",非常难查。
锁到哪里为止
一把锁能保证什么是有限的。边界在哪儿得说清楚,因为误以为它保护了你,比没有锁更危险。
先说粒度。这把锁是工具级 的,不是文件级------它只看工具定义上那个 isConcurrencySafe 布尔值,不认识"资源"这个概念。
所以两个 edit_file 改同一个文件时它管用,改 a.js 和 b.js 时照样把两个人排成队。
想做到文件级,得把布尔值换成一张锁表(Map<文件路径, ReadWriteLock>),代价是锁的数量、生命周期和清理时机都得自己管。这笔账只在"同时有大量写操作落在不同文件上"时才划算,批次小的 Agent 用不上。
然后是判断标准,这一条最容易被搞混:一把工具要不要独占锁,不看它跑在几个线程上,看它中间有没有 await。
前面那段工具定义里,read_file 用的是 readFileSync------从头到尾没有 await,整段代码一口气跑完,中间不可能被别的工具插进来。它和另一个同步工具之间天然就是互斥的,标不标 isConcurrencySafe 都不会出事。
会出事的是跨 await 的工具。bash 要 spawn 一个子进程、等它的输出;MCP 工具要等一次网络往返;一次构建、一次 lint、一次 git 操作,中间让出的时间足够别的工具跑完好几轮。
模型一轮里同时要"跑一下 lint"和"改这个文件",是很常见的组合。
所以标 isConcurrencySafe 的依据应该是这句话:这个工具会不会跨越 await 去碰共享状态。 按"看起来像不像写操作"去标,就会漏掉那些看着无害、实际会改东西的工具。
(demo 里我把文件工具写成了 async,就是为了把这个形状放大到肉眼可见。)
子 Agent 那一侧拿到的是无锁版(toAISDKFormatUnlocked),不带锁也不带 Hook。因为子 Agent 生来就是要并行跑的------一次最多三个------共用一把锁会把并行直接压成串行,隔离就没意义了。代价是子 Agent 的写操作和主 Agent 的写操作之间没有互斥。所以派子 Agent 出去之前,值得先分清它是去读、去试,还是去改写真的东西。
MCP 那边还有一个默认值要知道。MCP 工具注册进来时统一标成只读并发(isConcurrencySafe: true, isReadOnly: true),因为协议里没有任何字段能告诉客户端"这个工具会不会写"。接一个会写文件的 MCP Server 时,这个标记得自己改------注册表替你做不了这个判断。
再往外,是这把锁够不到的地方。它是进程内的:同时开两个 Agent 进程,或者 MCP Server 那边也在动同一个文件,它一点办法都没有。这已经不是粒度问题,得靠操作系统的文件锁,或者干脆约定一个文件只归一个进程管。
最后是公平性和超时。当前是"醒了重新抢",谁抢到算谁的,读者持续不断地来的时候写者会饿着;等待也没有超时。这两条在单进程、持有时间以毫秒计的前提下都不会造成实际后果------一旦把它换成跨进程的锁,两样都得补上。
完整代码
写个demo,node tool-lock.js 跑跑看:
javascript
// 一次回复里并发跑多个工具:没有读写锁,两个"读-改-写"会互相覆盖
// 运行:node tool-lock.js 零依赖,不需要 API key
//
// 说明:为了看清竞争,这里的工具都写成 async 的(真实的 bash 工具要等子进程、
// MCP 工具要等网络,就是这个形状)。我的 read_file / write_file 是同步的,
// 反而不会撞车,原因见文章。
const sleep = ms => new Promise(r => setTimeout(r, ms))
const log = (...a) => console.log(...a)
// ── 一把读写锁(核心就这 30 行)──────────────────────────
class ReadWriteLock {
constructor() {
this.exclusive = false // 有没有独占锁的持有者
this.readers = 0 // 当前共享锁的持有数
this.queue = [] // 等待队列:存被阻塞住的 resolve
}
async acquireShared() {
while (this.exclusive) await new Promise(r => this.queue.push(r))
this.readers++
}
releaseShared() {
this.readers--
if (this.readers === 0) this.wakeAll() // 最后一个读者走了,叫醒等待者
}
async acquireExclusive() {
while (this.exclusive || this.readers > 0) await new Promise(r => this.queue.push(r))
this.exclusive = true
}
releaseExclusive() {
this.exclusive = false
this.wakeAll()
}
// 唤醒全部等待者,让他们各自重新抢一次;抢不到的会再排回队里
wakeAll() { this.queue.splice(0).forEach(r => r()) }
}
// ── 一个假的文件系统 ──────────────────────────────────
const ORIGIN = 'const PORT = 3000\nconst TIMEOUT = 5000\n'
let disk = {}
// 读-改-写。中间三个 await,都是 Node 会让出去干别的时机
async function patch(path, key, value, lock) {
if (lock) { await lock.acquireExclusive(); log(` [独占] ${key} 拿到锁`) }
try {
const before = disk[path] // ① 读
const line = before.split('\n').find(l => l.startsWith(`const ${key} `))
await sleep(20) // ② 干点活(解析、校验、调 API)
const patched = before.replace(line, `const ${key} = ${value}`)
await sleep(20) // ③ 再干点活
disk[path] = patched // ④ 写回:用的是 before 那份快照
log(` ${key} 写回: ${value}`)
} finally {
if (lock) lock.releaseExclusive()
}
}
async function read(path, lock) {
if (lock) {
log(` read_file(${path}) 请求共享锁`)
await lock.acquireShared()
log(` [共享] read_file(${path}) 拿到锁,当前读者有 ${lock.readers} 个`)
}
try {
await sleep(20)
return disk[path]
} finally {
if (lock) lock.releaseShared()
}
}
// ── 一轮里模型同时要了 3 个工具调用 ────────────────────
async function round(lock, title) {
disk = { 'app.js': ORIGIN, 'package.json': '{ "name": "demo" }\n' }
log(`\n=== ${title} ===`)
await Promise.all([
patch('app.js', 'PORT', 8080, lock),
patch('app.js', 'TIMEOUT', 8000, lock),
read('package.json', lock),
])
log(' 最终 app.js:')
log(disk['app.js'].split('\n').filter(Boolean).map(l => ' ' + l).join('\n'))
}
async function main() {
await round(null, '场景一:没有锁,3 个调用同时跑')
await round(new ReadWriteLock(), '场景二:加一把读写锁')
}
main()
两段输出上面都贴过了,就不再重复。建议改一改 sleep 的数字再跑几次:不管怎么改,场景一的 PORT 都会丢,场景二的两个改动都在。
总结
核心价值
- "单线程所以没有竞争"是个陷阱。 真正决定一段代码安不安全的是它中间有没有
await,不是它跑在几个线程上。同步函数的读-改-写,可能会交叉执行,这显然很不安全,还会有冲突的情况。 - 锁的位置比锁的实现更值得想。 同样一把锁放在管线里不同的位置,保护的范围就不一样。放在执行包装里,工具作者只需要标一个布尔值,并发策略集中在一处,这才是分层该有的样子。
- 丢失更新最可怕的地方是没有报错。 两个工具都返回成功,模型拿着两份"成功"继续往下走。这类问题不会让你在日志里看见异常,只会让你在几天后发现文件内容不对。
一句话收尾:并发问题不会给你报错,它只会安安静静地把你刚写好的那段代码覆盖掉------所以在模型能一口气派出多个工具之后,锁不是优化,是底线。