跳至内容

使用 Shadow 工作区进行迭代

作者: Arvid Lunnemark归入研究

隐藏窗口与内核级文件夹代理,使 AI 能在不影响用户的情况下迭代代码。

以下是失败的秘诀:把几份相关文件粘贴进一个 Google 文档,把链接发给你最喜欢但对你代码库一无所知的 p60 软件工程师,然后让 TA 在文档里完整且正确地实现你的下一个 PR。

让一款 AI 做同样的事情,它也会(不出意料地)失败。

现在,换一种方式,给予他们对你的开发环境的远程访问权限,并让他们能够查看代码检查、跳转到定义以及运行代码,你或许就可以指望他们真正发挥一些作用。

Figure 1: Would you rather debug your pin-boxed future lifetimes in your code editor or in a Google Doc? AIs too.
Figure 1: Would you rather debug your pin-boxed future lifetimes in your code editor or in a Google Doc? AIs too.

我们认为,要让 AI 为你编写更多代码,其中一个关键在于它能在你的开发环境中进行迭代。但如果不加选择地让 AI 在你的文件夹中自由操作,只会导致混乱:想象你刚写好一个需要大量推理的函数,却被 AI 覆盖;或者你正准备运行程序,AI 却插入了无法编译的代码。要真正有用,AI 的迭代应在后台进行,而不影响你的编码体验。

为实现这一点,我们在 Cursor 中引入了我们称之为影子工作区的机制。在这篇博客文章中,我将先概述我们的设计准则,然后介绍撰写时 Cursor 中的实现(一个隐藏的 Electron 窗口),以及我们计划的未来方向(内核级文件夹代理)。

Figure 2: The hidden setting for the shadow workspace inside Cursor. Currently opt-in.
Figure 2: The hidden setting for the shadow workspace inside Cursor. Currently opt-in.

设计准则

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

  1. LSP 可用性:AI 应该能够看到其更改产生的 lint 提示、跳转到定义,更一般地说,能够与语言服务器协议(LSP)的各个部分进行交互。
  2. 可运行性:AI 应该能够运行其代码并查看输出结果。

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

应在满足以下要求的前提下实现这些目标:

  1. 独立性:不得影响用户的编码体验。
  2. 隐私:用户的代码应当是安全的(例如,将所有内容保存在本地)。
  3. 并发性:多个 AI 应能够并行完成各自的工作。
  4. 通用性:应适用于所有编程语言和各种工作区设置。
  5. 可维护性:应尽可能以最少且易于隔离的代码编写。
  6. 速度:任何环节都不应出现长达数分钟的延迟,并且吞吐量应足以支撑数百个 AI 分支。

这些取舍大多反映了为十多万用户打造代码编辑器的现实考量。我们真的不想对任何人的编码体验产生负面影响。

实现 LSP 可用性

在底层语言模型不变的前提下,让 AI 为其编辑获取 lint 反馈,是提升代码生成性能最有影响力的方法之一。lint 不仅能把“90% 可运行的代码”提升到“100% 可运行”,在上下文受限的场景中也非常有用——当 AI 需要在第一次尝试时对要调用的某个方法或服务做出有根据的判断时尤为如此。lint 还能帮助定位 AI 需要进一步补充信息的地方。

Figure 3: An AI implements a function by iterating on lints.
Figure 3: An AI implements a function by iterating on lints.

LSP 的可用性也比可运行性更简单,因为几乎所有语言服务器都能在未写入文件系统的文件上运行(正如我们稍后会看到的,一旦涉及文件系统,事情会困难不少)。所以我们就从这里开始!秉持第五条要求——可维护性——的精神,我们首先尝试了最简单可行的方案。

那些行不通的简单方案

Cursor 作为 VS Code 的一个分支,意味着我们已经可以非常便捷地访问各类语言服务器。在 VS Code 中,每个已打开的文件都由一个 TextModel 对象表示,该对象在内存中存储文件的当前状态。语言服务器从这些文本模型对象而非磁盘读取数据,这也正是它们能够在你输入时(而不是只在保存时)提供补全与语法检查的原因。

