用TypeScript实现通用智能体,核心循环100行代码 📅 发布时间:2026/9/9 1:44:58 👁 浏览次数: 用 TypeScript 写一个通用智能体核心逻辑 100 行左右这是可以落地的不是拿框架堆出来的宣传话术。最近我在做 Agent 相关功能发现很多开发者一上来就套 Dify、扣子或者各种 Agent 框架结果遇到问题只能改配置连“模型为什么调用工具失败”都说不清楚。智能体的底层拆开并不复杂核心就是一个循环把用户任务交给大模型模型决定直接回答还是调用工具如果调用工具把工具执行结果回传模型接着思考直到给出最终答案。下面我按这个思路用 TypeScript 从零写一个最小可用的通用智能体包含模型调用、工具注册、结果回传、循环上限和错误处理。适合已经会基础 TypeScript、想理解智能体原理或者想在 Node.js 项目里自己集成轻量 Agent 的开发者。1. 先别被“智能体”三个字绕晕它本质是一个循环这几年智能体这个概念被讲得越来越复杂规划、记忆、工具调用、多智能体协作、强化学习、自我进化。如果只看平台宣传会以为智能体是某种自带智能的黑盒。但真正自己动手实现一个最小版本就会发现核心抽象其实很简单就是一圈循环。这圈循环是这个样子把系统提示词和用户任务拼成消息列表。把消息列表发给大模型。模型返回两种选择之一直接给出最终回答或者声明要调用某个工具。如果是调用工具程序找到对应工具并执行把执行结果作为一条新消息追加进去。回到第 2 步直到模型给出最终回答或超过最大迭代次数。整个过程中模型负责“决策”程序负责“执行”工具执行结果又变成模型下一次决策的上下文。这个“决策—执行—观察—再决策”的闭环就是智能体和普通聊天机器人的本质区别。1.1 为什么说“能调用工具”才是分水岭普通聊天机器人是单轮映射把用户输入映射成一段文字回复。它没有行动能力也没法获取实时数据。智能体多出来的是“模型不直接做事而是声明要做什么事程序替它做再把结果喂回去”。举个例子。你问“北京现在多少度”如果模型没有工具它只能凭训练数据猜一个答案。如果模型有“查询天气”这个工具它会先返回一个 tool_call声明要调用 query_weather参数是 Beijing。程序执行这个工具拿到真实温度再作为 tool 消息回传模型基于真实数据给出回答。这一步看起来简单但它是 Agent 的骨架。RAG、知识库、数据库查询、发邮件、调接口本质上都是往这个骨架里注入不同工具。1.2 100 行能做到什么不能做到什么用“100 行”说事容易让一些人期待过高。先把边界说清楚。能做到的工具注册和按名字路由。多轮工具调用循环。工具参数 JSON 解析。工具执行失败的捕获与回传。最大迭代次数控制。一个可以直接跑的 Node.js 脚本。做起来比较吃力的跨会话的持久化记忆。任务自动分解和规划。多智能体通信。生产级限流、重试、审计、安全校验。复杂参数校验。所以这个 100 行代码更适合理解成“通用智能体内核”不是完整产品。业务能力靠工具注入复杂度靠外部系统吸收。想清楚这一点后面加需求就不会慌。1.3 为什么用 TypeScript而不是 Python智能体教程大量用 Python不是因为它不可替代而是生态里 AI 库多。但如果你的项目本身是 Node.js 技术栈或者你对 TypeScript 更熟完全可以在 JS 生态里实现同样的事情。另外如果你一直在纠结 TypeScript 和 JS 的区别这个项目会给你一个很直观的答案。Agent 的难点之一是消息结构复杂系统消息、用户消息、助手消息、工具消息还有 tool_call_id 这种强关联字段。用 TS 定义好类型后很多拼错字段、漏传参数的问题在编译期就暴露了不用等运行时才报错。这正是类型系统在真实项目里的价值不只是面试考点。2. 写代码前的准备环境、依赖和接口约定开始写代码之前先把运行环境和大模型接口约定好。这一步如果省了后面会频繁卡在环境报错上。2.1 运行环境Node.js TypeScript tsx我的本地测试环境是这样的Node.js 20。TypeScript 5.x。tsx 作为 TypeScript 直接运行工具。dotenv 读取 .env 环境变量。Node.js 18 以上自带 fetch所以不需要额外装 axios 或 node-fetch。这对实现 Agent 来说很方便减少了依赖也少踩版本兼容的坑。npm init -y npm install typescript tsx dotenv npx tsc --init如果你不想装 tsx也可以用 ts-node或者直接用 Bun 跑 TypeScript。不同运行方式的命令略有差别但代码本身不用改。编辑器用 VS Code 就行装好 TypeScript 插件后类型提示和报错都会直接显示在代码里。建议在项目里建一个 src 目录把 agent 的核心代码放进去和业务代码分开。后面扩展工具时目录结构会更清晰。2.2 大模型接口统一走 OpenAI 兼容协议现在很多大模型服务商都提供 OpenAI 兼容的 chat completions 接口也就是说请求路径、请求体结构、返回结构基本一致。这让代码可以只写一套换服务商时改环境变量就行。在项目根目录建一个 .env 文件内容类似这样OPENAI_BASE_URLhttps://api.example.com/v1 OPENAI_API_KEYsk-your-key OPENAI_MODELgpt-4o-mini TEMPERATURE0.2 MAX_ITERATIONS10注意几点OPENAI_BASE_URL 一定要确认是否带 /v1 后缀。不同服务商的路径不一样有的带有的不带。OPENAI_MODEL 的模型名要以你账号实际能用为准不要照抄示例。API Key 不要写进代码也不要提交到 git 仓库。.env 文件应该加进 .gitignore。我一般会先用 curl 或者 Postman 验证一次接口连通性确认 base_url、key、model 都没问题再开始写代码。这一步最多两分钟但能省掉后面很多不明不白的报错。如果你现在没有现成的大模型账号也可以用团队已有的网关服务或者本地方案只要它们兼容 chat completions 协议代码就不需要改。重点是把接口约定固定下来再谈 Agent 逻辑。2.3 先定义消息类型和数据结构Agent 的数据流核心是消息列表。OpenAI 兼容接口里的消息角色有 system、user、assistant、tool。工具调用涉及两个关键字段assistant 消息里的 tool_calls声明模型想调用什么工具。tool 消息里的 tool_call_id用来对应模型里的某一次调用。这两个字段必须匹配。程序端不能自己编造必须沿着模型返回的结构走。先把类型定义写出来type Role system | user | assistant | tool; interface ToolCall { id: string; type: function; function: { name: string; arguments: string; }; } interface ChatMessage { role: Role; content: string | null; tool_call_id?: string; tool_calls?: ToolCall[]; } interface Tool { name: string; description: string; parameters: Recordstring, unknown; handler: (args: any) Promisestring | string; }这里把 Tool.parameters 设计成 Recordstring, unknown是为了直接透传给大模型作为 JSON Schema。handler 接收的 args 来自模型生成的 JSON 参数注意它是 any后面需要做校验。3. 核心代码100 行的 Agent 循环长什么样类型定义好了接下来是核心实现。我按三个函数来拆callLLM 负责请求大模型runAgent 负责主循环入口文件负责把工具和任务组装起来。3.1 封装一次模型调用callLLMcallLLM 的核心是把 messages 和 tools 拼成标准请求体用 fetch 发出去然后返回模型回复的 message。async function callLLM(messages: ChatMessage[], tools: Tool[]) { const res await fetch(${process.env.OPENAI_BASE_URL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.OPENAI_API_KEY}, }, body: JSON.stringify({ model: process.env.OPENAI_MODEL, messages, tools: tools.map((t) ({ type: function, function: { name: t.name, description: t.description, parameters: t.parameters, }, })), temperature: Number(process.env.TEMPERATURE ?? 0.2), }), }); if (!res.ok) { throw new Error(LLM 接口返回 ${res.status}: ${await res.text()}); } const data await res.json(); return data.choices[0].message as ChatMessage; }有两个点值得说明。一是 tools 只透传 name、description、parameters不传 handler因为大模型不需要也不应该看到函数实现。二是把 HTTP 非 200 的情况直接抛异常让主循环或调用方统一处理不要在请求封装里悄悄吞掉。3.2 主循环runAgent主循环是整个 Agent 的心脏。async function runAgent(task: string, tools: Tool[], maxIterations 10) { const messages: ChatMessage[] [ { role: system, content: 你是一个通用智能体。能直接回答就直接回答需要外部数据或操作时调用工具。请用中文回答。, }, { role: user, content: task }, ]; for (let i 0; i maxIterations; i) { if (process.env.AGENT_DEBUG 1) { console.log([agent] 第 ${i 1} 轮当前消息数: ${messages.length}); } const reply await callLLM(messages, tools); messages.push(reply); if (!reply.tool_calls || reply.tool_calls.length 0) { return reply.content; } for (const call of reply.tool_calls) { const tool tools.find((t) t.name call.function.name); let result: string; try { if (!tool) { throw new Error(未注册的工具: ${call.function.name}); } const args JSON.parse(call.function.arguments); result await tool.handler(args); } catch (err: any) { result 工具执行失败: ${err.message}; } messages.push({ role: tool, content: result, tool_call_id: call.id }); } } throw new Error(达到最大迭代次数 ${maxIterations}任务尚未完成); }这个循环的逻辑很直白把系统提示词和用户任务拼成消息列表。每次请求都带上完整历史让模型能理解之前的工具调用结果。如果模型没有返回 tool_calls说明它决定直接回答这时候返回 content 就是最终答案。如果有 tool_calls逐个执行把执行结果作为 tool 消息追加然后进入下一轮。注意一点模型返回的 content 在有 tool_calls 时可能是 null不能拿它当最终答案。判断标准是 tool_calls 是否存在而不是 content 是否为空。3.3 最小入口先把 Agent 跑起来入口文件可以很简单。先不带任何工具只测试普通问答。import dotenv/config; import { runAgent } from ./agent; async function main() { const answer await runAgent(用一句话解释什么是智能体, []); console.log(answer); } main().catch((err) { console.error(err); process.exit(1); });运行命令npx tsx src/main.ts如果能正常输出一段中文解释说明环境、密钥、模型调用都没问题。这个阶段先不要急着加工具把基础链路跑通最重要。3.4 100 行是怎么算出来的把上面这些代码放在一起类型定义大概 20 行callLLM 大概 25 行runAgent 主体大概 35 行入口和工具示例大约 20 行。核心逻辑确实在 100 行上下。这个估算不是严格数字但不影响它说明一个问题Agent 的骨架并不重重的是工具本身和外部系统。很多人写 Agent 觉得复杂其实是被框架的配置项带偏了真正的主循环代码量非常少。这里最容易漏掉的是 tool_call_id 的对应关系。工具执行结果必须挂到模型那一次调用的 id 上否则接口会报错或模型无法理解。我在调试初期就犯过这个错结果是模型反复要求调用同一个工具循环怎么也走不出去。4. 跑通第一个任务从普通问答到工具调用核心循环写完下面把它放到真实任务里验证。我建议按三步走先纯问答再加一个简单工具最后观察 tool_calls。4.1 第一阶段不带工具先跑纯问答代码就是 3.3 节的入口。把 runAgent 的第二个参数传空数组跑一下。预期结果是模型直接返回 content没有 tool_callsAgent 走完第一轮循环就直接结束。如果这一步成功说明下面几件事都正常.env 里的环境变量能读到。base_url 和 key 配置正确。模型支持当前的请求格式。system prompt 能被模型理解。这一步失败不要急着写工具。先看报错是网络层、鉴权层还是请求格式层。请求返回 401 就去看 key返回 404 就去看 base_url 的路径报 JSON 解析错误就去看请求体结构。4.2 第二阶段注册一个计算器工具现在定义一个最简单的工具。计算器只做一件事接收一个数学表达式返回计算结果。const calculator: Tool { name: calculator, description: 计算数学表达式例如 1 2 * 3, parameters: { type: object, properties: { expression: { type: string, description: 要计算的数学表达式 }, }, required: [expression], }, handler: async ({ expression }) { // 仅用于本地演示生产环境不要直接使用 eval return String(eval(expression)); }, }; const answer await runAgent(帮我计算 2.5 * 4 10 等于多少, [calculator]); console.log(answer);这里必须说明eval 有代码执行风险生产环境千万不要直接用应该换成表达式解析库或者用白名单方式定义运算函数。演示代码里用 eval 是为了让工具实现最短。4.3 第三阶段打开调试日志观察 Agent 的执行轨迹在运行命令前设置环境变量AGENT_DEBUG1 npx tsx src/main.ts正常情况你会看到类似这样的轨迹[agent] 第 1 轮当前消息数: 2 [agent] 第 2 轮当前消息数: 4第一轮模型返回了一个 tool_calls调用 calculator。程序执行完把结果塞给模型第二轮模型基于结果给出最终回答。注意消息数从 2 变成 4说明中间多了 assistant 的 tool_call 和 tool 的返回结果。如果你没有加调试代码也可以用临时方式看返回结构在 callLLM 里把 data 打印出来。不过正式代码里建议用环境变量开关控制不要一直打日志。4.4 模型一直不调用工具先查这四个方向模型始终不调用工具时问题不一定出在代码更多时候是模型能力、请求结构或提示词的问题。排查方向按优先级排模型本身是否支持 function calling。不是所有模型都支持至少要选支持 tool_calls 的模型。tools 参数是否正确透传。看看请求体里有没有 tools每个 tool 是否包含 type、function 两个层级。system prompt 是否有引导。如果提示词只说“你是助手”模型默认会倾向直接回答。可以明确写一句“需要调用工具时请调用”。temperature 是否过高。温度调高容易让模型“自由发挥”工具调用这种结构化输出建议用 0.2 以下。排的时候按这个顺序先确认模型能力再确认请求结构再改提示词最后调参数。不要一上来就换模型或调温度。5. 稳定性设计别让 Agent 卡死在循环里能跑通工具调用只是第一步。Agent 在实际使用中最大的风险不是“回答错”而是“停不下来”。所以稳定性设计要优先于功能堆叠。5.1 最大迭代次数不是参数是保险丝任何 Agent 循环都必须有上限。原因很简单模型可能反复调用同一个工具可能工具每次返回不同结果也可能模型理解不了工具输出导致无限循环。没有 maxIterations你的成本和接口都会被拖死。默认值我给 10。简单任务两三轮就结束了复杂的任务 8 轮以内通常也能完成。如果经常打到上限说明要么任务拆解不清要么工具返回信息不足要么提示词没讲清楚什么时候该收尾。5.2 工具执行失败要回传给模型而不是直接中断这是新手最容易理解错的地方。工具执行报错了不要立刻扔异常结束循环而是把错误信息作为 tool 消息回传给模型。} catch (err: any) { result 工具执行失败: ${err.message}; }这样模型看到“工具执行失败xxx”可能会自己调整参数重新调用或者换一个工具最终仍然完成任务。直接把异常抛出等于剥夺了模型自我纠错的机会。5.3 给接口请求加上超时和基础重试fetch 默认没有超时时间。如果接口服务异常请求可能挂很久Agent 卡住任务也不结束。建议用 AbortController 控制超时。const controller new AbortController(); const timer setTimeout(() controller.abort(), 60000); try { const res await fetch(url, { method: POST, headers, body, signal: controller.signal, }); // ... } finally { clearTimeout(timer); }超时时间看你的模型和任务复杂度。普通问答 30 秒够用复杂任务可以放到 60 到 120 秒。重试逻辑不建议在主循环里写得太重先做一次简单重试比如 500 毫秒后重新请求。真正生产化时再考虑指数退避和熔断。还有一个细节超时和重试最好只包在 callLLM 内部不要让 runAgent 的循环逻辑和网络细节混在一起。这样主循环始终只关心“发消息、拿回复、判断是否结束”网络稳定性由底层函数负责。5.4 常见故障排查表现象优先排查请求返回 401API Key 错误或 Key 没有权限访问当前模型请求返回 404base_url 的路径写错确认是否带 /v1返回结构里没有 choices请求体格式不对或模型名不支持模型始终不调用工具模型是否支持 function calling、tools 是否透传、提示词是否引导工具执行报 JSON 解析错误模型生成的 arguments 不是合法 JSON给 handler 加容错循环一直不结束工具结果信息量不足或 maxIterations 设得太大输出是英文system prompt 里没有明确要求中文排查顺序建议固定先看现象再看请求体再看响应体再看参数最后看工具本身。不要跳步骤。很多时候报错信息已经告诉你是哪一层的问题问题是被忽略的请求体结构或 tool_call_id 不匹配而不是模型能力。6. 从 100 行到工程化多工具、批量任务和扩展方向核心循环稳定后就可以往工程化方向走了。这个阶段的核心目标是让 Agent 从“能跑”变成“适合长期跑”。6.1 多工具注册参数校验和命名规范工具多了以后第一个问题是参数校验。模型生成 JSON 参数不保证合法可能少字段、多字段、类型错误。handler 里全靠手动解析会很难维护。如果你用过 zod 或者 standard schema可以在这里做一层参数校验。把 Tool.parameters 配成 zod schemahandler 执行前先解析一次解析失败直接返回错误信息给模型即可。一个原则值得记住工具参数的错误要变成模型可读的错误消息而不是程序异常。关于命名建议工具名用 lower_snake_case 或 camelCase并且全局唯一。同名工具会让路由产生歧义模型也有概率调错。description 也要写清楚什么时候该用、参数含义是什么、返回什么。大模型主要靠 description 判断工具用途这块写得太简单工具基本等于没注册。6.2 批量任务先小批量再并发再失败重试批量跑 Agent 任务和批量调接口是两码事。Agent 任务有内部多轮循环单任务耗时长并发太高容易把接口速率打满。我常用的流程是先用 3 到 5 条样本任务跑一遍验证输入输出格式。确保每条任务都有唯一任务 ID输出文件名带上任务 ID。再开启并发从 2 到 3 并发开始逐步往上加。每个任务单独 try/catch失败任务记录错误原因不阻塞整批。最后单独处理失败任务而不是把整个批次重跑。一个简单的批量处理示例for (const task of tasks) { try { const answer await runAgent(task.prompt, tools, 10); await writeFile(output/${task.id}.md, answer); } catch (err) { await writeFile(output/${task.id}.error.log, String(err)); } }批量模式下输出一致性往往比输出速度更关键。不要为了跑完而丢掉每一条任务的执行记录。哪条任务用了多少次迭代、花了多少 token、工具调用是否成功、最终输出是否完整这些都要有日志可查。6.3 日志和可观测性把每一轮执行轨迹留下来Agent 的不确定性比普通接口高很多线上出了问题很难复现。所以日志不能只记“开始”和“结束”每一轮的下面这些信息都要留轮次序号。模型返回的消息角色和 tool_call 名称。工具执行结果摘要。累计 token 数和耗时。最终的迭代次数使用情况。建议把日志写到结构化文件或日志服务里方便按任务 ID 检索。Debug 级日志在文件里打全量控制台只打关键节点。不要让日志输出本身拖慢运行速度尤其是批量任务。6.4 再往下扩展记忆、知识库和多智能体内核稳定后扩展方向大致有三个。第一个是记忆。当前循环只保存单次任务的会话历史任务结束就没有了。要做长期记忆可以把历史摘要存起来新任务开始时把摘要作为上下文注入。这是最轻量的记忆实现。第二个是知识库。很多教程喜欢把 Agent 和企业知识库、向量数据库绑在一起讲容易让人认为智能体必须配向量库。实际做法通常是给 Agent 暴露一个 search_knowledge 工具工具内部去检索向量数据库或普通数据库返回相关片段。模型不需要理解向量检索细节它只需要知道“有这个工具可用”。第三个是多智能体。把一个复杂任务拆成多个小任务让不同类型的 Agent 负责不同阶段比如一个 Agent 负责拆任务另外几个负责执行。多智能体的通信协议和任务分配本身又是一个大话题建议先在单体 Agent 上跑稳再往这个方向扩展。回到开头那句话通用智能体的核心不是一个复杂的框架而是一个清晰的循环加上一组可靠的工具。先用 100 行代码把循环吃透再决定要不要引入平台和框架。像我之前用过 Dify、扣子这类可视化智能体平台界面里拖的“节点”和“工具”底层本质也是消息在模型、工具、记忆之间流转。你能理解这 100 行再去用平台时就不会被配置项吓住排查问题也会更有方向。