手搓一个迷你版 Cursor:从零实现能自动写代码的编程 Agent

我一直没搞明白那些编程 Agent 到底是怎么自己创建项目、改代码、跑命令的。Cursor 点一下就能生成整个项目,Claude Code 还能边跑边改 bug,看着特别神奇。直到这周硬着头皮写了个百来行的迷你版本,才算是把整个流程摸透了。

说出来你可能不信,核心逻辑其实就一个循环。剩下的全是细节坑。

先把最核心的问题想明白:大模型为啥需要 Tool?

说实话最开始我对 Tool 这个概念挺模糊的。不就是大模型吗,怎么还需要调用工具?

后来才反应过来,大模型本质上就是个纯文本生成器。它只会对着输入吐文字,既碰不到你电脑上的文件,也敲不了命令行,更打不开浏览器。你让它写个 React 项目,它能把代码给你列出来,但没法直接帮你存到本地、安装依赖、启动服务 ------ 它没有 "手"。

Tool 是啥?就是给大模型接了双手。

你提前定义好一批工具,比如读文件、写文件、执行命令,然后告诉大模型:"你有这些工具可以用,需要的时候直接调。" 大模型生成内容的时候,如果觉得需要动手,就不会直接给答案,而是返回一段工具调用的指令。你的程序接住这个指令,执行完把结果再扔回去,大模型根据结果继续思考下一步。

就这么个来回传话的过程,就是 Agent 的雏形。

打个不太恰当的比方,大模型就像坐在办公室里的产品经理,只会动嘴;工具就是研发工程师,能真正干活。产品经理说要做个按钮,研发去写代码,写完告诉产品经理做好了,产品经理再看下一步做啥。只不过这个产品经理反应特别快,一秒钟能想好几步。

整体思路:一个循环就是 Agent 的全部

现在业内做 Agent 最常用的就是 ReAct 框架,名字听着挺高端,拆开看就俩词:Reason(思考)和 Act(行动)。

整个流程跑起来是这样的:

  1. 把任务和历史对话扔给大模型,让它思考下一步该干啥
  2. 如果它觉得需要调用工具,就返回工具调用指令
  3. 程序执行工具,拿到结果
  4. 把结果塞回对话里,再扔给大模型,继续思考下一步
  5. 循环往复,直到大模型觉得任务做完了,直接返回最终答案

是不是特别像你带实习生干活?你把任务扔给他,他先想第一步该做啥,做完过来跟你汇报结果,你再告诉他下一步干啥。只不过这里 "你" 的角色也由大模型自己扮演了。

就这么一个循环,说穿了一点都不神秘。

我这次要做的就是一个能自动写代码的编程 Agent,目标很简单:给它一句 "创建一个 React TodoList 项目",它能自己用 Vite 建项目、写代码、装依赖、启动服务。

第一步:先给大模型造四只 "手"

要实现上面的目标,至少得四个工具:读文件、写文件、列目录、执行系统命令。

我用的是 LangChain 里的 tool 函数来定义工具,配合 zod 做参数校验。这个组合写起来很省心,参数不对的话直接就拦住了,不用自己写判断。

读写文件:最基础的能力

读文件最简单,接收一个文件路径,用 Node 的 fs 模块读出来返回就行。

javascript 复制代码
const readFileTool = tool(
    async({ filePath }) => {
        const content = await fs.readFile(filePath, 'utf-8');
        console.log(`[工具调用] read_file(${filePath})
        成功读取 ${content.length} 字节`)
        return content;
    },
    {
        name: 'read_file',
        description: `用此工具来读取文件内容,当用户要求读取文件、
        查看代码、分析文件内容时,调用此工具。输入文件路径(
        可以是相对路径或绝对路径)`,
        schema: z.object({
            filePath: z.string().describe('要读取的文件路径')
        })
    }
)

写文件稍微麻烦一点,要考虑目录不存在的情况。比如要写 src/App.tsx,如果 src 目录还没建,直接写会报错。所以要用 mkdirrecursive: true 递归创建目录,省得大模型还要自己先建文件夹。

javascript 复制代码
const writeFileTool = tool(
    async({ filePath, content }) => {
        try {
            const dir = path.dirname(filePath);
            await fs.mkdir(dir, { recursive: true }); // 自动创建目录,坑了我十分钟
            await fs.writeFile(filePath, content, 'utf-8');
            console.log(`[工具调用] write_file(${filePath})
            成功写入 ${content.length} 字节`)
            return `成功写入 ${filePath}`
        } catch(err) {
            console.log(`[工具调用] write_file(${filePath})
            错误: ${err.message}`)
            return `写入文件失败:${err.message}`
        }
    },
    {
        name: 'write_file',
        description: '向指定路径写入文件内容,自动创建目录',
        schema: z.object({
            filePath: z.string().describe('文件路径'),
            content: z.string().describe('要写入的文件内容')
        })
    }
)

