我一直没搞明白那些编程 Agent 到底是怎么自己创建项目、改代码、跑命令的。Cursor 点一下就能生成整个项目,Claude Code 还能边跑边改 bug,看着特别神奇。直到这周硬着头皮写了个百来行的迷你版本,才算是把整个流程摸透了。
说出来你可能不信,核心逻辑其实就一个循环。剩下的全是细节坑。
先把最核心的问题想明白:大模型为啥需要 Tool?
说实话最开始我对 Tool 这个概念挺模糊的。不就是大模型吗,怎么还需要调用工具?
后来才反应过来,大模型本质上就是个纯文本生成器。它只会对着输入吐文字,既碰不到你电脑上的文件,也敲不了命令行,更打不开浏览器。你让它写个 React 项目,它能把代码给你列出来,但没法直接帮你存到本地、安装依赖、启动服务 ------ 它没有 "手"。
Tool 是啥?就是给大模型接了双手。
你提前定义好一批工具,比如读文件、写文件、执行命令,然后告诉大模型:"你有这些工具可以用,需要的时候直接调。" 大模型生成内容的时候,如果觉得需要动手,就不会直接给答案,而是返回一段工具调用的指令。你的程序接住这个指令,执行完把结果再扔回去,大模型根据结果继续思考下一步。
就这么个来回传话的过程,就是 Agent 的雏形。
打个不太恰当的比方,大模型就像坐在办公室里的产品经理,只会动嘴;工具就是研发工程师,能真正干活。产品经理说要做个按钮,研发去写代码,写完告诉产品经理做好了,产品经理再看下一步做啥。只不过这个产品经理反应特别快,一秒钟能想好几步。
整体思路:一个循环就是 Agent 的全部
现在业内做 Agent 最常用的就是 ReAct 框架,名字听着挺高端,拆开看就俩词:Reason(思考)和 Act(行动)。
整个流程跑起来是这样的:
- 把任务和历史对话扔给大模型,让它思考下一步该干啥
- 如果它觉得需要调用工具,就返回工具调用指令
- 程序执行工具,拿到结果
- 把结果塞回对话里,再扔给大模型,继续思考下一步
- 循环往复,直到大模型觉得任务做完了,直接返回最终答案
是不是特别像你带实习生干活?你把任务扔给他,他先想第一步该做啥,做完过来跟你汇报结果,你再告诉他下一步干啥。只不过这里 "你" 的角色也由大模型自己扮演了。
就这么一个循环,说穿了一点都不神秘。
我这次要做的就是一个能自动写代码的编程 Agent,目标很简单:给它一句 "创建一个 React TodoList 项目",它能自己用 Vite 建项目、写代码、装依赖、启动服务。
第一步:先给大模型造四只 "手"
要实现上面的目标,至少得四个工具:读文件、写文件、列目录、执行系统命令。
我用的是 LangChain 里的 tool 函数来定义工具,配合 zod 做参数校验。这个组合写起来很省心,参数不对的话直接就拦住了,不用自己写判断。
读写文件:最基础的能力
读文件最简单,接收一个文件路径,用 Node 的 fs 模块读出来返回就行。
javascript
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('要读取的文件路径')
})
}
)
写文件稍微麻烦一点,要考虑目录不存在的情况。比如要写 src/App.tsx,如果 src 目录还没建,直接写会报错。所以要用 mkdir 加 recursive: true 递归创建目录,省得大模型还要自己先建文件夹。
javascript
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) {
console.log(`[工具调用] write_file(${filePath})
错误: ${err.message}`)
return `写入文件失败:${err.message}`
}
},
{
name: 'write_file',
description: '向指定路径写入文件内容,自动创建目录',
schema: z.object({
filePath: z.string().describe('文件路径'),
content: z.string().describe('要写入的文件内容')
})
}
)
说句题外话工具的 description 特别重要,这是大模型判断什么时候用这个工具的唯一依据。写得越清楚,大模型调用得越准。别写太简略,也别写太绕,说人话就行。
列目录:别小看这个,坑在细节
列目录这个工具,说出来有点丢人,我卡了快二十分钟。
最开始我顺手写了 files.map(file => file.name),跑起来一直报 undefined。我还以为是 fs 模块出问题了,翻了半天 Node 文档才反应过来 ------fs.readdir 返回的就是字符串数组啊!哪来的 file.name?纯粹是写别的接口写顺嘴了,想当然了。
改完之后长这样:
javascript
const listDirectoryTool = tool(
async ({ directoryPath }) => {
try {
const files = await fs.readdir(directoryPath);
console.log(`[工具调用] list_directory(${directoryPath})
成功列出 ${files.length} 个文件和文件夹`)
return `目录内容:\n ${files.join('\n')}`
} catch(err) {
console.log(`[工具调用] list_directory(${directoryPath})
错误: ${err.message}`)
return `列出目录内容失败:${err.message}`
}
},
{
name: 'list_directory',
description: '列出指定目录下的所有文件和文件夹',
schema: z.object({
directoryPath: z.string().describe('目录路径')
})
}
)
执行命令:这才是真正的杀招
前面三个工具都只能操作文件,真正让 Agent 拥有 "生产力" 的,是执行系统命令的能力。能跑命令,就能装依赖、启服务、打包构建,甚至还能 git 提交。
这里我用了 child_process.spawn 而不是 exec,因为 spawn 可以实时输出日志,用户能看到命令执行的过程,不会像 exec 那样执行完了才一次性吐出来,体验好很多。
javascript
const executeCommandTool = tool(
async ({ command, workingDirectory }) => {
const cwd = workingDirectory || process.cwd();
console.log(`[工具调用] execute_command(${command})
工作目录:${cwd}`);
return new Promise((resolve, reject) => {
const [cmd, ...args] = command.split(' ');
const child = spawn(cmd, args, {
cwd,
stdio: 'inherit', // 直接把子进程输出接到主进程,实时看日志
shell: true, // 别问我为什么知道,不加这个很多命令跑不起来
})
let errorMsg = '';
child.on('error', (err) => {
errorMsg = err.message
});
child.on('close', (code) => {
if (code === 0) {
console.log(`[工具调用] execute_command(${command})
成功执行`)
const cwdInfo = workingDirectory?
`\n\n重要提示:命令在目录"${workingDirectory}" 执行`
: '';
resolve(`命令行成功执行 ${command}${cwdInfo}`);
} else {
console.log(`[工具调用] execute_command(${command})
退出码:${code}`)
resolve(`命令执行失败,退出码:${code}\n 错误:${errorMsg}`)
}
})
})
},
{
name: 'execute_command',
description: '执行系统命令,支持指定工作目录,实时显示输出',
schema: z.object({
command: z.string().describe('要执行的命令'),
workingDirectory: z.string().describe('工作目录(推荐指定)')
})
}
)
注意这个坑:别同时用 cd 和 workingDirectory这个坑是真的坑,我踩完还愣了半天。
最开始写命令执行的时候,我习惯性地写了 cd react-todo-app && pnpm install,同时又传了 workingDirectory: 'react-todo-app'。结果一跑就报错说找不到目录。我还纳闷,刚创建的项目怎么就没了?去文件夹里看明明在啊。
后来盯着参数看了半天才拍大腿 ------workingDirectory 已经帮你把工作目录切到 react-todo-app 了,你再 cd 一次,相当于要在 react-todo-app 里面再找一个 react-todo-app,那能找到才怪。
说穿了就是一层窗户纸,但没踩过的话真容易犯迷糊。所以我后来直接在 System 提示词里把这条规则写死了,就怕大模型也犯同样的错。
第二步:把工具和大模型串成循环
工具都备齐了,接下来就是把它们和大模型串起来 ------ 这就是 Agent 的主循环。
我用的是 DeepSeek 的模型,通过 LangChain 的 ChatOpenAI 接入,因为兼容 OpenAI 格式,直接改 baseURL 就行。
javascript
const model = new ChatOpenAI({
modelName:'deepseek-v4-pro',
apiKey: process.env.DEEPSEEK_API_KEY,
temperature: 0, // 写代码温度低一点更稳定
configuration: {
baseURL: 'https://api.deepseek.com/v1',
},
});
const tools = [
readFileTool,
writeFileTool,
listDirectoryTool,
executeCommandTool
]
const modelWithTools = model.bindTools(tools);
这四种 Message 到底啥区别
LangChain 里有四种常用的消息类型,最开始我也记不住,用多了就明白了:
- SystemMessage:就是给大模型定规矩的。你是谁、你有啥工具、你要遵守什么规则,全写在这里。整个对话过程中一般不变。
- HumanMessage:用户说的话。也就是最开始的任务指令。
- AIMessage:大模型返回的话。如果它要调用工具,工具调用信息也在这条消息里。
- ToolMessage :工具执行完的结果。必须带上
tool_call_id,不然大模型不知道这个结果对应哪次调用。
很多人刚接触的时候觉得 ToolMessage 很抽象,其实你就把它理解成 "工具的回话" 就行。大模型喊了一声 "帮我读一下这个文件",工具读完了回一句 "内容是 xxx",这个回话就是 ToolMessage。
循环跑起来的完整流程
核心的 ReAct 循环其实就几十行代码。我设了个最大迭代次数 30 次,防止大模型钻牛角尖无限循环。
javascript
async function runAgentWithTools(query, maxIterations=30) {
const messages = [
new SystemMessage(`你是一个项目管理助手,使用工具完成任务。
当前工作目录: ${process.cwd()}
工具:
1. read_file: 读取文件
2. write_file: 写入文件
3. execute_command: 执行命令(支持 workingDirectory 参数)
4. list_directory: 列出目录
重要规则 - execute_command:
- workingDirectory 参数会自动切换到指定目录
- 当使用 workingDirectory 时,绝对不要在 command 中使用 cd
- 错误示例: { command: "cd react-todo-app && pnpm install", workingDirectory: "react-todo-app" }
正确示例: { command: "pnpm install", workingDirectory: "react-todo-app" }
回复要简洁,只说做了什么
`),
new HumanMessage(query)
];
// ReAct 循环,Agent 的灵魂就在这
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(`\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
}))
}
}
}
return messages[messages.length - 1].content
}
就这么一个 for 循环,就是整个 Agent 的核心。
每次循环都把当前所有消息扔给大模型。如果它返回了工具调用,就执行,把结果加进消息列表,下一轮继续。如果没返回工具调用,说明它觉得任务完成了,直接输出结果。
我第一次跑通的时候盯着控制台看了半天,看着它一步步创建项目、写文件、装依赖,那种感觉特别奇妙 ------ 就像看着一个笨手笨脚的实习生,居然真的把活干完了。
跑起来看看:Agent 自动生成的 TodoList 效果
光说流程没概念,给你们看一眼最终跑出来的页面。这就是 Agent 照着需求自动生成、安装依赖并启动后的完整效果:

整个页面用了蓝到紫的渐变背景,中间是一块大圆角的白色半透明卡片,配了柔和的投影,视觉上很干净。顶部是 Todo List 标题和 "保持专注,完成更多" 的副标题,下面是圆角输入框和一个紫蓝色的渐变加号按钮,输入框聚焦和按钮悬停都有微动效。
中间是三个筛选标签:全部、进行中、已完成,每个标签右上角带数量角标,切换选中态时有平滑的背景过渡。任务列表每一项左边是圆形复选框,右边是删除叉号,勾选完成后文字自动变灰加删除线,添加和删除任务都带淡入淡出的过渡动画,不会生硬地跳变。
卡片底部左右两侧分别统计待完成和已完成的数量,数据存在 localStorage 里,刷新页面不会丢失。
说实话我只在需求里写了 "样式要美观,要有动画效果" 这么一句模糊的话,它自己把渐变、阴影、圆角、悬停反馈、过渡动画全配上了,审美还挺在线。不用自己一句句调 CSS,这大概就是用编程 Agent 最爽的时刻。
我踩过的几个坑,你们别再踩了
整个写下来也就两百多行代码,但坑是真不少。挑几个印象深的说说。
第一个坑就是前面提过的,workingDirectory 和 cd 重复的问题。这个真的是低级错误,但特别容易犯。
第二个坑是 spawn 拆参数。最开始我用 command.split(' ') 把命令拆成命令名和参数,遇到带空格的路径直接就炸了。后来加了 shell: true 才解决,让系统 shell 去处理参数拆分,省事多了。
第三个坑是无限循环。我最开始没设最大迭代次数,有一次任务描述得比较模糊,大模型就卡在那反复读同一个文件,读了十几次还停不下来。所以一定要设兜底,要么限次数,要么加超时。我最后加了个一百万毫秒的超时强制退出,虽然粗暴但管用。
第四个坑是工具描述写得不清楚。一开始我把 write_file 的描述写得很简略,结果大模型经常用错,本来该写文件的时候它直接把代码贴在回复里。后来把描述写详细了,调用准确率立马就上去了。
往深扒一层:langchain 的 bindTools 到底干了啥
说实话最开始我以为 bindTools 是什么高深的东西,翻了下源码才发现,本质就是个语法糖。
它干了两件事:
- 把你传进去的 tools 数组,按 OpenAI 要求的格式转换成 JSON Schema,每次请求都自动带在请求体里
- 大模型返回结果的时候,帮你把
tool_calls从响应里解析出来,挂到 response 对象上,不用你自己去扒additional_kwargs
就这么点事。没有 bindTools 你自己拼参数、自己解析也能做,就是麻烦点。
很多人觉得 LangChain 很厚重,其实它核心做的就是两件事:一是兼容各家大模型的接口,让你换模型不用改代码;二是把常用的模式(比如工具调用、链、Agent)封装成现成的方法,少写点样板代码。
它不是什么黑魔法,就是个工程化的工具库。
最后说几句实在的
写完这个迷你 Agent 之后,我最大的感受有三个。
第一,Agent 真的没那么玄乎。很多人把它吹得像有自我意识一样,其实核心就是 "大模型决策 + 工具执行 + 循环反馈" 这三件套。真正的智能来自大模型本身,框架只是个传话的。理解了这一点,再看各种花里胡哨的 Agent 产品,你都能一眼看到底。
第二,工具的边界比工具本身更重要。我这个迷你版为了简单,啥限制都没加,理论上它能删你系统文件、能格式化硬盘。真要做生产级的 Agent,路径白名单、命令白名单、人工确认这些安全措施一个都不能少。能力越强,越要套缰绳。
第三,编程 Agent 不是银弹。它擅长做那种 "有明确步骤、重复性高" 的任务,比如搭项目骨架、改配置、写样板代码。但真要调试复杂 bug、做架构设计、理解业务逻辑,它还差得远。它是提效工具,不是替代品。
当然了,我这个只是最简陋的版本。真正的 Cursor 和 Claude Code 还做了很多优化,比如上下文管理、多轮规划、错误自动修复、代码理解等等。但核心骨架,其实都差不多。
如果你也跟着手搓了一遍,或者有别的坑我没提到,评论区留个言,我也想看看你的实现思路。