😭 我花了两小时找了一个数据结构 bug,才把"迷你 Cursor"跑起来,绝望(bushi)

写在前面:今天终于把"迷你 Cursor"------一个能自己创建 React 项目、写代码、装依赖、启动服务器的 Agent 搞定了。但过程极其痛苦:总共不到 200 行代码,我花了 2 小时找 bug。 最后发现问题出在数据结构上------tool.invoke(args) 传进去的参数格式,和 tool 函数定义时接收的格式不一致。一个括号的事,调了俩小时。写代码最远的距离,不是从无到有,而是从"应该能跑"到"真能跑"之间的距离。


一、今天的目标:手写一个"迷你 Cursor"

1.1 要实现什么?

老师说:

"使用 Claude Code 完成以下任务:用 Vite 创建一个 React 的 TodoList 项目,并且把它运行起来。"

用大白话说,就是让 AI 自己完成这件事:

arduino 复制代码
"给我用 Vite 创建一个 React TodoList,功能齐全、样式漂亮、能跑起来。"

要做到这件事,Agent 需要哪些能力?

需要的能力 对应的 Tool 比喻
创建项目目录和文件 write_file AI 的手,能写文件
修改已有文件 read_file + write_file AI 的眼睛+手
查看项目结构 list_directory AI 的导航
安装依赖/启动项目 exec_command AI 的脚,能运行命令

老师说一个 Agent 要完成的步骤:

"编程任务 planning 分三步:① Vite 创建项目(写入文件 Tool)→ ② LLM 编程能力强就能写的 → ③ 项目运行起来(调用 CLI 命令的 Tool)。"

1.2 四个工具,一个都不能少

all-tools.mjs 中,定义了四个工具:

javascript 复制代码
import { tool } from '@langchain/core/tools';
import fs from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import z from 'zod';

readFileTool:读文件

javascript 复制代码
const readFileTool = tool(
    async ({filePath}) => {
        const content = await fs.readFile(filePath, 'utf-8');
        return content;
    },
    {
        name: 'read_file',
        description: '读取文件内容',
        schema: z.object({
            filePath: z.string().describe('文件路径'),
        }),
    }
)

writeFileTool:写文件(自动创建目录)

javascript 复制代码
const writeFileTool = tool(
    async ({filePath, content}) => {
        const dir = path.dirname(filePath);
        await fs.mkdir(dir, { recursive: true }); // 自动创建目录
        await fs.writeFile(filePath, content, 'utf-8');
        return `成功写入 ${filePath}`;
    },
    {
        name: 'write_file',
        description: '写入文件内容,自动创建目录',
        schema: z.object({
            filePath: z.string().describe('文件路径'),
            content: z.string().describe('要写入的文件内容'),
        }),
    }
)

fs.mkdir(dir, { recursive: true }) 是"自动创建父目录"的魔法 ------如果 dir 不存在,它会把所有父目录都建好。

ListDirectoryTool:列出目录

javascript 复制代码
const ListDirectoryTool = tool(
    async ({workingDirectory}) => {
        const files = await fs.readdir(workingDirectory);
        return `目录内容:\n ${files.join('\n')}`;
    },
    {
        name: 'list_directory',
        description: '列出指定目录下的所有文件和文件夹',
        schema: z.object({
            workingDirectory: z.string().describe('目录路径'),
        }),
    }
)

execCommandTool:执行命令

javascript 复制代码
const execCommandTool = tool(
    async ({command, workingDirectory}) => {
        const cwd = workingDirectory || process.cwd();
        return new Promise((resolve, reject) => {
            const [cmd, ...args] = command.split(' ');
            const child = spawn(cmd, args, {
                cwd,
                stdio: 'inherit',
                shell: true,
            });
            // ... 错误处理和返回
        });
    },
    {
        name: 'exec_command',
        description: '执行系统命令,支持指定工作目录',
        schema: z.object({
            command: z.string().describe('要执行的命令'),
            workingDirectory: z.string().describe('工作目录'),
        }),
    }
)

二、踩坑记:数据结构错误找了两小时

2.1 问题出在哪?

mini-cursor.mjs 中 ReAct 循环的核心代码:

javascript 复制代码
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);
        //                          ↑ 这里传的是 args 对象
        messages.push(new ToolMessage({
            content: toolResult,
            tool_call_id: toolCall.id,
        }));
    }
}

toolCall.args 传进来的是一个对象: { filePath: "src/tool.mjs" }

而在 all-tools.mjs 中,工具的 async 函数是解构参数的:

javascript 复制代码
async ({filePath}) => { ... }
// 从对象中解构出 filePath

这是对的。

但我在一开始写的时候,写成了这样:

javascript 复制代码
// ❌ 错误写法
async (filePath) => { ... }
// filePath 是一个对象 { filePath: "..." },不是字符串 "..."

// 然后我试图用 fs.readFile(filePath)
// 但实际上 filePath = { filePath: "src/tool.mjs" }
// 读文件 → 报错 → 找两小时

async (filePath)async ({filePath}) 之间差了一个花括号,但这一个花括号,让我调试了两小时。

2.2 为什么差一个括号差这么多?

javascript 复制代码
// invoke 传进来的是:{ filePath: "src/tool.mjs", content: "..." }

// ✅ 解构参数:直接从对象里拿出 filePath
async ({filePath}) => {
    await fs.readFile(filePath, 'utf-8'); // filePath = "src/tool.mjs" ✅
}

// ❌ 普通参数:filePath 是整个对象
async (filePath) => {
    await fs.readFile(filePath, 'utf-8');
    // filePath = { filePath: "src/tool.mjs" }
    // fs.readFile({ filePath: "..." }) → 报错 ❌
}

invoke 传递的是对象,函数必须用解构才能拿到里面的具体值。

2.3 血的教训

老师说:

"langchain invoke 原样输出,同时还会细心地准备 tools 加到后面。LLM 工程开发的便捷性、可读性帮助。"

LangChain 帮你做了很多事,但参数传递的格式,你必须和它保持一致。 tool() 第二个参数定义了 schemainvoke 时就会按 schema 的结构传参。

写代码的时候,参数类型比逻辑更容易出错。 逻辑错了能跑但结果不对,参数类型错了直接报错------而且报错信息往往含糊不清。


三、System Prompt 里的"潜规则":比代码更重要的提示词

mini-cursor.mjs 的 System Prompt 里有一段特别重要的内容:

javascript 复制代码
new SystemMessage(`
    ...重要规则 - execute_command:
    - workingDirectory 参数会自动切换到指定目录
    - 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
    - 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
    这是错误的!因为 workingDirectory 已经在 react-todo-app 目录了,再 cd react-todo-app 会找不到目录
    - 正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }

    重要规则 - 文件路径:
    - 项目文件在 react-todo-app/src/ 目录下
    - 读写文件时,路径要带上 react-todo-app/ 前缀

    重要规则 - 执行顺序:
    - 启动 dev server (pnpm run dev) 必须是最后一步
    - 因为它是长进程不会退出
`)

这些"潜规则"写在 System Prompt 里,比写在代码里还重要。

因为代码是固定的,但 LLM 的行为是可塑的。如果你不告诉 LLM:

  • 不要在 workingDirectory 里再 cd------它会写出 cd dir && pnpm install,然后在 dir 目录下再 cd dir,找不到路径。
  • 文件名要带前缀------它会写 src/App.tsx 而不是 react-todo-app/src/App.tsx
  • dev server 要最后启动------它会先启动 server,再装依赖,结果 server 跑了,依赖没装完。

System Prompt 是 Agent 的"操作手册",写得越细,AI 犯错越少。


四、完整的 ReAct 循环 + Promise.all

mini-cursor.mjs 的 ReAct 循环比上节课的版本更完善:

javascript 复制代码
async function runAgentWithTools(query, maxIterations = 30) {
    const messages = [
        new SystemMessage(`...`),
        new HumanMessage(query),
    ];

    for (let i = 0; i < maxIterations; i++) {
        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,
                }));
            }
        }
    }
}

重复一下 ReAct 循环的上节课学到的三个动作:

步骤 代码 说明
Reason modelWithTools.invoke(messages) LLM 思考下一步
Act foundTool.invoke(toolCall.args) 执行工具
Observe new ToolMessage({ tool_call_id }) 观察工具结果

