返回主博客

使用影子工作区进行迭代

隐藏窗口和内核级文件夹代理,让 AI 可以在代码上迭代,而不会影响用户。

发布者Arvid

19分钟阅读


这是一个失败的秘诀:将一些相关文件粘贴到 Google 文档中,将链接发送给你最喜欢的 p60 软件工程师,他/她对你的代码库一无所知,然后要求他们完全且正确地在文档中实现你的下一个 PR。

要求 AI 做同样的事情,它也将会,可预见地,失败。

现在,相反地,给他们远程访问你的开发环境的权限,让他们能够看到代码检查(lints)、跳转到定义和运行代码,你或许可以真正期望他们能提供一些帮助。

图 1:你更愿意在你的代码编辑器中还是在 Google 文档中调试你 pin-boxed 的未来生命周期?AI 也是。

我们相信,让 AI 编写更多代码的关键之一是它们能够在你的开发环境中迭代。但是,天真地让 AI 在你的文件夹中随意运行会导致混乱:想象一下,你正在编写一个推理密集的函数,结果 AI 却覆盖了它;或者你试图运行你的程序,结果 AI 却插入了无法编译的代码。为了真正有所帮助,AI 的迭代需要发生在后台,而不影响你的编码体验。

为了实现这一点,我们在 Cursor 中实现了我们称之为影子工作区的功能。在这篇博文中,我将首先概述我们的设计标准,然后描述 Cursor 中当前实现的版本(一个隐藏的 Electron 窗口)以及我们未来计划发展的方向(一个内核级文件夹代理)。

图 2:Cursor 内部影子工作区的隐藏设置。目前是可选加入的。

设计标准

我们希望影子工作区实现以下目标

  1. LSP 可用性:AI 应该能够看到它们更改产生的代码检查(lints),能够跳转到定义,更普遍地能够与语言服务器协议 (LSP) 的所有部分进行交互。
  2. 可运行性:AI 应该能够运行它们的代码并查看输出。

我们最初专注于 LSP 可用性。

这些目标的实现应符合以下要求

  1. 独立性:用户的编码体验必须不受影响。
  2. 隐私性:用户的代码应该是安全的(例如,通过所有操作都在本地进行)。
  3. 并发性:多个 AI 应该能够同时工作。
  4. 通用性:它应该适用于所有语言和所有工作区设置。
  5. 可维护性:它的代码应该尽可能少且可隔离。
  6. 速度:任何地方都不应有分钟级的延迟,并且应该有足够的吞吐量来支持数百个 AI 分支。

这些要求中的许多都反映了为超过十万用户构建代码编辑器的现实情况。我们真的不希望对任何人的编码体验产生负面影响。

实现 LSP 可用性

让 AI 获取其编辑的代码检查(lints)是提高代码生成性能的最有效方法之一,同时保持底层语言模型不变。代码检查(lints)不仅可以将工作代码从 90% 提高到 100%,而且在上下文受限的情况下也非常有帮助,在这种情况下,AI 可能需要在第一次尝试时就对要调用的方法或服务进行有根据的猜测。代码检查(lints)可以帮助识别 AI 需要请求更多信息的地方。

图 3:AI 通过迭代代码检查(lints)来实现一个函数。

LSP 可用性也比可运行性更简单,因为几乎所有的语言服务器都可以在不写入文件系统的文件上运行(正如我们稍后将看到的,涉及到文件系统会使事情变得复杂得多)。所以让我们从这里开始!本着我们的第五个要求,即可维护性的精神,我们首先尝试了最简单的解决方案。

不起作用的简单解决方案

Cursor 是 VS Code 的一个分支,这意味着我们可以非常容易地访问语言服务器。在 VS Code 中,每个打开的文件都由一个 TextModel 对象表示,该对象在内存中存储文件的当前状态。语言服务器从这些文本模型对象而不是从磁盘读取,这就是它们可以在你键入时(而不仅仅是在你保存时)为你提供补全和代码检查(lints)的方式。

假设 AI 对文件 lib.ts 进行了编辑。我们显然不能修改与 lib.ts 对应的现有 TextModel 对象,因为用户可能同时正在编辑它。尽管如此,一个看似合理的想法是创建 TextModel 对象的副本,将该副本与磁盘上的任何真实文件分离,并让 AI 编辑该对象并从中获取代码检查(lints)。这可以通过以下 6 行代码完成。

