用 LangChain.js 手写一个能读写文件和执行命令的 Mini Cursor

用 LangChain.js 手写一个 Mini Cursor:从一次普通问答到能读写文件、执行命令的 Agent

本文来自一个小型 LangChain.js 实验项目。为了让没有看过原始代码的读者也能读懂,下面会把关键代码片段和上下文都放出来,不默认读者知道项目目录结构。

这个实验项目包含三类内容:

  • 一个最小的 ChatOpenAI.invoke 示例;
  • 一组通过 LangChain tool 封装的文件和命令行工具;
  • 一个由 Agent 任务生成出来的 React + TypeScript TodoList 项目。

这篇文章不写成大而全的 Agent 教程,只围绕一个目标展开:看清楚一个最小编程 Agent 是怎么从"调用大模型回答问题",一步步变成"能读文件、写文件、列目录、执行命令"的。

一、先看依赖:这个实验需要什么

先看运行这个实验需要哪些依赖。项目的 package.json 里核心依赖并不多:

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 工具调用机制搭出来的本地实验项目。

二、最小调用:先让模型回答问题

第一段代码只做一件事:创建模型实例,然后向模型提一个问题。

js 复制代码
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 工具:

js 复制代码
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("要读取的文件路径")
    })
  }
);

这里有三个关键点:

  1. tool(...) 的第一个参数是真正执行的函数;
  2. namedescription 会暴露给模型,帮助模型判断什么时候调用工具;
  3. schema 使用 zod 描述参数结构,避免模型随意传入不符合预期的参数。

工具定义好之后,通过 bindTools 绑定到模型:

js 复制代码
const modelWithTools = model.bindTools(tools);

接下来,代码手动维护了一个 messages 数组:

js 复制代码
const messages = [
  new SystemMessage("你是一个代码助手,可以使用工具读取文件并解释代码"),
  new HumanMessage("请读取指定源码文件内容并解释代码")
];

第一次调用模型时,模型未必直接回答,它可能返回 tool_calls,表示"我需要先调用某个工具"。代码检测到工具调用后,会找到对应工具并执行:

js 复制代码
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);
}

这就是一个非常朴素的工具调用循环:

  1. 用户提出任务;
  2. 模型判断需要工具;
  3. 程序执行工具;
  4. 工具结果通过 ToolMessage 放回上下文;
  5. 模型基于新上下文继续思考或给出最终答案。

这段代码已经很接近 Agent 的基本形态了。

四、单独看命令执行:Node 子进程示例

在进入完整工具箱之前,先看一个独立的 Node 子进程示例。它演示了如何用 Node.js 启动一个命令行任务:

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 ... 时,用户能在同一个控制台看到安装或创建过程。

后面通过监听事件判断执行结果:

js 复制代码
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. 写文件工具

写文件工具大致流程是:

js 复制代码
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(filePath, content, "utf-8");

这让 Agent 可以创建新文件,也可以覆盖已有文件。代码注释里写到了"确认路径是否在当前工作目录下",但实际实现中还没有做路径限制。因此如果要把它用在真实项目里,应该补上路径校验,比如把目标路径解析成绝对路径后,确认它仍然位于允许操作的工作目录内。

当前版本更适合学习和本地实验,不适合直接暴露给不可信输入。

2. 列目录工具

列目录工具使用的是:

js 复制代码
const files = await fs.readdir(directoryPath);
return `目录内容:\n${files.join("\n")}`;

这个工具很简单,但对 Agent 很重要。因为模型如果不知道项目结构,就很容易凭空猜测文件路径。先列目录,再读文件,通常比直接让模型猜路径稳定得多。

3. 执行命令工具

命令执行工具基于 Node.js 的 child_process.spawn

js 复制代码
const [cmd, ...args] = command.split(" ");
const child = spawn(cmd, args, {
  cwd,
  stdio: "inherit",
  shell: true
});

它支持传入 workingDirectory,这样 Agent 可以在指定目录下执行命令。例如在 react-todo-app 目录执行:

js 复制代码
{
  command: "pnpm install",
  workingDirectory: "react-todo-app"
}

编程 Agent 的系统提示词特别强调了这一点:

text 复制代码
当使用 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 事件拿到子进程退出码:

js 复制代码
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,更偏向稳定输出,这对代码生成和工具调用任务通常更合适。

它先注册工具:

js 复制代码
const tools = [
  readFileTool,
  writeFileTool,
  listDirectoryTool,
  executeCommandTool
];

const modelWithTools = model.bindTools(tools);

然后定义一个 runAgentWithTools 函数:

js 复制代码
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 数据结构很简单:

ts 复制代码
interface Todo {
  id: number
  text: string
  completed: boolean
}

type FilterType = "all" | "active" | "completed"

状态逻辑被抽到了 useTodos 里:

ts 复制代码
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:统计总数、待完成数和已完成数。

