类似Cursor、Cline、Trae这样的AI IDE已经成为程序员日常开发的标配。
对待AI IDE,一种常见的误区是认为他们的核心流程很复杂。
实际上,考虑到执行效率,通常Agent的设计并不复杂,更多工作量主要来自:
如何保证流程稳定可控(LLM输出的不稳定造成)与性能优化
如何节约上下文空间
......
Cursor的编辑代码功能,就蕴含了节约上下文空间的巧思。
Cursor核心原理
Cursor的核心部分其实是个简单的Agent:
用户的需求给到Cursor后,他会思考要完成任务需要使用哪些内部工具?
使用具体工具后,结合工具调用结果继续思考下一步应该使用什么工具。
直到最终任务结束。
其内部通过Tool_Use(对于openAI旗下模型,就是Function_Call)字段定义了如下10个工具:
codebase_search:语义搜索工具
read_file:读取文件内容(或大纲)
run_terminal_cmd:代表用户在终端执行命令
list_dir:快速列出目录内容
grep_search:基于正则表达式的快速文本搜索
edit_file:编辑文件内容
file_search:基于模糊匹配的快速文件路径搜索
delete_file:删除指定路径的文件
reapply:在编辑结果不符合预期时,调用更智能的模型重新应用最后一次文件编辑
diff_history:查看近期文件修改历史
一个例子
假设我们的需求是用antd的Button组件替换页面中的主按钮
一种可能的执行过程是:
执行run_terminal_cmd,检查antd是否已安装,若未安装需后续处理,参数如下:
{
"command": "npm list antd || echo 'antd not installed'",
"require_user_approval": false,
"is_background": false,
"explanation": "检查antd是否已安装,若未安装需后续处理。"
}
执行grep_search,查找包含主按钮的代码位置,确定需要替换的文件,参数如下:
{
"query": "主按钮|main-button|primary",
"include_pattern": "*.jsx|*.tsx|*.js",
"case_sensitive": false,
"explanation": "查找包含主按钮的代码位置,确定需要替换的文件。"
}
根据上一步grep_search返回的目标文件,执行read_file,确认按钮的上下文代码(如样式、事件处理等),参数如下:
{
"target_file": "src/components/ExamplePage.jsx",
"start_line_one_indexed": 1,
"end_line_one_indexed_inclusive": 50,
"should_read_entire_file": false,
"explanation": "读取包含主按钮的文件内容,分析按钮的属性和上下文。"
}
执行edit_file,将原生按钮或旧组件替换为antd Button,并保留原有逻辑(如onClick),参数如下:
{
"target_file": "src/components/ExamplePage.jsx",
"instructions": "将原生按钮替换为antd的Button组件,保留原有样式和事件。",
"code_edit": "// ... existing code ...\nimport { Button } from 'antd';\n// ... existing code ...\n<Button type=\"primary\">主按钮</Button>\n// ... existing code ..."
}
由于LLM生成的随机性,完整的工具调用并不一定按上述步骤。
比如在“搜索主按钮的位置”时,也可能先使用list_dir工具,列出文件目录,再从文件名判断哪个文件可能与“主按钮”相关。
为了让LLM记得之前的执行步骤,以及接下来要做什么,这些必要的信息都会存在于模型上下文中:
由于模型上下文有限,Cursor会在多个层面做上下文长度优化,比如:
默认情况下,Agent只会执行20轮步骤
read_file一次最多只会读取200行代码,不够的话再继续读200行
本文要讲的edit_file工具就是上下文长度优化的表率。
edit_file的实现原理
如果说read_file已经够占用上下文了,那么未经优化的情况下,edit_file占用的上下文应该在read_file的两倍左右。
毕竟,要想修改文件,你得同时知道:
原始文件是什么样
要修改成什么样
所以,有别于其他工具的实现原理就是单次命令执行(比如list_dir对应ls),edit_file是一个独立的AI workflow。
他包含至少3个步骤,涉及至少2次模型调用:
读文件
生成编辑方案(使用先进的模型)
执行编辑方案(使用小参数模型)
(可选)如果编辑方案不理想,用先进模型再执行一次(使用reapply工具)
举个例子,假设用户需求是在 src/utils/math.ts 文件中添加一个计算斐波那契数列的函数
第一步,获取文件路径,读取内容。
假设内容如下:
// 数学工具函数集合
/**
* 计算两个数的和
*/
export function add(a: number, b: number): number {
return a + b;
}
/**
* 计算两个数的差
*/
export function subtract(a: number, b: number): number {
return a - b;
}
第二步,首先准备编辑方案:
{
"content": "上面读取到的原始代码",
"query": "添加一个计算斐波那契数列的函数",
"path": "src/utils/math.ts",
"is_new": false
}
将上述方案发送给智能的模型(比如Claude 3.5及以上)。
模型返回如下内容:
// 数学工具函数集合
// ... existing code ...
/**
* 计算斐波那契数列的第n个数
*/
export function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
注意,其中原始内容中未修改的部分被注释 // ... existing code ... 代替了。
这种方式的好处是:可以显著减少上下文空间占用。
但也有缺点:没法通过上述信息直接还原编辑后的代码,得通过模型的能力还原。
所以,第三步,将上述信息一齐给到模型,将注释替换为原始代码:
{
"content": "读取到的原始代码",
"query": "添加一个计算斐波那契数列的函数",
"path": "src/utils/math.ts",
"is_new": false,
"code_edit": "上述代码编辑信息"
}
模型返回:
// 数学工具函数集合
/**
* 计算两个数的和
*/
export function add(a: number, b: number): number {
return a + b;
}
/**
* 计算两个数的差
*/
export function subtract(a: number, b: number): number {
return a - b;
}
/**
* 计算斐波那契数列的第n个数
*/
export function fibonacci(n: number): number {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
由于这一步逻辑不复杂,且对模型生成速度要求较高,所以通常交给微调过的小参数模型执行。
如果模型的执行效果不好(比如在一个大文件中一次性修改多处),还能使用reapply工具使用更智能的模型再执行一遍。
总结
Cursor的核心逻辑是一个简单的Agent,包含10个可调用的内部工具。
其中,大部分工具的实现原理是简单的命令执行。
而edit_file工具是一条涉及3个步骤的AI Workflow,中间涉及到注释的替换。
之所以这么做,是为了节约模型上下文空间。