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.pyn#....",
"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
}
]
}
其中:
prefix
部分,是由 promptElements
构建了,其中包含了:BeforeCursor
, AfterCursor
, SimilarFile
, ImportedFile
, LanguageMarker
, PathMarker
, RetrievalSnippet
等类型。从几种 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