从零手写AI Agent:100行TypeScript实现核心骨架 很多同学在接触 AI Agent 时会遇到一个很尴尬的困境看了很多概念图收藏了一堆开源项目但轮到自己动手却不知道第一行代码该写在哪里。市面上的 Agent 框架动辄几万行源码部署文档比小说还长学完之后感觉记住了很多名词真正要在一个业务系统里落地时仍然是无从下手。这篇文章想换一种切入方式。我们不引入任何重型框架只用一个你已经很熟悉的语言——TypeScript从零手写一个通用智能体的核心骨架。目标很明确用 100 行左右的核心代码把类 PI-Agent 的架构关键链路跑通包括模型调用、工具注册、任务规划、状态管理和循环执行。等你亲手把这个最小闭环跑起来再去读那些企业级 Agent 框架源码理解成本会低非常多。读懂并掌握这套最小实现后你的收获不仅是“会写一个 Demo”而是真正理解 Agent 的架构边界哪些逻辑属于编排层哪些逻辑属于工具层哪些逻辑应该交给模型决策哪些逻辑必须由工程代码强制约束。这些东西才是企业级 Agent 设计和生产中真正值钱的部分。1. 为什么你需要用 TypeScript 自己写一个 Agent先不急着写代码。我们先回答一个关键问题现在已经有了那么多 Agent 框架为什么还建议你自己用 TypeScript 手写一个第一框架帮你省掉了复杂度但也帮你屏蔽了原理。当你直接用现成框架时Agent 内部的工具调用循环、上下文管理、状态保存、异常重试这些关键机制对你来说是一个黑盒。一旦线上 Agent 行为不符合预期你连排查的方向都没有。自己实现一遍最小闭环等于给自己画了一张内部结构图。第二TypeScript 是现阶段做 Agent 工程落地很合适的语言。AI Agent 本质上是一个偏后端编排的系统它需要处理并发、类型约束、配置管理、日志监控。TypeScript 有完整的类型系统又能直接运行在 Node.js 生态中。对于企业内部已经以 Node.js 为技术栈的团队用 TypeScript 扩展 Agent 能力不需要引入一套新语言对于个人开发者TypeScript 的开发体验和调试体验也足够友好。第三企业级 Agent 和玩具 Demo 之间的差距往往不在模型的推理能力而在工程结构。同样是调用大模型接口有的人写出来是一个脆弱的脚本一旦工具返回异常格式就会崩溃上下文一长就丢信息加一个新的工具函数就要改动主流程。而有的人写出来的是一个边界清晰的系统新增工具只需要注册模型决策失败有兜底每一步执行都有日志。这两者的区别就是架构设计能力。所以这篇文章里的每一段代码都不是为了“好看”而是在为生产环境的可靠性做铺垫。2. 先理解 Agent 的核心组成与架构边界在动手写代码之前我们需要对 Agent 的基本组成有一个共同认知。一个通用 Agent 系统在不考虑复杂多智能体协作的情况下至少包含六个核心部分组成模块职责类比模型调用层与大模型 API 交互发送消息并获取回复人的大脑指令与提示词定义 Agent 的角色、行为边界和回复风格人的岗位说明书工具注册中心维护 Agent 可调用的外部函数清单人的双手和工具箱任务规划循环解析用户目标拆解步骤调用工具观察结果循环推进人的工作流程上下文记忆管理保存对话历史、任务状态和工具执行结果人的工作笔记状态管理与安全控制控制循环何时停止、如何降级、如何报错人的风险意识所谓的“类 PI-Agent 架构”并不是指某一个固定开源项目的代码结构而是近一年企业级 Agent 实践中逐渐收敛的一套参考思路把插件能力Plugin、意图理解Intent和编排Orchestration分层解耦让模型负责动态决策让代码负责刚性约束。你可能在企业 Agent 项目中见过类似的分层入口层负责接收用户请求统一鉴权和参数校验。编排层负责 Agent 的主循环决定下一步调用模型还是调用工具。工具层负责执行具体动作比如查数据库、调接口、发消息。记忆层负责管理短期上下文和长期知识。模型层负责屏蔽不同大模型厂商的 API 差异。这里要特别强调一个新手常见的误区Agent 不是“大模型聊天窗口加几个按钮”。聊天窗口加按钮本质上仍然是用户在驱动交互。真正的 Agent核心特征在于模型能够自主决定调用哪个工具、以什么参数调用、在什么条件下停止。这种“自主决策”能力需要代码侧提供一个安全且可控的执行环境。你接下来要手写的最小版本就是把这个执行环境搭出来。3. 环境准备与工程初始化现在我们进入实操环节。先准备一个最小的 TypeScript 项目环境。这里有一个原则不要一次性把依赖装得过于复杂。很多 Agent 项目跑不起来不是因为代码逻辑有问题而是因为依赖版本冲突。我们先从最精简的依赖开始跑通核心闭环后再慢慢加东西。3.1 环境要求开发环境建议满足以下条件操作系统Windows / macOS / Linux 均可。Node.js建议 18 及以上版本。版本号请以你安装时的稳定版本为准本文代码依赖原生fetchNode.js 18 以上原生支持。TypeScript使用 npm 安装即可。包管理器npm、yarn、pnpm 都可以本文以 npm 示例。如果你还没有 Node.js 环境请先到 Node.js 官网下载 LTS 版本安装。安装完成后在终端执行node -v npm -v能正常打印出版本号说明环境就绪。3.2 初始化 TypeScript 项目创建一个项目目录并初始化 package.jsonmkdir ts-agent-lite cd ts-agent-lite npm init -y安装 TypeScript 以及开发时的类型声明npm install typescript tsx types/node --save-dev这里解释一下安装的三个包分别做什么typescriptTypeScript 编译器用于类型检查和编译。tsx一个基于 esbuild 的 TypeScript 执行器可以让我们直接运行.ts文件不用每次手动编译调试效率高很多。types/nodeNode.js 的内置 API 类型声明让 TypeScript 认识process、console等全局对象。然后初始化tsconfig.jsonnpx tsc --init由于我们使用tsx运行编译配置可以简化。建议将tsconfig.json中的关键项调整为{ compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, strict: true, skipLibCheck: true, esModuleInterop: true, forceConsistentCasingInFileNames: true }, include: [src] }strict: true很关键。在 Agent 这类偏重工程健壮性的项目中严格模式能帮你在编译期就发现很多潜在空值和时间题。不要在初学阶段关闭严格模式。3.3 模型 API 的准备本文代码中的模型调用层我们做一个抽象设计不绑定某一家厂商 SDK。这样有一个明显的好处你可以把代码里的apiBaseUrl、apiKey、modelName改成你实际使用的兼容 OpenAI 协议的服务地址也可以替换成企业内网部署的模型服务。在项目根目录创建.env文件写入# .env LLM_API_KEY你的密钥 LLM_BASE_URLhttps://api.openai.com/v1 LLM_MODELgpt-4o-mini注意.env文件不要提交到 Git 仓库。由于我们不引入额外的dotenv依赖可以用 Node.js 自带的--env-file参数加载环境变量也可以在代码里读取process.env。为了最简单我们直接通过process.env读取并在启动命令里指定环境变量。4. 核心架构设计100 行代码怎么组织先看整体目录结构。我们不追求大而全而是忠于一个原则每个模块只做一件事模块之间通过类型约束连接。ts-agent-lite/ ├── src/ │ ├── types.ts # 类型定义 │ ├── tools.ts # 工具注册中心 │ ├── llm.ts # 大模型调用层 │ ├── agent.ts # Agent 编排循环 │ └── main.ts # 入口与示例 ├── .env ├── package.json └── tsconfig.json设计这套结构时有几个判断贯穿始终类型定义独立成文件避免循环依赖。工具层不依赖模型层模型层不依赖具体工具。这样职责干净测试时也能互相替换。Agent 编排层只认两个接口一个是ChatClient模型客户端一个是Tool[]工具列表。下面我们把每一层的关键设计拆开讲。5. 完整代码实现从类型定义到主循环5.1 类型定义先把边界画清楚首先定义 Agent 系统中的核心类型。文件路径src/types.ts。模型消息类型定义// 文件路径src/types.ts /** * 大模型消息类型 */ export type Role system | user | assistant | tool; export interface ChatMessage { role: Role; content: string; /** * 工具调用 ID用于关联工具请求和工具结果 */ tool_call_id?: string; } /** * 模型要求的工具调用描述 */ export interface ToolCall { id: string; type: function; function: { name: string; arguments: string; }; } /** * 模型调用结果 */ export interface ChatResult { content: string | null; toolCalls: ToolCall[]; } /** * 工具参数 schema用于向模型描述参数结构 */ export interface ToolParameterSchema { type: string; properties: Recordstring, unknown; required?: string[]; } /** * 工具定义Agent 可执行的最小单元 */ export interface ToolTArgs unknown { name: string; description: string; parameters: ToolParameterSchema; execute: (args: TArgs) Promisestring | string; } /** * Agent 执行配置 */ export interface AgentConfig { systemPrompt: string; maxIterations: number; tools: Tool[]; chatClient: ChatClient; } /** * 模型客户端接口 */ export interface ChatClient { chat(messages: ChatMessage[]): PromiseChatResult; }这里最关键的是Tool接口。我们把它设计成一个工具只需要提供名字、描述、参数格式和execute方法。Agent 主循环不关心工具内部实现它只负责把模型给的参数传给execute并拿回字符串结果。这样后续每新增一个工具都不需要改主循环。5.2 工具注册中心用最少代码管理你的工具集文件路径src/tools.ts。工具注册中心的职责有两部分维护一个全局工具 Map支持按名字查找。提供一个注册函数方便集中登记。// 文件路径src/tools.ts import { Tool } from ./types; /** * 工具注册表 * 内部使用 Map 保证查找效率 */ class ToolRegistry { private tools new Mapstring, Tool(); register(tool: Tool) { if (this.tools.has(tool.name)) { throw new Error(工具已存在: ${tool.name}); } this.tools.set(tool.name, tool); } get(name: string): Tool | undefined { return this.tools.get(name); } list(): Tool[] { return Array.from(this.tools.values()); } } export const registry new ToolRegistry();注册两个演示工具一个是天气查询一个是计算器。// 继续在 src/tools.ts 中追加 /** * 模拟天气查询工具 * 真实项目中这里会调用外部天气 API */ registry.register({ name: get_weather, description: 查询指定城市的当前天气, parameters: { type: object, properties: { city: { type: string, description: 城市名例如北京、上海, }, }, required: [city], }, execute: (args: { city: string }) { return 今日${args.city}天气晴朗气温 22 摄氏度适合出行。; }, }); /** * 计算器工具 * 对大模型不擅长的数学运算交给代码执行更保险 */ registry.register({ name: calculator, description: 执行四则运算支持加减乘除, parameters: { type: object, properties: { expression: { type: string, description: 数学表达式例如12 * 3 5, }, }, required: [expression], }, execute: (args: { expression: string }) { // 注意这里仅演示生产环境需要更安全的表达式求值方案 const result Function(use strict; return (${args.expression}))(); return 计算结果: ${result}; }, });这个工具注册中心虽然只有二十几行代码但它已经决定了 Agent 的扩展方式工具能力的扩展变成了“实现一个Tool对象并注册”而不是改动主流程。5.3 模型调用层隔离外部 API 差异文件路径src/llm.ts。模型调用层要做的事很简单把消息列表发给模型然后把模型返回的结果解析成内部统一的ChatResult结构。// 文件路径src/llm.ts import { ChatClient, ChatMessage, ChatResult } from ./types; interface OpenAIChatCompletionResponse { choices: Array{ message: { content?: string | null; tool_calls?: Array{ id: string; type: function; function: { name: string; arguments: string; }; }; }; }; } /** * 基于 OpenAI Chat Completions 协议实现的客户端 * 兼容大多数支持该协议的服务 */ export class OpenAIChatClient implements ChatClient { private apiKey: string; private baseUrl: string; private model: string; constructor() { this.apiKey process.env.LLM_API_KEY || ; this.baseUrl process.env.LLM_BASE_URL || https://api.openai.com/v1; this.model process.env.LLM_MODEL || gpt-4o-mini; if (!this.apiKey) { throw new Error(缺少 LLM_API_KEY 环境变量); } } async chat(messages: ChatMessage[]): PromiseChatResult { const response await fetch(${this.baseUrl}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.apiKey}, }, body: JSON.stringify({ model: this.model, messages, tools: [], // 工具的详细描述在 Agent 编排层注入 }), }); if (!response.ok) { const text await response.text(); throw new Error(模型调用失败: ${response.status} ${text}); } const data (await response.json()) as OpenAIChatCompletionResponse; const message data.choices[0]?.message; return { content: message?.content ?? null, toolCalls: (message?.tool_calls ?? []).map((tc) ({ id: tc.id, type: tc.type, function: { name: tc.function.name, arguments: tc.function.arguments, }, })), }; } }这里有一个设计细节值得注意tools参数目前传的是空数组工具描述会在 Agent 主循环中动态注入。这个设计不是笔误而是为了让模型调用层保持纯净它只负责收发消息不负责决定用哪些工具。5.4 Agent 编排主循环核心中的核心文件路径src/agent.ts。这是整个项目最核心的部分。所谓 Agent 的智能本质上就是在这个循环里产生的把当前会话消息发给模型。判断模型返回的是普通文本还是工具调用请求。如果是工具调用请求就执行工具把结果追加到消息列表回到第 1 步。如果是普通文本说明 Agent 认为任务完成结束循环。有了上面的类型定义这个循环的实现可以非常干净// 文件路径src/agent.ts import { AgentConfig, ChatMessage, ChatResult } from ./types; /** * 执行一次 Agent 对话 */ export async function runAgent( config: AgentConfig, userInput: string ): Promisestring { const messages: ChatMessage[] [ { role: system, content: config.systemPrompt }, { role: user, content: userInput }, ]; const toolMap new Map(config.tools.map((t) [t.name, t])); for (let i 0; i config.maxIterations; i) { console.log([Agent] 第 ${i 1} 轮推理...); const result: ChatResult await config.chatClient.chat(messages); // 场景 1模型没有要求调用工具直接返回最终回答 if (!result.toolCalls || result.toolCalls.length 0) { return result.content || 模型未返回内容; } // 场景 2模型要求调用工具 // 先把 assistant 的原始消息记入历史这是必须的 messages.push({ role: assistant, content: result.content || , tool_calls: result.toolCalls as never, }); for (const call of result.toolCalls) { const tool toolMap.get(call.function.name); if (!tool) { messages.push({ role: tool, tool_call_id: call.id, content: 错误: 工具 ${call.function.name} 不存在, }); continue; } console.log([Agent] 调用工具 ${call.function.name}, 参数: ${call.function.arguments}); let parsedArgs: unknown; try { parsedArgs JSON.parse(call.function.arguments); } catch (e) { messages.push({ role: tool, tool_call_id: call.id, content: 错误: 工具参数不是合法 JSON, }); continue; } try { const output await tool.execute(parsedArgs as never); messages.push({ role: tool, tool_call_id: call.id, content: output, }); } catch (e) { messages.push({ role: tool, tool_call_id: call.id, content: 错误: 工具执行异常 - ${(e as Error).message}, }); } } } throw new Error(Agent 在 ${config.maxIterations} 轮内未完成任务); }这段代码是整个项目的核心所以有几个点要仔细讲一下。第一messages数组就是上下文记忆的最小实现。每一轮对话、每一次工具调用结果都被追加到这个数组里并在下一轮重新发给模型。这就是 Agent 能记住前面步骤的原因。第二把assistant的原始消息推入历史时为什么要带上tool_calls因为 OpenAI 协议规定工具调用消息必须以assistant角色发起然后用对应的tool角色消息回答。如果不把tool_calls原样带回模型会丢失工具调用的上下文甚至报错。第三任何工具执行异常都不能让主循环崩溃。我们把异常捕获后转成普通的 tool 消息返回给模型让模型自己判断如何处理。这是 Agent 稳健性的关键模型偶尔会给出无法解析的参数或者工具内部出现运行时错误这些都不能让 Agent 直接退出。5.5 入口与示例把整个系统串起来文件路径src/main.ts。最后写一个入口把组件组装起来并完成一次完整的对话// 文件路径src/main.ts import { runAgent } from ./agent; import { OpenAIChatClient } from ./llm; import { registry } from ./tools; import { AgentConfig } from ./types; async function main() { const chatClient new OpenAIChatClient(); const config: AgentConfig { systemPrompt: 你是一个有用的智能助手。请用中文回答用户的问题。 如果需要查询天气或计算数学表达式请使用对应工具。 如果不需要使用工具直接回答用户。, maxIterations: 5, tools: registry.list(), chatClient, }; const question 今天北京天气怎么样顺便帮我算一下 123 * 45 678 等于多少。; console.log([User] ${question}); const answer await runAgent(config, question); console.log([Agent] ${answer}); } main().catch((err) { console.error(Agent 运行失败:, err); process.exit(1); });注意这里有一个常用的演示技巧把两个需要不同工具的问题放在同一句话里。这会让模型自动规划出“先查天气再算数”的执行顺序能直观看到 Agent 的自主决策能力。6. 运行与效果验证6.1 启动命令在package.json中配置 scripts{ scripts: { start: tsx src/main.ts } }然后在项目根目录执行npm start6.2 预期输出与分析正常运行时你应该能看到类似下面的日志输出具体内容取决于模型返回[User] 今天北京天气怎么样顺便帮我算一下 123 * 45 678 等于多少。 [Agent] 第 1 轮推理... [Agent] 调用工具 get_weather, 参数: {city:北京} [Agent] 调用工具 calculator, 参数: {expression:123 * 45 678} [Agent] 第 2 轮推理... [Agent] 北京今天天气晴朗气温 22 摄氏度适合出行。 123 * 45 678 的计算结果是 6210。如果看到这个效果说明你的最小 Agent 已经完整跑通了“意图理解 - 工具调用 - 结果汇总 - 自然语言回复”的链路。这里有一个判断标准第一轮推理时模型返回了两个工具调用这证明模型具备并行工具调用的能力第二轮推理时模型已经拿到了工具结果并基于结果组织回答。如果你的模型中第一轮只返回一个工具调用也是正常的说明模型选择了串行执行。两种策略没有绝对好坏但企业级 Agent 通常会在编排层配置并发策略。6.3 失败排查入口如果运行失败优先按这个顺序排查先看终端打印的未捕获异常。如果是缺少 LLM_API_KEY 环境变量说明.env文件没被正确读取需要检查启动命令。如果报模型调用失败 401说明密钥或 Base URL 配置错误。如果报模型调用失败 404说明模型名不在服务端支持列表中。如果运行超时查看是否没有配置maxIterations或模型陷入了工具调用死循环。7. 生产环境的关键工程改造点100 行代码跑通后你会意识到一个事实从“能跑的骨架”到“企业级 Agent”中间还隔着大量工程问题。这些问题不是模型能力问题而是系统设计问题。7.1 记忆管理不只是数组我们目前的消息列表是无限增长的。在真实生产环境中对话轮次一多token 成本飙升甚至超出模型上下文窗口限制。企业级 Agent 通常会引入滑动窗口只保留最近 N 轮消息。摘要压缩将早期对话交给模型生成摘要替代原始消息。向量检索把历史事实存入向量数据库需要时按相关性召回。结构化记忆独立保存用户偏好、任务状态、领域上下文不混在对话消息中。从代码上看这些改造的本质都是把messages: ChatMessage[]这个数组替换成一个更聪明的记忆管理器。7.2 可观测性日志就是 Agent 的审计日志一个不能观测的 Agent 是无法上生产的。建议在每一步增加结构化日志console.log( JSON.stringify({ time: new Date().toISOString(), step: i 1, type: tool_call, tool: call.function.name, args: call.function.arguments, }) );真实项目中这套日志要接入集中日志平台比如 ELK 或云厂商日志服务并在仪表盘上监控工具调用失败率、平均轮次、token 消耗等。7.3 工具安全边界工具接入是企业级 Agent 最容易出问题的地方。给出几个必须遵守的实践最小权限原则每个工具只授予完成自身任务所需的最小权限Agent 不要使用数据库管理员账号。参数校验不能信任模型生成的参数工具内部必须先做类型和范围检查再做业务操作。敏感操作确认涉及删除、变更、支付、发送消息等高风险操作必须增加人工确认环节不要让模型自主执行。表达式执行安全我们示例里的Function执行方式极其危险生产环境会把用户可控的表达式交给专门的表达式引擎或沙箱。7.4 失败重试与降级大模型 API 不稳定工具也会偶发失败。企业级实现需要为模型调用增加指数退避重试当模型连续多轮无法完成任务时应该优雅地告诉用户“当前无法处理”而不是抛出堆栈异常。8. 常见问题与排查思路问题现象可能原因排查方式解决方案运行报缺少LLM_API_KEY环境变量未注入检查启动命令与.env文件使用--env-file.env或提前设置环境变量模型返回 401API Key 错误直接 curl 测试模型接口在模型服务商后台重新生成密钥模型返回 404Base URL 或模型名错误查看官方文档确认接口路径修正LLM_BASE_URL或LLM_MODELAgent 无法调用工具模型未收到工具描述检查主循环是否把toolMap传给模型在请求体中注入 OpenAI 协议的tools参数Agent 疯狂循环调用同一工具工具结果让模型不满意或上下文缺失观察日志中的 tool 消息内容在工具执行失败时返回明确错误消息工具参数是 JSON 但反序列化失败模型生成非法 JSON打印arguments原始内容增加 JSON 解析容错尝试修复或让模型重新生成9. 从最小实现到下一阶段的路线图到这里你已经亲手搭建了一个最小但五脏俱全的 Agent 系统。这个系统的核心价值不在于功能丰富而在于它清晰展示了 Agent 的架构分层和核心循环机制。当你去看 LangChain、OpenAI Assistants API 或字节的 Coze 内部设计时会发现它们在解决同样的问题只是做了更多工程增强和产品化封装。建议下一步按以下顺序继续深入扩充工具集接入真实的天气 API、数据库查询、企业内网接口把工具从演示变成业务能力。增加流式输出把模型返回改成 SSE 流式输出提升用户交互体验。引入状态持久化把消息列表存入 Redis让 Agent 支持多轮跨会话记忆。增加函数调用协议的全量实现包括并行工具调用、多轮工具依赖、工具结果压缩等。尝试多 Agent 协作把一个通用 Agent 拆成规划 Agent、执行 Agent、审查 Agent模拟企业级多角色协作模式。真正的 Agent 开发能力不是背熟某个框架的 API而是能够独立回答这几个问题我的系统里谁在决策谁在执行失败时谁负责兜底上下文如何流动。今天这个 100 行骨架已经把答案的框架搭出来了。剩下的细节完全可以在这个骨架上按需填充。如果你在运行过程中遇到了和文中描述不一致的情况比如某个模型厂商的参数格式有差异思考路径也是一样的先定位是模型层、工具层还是编排层出现偏离再对症修改对应模块。架构清晰的好处就在这种时刻体现得最明显。