Cursor编辑代码功能是如何实现的?

2,756 阅读6分钟

大家好,我卡颂,专注于AI助力程序员转型阅读我的更多思考

类似CursorClineTrae这样的AI IDE已经成为程序员日常开发的标配。

对待AI IDE,一种常见的误区是认为他们的核心流程很复杂。

实际上,考虑到执行效率,通常Agent的设计并不复杂,更多工作量主要来自:

  • 如何保证流程稳定可控(LLM输出的不稳定造成)与性能优化

  • 如何节约上下文空间

......

Cursor编辑代码功能,就蕴含了节约上下文空间的巧思。

Cursor核心原理

Cursor的核心部分其实是个简单的Agent

用户的需求给到Cursor后,他会思考要完成任务需要使用哪些内部工具

使用具体工具后,结合工具调用结果继续思考下一步应该使用什么工具。

直到最终任务结束。

其内部通过Tool_Use(对于openAI旗下模型,就是Function_Call)字段定义了如下10个工具:

  1. codebase_search:语义搜索工具

  2. read_file:读取文件内容(或大纲)

  3. run_terminal_cmd:代表用户在终端执行命令

  4. list_dir:快速列出目录内容

  5. grep_search:基于正则表达式的快速文本搜索

  6. edit_file:编辑文件内容

  7. file_search:基于模糊匹配的快速文件路径搜索

  8. delete_file:删除指定路径的文件

  9. reapply:在编辑结果不符合预期时,调用更智能的模型重新应用最后一次文件编辑

  10. diff_history:查看近期文件修改历史

一个例子

假设我们的需求是用antd的Button组件替换页面中的主按钮

一种可能的执行过程是:

  1. 执行run_terminal_cmd,检查antd是否已安装,若未安装需后续处理,参数如下:
{
  "command": "npm list antd || echo 'antd not installed'",
  "require_user_approval": false,
  "is_background": false,
  "explanation": "检查antd是否已安装,若未安装需后续处理。"
}
  1. 执行grep_search,查找包含主按钮的代码位置,确定需要替换的文件,参数如下:
{
  "query": "主按钮|main-button|primary",
  "include_pattern": "*.jsx|*.tsx|*.js",
  "case_sensitive": false,
  "explanation": "查找包含主按钮的代码位置,确定需要替换的文件。"
}
  1. 根据上一步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": "读取包含主按钮的文件内容,分析按钮的属性和上下文。"
}
  1. 执行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次模型调用:

  1. 读文件

  2. 生成编辑方案(使用先进的模型)

  3. 执行编辑方案(使用小参数模型)

  4. (可选)如果编辑方案不理想,用先进模型再执行一次(使用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,中间涉及到注释的替换。

之所以这么做,是为了节约模型上下文空间。