假设一个 AI 对文件 lib.ts 进行了编辑。我们显然不能修改与 lib.ts 对应的现有 TextModel 对象,因为用户可能同时在编辑它。尽管如此,一个看似可行的想法是复制该 TextModel 对象,将副本与磁盘上的任何真实文件解绑,然后让 AI 在该对象上进行编辑并获取 lint 结果。下面这 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 扩展的扩展宿主进程,以便运行每个语言服务器扩展的两个副本;以及对所有流行的语言服务器进行分叉以支持同一文件的多个不同版本,然后将这些扩展捆绑进 Cursor。

当前的影子工作区实现

我们最终将“影子工作区”实现为一个隐藏窗口:每当 AI 想查看它生成代码的 lint 结果时,我们会为当前工作区启动一个隐藏窗口,然后在该窗口中进行编辑,并回传 lint 报告。我们会在请求之间复用这个隐藏窗口。这样在满足所有要求的同时,我们(几乎*)获得了完整的 LSP 可用性。(星号部分稍后解释。)

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

Figure 4: An architecture diagram! (Featuring our blackboard, which I adore.) Steps in yellow: (1) The AI proposes an edit to a file. (2) The edit is sent from the normal window's renderer process to its extension host, then over to the shadow window's extension host, and finally to the shadow window's renderer process. (3) The edit is applied inside the shadow window, hidden and independent from the user, and all lints are sent back the same way. (4) The AI receives the lint and decides how it wants to iterate.
Figure 4: An architecture diagram! (Featuring our blackboard, which I adore.) Steps in yellow: (1) The AI proposes an edit to a file. (2) The edit is sent from the normal window's renderer process to its extension host, then over to the shadow window's extension host, and finally to the shadow window's renderer process. (3) The edit is applied inside the shadow window, hidden and independent from the user, and all lints are sent back the same way. (4) The AI receives the lint and decides how it wants to iterate.

AI 正在普通窗口的 renderer 进程中运行。当它想查看自己编写代码的 lint 结果时,renderer 进程会请求主进程在同一文件夹中生成一个隐藏的影子窗口。

由于 Electron 的沙箱机制,这两个渲染进程无法直接相互通信。我们曾考虑过一种方案:复用 VS Code 为让渲染进程与扩展宿主进程通信而实现的精心设计的消息端口创建逻辑,用它来在普通窗口与影子窗口之间建立我们自己的消息端口 IPC。出于对可维护性的担忧,我们选择了一个权宜之计:复用现有的从渲染进程到扩展宿主的消息端口 IPC,然后在扩展宿主与扩展宿主之间通过一条独立的 IPC 连接进行通信。在这里,我们还顺势加入了一项体验改进:我们现在可以使用 gRPC 和buf(我们非常喜欢)进行通信,而不是依赖 VS Code 定制且略显脆弱的 JSON 序列化逻辑。

这种设置天然易于维护,因为新增代码与其他代码相互独立,而且用于隐藏窗口的核心代码只有一行(在 Electron 中打开窗口时,可以提供参数 show: false 来隐藏它)。它轻松满足通用性和隐私要求。

幸运的是,独立性也得到了满足!新窗口与用户完全独立,因此 AI 可以自由进行任何他们想要的更改,并为这些更改获取 lints。用户完全不会察觉。

关于“影子窗口”,我们有一个需要关注的问题:新窗口会天真地导致内存使用翻倍(2x)。为降低影响,我们在影子窗口中限制可运行的扩展、在闲置 15 分钟后自动终止它,并确保该功能为用户自主开启。尽管如此,它仍给并发带来挑战:我们无法为每个 AI 都简单地生成一个新的影子窗口。所幸,这里可以利用 AI 与人类之间的一个关键差异:AI 可以被暂停任意长的时间而不自觉。具体来说,假设你有两个 AI,A 和 B,分别提出编辑 A1 接着 A2,以及 B1 接着 B2,你可以将这些编辑交错执行。影子窗口首先将整个文件夹状态重置为 A1,获取 lints 并返回给 A。然后,将整个文件夹状态重置为 B1,获取 lints 并返回给 B。以此类推,继续处理 A2 和 B2。从这个意义上说,AI 更像计算机进程(CPU 也会以这种方式交错执行它们而不被察觉),而不是人类(人类具有对时间的内在感知)。