async getLintsForChange(origFile: ITextModel, edit: ISingleEditOperation) {
  // create the copied in-memory TextModel and apply the AI edit to it
  const newModel = this.modelService.createModel(origFile.getValue(), null);
  newModel.applyEdits([edit]);
  // wait for 2 seconds to allow language servers to process the new TextModel object
  await new Promise((resolve) => setTimeout(resolve, 2000));
  // read the lints from the marker service, which internally routes to the correct extension based on the language
  const lints = this.markerService.read({ resource: newModel.uri });
  newModel.dispose();
  return lints;
}

这个解决方案在可维护性方面显然是出色的。它也非常适合通用性,因为大多数人已经为他们的项目安装和配置了正确的特定于语言的扩展。并发性和隐私性也得到了轻松满足。

问题在于独立性。虽然创建 TextModel 副本意味着我们没有直接修改用户正在编辑的文件,但我们仍然告诉语言服务器,与用户正在使用的语言服务器是同一个,关于我们复制的文件的存在。这会导致问题:跳转到引用的结果将包括我们复制的文件,像 Go 这样具有多文件默认命名空间范围的语言会抱怨复制文件和用户可能正在编辑的原始文件中的所有函数都存在重复声明,而像 Rust 这样的语言,只有当文件在其他地方被显式导入时才会被包含,将不会给你任何错误。可能还有更多类似的问题。

你可能认为这些问题听起来很小,但独立性对我们来说绝对至关重要。如果我们稍微降低了正常编辑代码的体验,那么无论我们的 AI 功能有多好都没有关系——包括我自己在内的人们,都将不会使用 Cursor。

我们也考虑了一些其他最终失败的想法:在 VS Code 基础设施之外生成我们自己的 tscgoplsrust-analyzer 实例,复制扩展主机进程,其中运行所有 VS Code 扩展,以便我们可以运行每个语言服务器扩展的两个副本,以及 fork 所有流行的语言服务器以支持文件的多个不同版本,然后将这些扩展捆绑到 Cursor 中。

当前的影子工作区实现

我们最终将影子工作区实现为一个隐藏窗口:每当 AI 想要查看它编写的代码的代码检查(lints)时,我们都会为当前工作区生成一个隐藏窗口,然后在该窗口中进行编辑,并将代码检查(lints)报告回来。我们在请求之间重用隐藏窗口。这为我们提供了(几乎*)完整的 LSP 可用性,同时(几乎*)完全满足所有要求。星号问题稍后解决。

图 4 显示了一个简化的架构图。

图 4:架构图!(以我们喜爱的黑板为特色。)黄色步骤:(1)AI 提出对文件的编辑。(2)编辑从正常窗口的渲染器进程发送到其扩展主机,然后传递到影子窗口的扩展主机,最后传递到影子窗口的渲染器进程。(3)编辑在影子窗口内部应用,对用户隐藏且独立,所有代码检查(lints)都以相同的方式发送回来。(4)AI 接收代码检查(lints)并决定它想要如何迭代。

AI 在正常窗口的渲染器进程中运行。当它想要查看它编写的代码的代码检查(lints)时,渲染器进程会请求主进程在同一文件夹中生成一个隐藏的影子窗口。

由于 Electron 沙箱机制,两个渲染器进程无法直接相互通信。我们考虑的一个选项是重用 VS Code 实现的谨慎的消息端口创建逻辑,以允许渲染器进程与扩展主机进程通信,并使用它在正常窗口和影子窗口之间创建我们自己的消息端口 IPC。考虑到可维护性的负担,我们选择了一个 hack 方法:我们重用从渲染器进程到扩展主机的现有消息端口 IPC,然后使用独立的 IPC 连接从扩展主机到扩展主机进行通信。在那里,我们还偷偷地进行了一个生活质量改进:我们现在可以使用 gRPC 和 buf(我们喜欢它)进行通信,而不是 VS Code 的自定义且有些脆弱的 JSON 序列化逻辑。

这种设置自动地具有良好的可维护性,因为添加的代码与其他代码是独立的,并且隐藏窗口所需的核心代码只有一行(在 Electron 中打开窗口时,你可以提供参数 show: false 来隐藏它)。它轻松地满足了通用性和隐私性。

幸运的是,独立性也得到了满足!新窗口与用户完全独立,因此 AI 可以自由地进行它们想要做的任何更改,并获取它们的代码检查(lints)。用户不会注意到任何事情。

