Lazy loaded image
看 Cursor 如何优雅的解决编辑难题
字数 6852阅读时长 18 分钟
2025-6-28
2025-6-28
type
tags
category
icon
password
Multi-select
优先级
重要度
状态 2
预计结束时间
添加日期
URL
状态
分类(人工)
总结(AI 摘要)
status
notion image
这里有一个失败的秘诀:将一些相关文件粘贴到 Google 文档中,然后将链接发送给您最喜欢的对您的代码库一无所知的 p60 软件工程师,并要求他们在文档中完全正确地实现您的下一个 PR。
让人工智能来做同样的事情,也会失败,这是可以预见的。1
现在,如果让人工智能远程访问你的开发环境,让他们能够查看文件、进入定义和运行代码,你或许还真能指望他们帮上点忙。
notion image
图 1:你是愿意在代码编辑器中还是在 Google 文档中调试你的引脚框未来生命周期?人工智能也是如此。
我们认为,让人工智能编写更多代码的原因之一是,它们能够在开发环境中进行迭代。2 但是,天真地让人工智能在你的文件夹中自由运行会导致混乱:想象一下,你写出了一个推理密集型函数,却被人工智能覆盖了,或者你尝试运行程序,却被人工智能插入了无法编译的代码。人工智能的迭代需要在后台进行,而不影响你的编码体验,这样才能真正起到帮助作用。
为此,我们 在 Cursor 中实现了所谓的 影子工作区(shadow workspace )。3 在这篇博文中,我将首先概述我们的设计标准,然后介绍在撰写本文时 Cursor 中的实现方式(一个隐藏的 Electron 窗口)以及我们未来的打算(内核级文件夹代理)。
notion image
图 2:Cursor 内部阴影工作区的隐藏设置。目前选择加入。

设计标准

我们希望影子工作区能实现以下目标:
  1. LSP 可用性:人工智能应能从其更改中看到林特,能够转到定义,并能与 语言服务器协议 (LSP)的所有部分进行更广泛的交互 。
  1. 可运行性:人工智能应能运行自己的代码并查看输出结果。
我们首先关注的是 LSP 的可用性。
这些目标的实现应符合以下要求:
  1. 独立性:用户的编码体验必须不受影响。
  1. 私密性:用户的代码应该是安全的(例如,所有代码都是本地代码)。
  1. 并发性:多个人工智能应能同时工作。
  1. 通用性:应适用于所有语言和所有工作区设置。
  1. 可维护性:应尽可能少地编写可隔离的代码。
  1. 速度:任何地方都不应出现长达数分钟的延迟,吞吐量应足以容纳数百个人工智能分支。
其中很多都反映了为十几万用户构建代码编辑器的现实情况。我们真的不想对任何人的编码体验造成负面影响。

实现 LSP 可用性

在底层语言模型固定不变的情况下,让人工智能为其编辑获取衬垫是提高代码生成性能的最有效方法之一。在人工智能可能需要对首次尝试调用的方法或服务进行有根据的猜测时,衬垫不仅能让代码从 90% 的有效代码变为 100% 的有效代码,而且在上下文受限的情况下也非常有用。衬垫可以帮助识别人工智能需要询问更多信息的地方。
notion image
图 3:人工智能通过迭代衬垫来实现一个功能。
LSP 可用性也比可运行性更简单,因为几乎所有的语言服务器都能对未写入文件系统的文件进行操作(正如我们稍后将看到的,涉及文件系统会让事情变得更加困难)。因此,让我们从这里开始!根据我们的第五个要求,即可维护性,我们首先尝试了最简单的解决方案。

行不通的简单解决方案