综上所述,我们得到一个简单的 Protobuf API,供我们的后台 AI 使用,以便在完全不影响用户的情况下完善其编辑。

图 5:调试模式下的影子工作区,隐藏窗口已可见!此处我们发送一个测试请求。由于这是过去 15 分钟内的第一个请求,它会先启动新窗口,并通过编写一段显然会触发 linter 错误的代码("THIS SHOULD BE A LINTER ERROR")来等待语言服务器启动,并等待实际返回错误。随后,它执行 AI 编辑,获取 lints,并将其返回到用户的窗口。后续请求(此处未展示)会快得多。

承诺过的星号说明:某些语言服务器在报告 lint 之前依赖将代码写入磁盘。主要示例是 rust-analyzer 语言服务器,它只是运行项目级的 cargo check 来获取 lint,并且不与 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 系统调用,这是一个相对较新的能力,可以对文件或文件夹执行写时复制(copy-on-write)。

遗憾的是,对于我们中等规模的 monorepo,即使用 cp -c clonefile 也需要 45 秒才能完成。这太慢了,无法在每次请求 shadow workspace 之前都执行。硬链接令人担忧,因为你在 shadow 文件夹中运行的任何操作都可能意外修改原始仓库中的真实文件。符号链接也存在类似问题,且还有一个额外问题:它们并非被透明对待,往往需要额外配置(例如Node.js 的 --preserve-symlinks 标志)。

可以想象,如果配合某种巧妙的“记账”方案来避免在每次请求前都要重新复制文件夹,那么使用 clonefile(甚至是普通的 cp -r)也许能奏效。为了确保正确性,我们需要监控自上次完整拷贝以来用户文件夹中的所有文件变更,以及被复制文件夹中的所有文件变更;并在每次请求前撤销后者、重放前者。一旦任一侧的变更历史大到难以追踪,我们就可以执行一次新的完整拷贝并重置状态。这样可能可行,但感觉容易出错、脆弱,而且说实话,为了实现看起来如此简单的目标,这种做法有点丑陋。

我们的真正目标:内核级文件夹代理

我们的需求其实很简单:我们希望有一个影子文件夹 A′,对所有使用常规文件系统 API 的应用来说,它与用户的文件夹 A 完全一致;同时,我们能够快速配置一小组覆盖文件,这些文件的内容改为从内存中读取。我们也希望对文件夹 A′ 的任何写入都写入到内存中的覆盖存储,而不是磁盘。简而言之,我们需要一个带可配置覆盖的代理文件夹,并且我们乐于将覆盖表完全保存在内存中。这样,我们就可以在这个代理文件夹中启动我们的影子窗口,从而实现与磁盘的完全级别隔离。

关键是,我们需要对文件夹代理提供内核级支持,这样任何正在运行的代码就能在无需修改的情况下继续调用 readwrite 系统调用。一个可行方案是创建一个内核扩展 13,将其在内核的虚拟文件系统中注册为影子文件夹的后端,并实现上述所述的简单行为。

在 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 函数:先检查 overrides 是否包含该路径;如果没有,则直接从目标文件夹读取。

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 函数只是将内容写入 overrides 映射。

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,不过这些函数的写法会与上面示例非常相似。对于每一次新的 lint 请求,我们可以简单地把 overrides 映射重置为该特定 AI 的编辑结果即可,这个操作是瞬时的。若要防止内存占用失控,也可以将 overrides 映射保存在磁盘上(需要做一些额外的簿记工作)。

在能够完全掌控环境的情况下,我们更可能会将其实现为原生内核模块,以避免使用 FUSE 带来的额外用户态与内核态上下文切换开销。14

……但是:围墙花园

在 Linux 上,FUSE 文件夹代理运行良好,但我们的多数用户使用的是 macOS 或 Windows,而这两者都没有内置的 FUSE 实现。更不巧的是,随产品提供内核扩展也不可行:在搭载 Apple Silicon 的 Mac 上,用户安装内核扩展的唯一方式是重启电脑、按住特殊按键进入恢复模式,并将安全设置降级为“降低的安全性(Reduced Security)”。完全无法交付!

由于 FUSE 需要部分在内核中运行,像macFUSE这样的第三方 FUSE 实现也面临同样的问题——几乎不可能让用户去安装它。