影子窗口有一个担忧:新窗口天真地带来了 2 倍的内存使用量增加。我们通过限制在影子窗口中运行的扩展、在 15 分钟不活动后自动杀死它以及确保它是可选加入的来减少这种影响。尽管如此,这仍然对并发性提出了挑战:我们不能简单地为每个 AI 生成一个新的影子窗口。幸运的是,在这里我们可以利用 AI 和人类之间的一个关键区别因素:AI 可以无限期地暂停,甚至不会注意到。特别是,如果你有两个 AI,AABB,分别提出编辑 A1A_1 后跟 A2A_2B1B_1 后跟 B2B_2,你可以交错这些编辑。影子窗口首先将整个文件夹状态重置为 A1A_1,并获取代码检查(lints)并将其返回给 AA。然后,它将整个文件夹状态重置为 B1B_1,并获取代码检查(lints)并将其返回给 BB。对于 A2A_2B2B_2 等等也是如此。从这个意义上说,AI 更类似于计算机进程(它们也像这样被 CPU 交错而没有注意到),而不是人类(人类具有内在的时间感)。

所有这些加在一起,我们得到了 一个简单的 Protobuf API,我们的后台 AI 可以使用它来改进他们的编辑,而完全不影响用户。

图 5:调试模式下的影子工作区,隐藏窗口可见!在这里,我们发送一个测试请求。这是 15 分钟内的第一个请求,因此它首先启动新窗口并等待语言服务器启动,方法是写入应明显返回代码检查错误的代码(“THIS SHOULD BE A LINTER ERROR”),并等待它实际返回错误。然后,它执行 AI 编辑,获取代码检查(lints),并将它们返回给用户的窗口。随后的请求(此处未显示)要快得多。

承诺的星号:一些语言服务器依赖于代码写入磁盘后才报告代码检查(lints)。主要的例子是 rust-analyzer 语言服务器,它只是运行项目级别的 cargo check 来获取代码检查(lints),并且不与 VS Code 虚拟文件系统集成(有关参考,请参阅 此问题)。因此,除非用户正在使用已弃用的 RLS 扩展,否则影子工作区尚不支持 Rust 的 LSP 可用性。

实现可运行性

可运行性是事情变得既有趣又复杂的地方。我们目前专注于 Cursor 的短时程 AI——例如,在你使用它们时在后台为你实现函数,而不是实现整个 PR——所以我们还没有实现可运行性。尽管如此,思考如何实现它还是很有趣的。

运行代码需要将其保存到文件系统。许多项目也会有基于磁盘的副作用(想想,构建缓存和日志文件)。因此,我们不能再在与用户相同的文件夹中启动影子窗口。为了完美地运行所有项目,我们还需要网络级隔离,但目前,我们专注于实现磁盘隔离。

最简单的想法:cp -r

最简单的想法是递归地将用户的文件夹复制到 /tmp 位置,然后在那里应用 AI 编辑,保存文件并运行代码。对于不同 AI 的下一个编辑,我们将执行 rm -rf,然后执行新的 cp -r 调用,以确保影子工作区与用户的工作区保持同步。

问题在于速度:cp -r 非常慢。需要记住的是,为了能够运行一个项目,我们不仅需要复制源代码,还需要复制所有支持构建相关的文件。具体来说,我们需要复制 JavaScript 项目中的 node_modules,Python 项目中的 venv,以及 Rust 项目中的 target。即使对于中等规模的项目,这些通常也是巨大的文件夹,这宣告了天真的 cp -r 方法的终结。

复制和创建大型文件夹结构不必非常慢!一个存在的证明是 bun,它通常只需不到一秒的时间即可将缓存的依赖项安装到 node_modules 中。在 Linux 上,他们使用硬链接,这很快,因为没有实际的数据移动。在 macOS 上,他们使用 clonefile 系统调用,这是一个相对较新的添加,它执行文件或文件夹的写入时复制。

可悲的是,对于我们中等规模的 monorepo,即使是 cp -c clonefile 也需要 45 秒才能完成。这太慢了,无法在每个影子工作区请求之前运行。硬链接很可怕,因为你在影子文件夹中运行的任何东西都可能意外地修改原始仓库中的真实文件。符号链接也是如此,而且它们还有一个额外的问题,即不被透明地对待,这意味着它们通常需要额外的配置(例如 Node.js 的 --preserve-symlinks 标志)。

