對Copilot進行逆向工程之後,我發現它可能只用了參數量12B的小模型

為了弄清楚 Copilot 內部有哪些秘密,來自伊利諾伊大學香檳分校的一位研究者對 Copilot 進行了粗略的逆向工程。

2021 年,微軟、OpenAI、Github 三家聯合打造了一個好用的程式碼補全與建議工具 ——Copilot。

它會在開發者的程式碼編輯器內推薦程式碼行,比如當開發者在 Visual Studio Code、Neovim 和 JetBrains IDE 等集成開發環境中輸入程式碼時,它就能夠推薦下一行的程式碼。此外,Copilot 甚至可以提供關於完整的方法和複雜的演算法等建議,以及模板程式碼和單元測試的協助。

一年多過去,這一工具已經成為不少程式設計師離不開的「程式設計夥伴」。前特斯拉人工智慧總監 Andrej Karpathy 表示,「Copilot 大大加快了我的程式設計速度,很難想象如何回到『手動程式設計』。目前,我仍在學習如何使用它,它已經編寫了我將近 80% 的程式碼,準確率也接近 80%。」

習慣之餘,我們對於 Copilot 也有一些疑問,比如 Copilot 的 prompt 長什麼樣?它是如何調用模型的?它的推薦成功率是怎麼測出來的?它會收集使用者的程式碼片段發送到自己的伺服器嗎?Copilot 背後的模型是大模型還是小模型?

為了解答這些疑問,來自伊利諾伊大學香檳分校的一位研究者對 Copilot 進行了粗略的逆向工程,並將觀察結果寫成了部落格。

Andrej Karpathy 在自己的Twitter中推薦了這篇部落格。

以下是部落格原文

以下是部落格原文。

對 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 的示例:

{"prefix": "# Path: codeviz\\app.py\n# Compare this snippet from codeviz\\predictions.py:\n# import json\n# import sys\n# import time\n# from manifest import Manifest\n# \n# sys.path.append(__file__ + \"/..\")\n# from common import module_codes, module_deps, module_categories, data_dir, cur_dir\n# \n# gold_annots = json.loads(open(data_dir / \"gold_annotations.js\").read().replace(\"let gold_annotations = \", \"\"))\n# \n# M = Manifest(\n#     client_name = \"openai\",\n#     client_connection = open(cur_dir / \".openai-api-key\").read().strip(),\n#     cache_name = \"sqlite\",\n#     cache_connection = \"codeviz_openai_cache.db\",\n#     engine = \"code-davinci-002\",\n# )\n# \n# def predict_with_retries(*args, **kwargs):\n#     for _ in range(5):\n#         try:\n#             return M.run(*args, **kwargs)\n#         except Exception as e:\n#             if \"too many requests\" in str(e).lower():\n#                 print(\"Too many requests, waiting 30 seconds...\")\n#                 time.sleep(30)\n#                 continue\n#             else:\n#                 raise e\n#     raise Exception(\"Too many retries\")\n# \n# def collect_module_prediction_context(module_id):\n#     module_exports = module_deps[module_id][\"exports\"]\n#     module_exports = [m for m in module_exports if m != \"default\" and \"complex-export\" not in m]\n#     if len(module_exports) == 0:\n#         module_exports = \"\"\n#     else:\n#         module_exports = \"It exports the following symbols: \" + \", \".join(module_exports)\n#     \n#     # get module snippet\n#     module_code_snippet = module_codes[module_id]\n#     # snip to first 50 lines:\n#     module_code_snippet = module_code_snippet.split(\"\\n\")\n#     if len(module_code_snippet) > 50:\n#         module_code_snippet = \"\\n\".join(module_code_snippet[:50]) + \"\\n...\"\n#     else:\n#         module_code_snippet = \"\\n\".join(module_code_snippet)\n#     \n#     return {\"exports\": module_exports, \"snippet\": module_code_snippet}\n# \n# #### Name prediction ####\n# \n# def _get_prompt_for_module_name_prediction(module_id):\n#     context = collect_module_prediction_context(module_id)\n#     module_exports = context[\"exports\"]\n#     module_code_snippet = context[\"snippet\"]\n# \n#     prompt = f\"\"\"\\\n# Consider the code snippet of an unmodule named.\n# \nimport json\nfrom flask import Flask, render_template, request, send_from_directory\nfrom common import *\nfrom predictions import predict_snippet_description, predict_module_name\n\napp = Flask(__name__)\n\n@app.route('/')\ndef home():\n    return render_template('code-viz.html')\n\n@app.route('/data/')\ndef get_data_files(filename):\n    return send_from_directory(data_dir, filename)\n\n@app.route('/api/describe_snippet', methods=['POST'])\ndef describe_snippet():\n    module_id = request.json['module_id']\n    module_name = request.json['module_name']\n    snippet = request.json['snippet']\n    description = predict_snippet_description(\n        module_id,\n        module_name,\n        snippet,\n    )\n    return json.dumps({'description': description})\n\n# predict name of a module given its id\n@app.route('/api/predict_module_name', methods=['POST'])\ndef suggest_module_name():\n    module_id = request.json['module_id']\n    module_name = predict_module_name(module_id)\n","suffix": "if __name__ == '__main__':\r\n    app.run(debug=True)","isFimEnabled": true,"promptElementRanges": [{ "kind": "PathMarker", "start": 0, "end": 23 },{ "kind": "SimilarFile", "start": 23, "end": 2219 },{ "kind": "BeforeCursor", "start": 2219, "end": 3142 }]}

正如你所見,上述 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 該模型)。

這裡主要有兩件有趣的事情:

  1. 根據調用它的模式(OPEN_COPILOT/TODO_QUICK_FIX/UNKNOWN_FUNCTION_QUICK_FIX),它會略微修改 prompt。不要問我這些模式是如何激活的。
  2. 它從模型中請求 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,你可以通過修改擴展程式碼來實現:

  1. 搜尋擴展檔案。它通常在~/.vscode/extensions/github.copilot-/dist/extension.js 下。
  2. 搜尋字串 shouldLog (e,t,n){ ,如果找不到,也可以嘗試 shouldLog ( 。在幾個搜尋匹配中,其中一個將是非空函數定義。
  3. 在函數體的開頭,添加 return true。

如果你想要一個現成的 patch,只需複製擴展程式碼:https://thakkarparth007.github.io/copilot-explorer/muse/github.copilot-1.57.7193/dist/extension.js

注意,這是針對 1.57.7193 版本的。

原文中有更多細節連結,感興趣的讀者可以查看原文。

原文連結:https://thakkarparth007.github.io/copilot-explorer/posts/copilot-internals

相關文章

福布斯:2022 區塊鏈 50 強榜單

福布斯:2022 區塊鏈 50 強榜單

區塊鏈已經走了很長一段路了!自 2019 年首次發佈區塊鏈 50 強以來,福布斯年度榜單上的十億美元級公司 (按銷售額或市值計算至少是十億美...

蔚小理走到了命運的「岔路口」

蔚小理走到了命運的「岔路口」

當新能源汽車的資格賽進入衝刺階段,競爭的焦點也發生了變化。 作者 | 周永亮編輯| 鄭玄 近日,隨著小鵬財報發佈,蔚小理都交出了 2022 ...

深度學習,撞牆了

深度學習,撞牆了

早在 2016 年,Hinton 就說過,我們不用再培養放射科醫生了。如今幾年過去,AI 並沒有取代任何一位放射科醫生。問題出在哪兒? 近年...