ARTICLE DETAIL

资讯详情

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

用TypeScript实现通用智能体:核心循环与工具注册实战

用TypeScript实现通用智能体:核心循环与工具注册实战 真正动手写过一次智能体之后你才会意识到所谓“通用智能体”本质上不是某个复杂的框架而是一个“大模型 工具注册 循环决策”的最小闭环。只要你理解了这个闭环用 TypeScript 一百行左右就能搭出一个可以复用、可以扩展的智能体骨架。本文会从一个可直接运行的项目出发先讲清楚通用智能体的核心设计思路再给出完整的 TypeScript 代码最后补充运行效果、常见问题与工程化建议。无论你是刚接触大模型应用开发还是已经在业务中接入过 LLM这篇文章都能帮你把“智能体”这个概念落到代码层面。1. 为什么要用 TypeScript 实现通用智能体1.1 什么是通用智能体先抛开复杂的概念。一个通用智能体最简单的理解就是用户提出问题 → 大模型判断需要哪些信息 → 调用外部工具获取信息 → 把工具结果交回给大模型 → 大模型继续推理 → 直到生成最终回答。这个流程在学术界常被称为 ReAct 模式也就是 Reasoning推理与 Acting行动交替进行。与普通聊天机器人相比通用智能体的核心差异在于“工具调用”。普通聊天程序只能基于模型自身知识生成文本而智能体可以查询数据库获得业务数据调用内部接口完成操作获取实时天气、时间、新闻等外部信息根据工具返回结果继续推理而不是直接编造答案。“通用”体现在哪里体现在工具是动态注册的。今天注册一个天气工具它就能回答天气问题明天注册一个订单查询工具它就能处理订单相关需求。核心调度逻辑不需要改动这就是通用。1.2 为什么选择 TypeScript在智能体这个场景里TypeScript 有几个很现实的优势。第一类型安全。工具参数、消息格式、角色字段都是结构化的数据。用 TypeScript 的 interface 和类型联合可以在编译阶段拦截大量低级错误比如把role写成assistent这类拼写问题。第二异步友好。智能体核心流程中到处是异步操作调用大模型 API、等待工具执行、处理用户输入。TypeScript 基于 Promise 和async/await写起来非常自然。第三复用方便。同一个智能体核心逻辑既可以被 Node.js 后端服务调用也可以通过编译在浏览器或其他运行时中运行。对于团队里同时维护前端和后端的开发者来说学习成本也低。1.3 100行能做到什么需要明确一个边界100 行代码能做的是“最小可运行、可扩展的智能体骨架”而不是生产级平台。在这个骨架里你会看到大模型接口调用工具注册与动态发现多轮工具调用的循环处理消息历史的维护基础错误处理命令行交互入口。这意味着你可以在这套骨架上继续叠加业务逻辑而不必从零开始设计调度流程。对于想理解智能体原理、快速做原型验证、或者作为团队内部脚手架来说这个体量刚刚好。2. 通用智能体的核心设计思路2.1 核心循环先把整个流程画一遍方便后面读代码时对照用户输入 ↓ 拼装 messages 发送给大模型 ↓ 大模型判断是否需要调用工具 ├── 不需要 → 输出最终回答结束 └── 需要 → 返回工具名称和参数 ↓ 执行对应工具 ↓ 将工具结果追加为 tool 消息 ↓ 再次调用大模型重复判断这个循环是智能体的发动机。只要模型没有返回最终文本回答循环就一直继续。实际项目中需要限制最大轮数防止模型陷入“不停调用工具”的死循环后面代码里会加上这个保护。2.2 工具注册协议为了让大模型能理解“有哪些工具可用、每个工具需要什么参数”我们需要把工具描述成模型能读懂的 JSON 结构。这里使用的协议是 OpenAI 兼容的 Function Calling 规范现在已经成了事实标准。每个工具由四部分组成字段含义name工具名称模型会用它来指定调用目标description工具功能描述模型据此判断何时调用parameters参数 JSON Schema描述参数名、类型、是否必填execute实际执行函数接收解析后的参数并返回结果注意description写得好不好直接影响模型调用工具的准确率。比如一个天气工具如果描述是“查询天气”模型可能搞不清该传城市还是传日期如果写成“根据城市名返回天气情况”模型通常会正确提取city参数。2.3 消息历史的作用大模型本身是无状态的它不记得上一轮对话内容。所以我们需要把每轮交互都追加到messages数组里每次都把完整历史发送给模型。在工具调用场景中历史消息还有一个细节tool角色的消息必须带上tool_call_id用来对应之前模型返回的某一次工具调用。这个 ID 由大模型生成我们原样保存、原样回传即可。系统提示词system也很关键。它相当于给智能体设定行为规则。在通用智能体里我会明确告诉模型需要实时数据时先调用工具不要凭空编造。这一步能显著减少模型“一本正经胡说八道”的情况。3. 环境准备与项目初始化3.1 运行环境本文示例代码依赖以下环境Node.js 18 及以上版本推荐使用 20 LTSnpm 或 pnpm 包管理器TypeScript 5 及以上版本Visual Studio Code 或其他支持 TypeScript 的编辑器。代码中直接使用了 Node.js 内置的全局fetch所以不需要额外安装axios或node-fetch。如果你的 Node 版本低于 18请先升级。3.2 创建项目先在终端创建一个新项目目录mkdir ts-agent cd ts-agent npm init -y然后安装开发依赖npm install -D typescript tsx types/node再安装运行时依赖dotenv用于加载.env中的环境变量npm install dotenvtsx是一个基于 esbuild 的 TypeScript 直接运行工具省去了先编译再运行的麻烦非常适合本地开发和演示。在package.json中补充以下配置{ name: ts-agent, type: module, scripts: { dev: tsx src/agent.ts } }type: module表示项目使用 ESM 模块规范这样代码里的import语法可以直接生效。3.3 TypeScript 配置创建tsconfig.json{ compilerOptions: { target: ES2022, module: ES2022, moduleResolution: Bundler, strict: true, skipLibCheck: true, types: [node] }, include: [src] }几个关键配置说明target: ES2022编译目标使用现代 JavaScript 语法module: ES2022使用 ES Module 模块输出moduleResolution: Bundler适配tsx这类直接运行工具strict: true开启严格类型检查这是 TypeScript 的核心价值所在types: [node]引入 Node.js 类型声明让process、readline等对象有类型提示。3.4 环境变量配置创建.env文件OPENAI_API_KEYsk-你的密钥 OPENAI_API_URLhttps://api.openai.com/v1 OPENAI_MODELgpt-4o-mini如果你使用的是国内兼容 OpenAI 接口的模型服务把OPENAI_API_URL改成对应地址即可。风险提醒.env文件绝不能提交到 Git 仓库。建议把.env.example提交到仓库作为模板同时在.gitignore中加入node_modules/ dist/ .env4. 核心代码约100行实现通用智能体4.1 完整代码创建src/agent.ts下面是完整代码import readline from node:readline/promises; import { stdin, stdout } from node:process; import dotenv/config; type JsonObject Recordstring, unknown; interface Tool { name: string; description: string; parameters: JsonObject; execute: (args: JsonObject) Promisestring; } interface ChatMessage { role: system | user | assistant | tool; content: string; tool_call_id?: string; tool_calls?: Array{ id: string; type: function; function: { name: string; arguments: string }; }; } const API_URL process.env.OPENAI_API_URL ?? https://api.openai.com/v1; const MODEL process.env.OPENAI_MODEL ?? gpt-4o-mini; const SYSTEM_PROMPT 你是一个通用智能体助手可以调用工具完成任务。 需要实时数据时请先调用工具不要直接编造数据 工具返回结果后请用自然语言回复用户。; class Agent { private tools: Mapstring, Tool new Map(); register(tool: Tool): void { this.tools.set(tool.name, tool); } private buildTools() { return [...this.tools.values()].map((tool) ({ type: function, function: { name: tool.name, description: tool.description, parameters: tool.parameters, }, })); } private async callLLM(messages: ChatMessage[]) { const res await fetch(${API_URL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.OPENAI_API_KEY}, }, body: JSON.stringify({ model: MODEL, messages, tools: this.buildTools(), tool_choice: auto, }), }); if (!res.ok) { throw new Error(LLM API error: ${res.status} ${await res.text()}); } const data await res.json(); return data.choices[0].message; } async run(history: ChatMessage[], rounds 5): Promisevoid { if (rounds 0) { console.log(⚠️ 达到最大工具调用轮数停止执行。); return; } const message await this.callLLM(history); history.push({ role: assistant, content: message.content ?? , tool_calls: message.tool_calls, }); if (!message.tool_calls || message.tool_calls.length 0) { console.log( ${message.content}); return; } for (const call of message.tool_calls) { const tool this.tools.get(call.function.name); if (!tool) { history.push({ role: tool, content: 工具不存在: ${call.function.name}, tool_call_id: call.id, }); continue; } try { const args JSON.parse(call.function.arguments); const result await tool.execute(args); history.push({ role: tool, content: result, tool_call_id: call.id }); console.log( 调用工具 ${tool.name}结果: ${result}); } catch (err) { history.push({ role: tool, content: 工具执行失败: ${(err as Error).message}, tool_call_id: call.id, }); } } return this.run(history, rounds - 1); } } const currentTimeTool: Tool { name: getCurrentTime, description: 获取当前系统时间, parameters: { type: object, properties: {} }, async execute() { return new Date().toLocaleString(zh-CN, { timeZone: Asia/Shanghai, }); }, }; const weatherTool: Tool { name: getWeather, description: 根据城市名返回天气情况, parameters: { type: object, properties: { city: { type: string, description: 城市名例如杭州 }, }, required: [city], }, async execute(args) { const city args.city; if (typeof city ! string || city.length 0) { throw new Error(参数 city 不能为空); } return ${city}今天多云转晴气温 20~28℃空气质量良好; }, }; async function main() { const agent new Agent(); agent.register(currentTimeTool); agent.register(weatherTool); const rl readline.createInterface({ input: stdin, output: stdout }); const history: ChatMessage[] [{ role: system, content: SYSTEM_PROMPT }]; console.log( 你好我现在在杭州想知道现在几点以及今天适合穿什么\n); history.push({ role: user, content: 你好我现在在杭州想知道现在几点以及今天适合穿什么, }); await agent.run(history); while (true) { const input await rl.question(\n 请输入你的问题exit 退出: ); const text input.trim(); if (text exit) break; history.push({ role: user, content: text }); await agent.run(history); } rl.close(); } main().catch((err) { console.error(程序异常退出:, err); process.exit(1); });这段代码看起来长去掉空行和声明后核心逻辑大约 100 行。下面逐段拆解。4.2 类型定义与全局配置开头部分是整个智能体的“协议层”。type JsonObject Recordstring, unknown;这里定义了一个通用类型所有工具参数都归约为“字符串到未知类型”的映射。这样写的好处是Agent 核心逻辑不依赖任何具体工具的参数结构保持通用性。interface Tool { name: string; description: string; parameters: JsonObject; execute: (args: JsonObject) Promisestring; }Tool接口是工具注册的契约。核心循环只认这五个字段。你注册任何新工具时都必须实现这个结构。ChatMessage则描述了对话消息的格式。角色限定为四种system、user、assistant、tool。tool_calls字段用于保存模型的工具调用意图tool_call_id用于关联工具结果。4.3 Agent 类的运行机制Agent类是整个智能体的核心拆开看只有三个方法。register方法把工具放入Map结构。Map的 key 是工具名这样模型返回工具名后我们可以 O(1) 地找到对应实现。buildTools方法把内部工具映射成 OpenAI 接口需要的格式。这一步相当于给大模型递上一份“工具使用说明书”。callLLM方法负责与模型 API 通信。需要注意两点请求头必须带Authorization: Bearer ${process.env.OPENAI_API_KEY}请求体中的messages是完整历史tools是工具说明书tool_choice: auto表示让模型自行判断是否调用工具。如果接口返回非 2xx 状态这里会直接抛出错误避免程序在异常状态下继续执行。run方法是循环的入口。流程是这样的检查剩余轮数防止死循环调用callLLM获取模型回复把模型回复追加到历史中如果回复里没有tool_calls说明模型已经给出了最终回答直接输出如果有tool_calls逐个执行对应工具把结果作为tool消息追加到历史递归调用自身进入下一轮推理。这里的递归就是前面说的“推理 - 行动”循环。每一轮工具调用结果都会进入消息历史让模型在下一轮能看到工具返回的真实数据。4.4 两个示例工具currentTimeTool和weatherTool展示了两种典型工具形态。时间工具没有参数所以parameters里properties为空对象。它的execute函数返回当前系统时间字符串。天气工具有一个必填参数city参数description写了“城市名例如杭州”这是给模型看的提示。实际项目中如果你接入了真实天气 API只需要修改execute函数内部逻辑Agent 核心代码完全不用动。这里要特别强调参数校验。在execute内部我们先判断city是否为非空字符串不满足就抛出异常。原因是模型返回的 JSON 参数并不可靠偶尔会出现类型错误或遗漏必填字段。工具实现者必须对自己的参数负责。4.5 命令行交互入口main函数做了三件事创建 Agent 并注册两个工具预置一条测试问题演示一次完整对话进入循环等待用户持续输入。readline/promises是 Node.js 提供的 Promise 版命令行交互模块。用rl.question等待用户输入每次输入后追加到历史再交给agent.run处理。整个程序以main().catch(...)收尾保证未捕获异常能打印到控制台并以非零状态退出。5. 运行效果与验证5.1 启动方式确保.env文件已配置正确然后执行npm run dev5.2 预期输出程序启动后会自动执行一条内置问题然后进入交互模式。预期输出类似 你好我现在在杭州想知道现在几点以及今天适合穿什么 调用工具 getCurrentTime结果: 2025/3/18 14:32:07 调用工具 getWeather结果: 杭州今天多云转晴气温 20~28℃空气质量良好 现在是北京时间 2025年3月18日 14:32。杭州今天多云转晴气温 20~28℃空气质量良好适合穿短袖或薄长袖出门。 请输入你的问题exit 退出:注意模型可能一次调用多个工具也可能分多轮调用。上面的输出说明了两个关键行为模型没有直接编造时间而是先调用了getCurrentTime模型意识到温度信息来自天气工具于是又调用了getWeather拿到两个工具结果后模型综合信息生成了最终回答。如果你的模型在第一步就直接回答了“我不知道当前时间”或者“今天是晴天”通常是因为SYSTEM_PROMPT约束不够或者模型能力较弱。可以尝试更换更强的模型或者调整系统提示词。5.3 手动验证思路验证智能体是否“真的在思考”可以用两种方式。第一种问一个明显需要工具的问题比如“现在东京时间是几点”。如果模型没有调用工具就给出时间说明它可能是凭空编造的。第二种故意触发工具异常。比如你注册一个工具让它在执行时抛出错误观察智能体是否会把错误信息返回给模型并给出合理回答。这种容错能力是生产级智能体的基础。6. 常见问题与排查实际运行过程中你大概率会遇到下面这些问题。整理成表格方便快速定位问题现象常见原因解决思路返回 401 AuthenticationErrorAPI Key 缺失或错误检查.env配置确认密钥有效返回 404 Model Not Found模型名称不存在修改OPENAI_MODEL为服务商支持的模型名fetch is not definedNode 版本低于 18升级 Node.js 到 18 或更高版本工具执行报错 JSON.parse 失败模型返回了非法 JSON 参数在execute里做参数校验增加兜底逻辑模型一直调用工具不结束缺少轮数限制或工具描述不清确认rounds参数生效优化工具描述模型不调用工具直接回答系统提示词约束不足在SYSTEM_PROMPT中明确要求先调用工具单个工具结果过大工具返回了超长文本截断结果只返回模型需要的摘要信息下面重点展开三个高频问题。6.1 API Key 或接口地址配置错误现象程序启动后callLLM抛出LLM API error: 401或404。处理思路先确认.env中OPENAI_API_KEY是否为有效密钥再确认OPENAI_API_URL是否正确。如果你使用的是兼容接口服务很多服务商的地址末尾不需要加/v1也可能必须加/v1具体看官方文档。6.2 模型传参不准现象模型调用天气工具时传入了{ location: 杭州 }而不是代码中定义的{ city: 杭州 }。原因工具描述不够清晰或者模型本身遵循指令的能力较弱。解决方式在工具的description里写清参数名和示例值例如“根据城市名返回天气情况城市名示例杭州、上海、北京”。如果还是不行可以在execute里做一层参数兼容比如同时接受city和location两个键。6.3 智能体陷入工具调用死循环现象控制台不断打印“调用工具”程序迟迟不输出最终回答。原因模型在前几轮工具结果中没有得到足以生成最终答案的信息于是反复尝试。解决方式代码中的rounds 5就是第一道防线。达到上限后程序会停止并提示。更根本的解决方式是在工具返回内容中给出更完整、更直接的信息减少模型反复试探的次数。7. 最佳实践与工程化建议到这里你已经拥有一个可运行的智能体骨架。但要在真实项目中落地还需要考虑下面这些工程化细节。7.1 工具设计规范工具是智能体的“手脚”设计质量直接决定体验。命名要动词开头且语义明确比如queryOrderStatus、sendEmail而不是order、mail。description要包含三个信息功能是什么、什么时候调用、参数怎么传。例如根据订单号查询订单当前状态适合用户询问物流或订单进度时调用。 参数 orderId 是订单号形如 ORD20250001。这条描述让模型同时理解了“场景”和“参数格式”。7.2 上下文长度与成本控制消息历史会随着对话轮数不断增长。每轮调用都要重新发送完整历史token 消耗会显著上升。实际项目中建议对历史做截断只保留最近 N 轮对话对工具返回内容做摘要或截断尤其查询结果很大时为messages设置最大长度超出后合并早期消息。7.3 安全边界给智能体开放工具权限时必须遵守最小权限原则。不要给工具使用数据库管理员账号不要允许工具执行高危操作而不做二次确认。例如如果一个工具可以“删除用户”建议在execute里先返回“确认删除吗”或者强制要求模型提供授权凭证参数。同时API Key不要硬编码在代码里。本文使用.env管理是正确做法。如果部署到服务器建议使用密钥管理服务。7.4 超时、重试与可观测性调用外部 API 时网络抖动是常态。可以在callLLM里增加超时控制例如使用AbortSignal.timeout(30000)并在失败时做一两次重试。控制台打印目前是console.log生产环境建议改为结构化日志记录每一轮的模型回复、工具调用、耗时和 token 消耗。这样既能排查问题也能评估成本。7.5 扩展方向这套骨架已经预留了清晰的扩展点工具注册是动态的可以继续添加业务工具run方法可以改写为返回完整消息历史方便接入 Web 服务可以增加流式输出让回答逐字打印体验更好可以加入记忆模块把重要信息存入向量数据库可以支持多智能体协作让不同 Agent 负责不同领域。如果你需要在生产环境快速落地也可以参考这个思路去阅读 LangChain、OpenAI Swarm 等框架的源码。你会发现它们的核心调度逻辑与本文代码本质上是一致的。最后给你一个实际建议不要一开始就设计庞大的工具集先挑两个最核心的工具跑通闭环再逐步扩展。智能体项目的复杂度会随工具数量非线性增长保持骨架简单才是长期维护的关键。
返回列表