用 LangChain.js 手写一个 Mini Cursor:从一次普通问答到能读写文件、执行命令的 Agent
本文来自一个小型 LangChain.js 实验项目。为了让没有看过原始代码的读者也能读懂,下面会把关键代码片段和上下文都放出来,不默认读者知道项目目录结构。
这个实验项目包含三类内容:
- 一个最小的
ChatOpenAI.invoke示例; - 一组通过 LangChain
tool封装的文件和命令行工具; - 一个由 Agent 任务生成出来的 React + TypeScript TodoList 项目。
这篇文章不写成大而全的 Agent 教程,只围绕一个目标展开:看清楚一个最小编程 Agent 是怎么从“调用大模型回答问题”,一步步变成“能读文件、写文件、列目录、执行命令”的。
一、先看依赖:这个实验需要什么
先看运行这个实验需要哪些依赖。项目的 package.json 里核心依赖并不多:
{
"dependencies": {
"@langchain/core": "^1.2.1",
"@langchain/openai": "^1.5.3",
"chalk": "^5.6.2",
"dotenv": "^17.4.2",
"zod": "^4.4.3"
}
}
这些依赖基本对应了项目里的几件事:
@langchain/openai:创建兼容 OpenAI 接口的聊天模型;@langchain/core:使用消息类型、工具定义和工具绑定能力;dotenv:从环境变量读取模型服务配置;zod:给工具入参做结构描述;chalk:让命令行输出更醒目,方便观察 Agent 执行过程。
也就是说,这不是一个完整框架项目,而是一个围绕 LangChain 工具调用机制搭出来的本地实验项目。
二、最小调用:先让模型回答问题
第一段代码只做一件事:创建模型实例,然后向模型提一个问题。
import { ChatOpenAI } from "@langchain/openai";
import "dotenv/config";
const model = new ChatOpenAI({
modelName: "deepseek-v4-flash",
apiKey: process.env.DEEPSEEK_API_KEY,
configuration: {
baseURL: process.env.DEEPSEEK_BASE_URL
}
});
const response = await model.invoke("怎么右旋数组?");
console.log(response.content);
这里使用的是 LangChain 的 ChatOpenAI 适配器,但实际模型和服务地址通过环境变量切到了兼容 OpenAI 接口格式的 DeepSeek 服务:
DEEPSEEK_API_KEY:模型服务的 API Key;DEEPSEEK_BASE_URL:兼容 OpenAI 接口格式的服务地址;modelName:实际调用的模型名。
这一层还没有 Agent,也没有工具调用。模型只能根据输入文本生成回答,它无法主动读取本地文件,也无法修改项目。
LangChain 把底层 chat completions 调用包装成了统一的 invoke 接口。
三、第一步工具化:让模型能读文件
下一步是引入 LangChain 的工具机制。核心代码是把 Node.js 的 fs.readFile 包装成一个 read_file 工具:
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("要读取的文件路径")
})
}
);
这里有三个关键点:
tool(...)的第一个参数是真正执行的函数;name和description会暴露给模型,帮助模型判断什么时候调用工具;schema使用zod描述参数结构,避免模型随意传入不符合预期的参数。
工具定义好之后,通过 bindTools 绑定到模型:
const modelWithTools = model.bindTools(tools);
接下来,代码手动维护了一个 messages 数组:
const messages = [
new SystemMessage("你是一个代码助手,可以使用工具读取文件并解释代码"),
new HumanMessage("请读取指定源码文件内容并解释代码")
];
第一次调用模型时,模型未必直接回答,它可能返回 tool_calls,表示“我需要先调用某个工具”。代码检测到工具调用后,会找到对应工具并执行:
while (response.tool_calls && response.tool_calls.length > 0) {
const toolResults = await Promise.all(
response.tool_calls.map(async (toolCall) => {
const tool = tools.find(t => t.name === toolCall.name);
if (!tool) {
return `Error:找不到工具${toolCall.name}`;
}
const result = await tool.invoke(toolCall.args);
return result;
})
);
response.tool_calls.forEach((toolCall, index) => {
messages.push(
new ToolMessage({
content: toolResults[index],
tool_call_id: toolCall.id
})
);
});
response = await modelWithTools.invoke(messages);
messages.push(response);
}
这就是一个非常朴素的工具调用循环:
- 用户提出任务;
- 模型判断需要工具;
- 程序执行工具;
- 工具结果通过
ToolMessage放回上下文; - 模型基于新上下文继续思考或给出最终答案。
这段代码已经很接近 Agent 的基本形态了。
四、单独看命令执行:Node 子进程示例
在进入完整工具箱之前,先看一个独立的 Node 子进程示例。它演示了如何用 Node.js 启动一个命令行任务:
import { spawn } from "node:child_process";
const command = "pnpm create vite react-todo-app --template react-ts";
const [cmd, ...args] = command.split(" ");
const child = spawn(cmd, args, {
cwd: process.cwd(),
stdio: "inherit",
shell: true
});
这段代码的作用是让 Node 主进程启动一个外部命令。stdio: "inherit" 会把子进程的输入输出直接接到当前终端,所以执行 pnpm create vite ... 时,用户能在同一个控制台看到安装或创建过程。
后面通过监听事件判断执行结果:
child.on("error", (err) => {
errorMsg = err.message;
});
child.on("close", (code) => {
if (code === 0) {
process.exit(0);
} else {
process.exit(code || 1);
}
});
这个示例本身还不是 LangChain tool,但它解释了后面 execute_command 工具的来源:所谓“让 Agent 执行命令”,最终还是宿主程序用 Node.js 代替模型去启动子进程。模型只负责决定要不要调用这个工具,以及传什么参数。
这里同样要注意,command.split(" ") 只适合非常简单的命令。真实场景里命令参数一旦包含空格、引号或更复杂的 shell 语法,就需要更稳妥的解析方式或更明确的命令白名单。
五、把工具扩展成“编程工具箱”
有了读文件和执行命令的基础后,就可以把常见项目操作封装成一组工具。这个实验里封装了四个工具:
read_file:读取文件;write_file:写入文件,并自动创建目录;list_directory:列出目录;execute_command:执行命令行命令。
这四个工具组合起来,就不再只是“让模型看文件”,而是让模型具备了最基本的项目操作能力。
1. 写文件工具
写文件工具大致流程是:
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, "utf-8");
这让 Agent 可以创建新文件,也可以覆盖已有文件。代码注释里写到了“确认路径是否在当前工作目录下”,但实际实现中还没有做路径限制。因此如果要把它用在真实项目里,应该补上路径校验,比如把目标路径解析成绝对路径后,确认它仍然位于允许操作的工作目录内。
当前版本更适合学习和本地实验,不适合直接暴露给不可信输入。
2. 列目录工具
列目录工具使用的是:
const files = await fs.readdir(directoryPath);
return `目录内容:\n${files.join("\n")}`;
这个工具很简单,但对 Agent 很重要。因为模型如果不知道项目结构,就很容易凭空猜测文件路径。先列目录,再读文件,通常比直接让模型猜路径稳定得多。
3. 执行命令工具
命令执行工具基于 Node.js 的 child_process.spawn:
const [cmd, ...args] = command.split(" ");
const child = spawn(cmd, args, {
cwd,
stdio: "inherit",
shell: true
});
它支持传入 workingDirectory,这样 Agent 可以在指定目录下执行命令。例如在 react-todo-app 目录执行:
{
command: "pnpm install",
workingDirectory: "react-todo-app"
}
编程 Agent 的系统提示词特别强调了这一点:
当使用 workingDirectory 时,绝对不要在 command 中使用 cd
这条规则很实用。因为如果 workingDirectory 已经切换到了 react-todo-app,再执行 cd react-todo-app && pnpm install,就会变成进入 react-todo-app/react-todo-app,路径自然会出错。
这里还需要注意两点:
第一,command.split(" ") 对复杂命令并不可靠。比如参数里带空格、引号或管道时,简单 split 会拆错。当前代码同时设置了 shell: true,更合理的方式通常是把完整命令交给 shell,或者明确只支持简单命令并做好限制。
第二,这段代码实际是通过 close 事件拿到子进程退出码:
child.on("close", (code) => {
if (code === 0) {
process.exit(0);
} else {
process.exit(code || 1);
}
});
也就是说,父进程监听子进程事件,根据退出码判断命令是否执行成功。
六、Mini Cursor:用 ReAct 循环驱动工具
接下来,把上面的工具串起来,就得到一个简单的编程 Agent。
在完整 Agent 示例里,模型配置也做了调整:普通问答和读文件示例使用的是 deepseek-v4-flash,编程 Agent 使用的是 deepseek-v4-pro,并且把 baseURL 设置为 https://api.deepseek.com/v1。同时它设置了 temperature: 0,更偏向稳定输出,这对代码生成和工具调用任务通常更合适。
它先注册工具:
const tools = [
readFileTool,
writeFileTool,
listDirectoryTool,
executeCommandTool
];
const modelWithTools = model.bindTools(tools);
然后定义一个 runAgentWithTools 函数:
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage("你是一个项目管理助手,使用工具完成任务。"),
new HumanMessage(query)
];
for (let i = 1; i <= maxIterations; i++) {
const response = await modelWithTools.invoke(messages);
messages.push(response);
if (!response.tool_calls || response.tool_calls.length === 0) {
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:模型根据上下文思考下一步;
- Act:模型发起工具调用;
- Observe:程序执行工具,并把结果写回消息列表;
- Repeat:模型继续基于结果行动,直到不再需要工具。
为了避免 Agent 无限循环,代码设置了 maxIterations = 30。这在实践中很必要,因为只要涉及模型自主调用工具,就应该有迭代上限。
七、Agent 实际完成的任务:生成 React TodoList
这个 Agent 收到的任务是创建一个 React TodoList 应用,要求包括:
- 使用 Vite 创建 React + TypeScript 项目;
- 实现添加、删除、完成状态切换;
- 支持全部、进行中、已完成筛选;
- 显示统计信息;
- 使用
localStorage持久化; - 添加渐变背景、卡片阴影、圆角、悬停效果和 CSS transition;
- 安装依赖并启动开发服务器。
最终生成了一个 React + TypeScript + Vite 项目。核心组件大致长这样:
Todo 数据结构很简单:
interface Todo {
id: number
text: string
completed: boolean
}
type FilterType = "all" | "active" | "completed"
状态逻辑被抽到了 useTodos 里:
function useTodos() {
const [todos, setTodos] = useState<Todo[]>(() => {
const saved = localStorage.getItem("react-todo-app-data");
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) return parsed;
}
return [];
});
useEffect(() => {
localStorage.setItem("react-todo-app-data", JSON.stringify(todos));
}, [todos]);
return { todos, addTodo, toggleTodo, deleteTodo, clearCompleted };
}
这个实现覆盖了一个 TodoList 应用的主要功能:
addTodo:新增任务;toggleTodo:切换完成状态;deleteTodo:删除任务;clearCompleted:清空已完成任务;filteredTodos:根据筛选条件展示列表;stats:统计总数、待完成数和已完成数。
样式放在入口样式文件中,入口文件引入方式如下:
import "./index.css";
判断生成项目时,不能只看有没有某个文件名,还要看实际 import 链路。未被入口引用的样式文件不会影响页面。
index.css 中实现了渐变背景、卡片、筛选按钮、空状态、列表项动画、完成状态和移动端适配。例如列表项使用了 slideIn 动画:
.todo-item {
transition: all var(--transition);
animation: slideIn 0.35s ease-out;
}
从结果看,Agent 生成的 TodoList 功能是完整的,样式也基本满足任务要求。
React 项目的配置也比较标准:Vite 配置只启用了 @vitejs/plugin-react,package.json 里提供了 dev、build、preview 三个脚本,tsconfig 开启了比较严格的 TypeScript 检查。生成应用后,项目文档仍停留在 Vite 模板说明,这也是后续验收时可以继续完善的地方。
八、这个项目暴露出的几个实践点
1. Agent 的能力来自工具,不是来自“更聪明的提示词”
只调用 model.invoke 时,模型只能回答问题。绑定 read_file 后,它才能读取文件。继续绑定 write_file、list_directory、execute_command 后,它才具备操作项目的能力。
所以编程 Agent 的关键不是只写一段很长的 Prompt,而是给它设计一组清晰、可控、可观察的工具。
2. 工具描述会影响模型是否正确调用工具
每个工具都有 name、description 和 schema。这些信息不是给人看的文档,而是模型做决策时的重要上下文。
比如 execute_command 的描述里明确了支持 workingDirectory,系统消息又进一步强调不要重复 cd。这类约束能减少模型在命令执行时犯路径错误。
3. 工具执行结果必须回填到 messages
工具不是模型自己执行的,而是宿主程序执行的。执行结果必须包装成 ToolMessage 放回 messages,模型才能“看到”观察结果。
如果漏掉这一步,模型只知道自己发起了工具调用,却不知道工具返回了什么。
4. 需要验收 Agent 的产物
这个实验里有一个很典型的现象:核心组件和入口样式已经形成了完整 TodoList,但仍有模板残留文件没有同步整理。它不影响运行,因为没有被入口引用,但说明 Agent 生成项目后仍然需要人工检查。
编程 Agent 可以提高执行效率,但不能代替工程验收。至少应该检查:
- 入口文件实际引用了哪些样式;
- 生成的文件是否有遗留模板代码;
- 命令是否真的执行成功;
- 依赖是否安装完整;
- 构建是否能通过;
- 工具是否有路径和命令安全限制。
九、总结
这个项目用很少的代码展示了一个 Mini Cursor 的雏形:
- 先用
model.invoke完成最小模型调用; - 再用 LangChain
tool把读文件能力暴露给模型; - 然后用 Node.js 子进程机制实现命令执行;
- 接着把读、写、列目录、执行命令封装成工具箱;
- 最后用 ReAct 循环驱动模型不断调用工具,生成一个 React TodoList 项目。
如果继续完善,可以优先补三件事:
- 给文件读写工具增加工作目录边界校验;
- 给命令执行工具增加更严格的白名单或确认机制;
- 在 Agent 完成后自动运行
pnpm build,用构建结果作为验收信号。
这也是编程 Agent 最核心的落点:不是让模型“看起来会写代码”,而是让它在受控工具、明确反馈和可验证结果之间循环,把任务真正推进到可运行状态。