布景

众所周知,Github Copilot是一种依据机器学习的代码主动补全东西。它使用了来自GitHub的许多代码作为练习数据,并使用OpenAI的言语模型来生成代码。Copilot还可以学习用户的编码习惯,并依据上下文揣度出正确的代码片段。

在实际使用中发现大部份提示仍是非常好用的,可以较为准确的推测出用户意图,乃至是依据项目其他文件的上下文进行推理。比较猎奇这儿是怎样做到的,于是探索了这个VSCode插件的详细完成。

准备作业

因为Copilot并没有开源,因而咱们需求做一些逆向的准备。

首要,找到VSCode插件的安装目录,拿到extension.js

花了大半个月,我终于逆向分析了Github Copilot

image

在mac下插件目录在~/.vscode下,咱们可以拿到一个经过紧缩混杂的文件:

花了大半个月,我终于逆向分析了Github Copilot

image

1. 切割webpack_modules

针对整个webpack紧缩混杂的js,咱们首要要将不同的bundle辨认出来,切割成单个文件,以便于后续的剖析。

因为紧缩后的代码存在许多不确定性,一开始打算尝试经过正则提取,但无论如何都有各种鸿沟状况导致提取不正确,最简略的办法仍是经过AST来提取。

首要,经过babel-parser将源码解析为AST:

constast=parser.parse(source);

然后,经过babel-traverse遍历整个AST,找到modules的变量,取出里边的值:

functionparseModules(){
traverse(ast,{
enter(path){
if(
path.node.type==="VariableDeclarator"&&
path.node.id.name==="__webpack_modules__"
){
constmodules=path.node.init.properties;
for(constmoduleofmodules){
constmoduleId=module.key.value;
constmoduleAst=module.value;
constmoduleSource=generate(moduleAst).code;
try{
constast=transformRequire(prettier(clearfyParams(moduleId,moduleSource)));
constmainBody=ast.program.body[0].expression.body.body;
constmoduleCode=generate(types.Program(mainBody)).code;
fs.writeFileSync(
"./prettier/modules/"+moduleId+".js",
moduleCode,
"utf8"
);
}catch(e){
console.log(e);
}
}
}
},
});
}

终究,将处理往后的ast经过babel-generator和babel-types重新生成新的ast写入文件。

这样,咱们就得到了以模块id命名的独立bundle,在我的这一版中,解析出来的copilot的bundle现已非常多了,达到752个。

花了大半个月,我终于逆向分析了Github Copilot

image

2. 辨认模块依靠

咱们解析出来的bundle,第一层函数大概是这样:

花了大半个月,我终于逆向分析了Github Copilot

image

关于webpack来说,这几个参数是固定的,分别是moduleexportsrequire,所以咱们优先把这几个参数辨认出来,进行效果域的替换,这样才可以看出模块间的依靠联系:

functionclearfyParams(moduleId,moduleSource){
if(moduleSource.trim().startsWith("function")){
//change`function(e,t,n){`to`(e,t,n)=>{`
moduleSource=moduleSource.replace("function","");
moduleSource=moduleSource.replace(")",")=>");
}
constmoduleAst=parser.parse(moduleSource);
letflag=false;
traverse(moduleAst,{
ArrowFunctionExpression(path){
if(flag)return;
constparams=path.node.params;
params.forEach((param)=>{
if(param.name==="e"||param.name==="t"||param.name==="n"){
path.scope.rename(
param.name,
{
e:"module",
t:"exports",
n:"require",
}[param.name]
);
}
});
flag=true;
},
});
returnmoduleAst;
}

这样,咱们就得到了类似这样的有require和exports的代码:

varr=require(12781).Stream;
vari=require(73837);
functiono(){
this.source=null;
this.dataSize=0;
this.maxDataSize=1048576;
this.pauseStream=!0;
this._maxDataSizeExceeded=!1;
this._released=!1;
this._bufferedEvents=[];
}
module.exports=o;

3. 优化紧缩后的语法

JS代码经过紧缩后,会发生许多的逗号运算符、短路写法、三元表达式、括号闭包等等,非常阻止阅览,这儿参阅了github.com/thakkarpart…

