从零手写AI Agent核心引擎:100行TypeScript掌握企业级架构

发布时间:2026/8/26 22:18:07
从零手写AI Agent核心引擎:100行TypeScript掌握企业级架构 最近总有人问我AI Agent 到底要不要自己写一遍市面上 LangChain、Dify、Coze 已经很成熟了直接用不就行了我的判断是如果你只是做原型验证完全可以不写但如果你想理解 Agent 为什么聪明、为什么在复杂任务里会卡住、为什么生产环境经常失控那必须自己手写一遍核心引擎。尤其是企业级 Agent真正拉开差距的不是模型选得多大而是架构设计的边界、扩展和可观测性。这篇文章会用 TypeScript 从零实现一个通用智能体核心引擎控制在 100 行左右但架构上对齐企业级 Agent 的关键模块工具注册机制、LLM 适配层、Agent 主循环、上下文管理和终止条件。最终跑通一个能查天气、能做计算的 Agent 实例。读完你会真正理解 Agent 的构成也具备把 Demo 演进成工程化系统的能力。1. 这篇文章真正要解决的问题现在 AI Agent 的教程很多但大多数停留在调用 API 然后拼 Prompt的层面。真正到生产环境你会发现几个问题第一Agent 不只是一个函数调用。它需要在一个循环里反复推理理解目标、决定调用什么工具、观察工具输出、修正下一步计划。这个循环的设计直接决定了 Agent 能否稳定完成复杂任务。第二工具接入方式五花八门。真实企业里要把 Agent 接到内部 API、数据库、消息队列如果没有统一的工具注册抽象每接一个新系统就要改一遍核心逻辑。第三上下文管理容易被忽略。Agent 每轮工具调用都会产生新的消息上下文无限膨胀最后不是超出模型窗口就是费用失控。这些问题的共同答案都在 Agent 的架构设计里。我选择 TypeScript 来演示是因为它具备类型约束、异步支持和 Node 生态写出来的 Agent 核心结构和 Java、Python 的实现思路基本一致。你学的是架构能力而不是绑定某个框架。这篇文章适合三类读者正在做 AI Agent 开发的工程师、想从调 API走向设计系统的进阶开发者、以及需要在团队里落地 Agent 服务的技术负责人。2. 基础概念Agent 架构的核心模块在动笔写代码之前先把 Agent 的核心概念讲清楚。这里不会展开太多理论只讲后面写代码必须用到的几个模块。2.1 什么是 Agent 循环传统程序和 Agent 最大的区别在于传统程序的执行路径是预先写死的而 Agent 的执行路径是模型根据当前状态动态决定的。以一个用户请求为例用户帮我查北京的天气然后计算 (1527)*2 的结果普通程序需要你提前写好两步逻辑先调天气 API再做乘法计算。但 Agent 的做法是把用户请求交给大模型模型判断需要调用天气工具Agent 执行天气工具把结果返回给模型模型继续判断需要调用计算工具Agent 执行计算工具把结果返回给模型模型汇总所有工具结果生成最终回答。这个过程就叫 Agent 循环Agent Loop核心是推理 - 行动 - 观察不断迭代。每次循环模型都会基于新的工具反馈重新规划直到它认为任务已经完成。2.2 工具注册机制Agent 不能直接调用任意函数它只能调用暴露给它的工具。工具注册机制就是一张清单告诉模型你能用什么、每个工具是干什么的、参数怎么传。企业级 Agent 里工具注册不是简单塞进一个数组而是包含工具名称必须唯一且语义清晰功能描述用于让模型判断何时该用它参数 Schema用 JSON Schema 描述参数结构执行函数真正调用外部系统或内部服务的代码。这样设计的价值是Agent 核心引擎不需要关心天气 API 怎么调数据库连接串是什么它只面对统一的工具接口。新接入一个系统本质就是新增一个工具注册项。2.3 LLM 适配层不同公司的 Agent 可能用不同的模型服务有的用 OpenAI有的用国产模型有的用公司内部部署的模型网关。如果 Agent 核心代码直接绑定某个 SDK替换模型就要改一堆代码。所以需要一层 LLM 适配层把模型调用抽象成一个统一接口。核心引擎只依赖这个接口不关心底层是哪个模型、什么协议。这个设计在企业级项目里尤其重要它让你可以随时更换模型供应商也方便测试时用 Mock 数据替代真实调用。3. 环境准备与项目初始化要用 TypeScript 实现 Agent环境要求并不高。3.1 运行环境建议使用以下环境Node.js 18 或以上版本因为代码里会用到fetch做 HTTP 调用TypeScript 5.x项目使用 ESM 模块规范npm 或 pnpm 作为包管理器一个兼容 OpenAI Chat Completions 协议的模型服务。模型服务这里需要多说一句不是必须用 OpenAI 官方 API。现在很多国产模型和开源模型网关都提供兼容接口只需要修改baseURL、apiKey和model三个配置项即可。文章后面会给出具体配置方式。3.2 初始化项目先创建一个项目目录并初始化 npmmkdir typescript-agent cd typescript-agent npm init -y安装 TypeScript 和类型声明npm install typescript types/node --save-dev创建tsconfig.json重点配置 ESM 支持{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, outDir: dist, rootDir: src, strict: true, esModuleInterop: true, skipLibCheck: true }, include: [src] }修改package.json加上type: module让 Node 以 ESM 方式运行 TypeScript 编译后的文件{ name: typescript-agent, version: 1.0.0, type: module, scripts: { build: tsc, start: node dist/index.js } }这样项目骨架就搭好了。接下来进入核心模块设计。4. 核心流程拆解Agent 如何思考和行动在写完整代码之前先把 Agent 的主流程拆开讲清楚。这一步很关键因为很多人写 Agent 写到最后调试困难根本原因是没有理解主循环的每个状态。4.1 主循环设计Agent 的run方法本质上是一个for循环循环内做四件事把当前消息列表发送给 LLM模型返回 assistant 消息可能包含tool_calls也可能直接返回最终文本如果模型没有发起工具调用说明任务已经完成直接返回内容如果模型发起了工具调用则逐个执行工具把结果作为tool角色消息追加到消息列表然后进入下一轮循环。这里最容易踩的坑是忘记把模型的工具调用请求和工具执行结果追加到消息列表。模型本身没有记忆每次请求都需要携带完整的对话历史否则模型在下一轮就不知道自己刚才决定调用什么工具。4.2 工具调用与结果回填当模型返回tool_calls时它会给每个调用分配一个id同时给出工具名和 JSON 字符串格式的参数。Agent 需要第一步解析工具名从注册表中找到对应的工具定义第二步解析参数 JSON 字符串为对象传给工具执行函数第三步把执行结果包装成role: tool的消息并通过tool_call_id关联到模型的调用请求。有一个细节值得注意工具返回结果本身应该是一个字符串。这样设计是为了保持消息结构的统一也方便调试日志输出。如果工具返回的是复杂对象就在 Agent 层做一次JSON.stringify。4.3 终止条件设计Agent 循环必须强制设置最大迭代次数。原因很现实模型在复杂任务里可能陷入反复调用工具的循环或者因为工具返回内容不满足预期而不断重试如果不设上限请求时间和成本都无法控制。通常一个通用 Agent 的maxIterations设置在 5 到 15 之间。超过上限后最稳妥的做法是抛出一个明确异常让上层调用方决定如何处理而不是在 Agent 内部悄悄返回一个不完整的结果。5. 完整示例代码实现现在开始写代码。整个项目按模块拆分核心引擎集中在agent.ts中最终控制在 100 行左右。5.1 类型定义文件路径src/types.tsexport type Role system | user | assistant | tool; export interface ToolCall { id: string; name: string; arguments: string; } export interface Message { role: Role; content: string; name?: string; tool_call_id?: string; tool_calls?: ToolCall[]; } export interface ToolDefinition { name: string; description: string; parameters: Recordstring, unknown; execute: (args: any) Promiseunknown | unknown; }Message是所有消息的统一结构。注意tool_calls只出现在assistant消息里tool_call_id和name只出现在tool消息里用可选字段把它们放在同一个接口中可以简化后续处理逻辑。5.2 LLM 客户端适配层文件路径src/llm.tsimport { Message } from ./types.js; export interface ToolSchema { type: function; function: { name: string; description: string; parameters: Recordstring, unknown; }; } export interface LLMClient { chat(messages: Message[], tools: ToolSchema[]): PromiseMessage; } export class OpenAICompatibleClient implements LLMClient { constructor( private config: { apiKey: string; baseURL: string; model: string; } ) {} async chat(messages: Message[], tools: ToolSchema[]): PromiseMessage { const resp await fetch(${this.config.baseURL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${this.config.apiKey}, }, body: JSON.stringify({ model: this.config.model, messages, tools, tool_choice: auto, }), }); if (!resp.ok) { const text await resp.text(); throw new Error(LLM API error ${resp.status}: ${text}); } const data await resp.json(); return data.choices[0].message as Message; } }这里通过LLMClient接口屏蔽了底层模型细节。OpenAICompatibleClient使用 Node.js 内置fetch发起请求不依赖任何第三方 SDK只要模型服务兼容 Chat Completions 协议就能用。baseURL配置成你的模型网关地址即可。5.3 Agent 核心引擎文件路径src/agent.tsimport { Message, ToolDefinition, ToolCall } from ./types.js; import { LLMClient, ToolSchema } from ./llm.js; export interface AgentConfig { systemPrompt: string; maxIterations?: number; } export class Agent { private tools: Mapstring, ToolDefinition new Map(); private messages: Message[] []; private maxIterations: number; constructor( private llm: LLMClient, config: AgentConfig ) { this.maxIterations config.maxIterations ?? 10; this.messages.push({ role: system, content: config.systemPrompt }); } registerTool(tool: ToolDefinition): void { this.tools.set(tool.name, tool); } private toToolSchemas(): ToolSchema[] { return [...this.tools.values()].map((tool) ({ type: function, function: { name: tool.name, description: tool.description, parameters: tool.parameters, }, })); } private async executeTool(call: ToolCall): Promisestring { const tool this.tools.get(call.name); if (!tool) { return 错误未知工具 ${call.name}; } try { const args JSON.parse(call.arguments || {}); const result await tool.execute(args); return typeof result string ? result : JSON.stringify(result); } catch (e: any) { return 工具执行失败: ${e.message}; } } async run(input: string): Promisestring { this.messages.push({ role: user, content: input }); for (let i 0; i this.maxIterations; i) { const assistant await this.llm.chat(this.messages, this.toToolSchemas()); this.messages.push(assistant); if (!assistant.tool_calls?.length) { return assistant.content; } for (const call of assistant.tool_calls) { const result await this.executeTool(call); this.messages.push({ role: tool, content: result, name: call.name, tool_call_id: call.id, }); } } throw new Error(Agent 达到最大迭代次数 ${this.maxIterations}未能得出结论); } }这段是真正的核心。用Map管理工具注册表保证工具名唯一且查询效率稳定toToolSchemas把内部工具定义转换成模型能理解的 JSON SchemaexecuteTool统一处理工具调用和异常捕获把错误信息也变成工具结果回传给模型。run方法里每轮循环先调用模型再检查是否有工具调用。这个先追加消息、再判断返回的顺序很重要即使模型返回了最终答案也要先追加到历史里保证会话状态完整。如果模型返回了工具调用则在同一个循环内执行所有工具、回填结果然后进入下一轮。这里有一个细节值得强调多个工具调用是在for循环里逐个执行的。如果工具之间没有依赖可以考虑用Promise.all并发执行提升效率但并发时要注意工具是否有副作用后续章节会展开讨论。5.4 工具实例与入口文件路径src/tools.tsimport { ToolDefinition } from ./types.js; export const weatherTool: ToolDefinition { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称例如北京 }, }, required: [city], }, execute: async ({ city }: { city: string }) { // 演示实现真实场景可在这里接入天气服务 API return ${city} 今天晴25°C微风; }, }; export const calculatorTool: ToolDefinition { name: calculator, description: 执行四则混合运算例如 (12)*3, parameters: { type: object, properties: { expression: { type: string, description: 需要计算的数学表达式 }, }, required: [expression], }, execute: ({ expression }: { expression: string }) { try { // 仅用于演示。生产环境不要直接执行任意表达式建议使用表达式解析库或白名单方式。 const normalized expression.replace(/(\d)\s*\(/g, $1*(); const result Function(use strict; return (${normalized}))() as number; if (typeof result ! number || !Number.isFinite(result)) { throw new Error(非法表达式); } return 计算结果: ${expression} ${result}; } catch (e: any) { return 计算失败: ${e.message}; } }, };这里演示了两个工具天气查询和计算器。weatherTool是典型的外部服务对接场景calculatorTool是典型的逻辑执行场景。注意计算器实现里用了Function构造函数这在生产环境有严重的安全风险我这里只是为了让示例最小化跑通。真实项目应该使用mathjs或expr-eval这类安全的表达式解析库。文件路径src/index.tsimport { Agent } from ./agent.js; import { OpenAICompatibleClient } from ./llm.js; import { weatherTool, calculatorTool } from ./tools.js; const llm new OpenAICompatibleClient({ apiKey: process.env.OPENAI_API_KEY ?? , baseURL: process.env.OPENAI_BASE_URL ?? https://api.openai.com/v1, model: process.env.OPENAI_MODEL ?? gpt-4o-mini, }); const agent new Agent(llm, { systemPrompt: 你是一个智能助手。当用户需要查询天气或执行计算时你必须调用对应工具不要编造工具结果。, }); agent.registerTool(weatherTool); agent.registerTool(calculatorTool); const question process.argv[2] ?? 帮我查一下北京明天的天气并计算 (1527)*2 的结果; const answer await agent.run(question); console.log(\nAgent 最终回答\n, answer);入口文件把各模块组装起来创建 LLM 客户端、创建 Agent、注册工具、接收命令行问题并执行。process.argv[2]让用户可以通过命令行参数传入问题方便测试不同场景。到这里一个完整的 Agent 就跑通了。6. 运行结果与效果验证先编译项目npm run build设置模型服务环境变量。如果使用 OpenAI 官方 APIexport OPENAI_API_KEY你的密钥 export OPENAI_BASE_URLhttps://api.openai.com/v1 export OPENAI_MODELgpt-4o-mini如果使用兼容 OpenAI 协议的国内模型服务只需要把OPENAI_BASE_URL改成服务商提供的地址把OPENAI_MODEL改成对应模型名。不需要修改任何代码这正是 LLM 适配层的价值。运行npm start预期输出大致如下Agent 最终回答 我帮你处理这两个需求 1. 北京明天的天气北京明天晴25°C微风。 2. 计算 (1527)*2 的结果84。 两件事都完成了还有其他需要吗注意模型具体措辞每次可能不同但关键判断标准是——Agent 自主决定先调用天气工具再调用计算工具最后汇总结果。你可以在终端看到模型实际发出的工具调用和工具返回结果如果给 Agent 增加日志会看得更清楚。如何判断 Agent 真的成功三个标准用户请求里同时包含天气查询和计算模型必须发起两次工具调用工具的返回值被正确追加到消息历史模型引用的是真实工具结果而不是自己编造最终回答包含两个任务的答案且计算结果是准确的。如果运行失败优先检查环境变量是否设置正确以及模型服务是否支持 Function Calling 功能。下文会详细展开排查思路。7. 常见问题与排查思路从代码跑通到生产可用中间隔着大量细节问题。这里列出最常见的五类问题和排查方式。问题现象可能原因排查方式解决方案Agent 不调用工具直接编造结果模型不支持 Function Calling或工具描述不够清晰查看模型服务文档确认功能支持打印toToolSchemas()检查工具 Schema 是否正确更换支持工具调用的模型优化工具 description 和参数说明工具参数解析失败模型返回的参数 JSON 格式不正确把call.arguments原样打印出来查看在executeTool里增加 JSON.parse 的 try-catch并把错误信息回传给模型让它重新生成参数Agent 陷入无限循环工具结果没有让模型得到足够信息或者最大迭代次数设置过大查看消息历史分析每轮工具调用是否在重复设置更小的maxIterations检测到重复工具调用时强制终止上下文超出模型窗口工具返回结果过大或循环轮次太多统计每轮消息的 token 消耗对工具返回结果截断增加上下文压缩或摘要机制多个工具调用互相影响工具执行有副作用或存在竞态条件检查工具实现是否修改了共享状态对写操作工具设置为串行执行对只读工具可并发执行7.1 模型返回格式问题排查最常遇到的一类问题是模型返回的tool_calls不符合预期。这时候不要直接怀疑模型不够聪明先做这几步第一步确认请求 body 里的tools字段结构正确。type必须是functionfunction.parameters必须是合法的 JSON Schema。第二步确认消息历史拼接正确。尤其是assistant消息中带有tool_calls时后续消息必须包含与该调用对应的tool消息。如果漏掉某一条模型服务通常会报错甚至静默忽略工具调用结果。第三步确认模型版本支持工具调用。某些轻量模型虽然接口兼容但工具调用能力很弱会频繁返回空tool_calls或者错误参数。这时需要在模型网关侧做一次能力校验而不是在代码里打补丁。7.2 工具执行异常如何传递给模型我在这篇文章的实现里刻意把工具执行异常也包装成字符串返回给模型而不是直接让异常中断整个 Agent。这个设计有讲究当工具失败时模型可能根据错误信息调整参数重试或者改用其他工具甚至直接告诉用户这个操作不可用。如果不把错误信息回传给模型Agent 会在下一轮循环里失忆无法做出合理决策。但要注意如果工具错误信息包含敏感内容不应该直接传给模型。生产环境建议对工具错误做脱敏处理后再放入消息历史。8. 最佳实践从 Demo 到企业级 Agent文章写到这里你已经能用 100 行代码跑通一个 Agent。但 Demo 和企业级 Agent 之间的距离主要不在代码行数而在工程化能力。这一节把最关键的经验展开讲。8.1 保持核心引擎与工具解耦Agent类只依赖统一的工具注册表和LLMClient接口不关心某个工具内部怎么实现。这个解耦带来的直接好处是新增工具不需要改核心代码只需要注册一个新对象。在企业项目里工具可能分布在不同的微服务里甚至由不同团队维护。可以把每个工具封装成独立模块定义好ToolDefinition导出由上层统一注册。这样既方便单测也方便按团队拆分所有权。8.2 可观测性设计Agent 的调试难点在于它是动态决策的没法像传统程序一样按固定路径定位问题。所以必须在核心循环里埋好日志点至少需要记录每轮循环的编号和消息数量模型返回的原始内容包括tool_calls每个工具调用的名称、参数、执行耗时和返回结果循环结束原因正常返回答案、达到最大迭代次数、还是异常退出。这些日志在生产环境应该通过结构化日志输出按 traceId 串联一次 Agent 运行的完整链路。能做到这一步排错效率会提升一个数量级。8.3 上下文管理与成本控制消息历史无限增长是 Agent 生产环境最常见的问题。工具返回的 JSON 可能几百个字段几条工具调用的结果就能撑爆上下文窗口。工程化的做法是在循环中加入上下文管理策略对工具返回结果做长度截断只保留关键字段对过长的历史消息做摘要压缩设置单轮工具结果上限防止某个工具返回超大 JSON。这些策略都属于上下文压缩领域核心原则是让模型始终看到最重要的信息同时把 token 成本控制在预算内。8.4 安全与权限边界这一点必须强调因为 Agent 的工具本质上是一个可以被模型语言操控的函数调用入口。工具越强大风险越高。第一工具执行要设置超时时间。模型可能要求调用一个耗时很长的工具如果没有超时控制Agent 请求会一直挂起。第二工具执行要考虑幂等性。尤其对写操作类工具Agent 循环重试时可能重复调用需要确认是否会造成重复写入。第三权限最小化。不要让 Agent 的工具拥有比用户更高的权限比如用户没有删除权限就不能让 Agent 调用对应删除工具。第四敏感操作要分层授权。删除数据、发送消息、修改配置这类操作建议在工具执行前增加二次确认机制而不是让模型直接触发。8.5 测试策略Agent 的测试比传统程序复杂因为它存在随机性。工程化的做法是分三层测试单元测试测试每个工具的execute函数逻辑集成测试使用 Mock LLM 返回预设的工具调用序列验证 Agent 循环是否正确执行工具并回填结果端到端测试在测试环境使用真实模型和小样本数据集验证整体行为是否符合预期。这里推荐一个技巧写一个 MockLLMClient 实现LLMClient接口在测试里按顺序返回预设消息。这样既不需要真实调用模型也能覆盖 Agent 循环的各种分支。9. 总结与后续学习方向这篇文章真正讲清楚的事可以归纳为三点Agent 的核心是推理 - 行动 - 观察的循环而不是简单的 API 调用工具注册机制和 LLM 适配层是 Agent 架构中最关键的两个抽象它们决定了系统的扩展性和可替换性生产环境里的 Agent 需要显式控制终止条件、上下文长度和工具权限否则很容易在复杂任务中失控。建议你下一步做这样几件事先把文章里的代码完整跑通然后替换成你自己项目里的真实工具比如查数据库、调内部接口接着给 Agent 加上日志和上下文压缩观察它在多轮任务里的行为变化最后尝试把Agent类封装成一个可独立部署的服务加上traceId关联日志你就能直观感受到企业级 Agent 和 Demo 的差距在哪里。如果继续深入可以关注几个方向一个是多 Agent 协同架构即把任务拆给多个专职 Agent 并行处理一个是 Agent 的记忆系统让 Agent 在多次会话之间保持状态还有一个是 Agent 评测体系因为没有一个像传统软件一样的确定性验证方式评测反而成为工程化落地的核心难题。而这一切的起点都是你手写过的这 100 行核心引擎。架构设计能力从来不是看教程看出来的是把代码写出来、跑起来、再亲手改坏的工程经验累积出来的。