Cursor 是 VS Code 的一个分叉,这意味着我们已经可以非常容易地访问语言服务器。在 VS 代码中,每个打开的文件都由一个 TextModel 对象表示 ,该对象在内存中存储了文件的当前状态。语言服务器从这些文本模型对象中读取,而不是从磁盘中读取,因此它们可以在你输入时(而不是保存时)为你提供补全和衬垫。
假设人工智能对 lib.ts文件进行了编辑 。我们显然不能修改 lib.ts 对应的 现有 TextModel 对象 ,因为用户可能同时在编辑它。不过,一个听起来似乎不错的想法是创建一个 TextModel 对象的副本 ,将该副本与磁盘上的任何真实文件分离,然后让人工智能对该对象进行编辑并从中获取衬垫。这可以通过以下 6 行代码来实现。
这种解决方案在可维护性方面显然是一流的。它在通用性方面也很出色,因为大多数人已经为自己的项目安装并配置了合适的特定语言扩展。并发性和私密性完全可以满足。
问题在于独立性。虽然复制 TextModel 意味着我们不会直接修改用户正在编辑的文件,但我们仍会 将复制文件的存在 告知语言服务器,也就是 用户正在使用的语言服务器。这就会导致一些问题:go-to-references 的结果会包含我们复制的文件,像 Go 这样有多文件默认命名空间范围的语言会抱怨复制文件和用户可能正在编辑的原始文件中的所有函数都有重复声明,而像 Rust 这样只有在显式导入其他文件时才会包含这些文件的语言则根本不会出错。类似的问题可能还有很多。
你可能会认为这些问题听起来很小,但独立性对我们来说绝对至关重要。如果我们稍微降低了编辑代码的正常体验,那么我们的人工智能功能再好也没用--包括我自己在内的人都不会使用 Cursor。
我们还考虑了其他一些最终失败的想法: 在 VS Code 基础架构之外生成我们自己的 tscgoplsrust-analyzer 实例;4 复制运行所有 VS Code 扩展的扩展主机进程,这样我们就可以运行每个语言服务器扩展的两个副本;5 将所有流行的语言服务器分叉,以支持多个不同版本的文件,然后将这些扩展捆绑到 Cursor 中。6

当前影子工作区的实现