functionprettier(ast){
constmoduleTransformer={
//e.g.,`(0,r.getConfig)(e,r.ConfigKey.DebugOverrideProxyUrl);`
//getstransformedtor.getConfig(e,r.ConfigKey.DebugOverrideProxyUrl);
CallExpression(path){
if(path.node.callee.type!="SequenceExpression"){
return;
}
if(
path.node.callee.expressions.length==2&&
path.node.callee.expressions[0].type=="NumericLiteral"
){
path.node.callee=path.node.callee.expressions[1];
}
},
ExpressionStatement(path){
if(path.node.expression.type=="SequenceExpression"){
constexprs=path.node.expression.expressions;
letexprStmts=exprs.map((e)=>{
returntypes.expressionStatement(e);
});
path.replaceWithMultiple(exprStmts);
return;
}
if(path.node.expression.type=="AssignmentExpression"){
//handlecaseslike:`a=(expr1,expr2,expr3)`
//convertto:`expr1;expr2;a=expr3;`
if(path.node.expression.right.type=="SequenceExpression"){
constexprs=path.node.expression.right.expressions;
letexprStmts=exprs.map((e)=>{
returntypes.expressionStatement(e);
});
letlastExpr=exprStmts.pop();
path.node.expression.right=lastExpr.expression;
exprStmts.push(path.node);
path.replaceWithMultiple(exprStmts);
return;
}
//handlecaseslike:`exports.GoodExplainableName=a;`where`a`isafunctionoraclass
//rename`a`to`GoodExplainableName`everywhereinthemodule
if(
path.node.expression.left.type=="MemberExpression"&&
path.node.expression.left.object.type=="Identifier"&&
path.node.expression.left.object.name=="exports"&&
path.node.expression.left.property.type=="Identifier"&&
path.node.expression.left.property.name!="default"&&
path.node.expression.right.type=="Identifier"&&
path.node.expression.right.name.length==1
){
path.scope.rename(
path.node.expression.right.name,
path.node.expression.left.property.name
);
return;
}
}
if(path.node.expression.type=="ConditionalExpression"){
//handlecaseslike:`<test>?c:d;`
//convertto:`if(<test>){c;}else{d;}`
consttest=path.node.expression.test;
constconsequent=path.node.expression.consequent;
constalternate=path.node.expression.alternate;
constifStmt=types.ifStatement(
test,
types.blockStatement([types.expressionStatement(consequent)]),
types.blockStatement([types.expressionStatement(alternate)])
);
path.replaceWith(ifStmt);
return;
}
if(path.node.expression.type=="LogicalExpression"){
//handlecaseslike:`a&&b;`
//convertto:`if(a){b;}`
consttest=path.node.expression.left;
constconsequent=path.node.expression.right;
constifStmt=types.ifStatement(
test,
types.blockStatement([types.expressionStatement(consequent)]),
null
);
path.replaceWith(ifStmt);
return;
}
},
IfStatement(path){
if(!path.node.test||path.node.test.type!="SequenceExpression"){
return;
}
constexprs=path.node.test.expressions;
letexprStmts=exprs.map((e)=>{
returntypes.expressionStatement(e);
});
letlastExpr=exprStmts.pop();
path.node.test=lastExpr.expression;
exprStmts.push(path.node);
path.replaceWithMultiple(exprStmts);
},
ReturnStatement(path){
if(
!path.node.argument||
path.node.argument.type!="SequenceExpression"
){
return;
}
constexprs=path.node.argument.expressions;
letexprStmts=exprs.map((e)=>{
returntypes.expressionStatement(e);
});
letlastExpr=exprStmts.pop();
letreturnStmt=types.returnStatement(lastExpr.expression);
exprStmts.push(returnStmt);
path.replaceWithMultiple(exprStmts);
},
VariableDeclaration(path){
//change`consta=1,b=2;`to`consta=1;constb=2;`
if(path.node.declarations.length>1){
letnewDecls=path.node.declarations.map((d)=>{
returntypes.variableDeclaration(path.node.kind,[d]);
});
path.replaceWithMultiple(newDecls);
}
},
};
traverse(ast,moduleTransformer);
returnast;
}

4. require的模块id取名

因为紧缩之后的代码,require依靠只有moduleid,现已失去了本来的文件称号,所以这儿咱们需求手动映射(当然也可以凭借GPT)揣度一下不同文件的称号,维护一个map文件,然后在ast里将模块id替换为有语义的模块名:

functiontransformRequire(ast){
constmoduleTransformer={
VariableDeclaration(path){
if(path.node.declarations[0].init&&path.node.declarations[0].init.type==="CallExpression"){
if(path.node.declarations[0].init.callee.name==="require"){
constmoduleId=path.node.declarations[0].init.arguments[0].value;
if(NameMap[moduleId]){
const{name,path:modulePath}=NameMap[moduleId];
path.node.declarations[0].init.arguments[0].value='"'+modulePath+'"';
path.scope.rename(path.node.declarations[0].id.name,name);
}
}
}

},
};
traverse(ast,moduleTransformer);
returnast;
}

至此,咱们逆向相关的准备作业就完成了。

进口剖析

虽然前面咱们现已为逆向做了许多的作业,但实际上,逆向JS代码仍是一个体力活,在有限的精力下,咱们也只能手动将一些上下文紧缩变量进行揣度替换,尽或许还原一些中心文件的代码。

进口可以很轻易找到它的模块id是91238:

花了大半个月,我终于逆向分析了Github Copilot

image

经过一系列的手动优化操作,咱们可以大致还原这个进口文件的原始样貌:

花了大半个月,我终于逆向分析了Github Copilot

image

在VSCode的active函数中,copilot做了许多初始化相关的作业,以及将各个模块的示例注册到context中,后续取实例就从context上下文来取。

咱们的中心仍是想探索copilot的代码补全能力,进口文件的细节在这儿就不展开了。

代码提示进口逻辑

代码提示逻辑是在registerGhostText中注册的:

花了大半个月,我终于逆向分析了Github Copilot

image

在vscode中,主要经过InlineCompletionItemProvider来完成编辑器中的代码补全能力。

整个完成的进口逻辑经过还原后大致如下:

花了大半个月,我终于逆向分析了Github Copilot

image

整体仍是比较明晰的,它大致做了以下几件工作:

  • 假如用户封闭了InlineSuggestEnable、或许document不在处理白名单内,或许用户取消了输入,都会提早return,不进行代码提示。
  • 调用getGhostText办法拿到texts,这个大概便是终究会回来给用户的代码提示文本。
  • 调用completionsFromGhostTextResults,拿到终究的completions。这个函数比较简略,主要对文本进行了一些格式化的处理,比方处理Tab空格的问题,以及依据光标当时的位置核算出代码提示应当显示在编辑器中的坐标规模。

getGhostText中心逻辑

getGhostText是获取提示代码的中心办法,整体代码较多,咱们将其拆分一下:

1. 提取Prompt

constprompt=awaitextractprompt.extractPrompt(ctx,document,position);

提取prompt是一个比较杂乱的操作,接下来咱们独自拆一末节详细剖析。

2. 鸿沟判别

if("copilotNotAvailable"===prompt.type){
exports.ghostTextLogger.debug(
ctx,
"Copilotnotavailable,duetothe.copilotignoresettings"
);
return{
type:"abortedBeforeIssued",
reason:"Copilotnotavailableduetothe.copilotignoresettings",
};
}
if("contextTooShort"===prompt.type){
exports.ghostTextLogger.debug(ctx,"Breaking,notenoughcontext");
return{
type:"abortedBeforeIssued",
reason:"Notenoughcontext",
};
}
if(token?.isCancellationRequested){
exports.ghostTextLogger.info(ctx,"CancelledafterextractPrompt");
return{
type:"abortedBeforeIssued",
reason:"CancelledafterextractPrompt",
};
}

这儿的鸿沟规模主要是三种状况:

  • 包含在.copilotignore里的文件
  • 上下文太少了
  • 用户现已取消了

3. 二级缓存

在copilot内部做了两层缓存处理,第一层缓存是保存了上一次的prefixsuffix