样式放在入口样式文件中,入口文件引入方式如下:

ts 复制代码
import "./index.css";

判断生成项目时,不能只看有没有某个文件名,还要看实际 import 链路。未被入口引用的样式文件不会影响页面。

index.css 中实现了渐变背景、卡片、筛选按钮、空状态、列表项动画、完成状态和移动端适配。例如列表项使用了 slideIn 动画:

css 复制代码
.todo-item {
  transition: all var(--transition);
  animation: slideIn 0.35s ease-out;
}

从结果看,Agent 生成的 TodoList 功能是完整的,样式也基本满足任务要求。

React 项目的配置也比较标准:Vite 配置只启用了 @vitejs/plugin-reactpackage.json 里提供了 devbuildpreview 三个脚本,tsconfig 开启了比较严格的 TypeScript 检查。生成应用后,项目文档仍停留在 Vite 模板说明,这也是后续验收时可以继续完善的地方。

八、这个项目暴露出的几个实践点

1. Agent 的能力来自工具,不是来自"更聪明的提示词"

只调用 model.invoke 时,模型只能回答问题。绑定 read_file 后,它才能读取文件。继续绑定 write_filelist_directoryexecute_command 后,它才具备操作项目的能力。

所以编程 Agent 的关键不是只写一段很长的 Prompt,而是给它设计一组清晰、可控、可观察的工具。

2. 工具描述会影响模型是否正确调用工具

每个工具都有 namedescriptionschema。这些信息不是给人看的文档,而是模型做决策时的重要上下文。

比如 execute_command 的描述里明确了支持 workingDirectory,系统消息又进一步强调不要重复 cd。这类约束能减少模型在命令执行时犯路径错误。

3. 工具执行结果必须回填到 messages

工具不是模型自己执行的,而是宿主程序执行的。执行结果必须包装成 ToolMessage 放回 messages,模型才能"看到"观察结果。

如果漏掉这一步,模型只知道自己发起了工具调用,却不知道工具返回了什么。

4. 需要验收 Agent 的产物

这个实验里有一个很典型的现象:核心组件和入口样式已经形成了完整 TodoList,但仍有模板残留文件没有同步整理。它不影响运行,因为没有被入口引用,但说明 Agent 生成项目后仍然需要人工检查。

编程 Agent 可以提高执行效率,但不能代替工程验收。至少应该检查:

  • 入口文件实际引用了哪些样式;
  • 生成的文件是否有遗留模板代码;
  • 命令是否真的执行成功;
  • 依赖是否安装完整;
  • 构建是否能通过;
  • 工具是否有路径和命令安全限制。

九、总结

这个项目用很少的代码展示了一个 Mini Cursor 的雏形:

  1. 先用 model.invoke 完成最小模型调用;
  2. 再用 LangChain tool 把读文件能力暴露给模型;
  3. 然后用 Node.js 子进程机制实现命令执行;
  4. 接着把读、写、列目录、执行命令封装成工具箱;
  5. 最后用 ReAct 循环驱动模型不断调用工具,生成一个 React TodoList 项目。

如果继续完善,可以优先补三件事:

  • 给文件读写工具增加工作目录边界校验;
  • 给命令执行工具增加更严格的白名单或确认机制;
  • 在 Agent 完成后自动运行 pnpm build,用构建结果作为验收信号。

这也是编程 Agent 最核心的落点:不是让模型"看起来会写代码",而是让它在受控工具、明确反馈和可验证结果之间循环,把任务真正推进到可运行状态。

相关推荐
sramdram2 小时前
低功耗温湿度传感器芯片制氧机应用解决方案
人工智能·芯片·温湿度传感器·温湿度传感器芯片
库拉大叔2 小时前
GPT-5.6 技术测评:上下文窗口与数学推理性能横向对比
人工智能·gpt·数据分析
AI服务老曹2 小时前
RTSP摄像头接入AI分析项目实战记录:常见拉流失败排查与参数调优
人工智能
天外天-亮2 小时前
HBuilder X 使用 uview-plus 方式
开发语言·javascript·hbuilder x·uview-plus
liguojun20252 小时前
篮球馆自动计时收费系统:从规则配置到自动结算的全流程拆解
java·大数据·运维·人工智能·物联网·1024程序员节
ShiMetaPi2 小时前
事件相机商业化落地的 “最后一道关键门槛”
人工智能·计算机视觉·ai·自动驾驶·事件相机
大任视点2 小时前
互动之星AI剧集新作上线48小时收益破50万元,树立AI精品剧集商业化新标杆
大数据·人工智能·业界资讯
huashengzsj2 小时前
Shopify个人建站如何搭建独立站:从零开始的完整指南
前端·网络·人工智能
三品吉他手会点灯2 小时前
嵌入式机器学习 - 学习笔记1.1.1 - 什么是机器学习?
c语言·人工智能·笔记·嵌入式硬件·学习·机器学习