我们最终以隐藏窗口的形式实现了影子工作区:每当人工智能想要查看自己编写的代码的衬垫时,我们就会为当前工作区生成一个隐藏窗口,然后在该窗口中进行编辑,并反馈衬垫。我们会在不同请求之间重复使用隐藏窗口。这样,我们就可以(几乎*)完全使用 LSP,同时(几乎*)完全满足所有要求。星号稍后处理。
简化架构图如图 4 所示。
notion image
图 4:架构图!(以我们的黑板为特色,我很喜欢它):(1) 人工智能对文件提出编辑建议。(2) 编辑从正常窗口的渲染器进程发送到其扩展主机,然后再发送到阴影窗口的扩展主机,最后再发送到阴影窗口的渲染器进程。(3) 编辑应用于阴影窗口内部,隐藏起来,独立于用户,所有字幕都以同样的方式发送回来。(4) 人工智能接收林特并决定如何迭代。
人工智能在正常窗口的渲染器进程中运行。当它想要查看自己编写的代码的林特时,呈现器进程会要求主进程在同一文件夹中生成一个隐藏的阴影窗口。7
由于电子沙盒的存在,两个渲染器进程无法直接通信。我们考虑过的一个方案是重用 VS Code 为让渲染器进程与扩展主进程通信而实施的谨慎的消息端口创建逻辑,并利用它在正常窗口和阴影窗口之间创建我们自己的消息端口 IPC。8 考虑到可维护性方面的负担,我们选择了一种破解方法:我们重新使用从呈现器进程到扩展主机的现有消息端口 IPC,然后使用独立的 IPC 连接从扩展主机到扩展主机进行通信。在此过程中,我们还悄悄改进了生活质量:现在我们可以使用 gRPC 和 buf (我们非常喜欢)进行通信,而不是使用 VS Code 的自定义且有些脆弱的 JSON 序列化逻辑。
由于添加的代码独立于其他代码,而且隐藏窗口所需的核心代码只有一行(在 Electron 中打开窗口时,可以提供一个参数 show: false 来隐藏它),因此这种设置自动具有相当高的可维护性。 它完全满足通用性和隐私性的要求。
幸运的是,独立性也得到了满足!新窗口完全独立于用户,因此人工智能可以自由地进行他们想做的任何更改,并为他们获取林特。用户不会注意到任何事情。9
阴影窗口有一个令人担忧的问题:新窗口天真地增加了 2 倍的内存使用量。我们通过限制在阴影窗口中运行的扩展,在 15 分钟不活动后自动关闭阴影窗口,以及确保选择进入阴影窗口等措施来减少这种影响。不过,这也给并发性带来了挑战:我们不能简单地为每个人工智能生成一个新的影子窗口。幸运的是,我们可以利用人工智能与人类之间的一个关键区别因素:人工智能可以在不知不觉中无限期暂停。具体来说,如果有两个人工智能 AA 和 BB 分别在 A1A_1 之后进行 A2A_2 编辑,以及在 B1B_1 之后进行 B2B_2编辑,那么就可以将这些编辑交错进行。阴影窗口首先将整个文件夹的状态重置为 A1A_1,然后获取衬垫并将其返回给 AA。然后,它将整个文件夹状态重置为 B1B_1,并获取衬垫并将其返回到 BB。以此类推,直到 A2A_2 和 B2B_2。从这个意义上说,人工智能比人类(人类有内在的时间感)更类似于电脑进程(电脑进程也会在不知不觉中被 CPU 这样交错处理)。
综上所述,我们可以得到 一个简单的 Protobuf API, 我们的后台人工智能可以用它来完善自己的编辑工作,而完全不会影响到用户。10
您的浏览器不支持视频标记。
图 5:调试模式下的阴影工作区,隐藏窗口可见!下面我们发送一个测试请求。这是 15 分钟内的第一个请求,因此它首先启动了新窗口,通过编写明显应该返回林特错误("This SHOULD BE A LINTER ERROR")的代码等待语言服务器启动,并等待实际返回错误。然后,它执行 AI 编辑,获取字符串,并将其返回到用户窗口。随后的请求(此处未显示)要快得多。
承诺的星号有些语言服务器在报告词条前会先将代码写入磁盘。主要的例子是 rust-analyzer 语言服务器,它只是运行项目级的 货物检查 来获取衬垫,并没有与 VS Code 虚拟文件系统集成(参见 本期 参考资料)。因此,除非用户使用已废弃的 RLS 扩展, 否则影子工作区还不支持 Rust 的 LSP 可用性 。

实现可运行性

可运行性是事情变得既有趣又复杂的地方。我们目前正专注于 Cursor 的短时人工智能--例如,在你使用函数时在后台为你实现函数,而不是实现整个 PR--因此我们还没有实现可运行性。尽管如此,思考如何实现它还是很有趣的。
运行代码需要将其保存到文件系统中。12 许多项目还会有基于磁盘的副作用(比如构建缓存和日志文件)。因此,我们不能再在与用户相同的文件夹中启动影子窗口。为了实现所有项目的完美可运行性,我们还需要网络级隔离,但目前我们的重点是实现磁盘隔离。

最简单的想法: cp -r

最简单的方法是将用户文件夹递归复制到 /tmp 位置,然后应用人工智能编辑、保存文件并在此运行代码。对于不同人工智能的下一次编辑,我们将执行 rm -rf 之后的新 cp -r 调用,以确保影子工作区与用户工作区保持同步。
问题在于速度: cp -r 真的很慢。需要记住的是,要运行一个项目,我们不仅需要复制源代码,还需要复制所有与构建相关的支持文件。具体来说,我们需要复制 JavaScript 项目中的 node_modules 、 Python 项目中的 venv 和 Rust 项目中的 target 。这些文件夹通常都很大,即使是中等规模的项目也不例外 。

符号链接、硬链接、写时复制

