1. 从零手写 AI 编程 Agent:Mini Cursor 到底在做什么
你可能用过 Cursor、Trae 这类工具,输入一句“帮我建一个 React TodoList 并跑起来”,它就能自己创建项目、改代码、装依赖、启动服务。这背后其实不是什么黑魔法,而是一个很清晰的循环:大模型负责“想”,工具负责“做”,ReAct 循环把两者串起来。本文要做的,就是用 LangChain + Tool Calling + ReAct,从零搭一个 Mini Cursor,并接入 TaoToken 统一 Key/API 通道完成端到端验证。
适合谁看:已经会一点 Node.js,想搞懂 AI 编程 Agent 底层原理的开发者;想自己做一个能读写文件、执行命令的自动化助手的人;以及正在用 LangChain 但 Tool Calling 老是调不通、想找一份能直接跑通的参考实现的人。
我试过把整个链路拆开看,核心就三件事。第一,LLM 本身只会输出文本,它不能碰你的文件系统,也不能执行命令。第二,Tool Calling 机制给模型装上了“手”,模型输出一个结构化的调用请求,你的代码去执行,再把结果喂回去。第三,ReAct 循环让这个过程反复进行,直到模型认为任务完成、不再请求调用工具为止。理解了这三点,你就理解了所有 AI 编程工具的最小骨架。
本文会给出可复制的 Agent 初始化配置、四个核心工具的 schema 定义、ReAct 循环的完整代码,以及通过 TaoToken 接入模型后跑一次真实代码修改任务的验证过程。全程 Node.js + LangChain,代码可以直接落地。
2. 前置准备:用 TaoToken 统一模型通道
在写 Agent 之前,先把模型通道搞定。自己手写 Agent 最烦的一点是:换一个模型就要改一次 baseURL、改一次鉴权方式、改一次请求格式。TaoToken 的价值就在这里——它提供一个统一的 Key 和 API 通道,OpenAI 兼容格式,你只需要改 modelName 就能切换不同模型,Agent 代码完全不用动。
TaoToken 是什么:一个面向开发者的模型 API 聚合通道,提供统一的 API Key 和 OpenAI 兼容接口,适合用来做 Agent、Coding Plan、模型对话等场景。官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api (这个不加 UTM)。
你需要先拿到一个 API Key。操作路径:进入控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在 API Keys 页面 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 创建一个新 Key,复制出来备用。如果你只是想先验证模型能不能通,可以直接用模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 发一条消息试试。
拿到 Key 之后,项目初始化如下:
mkdir mini-cursor && cd mini-cursor pnpm init pnpm add @langchain/core @langchain/openai zod chalk dotenv然后在项目根目录建一个.env文件:
TAOTOKEN_API_KEY=sk-你的key TAOTOKEN_BASE_URL=https://taotoken.net/api这里有个容易踩的坑:baseURL 结尾不要多加/v1,TaoToken 的兼容层已经处理好了路径,多写反而会 404。如果你用的是其他兼容通道,习惯性加/v1,换到 TaoToken 时记得去掉。
注意:API Key 不要提交到 Git,
.env记得加进.gitignore。生产环境建议用环境变量注入,不要硬编码。
3. 可复制配置:工具 schema 与 ReAct 循环骨架
这一节是全文的核心,代码可以直接复制。先定义四个工具:读文件、写文件、列目录、执行命令。每个工具都用 LangChain 的tool()函数包装,包含 name、description、schema 三要素。
3.1 读文件与写文件工具
import { tool } from '@langchain/core/tools'; import fs from 'node:fs/promises'; import path from 'node:path'; import { z } from 'zod'; 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('要读取的文件路径'), }), } ); 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) { return `写入失败: ${err.message}`; } }, { name: 'write_file', description: '向指定路径写入文件内容,自动创建不存在的父目录。', schema: z.object({ filePath: z.string().describe('文件路径'), content: z.string().describe('要写入的完整内容'), }), } );fs.mkdir(dir, { recursive: true })等价于mkdir -p,会自动递归创建多级目录。不写recursive: true的话,父目录不存在会直接报错,这是新手最常踩的坑之一。
3.2 列目录与命令执行工具
const listDirectoryTool = tool( async ({ directoryPath }) => { try { const files = await fs.readdir(directoryPath); return `目录内容:\n${files.join('\n')}`; } catch (err) { return `列出目录失败: ${err.message}`; } }, { name: 'list_directory', description: '列出指定目录下的所有文件和子目录。', schema: z.object({ directoryPath: z.string().describe('目录路径'), }), } ); import { spawn } from 'node:child_process'; const executeCommandTool = tool( async ({ command, directoryPath }) => { const cwd = directoryPath || process.cwd(); console.log(`[工具调用] execute_command(${command}) cwd=${cwd}`); return new Promise((resolve) => { const child = spawn(command, [], { cwd, stdio: 'inherit', shell: true, }); let errorMsg = ''; child.on('error', (err) => { errorMsg = err.message; }); child.on('close', (code) => { if (code === 0) resolve(`命令执行成功: ${command}`); else resolve(`命令失败,退出码 ${code},错误: ${errorMsg}`); }); }); }, { name: 'execute_command', description: '执行系统命令,支持指定工作目录,实时显示输出。', schema: z.object({ command: z.string().describe('要执行的命令'), directoryPath: z.string().optional().describe('工作目录,推荐指定'), }), } );关于spawn和shell: true有个关键细节:当shell: true时,必须把整个命令作为第一个参数传入,第二个参数传空数组。如果你习惯性把命令按空格拆成cmd + args,管道|、重定向>这些 shell 特性会失效,Node.js 还会抛出 DEP0190 警告。正确写法就是上面这样:spawn(command, [], { shell: true })。
3.3 ReAct 循环骨架
工具定义好了,接下来把它们和模型串起来。这里用 TaoToken 作为模型通道:
import 'dotenv/config'; import { ChatOpenAI } from '@langchain/openai'; import { HumanMessage, SystemMessage, ToolMessage } from '@langchain/core/messages'; import chalk from 'chalk'; const model = new ChatOpenAI({ modelName: 'deepseek-v4-flash', apiKey: process.env.TAOTOKEN_API_KEY, configuration: { baseURL: process.env.TAOTOKEN_BASE_URL, }, }); const tools = [executeCommandTool, readFileTool, writeFileTool, listDirectoryTool]; const modelWithTools = model.bindTools(tools); async function runAgent(query, maxIterations = 30) { const messages = [ new SystemMessage( `你是一个编程助手。当前工作目录:${process.cwd()} 可用工具:read_file / write_file / list_directory / execute_command 规则:directoryPath 参数直接切换目录,不要用 cd 命令。回复简洁。` ), new HumanMessage(query), ]; 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(chalk.green(`[Agent] 任务完成: ${response.content}`)); return response.content; } for (const toolCall of response.tool_calls) { const foundTool = tools.find((t) => t.name === toolCall.name); if (foundTool) { const result = await foundTool.invoke(toolCall.args); messages.push( new ToolMessage({ content: result, tool_call_id: toolCall.id, }) ); } } } return messages[messages.length - 1].content; }这段循环就是 ReAct 的全部:模型思考 → 有工具调用就执行 → 结果回填 → 再思考。tool_call_id必须原样带回,因为模型可能一轮同时调用多个工具,没有这个 id 它就分不清哪个结果对应哪个请求,后续推理会乱套。
4. 验证请求:跑一次真实的代码修改任务
配置写完了,现在跑一个端到端任务验证。任务描述如下:
const task = ` 创建一个 React TodoList 应用: 1. 用 pnpm create vite react-todo-app --template react-ts 创建项目 2. 修改 src/App.tsx,实现添加/删除/标记完成/筛选/持久化 3. 添加样式 4. pnpm install 安装依赖 5. pnpm run dev 启动服务 `; runAgent(task).catch((err) => console.error(`错误: ${err.message}`));执行node agent.mjs,你会看到类似这样的轨迹:
| 轮次 | LLM 决策 | 工具 | 结果 |
|---|---|---|---|
| 1 | 创建项目脚手架 | execute_command | 成功 |
| 2 | 写入 App.tsx 组件 | write_file | 成功 |
| 3 | 写入 App.css 样式 | write_file | 成功 |
| 4 | 安装依赖 | execute_command | 成功 |
| 5 | 启动开发服务器 | execute_command | 成功 |
| 6 | 确认任务完成 | 无 | 返回总结 |
成功的关键标志是第 6 轮:模型不再请求任何工具,直接返回文本总结。这说明它认为任务已经完成。如果模型在第 6 轮还在反复调用同一个工具,通常是工具返回结果里缺少明确的成功/失败信号,模型无法判断状态,就会一直重试。
验证模型通道是否正常,可以先用模型对话页面 https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 发一条简单消息,确认 Key 和 baseURL 没问题,再跑 Agent。这样能把“模型通道问题”和“Agent 逻辑问题”分开排查。
5. 本篇常见错误排查
5.1 Schema 参数名和函数参数名不一致
现象:模型调用了工具,但函数收到的参数是undefined。
// 错误:Schema 里叫 directory,函数里用 directoryPath schema: z.object({ directory: z.string() }), async ({ directoryPath }) => { ... } // 正确:两边完全一致 schema: z.object({ directoryPath: z.string() }), async ({ directoryPath }) => { ... }LangChain 是按 schema 的字段名去解构参数的,名字对不上就取不到值。这个错误不报异常,只是静默失败,最难查。
5.2 在子进程 close 回调里调用 process.exit()
现象:执行完第一个命令后整个 Node 进程直接退出,Agent 没来得及进入下一轮 ReAct。
原因是在child.on('close')里调了process.exit()。子进程结束只是子进程的事,主进程应该继续跑循环。解决方法是不要在 close 回调里退出进程,让主流程自然控制。
5.3 忘记 recursive: true 导致写文件失败
现象:fs.mkdir('a/b/c')报错,因为父目录a/b/不存在。
解决:始终用fs.mkdir(dir, { recursive: true }),等价于mkdir -p。写文件工具里这一行不能省。
5.4 baseURL 多写了 /v1
现象:请求返回 404 或路径错误。
TaoToken 的兼容层已经处理了路径,baseURL 写https://taotoken.net/api即可,不要画蛇添足加/v1。如果你从其他通道迁移过来,这是第一个要检查的地方。
5.5 工具 description 写得太模糊
现象:模型该调read_file的时候调了execute_command,或者该写文件的时候去列目录。
工具的description是给模型看的“使用说明书”,写得越清楚,模型选得越准。比如read_file的 description 要明确写“读取文件内容,查看代码时调用”,而不是只写“读文件”。schema 里的.describe()同理,参数含义要写清楚。
6. 继续深入:把 Mini Cursor 用起来
到这里,一个能读写文件、执行命令、自主循环的 Mini Cursor 就跑通了。它的核心结构可以归纳成三层:决策层是 LLM,负责理解意图和规划步骤;工具层是四个 LangChain Tool,负责实际的文件和命令操作;调度层是 ReAct 循环,把前两者串起来直到任务完成。
如果你想把它用在长期编码或 Agent 场景,可以走 Coding Plan 通道 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,统一管理额度和 Key。接入细节和更多工具定义方式,可以查接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你用的是 Claude Code 这类工具,Anthropic 兼容入口在 https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode-anthropic&utm_campaign=rewrite 。
最后给一个实用建议:先别急着加更多工具。把读、写、列、执行这四个跑稳,理解清楚tool_call_id的作用和 ReAct 的终止条件,比堆一堆花哨功能有用得多。等这四个工具稳定了,再考虑加代码搜索、Git 操作、测试运行这些能力,那时候你已经有足够的判断力知道该怎么设计了。