可以想象,如果与一些聪明的记账方案结合使用,clonefile(甚至普通的 cp -r)可能会奏效,以防止在每个请求之前重新复制文件夹。为了确保正确性,我们需要监视自上次完全复制以来用户文件夹中的所有文件更改,以及复制文件夹中的所有文件更改,并且在每个请求之前撤消后者并重放前者。每当任何一侧的更改历史变得太大而无法跟踪时,我们可以执行新的完整复制并重置状态。这可能可行,但感觉容易出错、脆弱,而且坦率地说,对于实现听起来如此简单的事情来说有点丑陋。

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

我们真正想要的是简单的:我们希望影子文件夹 AA\prime 对于所有使用常规文件系统 API 的应用程序来说,看起来都与用户的文件夹 AA 完全相同,并且能够快速配置一小部分覆盖文件,这些文件的内容改为从内存中读取。我们还希望对文件夹 AA\prime 的任何写入都写入到内存中的覆盖存储中,而不是磁盘。简而言之,我们想要一个带有可配置覆盖的代理文件夹,并且我们很乐意将覆盖表完全保存在内存中。然后,我们可以在此代理文件夹中生成我们的影子窗口,并实现完美的磁盘级独立性。

至关重要的是,我们需要内核级支持文件夹代理,以便任何正在运行的代码都可以继续调用 readwrite 系统调用,而无需进行任何更改。一种方法是创建一个内核扩展将其自身注册为内核虚拟文件系统中影子文件夹的后端,并实现上面概述的简单行为。

在 Linux 上,我们可以使用 FUSE(“用户空间文件系统”)在用户级别执行此操作。FUSE 是一个内核模块,默认情况下已存在于大多数 Linux 发行版中,并将文件系统调用代理到用户级进程。这使得实现文件夹代理更加简单。文件夹代理的玩具实现可能如下所示,此处以 C++ 呈现。

首先,我们导入用户级 FUSE 库,该库负责与 FUSE 内核模块通信。我们还定义了目标文件夹(用户的文件夹)和内存中的覆盖映射。

#define FUSE_USE_VERSION 31
#include <fuse3/fuse.h>
// other includes...
using namespace std;
// the proxied folder that we do not want to modify
string target_folder = "/path/to/target/folder";
// the in-memory overrides to apply
unordered_map<string, vector<char>> overrides;

然后,我们定义我们的自定义 read 函数,以检查覆盖是否包含路径,如果不是,则仅从目标文件夹读取。

int proxy_read(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
    // check if the path is in the overrides
    string path_str(path);
    if (overrides.count(path_str)) {
        const vector<char>& content = overrides[path_str];
        // if so, return the content of the override
        if (offset < content.size()) {
            if (offset + size > content.size())
                size = content.size() - offset;
            memcpy(buf, content.data() + offset, size);
        } else {
            size = 0;
        }
        return size;
    }
    // otherwise, open and read the file from the proxied folder
    string fullpath = target_folder + path;
    int fd = open(fullpath.c_str(), O_RDONLY);
    if (fd == -1)
        return -errno;
    int res = pread(fd, buf, size, offset);
    if (res == -1)
        res = -errno;
    close(fd);
    return res;
}

我们的自定义 write 函数只是写入覆盖映射。

int proxy_write(const char *path, const char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
    // always write to the overrides
    string path_str(path);
    vector<char>& content = overrides[path_str];
    if (offset + size > content.size()) {
        content.resize(offset + size);
    }
    memcpy(content.data() + offset, buf, size);
    return size;
}

最后,我们将我们的自定义函数注册到 FUSE。

int main(int argc, char *argv[])
{
    struct fuse_operations operations = {
        .read = proxy_read,
        .write = proxy_write,
    };
    return fuse_main(argc, argv, &operations, NULL);
}

真正的实现需要实现整个 FUSE API,包括 readdirgetattrlock,但这些函数将与上面的函数非常相似。对于每个新的代码检查(lints)请求,我们可以简单地将覆盖映射重置为仅包含该特定 AI 的编辑,这是即时的。如果我们想保证防止内存爆炸,我们也可以将覆盖映射保存在磁盘上(需要一些额外的簿记工作)。

通过对环境的完美控制,我们可能希望将其实现为本机内核模块,以避免来自 FUSE 的额外用户-内核上下文切换的开销。

...但是:围墙花园