循环条件: response.tool_calls 不为空。 终止条件: LLM 认为不需要再调工具,直接生成答案。


五、案例:从零创建一个 TodoList 项目

case1 的内容是:

markdown 复制代码
创建以一个功能丰富的 React TodoList 应用:
1. npm create vite@latest react-todo-app -- --template react-ts
2. 修改 src/App.tsx,实现完整功能的 TodoList
   - 添加、删除、标记完成
   - 分类筛选(全部/进行中/已完成)
   - 统计信息显示
   - localStorage 数据持久化
3. 添加复杂样式(渐变背景、卡片阴影、圆角、悬停效果)
4. 添加动画(添加/删除时的过渡动画,用 CSS Transitions)
5. 列出目录确定

注意:使用 pnpm,功能要完整,样式要美观,要有动画效果

Agent 的工作流程大概是这样:

轮次 思考 行动 观察
1 需要先创建项目 exec_command: "npm create vite@latest..." 项目创建成功
2 需要写代码 write_file: "react-todo-app/src/App.tsx" 文件写入成功
3 需要写样式 write_file: "react-todo-app/src/App.css" 写入成功
4 需要安装依赖 exec_command: "pnpm install" 安装成功
5 看看目录结构 list_directory: "react-todo-app/src" 文件都在
6 启动服务器 exec_command: "pnpm run dev" 服务器启动成功

每一步都在 ReAct 循环里自动完成------完全不需要人工干预。


六、总结:迷你 Cursor 的架构

文件 做了什么
工具层 all-tools.mjs 定义 4 个工具(读/写/列表/执行命令)
Agent 层 mini-cursor.mjs ReAct 循环 + 工具调用
子进程层 node-exec.mjs spawn 执行 CLI 命令
System Prompt mini-cursor 内联 给 LLM 的操作规则

"迷你 Cursor"的架构,和真正的 Cursor/Claude Code 是同一个思路。 只不过真正的产品有更完善的错误处理、更丰富的工具集、更复杂的任务编排。


写在最后

今天最大的收获有两个。第一个是终于写出了一个能自己干活的编程 Agent ------从创建项目到启动服务器,全程自动化。第二个是花了两小时跟一个括号过不去 ------async (filePath)async ({filePath}) 的区别,让我深刻理解了"参数解构"这件事。

老师说后面还要继续学,估计会讲到多智能体协作(LangGraph)和更复杂的任务编排。我已经准备好了------下次再遇到 bug,我会先检查括号。

下次面试官问你:"Agent 怎么执行多个工具调用?"

你可以淡定地说:

"Agent 通过 LangChain 注册工具列表,model.bindTools(tools) 把工具绑定到 LLM。在 ReAct 循环中,LLM 返回的 tool_calls 数组可能包含多个工具调用,遍历执行即可。每个工具调用通过 foundTool.invoke(toolCall.args) 执行参数需要和 tool() 中 schema 定义的结构一致------传的是对象,函数要用解构参数接收(async ({filePath}) 而不是 async (filePath))。tool 执行结果通过 new ToolMessage({ content, tool_call_id }) 推入 messages 上下文,然后再次调用 LLM,直到 tool_calls 为空。"

然后看着面试官满意的表情,心里默念:这波,又稳了。


本文所有代码示例均来自课堂学习资料,真实可运行。

相关推荐
竹林8181 小时前
wagmi v2 监听合约事件踩坑记:从 `useContractEvent` 到 `watchContractEvent`,我重构了三版才搞定实时数据流
前端·javascript
天若有情6732 小时前
纯HTML+Tailwind单页作品集网站——零框架静态前端完整源码
前端·html
WebGirl2 小时前
H5唤起app前端实现方案
前端
heimeiyingwang2 小时前
【架构实战】SQL注入与XSS防御:常见Web漏洞的系统性修复
前端·sql·架构
xingcheng2 小时前
Vue 2 + JavaScript编码规范skill
javascript
Larcher2 小时前
从零实现一个可运行的 RAG - LangChain、内存向量库与检索增强生成
前端·javascript
mONESY2 小时前
Vue3流式调用大模型接口完整实践
javascript
饼饼饼3 小时前
React 组件间传参好痛苦?试试这一套"优雅组合拳":Context 与 Refs 实战
前端