复制和创建大型文件夹结构并不一定要超慢! bun就是一个存在的证明 ,它将缓存的依赖项安装到 node_modules 中通常需要亚秒级的时间 。在 Linux 上,它们使用硬链接,因为没有实际的数据移动,所以速度很快。在 macOS 上,它们使用 clonefile 系统调用 ,这是较新加入的功能,可在写入文件或文件夹时执行复制。
遗憾的是,对于我们这个中等大小的 monorepo,即使是 cp -c clonefile 也需要 45 秒才能完成。这样的速度太慢,不适合在每次影子工作区请求前运行。硬链接很可怕,因为在影子文件夹中运行的任何操作都可能意外修改原始版本库中的真实文件。Symlinks 同样如此,而且它们还有一个额外的问题,就是不能透明处理,这意味着它们通常需要额外的配置(例如 Node.js 的 --preserve-symlinks 标志)。
我们可以想象,克隆文件(甚至是普通的 cp -r)如果配合一些巧妙的记账方案,就可以避免每次请求前都要重新复制文件夹。为确保正确性,我们需要监控用户文件夹自上次完全复制后的所有文件更改,以及复制文件夹中的所有文件更改,并在每次请求前撤销后者,重放前者。每当任何一方的更改历史变得太大而无法跟踪时,我们就可以进行一次新的完整复制并重置状态。这可能行得通,但感觉容易出错,而且很脆弱,坦率地说,实现听起来如此简单的事情却有点难看。

我们真正想要的内核级文件夹代理

我们真正想要的很简单:我们希望影子文件夹 A′A\prime 在所有使用常规文件系统 API 的应用程序看来与用户的 AA 文件夹完全相同,并能快速配置一小部分覆盖文件,而这些文件的内容则从内存中读取。我们还希望对文件夹 A′A\prime 的任何写入都写入内存中的覆盖存储,而不是写入磁盘。简而言之,我们需要一个具有可配置覆盖的代理文件夹,而且我们乐意将覆盖表完全保存在内存中。然后,我们就可以在这个代理文件夹中生成影子窗口,实现完美的磁盘级独立性。
最重要的是,我们需要内核级对文件夹代理的支持,这样任何运行代码都可以继续调用 读写 系统调用,而无需做任何更改。一种方法是创建内核扩展 13,将自己注册为内核虚拟文件系统中影子文件夹的后端,并实现上述简单行为。
在 Linux 上,我们可以通过 FUSE ("用户空间中的文件系统")在用户级实现这一功能 。FUSE 是一个内核模块,默认情况下已存在于大多数 Linux 发行版中,它将文件系统调用代理到用户级进程。这使得文件夹代理的实现更加简单。文件夹代理的模拟实现如下,这里用 C++ 表示。
首先,我们导入负责与 FUSE 内核模块通信的用户级 FUSE 库。我们还定义了目标文件夹(用户文件夹)和内存中的重写映射。
然后,我们定义自定义 读取 函数,检查重写是否包含路径,如果不包含,则直接从目标文件夹中读取。
我们的自定义 写入 函数只需写入重写映射。
最后,我们向 FUSE 注册自定义函数。
真正的实现需要实现整个 FUSE API,包括 readdirgetattrlock,但函数将与上述函数非常相似。对于每一个新的林特请求,我们都可以简单地重置重写映射,使其只包含特定人工智能的编辑内容,这样就可以立竿见影了。如果我们想确保内存不会爆炸,也可以将重写映射保留在磁盘上(需要一些额外的簿记工作)。
如果要对环境进行完美控制,我们很可能希望将其作为原生内核模块来实现,以避免 FUSE 额外的用户内核上下文切换带来的开销。14

......但是围墙花园

