用TypeScript手写AI Agent骨架:核心是自主调用工具的循环
2026/8/26 20:48:54 网站建设 项目流程

先说一个结论: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 的执行闭环:

  1. 用户输入一个目标,比如“统计这份数据里销售额最高的三个城市”。
  2. Agent 把目标交给大模型,大模型发现:我需要先读取数据文件,然后做计算,最后输出结果。
  3. 大模型不会直接返回最终答案,而是返回一个“工具调用指令”:调用文件读取工具,参数是文件路径。
  4. Agent 执行这个工具,拿到真实数据结果。
  5. Agent 把工具结果追加到对话上下文里,再次交给大模型。
  6. 大模型根据真实数据继续生成下一步指令,或者认为信息足够,输出最终答案。

这个“模型思考 -> 工具执行 -> 结果回填 -> 再思考”的循环,才是 Agent 的基本运行机制。类 PI-Agent 架构强调的也正是这个点:Agent 是任务规划器,也是任务执行调度器,而不是一个单纯的对话接口。

1.1 为什么用 TypeScript 而不直接用 Python 框架

如果你只是跑一次研究 Demo,Python 生态确实方便。但如果你要做的 Agent 需要落地到现有业务系统,情况就不同了:

  • 前端项目本身就是 TypeScript,Agent 可以直接复用已有的数据类型定义和接口调用层。
  • 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 以上,因为后面要用到fetch,Node 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.json

2.2 核心模块设计思路

在写代码之前,先设计几个关键接口,因为这些接口决定了 Agent 能不能扩展。

  • Message:Agent 和模型之间传递的消息结构,包含角色、内容、工具调用 ID、工具名称。
  • ToolCall:模型返回的一个工具调用指令,包含调用 ID、工具名、参数 JSON。
  • ToolResult:工具执行后的结果,统一成okdata两个字段。
  • 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: Record<string, unknown>): Promise<ToolResult>; } interface LLMClient { chat(messages: Message[], tools: Tool[]): Promise<{ content: string; toolCalls: ToolCall[]; }>; } class Agent { private tools = new Map<string, Tool>(); private messages: Message[] = []; constructor( private llm: LLMClient, private maxIterations = 10 ) {} registerTool(tool: Tool) { this.tools.set(tool.name, tool); } async run(input: string): Promise<string> { 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: Record<string, 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 参数解释与调整思路

上面的代码里有两个最核心的参数,运行时要重点关注:

  • maxIterations:Agent 最多循环多少次。设置太小,复杂任务可能没做完就结束;设置太大,模型可能在错误路径上反复尝试,消耗大量 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 排查顺序:先看现象,再看日志,最后改代码

遇到问题,我建议按照下面的顺序排查,而不是凭感觉乱改:

  1. 先看现象。是完全没有输出,还是输出错误,还是卡住不动。
  2. 再看日志。如果没有任何日志,先补日志,特别是run方法入口、模型返回值、工具调用前后的关键位置。
  3. 再检查输入。用户输入是否合法,工具参数是否完整。
  4. 再检查环境。Node 版本、TypeScript 版本、依赖是否安装完整。
  5. 最后才改代码逻辑。每次只改一个变量,改完立刻跑最小样例验证。

这里最容易被忽略的是第 2 步。很多人一看没有输出就直接改代码,结果改了半天才发现是 API Key 写错了。先确认日志能打印出模型请求参数,再往下定位。

4.3 哪些边界不要盲目照搬

最后说几个必须明确的边界:

  • 不要在生产环境使用任意代码执行工具。我的计算器例子已经做了限制,但只要用了Function,仍然有风险。生产环境请使用专门的表达式解析库。
  • 不要使用无限制的maxIterations。Agent 不是万能的,复杂任务应该在任务拆分层解决,而不是让模型无限尝试。
  • 不要把所有工具都放在一个进程里。当工具数量和调用量上来后,一定要独立部署、权限隔离。
  • 不要把 Agent 输出当作可信内容。模型可能产生错误结论,工具结果也可能不正确。生产环境要有结果校验或人工审核环节。
  • 低配置机器也能跑 Agent 骨架,但模型调用依赖网络接口,真正的性能瓶颈通常在模型接口的响应时间和 token 消耗上,不在本地 CPU。

如果你只是学习,用 Mock 客户端跑通循环就够了。如果要接入真实模型,先从单条任务做起,确认输出稳定后,再设计成批量任务。批量任务一定要考虑超时、失败重试、输出目录命名和并发限制,否则数据一多就会出各种“看起来像代码 bug 其实是并发问题”的情况。

踩过几次之后我发现,很多 Agent 项目跑不起来,不是模型不够强,也不是框架不够新,而是最基础的这条执行循环没有设计干净。先把循环和工具边界梳理好,再往上加模块,你会觉得整个架构都清爽很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询