2023 年,生成式AI 的火爆,讓越來越多的組織開始引入AI 輔助編碼。與在2021 年發布的GitHub Copilot 略有差異的是,程式碼補全只是眾多場景中的一個。 大量的企業內部在探索結合需求產生完整程式碼、程式碼審查等場景,也引進生成式AI,來提升開發效率。
在這個背景下,我們(Thoughtworks 開源社群)也開源了一系列的AI 輔助工具,以幫助更多的組織建立自己的AI 輔助編碼助理:
由於,當我們設計AutoDev 時,各類開源模型也不斷演進。在這個背景下,它的步驟是:
也因此,這個教程也是圍繞著這三個步驟展開的。 除此,基於我們的經驗,本教程的範例技術堆疊:
由於,我們在AI 方面的經驗相對比較有限,難免會有一些錯誤,所以,我們也希望能夠與更多的開發者一起,來建構這個開源專案。
結合JetBrains 2023《開發者生態系統》報告的人工智慧部分,我們可以總結出一些通用的場景,這些場景反映了在開發過程中生成式AI 可以發揮作用的領域。以下是一些主要的場景:
而在我們建構AutoDev 時,也發現了諸如於創建SQL DDL、生成需求、TDD 等場景。所以。我們提供了自訂場景的能力,讓開發者可以自訂自己的AI 能力,詳細見:https://ide.unitmesh.cc/customize。
在日常編碼時,會存在幾類不同場景,對於AI 響應速度的要求也是不同的(僅作為示例):
場景 | 反應速度 | 生成品質要求 | 大小預期 | 說明 |
---|---|---|---|---|
程式碼補全 | 快 | 中 | 1~6B | 程式碼補全是日常編碼中最常用的場景,反應速度至關重要。 |
文件生成 | 中 | 中 | 1 | 文件產生需要充分理解程式碼結構,速度和品質同樣重要。 |
程式碼審查 | 快 | 中 | 1 | 程式碼審查需要高品質的建議,同時回應速度也需盡可能快。 |
單元測試生成 | 快 | 中 | 6B~ | 單元測試產生的上下文較少,反應速度和AI品質同樣重要。 |
程式碼重構 | 中 | 高 | 32B~ | 程式碼重構可能需要更多上下文理解,反應速度可適度減緩。 |
需求生成 | 中 | 高 | 32B~ | 需求產生是相對複雜的場景,反應速度可以適度放緩,確保準確性。 |
自然語言代碼搜尋與解釋 | 中-低 | 高 | 32B~ | 自然語言代碼搜尋與解釋是相對複雜的場景,反應速度可以適度放緩,確保準確性。 |
PS:這裡的32B 僅作為一個量級表示,因為在更大的模型下,效果會更好。
因此,我們將其總結為:一大一中一微三模型,提供全面AI 輔助編碼:
AI 程式碼補全能結合IDE 工具分析程式碼上下文和程式語言的規則,由AI 自動產生或建議程式碼片段。在類似GitHub Copilot 的程式碼補全工具中, 通常會分為三種細分模式:
行內補全(Inline)
類似FIM(fill in the middle)的模式,補全的內容在目前行中。諸如: BlotPost blogpost = new
,補全為: BlogPost();
, 以實現: BlogPost blogpost = new BlogPost();
。
我們可以Deepseek Coder 作為例子,看在這個場景下的效果:
< |fim▁begin| > def quick_sort(arr):
if len(arr) < = 1:
return arr
pivot = arr[0]
left = []
right = []
< |fim▁hole| >
if arr[i] < pivot:
left.append(arr[i])
else:
right.append(arr[i])
return quick_sort(left) + [pivot] + quick_sort(right) < |fim▁end| >
在這裡,我們就需要結合遊標前和遊標後的程式碼。
區塊內補全(InBlock)
透過上下文學習(In-Context Learning)來實現,補全的內容在目前函數區塊中。諸如於,原始的程式碼是:
fun createBlog ( blogDto : CreateBlogDto ): BlogPost {
}
補全的代碼為:
val blogPost = BlogPost (
title = blogDto.title,
content = blogDto.content,
author = blogDto.author
)
return blogRepository.save(blogPost)
塊間補全(AfterBlock)
透過上下文學習(In-Context Learning)來實現,在目前函數區塊之後補全,如:在目前函數區塊之後補全一個新的函數。諸如於,原始的程式碼是:
fun createBlog ( blogDto : CreateBlogDto ): BlogPost {
// ...
}
補全的代碼為:
fun updateBlog ( id : Long , blogDto : CreateBlogDto ): BlogPost {
// ...
}
fun deleteBlog ( id : Long ) {
// ...
}
當我們建立對應的AI 補全功能時,也需要考慮應用到對應的模式資料集,以提升補全的質量,提供更好的使用者體驗。
撰寫本文裡的一些相關資源:
程式碼解釋旨在幫助開發者更有效地管理和理解大型程式碼庫。這些助手能夠回答關於程式碼庫的問題、 提供文件、搜尋程式碼、識別錯誤來源、減少程式碼重複等, 從而提高開發效率、降低錯誤率,並減輕開發者的工作負擔。
在這個場景下,取決於我們預期的生成質量,通常會由一大一微或一中一微兩個模型組成,更大的模型在生成的質量上結果更好。結合,我們在Chocolate Factory 工具中的設計經驗,通常這樣的功能可以分成幾個步驟:
作為一個RAG 應用,其分為indexing 和query 兩個部分。
在indexing 階段,我們需要將程式碼庫進行索引,並涉及文字分割、向量化、資料庫索引等技術。 其中最有挑戰的內容是拆分,我們參考的折分規則是:https://docs.sweep.dev/blogs/chunking-2m-files 。即:
在不同的場景下,我們也可以透過不同的方式進行折分,如在Chocolate Factory 是透過AST 進行折分,以確保產生上下文的品質。
在querying 階段,需要結合我們一些傳統的搜尋技術,如:向量化搜尋、路徑搜尋等,以確保搜尋的品質。同時,在中文場景下,我們也需要考慮到轉換為中文的問題,例如:將英文轉換為中文,以確保搜尋的品質。
對於日常輔助來說,我們也可以透過生成式AI 來實現,例如:自動建立SQL DDL、自動建立測試案例、自動建立需求等。這些只需要透過自訂提示詞, 結合特定的領域知識,便可以實現,這裡不再贅述。
除了模型之外,情境也是影響AI 輔助能力的重要因素。當我們建構AutoDev 時,我們也發現了兩種不同的上下文模式:
簡單對比如下:
相關上下文 | 相似上下文 | |
---|---|---|
檢索技術 | 靜態程式碼分析 | 相似式搜尋 |
資料結構資訊 | AST、CFG | Similar Chunk |
跨平台能力 | 依賴IDE,或獨立的解析器 | 不依賴具體平台 |
上下文品質 | 極高 | 高 |
產生結果 | 極高 | 高 |
建構成本 | 依賴語言、平台 | 低 |
在支援IDE 有限時,相關上下文的才會帶來更高的性價高。
GitHub Copilot 採用了相似上下文的架構模式,其精簡的架構分層如下:
在「公開」 的Copilot-Explorer 計畫的研究資料裡,可以看到Prompt 是如何建構出來的。如下是發送到的prompt 請求:
{
"prefix" : " # Path: codeviz \ app.py 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
}
]
}
其中:
RetrievalSnippet
,其中包含了: BeforeCursor
, prefix
, SimilarFile
, ImportedFile
, LanguageMarker
, AfterCursor
, PathMarker
等類型。從幾種PromptElementKind
的名稱,我們也可以看出其真正的意義。suffix
部分,則是由遊標所在的部分決定的,根據tokens 的上限(2048 )去計算還有多少位置放下。而這裡的Token 計算則是真正的LLM 的token 計算,在Copilot 裡是透過Cushman002 計算的,諸如於中文的字符的token 長度是不一樣的,如: { context: "console.log('你好,世界')", lineCount: 1, tokenLength: 30 }
,其中context 中的內容的length 為20,但是tokenLength是30,中文字元共5 個(包含,
)的長度,單一字元佔的token 就是3。如下是一個更詳細的Java 應用的上下文範例:
// Path: src/main/cc/unitmesh/demo/infrastructure/repositories/ProductRepository.java
// Compare this snippet from src/main/cc/unitmesh/demo/domain/product/Product.java:
// ....
// Compare this snippet from src/main/cc/unitmesh/demo/application/ProductService.java:
// ...
// @Component
// public class ProductService {
// //...
// }
//
package cc . unitmesh . demo . repositories ;
// ...
@ Component
public class ProductRepository {
//...
在計算上下文裡,GitHub Copilot 採用的是Jaccard 係數(Jaccard Similarity) ,這部分的實現是在Agent 實現,更詳細的邏輯可以參考: 花了大半個月,我終於逆向分析了Github Copilot。
相關資源:
如上所述,相關程式碼依賴靜態程式碼分析,主要藉助程式碼的結構訊息,如:AST、CFG、DDG 等。在不同的場景和平台之下,我們可以結合不同的靜態程式碼分析工具, 如下是常見的一些靜態程式碼分析工具:
在補全場景下,透過靜態程式碼分析,我們可以得到目前的上下文,如:目前的函數、目前的類別、目前的檔案等。如下是一個AutoDev 的生成單元測試的上下文範例:
// here are related classes:
// 'filePath: /Users/phodal/IdeaProjects/untitled/src/main/java/cc/unitmesh/untitled/demo/service/BlogService.java
// class BlogService {
// blogRepository
// + public BlogPost createBlog(BlogPost blogDto)
// + public BlogPost getBlogById(Long id)
// + public BlogPost updateBlog(Long id, BlogPost blogDto)
// + public void deleteBlog(Long id)
// }
// 'filePath: /Users/phodal/IdeaProjects/untitled/src/main/java/cc/unitmesh/untitled/demo/dto/CreateBlogRequest.java
// class CreateBlogRequest ...
// 'filePath: /Users/phodal/IdeaProjects/untitled/src/main/java/cc/unitmesh/untitled/demo/entity/BlogPost.java
// class BlogPost {...
@ ApiOperation ( value = "Create a new blog" )
@ PostMapping ( "/" )
public BlogPost createBlog ( @ RequestBody CreateBlogRequest request ) {
在這個範例中,會分析createBlog
函數的上下文,取得函數的輸入和輸出類別: CreateBlogRequest
、 BlogPost
訊息,以及BlogService 類別訊息,作為上下文(在註解中提供)提供給模型。在這時,模型會產生更準確的建構函數,以及更準確的測試案例。
由於相關上下文依賴不同語言的靜態程式碼分析、不同IDE 的API,所以,我們也需要針對不同的語言、不同的IDE 進行適配。在建置成本上,相對於相似上下文成本更高。
IDE、編輯器作為開發者的主要工具,其設計和學習成本也相對比較高。首先,我們可以用官方提供的模板產生:
然後,再往上加入功能(是不是很簡單),當然不是。以下是一些可以參考的IDEA 插件資源:
當然了,更適合的是參考AutoDev 插件。
可以直接使用官方的模板來產生對應的插件:https://github.com/JetBrains/intellij-platform-plugin-template
對於IDEA 外掛實作來說,主要是透過Action 和Listener 來實現的,只需要在plugin.xml
中註冊即可。 詳細可以參考官方文件:IntelliJ Platform Plugin SDK
由於我們前期未AutoDev 考慮到對IDE 版本的相容問題,後期為了相容於舊版的IDE,我們需要對插件進行相容性處理。所以,如官方文件:Build Number Ranges 中所描述,我們可以看到不同版本,對於JDK 的要求是不一樣的,如下是不同版本的要求:
Branch number | IntelliJ Platform version |
---|---|
233 | 2023.3 |
232 | 2023.2 |
231 | 2023.1 |
223 | 2022.3 |
222 | 2022.2 NOTE Java 17 is now required (blog post) |
221 | 2022.1 |
213 | 2021.3 |
212 | 2021.2 |
211 | 2021.1 |
203 | 2020.3 NOTE Java 11 is now required (blog post) |
並配置到gradle.properties
:
pluginSinceBuild = 223
pluginUntilBuild = 233.*
後續配置相容性比較麻煩,可以參考AutoDev 的設計。
在自動程式碼補全上,國內的廠商主要參考的是GitHub Copilot 的實現,邏輯也不複雜。
採用快捷鍵方式觸發
其主要是在Action 裡監聽使用者的輸入,然後:
功能 | 快速鍵 | 說明 |
---|---|---|
requestCompletions | Alt + / | 取得目前的上下文,然後透過模型取得補全結果 |
applyInlays | TAB | 將補全結果展示在IDE 上 |
disposeInlays | ESC | 取消補全 |
cycleNextInlays | Alt + ] | 切換到下一個補全結果 |
cyclePrevInlays | Alt + [ | 切換到上一個補全結果 |
採用自動觸發方式
其主要透過EditorFactoryListener
監聽使用者的輸入,然後:根據不同的輸入,觸發不同的補全結果。核心程式碼如下:
class AutoDevEditorListener : EditorFactoryListener {
override fun editorCreated ( event : EditorFactoryEvent ) {
// ...
editor.document.addDocumentListener( AutoDevDocumentListener (editor), editorDisposable)
editor.caretModel.addCaretListener( AutoDevCaretListener (editor), editorDisposable)
// ...
}
class AutoDevCaretListener ( val editor : Editor ) : CaretListener {
override fun caretPositionChanged ( event : CaretEvent ) {
// ...
val wasTypeOver = TypeOverHandler .getPendingTypeOverAndReset(editor)
// ...
llmInlayManager.disposeInlays(editor, InlayDisposeContext . CaretChange )
}
}
class AutoDevDocumentListener ( val editor : Editor ) : BulkAwareDocumentListener {
override fun documentChangedNonBulk ( event : DocumentEvent ) {
// ...
val llmInlayManager = LLMInlayManager .getInstance()
llmInlayManager
.editorModified(editor, changeOffset)
}
}
}
再根據不同的輸入,觸發不同的補全結果,並對結構進行處理。
渲染補全程式碼
隨後,我們需要實作一個Inlay Render,它繼承自EditorCustomElementRenderer
。
結合IDE 的介面能力,我們需要加入對應的Action,以及對應的Group,以及對應的Icon。如下是一個Action 的範例:
<add-to-group group-id="ShowIntentionsGroup" relative-to-action="ShowIntentionActions" anchor="after"/>
如下是AutoDev 的一些ActionGroup:
Group ID | AI 用途 | Description |
---|---|---|
ShowIntentionsGroup | 程式碼重構、程式碼解釋、程式碼產生、程式碼測試 | 用於在程式碼上下文中顯示提示,以及透過Alt + Enter 和macOS 上的⌥ + Enter 快速鍵來存取。 |
ConsoleEditorPopupMenu | 修復錯誤 | 在控制台中顯示的選單,如程式運行結構的控制台。 |
Vcs.MessageActionGroup | 代碼資訊生成 | 用於在VCS 中編寫提交資訊的選單。 |
Vcs.Log.ContextMenu | 程式碼審查、程式碼解釋、程式碼生成 | 用於在VCS 中查看日誌的選單,可實現的功能:AI 檢視程式碼、產生發布日誌。 |
EditorPopupMenu | 皆可 | 右鍵選單,也可新增對應的ActionGroup |
在編寫ShowIntentionsGroup 時,我們可以參考AutoDev 的實作來建構對應的Group:
< group id = " AutoDevIntentionsActionGroup " class = " cc.unitmesh.devti.intentions.IntentionsActionGroup "
icon = " cc.unitmesh.devti.AutoDevIcons.AI_COPILOT " searchable = " false " >
< add-to-group group-id = " ShowIntentionsGroup " relative-to-action = " ShowIntentionActions " anchor = " after " />
</ group >
由於Intellij 的平台策略,使得運行於Java IDE(Intellij IDEA)與在其它IDE 如Python IDE(Pycharm)之間的差異性變得更大。我們需要提供基於多平台產品的相容性,詳細介紹可以參考:Plugin Compatibility with IntelliJ Platform Products
首先,將插件的架構進一步模組化,也就是針對不同的語言,提供不同的模組。如下是AutoDev 的模組化架構:
java/ # Java 语言插件
src/main/java/cc/unitmesh/autodev/ # Java 语言入口
src/main/resources/META-INF/plugin.xml
plugin/ # 多平台入口
src/main/resources/META-INF/plugin.xml
src/ # 即核心模块
main/resource/META-INF/core.plugin.xml
在plugin/plugin.xml
中,我們需要加入對應的depends
,以及extensions
,如下是一個範例:
< idea-plugin package = " cc.unitmesh " xmlns : xi = " http://www.w3.org/2001/XInclude " allow-bundled-update = " true " >
< xi : include href = " /META-INF/core.xml " xpointer = " xpointer(/idea-plugin/*) " />
< content >
< module name = " cc.unitmesh.java " />
<!-- 其它模块 -->
</ content >
</ idea-plugin >
而在java/plugin.xml
中,我們需要加入對應的depends
,以及extensions
,如下是一個範例:
< idea-plugin package = " cc.unitmesh.java " >
<!-- suppress PluginXmlValidity -->
< dependencies >
< plugin id = " com.intellij.modules.java " />
< plugin id = " org.jetbrains.plugins.gradle " />
</ dependencies >
</ idea-plugin >
隨後,Intellij 會自動載入對應的模組,以實現多語言的支援。根據我們預期支援的不同語言,便需要對應的plugin.xml
,諸如於:
cc.unitmesh.javascript.xml
cc.unitmesh.rust.xml
cc.unitmesh.python.xml
cc.unitmesh.kotlin.xml
cc.unitmesh.java.xml
cc.unitmesh.go.xml
cc.unitmesh.cpp.xml
最後,在不同的語言模組裡,實現對應的功能即可。
為了簡化這個過程,我們使用Unit Eval 來展示如何建構兩種類似的上下文。
透過靜態程式碼分析,我們可以得到目前的函數、目前的類別、目前的檔案等。再結合路徑相似性,尋找最貼進的上下文。
private fun findRelatedCode ( container : CodeContainer ): List < CodeDataStruct > {
// 1. collects all similar data structure by imports if exists in a file tree
val byImports = container. Imports
.mapNotNull {
context.fileTree[it. Source ]?.container?. DataStructures
}
.flatten()
// 2. collects by inheritance tree for some node in the same package
val byInheritance = container. DataStructures
.map {
(it. Implements + it. Extend ).mapNotNull { i ->
context.fileTree[i]?.container?. DataStructures
}.flatten()
}
.flatten()
val related = (byImports + byInheritance).distinctBy { it. NodeName }
// 3. convert all similar data structure to uml
return related
}
class RelatedCodeStrategyBuilder ( private val context : JobContext ) : CodeStrategyBuilder {
override fun build (): List < TypedIns > {
// ...
val findRelatedCodeDs = findRelatedCode(container)
val relatedCodePath = findRelatedCodeDs.map { it. FilePath }
val jaccardSimilarity = SimilarChunker .pathLevelJaccardSimilarity(relatedCodePath, currentPath)
val relatedCode = jaccardSimilarity.mapIndexed { index, d ->
findRelatedCodeDs[index] to d
}.sortedByDescending {
it.second
}.take( 3 ).map {
it.first
}
// ...
}
}
上述的程式碼,我們可以透過程式碼的Imports 資訊作為相關程式碼的一部分。再透過程式碼的繼承關係,來尋找相關的程式碼。最後,透過再路徑相似性,來尋找最貼近的脈絡。
先尋找,再透過程式碼相似性,來尋找相關的程式碼。核心邏輯所示:
fun pathLevelJaccardSimilarity ( chunks : List < String >, text : String ): List < Double > {
// ...
}
fun tokenize ( chunk : String ): List < String > {
return chunk.split( Regex ( " [^a-zA-Z0-9] " )).filter { it.isNotBlank() }
}
fun similarityScore ( set1 : Set < String >, set2 : Set < String >): Double {
// ...
}
詳細見:SimilarChunker
TODO
TreeSitter 是一個用於產生高效的自訂語法分析器的框架,由GitHub 開發。它使用LR(1)解析器,這意味著它可以在O(n)時間內解析任何語言,而不是O(n²)時間。它還使用了一種稱為「語法樹的重用」的技術,該技術使其能夠在不重新解析整個文件的情況下更新語法樹。
由於TreeSitter 已經提供了多語言的支持,你可以使用Node.js、Rust 等語言來建立對應的插件。詳細見:TreeSitter。
根據我們的意圖不同,使用TreeSitter 也有不同的方式:
解析Symbol
在程式碼自然語言搜尋引擎Bloop 中,我們使用TreeSitter 來解析Symbol,以實現更好的搜尋品質。
; ; methods
(method_declaration
name: (identifier) @hoist.definition.method)
隨後,根據不同的類型來決定如何顯示:
pub static JAVA : TSLanguageConfig = TSLanguageConfig {
language_ids : & [ "Java" ] ,
file_extensions : & [ "java" ] ,
grammar : tree_sitter_java :: language ,
scope_query : MemoizedQuery :: new ( include_str ! ( "./scopes.scm" ) ) ,
hoverable_query : MemoizedQuery :: new (
r#"
[(identifier)
(type_identifier)] @hoverable
"# ,
) ,
namespaces : & [ & [
// variables
"local" ,
// functions
"method" ,
// namespacing, modules
"package" ,
"module" ,
// types
"class" ,
"enum" ,
"enumConstant" ,
"record" ,
"interface" ,
"typedef" ,
// misc.
"label" ,
] ] ,
} ;
Chunk 代碼
如下是Improving LlamaIndex's Code Chunker by Cleaning Tree-Sitter CSTs 中的TreeSitter 的使用方式:
from tree_sitter import Tree
def chunker (
tree : Tree ,
source_code : bytes ,
MAX_CHARS = 512 * 3 ,
coalesce = 50 # Any chunk less than 50 characters long gets coalesced with the next chunk
) -> list [ Span ]:
# 1. Recursively form chunks based on the last post (https://docs.sweep.dev/blogs/chunking-2m-files)
def chunk_node ( node : Node ) -> list [ Span ]:
chunks : list [ Span ] = []
current_chunk : Span = Span ( node . start_byte , node . start_byte )
node_children = node . children
for child in node_children :
if child . end_byte - child . start_byte > MAX_CHARS :
chunks . append ( current_chunk )
current_chunk = Span ( child . end_byte , child . end_byte )
chunks . extend ( chunk_node ( child ))
elif child . end_byte - child . start_byte + len ( current_chunk ) > MAX_CHARS :
chunks . append ( current_chunk )
current_chunk = Span ( child . start_byte , child . end_byte )
else :
current_chunk += Span ( child . start_byte , child . end_byte )
chunks . append ( current_chunk )
return chunks
chunks = chunk_node ( tree . root_node )
# 2. Filling in the gaps
for prev , curr in zip ( chunks [: - 1 ], chunks [ 1 :]):
prev . end = curr . start
curr . start = tree . root_node . end_byte
# 3. Combining small chunks with bigger ones
new_chunks = []
current_chunk = Span ( 0 , 0 )
for chunk in chunks :
current_chunk += chunk
if non_whitespace_len ( current_chunk . extract ( source_code )) > coalesce