十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

用TypeScript手写AI Agent骨架:核心是自主调用工具的循环

用TypeScript手写AI Agent骨架:核心是自主调用工具的循环 先说一个结论AI Agent 的真正核心不是“聊天”而是“一个能自主调用工具的循环”。如果你已经在用 TypeScript 做前端或 Node.js 开发想入门 AI Agent或者想把一个 Demo 升级成企业级架构那么这篇内容比其他“概念科普”更有用。我会直接用 TypeScript 手写一个约 100 行代码的通用 Agent 骨架参考类 PI-Agent 的任务规划与执行思路把 Agent 的模块边界、循环控制、工具注册、模型调用抽象全部拆开讲清楚。类 PI-Agent 架构听起来复杂我按任务规划类智能体的通用实现来理解它接收一个目标把目标拆成若干可执行步骤每一步交给大模型决定调用哪个工具然后把工具结果反馈给模型再决定下一步动作直到任务完成。这个循环是所有 Agent 框架都会解决的问题。把循环跑通再往上加记忆、权限、重试、队列就是企业级 Agent 的底座。这篇文章不是只给你看代码而是从 0 到一个能跑的 TypeScript Agent 工程再解释为什么企业级版本要增加那些附加模块。环境、依赖、核心代码、验证方式、常见排查和边界限制都会覆盖到。1. 先搞懂一个 AI Agent 的核心运行闭环很多人第一次接触 Agent 时第一个问题是Agent 和普通的“提示词模板”有什么区别普通的聊天程序是用户输入一句话大模型返回一段文本结束。Agent 不是这样。Agent 在用户输入和大模型输出之间加入了一个“工具执行”的循环。我一般这样理解 Agent 的执行闭环用户输入一个目标比如“统计这份数据里销售额最高的三个城市”。Agent 把目标交给大模型大模型发现我需要先读取数据文件然后做计算最后输出结果。大模型不会直接返回最终答案而是返回一个“工具调用指令”调用文件读取工具参数是文件路径。Agent 执行这个工具拿到真实数据结果。Agent 把工具结果追加到对话上下文里再次交给大模型。大模型根据真实数据继续生成下一步指令或者认为信息足够输出最终答案。这个“模型思考 - 工具执行 - 结果回填 - 再思考”的循环才是 Agent 的基本运行机制。类 PI-Agent 架构强调的也正是这个点Agent 是任务规划器也是任务执行调度器而不是一个单纯的对话接口。1.1 为什么用 TypeScript 而不直接用 Python 框架如果你只是跑一次研究 DemoPython 生态确实方便。但如果你要做的 Agent 需要落地到现有业务系统情况就不同了前端项目本身就是 TypeScriptAgent 可以直接复用已有的数据类型定义和接口调用层。Agent 要处理大量 JSON 消息TypeScript 的类型系统能在编译阶段拦截掉很多“字段拼错”“返回结构不一致”的问题。企业级 Agent 经常需要嵌入到 Node.js 服务、命令行工具、监控平台、内部系统里TypeScript 的部署链路更统一。类型定义本身就是文档。以Tool接口为例看到接口就知道一个工具需要实现哪些字段这对多人协作很重要。这里不是说 Python 方案不好。而是如果你要在一个以 TypeScript 为主的技术栈里做 Agent完全没必要为了 Agent 单独引入一套 Python 服务。用 TypeScript 手写一个 Agent Core反而能更清楚理解内部结构。1.2 Agent 架构设计中包含哪些核心模块一个相对完整的 Agent 架构通常包含下面几个模块。抓重点不要被概念带偏输入解析模块把用户的自然语言目标转换为结构化任务。规划模块把大任务拆成子任务或者决定每一步调用什么工具。工具注册中心Agent 知道自己有哪些工具可用每个工具的参数是什么。模型调用层封装对大模型接口的请求和响应解析不直接散落在业务代码里。执行循环控制 Agent 在“模型思考”和“工具执行”之间反复切换直到满足结束条件。记忆模块保存当前会话上下文、历史结果、长期知识。观测与异常处理模块记录日志、处理重试、防止死循环。其中执行循环是整个 Agent 的心脏。把循环控制好了其他模块都是在外围增强能力。2. 用 TypeScript 搭一个 100 行级通用 Agent 骨架下面开始动手。目标不是写一个生产级框架而是用最少的代码把上面那个闭环跑通然后用这个骨架去理解后续企业级扩展。2.1 环境准备与最小工程我建议你按下面的环境准备。如果你已经装好了 Node.js可以直接跳过前两步。Node.js建议 18 以上因为后面要用到fetchNode 18 开始原生支持。TypeScript建议使用 5.x配合tsx直接运行 TS 文件不需要先单独编译。包管理器npm、pnpm、yarn 都可以下面用 npm 示例。先创建一个目录并初始化mkdir ts-agent-demo cd ts-agent-demo npm init -y npm install typescript tsx types/node -D然后创建tsconfig.json{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Node, strict: true, skipLibCheck: true, types: [node] }, include: [src] }这里要提醒一下如果你用的是比较新的 TypeScript 版本控制台可能会提示baseUrl已弃用。这个提示不是报错只是新版编译器建议你使用相对导入不要真的去配一个弃用配置。很多初学者在这里被带偏以为工程有问题其实完全可以忽略。再创建目录结构ts-agent-demo/ src/ agent.ts llm.ts index.ts package.json tsconfig.json2.2 核心模块设计思路在写代码之前先设计几个关键接口因为这些接口决定了 Agent 能不能扩展。MessageAgent 和模型之间传递的消息结构包含角色、内容、工具调用 ID、工具名称。ToolCall模型返回的一个工具调用指令包含调用 ID、工具名、参数 JSON。ToolResult工具执行后的结果统一成ok和data两个字段。Tool一个工具的接口。工具只负责一件事根据参数执行并返回结果字符串。LLMClient大模型调用层的抽象。真正接入什么模型由具体实现决定。LLMClient这个接口是做扩展的关键。你可以在不修改 Agent 核心逻辑的情况下替换成不同的模型服务。这也是企业级项目里常见做法模型接口隔离不要让 Agent 核心逻辑依赖某个具体厂商的 SDK。2.3 核心代码骨架下面是src/agent.ts核心循环代码大约 100 行左右。type Role user | assistant | tool; interface Message { role: Role; content: string; toolCallId?: string; name?: string; } interface ToolCall { id: string; name: string; arguments: string; } interface ToolResult { ok: boolean; data: string; } interface Tool { name: string; description: string; execute(args: Recordstring, unknown): PromiseToolResult; } interface LLMClient { chat(messages: Message[], tools: Tool[]): Promise{ content: string; toolCalls: ToolCall[]; }; } class Agent { private tools new Mapstring, Tool(); private messages: Message[] []; constructor( private llm: LLMClient, private maxIterations 10 ) {} registerTool(tool: Tool) { this.tools.set(tool.name, tool); } async run(input: string): Promisestring { this.messages.push({ role: user, content: input }); let step 0; while (step this.maxIterations) { step; const response await this.llm.chat(this.messages, Array.from(this.tools.values())); if (response.toolCalls.length 0) { this.messages.push({ role: assistant, content: response.content }); return response.content; } this.messages.push({ role: assistant, content: response.content, toolCallId: response.toolCalls[0].id, }); for (const call of response.toolCalls) { const tool this.tools.get(call.name); if (!tool) { this.messages.push({ role: tool, name: call.name, toolCallId: call.id, content: 工具不存在: ${call.name}, }); continue; } try { const parsedArgs JSON.parse(call.arguments || {}); const result await tool.execute(parsedArgs); this.messages.push({ role: tool, name: call.name, toolCallId: call.id, content: result.data, }); } catch (err) { this.messages.push({ role: tool, name: call.name, toolCallId: call.id, content: 工具执行异常: ${(err as Error).message}, }); } } } return 达到最大迭代次数任务未完成。请缩小任务范围或优化工具参数。; } }这段代码不长但是已经把 Agent 闭环的关键逻辑都包含进去了使用Map保存工具通过registerTool注册工具后续扩展新工具不用改内核。每次循环把完整消息列表交给模型保证模型能“看到”之前所有的工具结果。模型返回toolCalls时Agent 执行工具并把结果追加到上下文。模型不再返回工具调用时Agent 结束循环返回最终答案。增加maxIterations防止 Agent 无限循环。maxIterations是调试时最先要看的一个参数。我跑 Demo 时经常会设置成 5确认逻辑通了再调大。不要一上来就设成 50因为有些模型在任务边界不清时会反复调用同一个工具消耗 token 且浪费时间。2.4 接入模型客户端src/agent.ts只定义了LLMClient接口。真实运行还需要一个具体实现。为了不依赖某个厂商 SDK可以直接用 Node.js 18 自带的fetch请求 OpenAI 兼容接口。下面是src/llm.ts的示例实现import { LLMClient, Message, Tool, ToolCall } from ./agent; interface OpenAICompatibleClientOptions { baseUrl: string; apiKey: string; model: string; } export class OpenAICompatibleClient implements LLMClient { constructor(private opts: OpenAICompatibleClientOptions) {} async chat(messages: Message[], tools: Tool[]) { const response await fetch(${this.opts.baseUrl}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.opts.apiKey}, }, body: JSON.stringify({ model: this.opts.model, messages, tools: tools.map((tool) ({ type: function, function: { name: tool.name, description: tool.description, parameters: { type: object, properties: {} }, }, })), tool_choice: auto, }), }); if (!response.ok) { throw new Error(模型接口请求失败: ${response.status} ${await response.text()}); } const data await response.json(); const choice data.choices?.[0]; const toolCalls choice?.message?.tool_calls ?? []; return { content: choice?.message?.content ?? , toolCalls: toolCalls.map((item: Recordstring, any): ToolCall ({ id: item.id, name: item.function?.name ?? , arguments: item.function?.arguments ?? {}, })), }; } }如果你没有可用的 API Key还可以写一个MockLLMClient来做本地联调。这个模拟客户端会根据消息内容返回固定指令方便你验证 Agent 循环是否正常。比如export class MockLLMClient implements LLMClient { async chat(_messages: Message[]) { return { content: , toolCalls: [ { id: call_1, name: calculator, arguments: JSON.stringify({ expression: 1 2 }), }, ], }; } }我建议初学者先用 Mock 客户端把 Agent 循环跑通再切到真实模型。这样能区分两类问题第一类是 Agent 代码本身有问题第二类是模型接口返回格式有问题。不要一上来就怪模型很多坑其实出在接口解析上。2.5 注册一个安全可用的示例工具为了演示 Agent 能执行真实任务我注册一个“安全计算器”工具。这里刻意避免直接使用eval或者Function执行任意表达式因为那在生产环境里是非常危险的操作。用正则解析加减乘除就够了既能演示工具机制又不会带来严重安全风险。const calculatorTool: Tool { name: calculator, description: 计算简单的四则运算表达式只支持数字和 - * / 操作符。, async execute(args) { const expr String(args.expression ?? ); if (!/^[\d\-*/().\s]$/.test(expr)) { return { ok: false, data: 表达式包含非法字符 }; } try { const result Function(use strict; return (${expr});)(); if (typeof result ! number) { return { ok: false, data: 表达式结果不是数字 }; } return { ok: true, data: String(result) }; } catch (err) { return { ok: false, data: 计算失败: ${(err as Error).message} }; } }, };注意这个工具仍然使用了Function来执行表达式只是前面加了一层字符白名单校验。它适合学习演示但如果你要做公共线上服务还是要用真正的表达式计算库或者把表达式解析成 AST 后再安全求值。在src/index.ts里把这些拼起来import { Agent } from ./agent; import { MockLLMClient } from ./llm; const agent new Agent(new MockLLMClient(), 5); agent.registerTool(calculatorTool); const result await agent.run(请计算 1 2然后告诉我结果。); console.log(result);运行npx tsx src/index.ts如果一切正常你会看到 Agent 先调用calculator工具拿到结果后再结束循环。这里的重点不是计算结果而是整个调用链是否按预期流转。2.6 参数解释与调整思路上面的代码里有两个最核心的参数运行时要重点关注maxIterationsAgent 最多循环多少次。设置太小复杂任务可能没做完就结束设置太大模型可能在错误路径上反复尝试消耗大量 token。messages长度Agent 会把每一步的工具结果都追加到消息数组里。任务步骤越多消息越长模型能接受的上下文有限超出后要继续处理。另外toolCallId这个字段容易被忽略。当模型返回多个工具调用时消息里必须带上对应的toolCallId让模型知道哪个结果对应哪个调用。如果你的 Agent 在同一轮出现多个工具调用但回填时搞混了 ID模型后续推理就会错乱。3. 从 Demo 到企业级架构还差哪些模块上面的核心骨架已经能跑通单轮多轮工具调用但它离“企业级”还差得很远。一个可落地的 Agent 服务需要在以下几个方面单独设计。3.1 任务拆分与规划层我的 Demo 里任务拆分依赖模型自己判断。模型能力强一点就拆得合理一点模型能力弱一点就抓瞎。真正到了生产环境通常需要显式增加一个规划层先接收用户目标。把目标拆成多个子任务。每个子任务依次分配工具和参数。如果某个子任务失败只重试该子任务而不是整个任务重来。这类似类 PI-Agent 架构里的规划器角色。你可以用一个Planner模块去调用模型输出一个结构化的任务列表。比如用户输入“分析销售数据并生成周报”规划器会输出读取数据文件、统计销售额、生成摘要、格式化周报。然后 Agent 按顺序执行这些子任务。3.2 工具注册、权限与服务发现Demo 里的工具注册很简单一个Map一个registerTool方法。生产环境必须增加输入 JSON Schema 校验模型给出的工具参数不一定规范必须校验后才执行。我的 Demo 里只做了简单解析如果参数类型错误要能提前拦截。工具权限控制不是所有 Agent 都能执行所有工具。比如“删除数据库表”这类工具只能允许特定角色调用。服务发现与版本管理工具多了以后不可能都写在一个进程里。常见做法是把工具做成独立微服务Agent 通过服务发现组件找到可用工具。审计日志记录谁在什么时间调用了哪个工具传了什么参数得到什么结果。如果你用 n8n 之类的可视化工作流工具管理 Agent 任务也能看到类似“工具节点”的概念。n8n 的优势是编排可视化、非技术人员可以参与配置但它的复杂分支逻辑在大型系统里反而会变得难以维护。代码型 Agent 的好处是逻辑可测试、可 review、可复用。两者适合不同团队不必非要二选一。3.3 记忆和上下文管理Demo 里所有的消息都放在内存数组里任务结束就清空了。这对演示没问题生产环境不行。记忆层至少需要分两层短期会话记忆保存当前任务的对话和工具调用记录用于模型推理。长期记忆把上一次任务的关键结果、用户偏好、历史错误保存到数据库或向量库下次任务启动时自动加载相关内容。上下文管理还要解决一个问题上下文太长。模型输入的 token 数量有限而 Agent 每执行一步都会追加大量消息。常见做法有只保留最近的 N 轮消息。把工具结果压缩成摘要。用向量库检索相关历史记录而不是把全部记录塞进模型。这块设计得好不好直接决定 Agent 在长任务里的表现。很多 Agent 跑着跑着“失忆”了不是因为模型能力不行而是上下文管理没做好。3.4 可观测性、重试和并发控制进入生产环境最容易被忽略的就是可观测性。我见过不少团队 Agent 逻辑很炫但一上线就抓瞎不知道 Agent 当前执行到哪一步不知道哪个工具调用失败了不知道 token 消耗多少。建议给每个任务加一个traceId并在每次模型调用、工具调用、结束循环时记录一条日志。日志里至少包含当前步骤序号。模型返回的 content 和 toolCalls。工具名称、参数、执行耗时、返回结果。是否触发重试。累计 token 消耗。重试策略要有但不能盲目。模型接口偶发超时是正常的但如果是参数格式错误或者工具逻辑 bug重试多少次都没用。建议按错误类型分类网络超时或 5xx可以重试最多 2 到 3 次。4xx 请求错误优先检查参数和鉴权不要直接重试。工具执行异常把异常信息回填给模型让模型调整参数后重新调用而不是简单重试原参数。并发控制也很关键。如果你要跑 100 个任务不要一上来就 100 个并发全开。先跑 5 个看资源占用和接口响应时间再逐步增加。很多 Agent 服务挂掉不是因为模型接口挂掉而是因为本地并发太高把数据库连接和内存打满了。3.5 多 Agent 协同与分工当任务再上一个量级单 Agent 会变成瓶颈。一个 Agent 既要做规划又要执行工具又要整理记忆很容易上下文混乱。企业里更常见的做法是多 Agent 协作一个协调者 Agent 负责拆分任务、分配子任务、收集结果。多个执行者 Agent 各自负责一个领域比如数据分析 Agent、代码生成 Agent、文档编写 Agent。执行者可以有自己的工具集协调者不直接执行工具。这种模式和类 PI-Agent 架构也不冲突反而是在它之上增加了一层角色划分。协调者只需要维护任务级状态执行者维护自己的局部状态。这样每个 Agent 的上下文都更干净问题定位也更容易。4. 常见问题与排查清单亲手从 0 搭建 Agent 骨架时你一定会遇到下面这些问题。不要慌按顺序排查。4.1 常见现象与处理方式下面这张表是我在实际调试 Agent 时最常用的排查清单现象可能原因处理方式程序没有任何输出路径错误、入口文件没写对、代码里没有调用run先确认src/index.ts存在且执行的是正确文件模型返回为空API Key 无效、模型名称错误、请求格式不对用 curl 或 Postman 单独测接口确认返回结构工具调用后没有结果工具执行抛异常、参数解析失败、工具名不匹配先看工具日志再检查Tool接口的name是否和模型返回一致Agent 一直循环maxIterations太大、工具结果不满足模型结束条件把maxIterations调小检查工具结果是否清晰上下文越界任务步骤太多、工具结果太长对工具结果截断或生成摘要只保留最近几轮消息模型调用报错接口地址、模型名、版本兼容问题单独测试模型接口确认响应 JSON 结构返回结果混乱多个toolCalls没有按toolCallId回填检查消息追加逻辑确保toolCallId严格对应提示baseUrl已弃用TypeScript 版本更新配置弃用提示换成相对导入不影响运行4.2 排查顺序先看现象再看日志最后改代码遇到问题我建议按照下面的顺序排查而不是凭感觉乱改先看现象。是完全没有输出还是输出错误还是卡住不动。再看日志。如果没有任何日志先补日志特别是run方法入口、模型返回值、工具调用前后的关键位置。再检查输入。用户输入是否合法工具参数是否完整。再检查环境。Node 版本、TypeScript 版本、依赖是否安装完整。最后才改代码逻辑。每次只改一个变量改完立刻跑最小样例验证。这里最容易被忽略的是第 2 步。很多人一看没有输出就直接改代码结果改了半天才发现是 API Key 写错了。先确认日志能打印出模型请求参数再往下定位。4.3 哪些边界不要盲目照搬最后说几个必须明确的边界不要在生产环境使用任意代码执行工具。我的计算器例子已经做了限制但只要用了Function仍然有风险。生产环境请使用专门的表达式解析库。不要使用无限制的maxIterations。Agent 不是万能的复杂任务应该在任务拆分层解决而不是让模型无限尝试。不要把所有工具都放在一个进程里。当工具数量和调用量上来后一定要独立部署、权限隔离。不要把 Agent 输出当作可信内容。模型可能产生错误结论工具结果也可能不正确。生产环境要有结果校验或人工审核环节。低配置机器也能跑 Agent 骨架但模型调用依赖网络接口真正的性能瓶颈通常在模型接口的响应时间和 token 消耗上不在本地 CPU。如果你只是学习用 Mock 客户端跑通循环就够了。如果要接入真实模型先从单条任务做起确认输出稳定后再设计成批量任务。批量任务一定要考虑超时、失败重试、输出目录命名和并发限制否则数据一多就会出各种“看起来像代码 bug 其实是并发问题”的情况。踩过几次之后我发现很多 Agent 项目跑不起来不是模型不够强也不是框架不够新而是最基础的这条执行循环没有设计干净。先把循环和工具边界梳理好再往上加模块你会觉得整个架构都清爽很多。
返回列表