跳至主內容

以 Shadow 工作區反覆迭代

作者: Arvid Lunnemark 屬於 研究

隱藏視窗與核心層級的資料夾代理,讓 AI(人工智慧)能在不影響使用者的情況下迭代程式碼。

這是注定失敗的做法:把幾個看起來相關的檔案貼到 Google 文件,把連結丟給一位你很信任但對你的程式碼庫一無所知的 p60 軟體工程師,然後要他們在那份文件裡完整且正確地實作你下一個 PR(拉取請求)。

請一個 AI(人工智慧)做同樣的事情,它也大概會失敗——不出所料。

現在,改為讓他們能遠端存取你的開發環境,並具備查看 Lint 提示、跳轉至定義以及執行程式碼的能力,如此一來,你其實可以期待他們真的能派上用場。

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 中加入了我們稱為 shadow workspace 的機制。在這篇部落格文章中,我會先說明我們的設計準則,接著介紹撰寫本文時 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 不僅能把「九成可運作的程式碼」推進到「百分之百可運作」,在脈絡受限、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 物件,該物件會在記憶體中儲存檔案的當前狀態。語言伺服器會讀取這些 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-to-references 的結果會包含我們的副本檔案;像 Go 這類具有跨檔案預設命名空間範圍的語言,會對副本與使用者可能正在編輯的原始檔案中所有函式的重複宣告提出警告;而像 Rust 這種只有在其他地方明確匯入時才會包含檔案的語言,則完全不會報錯。很可能還有更多類似的問題。

你可能會覺得這些問題微不足道,但對我們而言,自主性絕對關鍵。只要稍微影響到一般編輯程式碼的體驗,無論我們的 AI(人工智慧)功能多好——包括我在內的使用者都不會使用 Cursor。

我們也考慮過其他最後證明不可行的做法:在 VS Code 基礎設施之外啟動我們自己的 tscgoplsrust-analyzer 執行個體;複製執行所有 VS Code 擴充功能的 extension host 行程,讓我們能同時執行每個語言伺服器擴充功能的兩個副本;以及 fork 所有熱門的語言伺服器以支援檔案的多個不同版本,然後將那些擴充功能打包進 Cursor。

目前的 Shadow 工作區實作

我們最後把「shadow workspace」做成一個隱藏視窗:每當某個 AI 想查看它所寫程式碼的 lints 時,我們就為目前的工作區啟動一個隱藏視窗,改在那個視窗中進行編輯,然後回傳 lints。我們會在多個請求之間重複使用該隱藏視窗。這讓我們在滿足所有需求的同時,幾乎達到完整的 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 行程會請求 main 行程在同一個資料夾中建立一個隱藏的 shadow 視窗。

由於 Electron 的沙箱機制,兩個 renderer 裝載程序無法直接互相通訊。我們曾考慮一個做法:重用 VS Code 為讓 renderer 裝載程序與擴充功能主機(extension host)溝通而實作的嚴謹訊息埠建立邏輯,並用它在一般視窗與陰影視窗之間建立我們自有的訊息埠式 IPC。考量維護成本,我們改採取一個取巧的解法:重用既有從 renderer 到擴充功能主機的訊息埠 IPC,然後再透過獨立的 IPC 連線在擴充功能主機之間通訊。同時,我們也順帶加入了一項易用性改進:我們現在可以使用 gRPC 與buf(我們很愛)來進行通訊,而不必依賴 VS Code 客製且略嫌脆弱的 JSON 序列化邏輯。

此做法本身就相當易於維護,因為新增的程式碼與其他程式碼彼此獨立,且用於隱藏視窗的核心程式碼只需一行(在 Electron 中開啟視窗時,可傳入參數 show: false 來將其隱藏)。它也能輕鬆滿足通用性與隱私。

幸好,獨立性也滿足了!新視窗與使用者完全分離,因此 AI(人工智慧)可以自由進行任何所需變更,並獲得相應的程式碼檢查(lint)。使用者完全不會察覺。

關於 shadow 視窗有一項顧慮:新開視窗會讓記憶體使用量天真地增加 2 倍。我們透過限制能在 shadow 視窗中執行的擴充功能、在閒置 15 分鐘後自動終止它,以及確保此功能為使用者自行選擇啟用(opt‑in),來降低其影響。不過,這對並行性仍是一項挑戰:我們無法為每個 AI 都直接產生一個新的 shadow 視窗。所幸,我們可以利用 AI 與人類之間的一個關鍵差異:AI 可以被暫停任意長的時間而毫無察覺。具體來說,若你有兩個 AI,A 與 B,分別提出 A1 接著 A2、以及 B1 接著 B2 的編輯建議,你可以將這些編輯交錯處理。shadow 視窗會先將整個資料夾狀態重設為 A1,取得 lints 並回傳給 A。接著再將整個資料夾狀態重設為 B1,取得 lints 並回傳給 B。之後以此類推處理 A2 與 B2。從這個角度來看,AI 比較像電腦中的程序(CPU 也會以這種方式交錯排程,而程序不會察覺),而非具備時間感的人類。

