单卡3090跑多Agent:跨进程锁让模型独占不再撞车

用跨进程flock按回合粒度给本地模型加闸,真实多进程对决抓出单测无法发现的锁路径bug。
一位本地AI玩家在3090上通过llama-swap挂载Qwen模型,聊天会话、cron任务与子Agent三个进程同时请求同一推理引擎,导致KV缓存互相踩踏、生成质量下降。作者提出以跨进程flock按回合粒度加锁的方案:回合开始时获取、结束时释放,进程死亡时内核自动释放锁,免去过期回收逻辑。闸门在回合构建、后备遍历和释放三个位置挂钩,全程失败开放,Agent可用性不依赖它。单元测试17个全绿,但两个实时CLI加cron回合的真实对决第一次就暴露了profile-home路径解析错误导致的锁路径不一致问题——只有部分进程能看到的闸门等于两个闸门。修复后经过三次对决验证,并实现了跨夜cron自动回程、无需干预的恢复。
一张3090、一个Qwen后端、多个Agent会话同时在线——这是很多本地AI玩家的真实配置。但当聊天会话、cron任务和子Agent同时命中同一个推理引擎时,KV缓存会因并发请求而互相踩踏,生成质量急剧下降。一位开发者在rss分享了他的解决方案:用跨进程文件锁为Agent回合建立"单车道"机制,并用真实对决测试抓出了单元测试无法发现的路径bug。

