對Copilot進行逆向工程之后,我發現它可能只用了參數量12B的小模型
2021 年,微軟、OpenAI、Github 三家聯合打造了一個好用的代碼補全與建議工具 ——Copilot。
它會在開發者的代碼編輯器內推薦代碼行,比如當開發者在 Visual Studio Code、Neovim 和 JetBrains IDE 等集成開發環境中輸入代碼時,它就能夠推薦下一行的代碼。此外,Copilot 甚至可以提供關于完整的方法和復雜的算法等建議,以及模板代碼和單元測試的協助。
一年多過去,這一工具已經成為不少程序員離不開的「編程伙伴」。前特斯拉人工智能總監 Andrej Karpathy 表示,「Copilot 大大加快了我的編程速度,很難想象如何回到『手動編程』。目前,我仍在學習如何使用它,它已經編寫了我將近 80% 的代碼,準確率也接近 80%。」
習慣之余,我們對于 Copilot 也有一些疑問,比如 Copilot 的 prompt 長什么樣?它是如何調用模型的?它的推薦成功率是怎么測出來的?它會收集用戶的代碼片段發送到自己的服務器嗎?Copilot 背后的模型是大模型還是小模型?
為了解答這些疑問,來自伊利諾伊大學香檳分校的一位研究者對 Copilot 進行了粗略的逆向工程,并將觀察結果寫成了博客。
Andrej Karpathy 在自己的推特中推薦了這篇博客。
以下是博客原文。
對 Copilot 進行逆向工程
Github Copilot 對我來說非常有用。它經常能神奇地讀懂我的心思,并提出有用的建議。最讓我驚訝的是它能夠從周圍的代碼(包括其他文件中的代碼)中正確地「猜測」函數 / 變量。只有當 Copilot 擴展從周圍的代碼發送有價值的信息到 Codex 模型時,這一切才會發生。我很好奇它是如何工作的,所以我決定看一看源代碼。
在這篇文章中,我試圖回答有關 Copilot 內部結構的具體問題,同時也描述了我在梳理代碼時所得到的一些有趣的觀察結果。
這個項目的代碼可以在這里找到:
代碼地址:https://github.com/thakkarparth007/copilot-explorer
整篇文章結構如下:
逆向工程概述
幾個月前,我對 Copilot 擴展進行了非常淺顯的「逆向工程」,從那時起我就一直想要進行更深入的研究。在過去的近幾周時間終于得以抽空來做這件事。大體來講,通過使用 Copilot 中包含的 extension.js 文件,我進行了一些微小的手動更改以簡化模塊的自動提取,并編寫了一堆 AST 轉換來「美化」每個模塊,將模塊進行命名,同時分類并手動注釋出其中一些最為有趣的部分。
你可以通過我構建的工具探索逆向工程的 copilot 代碼庫。它可能不夠全面和精致,但你仍可以使用它來探索 Copilot 的代碼。
工具鏈接:https://thakkarparth007.github.io/copilot-explorer/
Copilot:概述
Github Copilot 由如下兩個主要部分組成:
- 客戶端:VSCode 擴展收集你輸入的任何內容(稱為 prompt),并將其發送到類似 Codex 的模型。 無論模型返回什么,它都會顯示在你的編輯器中。
- 模型:類似 Codex 的模型接受 prompt 并返回完成 prompt 的建議。
秘訣 1:prompt 工程
現在,Codex 已經在大量公共 Github 代碼上得到了訓練,因此它能提出有用的建議是合理的。但是 Codex 不可能知道你當前項目中存在哪些功能,即便如此,它還是能提出涉及項目功能的建議,它是如何做到的?
讓我們分兩個部分來對此進行解答:首先讓我們來看一下由 copilot 生成的一個真實 prompt 例子,而后我們再來看它是如何生成的。
prompt 長啥樣
Copilot 擴展在 prompt 中編碼了大量與你項目相關的信息。Copilot 有一個相當復雜的 prompt 工程 pipeline。如下是一個 prompt 的示例:
正如你所見,上述 prompt 包括一個前綴和一個后綴。Copilot 隨后會將此 prompt(在經過一些格式化后)發送給模型。在這種情況下,因為后綴是非空的,Copilot 將以 “插入模式”,也就是 fill-in-middle (FIM) 模式來調用 Codex。
如果你查看前綴,將會看到它包含項目中另一個文件的一些代碼。參見 # Compare this snippet from codeviz\\predictions.py: 代碼行及其之后的數行
prompt 是如何準備的?
Roughly, the following sequence of steps are executed to generate the prompt:
一般來講,prompt 通過以下一系列步驟逐步生成:
1. 入口點:prompt 提取發生在給定的文檔和光標位置。其生成的主要入口點是 extractPrompt (ctx, doc, insertPos)
2. 從 VSCode 中查詢文檔的相對路徑和語言 ID。參見:getPromptForRegularDoc (ctx, doc, insertPos)
3. 相關文檔:而后,從 VSCode 中查詢最近訪問的 20 個相同語言的文件。請參閱 getPromptHelper (ctx, docText, insertOffset, docRelPath, docUri, docLangId) 。這些文件后續會用于提取將要包含在 prompt 中的類似片段。我個人認為用同一種語言作為過濾器很奇怪,因為多語言開發是相當常見的。不過我猜想這仍然能涵蓋大多數情況。
4. 配置:接下來,設定一些選項。具體包括:
- suffixPercent(多少 prompt tokens 應該專用于后綴?默認好像為 15%)
- fimSuffixLengthThreshold(可實現 Fill-in-middle 的后綴最小長度?默認為 -1,因此只要后綴非空,FIM 將始終啟用,不過這最終會受 AB 實驗框架控制)
- includeSiblingFunctions 似乎已被硬編碼為 false,只要 suffixPercent 大于 0(默認情況下為 true)。
5. 前綴計算:現在,創建一個「Prompt Wishlist」用于計算 prompt 的前綴部分。這里,我們添加了不同的「元素」及其優先級。例如,一個元素可以類似于「比較這個來自 < path> 中的片段」,或本地導入的上下文,或每個文件的語言 ID 及和 / 或路徑。這都發生在 getPrompt (fs, curFile, promptOpts = {}, relevantDocs = []) 中。
- 這里有 6 種不同類型的「元素」 – BeforeCursor, AfterCursor, SimilarFile, ImportedFile ,LanguageMarker,PathMarker。
- 由于 prompt 大小有限,wishlist 將按優先級和插入順序排序,其后將由元素填充到該 prompt 中,直至達到大小限制。這種「填充」邏輯在 PromptWishlist.fulfill (tokenBudget) 中得以實現。
- LanguageMarkerOption、NeighboringTabsPositionOption、SuffixStartMode 等一些選項控制這些元素的插入順序和優先級。一些選項控制如何提取某些信息,例如,NeighboringTabsOption 控制從其他文件中提取片段的積極程度。某些選項僅為特定語言定義,例如,LocalImportContextOption 僅支持為 Typescript 定義。
- 有趣的是,有很多代碼會參與處理這些元素的排序。但我不確定是否使用了所有這些代碼,有些于我而言看起來像是死代碼。例如,neighborTabsPosition 似乎從未被設置為 DirectlyAboveCursor…… 但我可能是錯的。同樣地,SiblingOption 似乎被硬編碼為 NoSiblings,這意味著沒有實際的同級(sibling)函數提取發生。總之,也許它們是為未來設計的,或者可能只是死代碼。
6. 后綴計算:上一步是針對前綴的,但后綴的邏輯相對簡單 —— 只需用來自于光標的任意可用后綴填充 token budget 即可。這是默認設置,但后綴的起始位置會根據 SuffixStartMode 選項略有不同, 這也是由 AB 實驗框架控制的。例如,如果 SuffixStartMode 是 SiblingBlock,則 Copilot 將首先找到與正在編輯的函數同級的功能最相近的函數,并從那里開始編寫后綴。
- 后綴緩存:有件事情十分奇怪,只要新后綴與緩存的后綴相差「不太遠」,Copilot 就會跨調用緩存后綴, 我不清楚它為何如此。這或許是由于我難以理解代碼混淆(obfuscated code)(盡管我找不到該代碼的替代解釋)。
仔細觀察一下片段提取
對我來說,prompt 生成最完整的部分似乎是從其他文件中提取片段。它在此處被調用并被 neighbor-snippet-selector.getNeighbourSnippets 所定義。根據選項,這將會使用「Fixed window Jaccard matcher」或「Indentation based Jaccard Matcher」。我難以百分百確定,但看起來實際上并沒有使用 Indentation based Jaccard Matcher。
默認情況下,我們使用 fixed window Jaccard Matcher。這種情況下,將給定文件(會從中提取片段的文件)分割成固定大小的滑動窗口。然后計算每個窗口和參考文件(你正在錄入的文件)之間的 Jaccard 相似度。每個「相關文件」僅返回最優窗口(盡管存在需返回前 K 個片段的規定,但從未遵守過)。默認情況下,FixedWindowJaccardMatcher 會被用于「Eager 模式」(即窗口大小為 60 行)。但是,該模式由 AB Experimentation framework 控制,因此我們可能會使用其他模式。
秘訣 2:模型調用
Copilot 通過兩個 UI 提供補全:Inline/GhostText 和 Copilot Panel。在這兩種情況下,模型的調用方式存在一些差異。
Inline/GhostText
主要模塊:https://thakkarparth007.github.io/copilot-explorer/codeviz/templates/code-viz.html#m9334&pos=301:14
在其中,Copilot 擴展要求模型提供非常少的建議 (1-3 條) 以提速。它還積極緩存模型的結果。此外,如果用戶繼續輸入,它會負責調整建議。如果用戶打字速度很快,它還會請求模型開啟函數防抖動功能(debouncing)。
這個 UI 也設定了一些邏輯來防止在某些情況下發送請求。例如,若用戶光標在一行的中間,那么僅當其右側的字符是空格、右大括號等時才會發送請求。
1、通過上下文過濾器(Contextual Filter)阻止不良請求
更有趣的是,在生成 prompt 后,該模塊會檢查 prompt 是否「足夠好」,以便調用模型, 這是通過計算「上下文過濾分數」來實現的。這個分數似乎是基于一個簡單的 logistic 回歸模型,它包含 11 個特征,例如語言、之前的建議是否被接受 / 拒絕、之前接受 / 拒絕之間的持續時間、prompt 中最后一行的長度、光標前的最后一個字符等。此模型權重包含在擴展代碼自身。
如果分數低于閾值(默認 15% ),則不會發出請求。探索這個模型會很有趣,我觀察到一些語言比其他語言具有更高的權重(例如 php > js > python > rust > dart…php)。另一個直觀的觀察是,如果 prompt 以 ) 或 ] 結尾,則分數低于以 ( 或 [ 結尾的情況 。這是有道理的,因為前者更可能表明早已「完成」,而后者清楚地表明用戶將從自動補全中受益。
Copilot Panel
主要模塊:https://thakkarparth007.github.io/copilot-explorer/codeviz/templates/code-viz.html#m2990&pos=12:1
Core logic 1:https://thakkarparth007.github.io/copilot-explorer/codeviz/templates/code-viz.html#m893&pos=9:1
Core logic 2:https://thakkarparth007.github.io/copilot-explorer/codeviz/templates/code-viz.html#m2388&pos=67:1
與 Inline UI 相比,此 UI 會從模型中請求更多樣本(默認情況下為 10 個)。這個 UI 似乎沒有上下文過濾邏輯(有道理,如果用戶明確調用它,你不會想不 prompt 該模型)。
這里主要有兩件有趣的事情:
- 根據調用它的模式(OPEN_COPILOT/TODO_QUICK_FIX/UNKNOWN_FUNCTION_QUICK_FIX),它會略微修改 prompt。不要問我這些模式是如何激活的。
- 它從模型中請求 logprobs,解決方案列表按 mean logprobs 分類排序。
不顯示無用的補全建議:
在(通過任一 UI)顯示建議之前,Copilot 執行兩個檢查:
如果輸出是重復的(如:foo = foo = foo = foo...),這是語言模型的常見失敗模式,那么這個建議會被丟棄。這在 Copilot proxy server 或客戶端都有可能發生。
如果用戶已經打出了該建議,該建議也會被丟棄。
秘訣 3:telemetry
Github 在之前的一篇博客中聲稱,程序員編寫的代碼中有 40% 是由 Copilot 編寫的(適用于 Python 等流行語言)。我很好奇他們是如何測出這個數字的,所以想在 telemetry 代碼中插入一些內容。
我還想知道它收集了哪些 telemetry 數據,尤其是是否收集了代碼片段。我想知道這一點,因為雖然我們可以輕松地將 Copilot 擴展指向開源 FauxPilot 后端而不是 Github 后端,該擴展可能仍然會通過 telemetry 發送代碼片段到 Github,讓一些對代碼隱私有疑慮的人放棄使用 Copilot。我想知道情況是不是這樣。
問題一:40% 的數字是如何測量的?
衡量 Copilot 的成功率不僅僅是簡單地計算接受數 / 拒絕數的問題,因為人們通常都會接受推薦并進行一些修改。因此,Github 的工作人員會檢查被接受的建議是否仍然存在于代碼中。具體來說,他們會在建議代碼被插入之后的 15 秒、30 秒、2 分鐘、5 分鐘、10 分鐘進行檢查。
現在,對已接受的建議進行精確搜索過于嚴格,因此他們會測量建議的文本和插入點周圍的窗口之間的編輯距離(在字符級別和單詞級別)。如果插入和窗口之間的「單詞」級編輯距離小于 50%(歸一化為建議大小),則該建議被視為「仍在代碼中」。
當然,這一切只針對已接受代碼。
問題二:telemetry 數據包含代碼片段嗎?
是的,包含。
在接受或拒絕建議 30 秒后,copilot 會在插入點附近「捕獲」一份快照。具體來說,該擴展會調用 prompt extraction 機制來收集一份「假設 prompt」,該 prompt 可以用于在該插入點提出建議。copilot 還通過捕獲插入點和所「猜測」的終結點之間的代碼來捕獲「假設 completion」。我不太明白它是怎么猜測這個端點的。如前所述,這發生在接受或拒絕之后。
我懷疑這些快照可能會被用作進一步改進模型的訓練數據。然而,對于假設代碼是否「穩定下來」,30 秒似乎太短了。但我猜,考慮到 telemetry 包含與用戶項目對應的 github repo,即使 30 秒的時間內會產生嘈雜的數據點,GitHub 的工作人員也可以離線清理這些相對嘈雜的數據。當然,所有這些都只是我的猜測。
注意,GitHub 會讓你選擇是否同意用你的代碼片段「改進產品」,如果你不同意,包含這些片段的 telemetry 就不會被發送到服務器上(至少在我檢查的 v1.57 中是這樣,但我也驗證了 v1.65)。在它們通過網絡發送之前,我通過查看代碼和記錄 telemetry 數據點來檢查這一點。
其他觀察結果
我稍微修改了擴展代碼以啟用 verbose logging(找不到可配置的參數)。我發現這個模型叫做「cushman-ml」,這強烈地暗示了 Copilot 使用的可能是 12B 參數模型而不是 175B 參數模型。對于開源工作者來說,這是非常令人鼓舞的,這意味著一個中等大小的模型就可以提供如此優秀的建議。當然,Github 所擁有的巨量數據對于開源工作者來說仍然難以獲得。
在本文中,我沒有介紹隨擴展一起發布的 worker.js 文件。乍一看,它似乎基本上只提供了 prompt-extraction logic 的并行版本,但它可能還有更多的功能。
文件地址:https://thakkarparth007.github.io/copilot-explorer/muse/github.copilot-1.57.7193/dist/worker_expanded.js
啟用 verbose logging
如果你想啟用 verbose logging,你可以通過修改擴展代碼來實現:
- 搜索擴展文件。它通常在~/.vscode/extensions/github.copilot-<version>/dist/extension.js 下。
- 搜索字符串 shouldLog (e,t,n){ ,如果找不到,也可以嘗試 shouldLog ( 。在幾個搜索匹配中,其中一個將是非空函數定義。
- 在函數體的開頭,添加 return true。
如果你想要一個現成的 patch,只需復制擴展代碼:https://thakkarparth007.github.io/copilot-explorer/muse/github.copilot-1.57.7193/dist/extension.js
注意,這是針對 1.57.7193 版本的。
原文中有更多細節鏈接,感興趣的讀者可以查看原文。