说句题外话工具的 description 特别重要,这是大模型判断什么时候用这个工具的唯一依据。写得越清楚,大模型调用得越准。别写太简略,也别写太绕,说人话就行。

列目录:别小看这个,坑在细节

列目录这个工具,说出来有点丢人,我卡了快二十分钟。

最开始我顺手写了 files.map(file => file.name),跑起来一直报 undefined。我还以为是 fs 模块出问题了,翻了半天 Node 文档才反应过来 ------fs.readdir 返回的就是字符串数组啊!哪来的 file.name?纯粹是写别的接口写顺嘴了,想当然了。

改完之后长这样:

javascript 复制代码
const listDirectoryTool = tool(
    async ({ directoryPath }) => {
        try {
            const files = await fs.readdir(directoryPath);
            console.log(`[工具调用] list_directory(${directoryPath})
            成功列出 ${files.length} 个文件和文件夹`)
            return `目录内容:\n ${files.join('\n')}`
        } catch(err) {
            console.log(`[工具调用] list_directory(${directoryPath})
            错误: ${err.message}`)
            return `列出目录内容失败:${err.message}`
        }
    }, 
    {
        name: 'list_directory',
        description: '列出指定目录下的所有文件和文件夹',
        schema: z.object({
            directoryPath: z.string().describe('目录路径')
        })
    }
)

执行命令:这才是真正的杀招

前面三个工具都只能操作文件,真正让 Agent 拥有 "生产力" 的,是执行系统命令的能力。能跑命令,就能装依赖、启服务、打包构建,甚至还能 git 提交。

这里我用了 child_process.spawn 而不是 exec,因为 spawn 可以实时输出日志,用户能看到命令执行的过程,不会像 exec 那样执行完了才一次性吐出来,体验好很多。

javascript 复制代码
const executeCommandTool = tool(
    async ({ command, workingDirectory }) => {
        const cwd = workingDirectory || process.cwd();
        console.log(`[工具调用] execute_command(${command})
        工作目录:${cwd}`);
        return new Promise((resolve, reject) => {
            const [cmd, ...args] = command.split(' ');
            const child = spawn(cmd, args, {
                cwd,
                stdio: 'inherit', // 直接把子进程输出接到主进程,实时看日志
                shell: true, // 别问我为什么知道,不加这个很多命令跑不起来
            })
            let errorMsg = '';
            child.on('error', (err) => {
                errorMsg = err.message
            });
            child.on('close', (code) => {
                if (code === 0) {
                   console.log(`[工具调用] execute_command(${command})
                   成功执行`)
                   const cwdInfo = workingDirectory?
                   `\n\n重要提示:命令在目录"${workingDirectory}" 执行`
                   : '';
                   resolve(`命令行成功执行 ${command}${cwdInfo}`);
                } else {
                    console.log(`[工具调用] execute_command(${command})
                    退出码:${code}`)
                    resolve(`命令执行失败,退出码:${code}\n 错误:${errorMsg}`)
                }
            })
        })
        
    },
    {
        name: 'execute_command',
        description: '执行系统命令,支持指定工作目录,实时显示输出',
        schema: z.object({
            command: z.string().describe('要执行的命令'),
            workingDirectory: z.string().describe('工作目录(推荐指定)')
        })
    }
)

注意这个坑:别同时用 cd 和 workingDirectory这个坑是真的坑,我踩完还愣了半天。

最开始写命令执行的时候,我习惯性地写了 cd react-todo-app && pnpm install,同时又传了 workingDirectory: 'react-todo-app'。结果一跑就报错说找不到目录。我还纳闷,刚创建的项目怎么就没了?去文件夹里看明明在啊。

后来盯着参数看了半天才拍大腿 ------workingDirectory 已经帮你把工作目录切到 react-todo-app 了,你再 cd 一次,相当于要在 react-todo-app 里面再找一个 react-todo-app,那能找到才怪。

说穿了就是一层窗户纸,但没踩过的话真容易犯迷糊。所以我后来直接在 System 提示词里把这条规则写死了,就怕大模型也犯同样的错。

第二步:把工具和大模型串成循环

工具都备齐了,接下来就是把它们和大模型串起来 ------ 这就是 Agent 的主循环。

我用的是 DeepSeek 的模型,通过 LangChain 的 ChatOpenAI 接入,因为兼容 OpenAI 格式,直接改 baseURL 就行。