问题的本质:一个模型,多个进程
llama-swap在:8080端口后面挂着一个Qwen模型,运行在一张3090上。聊天通道、定时任务运行器、子Agent工具各自生成独立进程,同时向这个引擎发起请求。两个回合同时命中,正在使用的KV缓存就会被破坏。
核心矛盾在于:会话不是单一进程。进程内的锁保护不了跨进程的竞争,而问题恰恰来自三个不同进程。开发者因此将锁设计为跨进程的flock,按主机和端口为每个后端加锁——这是整个方案中最关键的第一个设计决策。
KV缓存(Key-Value Cache)是大语言模型推理时的核心内存结构。Transformer在生成每个token时,需要对所有历史token计算注意力,为避免重复计算,推理引擎会把每一层注意力机制中的Key和Value张量缓存下来。对于长上下文对话,这份缓存可以占用数GB显存。当两个请求同时命中同一个推理引擎时,引擎会尝试复用或扩展同一份KV缓存,但两条会话的历史完全不同,缓存的索引和序列位置会发生冲突,导致模型读到错乱的上下文,输出质量断崖式下跌,甚至出现乱码或重复。这也是为什么单GPU后端在同一时刻最好只服务一个正在生成的请求。
flock是Linux提供的文件锁机制(flock(2)系统调用),与传统的POSIX记录锁不同,它把锁绑定在打开的文件描述符上。这意味着进程正常退出或崩溃时,内核会自动释放该描述符持有的所有锁,无需任何清理逻辑。flock的另一个特性是它的锁是「建议性」的——只有同样用flock去竞争同一路径的进程才会互斥,其他进程即便读写该文件也不受阻拦。这解释了后文为何锁路径必须对所有进程一致:只有所有进程竞争同一个真实存在的路径,闸门才真正生效;路径不一致时,各进程会各自加锁,看似互斥实则各锁各的。
按回合持锁,而非按会话
锁的持有范围决定了方案的成败。如果按会话持锁,一个长期运行的聊天会话会永久占用3090,所有cron任务只能挨饿。因此锁的粒度是单个回合:回合开始时获取,结束时释放。安静会话就是安静会话,只有正在生成的回合才持有通道。
第二个设计决策是清理机制。flock存在于文件描述符中,进程死亡时内核自动释放锁——这意味着不需要编写过期锁回收器、心跳文件或判断20分钟前的锁是否已死的逻辑。释放路径可以任意崩溃,闸门依然会重新打开。锁旁边有一个小型sidecar文件,记录pid、会话、模型和时间戳,让闸门在看起来卡住时可被检查。
三个挂钩点,处处失败开放
闸门在Agent循环的三个位置挂钩:
回合构建时——运行时恢复主模型后立即尝试获取锁,闸门繁忙则将回合送入实际的后备链(从云端模型开始)。
后备遍历时——跳过指向繁忙本地后端的链条目,防止云端主配置的会话回退到已饱和的引擎。
回合释放时——释放逻辑位于回合运行器最外层的finally中,无论完成、报错还是取消,都在同一位置释放。
新增的失败原因local_backend_busy渲染为"本地模型正被另一会话使用",让偏转看起来就是偏转,而非提供商故障。
失败开放是底线:这是性能守卫,Agent可用性从不依赖它。闸门出错不阻塞回合,没有配置则行为与之前完全一致,任何非本地请求完全跳过闸门。最坏情况是两个回合共享显卡——也就是没有闸门时的世界。
失败开放(fail-open)是一种系统设计原则:当某个守卫组件自身出错或不可用时,系统选择放行请求而不是拒绝,以优先保证主流程的可用性。与之相对的是fail-closed,出错即阻塞,安全性更高但容易造成级联故障。这里闸门的定位是「性能守卫」而非「安全边界」——它启用时只影响显存争用情况,出错或未配置时整个Agent行为应与之前完全一致。把可用性押在守卫上会让一次锁文件异常、路径解析错误或权限问题直接冻结所有Agent回合,代价远大于短暂共享显卡带来的生成质量损失。fail-open的典型实现是把守卫逻辑包在try/catch里,异常时返回「允许」,并在守卫返回不确定结果时也选择放行。
测试全绿,真实对决却翻车
测试套件首次运行即通过:17个闸门测试覆盖互斥、繁忙跳过、失败开放和真实跨进程flock场景,外加74个后备和回合生命周期回归测试。
但真正的验证来自两个实时CLI会话加一个cron回合的"对决",第一次就抓到了测试套件结构上无法发现的bug。flock只排斥计算相同锁路径的进程,而单元测试在单进程中运行,使用手工选择的home目录——任何内部一致的路径都能通过测试。
实际运行时,profile-home剥离逻辑解析了错误的根目录,锁和sidecar落在了每profile的目录树下,规范共享目录却是空的。只有对决打印出锁文件的真实位置,这个问题才暴露出来。更隐蔽的是:两个CLI会话偏转仍然"正常工作",因为它们属于同一profile,对同一个错误路径达成了一致。但会话来自机器上的每一个profile,共享根目录的存在就是为了让所有会话落到同一把锁上——只有部分进程能看到的闸门等于两个闸门。
修复根目录的提交之后,第二次对决中锁和sidecar位于规范路径,B偏转到云端模型,A干净释放,通过。第三次对决加入了真正想要的探针:A宣告完成后锁文件空闲,新的获取证明本地模型立即可再次获取。
profile-home指的是Agent框架为每个配置档案(profile)分配的独立主目录。这类框架通常支持多个profile,每个profile有自己的配置、会话历史和缓存目录,彼此隔离。但推理后端是共享资源,为此框架会额外维护一个「规范共享目录」,其存在意义正是让所有profile的会话都落到同一把锁上。问题在于解析路径时如果误取了profile专属的home而非规范根目录,不同profile就会算出不同的锁路径。两个属于同一profile的会话因为路径一致仍然互斥(表现为「正常工作」),而跨profile的会话则各自加锁、互不感知,闸门被悄悄复制成多把,实际闸门数量等于参与争用的profile数,守护效果形同虚设。这类bug在单进程测试中天然不可见,只有让真实的多profile进程同时运行才会暴露。
自动回程:不干预也能恢复
永不返回的后备是运行单个模型的昂贵方式,因此回程有独立检查。主运行时在每个回合顶部恢复,闸门只偏转碰撞的回合,下一个计划回合会再次优先尝试本地模型。
实际验证是跨一夜的cron回合:00:05使用本地模型,00:11在对决持有闸门时偏转到云端,00:17回到本地模型,全程无需任何干预。
最终配置为model.single_session_lock: true,网关在修复代码后两次重启,所有通道重新连接正常。闸门按后端而非模型加锁,因为显卡一次只加载一个模型;当第二条GPU通道出现时,键将变为模型作用域。
给同类配置的建议
如果你在一台模型服务器后面运行任何会生成并发会话的东西,这个形态值得参考。把精力花在两个真实进程做真实回合上——单进程测试套件无论锁落在哪里都会通过。务必失败开放,因为一个能让Agent死锁的闸门,比它要阻止的争用更糟糕。
一张3090,一次一个模型。
相关推荐

英伟达3万开发者用Cursor,代码提交量增3倍
Cursor现场工程师拆解企业AI编程从试点到规模化之间的五个缺口:战略清晰度、风险分级、治理审计、业务度量与运营战略。英伟达3万开发者每日使用Cursor,代码提交量增至3倍且缺陷率持平,关键在于重写SDLC而非叠加工具。

Ponytail:给AI编码代理装上资深工程师思维
Ponytail 是一个 GitHub 免费工具,为 AI 编码代理注入资深工程师思维,让 AI 先问代码是否必要。据 TamilDeck 介绍,它可将 AI 生成的 200 行代码压缩到 30 行,降低 token 成本,支持 Cursor rules 与 Claude Code skill 接入,但安全审查仍需人工把关。

Cursor AI 接入 GitHub MCP 服务器完整指南
手把手教你将 Cursor AI 接入 GitHub MCP 服务器:创建个人访问令牌、配置 mcp.json、用 ENV 块安全管理 token,并通过查询 PR 验证连接是否生效。