綜合以上內容,我們得到一個簡潔的 Protobuf API,讓我們的背景 AI(人工智慧)可以用來優化其編輯,且完全不影響使用者。

圖 5:除錯模式中的 Shadow 工作區,隱藏視窗已顯示!這裡我們送出一個測試請求。這是 15 分鐘內的第一個請求,因此它會先開啟新視窗,並透過撰寫理應觸發 Linter 錯誤的程式碼("THIS SHOULD BE A LINTER ERROR"),等待語言伺服器啟動,直到實際回傳錯誤為止。接著執行 AI(人工智慧)編輯、取得 Lint 結果,並將其回傳到使用者的視窗。隨後的請求(此處未示)會快得多。

先前提到的星號說明:部分語言伺服器在回報靜態檢查(lints)前,需要程式碼先寫入磁碟。典型例子是 rust-analyzer 語言伺服器;它會在專案層級執行 cargo check 來取得 lints,且未與 VS Code 的虛擬檔案系統整合(詳見此 issue以供參考)。因此,除非使用者改用已淘汰的 RLS 擴充功能,否則 shadow workspace 目前尚不支援 Rust 的 LSP 可用性。

達成可執行性

「可執行性」是既有趣又複雜的議題。我們目前將重心放在 Cursor 的短時尺度 AI——例如在你使用期間於背景中為你實作函式,而非一次完成整個 PR(拉取請求)——因此尚未實作可執行性。不過,思考如何實現它仍然很有趣。

執行程式碼需要先將其儲存到檔案系統。許多專案也會產生以磁碟為基礎的副作用(例如建置快取與日誌檔)。因此,我們無法再在與使用者相同的資料夾中啟動 shadow 視窗。為了確保所有專案都能順利執行,我們也需要網路層級的隔離,但目前我們先專注於實現磁碟隔離。

最簡單的做法:cp -r

最簡單的做法是以遞迴方式將使用者的資料夾複製到 /tmp,然後套用 AI 編輯、儲存檔案,並在那裡執行程式碼。針對下一次由不同 AI 進行的編輯,我們會先執行 rm -rf,再進行新的 cp -r 呼叫,以確保影子工作區與使用者的工作區保持同步。

問題在於速度:cp -r 非常慢。要記得的是,為了能執行一個專案,我們不只要複製原始碼,還得把所有與建置相關的支援檔案一起帶上。具體而言,JavaScript 專案需要複製 node_modules,Python 專案需要複製 venv,Rust 專案需要複製 target。這些資料夾通常都非常龐大,即使是中型專案也不例外,這也讓天真地使用 cp -r 的做法變得不可行。

符號連結、硬連結、寫時複製

複製與建立大型資料夾結構不一定得很慢!一個現成的證明是bun,它在將快取的相依套件安裝到 node_modules 時,常常能在不到一秒內完成。在 Linux 上使用硬連結(hard link),速度快是因為沒有實際的資料搬移;在 macOS 上,則使用clonefile 系統呼叫(syscall),這是較新的機制,會對檔案或資料夾進行寫入時複製(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 上,使用者安裝核心延伸模組的唯一方式,是在按住特殊按鍵的同時重新啟動電腦進入復原模式,然後將安全性降級為「降低的安全性」。完全無法出貨!

由於 FUSE 的一部分必須在作業系統核心中執行,像macFUSE這類第三方 FUSE 實作也同樣面臨一個幾乎不可能讓使用者願意安裝的難題。

有人嘗試用更有創意的方式來因應這項限制。其中一種作法是採用 macOS 原生支援的網路檔案系統(例如NFSSMB),並在其下層加上一個 FUSE API。有一個以 NFS 為基礎、在其上提供類 FUSE API 的開源概念驗證(proof of concept)本機伺服器,託管於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 repo 只包含三個非原始碼檔案(Attributions.txt、License.txt、README.md),而且是由一個用途可疑、看似只為單一目的而設立的 GitHub 帳號 macos-fuse-t 建立,沒有任何進一步資訊。顯然,我們不可能把來路不明的二進位檔交付給使用者……未結案的 issue 也顯示,基於 NFS/SMB 的做法存在更根本的問題,主要與 Apple 的核心層級錯誤有關。

我們還能怎麼辦?要嘛採用全新的創意做法,或者……政治手段!Apple 十年來逐步淘汰 kernel extensions 的過程,促使他們開放越來越多使用者層級的 API(例如DriverKit),而他們對舊檔案系統的內建支援最近也已轉移至 user-land。他們的開源 MS-DOS 程式碼中提及一個名為 FSKit 的私有 framework,見此,聽起來相當有前景!感覺只要再加一點「政治」運作,我們或許就能促使他們定案並將 FSKit 對外釋出給開發者(或者他們已經打算這麼做了?),如此一來,也可能解決 macOS 上的可執行性問題。

未決問題

如前所示,讓 AI 在背景中反覆迭代程式碼這個看似簡單的問題,其實相當複雜。Shadow workspace 是一個為期 1 週、由 1 人完成的專案,旨在快速落實我們當時的即時需求:將 lint 結果呈現給 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