functionupdateGlobalCacheKey(prefix,suffix,promptKey){
prefixCache=prefix;
suffixCache=suffix;
promptKeyCache=promptKey;
}

这儿的promptKey是依据prefixsuffix的内容核算得到。在copilot向后台建议恳求前,假如判别这次恳求的prefix和suffix仍是和之前的一样,则会读取缓存的内容:

花了大半个月,我终于逆向分析了Github Copilot

image

紧接着,假如上一层的缓存没有射中,copilot还会核算二级缓存,会核算当时的prompt在不在缓存规模内:

花了大半个月,我终于逆向分析了Github Copilot

image

在这儿,copilot采取的缓存是LRU缓存战略,prompt默许会缓存100条:

exports.completionCache=news.LRUCacheMap(100);

而keyForPrompt函数便是一个对prefix和suffix的hash:

exports.keyForPrompt=function(e){
returnr.SHA256(e.prefix+e.suffix).toString();
};

4. 真实建议恳求

到了真实到向后台发送prompt恳求的时分,copilot仍是做了两件比较细致的工作:

  1. 设置Debounce时延
  2. 判别contexualFilterScore是否达到阈值

首要,为了防止频频向后台发送恳求,copilot做了debounce,要知道模型核算是十分耗费算力的,因而在这个场景下,必须要做debounce。copilot的这个debounce也不是一般的,让咱们看看它的完成细节:

exports.getDebounceLimit=asyncfunction(e,t){
letn;
if((awaite.get(r.Features).debouncePredict())&&t.measurements.contextualFilterScore){
conste=t.measurements.contextualFilterScore;
constr=.3475;
consti=7;
n=25+250/(1+Math.pow(e/r,i));
}elsen=awaite.get(r.Features).debounceMs();
returnn>0?n:75;
};

copilot有一个猜测开关,假如这个猜测开关翻开,会依据当时的内容相关性评分猜测当时的debounce时延,这个处理就比较高档了。当然在开关没翻开的状况下默许值为75ms。

其次便是contexualFilterScore 了,这个值代表的是上下文的评分,copilot会记载之前几回上下文有没有采用的成果,貌似是经过一个简略的线性回归来猜测当时的上下文被采用的或许性,假如小于必定的阈值,则不会再给用户进行提示,优化用户体验。

花了大半个月,我终于逆向分析了Github Copilot

image

当时版别的阈值应该是35%。这个contextualFilterEnable开关默许是翻开的。

终究,便是向后台真实建议恳求了:

花了大半个月,我终于逆向分析了Github Copilot

image

5. 流程总结

画个图总结一下copilot向后台建议恳求之前做的工作:

花了大半个月,我终于逆向分析了Github Copilot

image

Extract中心逻辑

Extract首层逻辑其实并不杂乱,终究回来了prompt目标:

花了大半个月,我终于逆向分析了Github Copilot

image

上图中用红框标出的字段来源于装备,其他的字段来自于getPrompt办法的回来,getPrompt是获取prompt的中心逻辑,这块咱们接下来独自展开讨论,先来看看装备的问题。

在copilot(VSCode)系统中,有许多装备是对接了微软的AB试验渠道的,可以在文件中找到这样的模块:

asyncfetchExperiments(e,t){
constn=e.get(r.Fetcher);
leto;
try{
o=awaitn.fetch("https://default.exp-tas.com/vscode/ab",{
method:"GET",
headers:t
});
}catch(t){
returni.ExpConfig.createFallbackConfig(e,`ErrorfetchingExPconfig:${t}`);
}
if(!o.ok)returni.ExpConfig.createFallbackConfig(e,`ExPrespondedwith${o.status}`);
consts=awaito.json(),
a=s.Configs.find(e=>"vscode"===e.Id)??{
Id:"vscode",
Parameters:{}
},
c=Object.entries(a.Parameters).map(([e,t])=>e+(t?"":"cf"));
returnnewi.ExpConfig(a.Parameters,s.AssignmentContext,c.join(";"));
}

这个便是拉取了ab试验的渠道,许多特性开关都是经过装备下发,copilot的相关装备也不例外。

这些装备在渠道没有指定的时分,都是以它的默许值。

经过我实际抓包,发现我的Copilot插件装备好像没有经过装备渠道独自指定,因而整个字段应该取的默许值:

  • suffixPercent,默许值为15.
  • fimSuffixLengthThreshold,默许值为0
  • maxPromptCompletionTokens,默许值为2048
  • neighboringTabsOption,默许值为eager
  • neighboringSnippetTypes,默许值为NeighboringSnippets
  • numberOfSnippets,默许值为4
  • snippetPercent,默许值为0
  • suffixStartMode,默许值为CursorTrimStart
  • tokenizerName, 默许值为cushman002
  • indentationMinLength,默许值为undefined
  • indentationMaxLength,默许值为undefined
  • cursorContextFix,默许值为false

这些会作为Prompt的基础装备字段传给getPrompt办法。

getPrompt中心逻辑

一些额定的装备字段

在getPrompt逻辑里,首要扩充了一系列装备字段:

  • languageMarker,默许值为Top
  • pathMarker,默许值为Top
  • localImportContext,默许值为Declarations
  • snippetPosition,默许值为TopOfText
  • lineEnding,默许值为ConvertToUnix
  • suffixMatchThreshold,默许值为0
  • suffixMatchCriteria,默许值为Levenshtein
  • cursorSnippetsPickingStrategy,默许值为CursorJaccard

prompt的组成

在Copilot中,prompt是由多种类型组合而成,可以在PromptElementKind中找到:

  • BeforeCursor,是光标前的内容
  • AfterCursor,是光标后的内容
  • SimilarFile,与当时文件类似度较高的内容
  • ImportedFile:import依靠
  • LanguageMarkder,文件最初的符号语法
  • PathMarker,文件的途径信息

PromptElement的优先级

Copilot完成了一个优先级的辅佐类,用来设置不同类型的Element优先级:

classPriorities{
constructor(){
this.registeredPriorities=[0,1];
}
register(e){
if(e>Priorities.TOP||e<Priorities.BOTTOM)thrownewError("Prioritymustbebetween0and1");
this.registeredPriorities.push(e);
returne;
}
justAbove(...e){
constt=Math.max(...e);
constn=Math.min(...this.registeredPriorities.filter(e=>e>t));
returnthis.register((n+t)/2);
}
justBelow(...e){
constt=Math.min(...e);
constn=Math.max(...this.registeredPriorities.filter(e=>e<t));
returnthis.register((n+t)/2);
}
between(e,t){
if(this.registeredPriorities.some(n=>n>e&&n<t)||!this.registeredPriorities.includes(e)||!this.registeredPriorities.includes(t))thrownewError("Prioritiesmustbeadjacentinthelistofpriorities");
returnthis.register((e+t)/2);
}
}

可以看到justAbove和justBelow,便是生成一个比传入优先级略高或略低的优先级,确保这个优先级在现在的状况下只比传入的优先级高或低一点。

在Copilot中,不同类型的优先级是这样发生的:

constbeforeCursorPriority=priorities.justBelow(p.Priorities.TOP);
constlanguageMarkerPriority=
promptOpts.languageMarker===h.Always
?priorities.justBelow(p.Priorities.TOP)
:priorities.justBelow(beforeCursorPriority);
constpathMarkerPriority=
promptOpts.pathMarker===f.Always?priorities.justBelow(p.Priorities.TOP):priorities.justBelow(beforeCursorPriority);
constimportedFilePriority=priorities.justBelow(beforeCursorPriority);
constlowSnippetPriority=priorities.justBelow(importedFilePriority);
consthighSnippetPriority=priorities.justAbove(beforeCursorPriority);

这儿可以简略揣度一下:

  • beforeCursorPriority,为0.5
  • languageMarkerPriority,为0.25
  • pathMarkderPriority,为0.375
  • importedFilePriority,为0.4375
  • lowSnippetPriority,为0.40625
  • highSnippetPriority,为0.75

所以在默许的场景下,这几种类型的优先级排序为:highSnippetPriority > beforeCursorPriority > importedFilePriority > lowSnippetPriority > pathMarkderPriority > languageMarkerPriority

PromptElement主要内容

  1. languageMarker和pathMarker

languageMarker和pathMarker是最早被放进promptWishList中的,经过前面的剖析,咱们知道在装备中,languageMarker和pathMarker都是有默许值的,因而下面的判别分支必定会走到:

if(promptOpts.languageMarker!==h.NoMarker){
conste=newLineEnded(r.getLanguageMarker(resourceInfo));
languageMarkerId=promptWishlist.append(e,p.PromptElementKind.LanguageMarker,languageMarkerPriority);
}
if(promptOpts.pathMarker!==f.NoMarker){
conste=newLineEnded(r.getPathMarker(resourceInfo));
if(e.length>0){
pathMarkerId=promptWishlist.append(e,p.PromptElementKind.PathMarker,pathMarkerPriority);
}
}

这两个函数完成也比较简略,咱们先来看一下getLanguageMarker:

exports.getLanguageMarker=function(e){
const{
languageId:t
}=e;
return-1!==n.indexOf(t)||hasLanguageMarker(e)?"":tinr?r[t]:comment(`Language:${t}`,t);
};

这儿首要确认了languageId,不在ignoreList当中,在copilot中,有两种言语是被排除在外的:

constn=["php","plaintext"];

其次再看一下言语自身是否有符号语法,在这个Map中(HTML、Python、Ruby、Shell、YAML):

constr={
html:"<!DOCTYPEhtml>",
python:"#!/usr/bin/envpython3",
ruby:"#!/usr/bin/envruby",
shellscript:"#!/bin/sh",
yaml:"#YAMLdata"
};

其余的状况就回来一行注释,类似这样:

//Language:${languageId}

getPathMarker逻辑更简略些,仅仅一行注释,标明文件途径(暂时搞不清楚这个信息给模型有什么用,或许途径里边包含了目录结构信息和文件名协助模型更好进行揣度?):

exports.getPathMarker=function(e){
returne.relativePath?comment(`Path:${e.relativePath}`,e.languageId):"";
};
  1. localImportContext

localImportContext的完成要杂乱一点,经过上面的装备咱们可以看到这个也是默许有值的,会进到下面这个分支当中:

if(promptOpts.localImportContext!==y.NoContext)
for(consteofawaiti.extractLocalImportContext(resourceInfo,promptOpts.fs))
promptWishlist.append(newLineEnded(e),p.PromptElementKind.ImportedFile,importedFilePriority);

extractLocalImportContext是一个异步函数,让咱们看一下这儿边的完成:

constreg=/^\s*import\s*(type|)\s*{[^}]*}\s*from\s*['"]./gm;
exports.extractLocalImportContext=asyncfunction(resourceInfo,fs){
let{
source:source,
uri:uri,
languageId:languageId
}=resourceInfo;
returnfs&&"typescript"===languageId?asyncfunction(source,uri,fs){
letlanguage="typescript";
letresult=[];
constimportEndIndex=function(source){
letmatch;
letlastIndex=-1;
reg.lastIndex=-1;
do{
match=reg.exec(source);
if(match){
lastIndex=reg.lastIndex+match.length;
}
}while(match);
if(-1===lastIndex)return-1;
constnextNewLine=source.indexOf("\n",lastIndex);
return-1!==nextNewLine?nextNewLine:source.length;
}(source);
if(-1===importEndIndex)returnresult;
source=source.substring(0,importEndIndex);
letast=awaiti.parseTreeSitter(language,source);
try{
for(letnodeoffunction(node){
lett=[];
for(letchildNodeofnode.namedChildren)if("import_statement"===childNode.type){
t.push(childNode);
}
returnt;
}(ast.rootNode)){
letfilePath=getTSFilePath(uri,node);
if(!filePath)continue;
letnamedImports=parseNamedImport(node);
if(0===namedImports.length)continue;
letexports=awaitgetExports(filePath,language,fs);
for(leteofnamedImports)if(exports.has(e.name)){
result.push(...exports.get(e.name));
}
}
}finally{
ast.delete();
}
returnresult;
}(source,uri,fs):[];
}

首要咱们可以关注到的是,这个函数先判别了Typescript的言语,也就意味着当时版别的Copilot,只对ts文件的import依靠做了处理。

这个函数之所以是异步的,便是这儿要拿到import句子的语法树,这个进程比较杂乱,copilot是使用了wasm的方式,经过tree-sitter来解析语法树的,这个进程是异步的。

终究,copilot提取出一切的import,而且回来了一切named import对应的export代码,也便是终究索引到了依靠文件,将用到的export都作为上下文extract出来。

  1. snippets

snippets的处理是比较杂乱的,在Copilot中,首要拿到了一个snippets:

constsnippets=[...retrievalSnippets,...(promptOpts.neighboringTabs===a.NeighboringTabsOption.None||0===neighborDocs.length?[]
:awaita.getNeighborSnippets(
resourceInfo,
neighborDocs,
promptOpts.neighboringSnippetTypes,
promptOpts.neighboringTabs,
promptOpts.cursorContextFix,
promptOpts.indentationMinLength,
promptOpts.indentationMaxLength,
promptOpts.snippetSelection,
promptOpts.snippetSelectionK,
lineCursorHistory,
promptOpts.cursorSnippetsPickingStrategy
)),
];

在默许的场景下,retrievalSnippets是空的,而neighboringTabs在咱们前面的剖析中是eager,所以会经过getNeighborSnippets去拿到这个数组。

留意这儿传入了neighborDocs,这个是在extract的进口就传过来的,对应的代码是:

letneighborDocs=[];
letneighborSource=newMap();
try{
constt=awaitu.NeighborSource.getNeighborFiles(ctx,uri,repoUserData);
neighborDocs=t.docs;
neighborSource=t.neighborSource;
}catch(t){
telemetry.telemetryException(ctx,t,"prompt.getPromptForSource.exception");
}

在默许的状况下,这儿拿到的fileType是OpenTabs,所以会默许经过VSCode拿到现在翻开的tab中,包含同类型言语文件的一切内容(按拜访时间排序),对应的代码如下:

exports.OpenTabFiles=class{
constructor(e,t){
this.docManager=e;
this.neighboringLanguageType=t;
}
asynctruncateDocs(e,t,n,r){
consto=[];
lets=0;
for(constaofe)if(!(s+a.getText().length>i.NeighborSource.MAX_NEIGHBOR_AGGREGATE_LENGTH)&&("file"==a.uri.scheme&&a.fileName!==t&&i.considerNeighborFile(n,a.languageId,this.neighboringLanguageType)&&(o.push({
uri:a.uri.toString(),
relativePath:awaitthis.docManager.getRelativePath(a),
languageId:a.languageId,
source:a.getText()
}),s+=a.getText().length),o.length>=r))break;
returno;
}
asyncgetNeighborFiles(e,t,n,o){
lets=[];
consta=newMap();
s=awaitthis.truncateDocs(utils2.sortByAccessTimes(this.docManager.textDocuments),e.fsPath,n,o);
a.set(i.NeighboringFileType.OpenTabs,s.map(e=>e.uri));
return{
docs:s,
neighborSource:a
};
}
};

接着咱们来看一下getNeighborSnippets的完成:

exports.getNeighborSnippets=asyncfunction(
resourceInfo,
neighborDocs,
neighboringSnippetTypes,
neighboringTabs,
cursorContextFix,
indentationMinLength,
indentationMaxLength,
snippetSelection,
snippetSelectionK,
lineCursorHistory,
cursorSnippetsPickingStrategy
){
constoptions={
...exports.neighborOptionToSelection[neighboringTabs],
};
consty=(function(
resourceInfo,
neighboringSnippetTypes,
options,
cursorContextFix,
indentationMinLength,
indentationMaxLength,
lineCursorHistory,
cursorSnippetsPickingStrategy=i.CursorSnippetsPickingStrategy
.CursorJaccard
){
letd;
if(neighboringSnippetTypes===s.NeighboringSnippets){
d=
void0!==indentationMinLength&&void0!==indentationMaxLength
?o.IndentationBasedJaccardMatcher.FACTORY(
indentationMinLength,
indentationMaxLength,
cursorContextFix
)
:o.FixedWindowSizeJaccardMatcher.FACTORY(
options.snippetLength,
cursorContextFix
);
}else{
if(neighboringSnippetTypes===s.NeighboringFunctions){
d=o.FunctionJaccardMatcher.FACTORY(
options.snippetLength,
cursorContextFix,
indentationMinLength,
indentationMaxLength
);
}else{
r.ok(
void0!==lineCursorHistory,
"lineCursorHistoryshouldnotbeundefined"
);
d=i.CursorHistoryMatcher.FACTORY(
options.snippetLength,
lineCursorHistory,
cursorSnippetsPickingStrategy,
cursorContextFix
);
}
}
returnd.to(resourceInfo);
})(
resourceInfo,
neighboringSnippetTypes,
options,
cursorContextFix,
indentationMinLength,
indentationMaxLength,
lineCursorHistory,
cursorSnippetsPickingStrategy
);
return0===options.numberOfSnippets
?[]
:(
awaitneighborDocs
.filter((e)=>e.source.length<1e4&&e.source.length>0)
.slice(0,20)
.reduce(
async(e,t)=>
(
awaite
).concat(
(
awaity.findMatches(t,snippetSelection,snippetSelectionK)
).map((e)=>({
relativePath:t.relativePath,
...e,
}))
),
Promise.resolve([])
)
)
.filter((e)=>e.score&&e.snippet&&e.score>options.threshold)
.sort((e,t)=>e.score-t.score)
.slice(-options.numberOfSnippets);
};

在这个完成中,咱们可以得到以下要害信息:

  • neighboringSnippetTypes默许为NeighboringSnippets,所以会走到FixedWindowSizeJaccardMatcher的逻辑里
  • 回来值是依据neighborDocs的内容,过滤掉过小和过大文件,经过findMatchers拿到的成果
  • 终究过滤掉score较低的,不过threshold默许为0,所以这儿应该保存了一切的内容
  • 依据score进行排序,选取较大的4条(numberOfSnippets默许为4)

紧接着咱们就来看看FixedWindowSizeJaccardMatcher的逻辑:

classFixedWindowSizeJaccardMatcherextendsi.WindowedMatcher{
constructor(resourceInfo,snippetLength,cursorContextFix){
super(resourceInfo,cursorContextFix);
this.windowLength=snippetLength;
this.cursorContextFix=cursorContextFix;
}
id(){
return"fixed:"+this.windowLength;
}
getWindowsDelineations(e){
returno.getBasicWindowDelineations(this.windowLength,e);
}
trimDocument(e){
returne.source.slice(0,e.offset).split("\n").slice(-this.windowLength).join("\n");
}
_getCursorContextInfo(e){
returnr.getCursorContext(e,{
maxLineCount:this.windowLength,
cursorContextFix:this.cursorContextFix
});
}
similarityScore(e,t){
returncomputeScore(e,t);
}
}

这儿的snippetLength 在eager的状况下默许为60,也就意味着snippet最多不超过60行。

这个类承继了WindowedMatcher,findMatches就在WindowedMatcher里:

asyncfindMatches(e,t=i.SnippetSelectionOption.BestMatch,n){
if(t==i.SnippetSelectionOption.BestMatch){
constt=awaitthis.findBestMatch(e);
returnt?[t]:[];
}
returnt==i.SnippetSelectionOption.TopK&&(awaitthis.findTopKMatches(e,n))||[];
}

在这儿第二个参数其实默许是undefined,所以默许走到BestMatch的分支:

asyncfindBestMatch(e){
if(0===e.source.length||0===this.referenceTokens.size)return;
constt=e.source.split("\n");
constn=this.retrieveAllSnippets(e,s.Descending);
return0!==n.length&&0!==n[0].score?{
snippet:t.slice(n[0].startLine,n[0].endLine).join("\n"),
semantics:o.SnippetSemantics.Snippet,
provider:o.SnippetProvider.NeighboringTabs,
...n[0]
}:void0;
}

可以看到所谓BestMatch,便是取出retrieveAllSnippets 的第0条成果作为snippet回来。

retrieveAllSnippets(e,t=s.Descending){
constn=[];
if(0===e.source.length||0===this.referenceTokens.size)returnn;
constsourceArr=e.source.split("\n");
constkey=this.id()+":"+e.source;
constresult=c.get(key)??[];
constnoCache=0==result.length;
consttokens=noCache?sourceArr.map(this.tokenizer.tokenize,this.tokenizer):[];
for(const[index,[startLine,endLine]]ofthis.getWindowsDelineations(sourceArr).entries()){
if(noCache){
conste=newSet();
tokens.slice(startLine,endLine).forEach(t=>t.forEach(e.add,e));
result.push(e);
}
constr=result[index];
consts=this.similarityScore(r,this.referenceTokens);
n.push({
score:s,
startLine:startLine,
endLine:endLine
});
}
if(noCache){
c.put(key,result);
}
returnthis.sortScoredSnippets(n,t);
}

这段代码的中心是依据窗口核算出不同的代码片段与当时文件的类似度,并回来排序后的片段列表。

首要这儿做了个缓存处理,用来缓存现已核算过类似度的代码;

然后咱们要点关注下这儿的几个逻辑:

  • 经过tokenize获取到当时代码片段每一行的token
  • 经过getWindowsDelineations将代码切割成不同的小窗口(步长为1)
  • 每个窗口的token和当时文件(referenceDoc)的token做一次类似度核算(Jaccard类似度)

这三个点都非常要害,咱们展开来剖析下:

  1. tokenize核算每一行的token

    constp=newSet(["we","our","you","it","its","they","them","their","this","that","these","those","is","are","was","were","be","been","being","have","has","had","having","do","does","did","doing","can","don","t","s","will","would","should","what","which","who","when","where","why","how","a","an","the","and","or","not","no","but","because","as","until","again","further","then","once","here","there","all","any","both","each","few","more","most","other","some","such","above","below","to","during","before","after","of","at","by","about","between","into","through","from","up","down","in","out","on","off","over","under","only","own","same","so","than","too","very","just","now"]);
    constd=newSet(["if","then","else","for","while","with","def","function","return","TODO","import","try","catch","raise","finally","repeat","switch","case","match","assert","continue","break","const","class","enum","struct","static","new","super","this","var",...p]);
    tokenize(e){
    returnnewSet(splitIntoWords(e).filter(e=>!this.stopsForLanguage.has(e)));
    }
    functionsplitIntoWords(e){
    returne.split(/[^a-zA-Z0-9]/).filter(e=>e.length>0);
    }
    

    可以看到处理tokens其实便是分词的进程,比一般单词切割多了一步,便是过滤常见的要害词,这些要害词不影响类似度的核算(比方if、for这种)。

  2. getWindowsDelineations切割窗口

    exports.getBasicWindowDelineations=function(e,t){
    constn=[];
    constr=t.length;
    if(0==r)return[];
    if(r<e)return[[0,r]];
    for(lett=0;t<r-e+1;t++)n.push([t,t+e]);
    returnn;
    };
    

    getWindowsDelineations 自身逻辑并不杂乱,便是依据传入的windowSize回来一个二维数组,这个二维数组的每一项都是一个起始行数和停止行数,它回来的是步长为1,在文件里边windowSize长度内的一切或许区间。

    得到这些区间后,会跟当时的内容(同样windowSize)进行类似度核算,选择出类似度最高的区间内容回来,这个内容便是终究的snippet。

    其中,获取当时内容的办法如下:

    getreferenceTokens(){
    if(void0===this._referenceTokens){
    this._referenceTokens=this.tokenizer.tokenize(this._getCursorContextInfo(this.referenceDoc).context);
    }
    returnthis._referenceTokens;
    }
    exports.getCursorContext=functione(doc,opts={}){
    constopts=function(e){
    return{
    ...i,
    ...e
    };
    }(opts);
    consts=r.getTokenizer(opts.tokenizerName);
    if(void0===opts.maxTokenLength&&void0!==opts.maxLineCount){
    conste=doc.source.slice(0,doc.offset).split("\n").slice(-opts.maxLineCount);
    constn=e.join("\n");
    return{
    context:n,
    lineCount:e.length,
    tokenLength:s.tokenLength(n),
    tokenizerName:opts.tokenizerName
    };
    }
    //...
    };
    

    可以看到,这儿取的是当时光标前一切内容在窗口巨细的切断,这个会token分词之后与对应的相关文件token进行类似度核算。

  3. 类似度核算(Jaccard

    Copilot经过一个非常简略的**Jaccard** 类似度核算办法:

    functioncomputeScore(e,t){
    constn=newSet();
    e.forEach(e=>{
    if(t.has(e)){
    n.add(e);
    }
    });
    returnn.size/(e.size+t.size-n.size);
    }
    

    实际上,Jaccard类似度核算公式为:

    花了大半个月,我终于逆向分析了Github Copilot

    image

    这是一个非常简略的集合运算,使用交集占比来求类似度,Copilot使用两个分词集合来快速核算文本类似度。

终究,copilot调用了processSnippetsForWishlist,将snippet加入到wishList当中:

function$(){
constmaxSnippetLength=Math.round((promptOpts.snippetPercent/100)*promptOpts.maxPromptLength);
c.processSnippetsForWishlist(
snippets,
resourceInfo.languageId,
tokenizer,
promptOpts.snippetProviderOptions,
{
priorities:priorities,
low:lowSnippetPriority,
high:highSnippetPriority,
},
promptOpts.numberOfSnippets,
maxSnippetLength
).forEach((e)=>{
lett=p.PromptElementKind.SimilarFile;
if(e.provider===c.SnippetProvider.Retrieval){
t=p.PromptElementKind.RetrievalSnippet;
}else{
if(e.provider==c.SnippetProvider.SymbolDef){
t=p.PromptElementKind.SymbolDefinition;
}
}
promptWishlist.append(e.announcedSnippet,t,e.priority,e.tokens,e.normalizedScore);
});
}

从前面咱们可以得知snippetPercent默许为0,所以这儿maxSnippetLength也为0.

咱们深化看一下processSnippetsForWishList的完成:

exports.processSnippetsForWishlist=function(snippets,languageId,tokenizer,snippetProviderOptions,priorities,numberOfSnippets,maxSnippetLength){
const{
reserved:reserved,
candidates:candidates
}=selectSnippets(snippets,numberOfSnippets,snippetProviderOptions);
letd=0;
leth=[];
lethighPriorities=priorities.high;
letlowPriorities=priorities.low;
functiong(snippet,r){
consto=announceSnippet(snippet,languageId);
constc=tokenizer.tokenLength(o);
letl;
if(r+c<=maxSnippetLength){
l=highPriorities;
highPriorities=priorities.priorities.justBelow(l);
}else{
l=lowPriorities;
lowPriorities=priorities.priorities.justBelow(l);
}
h.push({
announcedSnippet:o,
provider:snippet.provider,
providerScore:snippet.providerScore,
normalizedScore:snippet.normalizedScore,
priority:l,
tokens:c,
relativePath:snippet.relativePath
});
returnr+c;
}
for(constsnippetof[...reserved,...candidates]){
if(h.length>=numberOfSnippets)break;
d=g(snippete,d);
}
l(h);
h.reverse();
returnh;
};

可以看到这儿maxSnippetLength影响的是Priority,在这儿默许状况下便是lowPriority了。

这儿的处理其实本质上是对score进行正则化,重排序,然后回来announcedSnippet,这个announceSnippet便是终究被加入到Prompt文本里的内容:

functionannounceSnippet(e,t){
constn=s[e.semantics];
leti=(e.relativePath?`Comparethis${n}from${e.relativePath}:`:`Comparethis${n}:`)+"\n"+e.snippet;
if(i.endsWith("\n")){
i+="\n";
}
returnr.commentBlockAsSingles(i,t);
}

可以看到这儿,相关文件的snippet会包裹在注释里,并在头部加上一行Compare this …的文案,供给给模型。

  1. beforeCursor

beforeCursor的代码比较简略:

promptWishlist.appendLineForLine(source.substring(0,offset),p.PromptElementKind.BeforeCursor,beforeCursorPriority).forEach((e)=>
V.push(e)
);

留意这儿用了appendLineForLine,而不是append,让咱们看一下appendLineForLine的完成:

appendLineForLine(text,kind,priority){
constlineArr=(lines=this.convertLineEndings(text)).split("\n");
for(leti=0;i<lineArr.length-1;i++)lineArr[i]+="\n";
constlines=[];
lineArr.forEach((line)=>{
if("\n"===line&&lines.length>0&&!lines[lines.length-1].endsWith("\n\n")){
lines[lines.length-1]+="\n";
}else{
lines.push(line);
}
});
constresult=[];
lines.forEach((text,index)=>{
if(""!==text){
result.push(this.append(text,kind,priority));
if(index>0){
this.content[this.content.length-2].requires=[
this.content[this.content.length-1],
];
}
}
});
returnresult;
}

实际上这段代码的效果便是将光标前的内容按行append,这样在token有限的状况下,可以按行保存最大的上下文。

wishList的fullfill整合处理

在接下来便是一系列依靠联系的处理:

if(h.Top===promptOpts.languageMarker&&V.length>0&&void0!==languageMarkerId){
promptWishlist.require(languageMarkerId,V[0]);
}
if(f.Top===promptOpts.pathMarker&&V.length>0&&void0!==pathMarkerId){
if(languageMarkerId){
promptWishlist.require(pathMarkerId,languageMarkerId);
}else{
promptWishlist.require(pathMarkerId,V[0]);
}
}
if(void0!==languageMarkerId&&void0!==pathMarkerId){
promptWishlist.exclude(pathMarkerId,languageMarkerId);
}

这儿其实我不太理解的一点是,pathMarker和languageMarker在这个逻辑里互斥了,在咱们上面剖析可以看到pathMarker的优先级比languageMarker优先级高,这儿加了一个exclude,也就意味着languageMarker永久不或许出现了。

终究,假如是suffixPercent为0的状况下,代码到这儿就直接完毕了,调用fullfill办法回来终究的成果:

if(0===promptOpts.suffixPercent||q.length<=promptOpts.fimSuffixLengthThreshold)
returnpromptWishlist.fulfill(promptOpts.maxPromptLength);

当然,经过咱们上面的剖析suffixPercent在当时版别的默许值是15,不为0,会走到suffix的逻辑。

不过咱们可以先看一下fullfill的处理,suffix逻辑咱们就不剖析了:

fulfill(maxPromptLength){
constpromptChoices=newPromptChoices();
constpromptBackground=newPromptBackground();
constelements=this.content.map((e,t)=>({
element:e,
index:t,
}));
elements.sort((e,t)=>
e.element.priority===t.element.priority
?t.index-e.index
:t.element.priority-e.element.priority
);
constrequires=newSet();
constexcludes=newSet();
letlastElement;
constresults=[];
letpromptLength=maxPromptLength;
elements.forEach((e)=>{
constelement=e.element;
constindex=e.index;
if(
promptLength>=0&&
(promptLength>0||void0===lastElement)&&
element.requires.every((e)=>requires.has(e.id))&&
!excludes.has(r.id)
){
lettokens=element.tokens;
constnextElement=(function(e,t){
letn;
letr=1/0;
for(constiofe)
if(i.index>t&&i.index<r){
n=i;
r=i.index;
}
returnn;
})(results,index)?.element;
if(element.text.endsWith("\n\n")&&nextElement&&!nextElement.text.match(/^\s/)){
tokens++;
}
if(promptLength>=tokens){
promptLength-=tokens;
requires.add(r.id);
element.excludes.forEach((e)=>excludes.add(e.id));
promptChoices.markUsed(element);
promptBackground.markUsed(element);
results.push(e);
}else{
if(void0===lastElement){
lastElement=e;
}else{
promptChoices.markUnused(e.element);
promptBackground.markUnused(e.element);
}
}
}else{
promptChoices.markUnused(element);
promptBackground.markUnused(element);
}
});
results.sort((e,t)=>e.index-t.index);
letprefix=results.reduce((e,t)=>e+t.element.text,"");
letprefixLength=this.tokenizer.tokenLength(prefix);
for(;prefixLength>maxPromptLength;){
u.sort((e,t)=>
t.element.priority===e.element.priority
?t.index-e.index
:t.element.priority-e.element.priority
);
conste=u.pop();
if(e){
promptChoices.undoMarkUsed(e.element);
promptChoices.markUnused(e.element);
promptBackground.undoMarkUsed(e.element);
promptBackground.markUnused(e.element);
if(void0!==lastElement){
promptChoices.markUnused(lastElement.element);
promptBackground.markUnused(lastElement.element);
}
lastElement=void0;
}
u.sort((e,t)=>e.index-t.index);
prefix=u.reduce((e,t)=>e+t.element.text,"");
prefixLength=this.tokenizer.tokenLength(prefix);
}
constf=[...u];
if(void0!==lastElement){
f.push(lastElement);
f.sort((e,t)=>e.index-t.index);
constprefix=f.reduce((e,t)=>e+t.element.text,"");
constprefixLength=this.tokenizer.tokenLength(prefix);
if(prefixLength<=maxPromptLength){
promptChoices.markUsed(l.element);
promptBackground.markUsed(l.element);
constpromptElementRanges=newPromptElementRanges(f);
return{
prefix:prefix,
suffix:"",
prefixLength:prefixLength,
suffixLength:0,
promptChoices:promptChoices,
promptBackground:promptBackground,
promptElementRanges:promptElementRanges,
};
}
promptChoices.markUnused(l.element);
promptBackground.markUnused(l.element);
}
constm=newPromptElementRanges(u);
return{
prefix:prefix,
suffix:"",
prefixLength:prefixLength,
suffixLength:0,
promptChoices:promptChoices,
promptBackground:promptBackground,
promptElementRanges:m,
};
}

这个fullfill逻辑中心有两点:

  • 首要依照Priority排序(Priority相同按index),处理文本内容,这就意味着,在有限的Token下,Priority越高的文本越能被确保。
  • 输出的时分,是依照index排序的,也便是说Priority只用作处理文本的优先级,终究组合的prefix文本的次序是依照刺进wishList的先后次序的。

所以咱们依据前面的剖析,可以看到文本优先级是这样的:

  • languageMarker
  • pathMarkder
  • importedFile
  • Snippet
  • beforeCursor

而处理优先级是这样的(优先确保的内容):

  • beforeCursor
  • importedFile
  • Snippet
  • pathMarkder
  • languageMarker

Prompt组成的图示

prompt从代码上看比较杂乱,咱们整体把prefix的组成画一下做个总结:

花了大半个月,我终于逆向分析了Github Copilot

image

抓包试验一下

咱们找个TS文件来试试:

花了大半个月,我终于逆向分析了Github Copilot

image

可以看到,在Copilot建议的恳求中,prompt包含了Path Marker和BeforeCursor两个部分,这也是咱们在使用进程中绝大多数的面临场景。

假如代码相关性够高,可以看到snippet的部分,比方咱们拷贝了一个简略的文件:

花了大半个月,我终于逆向分析了Github Copilot

image

这个时分就会生成对应的snippet:

花了大半个月,我终于逆向分析了Github Copilot

image

小结

从Copilot中,咱们可以了解到值得学习的几个中心思维:

  • 关于编辑器输入的鸿沟判别,包含太少、太多、取消等等许多场景完全的考虑
  • 缓存思维,使用多级缓存战略维护后台,模型运算自身便是一件贵重的工作
  • prompt的设计,不仅仅包含了上下文代码,在文件解析、编辑器翻开的相关代码上还做了许多
  • 使用简略的Jaccard算法核算分词后的文本类似度,可以快速决策出当时上下文相关的snippet
  • 试验特性,在Copilot中,许多的参数、优先级、设置字段都是经过试验来控制的,有一套完好的监控上报系统,协助Copilot去调整这些参数,以达到更好的效果

Copilot的逻辑比我幻想中要杂乱许多,逆向剖析难度就更高了。耗费了许多时间在解析作业上,本文相关的东西链和代码都已上传Github,期望可以给一些有需求的同学供给协助:

github.com/mengjian-gi…