前端转AI Agent实战:从零手写一个Cursor级代码助手
1. 为什么前端转 AI Agent 的第一站是造一个 Cursor前端开发者转 AI Agent最容易踩的坑就是一上来去啃 LangChain 的源码或者抱着论文硬读 ReAct、Reflexion 这些概念。我见过太多人卡在这一步最后不了了之。真正高效的路径其实反直觉先动手做一个能用的东西哪怕它很简陋然后再回头补理论。而造一个 Cursor就是这条路径上最合适的练手项目。为什么是 Cursor 而不是别的因为 Cursor 这类 AI 代码编辑器本质上是一个闭环的 Agent 系统它要理解你的意图、读取文件、生成代码、执行命令、根据结果调整。这套流程几乎覆盖了 AI Agent 的所有核心能力——工具调用、上下文管理、多轮决策、错误恢复。你把它拆开看会发现它没有想象中那么神秘核心就是一个LLM 工具集 循环控制的组合。对前端来说这件事的门槛比想象中低。你熟悉 JavaScript/TypeScript懂异步流程会处理 UI 状态这些恰好是 Agent 编排最需要的能力。Agent 的思考-行动-观察循环用前端的话说就是一个带状态的异步任务队列。你不需要先成为算法工程师你需要的是把已有的工程能力迁移过来。这篇文章我会带你从零搭一个最小可用的 AI 代码助手它能读文件、改代码、跑命令并且能根据执行结果自己决定下一步。全程用 TypeScript不依赖重型框架把每一步的为什么讲清楚。读完你手里会有一个能跑的东西而不是一堆概念。提示本文面向有前端基础、想切入 AI Agent 方向的开发者。如果你连 Node.js 的 fs 模块都没用过建议先补一下基础再回来。2. 拆解 Cursor 的核心能力一个 Agent 到底需要什么2.1 从聊天机器人到Agent的分水岭普通聊天机器人是你问一句它答一句无状态、无行动。Agent 的关键区别在于它能采取行动并观察结果。你让 Cursor 帮你改一个 bug它不是直接吐一段代码给你而是先读相关文件理解上下文然后修改文件再运行测试如果测试失败它会看报错信息再改一次。这个行动-观察-再行动的循环就是 Agent 的灵魂。用前端能理解的话说聊天机器人是一个纯函数输入输出确定Agent 是一个带副作用的状态机每一步的输出会影响下一步的输入。这个状态机的核心循环学术上叫 ReActReasoning Acting但你别被名字吓到它落地下来就是一个 while 循环。2.2 三个必备组件大脑、手脚、记忆一个能用的代码 Agent拆开就是三样东西大脑LLM负责决策。给它当前的任务状态和可用工具它输出下一步该干什么。这是唯一需要调用外部模型的地方。手脚工具集负责执行。读文件、写文件、执行命令、搜索代码每个工具就是一个函数有明确的输入输出。记忆上下文管理负责记录。把历史对话、工具执行结果、当前文件状态组织成 LLM 能理解的格式塞进下一次请求。这三样东西里前端最容易低估的是记忆管理。很多人以为 Agent 难在模型其实难在上下文。一个真实任务跑下来工具调用结果可能几十条全塞进去会超 token 限制塞少了模型又失忆。Cursor 之所以好用很大一部分功夫花在上下文裁剪和检索上。2.3 工具调用的本质让 LLM 输出结构化数据工具调用听起来高级本质很简单你告诉 LLM 你有这些工具每个工具需要什么参数然后要求它输出一段 JSON说明它想调用哪个工具、传什么参数。你的代码解析这段 JSON执行对应函数把结果再喂回去。关键点在于约束 LLM 的输出格式。早期做法是在 prompt 里写请输出 JSON但模型经常不听话多写一句话就解析失败。现在主流方案是用模型原生的 function calling 能力或者用 JSON Schema 强约束。我实测下来对于简单 Agent直接在 prompt 里给几个 few-shot 示例配合严格的解析容错就够用了。// 工具定义的结构这是整个 Agent 的手脚清单 interface Tool { name: string; description: string; parameters: Recordstring, { type: string; description: string }; execute: (args: any) Promisestring; }每个工具的 description 极其重要它是 LLM 判断什么时候用这个工具的唯一依据。写 description 的原则是说清楚做什么和什么时候用而不是怎么实现。比如读文件工具description 应该写读取指定路径的文件内容当你需要查看某个文件的现有代码时使用而不是调用 fs.readFileSync。3. 环境搭建与项目骨架把地基打对3.1 技术选型为什么不用 LangChain新手最容易犯的错是上来就装 LangChain。我的建议是第一个 Agent 千万别用框架。原因有三个。第一框架封装了太多细节你调不通的时候根本不知道问题出在 prompt、工具定义还是循环控制。第二LangChain 的抽象层很厚一个简单功能要理解 Chain、Agent、Tool、Memory 一堆概念学习成本反而更高。第三你自己手写的循环只有一两百行完全可控出问题一眼能定位。等你手写过一个能跑的 Agent再去看 LangChain会发现它做的事你都懂这时候用框架才是提效而不是添乱。这个顺序不能反。技术栈就选最朴素的Node.js TypeScript 一个模型 SDK。模型我建议用支持 function calling 的这样工具调用最稳。如果你只是想先跑通流程任何能返回文本的模型都行我们自己在 prompt 里做 JSON 约束。3.2 目录结构为后续扩展留好口子项目结构不用复杂但要有清晰的边界。我习惯这样分agent-demo/ ├── src/ │ ├── tools/ # 所有工具的实现 │ │ ├── readFile.ts │ │ ├── writeFile.ts │ │ ├── runCommand.ts │ │ └── index.ts # 工具注册表 │ ├── agent.ts # 核心循环 │ ├── llm.ts # 模型调用封装 │ ├── context.ts # 上下文管理 │ └── index.ts # 入口 ├── package.json └── tsconfig.json这个结构的关键是工具和循环分离。工具只管执行不关心谁调用循环只管调度不关心工具怎么实现。这样你后面加新工具只需要在 tools 目录加文件、在注册表登记循环代码一行不用改。这是可扩展性的基础。3.3 模型调用的封装把易变的部分隔离模型调用是最容易变的部分——今天用这家明天换那家接口格式都不一样。所以一定要封装一层。封装的目标是上层循环只认一个统一的接口底层换模型不影响上层。// llm.ts interface Message { role: system | user | assistant | tool; content: string; } async function callLLM(messages: Message[]): Promisestring { // 这里对接具体模型返回纯文本 // 关键把模型差异全部吃掉上层只拿到字符串 }我踩过的一个坑早期我把模型返回的原始对象直接透传到循环里结果换模型时到处报错。后来强制在 llm.ts 里做归一化上层永远只处理字符串问题就消失了。这个隔离层看着简单但能省掉后面大量的重构。注意模型调用一定要加超时和重试。Agent 循环里一次调用卡住整个任务就挂了。我一般设 60 秒超时失败重试 2 次重试时把错误信息也带上让模型有机会自我修正。4. 核心循环实现Agent 的思考-行动-观察怎么落地4.1 主循环的骨架一个 while 循环就够了Agent 的核心循环剥掉所有包装就是一个 while 循环。每一轮把当前上下文发给 LLMLLM 返回一个动作调用工具或给出最终答案执行动作把结果加入上下文进入下一轮。直到 LLM 说我完成了或者达到最大轮数。async function runAgent(task: string, maxSteps 15) { const context initContext(task); for (let step 0; step maxSteps; step) { const response await callLLM(context.messages); const action parseAction(response); if (action.type finish) { return action.answer; } const result await executeTool(action.tool, action.args); context.addToolResult(action.tool, result); } return 达到最大步数限制任务未完成; }就这么点代码但它就是 Agent 的心脏。maxSteps 这个限制非常重要它是防止 Agent 陷入死循环的保险丝。我见过没有步数限制的 Agent遇到一个它解决不了的问题会疯狂重试同一个工具烧掉大量 token。15 步对大多数代码任务够用复杂任务可以放宽到 30。4.2 动作解析让 LLM 的输出可被程序理解parseAction 是整个循环里最需要防御性编程的地方。LLM 的输出永远可能不符合预期你必须假设它会出错。我的做法是在 system prompt 里明确规定输出格式给出正例和反例然后在解析时做多层容错。function parseAction(response: string): Action { // 第一层尝试直接 JSON.parse try { const parsed JSON.parse(response); return normalizeAction(parsed); } catch {} // 第二层从文本里提取 JSON 块 const jsonMatch response.match(/json\n([\s\S]*?)\n/); if (jsonMatch) { try { return normalizeAction(JSON.parse(jsonMatch[1])); } catch {} } // 第三层兜底当作最终答案 return { type: finish, answer: response }; }这个三层容错是我反复调试出来的。第一层处理规范输出第二层处理模型爱加 markdown 代码块的毛病第三层保证即使完全解析失败也不会让程序崩溃而是把模型的原始输出当作答案返回。宁可返回一个不完美的答案也不要抛异常中断整个流程。4.3 上下文管理Agent 的记忆怎么组织上下文管理是决定 Agent 好不好用的关键。最朴素的做法是把所有历史消息都塞进去但很快会超 token 限制。我的策略是分层保留system prompt永远保留包含工具定义和输出格式要求。原始任务永远保留这是 Agent 的北极星。最近 N 轮工具调用完整保留保证短期记忆连贯。更早的工具调用压缩成摘要比如已读取 a.ts、b.ts修改了 c.ts。function buildContext(state: AgentState): Message[] { const messages: Message[] [systemPrompt, originalTask]; // 保留最近 6 轮完整记录 const recent state.history.slice(-6); const older state.history.slice(0, -6); if (older.length 0) { messages.push({ role: system, content: 之前的操作摘要${summarize(older)} }); } messages.push(...recent); return messages; }这个近期完整 远期摘要的模式是我试过性价比最高的方案。它既保证了 Agent 不会忘记刚做过什么又控制了 token 消耗。summarize 函数可以很简单就是把工具名和关键参数列出来不需要调用模型。5. 工具集设计读、写、跑三件套怎么做得稳5.1 读文件工具路径安全是第一位的读文件工具看着简单但有个致命问题路径穿越。如果 LLM 生成了../../etc/passwd这样的路径你的 Agent 就可能读到不该读的东西。所以第一件事是限制工作目录。import path from path; import fs from fs/promises; const WORKSPACE path.resolve(./workspace); async function readFile(args: { path: string }): Promisestring { const fullPath path.resolve(WORKSPACE, args.path); // 关键确保解析后的路径仍在工作区内 if (!fullPath.startsWith(WORKSPACE)) { return 错误路径超出工作区范围拒绝访问; } try { const content await fs.readFile(fullPath, utf-8); return content; } catch (e) { return 读取失败${(e as Error).message}; } }注意这里返回错误的方式——不抛异常而是返回错误字符串。因为错误信息要喂回给 LLM让它知道这次操作失败了原因是什么它才能调整策略。如果你抛异常循环就断了Agent 失去了自我修正的机会。这是 Agent 工具设计和普通函数设计最大的区别。5.2 写文件工具为什么要有先读后写的约束写文件工具最大的风险是覆盖掉不该覆盖的内容。LLM 有时候会想当然地重写整个文件结果把用户其他代码删了。我的做法是加一个约束写之前必须先读过这个文件。如果 Agent 没读过就要写工具直接拒绝。const readFiles new Setstring(); async function writeFile(args: { path: string; content: string }): Promisestring { const fullPath path.resolve(WORKSPACE, args.path); if (!fullPath.startsWith(WORKSPACE)) { return 错误路径超出工作区范围; } if (!readFiles.has(fullPath)) { return 错误写入前必须先读取该文件请先调用 readFile; } await fs.writeFile(fullPath, args.content, utf-8); return 已写入 ${args.path}共 ${args.content.length} 字符; }这个先读后写的约束逼着 Agent 在修改前先了解现状大幅降低了误删风险。readFiles 这个 Set 在 readFile 成功时更新。这个设计思路来自代码审查的基本原则你不该修改你没看过的代码。5.3 执行命令工具白名单比黑名单靠谱执行命令是最强大也最危险的工具。我的建议是用白名单只允许执行明确列出的命令前缀比如npm test、npm run build、node、tsc这些。黑名单永远堵不完白名单才是安全的。const ALLOWED_COMMANDS [npm test, npm run build, npx tsc, node ]; async function runCommand(args: { command: string }): Promisestring { const cmd args.command.trim(); if (!ALLOWED_COMMANDS.some(prefix cmd.startsWith(prefix))) { return 错误命令 ${cmd} 不在允许列表中; } try { const { stdout, stderr } await execAsync(cmd, { cwd: WORKSPACE, timeout: 30000, }); return 输出\n${stdout}\n${stderr ? 错误输出\n stderr : }; } catch (e: any) { return 执行失败${e.message}\n${e.stdout || }\n${e.stderr || }; } }这里有个细节命令执行失败时要把 stdout 和 stderr 都返回。因为很多测试框架失败时关键信息在 stdout 里报错在 stderr 里两个都要给 LLM 看它才能判断问题出在哪。我一开始只返回 stderr结果 Agent 经常看不懂测试为什么失败补上 stdout 后成功率明显提升。5.4 工具注册表让循环和工具解耦所有工具通过一个注册表统一暴露给循环。循环不需要知道有哪些工具只需要遍历注册表生成 prompt 里的工具描述。// tools/index.ts export const tools: Tool[] [ { name: readFile, description: 读取工作区内指定文件的内容。当你需要查看某个文件的现有代码时使用。, parameters: { path: { type: string, description: 相对于工作区的文件路径 } }, execute: readFile, }, // ... 其他工具 ]; export function getToolByName(name: string): Tool | undefined { return tools.find(t t.name name); }这个注册表模式的好处是加新工具只需要往数组里加一项循环代码完全不用动。而且工具描述集中在一处方便统一调整 prompt。我后面加搜索代码工具时就是加了一个文件、注册表加一项五分钟搞定。6. 实测中的坑Agent 为什么不听话6.1 坑一模型不按格式输出解析频繁失败这是新手遇到的第一个坑。你在 prompt 里写请输出 JSON模型十次有三次给你加一句好的我来帮你然后 JSON 就解析失败了。我的解决方案是三重加固prompt 里给严格的格式示例、解析时做多层容错、失败时把错误反馈给模型让它重试。具体做法是在 system prompt 里明确写你的每次回复必须且只能是一个 JSON 对象不要有任何其他文字。然后给一个完整的示例。实测下来加上 few-shot 示例后格式错误率从 30% 降到 5% 以下。剩下的 5% 靠解析容错兜底。6.2 坑二Agent 陷入读文件-读文件-读文件的死循环我遇到过一个典型场景Agent 要改一个 bug它读了 a.ts没找到问题又读了一遍 a.ts还是没找到再读一遍。三轮下来 token 烧了不少问题没解决。根因是它没有已经读过这个文件的记忆。解决方案是在上下文里显式记录已执行的操作。我在每轮工具结果里都带上操作摘要并且在 system prompt 里加一句不要重复读取已经读过的文件除非文件内容发生了变化。加上这个约束后重复读取的情况基本消失了。6.3 坑三错误信息太简略Agent 无法自我修正前面提过工具返回错误时信息要足够详细。我踩过的坑是runCommand 失败时只返回命令执行失败Agent 完全不知道哪里错了只能瞎猜。后来改成返回完整的 stdout stderrAgent 立刻就能根据报错定位问题。这个经验可以推广到所有工具错误信息要包含发生了什么和可能的原因。比如读文件失败不要只说读取失败要说读取失败文件不存在路径 xxx。信息越具体Agent 自我修正的能力越强。6.4 坑四maxSteps 设太小复杂任务做不完一开始我把 maxSteps 设成 5觉得够用了。结果一个稍微复杂的重构任务Agent 读到第 5 步还没开始改代码就被强制中断了。后来调到 15大部分任务能完成。再复杂的任务我会在 prompt 里提示 Agent 优先完成核心目标不要过度探索。maxSteps 的设置是个权衡太小任务做不完太大浪费 token 且可能死循环。我的经验值是简单任务 10中等任务 20复杂任务 30。同时配合连续 3 步没有实质性进展就中断的额外判断双保险。7. 从 Demo 到可用下一步该补什么7.1 加一个代码搜索工具让 Agent 能定位只有读、写、跑三个工具Agent 找代码只能靠猜路径。加一个搜索工具让它能按关键词或正则找文件效率会大幅提升。实现上可以用简单的递归遍历 内容匹配不需要上重型索引。async function searchCode(args: { keyword: string }): Promisestring { const results: string[] []; // 递归遍历 WORKSPACE匹配包含 keyword 的文件和行号 // 返回格式文件路径:行号: 内容 return results.join(\n) || 未找到匹配内容; }这个工具的价值在于它把大海捞针变成了精确定位。Agent 拿到搜索结果后可以直接读相关文件而不是盲目遍历。我加上这个工具后Agent 完成任务的步数平均减少了 30%。7.2 上下文压缩长任务的必修课任务一长上下文必然膨胀。除了前面说的近期完整 远期摘要还可以引入向量检索把历史操作存成向量每轮只检索最相关的几条塞进上下文。不过这属于进阶优化第一个版本用摘要就够了。我的建议是先把摘要方案跑通观察哪些信息被压缩后 Agent 会失忆再针对性优化。不要一上来就上向量库那是过度设计。7.3 人工确认环节危险操作要拦一道写文件和执行命令这两个工具在生产环境里应该加人工确认。做法很简单工具执行前把操作详情打印出来等用户输入 y/n 再继续。这在开发阶段可能嫌烦但一旦 Agent 开始操作真实项目这道防线能救命。async function confirmAction(description: string): Promiseboolean { // 打印操作详情等待用户确认 // 返回 true 才继续执行 }我自己的习惯是读操作和搜索操作自动放行写操作和命令执行必须确认。这样既保证了效率又守住了安全底线。7.4 日志与可观测性出问题时能复盘Agent 跑起来是个黑盒出问题必须能复盘。我的做法是把每一轮的完整上下文、LLM 原始输出、工具执行结果都写到日志文件。调试时打开日志一眼就能看出是哪一步决策错了。这个习惯帮我定位过很多诡异问题比如某次 Agent 反复调用同一个工具看日志发现是工具返回的结果里有个特殊字符导致 LLM 解析异常。没有日志这种问题根本无从查起。8. 我做完这个 Demo 之后的几点真实体会第一个体会是Agent 的难点不在模型在工程。模型能力是现成的但怎么组织上下文、怎么设计工具、怎么处理错误这些才是决定 Agent 好不好用的关键。前端转过来的人在这方面反而有优势因为这些都是工程问题不是算法问题。第二个体会是别追求一步到位。我第一版 Agent 只有读文件和写文件两个工具连命令执行都没有但它已经能帮我做一些简单的代码修改了。先跑通最小闭环再逐步加能力这个节奏比憋大招靠谱得多。第三个体会是prompt 是要反复调的。同一个工具描述换个说法Agent 的表现可能天差地别。我建议把 prompt 当成代码一样管理每次改动都记录效果慢慢就能摸出规律。工具描述里什么时候用比做什么更重要这一点我调了很多次才真正理解。最后一个实用技巧如果你想让 Agent 更稳可以在 system prompt 里加一句在调用工具前先用一句话说明你的计划。这个思考前置的约束能让 Agent 的决策更连贯也方便你调试时理解它的意图。实测下来加上这句话后Agent 跑偏的概率明显降低。