有人尝试绕过这一限制并提出了各类创意方案。一种做法是选用 macOS 原生支持的网络文件系统(例如NFSSMB),并在其下方接入 FUSE API。已有一个基于 NFS 构建、提供类 FUSE API 的开源本地服务器,托管于xetdata/nfsserve;此外,闭源项目macOS-FUSE-t同时支持基于 NFS 和 SMB 的后端。

问题解决了吗?并没有……文件系统并不只是读、写和列出文件这么简单!这里,Cargo 报错是因为 xetdata/nfsserve 实现所基于的早期 NFS 版本不支持文件锁定。

Figure 6: Cargo fails because NFSv3 does not support file locking...
Figure 6: Cargo fails because NFSv3 does not support file locking...

MacOS-FUSE-t 基于 NFSv4 构建,确实支持文件锁定,但该 GitHub 仓库除了三个非源代码文件(Attributions.txt、License.txt、README.md)外别无他物,而且由一个用途可疑、仅此单一目的的用户名 macos-fuse-t 的 GitHub 账号创建,且没有更多信息。显然,我们不可能向用户分发来路不明的二进制文件……这些未解决问题还表明了基于 NFS/SMB 的方法存在一些更为基础性的缺陷,主要与 Apple 的内核缺陷有关。

我们还剩下哪些选项?要么想出一种全新的创意路径,要么等到 15,或者……政治!Apple 十年来逐步淘汰内核扩展的进程,促使他们开放越来越多的用户态 API(例如DriverKit),而他们对旧文件系统的内置支持也在近期迁移到了用户态。他们的开源 MS-DOS 代码中引用了一个名为 FSKit 的私有框架,相关内容见此处,这听起来非常令人期待!感觉只要稍微做点“公关/游说”,我们或许就能推动他们将 FSKit 最终定稿并向外部开发者发布(或者他们已经计划这么做了?),那样的话,我们在 macOS 上的可运行性问题也可能迎刃而解。

未决问题

正如我们所见,让 AI 在后台对代码进行迭代这一看似简单的问题,其实相当复杂。Shadow workspace 是一个为期 1 周、由 1 人完成的项目,旨在实现一套方案以满足我们当下“将 lints 呈现给 AI”的迫切需求。未来,我们计划扩展它来解决可运行性(runnability)问题。还有一些悬而未决的问题:

  1. 有没有一种不用创建内核扩展或使用 FUSE API,就能实现我们设想的简易代理文件夹的方法?FUSE 试图解决一个更大的问题(任何类型的文件系统),因此看起来很有可能在 macOS 和 Windows 上存在一些较为冷门的 API,能满足我们对文件夹代理的需求,但并不适用于通用的 FUSE 实现。
  2. 在 Windows 上,代理文件夹的实现究竟是什么样的?像WinFsp这样的方案直接可用吗,还是会有安装、性能或安全方面的问题?我大部分时间都在研究如何在 macOS 上实现文件夹代理。
  3. 也许可以在 macOS 上使用 DriverKit 并模拟一个假的 USB 设备来充当代理文件夹?我对此表示怀疑,但我还没有足够深入地研究该 API,无法自信地断言这不可能。
  4. 我们如何实现网络层面的独立性?需要特别考虑的一种情况是,当 AI 想要调试一个集成测试,而代码分散在三个微服务之间。16 我们可能需要做一些更类似于虚拟机(VM)的事情,但这将需要更多工作来确保整个环境设置和所有已安装软件的等效性。
  5. 是否有办法在尽量减少用户设置负担的前提下,从用户的本地工作区创建一个完全相同的远程工作区?在云端,我们可以开箱即用 FUSE(若出于性能考虑,甚至可以用内核模块),无需任何“政治”协调,同时还能保证对用户不额外占用内存并保持完全独立。对于不太在意隐私的用户,这可能是一个不错的替代方案。一个初步想法是通过观察系统自动推断出一个 docker 容器(也许结合编写脚本来检测机器上正在运行的内容,并使用语言模型生成 Dockerfile)。

如果你对这些问题有好的想法,请发送邮件至arvid@anysphere.inc告诉我。另外,如果你想参与类似的工作,我们正在招聘

归类于: 研究

作者: Arvid Lunnemark