ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

100行TypeScript代码实现通用智能体:从工具调用到多步推理

100行TypeScript代码实现通用智能体:从工具调用到多步推理 这次我们直接看一个很具体的问题如何用 TypeScript 把通用智能体跑起来并且把核心代码控制在 100 行左右。这里说的通用智能体不是某个重量级框架而是 Agent 最核心的闭环模型负责理解任务、规划步骤代码负责执行工具、回传结果模型根据结果继续推理直到给出最终答案。这个闭环一旦跑通后续加记忆、加规划、加知识库都只是扩展问题。我选择 TypeScript 来做这个示例有几个原因第一它不依赖任何 Agent 框架只依赖 Node.js 环境和 TypeScript 本身第二代码直接对接 OpenAI 兼容的 Chat Completions 接口只要模型服务支持tools参数就能接入不管是云端模型还是本地推理服务第三消息结构、工具参数、返回结果都有类型约束改起来比纯 JavaScript 清晰很多。本文会给出完整代码、运行测试流程、接口封装示例和批量任务处理思路也会把最容易踩的坑列出来。如果你正在做智能体开发或者想弄明白各种 Agent 框架底层是怎么工作的这篇文章值得收藏。1. 核心能力速览能力项说明项目类型TypeScript 轻量通用智能体示例依赖环境Node.js 18、TypeScript、支持tools参数的大模型接口主要功能多步推理、工具调用、消息历史维护、可扩展工具集推荐硬件调用云端模型无特殊硬件要求使用本地模型时按模型要求配置显存占用取决于接入的模型本示例代码本身只占用少量内存支持平台Windows、Linux、macOS启动方式命令行脚本可扩展为 HTTP API 服务是否支持 API可以封装为 HTTP 接口文中提供封装思路是否支持批量任务支持可编写批处理脚本并发执行适合场景Agent 原理学习、原型验证、自动化任务、内部工具集成这里要说明一点这个项目本身是一个教学型示例目的是把 Agent 的工具调用机制讲清楚。它不追求生产级高并发也不内置复杂的记忆和规划模块但核心循环和接口设计是通用的。你完全可以在它基础上继续扩展。2. 适用场景与使用边界这个实现适合三类人。第一类是刚开始接触智能体开发的工程师想跳过漫长框架文档直接看一个最小可运行样本第二类是已经有业务系统的开发同学需要在自己的 TypeScript 项目里嵌入一个轻量 Agent对外提供工具调用能力第三类是想基于本地模型做实验的玩家只要本地推理服务支持 OpenAI 兼容接口就可以用同样代码接入。它不适合的场景也很明显。如果业务需要处理超长上下文、多智能体协作、复杂记忆管理、高并发生产流量这个 100 行示例不够应该去评估成熟的 Agent 框架或云平台能力。另外工具调用越强大越要注意安全边界。示例里只放了获取时间和数字加法可以在本地放心运行但如果要把工具扩展成执行命令、读写文件、调用外部系统就必须做好权限校验和操作审计。涉及用户数据、版权素材、人脸声音等敏感内容时要确保已获得合法授权不能因为“技术能跑通”就忽略合规要求。3. 环境准备与前置条件首先确认本机 Node.js 版本。代码里用到原生fetchNode.js 18 开始默认支持所以建议使用 Node.js 18 或更高版本。打开终端检查node -v npm -v如果没有安装 Node.js先去官网下载 LTS 版本安装完成后再次检查版本即可。接着创建项目并安装依赖。这里需要三个基础包typescript用于编译tsx用于直接运行 TypeScript 文件dotenv用于读取.env配置文件。types/node提供 Node.js 环境类型提示。mkdir ts-agent-demo cd ts-agent-demo npm init -y npm install typescript tsx dotenv npm install -D types/node然后创建一个tsconfig.json指定编译目标。这里用ES2022是因为代码里会用到String.prototype和异步迭代等现代语法也可以用更保守的目标但建议直接使用 ES2022 以上。{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, strict: true, esModuleInterop: true, skipLibCheck: true, types: [node] } }如果运行批量任务时用到顶层await需要把module设置为ESNext或在tsx环境下运行。也可以用module: CommonJS搭配node运行编译后的 JS但为了简单下面所有例子都直接用tsx运行。环境变量方面准备一个.env文件。如果使用云端模型需要填写 API Key如果使用本地兼容接口只需要把地址指向本地服务。模型名根据实际部署填写不同模型对tools的支持程度不一样建议选择支持 function calling 或工具调用的模型。4. 100行代码实现通用智能体核心代码先理清设计思路。一个通用智能体最少需要四部分消息结构、工具定义、模型调用函数、主循环。消息结构用来保存 system、user、assistant、tool 四种角色的消息工具定义告诉模型有哪些函数可以调用模型调用函数负责把消息和工具列表发给大模型主循环负责判断模型返回的是普通文本还是工具调用如果是工具调用就执行并把结果追加到消息历史里然后再次调用模型直到模型输出最终答案。下面是完整的示例代码保存为agent.tsimport { config } from dotenv; config(); const API_KEY process.env.API_KEY ?? ; const BASE_URL process.env.BASE_URL ?? https://api.openai.com/v1; const MODEL process.env.MODEL ?? gpt-4o-mini; type ToolCall { id: string; type: function; function: { name: string; arguments: string }; }; type Message { role: system | user | assistant | tool; content: string; tool_call_id?: string; tool_calls?: ToolCall[]; }; type Tool { name: string; description: string; parameters: Recordstring, unknown; execute: (args: any) string | Promisestring; }; const tools: Tool[] [ { name: get_current_time, description: 获取当前日期和时间, parameters: { type: object, properties: {} }, execute: () new Date().toLocaleString(), }, { name: add_numbers, description: 计算两个数字的和, parameters: { type: object, properties: { a: { type: number, description: 第一个数字 }, b: { type: number, description: 第二个数字 }, }, required: [a, b], }, execute: ({ a, b }) String(a b), }, ]; async function callLLM(messages: Message[]): PromiseMessage { const res await fetch(${BASE_URL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${API_KEY}, }, body: JSON.stringify({ model: MODEL, messages, tools: tools.map(({ name, description, parameters }) ({ type: function, function: { name, description, parameters }, })), }), }); if (!res.ok) throw new Error(await res.text()); const data await res.json(); return data.choices[0].message; } async function runAgent(userInput: string, maxSteps 5): Promisestring { const messages: Message[] [ { role: system, content: 你是通用智能体可以调用工具完成任务回答要简洁。 }, { role: user, content: userInput }, ]; for (let step 0; step maxSteps; step) { const reply await callLLM(messages); messages.push(reply); if (!reply.tool_calls?.length) return reply.content; for (const toolCall of reply.tool_calls) { const tool tools.find((t) t.name toolCall.function.name); if (!tool) { messages.push({ role: tool, tool_call_id: toolCall.id, content: 未知工具: ${toolCall.function.name} }); continue; } let result: string; try { const args JSON.parse(toolCall.function.arguments || {}); result String(await tool.execute(args)); } catch (err) { result 工具执行失败: ${(err as Error).message}; } messages.push({ role: tool, tool_call_id: toolCall.id, content: result }); } } return 已达到最大步骤数任务未完成。; } async function main() { const input process.argv.slice(2).join( ) || 现在几点了; console.log(await runAgent(input)); } main().catch((err) { console.error(err); process.exit(1); });这段代码去掉空行和类型定义核心循环逻辑确实在 100 行左右。重点不是代码行数而是这个结构可以复用到很多项目里。4.1 消息类型定义Message类型对应 Chat Completions 接口的消息格式。role可以是system、user、assistant、tool。当模型返回工具调用时assistant消息会带上tool_calls数组当代码执行完工具后需要往消息列表里追加一条role: tool的消息并且通过tool_call_id关联到对应的工具调用请求。这个对应关系容易出错少了tool_call_id很多模型会直接报错。由于接口返回的message里可能同时有content和tool_calls这里把Message设计成可选字段。主流模型的返回结构基本一致即使换了模型服务商也只需要微调类型定义。4.2 工具注册与执行tools数组就是智能体可以使用的工具列表。每个工具包含四个字段名字、描述、参数 JSON Schema、执行函数。描述非常关键模型靠描述判断什么时候该调用哪个工具。参数 Schema 必须用 JSON Schema 格式模型会参考它生成合法的参数 JSON。示例里放了两个工具get_current_time获取当前时间add_numbers计算两个数字的和。execute函数可以是同步的也可以是异步的。实际项目里可以把execute改成任何你想让智能体执行的操作比如查数据库、调业务接口、发消息等。但注意工具能力越强越要校验入参和输出避免不可控操作。4.3 主循环与多步推理runAgent函数是核心。它先把 system 和 user 消息放进历史然后进入循环。每次循环做三件事调用模型、追加返回消息、判断是否有工具调用。如果没有工具调用说明模型可以直接回答返回content。如果有工具调用就逐个执行把工具结果追加到消息历史然后继续下一次循环。maxSteps参数用来限制最大推理步数。这个限制很重要因为模型可能在复杂任务里反复调用工具甚至陷入死循环。示例里默认是 5 步实际使用可以按任务复杂度调整。多步推理能力是“通用智能体”的核心它让模型不是一次生成完答案而是可以根据工具返回结果动态调整下一步动作。4.4 如何接入其他模型或本地推理服务这段代码默认请求https://api.openai.com/v1如果要用本地推理服务只需要修改BASE_URL。比如本地部署了一个 OpenAI 兼容接口监听在8000端口那么.env里可以这样写BASE_URLhttp://127.0.0.1:8000/v1 MODEL你的本地模型名注意不是所有模型都支持工具调用。如果你的模型不支持tools参数接口会忽略该字段或返回错误。建议先看模型文档确认它支持 function calling 或 tool use 能力。从实践来看市面上的主流模型基本都支持但参数格式可能存在细微差异遇到问题时优先检查接口返回的错误信息。5. 运行测试与效果验证先创建.env文件API_KEY你的密钥 BASE_URLhttps://api.openai.com/v1 MODEL你的模型名如果使用本地推理服务把BASE_URL改成本地地址并填入支持的模型名。不要真的把密钥写进代码或提交到 Git.env要加入.gitignore。运行第一个测试让智能体查询时间npx tsx agent.ts 现在几点了正常结果是模型返回当前日期和时间。由于代码里没有加日志默认只输出最终回答。如果想观察工具调用的过程可以在main函数里打印中间消息或者在runAgent中模型返回后加一段调试输出。第二个测试验证工具参数解析和计算能力npx tsx agent.ts 请计算 123456 654321预期结果是777777。这个用例能说明模型成功生成了add_numbers工具调用参数被正确解析工具结果被传回模型最终模型基于工具结果给出答案。第三个测试验证多步推理。可以故意问一个需要先拿时间再判断的问题npx tsx agent.ts 现在是几点如果小时数大于12请输出下午否则输出上午这会让模型先调用get_current_time拿到结果后再根据小时数推理。判断成功标准是最终答案正确并且过程中发生了一次工具调用模型没有一次性硬编答案。常见失败原因有三个。一是 API Key 或 BASE_URL 配置错误接口返回 401 或 404二是模型不支持tools参数返回 400三是工具执行函数本身报错比如参数解析失败。遇到这些问题先看终端输出的异常信息把接口返回的响应体打印出来通常能直接定位。6. 接口 API 与批量任务上面的代码默认是命令行入口适合手动测试。如果要把智能体接入到业务系统里最直接的办法是封装成 HTTP API 服务。下面用 Node.js 原生http模块写一个极简示例避免引入额外依赖import http from http; import { runAgent } from ./agent; const server http.createServer(async (req, res) { if (req.method POST req.url /agent) { let body ; for await (const chunk of req) body chunk; try { const { prompt } JSON.parse(body); const answer await runAgent(prompt); res.setHeader(Content-Type, application/json); res.end(JSON.stringify({ answer })); } catch (err) { res.statusCode 500; res.end(JSON.stringify({ error: (err as Error).message })); } } else { res.statusCode 404; res.end(); } }); server.listen(3000, () { console.log(agent service running at http://127.0.0.1:3000); });这里没有处理超时、并发限制、鉴权等生产级问题但足以验证接口链路。启动服务后可以用curl测试curl -X POST http://127.0.0.1:3000/agent \ -H Content-Type: application/json \ -d {prompt:现在几点了}也可以用 Python 调用import requests resp requests.post( http://127.0.0.1:3000/agent, json{prompt: 请计算 1 2}, timeout60 ) print(resp.json())批量任务方面核心思路是把一批 prompt 逐条交给runAgent同时控制并发数量。假设有一个tasks.txt每行一个任务现在几点了 请计算 1 2 请计算 100 200可以写一个batch.tsimport { readFile } from fs/promises; import { runAgent } from ./agent; async function main() { const lines (await readFile(tasks.txt, utf-8)).split(\n).filter(Boolean); const concurrency 3; let index 0; async function worker() { while (index lines.length) { const task lines[index]; try { const result await runAgent(task); console.log([${task}] ${result}); } catch (err) { console.error([${task}] 失败: ${(err as Error).message}); } } } await Promise.all(Array.from({ length: concurrency }, worker)); } main();批量任务最需要注意的是限流。大模型接口通常有每分钟请求次数限制并发过高会触发 429。这里用concurrency控制同时进行的任务数实际要根据接口限制调整。建议批量任务增加重试机制对超时和 429 做指数退避。7. 资源占用与性能观察纯代码层面的资源占用非常低。开启一个 Node.js 进程运行一个简单 Agent 任务内存占用通常在几十 MB 级别具体数值取决于运行时和系统环境。重点要观察的是模型接口的响应时间因为一次任务可能需要多轮模型调用每轮几百毫秒到几十秒都有可能。如果接入的是云端模型性能瓶颈主要在网络和模型推理延迟上。通过观察日志里每轮callLLM的耗时可定位瓶颈。如果接入的是本地模型性能瓶颈可能在 GPU 显存或 CPU 算力。此时可以通过nvidia-smi或系统任务管理器观察显存占用。不同模型、不同量化方式、不同并发数量显存占用差异很大没有统一答案以本机实际测试为准。批量任务要格外注意并发对资源的影响。上面batch.ts虽然限制了并发数但如果每个任务内部有多轮工具调用实际同时进行的大模型请求可能超过concurrency。建议在封装批量执行器时统计“当前正在执行的 Agent 数量”而不只是任务行数。另外Node.js 默认堆内存有限如果一次性读取超大tasks.txt或者积累太多消息历史可以用node --max-old-space-size4096提升堆内存上限。8. 常见问题与排查方法问题现象可能原因排查方式解决方案启动后立刻报Cannot find module依赖未安装或 tsx 不存在检查node_modules是否存在运行npm install请求模型接口返回 401API Key 错误或未配置打印.env中读取到的值检查环境变量并确认 Key 有效请求模型接口返回 400模型不支持tools或消息格式不对打印请求 body 和响应体换用支持工具调用的模型或调整消息结构模型返回文本但没有执行工具工具描述不清晰或模型判断不需要调用查看模型返回的完整内容优化工具描述或更换提示词工具执行时报 JSON 解析失败模型生成的参数不是合法 JSON打印toolCall.function.arguments在解析前做 try/catch 并回传错误给模型多步循环不结束模型反复调用工具或任务过于复杂检查maxSteps是否过小增加maxSteps或给模型更明确的目标批量任务卡住接口限流、超时未处理检查日志中是否有 429 或超时降低并发并增加重试本地模型接口能访问但工具调用无效本地服务不支持 function calling 协议查看本地服务文档换用兼容 OpenAItools参数的服务端排查时有一个通用技巧把callLLM返回的原始 JSON 打印出来看。接口返回里包含了模型决定调用哪个工具、参数是什么、最终回答是什么任何异常都能从原始结构里找到线索。不要把data.choices[0].message之外的内容忽略掉很多错误信息在响应体的error字段里。9. 最佳实践与使用建议第一次运行先小参数测试。比如先用maxSteps3只测试一个工具确认调用闭环保通后再扩展多个工具。这样能避免问题叠在一起很难定位。建议保留一套最小可运行配置哪怕后续加了复杂的工具链也能快速回退到基础版本做排查。项目目录建议按职责分开agent.ts放主循环和模型调用tools.ts放工具定义types.ts放消息和工具类型。这样后续添加新工具时不需要修改主循环代码只要往tools数组里加一项即可。工具执行函数要统一返回字符串方便模型消费。如果工具返回的是对象提前JSON.stringify再回传避免消息结构不统一。安全性是智能体项目最容易忽略的部分。示例里的两个工具没有副作用但真实系统里工具可能涉及文件读写、数据库操作、外部请求。建议给每个工具增加超时机制防止模型调用一个永远不返回的工具同时限制工具可操作的范围比如指定可读目录、可调用的接口白名单。涉及用户隐私或版权内容时必须先确认授权范围。接口服务不要裸奔。如果需要把/agent接口开放给其他系统建议在前面加一层鉴权比如 API Token 或签名校验。批量任务要记录日志和失败重试不能只把结果打印到控制台。日志里至少要包含任务 ID、模型名称、请求耗时、工具调用次数、最终结果这些信息在排查问题时非常有用。关于模型选择建议先用一个小模型跑通流程再根据效果换成更强模型。小模型速度快、成本低适合调试大模型对复杂工具调用的准确率更高但在工具定义较多时延迟也会增加。对于生产环境还需要关注模型版本升级对tools行为的影响最好在发布前做一轮回归测试。10. 总结与下一步这个项目最值得尝试的点是用很小的代码量把 Agent 的工具调用闭环讲清楚了。它没有隐藏逻辑没有黑盒框架所有消息流转都在runAgent函数里非常适合作为智能体开发的入门模板。你先应该验证的功能是“模型返回工具调用 - 代码执行 - 结果回传 - 模型最终回答”这条链路只要这条路通了其他功能都可以往上加。最容易踩的坑有两个一是模型不支持tools参数二是消息历史里漏掉tool_call_id关联。前者会导致请求 400后者会导致模型无法理解工具调用的上下文。只要把这两个点处理好这个智能体就跑得起来。后续可以扩展的方向很多加入短期记忆模块让 Agent 记住历史对话接入向量数据库做 RAG让它能回答私有知识库问题增加多工具链和任务规划让 Agent 自动拆解复杂任务或者把 HTTP 接口从简易示例升级成带鉴权、限流、任务队列的生产服务。从这 100 行代码出发一条完整的智能体开发路径已经很清晰了。
返回列表