javascript 复制代码
const model = new ChatOpenAI({
  modelName:'deepseek-v4-pro',
  apiKey: process.env.DEEPSEEK_API_KEY,
  temperature: 0, // 写代码温度低一点更稳定
  configuration: {
    baseURL: 'https://api.deepseek.com/v1',
  },
});

const tools = [
    readFileTool,
    writeFileTool,
    listDirectoryTool,
    executeCommandTool
]

const modelWithTools = model.bindTools(tools);

这四种 Message 到底啥区别

LangChain 里有四种常用的消息类型,最开始我也记不住,用多了就明白了:

  • SystemMessage:就是给大模型定规矩的。你是谁、你有啥工具、你要遵守什么规则,全写在这里。整个对话过程中一般不变。
  • HumanMessage:用户说的话。也就是最开始的任务指令。
  • AIMessage:大模型返回的话。如果它要调用工具,工具调用信息也在这条消息里。
  • ToolMessage :工具执行完的结果。必须带上 tool_call_id,不然大模型不知道这个结果对应哪次调用。

很多人刚接触的时候觉得 ToolMessage 很抽象,其实你就把它理解成 "工具的回话" 就行。大模型喊了一声 "帮我读一下这个文件",工具读完了回一句 "内容是 xxx",这个回话就是 ToolMessage。

循环跑起来的完整流程

核心的 ReAct 循环其实就几十行代码。我设了个最大迭代次数 30 次,防止大模型钻牛角尖无限循环。

