写在前面:今天终于把"迷你 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() 第二个参数定义了 schema,invoke 时就会按 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为空。"
然后看着面试官满意的表情,心里默念:这波,又稳了。
本文所有代码示例均来自课堂学习资料,真实可运行。