在 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.pyn# Compare this snippet from codeviz\predictions.py:n# import jsonn# import sysn# import timen# from manifest import Manifestn# n# sys.path.append(__file__ + "/..")n# from common import module_codes, module_deps, module_categories, data_dir, cur_dirn# 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# continuen# else:n# raise en# 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 snippetn# 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 jsonnfrom flask import Flask, render_template, request, send_from_directorynfrom common import *nfrom predictions import predict_snippet_description, predict_module_namennapp = Flask(__name__)[email protected]('/')ndef home():n return render_template('code-viz.html')[email protected]('/data/
')ndef get_data_files(filename):n return send_from_directory(data_dir, filename)[email protected]('/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})nn# predict name of a module given its [email protected]('/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__':rn 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 是如何准备的?
一般来讲,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)阻止不良请求
<span style="margin: 0px; padding: 0px; outline-style: initial; outline-width: 0px; max-width: 100%; font-size: 15px; ov
Keyword: Domo