对于 Linux 而言,FUSE 文件夹代理非常有效,但我们的大多数用户使用的是 macOS 或 Windows,这两种操作系统都没有内置的 FUSE 实现。不幸的是,内核扩展的发布也是个问题:在装有 Apple Silicon 的 Mac 上,用户安装内核扩展的唯一方法是重启计算机,同时按住一个特殊的键进入恢复模式,然后降级到 "降低安全性 "模式。无法发货!
由于 FUSE 部分需要在内核中运行,第三方 FUSE 实现(如 macFUSE )同样面临无法让用户安装的问题。
有人试图创造性地绕过这一限制。一种方法是采用 macOS 原生支持的基于网络的文件系统(如 NFSSMB),并在其下方添加 FUSE API。 xetdata/nfsserve 上有一个开源的概念验证本地服务器,它在 NFS 的基础上构建了类似 FUSE 的 API ,而闭源项目 macOS-FUSE-t 则 支持基于 NFS 和 SMB 的后端。
问题解决了吗?并非如此...文件系统不仅仅是读写和列出文件那么简单!在这里,Cargo 会抱怨,因为 xetdata/nfsserve 实现所基于的 NFS 早期版本 不支持文件锁定。
notion image
图 6:因为 NFSv3 不支持文件锁定,所以 Cargo 失败了...
MacOS-FUSE-t 基于 NFSv4 构建,而 NFSv4 确实 支持文件锁定,但 GitHub 仓库中只有三个非源文件(Attributions.txt、License.txt、README.md),而且是由一个 GitHub 账户创建的,用户名也很可疑,只有 macos-fuse-t ,没有其他信息。显然,我们不能随意向用户发送二进制文件......开放问题还表明,基于 NFS/SMB 的方法存在一些更基本的问题,主要与苹果 内核漏洞 有关 。
我们还能怎么办?要么是新的创新方法,要么是......政治!苹果公司在逐步淘汰内核扩展的十年历程中,开放了越来越多的用户级应用程序接口(如 DriverKit),其对旧文件系统的内置支持最近也 转到了用户领域。他们开放的 MS-DOS 代码引用了一个名为 FSKit 私有框架 ,听起来很有前途!我们认为,只要稍加努力,就有可能让他们最终确定 FSKit 并 向外部开发者发布 (或许他们已经计划这样做了?

开放性问题

正如我们所见,让人工智能在后台迭代代码这个看似简单的问题其实相当复杂。影子工作区是一个为期一周、由一个人完成的项目,目的是创建一个实施方案,以解决我们向人工智能展示衬垫的迫切需求。未来,我们计划对其进行扩展,以解决可运行性问题。几个开放性问题
  1. 是否有其他方法可以在不创建内核扩展或使用 FUSE API 的情况下实现我们所想的简单代理文件夹?FUSE 试图解决一个更大的问题(任何类型的文件系统),因此我们觉得 macOS 和 Windows 上可能存在一些不起眼的 API,它们适用于我们的文件夹代理,但却不适用于一般的 FUSE 实现。
  1. 代理文件夹在 Windows 上到底是怎样的?像 WinFsp这样的东西是否就能 正常工作,还是存在安装、性能或安全方面的问题?我花了大部分时间研究如何在 macOS 上实现文件夹代理。
  1. 也许有办法在 macOS 上使用 DriverKit,模拟一个假的 USB 设备作为代理文件夹?我对此表示怀疑,但我还没有仔细研究过 API,不能肯定地说这是不可能的。
  1. 如何实现网络级别的独立性?需要考虑的一种特殊情况是,当人工智能想要调试一个集成测试时,代码被分成了三个微服务。16 我们有可能想做一些更类似虚拟机的事情,不过这需要更多的工作来确保整个环境设置和所有已安装软件的等效性。
  1. 是否有办法在用户本地工作区的基础上创建一个完全相同的远程工作区,并尽可能减少用户的设置?在云中,我们可以使用开箱即用的 FUSE(如果出于性能考虑,甚至可以使用内核模块),而无需进行任何政治设置,同时还能保证用户不会额外占用内存,并且完全独立。对于不太注重隐私的用户来说,这可能是一个不错的选择。一个原型构想是通过观察系统(也许可以结合使用编写脚本来检测机器上正在运行什么,以及使用语言模型来编写 Dockerfile)来自动生成某种 docker 容器。
如果你对这些问题有好的想法,请给我发电子邮件: arvid@anysphere.inc。另外,如果你想从事类似工作, 我们正在招聘
上一篇
LLM 大模型学习
下一篇
如何提升订阅页面的转化率