javascript 复制代码
async function runAgentWithTools(query, maxIterations=30) {
    const messages = [
        new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
        当前工作目录: ${process.cwd()}
        工具: 
        1. read_file: 读取文件
        2. write_file: 写入文件
        3. execute_command: 执行命令(支持 workingDirectory 参数)
        4. list_directory: 列出目录
        重要规则 - execute_command:
            - workingDirectory 参数会自动切换到指定目录
            - 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
            - 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
            正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
            回复要简洁,只说做了什么
        `),
        new HumanMessage(query)
    ];

    // ReAct 循环,Agent 的灵魂就在这
    for(let i = 0; i < maxIterations; i++){
        console.log(chalk.bgGreen(`正在等待第${i+1}次 AI 思考......`));
        const response = await modelWithTools.invoke(messages);
        messages.push(response);

        // 没有工具调用了,说明任务做完了
        if (!response.tool_calls || response.tool_calls.length === 0) {
            console.log(`\n: AI 最终回复:\n ${response.content}\n`);
            return response.content
        }

        // 挨个执行工具调用,把结果塞回去
        for (const toolCall of response.tool_calls) {
            const foundTool = tools.find(t => t.name === toolCall.name);
            if (foundTool) {
                const toolResult = await foundTool.invoke(toolCall.args)
                messages.push(new ToolMessage({
                    content: toolResult,
                    tool_call_id: toolCall.id
                }))
            }
        }
    }
    return messages[messages.length - 1].content
}

就这么一个 for 循环,就是整个 Agent 的核心。

每次循环都把当前所有消息扔给大模型。如果它返回了工具调用,就执行,把结果加进消息列表,下一轮继续。如果没返回工具调用,说明它觉得任务完成了,直接输出结果。

我第一次跑通的时候盯着控制台看了半天,看着它一步步创建项目、写文件、装依赖,那种感觉特别奇妙 ------ 就像看着一个笨手笨脚的实习生,居然真的把活干完了。

跑起来看看:Agent 自动生成的 TodoList 效果

光说流程没概念,给你们看一眼最终跑出来的页面。这就是 Agent 照着需求自动生成、安装依赖并启动后的完整效果:

整个页面用了蓝到紫的渐变背景,中间是一块大圆角的白色半透明卡片,配了柔和的投影,视觉上很干净。顶部是 Todo List 标题和 "保持专注,完成更多" 的副标题,下面是圆角输入框和一个紫蓝色的渐变加号按钮,输入框聚焦和按钮悬停都有微动效。

中间是三个筛选标签:全部、进行中、已完成,每个标签右上角带数量角标,切换选中态时有平滑的背景过渡。任务列表每一项左边是圆形复选框,右边是删除叉号,勾选完成后文字自动变灰加删除线,添加和删除任务都带淡入淡出的过渡动画,不会生硬地跳变。

卡片底部左右两侧分别统计待完成和已完成的数量,数据存在 localStorage 里,刷新页面不会丢失。

说实话我只在需求里写了 "样式要美观,要有动画效果" 这么一句模糊的话,它自己把渐变、阴影、圆角、悬停反馈、过渡动画全配上了,审美还挺在线。不用自己一句句调 CSS,这大概就是用编程 Agent 最爽的时刻。

我踩过的几个坑,你们别再踩了

整个写下来也就两百多行代码,但坑是真不少。挑几个印象深的说说。

第一个坑就是前面提过的,workingDirectorycd 重复的问题。这个真的是低级错误,但特别容易犯。

第二个坑是 spawn 拆参数。最开始我用 command.split(' ') 把命令拆成命令名和参数,遇到带空格的路径直接就炸了。后来加了 shell: true 才解决,让系统 shell 去处理参数拆分,省事多了。

第三个坑是无限循环。我最开始没设最大迭代次数,有一次任务描述得比较模糊,大模型就卡在那反复读同一个文件,读了十几次还停不下来。所以一定要设兜底,要么限次数,要么加超时。我最后加了个一百万毫秒的超时强制退出,虽然粗暴但管用。

第四个坑是工具描述写得不清楚。一开始我把 write_file 的描述写得很简略,结果大模型经常用错,本来该写文件的时候它直接把代码贴在回复里。后来把描述写详细了,调用准确率立马就上去了。

往深扒一层:langchain 的 bindTools 到底干了啥

说实话最开始我以为 bindTools 是什么高深的东西,翻了下源码才发现,本质就是个语法糖。

它干了两件事:

  1. 把你传进去的 tools 数组,按 OpenAI 要求的格式转换成 JSON Schema,每次请求都自动带在请求体里
  2. 大模型返回结果的时候,帮你把 tool_calls 从响应里解析出来,挂到 response 对象上,不用你自己去扒 additional_kwargs

就这么点事。没有 bindTools 你自己拼参数、自己解析也能做,就是麻烦点。

很多人觉得 LangChain 很厚重,其实它核心做的就是两件事:一是兼容各家大模型的接口,让你换模型不用改代码;二是把常用的模式(比如工具调用、链、Agent)封装成现成的方法,少写点样板代码。

它不是什么黑魔法,就是个工程化的工具库。

最后说几句实在的

写完这个迷你 Agent 之后,我最大的感受有三个。

第一,Agent 真的没那么玄乎。很多人把它吹得像有自我意识一样,其实核心就是 "大模型决策 + 工具执行 + 循环反馈" 这三件套。真正的智能来自大模型本身,框架只是个传话的。理解了这一点,再看各种花里胡哨的 Agent 产品,你都能一眼看到底。

第二,工具的边界比工具本身更重要。我这个迷你版为了简单,啥限制都没加,理论上它能删你系统文件、能格式化硬盘。真要做生产级的 Agent,路径白名单、命令白名单、人工确认这些安全措施一个都不能少。能力越强,越要套缰绳。

第三,编程 Agent 不是银弹。它擅长做那种 "有明确步骤、重复性高" 的任务,比如搭项目骨架、改配置、写样板代码。但真要调试复杂 bug、做架构设计、理解业务逻辑,它还差得远。它是提效工具,不是替代品。

当然了,我这个只是最简陋的版本。真正的 Cursor 和 Claude Code 还做了很多优化,比如上下文管理、多轮规划、错误自动修复、代码理解等等。但核心骨架,其实都差不多。

如果你也跟着手搓了一遍,或者有别的坑我没提到,评论区留个言,我也想看看你的实现思路。

相关推荐
hboot5 小时前
AI工程师第五课 - 大语言模型基础
python·llm·fastapi
民乐团扒谱机6 小时前
【全流程实战】LibTV节点式AI视频工作流落地教程|从剧本到成片可复用流水线,新手/开发者双版本
人工智能·音视频
STLearner7 小时前
ICML 2026 | LLM×Graph论文总结[1]【图基础模型,文本属性图,多模态属性图,图对齐,图提示学习,关系深度学习
论文阅读·人工智能·python·深度学习·学习·机器学习·数据挖掘
冬奇Lab7 小时前
MCP 系列(05):Resources 和 Prompts 进阶——动态数据、参数化 URI 与多轮模板
人工智能·llm·mcp
冬奇Lab7 小时前
开源项目第158期:cangjie-skill — 把书、视频、播客里的方法论蒸馏成可调用的 AI Skills
人工智能·开源·资讯
习明然8 小时前
我的本地化AI项目(三)
人工智能·python·electron·c#·avalonia
程序猿炎义9 小时前
一人内容团队——用Amazon Quick Desktop实现小红书从选题到发布的全流程自动化
大数据·人工智能·microsoft·自动化·小红书