230行代码,零依赖,我用一个文件造了一个AI Agent(TaoToken 统一 Key 接入版)
发布时间:2026/10/7 20:09:49 作者:尧图编辑部 阅读量:1,286
)
1. 为什么我要用 230 行手搓一个 AI Agent先说结论AI Agent 的核心不是框架是一个 while 循环。你打开 LangChain、AutoGPT、CrewAI 的源码剥掉中间件、插件系统、抽象层剩下的东西只有一个 ReAct 循环——用户提问LLM 思考判断要不要调工具调完把结果喂回去继续思考直到不需要工具就输出最终回答。就这么点事。我之所以想手搓一个是因为受够了 node_modules 吃掉半个硬盘的体验。装一个 Agent 框架动辄几百个依赖光看 package.json 就劝退。更麻烦的是你根本不知道它内部到底怎么跑的出了问题只能靠猜。所以我想验证一件事如果只保留最核心的 ReAct 逻辑一个文件、零 npm 依赖能不能跑通一个能读写文件、执行命令的 Agent答案是能。整个项目就一个mini-agent.mjs文件230 行左右分四块工具定义、LLM 调用、ReAct 循环、命令行交互。没有 package.json没有构建步骤node mini-agent.mjs直接跑。这篇文章我会把完整源码、环境变量配置、以及怎么通过 TaoToken 统一 Key 接入 LLM 全部给你你复制粘贴就能在本地跑起来还能亲眼看到工具调用链路是怎么一步步发生的。适合谁看想搞明白 Agent 底层原理但被框架劝退的人想自己写个轻量工具但不想引依赖的人以及手上有一堆模型 Key、想统一管理调用入口的人。下面所有代码我都实测跑过Node.js 版本 18 以上即可。2. TaoToken 统一 Key 接入一个入口管所有模型在写 Agent 之前得先解决 LLM 调用的问题。你可能手上有好几个模型的 KeyDeepSeek 一个、GLM 一个、Kimi 一个每个的 API 地址、鉴权方式、请求格式都不太一样。如果 Agent 里硬编码这些差异换模型就得改代码很烦。我的做法是通过 TaoToken 统一 Key 通道来接入。它提供一个兼容 OpenAI 格式的 API 入口你只需要一个 Key、一个 Base URL就能调用多个模型。对 Agent 来说这意味着 LLM 调用层可以写得非常干净——一个fetch请求改个 model 名字就换模型不用动其他代码。具体来说TaoToken 的 API 地址是https://taotoken.net/api兼容 OpenAI 的/v1/chat/completions接口。你需要在官网注册后拿到 API Key然后在环境变量里配置。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册流程很简单登录后在控制台就能创建 Key。这里有个关键点Agent 的 LLM 调用层我设计成纯 HTTP 请求不依赖任何 SDK。因为一旦引入 openai 这个 npm 包就又回到依赖地狱了。用原生fetchNode 18 内置发 POST 请求解析 JSON 响应足够了。这样整个 Agent 的依赖数就是零。你可能会问为什么不直接用某个模型官方的 API因为统一入口的好处是换模型只改一个环境变量不用改代码Key 管理集中在一处不用在多个平台之间切换而且请求格式统一Agent 的解析逻辑只需要写一套。对于要反复试验不同模型效果的场景这个便利性很实在。配置的时候注意Base URL 填https://taotoken.net/api不要多加/v1具体路径在代码里拼接。Key 放在环境变量TAOTOKEN_API_KEY里不要硬编码进源码避免泄露。模型 ID 放在TAOTOKEN_MODEL里默认可以先用一个通用模型跑通后再换。3. 可复制配置与单文件源码这一节是重头戏我把完整源码和配置都给你。先建一个目录然后创建mini-agent.mjs文件。不需要npm init不需要装任何东西。首先是环境变量配置。你可以直接 export也可以写个.env文件手动 source。我习惯直接 exportexport TAOTOKEN_API_KEY你的Key export TAOTOKEN_BASE_URLhttps://taotoken.net/api export TAOTOKEN_MODELdeepseek-v3如果你用.env文件管理可以写一个config.json放在同目录代码里读取。但为了零依赖我直接用环境变量最省事。下面这个 JSON 片段可以作为你的配置参考路径和字段名跟源码里保持一致{ apiKey: 从环境变量 TAOTOKEN_API_KEY 读取, baseUrl: https://taotoken.net/api, model: deepseek-v3, maxIterations: 15 }然后是完整源码。我把它分成四部分你直接整个复制到mini-agent.mjs里就行import { spawn } from node:child_process; import { readFile, writeFile, readdir } from node:fs/promises; import { createInterface } from node:readline; // 第一部分工具定义 const tools { read_file: { description: 读取指定路径的文件内容, parameters: { path: 文件路径 }, async execute({ path }) { try { return await readFile(path, utf-8); } catch (e) { return 读取失败: ${e.message}; } }, }, write_file: { description: 将内容写入指定路径的文件, parameters: { path: 文件路径, content: 写入内容 }, async execute({ path, content }) { try { await writeFile(path, content, utf-8); return 已写入 ${path}; } catch (e) { return 写入失败: ${e.message}; } }, }, list_dir: { description: 列出指定目录下的文件和子目录, parameters: { path: 目录路径默认当前目录 }, async execute({ path . }) { try { const entries await readdir(path, { withFileTypes: true }); return entries .map((e) ${e.isDirectory() ? [目录] : [文件]} ${e.name}) .join(\n); } catch (e) { return 列出失败: ${e.message}; } }, }, run_command: { description: 执行 shell 命令并返回输出, parameters: { command: 要执行的命令 }, async execute({ command }) { return new Promise((resolve) { const proc spawn(command, { shell: true }); let out ; proc.stdout.on(data, (d) (out d)); proc.stderr.on(data, (d) (out d)); proc.on(close, (code) resolve(out || 命令退出码: ${code})); }); }, }, }; // 第二部分LLM 调用 async function callLLM(prompt, model) { const apiKey process.env.TAOTOKEN_API_KEY; const baseUrl process.env.TAOTOKEN_BASE_URL || https://taotoken.net/api; if (!apiKey) throw new Error(缺少 TAOTOKEN_API_KEY 环境变量); const res await fetch(${baseUrl}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey}, }, body: JSON.stringify({ model, messages: [{ role: user, content: prompt }], temperature: 0.3, }), }); if (!res.ok) { const err await res.text(); throw new Error(LLM 请求失败 ${res.status}: ${err}); } const data await res.json(); return data.choices[0].message.content; } // 第三部分ReAct 循环 function buildSystemPrompt() { const toolList Object.entries(tools) .map(([name, t]) - ${name}: ${t.description}参数: ${JSON.stringify(t.parameters)}) .join(\n); return 你是一个可以调用工具的 AI Agent。可用工具 ${toolList} 当你需要调用工具时输出以下格式必须是合法 JSON tool_call{name:工具名,args:{...}}/tool_call 如果不需要工具直接回答用户问题。每次只调用一个工具。; } function extractToolCall(text) { const m text.match(/tool_call([\s\S]*?)\/tool_call/); if (!m) return null; try { return JSON.parse(m[1].trim()); } catch { return null; } } async function react(userInput, model, maxIter 15) { const history [User: ${userInput}]; let lastCallKey ; for (let i 1; i maxIter; i) { console.log(\n⚙ 迭代 ${i}/${maxIter}); const prompt buildSystemPrompt() \n\n history.join(\n\n); const reply await callLLM(prompt, model); const call extractToolCall(reply); if (!call) { console.log( Assistant: ${reply}); return reply; } const callKey JSON.stringify(call); if (callKey lastCallKey) { console.log( 检测到重复调用强制终止); history.push(Assistant: ${reply}); history.push([系统]: 检测到重复工具调用请基于已有信息直接回答。); const final await callLLM(buildSystemPrompt() \n\n history.join(\n\n), model); console.log( Assistant: ${final}); return final; } lastCallKey callKey; console.log( 调用工具: ${call.name}(${JSON.stringify(call.args)})); const tool tools[call.name]; const result tool ? await tool.execute(call.args) : 未知工具: ${call.name}; console.log( 结果: ${result.slice(0, 200)}); history.push(Assistant: ${reply}); history.push([工具结果]: ${result}); } return 达到最大迭代次数已停止。; } // 第四部分交互式 REPL const model process.env.TAOTOKEN_MODEL || deepseek-v3; const rl createInterface({ input: process.stdin, output: process.stdout }); console.log(Mini Agent 已启动模型: ${model}); console.log(输入问题开始对话CtrlC 退出\n); function ask() { rl.question(You , async (input) { if (!input.trim()) return ask(); try { await react(input.trim(), model); } catch (e) { console.error( 错误: ${e.message}); } ask(); }); } ask();这就是全部代码230 行左右。你可以看到工具定义部分每个工具就是一个对象有 description、parameters 和 execute 方法。LLM 调用部分用原生 fetch没有任何 SDK。ReAct 循环部分就是那个 for 循环检测tool_call标签执行工具把结果追加到 history继续下一轮。REPL 部分用 readline 做交互。有个细节值得说重复调用检测。模型有时候会陷入死循环反复调同一个工具。我用lastCallKey记录上一次的调用如果这次和上次完全一样就强制终止让模型基于已有信息总结。这个防御机制很关键不然 token 会烧光。4. 验证请求与观察 ReAct 推理链路代码写完了现在跑起来验证。先确认环境变量都设好了echo $TAOTOKEN_API_KEY echo $TAOTOKEN_MODEL然后直接运行node mini-agent.mjs你会看到启动信息然后出现You提示符。我们分三个场景来验证每个场景都能观察到不同的 ReAct 行为。场景一普通对话零工具调用。输入「你好介绍一下你自己」。你会看到⚙ 迭代 1/15 Assistant: 我是一个可以调用工具的 AI Agent能读写文件、列出目录、执行命令...这里 LLM 判断不需要工具一次迭代就返回了。说明 ReAct 循环的「无工具调用即终止」分支工作正常。场景二读取文件一次工具调用。先在当前目录建个测试文件echo hello from mini agent test.txt然后在 REPL 里输入「读取 test.txt 的内容并解释」。你会看到⚙ 迭代 1/15 调用工具: read_file({path:test.txt}) 结果: hello from mini agent ⚙ 迭代 2/15 Assistant: test.txt 的内容是 hello from mini agent...这里发生了两次迭代第一次 LLM 决定调 read_file工具执行后结果被追加到 history第二次 LLM 拿到结果生成最终回答。这就是完整的 ReAct 链路——思考、行动、观察、再思考。场景三执行命令观察自主决策。输入「用命令查看当前 node 版本和操作系统」。你会看到⚙ 迭代 1/15 调用工具: run_command({command:node --version uname -a}) 结果: v20.11.0 Darwin xxx 23.4.0 arm64 ⚙ 迭代 2/15 Assistant: 当前 Node.js 版本 v20.11.0操作系统 macOS arm64...注意这里 LLM 自己决定执行什么命令我没有告诉它用node --version是它根据问题自主选择的。这就是 Agent 和普通脚本的区别——你不用告诉它怎么做它自己规划步骤。如果你想换模型测试直接改环境变量重启TAOTOKEN_MODELglm-4 node mini-agent.mjs因为走的是 TaoToken 统一入口换模型不用改任何代码。你可以对比不同模型在工具调用格式上的遵循程度有些模型对tool_call标签的遵循更稳定有些偶尔会输出格式错误的 JSON这时候extractToolCall里的 try-catch 就会返回 nullAgent 会当作普通回答处理。5. 常见报错排查401、local proxy failed 与格式错误跑的过程中你可能会遇到几个典型报错我把我踩过的坑列出来对照着排查。第一个401 Unauthorized。报错长这样LLM 请求失败 401: {error:{message:Invalid API key}}原因通常是TAOTOKEN_API_KEY没设、设错了、或者带了多余空格。排查步骤先echo $TAOTOKEN_API_KEY确认值存在且没有引号然后确认 Key 是在 TaoToken 控制台创建的、没有过期最后确认请求头里是Bearer ${apiKey}中间有一个空格。如果 Key 是从网页复制的注意别把首尾空格带进去。第二个local proxy failed 或连接超时。报错类似LLM 请求失败: fetch failed这种一般是网络层问题。先确认TAOTOKEN_BASE_URL是https://taotoken.net/api没有多余斜杠。然后确认你的网络能正常访问该地址可以用 curl 测一下curl -I https://taotoken.net/api如果 curl 也失败说明是网络环境问题检查你的 DNS 或网络配置。如果 curl 成功但 Node 里失败可能是 Node 版本太低不支持 fetch确认node --version在 18 以上。第三个reading choices 报错。报错长这样TypeError: Cannot read properties of undefined (reading choices)这说明data.choices是 undefined通常是响应结构不符合预期。可能原因Base URL 写错了比如写成了https://taotoken.net少了/api导致请求打到了别的路径或者模型 ID 不存在接口返回了错误结构但状态码是 200。排查方法在callLLM里加一行console.log(JSON.stringify(data))看看实际返回了什么。确认 model 字段是你账号下有权限的模型。第四个工具调用格式解析失败。表现是 Agent 一直不调工具直接把tool_call当普通文本输出了。原因是模型没有严格遵循格式输出的 JSON 不合法。解决办法在 system prompt 里强调「必须是合法 JSON」或者换个对格式遵循更好的模型。你也可以在extractToolCall里加日志看看模型实际输出了什么。第五个OAuth 或鉴权相关报错。如果你看到类似OAuth token invalid的提示说明你可能混用了其他平台的鉴权方式。TaoToken 用的是 Bearer Token不需要 OAuth 流程。确认你没有把其他平台的配置串进来。排查的核心思路就一条先确认环境变量再确认网络连通最后看响应结构。大部分问题出在前两步。6. 从这 230 行出发你还能做什么跑通之后你会发现这个 Agent 的扩展点非常清晰。想加工具在tools对象里加一个键值对就行description 写清楚execute 实现逻辑system prompt 会自动把新工具列进去。比如加一个search_web工具或者加一个query_database工具都是几十行的事。想换 LLM 调用方式callLLM函数是唯一跟模型交互的地方你想换成流式输出、加 function calling、加多轮消息数组都只改这一个函数。因为走的是 TaoToken 统一入口你甚至可以在callLLM里根据任务类型动态切换模型——简单任务用便宜模型复杂推理用强模型。想持久化对话历史把history数组存到文件下次启动读回来就行。想加多轮记忆在 system prompt 里注入历史摘要。这些都不需要引入任何依赖。我实测下来这个单文件 Agent 最适合的场景是本地开发辅助、批量文件处理、以及作为理解 Agent 原理的教学样本。它不适合生产环境的高并发场景但作为你理解 ReAct 循环的起点足够了。所有 Agent 框架本质上都是这 230 行的变体——区别只在于它们加了更多的抽象和封装。你先把这个循环吃透再看那些框架会发现一切都清晰了。如果你想把 Key 管理和模型调用做得更规范可以去 TaoToken 控制台创建独立的 API Key按项目区分权限。接入文档在 https://taotoken.net/api 对应的文档页有详细说明模型列表和参数也都在那里。想直接体验模型对话效果可以从模型对话入口进去试如果是长期做编码类 AgentCoding Plan 会更划算。先把这 230 行跑起来剩下的都是在这个循环上做加法。