230行零依赖Node.js AI Agent手搓指南

📅 2026/6/24 19:15:10
230行零依赖Node.js AI Agent手搓指南
1. 为什么230行代码能跑通一个AI Agent先拆解“Agent”到底在做什么很多人看到“AI Agent”四个字第一反应是这玩意儿不就得搭大模型API、接向量库、配工作流引擎、搞状态管理、上调度队列动辄几百个文件、十几个依赖包光package.json里devDependencies都得拉三屏。结果标题说“230行、零依赖、单文件”——不是吹牛就是骗点击我一开始也这么想。直到我把这个文件拖进VS Code逐行加断点跑了一遍才真正理解它为什么能成立。核心在于它没去复刻LangChain或LlamaIndex那种企业级抽象层而是直击ReAct范式最原始的神经末梢——推理Reason→ 行动Act→ 观察Observe→ 再推理。它把整个循环压缩成一个纯函数调用链连async/await都只用在最关键的HTTP请求处其余全是同步逻辑。没有中间件、没有插件系统、没有状态持久化——所有“智能”都来自Prompt Engineering与结构化输出解析的精准配合。比如当用户问“上海今天气温多少”它不会去查数据库或调天气API那是后续扩展的事而是先让模型判断“这需要调用什么工具”——模型输出JSON格式的{tool: weather, query: 上海}接着程序用正则JSON.parse安全提取字段不用eval也不引入json5再拼接URL发请求最后把响应喂回模型做最终总结。整个过程像一台精密的手摇留声机每个齿轮咬合严丝合缝但全靠人力上发条没有自动变速器。提示这种设计天然规避了“依赖爆炸”。你不需要axios——Node.js原生https模块够用不需要zod——手写几行正则就能校验JSON片段不需要uuid——时间戳随机数足够生成临时session ID。所谓“零依赖”本质是对每行代码的控制权绝不让渡。这不是偷懒而是把复杂度从“依赖管理”转移到“逻辑自控”。我试过把它部署到树莓派4B上——没装npm直接node agent.js就跑起来了。它不关心你用的是OpenAI、Claude还是本地Ollama只要API返回符合约定的JSON格式它就能继续转。这种“协议优先、实现其次”的思路正是手搓Agent最硬核的起点。2. 代码骨架拆解230行里哪37行决定了成败我把原始代码按功能切成了五块发现真正决定Agent能否活过第一个交互的其实是其中37行——它们构成了整个系统的“心脏起搏器”。下面逐段还原并解释为什么不能删、不能改2.1 核心循环ReAct的最小可行闭环第89–112行function runReActLoop(prompt, model, tools, maxSteps 5) { let history [{ role: user, content: prompt }]; for (let step 0; step maxSteps; step) { const response await callLLM(history, model); // 调用大模型 const action parseAction(response); // 解析模型是否要调工具 if (!action) { return response; // 模型说“我直接回答”结束循环 } const observation await executeTool(action, tools); // 执行工具 history.push({ role: assistant, content: response }); history.push({ role: user, content: Observation: ${observation} }); // 把观察结果喂回去 } return Reached max steps without final answer; }这段代码只有24行但藏着三个关键设计历史记录的极简主义history数组只存{role, content}不存token数、不存timestamp、不存tool_calls。因为每次调用模型时我们只关心“上一句人说了啥上一句模型干了啥上一次观察结果是啥”。多存一个字段就多一分序列长度失控风险。parseAction的防御性解析它不指望模型输出完美JSON。实际代码里是这样写的function parseAction(text) { const match text.match(/action([\s\S]*?)\/action/); if (!match) return null; try { return JSON.parse(match[1].trim()); } catch (e) { return null; // 解析失败就当没这回事避免崩溃 } }用XML标签包裹JSON比纯正则匹配{}更鲁棒——模型偶尔多打个逗号或少个引号标签还能兜住。这是我在调试时被坑了7次后加上的。Observation注入的时机卡点必须在history.push之后立刻把observation作为user角色塞进去。如果错写成assistant模型会以为这是它自己说的导致幻觉叠加。这个细节在LangChain文档里藏得很深但手搓时一眼就能看清。2.2 工具注册机制如何让模型“知道”能调什么第45–62行const tools [ { name: weather, description: Get current weather for a city. Input: {\city\: \Shanghai\}, execute: async (input) { const res await fetch(https://api.example.com/weather?q${input.city}); return JSON.stringify(await res.json()); } } ];这里的关键不是fetch而是description字段——它直接喂进了system prompt。模型看到这段文字才知道“weather”是个可用工具且输入格式必须是{city: xxx}。我试过把description写成“查天气”模型永远猜不出参数名写成“输入城市名”它会传{q: Shanghai}导致API报错。工具描述给模型的API文档一字之差满盘皆输。注意execute函数必须返回Promisestring且字符串内容要能被模型读懂。我曾返回二进制图片Buffer模型直接懵了。后来改成returnImage URL: ${url};它就能在总结里说“我找到了一张上海外滩的照片”。2.3 Prompt工程用300字符撬动模型的思维链第15–42行const systemPrompt You are a helpful AI assistant that follows the ReAct pattern. When you need external information, use action{...}/action with valid JSON. Available tools: ${tools.map(t ${t.name}: ${t.description}).join(; )} Your output must be one of: - Final answer: plain text, no tags - Action: action{tool:name,input:{...}}/action Never output both. Never explain your reasoning. ;这段prompt只有12行但经过17版迭代。早期版本写“请按ReAct模式思考”模型要么不调工具要么疯狂嵌套action后来加上“Never output both”错误率降了60%最后补上“Never explain your reasoning”才真正杜绝了模型在action外自说自话。手搓Agent的Prompt不是写作文是写电路图——每个标点都在控制电流走向。3. 零依赖的代价哪些“便利”被主动放弃以及为什么值得“零依赖”听起来很酷但背后是大量显性成本的转移。这不是技术洁癖而是对可控性的极致追求。我列了一张对比表说明每放弃一个常见依赖换来什么放弃的依赖原本能省的事手动实现的代码量换来的收益实测影响axios自动重试、超时、拦截器12行封装https.request错误堆栈100%指向业务层无黑盒调试时间减少40%因网络抖动导致的假失败归零zodJSON Schema校验、类型推导8行正则JSON.parse容错启动时间从320ms降到47msV8冷启动树莓派上首响应快6倍适合边缘设备uuid生成唯一IDDate.now().toString(36)Math.random().toString(36).substr(2,5)无额外内存占用无随机数种子依赖session ID在低熵环境如Docker容器仍稳定dotenv环境变量加载process.env.MODEL_API_KEYfs.readFileSync(.env,utf8).match(/API_KEY(.*)/)[1]最典型的例子是重试机制。axios默认3次重试但它的重试逻辑会吞掉原始错误码。有次API返回429 Too Many Requestsaxios默默重试结果下游服务被压垮。而手搓的retryFetch函数是这样写的async function retryFetch(url, options, retries 2) { try { const res await fetch(url, options); if (res.status 500 retries 0) { await new Promise(r setTimeout(r, 1000)); // 指数退避可在此扩展 return retryFetch(url, options, retries - 1); } return res; } catch (err) { if (retries 0) { await new Promise(r setTimeout(r, 1000)); return retryFetch(url, options, retries - 1); } throw err; // 最终错误必须抛出不能静默 } }它只重试5xx和网络错误4xx直接上报——因为429是业务限流重试只会雪上加霜。这种颗粒度的控制是任何通用HTTP库给不了的。经验零依赖不等于拒绝轮子而是把轮子拆开只装你需要的齿。我保留了Node.js 18的stream/web用TextEncoderStream处理大模型流式响应因为它原生支持且API比node-fetch的ReadableStream更贴近WHATWG标准。4. 从单文件到生产级四步演进路径与踩坑实录230行代码只是起点。我在真实项目中把它扩展成支撑日均2万请求的服务走了四步每步都踩过坑4.1 第一步加状态管理——用Map替代全局变量第115–132行原始代码所有数据都在闭包里无法支持多用户。我加了const sessions new Map(); // sessionId → { history, createdAt } function getSession(id) { const session sessions.get(id); if (!session) { sessions.set(id, { history: [], createdAt: Date.now() }); } return sessions.get(id); } // 在runReActLoop开头加 const session getSession(sessionId); history session.history;坑Node.js的Map不是线程安全的高并发下sessions.get(id)可能返回undefined然后set覆盖掉刚创建的session。解决方案是用??操作符sessions.set(id, sessions.get(id) ?? { history: [], createdAt: Date.now() });但更稳妥的是用WeakMap关联req对象不过那会增加内存泄漏风险。最后我选了LRUMap轻量级仅2KB因为缓存1000个session比修Map竞态更省事。4.2 第二步加工具市场——动态加载而非硬编码第135–158行原始代码里tools是静态数组。上线后运营要加“查股票”工具难道要重启服务我改成const toolRegistry new Map(); function registerTool(name, config) { toolRegistry.set(name, { ...config, execute: wrapTool(config.execute) // 加统一错误处理 }); } // 加载目录下所有.js文件 const toolFiles fs.readdirSync(./tools); toolFiles.forEach(file { if (file.endsWith(.js)) { const tool require(./tools/${file}); registerTool(tool.name, tool); } });坑require是同步的但工具的execute可能是异步的。有次一个股票工具用了child_process.execSync阻塞了整个Event Loop。后来强制要求所有execute返回Promise并在wrapTool里加超时function wrapTool(fn) { return async (...args) { const controller new AbortController(); const timeout setTimeout(() controller.abort(), 5000); try { return await Promise.race([ fn(...args, { signal: controller.signal }), new Promise((_, r) setTimeout(() r(new Error(Tool timeout)), 5000)) ]); } finally { clearTimeout(timeout); } }; }4.3 第三步加可观测性——不引入OpenTelemetry手写埋点第160–185行我想看“哪个工具调用失败最多”但不想装opentelemetry/sdk-node12MB。于是const metrics { toolCalls: new Map(), // weather → { success: 120, fail: 3 } stepLatency: [] // [120, 89, 210, ...] }; function recordToolCall(toolName, success) { const stat metrics.toolCalls.get(toolName) || { success: 0, fail: 0 }; if (success) stat.success; else stat.fail; metrics.toolCalls.set(toolName, stat); } // 在executeTool里调用 try { const result await tool.execute(input); recordToolCall(tool.name, true); return result; } catch (e) { recordToolCall(tool.name, false); throw e; }坑Map的键是字符串但工具名可能含空格或特殊字符。有次stock price被当成两个key。解决方案是tool.name.replace(/\W/g, _)标准化。4.4 第四步加安全网关——防Prompt注入不靠WAF第188–215行用户输入action{tool:rm,input:{path:/}}怎么办原始代码直接执行。我加了白名单校验const ALLOWED_TOOLS new Set([weather, wiki, calculator]); function validateAction(action) { if (!ALLOWED_TOOLS.has(action.tool)) { throw new Error(Tool ${action.tool} not allowed); } // 深度校验input结构 if (action.tool weather typeof action.input.city ! string) { throw new Error(weather input.city must be string); } }坑模型可能输出{tool:weather ,input:{...}}——tool名带空格。Set.has()严格匹配所以加了action.tool.trim()。后来发现有些工具名含emoji如 stock干脆全转小写去空格正则过滤非字母数字。5. Node.js实战细节为什么选它而不是Python/Go标题里强调Node.js不是跟风。我在对比PythonFastAPI、GoFiber、RustAxum后确认Node.js是此场景最优解原因有三5.1 流式响应的零成本穿透大模型输出是流式的SSE前端要实时显示打字效果。Node.js的http.ServerResponse原生支持write()分块推送res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache }); // 每收到模型一个chunk就write一行 modelStream.on(data, chunk { res.write(data: ${JSON.stringify({ chunk })}\n\n); });Python的Starlette要装StreamingResponseGo的fiber要手动ctx.SetBodyStreamWriter而Node.js——就是res.write()。230行里有32行在处理流式响应全靠原生能力撑住。5.2 内存模型适配Agent的短生命周期Agent每次请求的生命周期极短平均3.2秒但并发高。Node.js的V8引擎对短生命周期对象有极致优化new Object()在新生代GC时几乎零成本。我测过同样逻辑下Node.js内存峰值比Python低57%因为Python的dict和list有固定内存开销而V8的Object是动态属性槽。5.3 工具生态的“恰到好处”查天气要用fetchNode.js 18原生支持读文件要用fs.promises原生支持调本地Ollama要用child_process.spawn原生支持。而Python要装requests、aiofiles、asyncio.subprocessGo要写exec.CommandContext——Node.js把“调外部程序”这件事做得像呼吸一样自然。实测在2核4G的腾讯云轻量服务器上Node.js版QPS达187Python FastAPI版仅112相同模型API。差距不在语言本身而在I/O绑定操作的胶水代码量——Node.js少写了43%的胶水代码意味着更少的bug和更快的迭代。6. 从CodeBuddy到mini-openclaw这个单文件如何成为你的AI开发脚手架看到热搜词里有CodeBuddy和mini-openclaw很多人以为它们是竞品。其实它们是同一思想的不同实现把Agent的复杂度从框架层下沉到开发者认知层。CodeBuddy用IDE插件形态降低使用门槛mini-openclaw用K8s Operator形态解决部署问题而这个230行文件是它们共同的“最小公分母”。你可以把它当作乐高底板想快速验证想法直接改tools数组加个console.log打印每步耗时5分钟看到效果想集成进现有系统把runReActLoop封装成Express路由POST /agent接收JSON30行搞定想跑在浏览器里用Web Workers加载把fetch换成window.fetch去掉fs相关代码100%兼容想对接微信把prompt改成微信消息格式history存到RedisexecuteTool调用微信API核心逻辑0修改。我最近用它做了个“微信AI客服”原型用户发“查订单#12345”Agent调用订单查询工具再把结果格式化成微信卡片。全程没碰wechaty或official-account-sdk只改了3处——parseAction适配微信文本格式、executeTool加微信API调用、runReActLoop结尾加卡片模板渲染。最后分享个小技巧把230行代码里的MODEL_API_KEY替换成process.env.OPENAI_API_KEY || sk-...然后用node --inspect-brk agent.js启动。Chrome DevTools里直接断点调试模型响应比看100行日志还快。这才是手搓的终极自由——你不是使用者你是造物主。