TypeScript手写AI Agent核心骨架:循环控制与工具调用实战

📅 2026/8/26 12:42:52
TypeScript手写AI Agent核心骨架:循环控制与工具调用实战
在 TypeScript 生态里AI Agent 的开发已经不再是实验室里的冷门方向。越来越多的工程团队开始用代码封装大模型能力把“调用一次模型”升级成“让模型在循环里自主决策、调用工具、完成任务”。这里的难点不是怎么写 prompt而是怎么设计 Agent 的核心架构状态如何流转、工具如何注册、模型如何决定下一步、失败如何回退。这篇文章会从一个贴近实际项目的角度出发用 TypeScript 手写一个通用智能体核心骨架。总代码量控制在 100 行左右不依赖重型框架只依赖大模型 SDK 和 TypeScript 类型系统。完成后你会得到一个可运行、可扩展、可观察的最小 Agent 架构并能理解类似 PI-Agent 这类企业级产品背后的核心设计原理。文章适合已经写过基础 TypeScript、想进入 AI Agent 开发的工程师阅读。如果你想搭建一个能调用工具、能多轮推理、能稳定运行的生产级 Agent这篇文章给出的不是最终答案而是最值得先掌握的地基。1. 先拆开 AI Agent它不是“大模型套壳”而是一个循环控制系统很多刚接触 AI Agent 的开发者会把 Agent 理解为“接入了大模型的聊天机器人”。这个理解不完整。聊天机器人是“用户问一句模型答一句”整个流程是线性的一次性调用。而 Agent 的本质是一个循环控制系统模型根据当前状态做出决策决策可能是回答问题也可能是调用某个工具工具返回结果后再交给模型继续推理直到任务完成或达到终止条件。1.1 Agent 的三大核心部件模型、工具、循环一个可用的 Agent 最少包含三个部分。模型Model是决策中枢。它负责理解用户输入、判断当前进度、决定下一步动作。在 TypeScript 项目中模型通常通过 SDK 接入比如 OpenAI SDK、Anthropic SDK或者国内云厂商的兼容接口。模型本身不直接操作外部系统。工具Tool是 Agent 的手脚。工具把 Agent 的能力边界从“文本生成”扩展到“查询数据库、调用 HTTP 接口、读写文件、执行计算、发送消息”。每个工具就是一个可被模型调用的函数同时附带一段描述让模型知道“什么场景下应该调用这个工具”。循环Loop是 Agent 的引擎。模型并不一定在第一次输出时就给出最终答案。它可能先输出“我需要查询用户的订单状态”然后触发订单查询工具工具返回结果后模型再根据结果继续推理。这个“模型输出 - 解析动作 - 执行工具 - 把结果交还给模型”的过程不断循环直到模型输出最终答案或到达最大轮数限制。1.2 Agent 与普通程序控制的区别普通程序的控制流完全由开发者编写什么时候查数据库、什么时候调接口、失败之后走哪个分支全部预先写死。Agent 的控制流则由模型在运行时动态决定。这意味着开发 Agent 的核心工作发生了变化不再编写所有分支逻辑而是为模型提供清晰的能力边界。不再只关注函数的输入输出还要关注工具描述的准确性因为模型靠描述决定调用哪个工具。不再假设流程一定成功而是给循环设置终止条件防止模型无限调用工具。1.3 PI-Agent 架构给我们的启发PI-Agent 这类企业级 Agent 看起来很复杂但拆开来看核心骨架并不神秘。它通常包含几个层次模型接入层、工具注册层、Agent 执行层、记忆管理层、观测追踪层。在 100 行核心代码里我们重点实现前三个层次。记忆管理可以用简单消息数组代替观测追踪可以用日志和回调函数占位。这样既保证代码可运行又保留了向企业级架构演进的空间。2. 环境准备TypeScript 项目、依赖与最小模型配置开始写代码之前先把环境对齐。如果环境不统一后面很多报错都会出现在“类型匹配”和“SDK 调用方式”上而不是真正的逻辑错误。2.1 初始化 TypeScript 项目推荐使用 Node.js 18 及以上版本TypeScript 5.x。初始化项目时可以直接创建一个最小工程结构。mkdir ts-agent-demo cd ts-agent-demo npm init -y npm install typescript tsx openai zod npx tsc --init安装依赖后把package.json中的脚本改成下面这样方便后续用tsx直接运行 TypeScript 文件{ scripts: { dev: tsx src/index.ts, build: tsc, start: node dist/index.js } }这里使用openaiSDK 的原因有两个。第一它兼容 OpenAI 官方的 Chat Completions 接口。第二国内很多大模型平台提供了兼容 OpenAI 格式的接口可以只修改baseURL和apiKey完成接入不需要改业务代码。这符合 Agent 开发中“模型可替换”的思想。2.2 创建环境变量在项目根目录创建.env文件保存模型访问配置MODEL_API_KEY你的API密钥 MODEL_BASE_URLhttps://api.openai.com/v1 MODEL_NAMEgpt-4o-mini实际项目中不要把 API Key 写死在代码里更不要提交到 Git 仓库。如果团队有配置中心密钥应该放在配置中心本地开发可以使用.env文件并在.gitignore中排除它。2.3 定义消息类型与工具类型Agent 内部所有数据都围绕“消息”流转。为了让代码清晰先定义基础类型。在src/types.ts中写入export type ChatMessage { role: system | user | assistant | tool; content: string; tool_call_id?: string; name?: string; tool_calls?: ToolCall[]; }; export type ToolCall { id: string; type: function; function: { name: string; arguments: string; }; }; export type ToolDefinition { name: string; description: string; parameters: Recordstring, unknown; handler: (args: Recordstring, unknown) Promiseunknown | unknown; };这段类型定义是整个 Agent 的地基。ChatMessage表示进入模型的一条消息。tool角色用于把工具执行结果返回给模型。ToolCall是模型发出的“调用某个函数”的指令。它包含函数名和参数参数是 JSON 字符串需要解析后才能传给真实函数。ToolDefinition是开发者注册工具时提供的元信息。parameters用于描述参数的 JSON Schemahandler是真正执行工具逻辑的函数。3. 用 100 行代码实现通用 Agent 核心循环核心循环是整个 Agent 最值得仔细看的部分。它做的事情可以概括成一句话把多轮对话历史交给模型解析模型是否要求调用工具如果是就执行工具再把结果追加到历史中继续调用模型直到模型给出最终回答。3.1 搭建 Agent 类的基本结构在src/agent.ts中创建Agent类。类成员包括模型客户端、模型配置、工具注册表、消息历史和最大循环轮数。import OpenAI from openai; import { ChatMessage, ToolDefinition } from ./types; export class Agent { private tools: Mapstring, ToolDefinition new Map(); private messages: ChatMessage[] []; private maxIterations: number; constructor( private client: OpenAI, private model: string, private systemPrompt: string, maxIterations 10 ) { this.maxIterations maxIterations; this.messages.push({ role: system, content: systemPrompt }); } registerTool(tool: ToolDefinition) { this.tools.set(tool.name, tool); } addUserMessage(content: string) { this.messages.push({ role: user, content }); } }tools使用 Map 存储好处是工具查找时间复杂度为 O(1)而且天然保证工具名不重复。messages保存完整对话历史这是 Agent 具备多轮记忆能力的基础。3.2 实现对话主循环下面的run()方法就是 Agent 的核心循环。async run(): Promisestring { for (let i 0; i this.maxIterations; i) { const response await this.client.chat.completions.create({ model: this.model, messages: this.messages, tools: Array.from(this.tools.values()).map((t) ({ type: function as const, function: { name: t.name, description: t.description, parameters: t.parameters } })), tool_choice: auto }); const choice response.choices[0]; const message choice.message; const toolCalls message.tool_calls ?? []; this.messages.push({ role: assistant, content: message.content ?? , tool_calls: toolCalls }); if (toolCalls.length 0) { return message.content ?? ; } for (const call of toolCalls) { const toolResult await this.executeToolCall(call); this.messages.push({ role: tool, tool_call_id: call.id, name: call.function.name, content: JSON.stringify(toolResult) }); } } throw new Error(Agent 达到最大迭代轮数任务未完成); }这段代码解释了 Agent 循环的完整流程把历史消息和所有工具定义一起传给模型。模型返回两种结果之一要么直接输出最终内容要么返回tool_calls。如果返回tool_calls把模型这条消息加入历史然后逐个执行工具。工具结果以tool角色消息追加到历史。携带更新的历史再次请求模型进入下一轮。整段逻辑不包含复杂的任务编排但这是所有 Agent 框架共同的“心脏”。3.3 执行工具调用executeToolCall负责把模型发出的函数调用指令翻译成真实代码执行。private async executeToolCall(call: any): Promiseunknown { const tool this.tools.get(call.function.name); if (!tool) { return { error: 未找到工具: ${call.function.name} }; } try { const args JSON.parse(call.function.arguments || {}); return await tool.handler(args); } catch (error) { return { error: 工具执行失败: ${error instanceof Error ? error.message : String(error)} }; } }工具执行失败时不直接抛出异常而是把错误信息返回给模型。这是 Agent 设计中一个非常关键的原则模型应该有能力从错误中恢复。比如工具返回“无法连接数据库”模型可以决定换一种查询方式或者直接告诉用户当前服务不可用。4. 注册真实工具让 Agent 具备外部世界操作能力工具注册是 Agent 扩展能力最直接的方式。下面给出两个常见工具示例一个是查询用户余额一个是执行加减乘除计算。第一个展示外部业务数据如何交付给模型第二个展示纯计算工具的用法。4.1 编写工具定义在src/tools.ts中创建工具注册函数import { ToolDefinition } from ./types; export const tools: ToolDefinition[] [ { name: get_user_balance, description: 根据用户ID查询余额当用户询问余额、账户金额、欠费时使用, parameters: { type: object, properties: { userId: { type: string, description: 用户ID例如 user_123 } }, required: [userId] }, handler: async ({ userId }) { const data: Recordstring, number { user_123: 199.5, user_456: 0 }; return { userId, balance: data[userId] ?? -1, currency: CNY }; } }, { name: calculate, description: 执行两个数字的四则运算当需要进行数学计算时使用, parameters: { type: object, properties: { left: { type: number }, right: { type: number }, operator: { type: string, enum: [add, subtract, multiply, divide] } }, required: [left, right, operator] }, handler: ({ left, right, operator }) { switch (operator) { case add: return left right; case subtract: return left - right; case multiply: return left * right; case divide: if (right 0) { throw new Error(除数不能为0); } return left / right; default: throw new Error(未知运算符: ${operator}); } } } ];这里的description字段非常关键。模型决定是否调用工具主要依赖这个描述。描述越具体越能说明触发场景模型选错工具的概率就越低。parameters则定义了参数结构模型会按 JSON Schema 生成参数。4.2 工具设计最容易踩的两个坑第一个坑是参数设计过于模糊。如果只写“用户ID”模型可能生成id、userId、user_id等不同字段名导致JSON.parse后取不到值。参数名必须写清楚并且可以在描述中给一个示例值。第二个坑是返回值不是 JSON 友好的结构。模型拿到工具返回值后需要理解它所以要返回普通对象而不是Map、Set或带循环引用的对象。调用JSON.stringify时一旦出现循环引用整个循环会崩溃。5. 组装入口用最小案例验证 Agent 能跑通核心代码写完以后需要组装一个入口文件验证效果。在src/index.ts中完成模型客户端创建、工具注册、对话启动三步。import OpenAI from openai; import { Agent } from ./agent; import { tools } from ./tools; const client new OpenAI({ apiKey: process.env.MODEL_API_KEY, baseURL: process.env.MODEL_BASE_URL }); const agent new Agent( client, process.env.MODEL_NAME || gpt-4o-mini, 你是一个智能助手可以回答用户问题也可以在必要时调用工具完成任务。回答尽量简洁。 ); tools.forEach((tool) agent.registerTool(tool)); async function main() { agent.addUserMessage(用户 user_123 的账户余额是多少); const answer await agent.run(); console.log(Agent 回答:, answer); } main().catch((error) { console.error(执行失败:, error); process.exit(1); });运行命令npx tsx src/index.ts如果配置正确你会看到类似输出Agent 回答: 用户 user_123 的当前余额为 199.5 元人民币。从输出看Agent 完成了以下动作解析用户问题 - 识别需要调用余额查询工具 - 调用工具拿到数据 - 把数据整理成自然语言回答。这不是一条固定的 if-else 流程而是模型在运行时自主选择的结果。6. 深入解析为什么这套架构能支撑企业级 Agent前面用 100 行代码跑通了一个最小 Agent。现在回头分析这套简单架构里哪些设计决定了它能否走向生产环境。6.1 消息历史设计Agent 记忆的原始形态当前代码把messages保存在 Agent 实例内部。每次循环都携带完整历史发给模型这样模型才能记住上下文。但生产环境的记忆不会这么简单。实际项目需要区分短期记忆和长期记忆。短期记忆通常指当前会话内的消息窗口需要考虑长度控制长期记忆则要引入向量数据库把用户历史行为、业务偏好、重要结论存储起来按需检索后注入 prompt。6.2 工具注册中心Agent 能力边界的管理入口Mapstring, ToolDefinition本质上是一个工具注册中心。生产环境中这个注册中心会有更复杂的形态工具可能来自多个服务、多个团队需要版本管理、权限校验、鉴权、限流、观测。但核心仍然是一个工具名到工具实现的映射。工具权限值得单独强调。当前示例中工具函数内部没有权限判断。一旦 Agent 面向真实用户工具可能操作真实订单、发送真实消息、修改真实数据。工具层必须在执行前校验调用者权限、操作范围和配额。否则一个恶意 prompt 就能让 Agent 调用高权限工具。6.3 错误处理策略模型不是确定性程序必须容忍失败大模型输出天然具有不确定性。模型可能生成了不存在的工具名可能生成了 JSON 解析失败的工具参数也可能连续三轮调用同一个工具不收敛。当前代码中用 try/catch 包住工具执行并把错误信息返回给模型这是一种“给模型自行修正机会”的策略。更完备的 Agent 还会加入工具参数二次校验不符合 JSON Schema 时直接拒绝。工具重试和降级策略。并发工具调用优化。循环中的“审计日志”记录每次模型输出、工具调用和结果用于事后排查。6.4 观测追踪Agent 调式难只能靠结构化记录传统接口调试可以看接口日志、看数据库查询、看报错堆栈。Agent 的每一次决策都依赖模型输入和输出问题可能出在 prompt 编写、工具描述、模型选择、参数解析等多个环节。生产级 Agent 从上到下要记录这些数据记录层级记录内容排查价值请求层用户原始输入、会话 ID、模型名称确认输入是否完整模型层传给模型的完整消息序列检查模型是否看到了错误的上下文决策层模型输出内容、tool_calls 内容确认模型为什么选择某个工具工具层工具参数、执行耗时、返回值确认工具逻辑是否正确结果层最终回答、轮数、Token 消耗评估成本和效果这些数据和当前代码里的messages数组一一对应所以最小原型阶段就可以在关键位置埋日志。7. 常见问题排查从现象定位 Agent 异常根因Agent 项目一旦跑起来大概率会遇到下面几类问题。这里的排查顺序非常重要从输入到模型从模型到工具层层缩小范围。7.1 模型完全不调用工具现象是模型直接给出回答即使工具存在且参数匹配也不触发tool_calls。可能原因和排查路径工具描述不够具体。检查工具的description是否写清了触发场景。模型能力较弱或参数错误。部分小模型对 function calling 支持很差可以换一个模型测试。messages 里的系统提示词过于强势例如“总是直接回答”会抑制工具调用。是否传入了tools参数。检查tool_choice是否被设置成none。推荐做法是把工具描述写成交互场景比如“当用户询问余额时使用”而不是“查询用户余额”。7.2 工具参数解析失败现象是JSON.parse(call.function.arguments)抛异常或者解析后核心字段缺失。可能原因模型返回的 JSON 带有额外文本比如换行和反引号。参数名与真实字段不一致。SDK 对工具参数有特殊封装实际收到的结构不是预期对象。排查方式是打印原始call对象查看模型生成的原始参数。不要直接在executeToolCall里做无提示JSON.parse应该先记录原始内容再解析。7.3 Agent 无限循环现象是模型不断调用工具直到触发maxIterations抛出“任务未完成”。可能原因工具返回值结构不稳定模型无法判断是否已拿到最终结果。工具执行结果一直没有进入下一次请求导致模型反复发出相同请求。系统提示词没有明确“拿到结果后要直接回答”。预防建议至少三条设置最大迭代轮数避免无限消耗 Token。工具返回值中包含明确状态字段例如success和message。在run()内部记录每一轮的 tool_calls发现与上一轮完全相同时提前终止。7.4 上下文长度超限现象是请求模型时报context_length_exceeded或类似错误。可能原因是循环轮数多、工具返回内容大导致消息历史膨胀。处理方案对工具返回值做截断只保留前 N 个字符。使用消息摘要机制把早期历史压缩成一段总结。限制对话轮数或使用滑动窗口。换用支持更长上下文的模型。8. 从 100 行原型到企业级 Agent核心演进路线最小原型能跑通只代表“Agent 机制”成立。企业级 Agent 还需要在六个方向补齐。8.1 模型管理从单模型到模型路由生产系统往往需要支持多个模型。简单问答走快模型复杂推理走强模型工具调用走兼容性好的模型。模型路由层负责根据任务类型、上下文长度、成本预算自动选择模型。最小原型里写死的this.model在此时就要抽象成配置或路由结果。8.2 工具层从本地函数到远程服务当前工具的handler是本地函数。生产环境下工具实现可能部署在独立服务中Agent 通过 HTTP、gRPC 或消息队列调用工具。这时工具注册中心还需要引入超时、重试、熔断和服务发现能力。工具描述中提供的 JSON Schema 不只是给大模型看还可以复用来生成表单、校验参数、生成 API 文档。工具定义越规范后续扩展越容易。8.3 记忆层从消息数组到分层记忆企业级 Agent 需要区分会话内上下文、用户画像、业务数据、全局知识库。一个比较实用的分层方案记忆类型存储方式过期策略会话消息RedisTTL 30 分钟会话结束自动清理用户偏好摘要向量数据库或关系库长期业务事实业务库随业务变更知识文档向量库按文档版本管理8.4 观测层从 console.log 到链路追踪每一轮模型的输入输出、工具调用、Token 消耗都应该形成结构化日志。生产环境建议使用 OpenTelemetry 标准上报把 Agent 调用链路和业务系统打通。排查问题时只需要按会话 ID 拉出完整决策链。8.5 安全层从“无权限”到“双重校验”安全不只是 API Key 管理。Agent 的提示词注入风险尤其需要警惕。用户输入可能包含“忽略之前所有指令”这类攻击内容工具返回值也可能携带恶意指令。生产环境的 Agent 建议做三层防护输入侧过滤识别明显的提示词注入。工具调用前权限校验确认当前用户是否具有该工具的执行权限。工具调用后结果过滤防止工具返回值携带危险内容进入模型上下文。8.6 评测层从“人工看结果”到自动化评估Agent 改造完后如何确认效果传统功能测试覆盖不到模型输出的多样性。实际项目可以建立评估集设计 100 个到 1000 个典型任务每个任务有标准输入和期望步骤运行 Agent 后自动比对结果。评估维度至少包括任务完成率、工具调用准确率、平均迭代轮数、单任务成本、失败场景分布。评测数据要进入 CI/CD防止一次 prompt 调整导致其他场景退化。9. 可复用的开发检查清单下面是开发 Agent 时可以反复使用的检查清单每次新增工具、调整模型或发布前都建议按这个列表过一遍。工具开发检查[ ] 工具名是否唯一是否使用小写加下划线的格式。[ ] 工具描述是否写清了触发条件避免空泛词汇。[ ] 参数是否都声明了类型必填项是否在required中。[ ] 工具返回值是否是 JSON 友好结构。[ ] 工具内部是否捕获了外部依赖异常。[ ] 是否给工具设置了合理的超时和重试策略。Agent 运行前检查[ ] API Key 和 baseURL 是否通过配置文件或环境变量注入。[ ] 系统提示词是否明确禁止模型直接编造工具结果。[ ] 最大迭代轮数是否设置为合理值避免无限循环。[ ] 是否记录每一轮的模型输出和工具调用日志。Agent 发布前检查[ ] 是否评估过单次任务的 Token 成本。[ ] 是否对模型输出做了质量抽检。[ ] 是否准备好模型不可用时的降级方案。[ ] 是否检查过工具层权限确认用户不能越权调用。[ ] 是否配置监控告警至少覆盖错误率、超时率和未完成任务率。10. 扩展方向这套骨架还能往哪里走完成了最小 Agent你可能对下面这些方向产生兴趣。多 Agent 协作。当前是一个 Agent 在循环中调用工具。多 Agent 架构则是多个 Agent 各司其职一个负责拆解任务一个负责执行代码一个负责审核结果。它们之间通过消息队列或共享任务状态通信。相比单 Agent多 Agent 更容易实现职责隔离和并行处理但也引入了任务分发、结果汇总、状态同步等新复杂度。Agent 工作流平台。类似 n8n 这类可视化工作流平台本质上也是把工具节点和模型节点串联起来。你可以把这里的 Agent 封装成一个可复用的工作流节点外部系统通过 Webhook 或消息队列向它提交任务Agent 完成后把结果回调给工作流。这种模式适合把 Agent 能力嵌入到已有业务系统中。函数调用之外的 Agent 能力。除了 function callingAgent 还可以使用代码解释器、浏览器操作、数据库操作等能力。这些能力本质上都服务于同一个目标扩大模型能够感知和影响的范围。不同的能力载体只要统一封装成工具定义就能复用当前这套 Agent 循环。当前这套 TypeScript Agent 骨架最值得保留的不是代码本身而是它所体现的设计思路消息是上下文传递的唯一载体工具是能力边界的唯一入口循环是任务推进的唯一引擎。把这个思路理解透再去阅读 LangChain、PI-Agent 或任何企业级框架的源码你看到的就会是熟悉的骨架而不是散落的功能点。