对于 Linux,FUSE 文件夹代理效果很好,但我们的大多数用户使用 macOS 或 Windows,这两者都没有内置的 FUSE 实现。不幸的是,发布内核扩展也是不可能的:在配备 Apple Silicon 的 Mac 上,用户安装内核扩展的唯一方法是在按住特殊键进入恢复模式时重启计算机,然后降级到“降低安全性”。不可发布!

由于 FUSE 部分需要在内核内部运行,因此第三方 FUSE 实现(如 macFUSE)也遇到了同样的问题——无法让用户安装它。

已经有一些尝试围绕此限制进行创新。一种方法是采用 macOS 本机支持的基于网络的文件系统(例如,NFSSMB),并在其下放置 FUSE API。有一个开源的概念验证本地服务器,其 FUSE 样式的 API 构建在 NFS 之上,托管在 xetdata/nfsserve,以及闭源项目 macOS-FUSE-t 支持构建在 NFS 和 SMB 之上的后端。

问题解决了吗?并非如此……文件系统比仅仅读取、写入和列出文件更复杂!在这里,Cargo 抱怨,因为 xetdata/nfsserve 实现所基于的早期版本的 NFS 不支持文件锁定。

图 6:Cargo 失败,因为 NFSv3 不支持文件锁定……

MacOS-FUSE-t 构建在 NFSv4 之上,NFSv4 确实 支持文件锁定,但 GitHub 仓库仅包含三个非源文件(Attributions.txt、License.txt、README.md),并且由具有可疑的单一用途用户名 macos-fuse-t 的 GitHub 帐户创建,没有更多信息。显然,我们不能向我们的用户发布随机二进制文件……未解决的问题也表明了基于 NFS/SMB 的方法存在一些更根本的问题,主要与 Apple 内核错误有关。

我们还剩下什么?要么是一种新的创新方法,要么……政治!Apple 长达十年的逐步淘汰内核扩展的旅程导致他们开放了越来越多的用户级 API(例如 DriverKit),并且他们对旧文件系统的内置支持最近已 切换到用户空间。他们的开源 MS-DOS 代码 在此处 引用了一个名为 FSKit 的私有框架,这听起来非常有希望!感觉有可能通过一点政治手段,我们可以让他们最终完成并向外部开发人员发布 FSKit(或者他们可能已经在计划这样做?),在这种情况下,我们可能也会找到 macOS 的可运行性问题的解决方案。

开放性问题

正如我们所见,让 AI 在后台迭代代码这个看似简单的问题实际上非常复杂。影子工作区是一个为期 1 周、1 人的项目,旨在创建一个实现来解决我们当前的需求,即向 AI 显示代码检查(lints)。在未来,我们计划将其扩展到也解决可运行性问题。一些开放性问题

  1. 是否有另一种方法来实现我们正在考虑的简单代理文件夹,而无需创建内核扩展或使用 FUSE API?FUSE 试图解决更大的问题(任何类型的文件系统),因此感觉上可能在 macOS 和 Windows 上存在一些晦涩的 API,这些 API 适用于我们的文件夹代理,但不适用于通用的 FUSE 实现。
  2. Windows 上代理文件夹的故事究竟是什么样的?像 WinFsp 这样的东西可以直接工作吗,还是存在安装、性能或安全问题?我大部分时间都在研究如何在 macOS 上进行文件夹代理。
  3. 也许有一种在 macOS 上使用 DriverKit 并模拟一个假 USB 设备来充当代理文件夹的方法?我对此表示怀疑,但我还没有足够仔细地研究该 API,无法自信地说这是不可能的。
  4. 我们如何实现网络级独立性?需要考虑的一种特殊情况是,当 AI 想要调试一个集成测试时,代码在三个微服务之间拆分。我们可能想要做一些更像 VM 的事情,尽管这将需要更多的工作来确保整个环境设置和所有已安装软件的等效性。
  5. 是否有一种方法可以从用户的本地工作区创建一个相同的远程工作区,并尽可能减少用户所需的设置?在云端,我们可以开箱即用地使用 FUSE(或者如果出于性能原因需要,甚至可以使用内核模块),而无需进行任何政治操作,并且我们还可以保证用户不会占用额外的内存,并实现完全独立。对于不太关心隐私的用户来说,这可能是一个不错的选择。一个初步的想法是通过观察系统来自动推断 Docker 容器(或许可以结合编写脚本来检测机器上运行的内容,并使用语言模型来编写 Dockerfile)。

如果您对这些问题有好的想法,请发送电子邮件至 arvid@anysphere.inc。此外,如果您想从事类似的工作,我们正在招聘