首页 / 资讯中心 / 文章详情

从零用TypeScript手写轻量级Agent框架:ReAct循环与MCP工具接入实战

从零用TypeScript手写轻量级Agent框架:ReAct循环与MCP工具接入实战 ★ FEATURED ARTICLE
1. 为什么我要自己造一个 Agent 框架市面上现成的 Agent 框架已经不少了LangChain、AutoGen、CrewAI 这些名字随便一搜就是一大把。但我自己带团队做 AI 应用落地的过程中越来越觉得这些框架有个通病抽象层太厚调试链路太长。你写一个简单的 ReAct 循环框架内部可能帮你封装了十几层调用出了问题日志翻半天都找不到是哪一步的 prompt 拼错了。更别提版本升级带来的 breaking change上周还能跑的代码这周升个小版本就报Agent execution terminated due to error排查半天发现是某个内部依赖改了接口。所以我就动了念头用 TypeScript 从零搭一个轻量级 Agent 框架不依赖任何重型编排库核心逻辑自己写每一行代码都能说清楚它在干什么。这个框架的目标很明确——支持 ReAct 推理循环、能对接 MCP 协议的工具生态、类型安全、可调试、可扩展。适合那些跟我一样想真正搞懂 Agent 底层运转机制而不是只会调 API 的开发者。这篇文章我会把整个搭建过程拆开讲包括架构设计的取舍、ReAct 循环的具体实现、MCP 工具的接入方式、类型系统的设计以及我在实操中踩过的坑。代码全部用 TypeScript 写不依赖任何 Agent 专用框架你跟着做就能得到一个能跑的最小可用版本。2. 整体架构设计与技术选型取舍2.1 为什么不直接用现成框架先说清楚这个决策背后的逻辑。现成框架的优势是开箱即用工具生态丰富社区活跃。但劣势同样明显黑盒程度高你很难精确控制每一轮对话的上下文构造依赖膨胀一个 Agent 框架可能拖进来几十个 npm 包类型定义松散很多框架的 TypeScript 类型就是any加一层皮写起来毫无安全感。我需要的场景是一个中等复杂度的 Agent能调用 5 到 10 个工具需要多轮推理偶尔要处理流式输出。这种场景下自己写核心循环大概 300 行代码就能搞定而引入框架反而增加了理解和维护成本。当然如果你的场景是复杂的多 Agent 协作、需要大量预置工具那用现成框架更划算。选型没有绝对的对错只有适不适合。2.2 核心模块划分我把整个框架拆成四个核心模块每个模块职责单一模块之间通过明确的接口通信LLM 适配层统一不同模型提供商的调用接口屏蔽差异。我定义了一个LLMProvider接口任何模型只要实现这个接口就能接入。工具注册与执行层管理工具的注册、参数校验和执行。工具定义遵循 MCP 协议的 schema 规范这样后续接入 MCP server 时不用改代码。ReAct 推理引擎核心循环负责构造 prompt、解析模型输出、决定下一步动作、维护对话历史。类型系统与消息模型定义消息、工具调用、推理步骤等核心数据结构保证全链路类型安全。这样划分的好处是每个模块都可以独立测试。比如我想换一个模型只需要实现一个新的LLMProvider推理引擎完全不用动。2.3 技术栈选择运行时用 Node.js语言用 TypeScript这个没什么好纠结的。几个关键依赖的选择我说明一下理由依赖用途选择理由zod参数校验与 schema 定义类型推导能力强能从 schema 反推 TS 类型和 MCP 的 JSON Schema 转换方便eventsource-parser流式响应解析轻量专门处理 SSE 格式比自己写解析器靠谱tsx开发时直接运行 TS省去编译步骤改完即跑开发体验好注意我没有引入任何 HTTP 客户端库直接用 Node.js 18 内置的fetch。少一个依赖就少一个潜在的版本冲突。提示TypeScript 7.0 之后baseUrl选项会被弃用如果你在tsconfig.json里用了baseUrl做路径别名建议尽早迁移到paths配合moduleResolution: bundler的方案避免升级时踩坑。3. 核心类型系统与消息模型设计3.1 消息类型的定义Agent 的一切交互都可以归结为消息的流转。我把消息分成四种角色system、user、assistant、tool。用 TypeScript 的 discriminated union 来定义这样在 switch 分支里能自动收窄类型type MessageRole system | user | assistant | tool; interface BaseMessage { role: MessageRole; content: string; } interface SystemMessage extends BaseMessage { role: system; } interface UserMessage extends BaseMessage { role: user; } interface AssistantMessage extends BaseMessage { role: assistant; toolCalls?: ToolCall[]; } interface ToolMessage extends BaseMessage { role: tool; toolCallId: string; toolName: string; } type Message SystemMessage | UserMessage | AssistantMessage | ToolMessage;这里有个设计决策值得说为什么 assistant 消息要带 toolCalls 字段。因为模型在决定调用工具时返回的是一条 assistant 消息里面包含工具调用的意图。这条消息必须原样保留在对话历史里否则下一轮请求时模型会困惑——它看到工具返回了结果但找不到自己当初为什么要调这个工具。这是很多人自己写 Agent 时容易忽略的细节。3.2 工具调用的类型建模工具调用是 Agent 的核心能力类型定义要足够精确interface ToolCall { id: string; name: string; arguments: Recordstring, unknown; } interface ToolDefinitionT extends z.ZodType z.ZodType { name: string; description: string; parameters: T; execute: (args: z.inferT) Promisestring; }ToolDefinition用了泛型加 zod schema好处是execute函数的参数类型能自动从 schema 推导出来。你定义工具时写一遍 schema参数类型和运行时校验就都有了不用维护两份定义。这个设计灵感来自 tRPC用过的都知道这种端到端类型安全的爽感。3.3 推理步骤的记录为了可调试我把每一轮推理都记录成一个ReasoningStepinterface ReasoningStep { index: number; thought: string; action?: { toolName: string; args: Recordstring, unknown; }; observation?: string; timestamp: number; }这个结构在调试时特别有用。当 Agent 行为不符合预期时我把所有步骤打印出来一眼就能看出是哪一步的 thought 跑偏了还是工具返回的 observation 有问题。可观测性是自研框架相对现成框架最大的优势之一一定要在设计阶段就考虑进去。4. ReAct 推理引擎的实现细节4.1 ReAct 循环的本质ReAct 的核心思想是让模型交替进行推理Reasoning和行动Acting。用大白话说就是模型先想一下该干什么然后决定调不调工具调完工具看到结果再想下一步直到它认为可以给出最终答案为止。这个循环用伪代码表示就是while (未达到终止条件) { 构造 prompt包含历史消息和工具定义 调用 LLM 解析输出 if (输出包含工具调用) { 执行工具把结果加入历史 } else { 返回最终答案结束循环 } }看起来简单但魔鬼在细节里。终止条件怎么定工具调用失败怎么办模型一直循环调用同一个工具怎么办下面逐个说。4.2 系统提示词的构造系统提示词决定了 Agent 的行为模式。我的做法是把工具定义用 JSON Schema 格式注入到系统提示词里同时给出明确的输出格式要求function buildSystemPrompt(tools: ToolDefinition[]): string { const toolDescriptions tools.map(t ({ name: t.name, description: t.description, parameters: zodToJsonSchema(t.parameters), })); return 你是一个能够使用工具的智能助手。 你可以使用以下工具 ${JSON.stringify(toolDescriptions, null, 2)} 当你需要调用工具时请以如下 JSON 格式输出 {tool: 工具名, arguments: {...}} 当你已经获得足够信息可以回答用户时直接输出最终答案不要包含 JSON。 每次只调用一个工具等待结果后再决定下一步。; }这里有个关键点要求模型每次只调一个工具。虽然现在很多模型支持并行工具调用但在自研框架的初期串行调用更容易调试也更容易控制上下文长度。等框架稳定了再考虑并行优化。4.3 输出解析的健壮性处理模型输出解析是整个循环里最容易出问题的环节。模型可能返回带 markdown 代码块的 JSON可能返回格式不对的 JSON也可能在 JSON 前后加一堆解释文字。我的解析策略是分层的function parseModelOutput(content: string): ParsedOutput { // 第一层尝试直接解析 const direct tryParseJson(content); if (direct isValidToolCall(direct)) { return { type: tool_call, call: direct }; } // 第二层提取代码块中的 JSON const codeBlockMatch content.match(/(?:json)?\s*([\s\S]*?)/); if (codeBlockMatch) { const parsed tryParseJson(codeBlockMatch[1]); if (parsed isValidToolCall(parsed)) { return { type: tool_call, call: parsed }; } } // 第三层用正则找第一个完整的 JSON 对象 const jsonMatch content.match(/\{[\s\S]*?tool[\s\S]*?\}/); if (jsonMatch) { const parsed tryParseJson(jsonMatch[0]); if (parsed isValidToolCall(parsed)) { return { type: tool_call, call: parsed }; } } // 兜底当作最终答案 return { type: final_answer, content }; }三层解析加上兜底实测下来能覆盖 95% 以上的情况。剩下的 5% 通常是模型真的跑偏了这时候当作最终答案返回反而比强行解析更安全。4.4 循环控制与安全边界无限循环是自研 Agent 最容易踩的坑。我设置了三个安全边界最大迭代次数默认 10 轮超过就强制终止并返回当前状态。重复调用检测如果连续两轮调用了同一个工具且参数完全相同判定为死循环终止。单轮超时每次 LLM 调用设置 60 秒超时工具执行设置 30 秒超时。const MAX_ITERATIONS 10; const MAX_REPEAT_CALLS 2; async function runReActLoop( messages: Message[], tools: ToolDefinition[], provider: LLMProvider ): PromiseAgentResult { const steps: ReasoningStep[] []; let repeatCount 0; let lastCallSignature ; for (let i 0; i MAX_ITERATIONS; i) { const response await provider.chat(messages, tools); const parsed parseModelOutput(response.content); if (parsed.type final_answer) { return { answer: parsed.content, steps }; } const signature ${parsed.call.name}:${JSON.stringify(parsed.call.arguments)}; if (signature lastCallSignature) { repeatCount; if (repeatCount MAX_REPEAT_CALLS) { return { answer: 检测到重复调用已终止, steps }; } } else { repeatCount 0; } lastCallSignature signature; // 执行工具并记录步骤 const observation await executeTool(parsed.call, tools); steps.push({ index: i, thought: response.content, action: { toolName: parsed.call.name, args: parsed.call.arguments }, observation, timestamp: Date.now(), }); messages.push( { role: assistant, content: response.content }, { role: tool, content: observation, toolCallId: parsed.call.id, toolName: parsed.call.name } ); } return { answer: 达到最大迭代次数, steps }; }注意重复调用检测的签名里参数要做稳定的序列化。如果参数对象里 key 的顺序不固定JSON.stringify出来的字符串会不一样检测就失效了。生产环境建议用排序后的 key 做序列化。5. MCP 工具生态的接入方案5.1 MCP 协议解决了什么问题MCPModel Context Protocol本质上是一套标准化的工具描述和调用协议。在没有 MCP 之前每个 Agent 框架都有自己的工具定义格式你想把一个工具从 A 框架迁移到 B 框架得重写一遍。MCP 出现之后工具提供方只要实现一个 MCP server所有支持 MCP 的客户端都能用。对我们自研框架来说接入 MCP 意味着瞬间获得一个庞大的工具生态。文件系统操作、数据库查询、浏览器自动化、代码执行这些常见能力都有现成的 MCP server。我们不需要自己实现只要做好客户端对接就行。5.2 MCP 客户端的实现MCP 支持多种传输方式最常用的是 stdio 和 HTTP。stdio 方式下MCP server 作为一个子进程运行通过标准输入输出通信。我用 Node.js 的child_process来实现import { spawn, ChildProcess } from child_process; class MCPClient { private process: ChildProcess; private requestId 0; private pending new Mapnumber, (result: unknown) void(); constructor(command: string, args: string[]) { this.process spawn(command, args); this.process.stdout?.on(data, (data) this.handleData(data)); } private handleData(data: Buffer) { const lines data.toString().split(\n).filter(Boolean); for (const line of lines) { try { const message JSON.parse(line); if (message.id this.pending.has(message.id)) { this.pending.get(message.id)!(message.result); this.pending.delete(message.id); } } catch { // 忽略非 JSON 输出 } } } async listTools(): PromiseMCPTool[] { const result await this.sendRequest(tools/list, {}); return (result as { tools: MCPTool[] }).tools; } async callTool(name: string, args: Recordstring, unknown): Promisestring { const result await this.sendRequest(tools/call, { name, arguments: args }); return JSON.stringify(result); } private sendRequest(method: string, params: unknown): Promiseunknown { const id this.requestId; const request JSON.stringify({ jsonrpc: 2.0, id, method, params }); this.process.stdin?.write(request \n); return new Promise((resolve) this.pending.set(id, resolve)); } }这段代码是简化版实际使用还需要处理初始化握手、错误响应、进程退出重连等情况。但核心思路就是这样用 JSON-RPC 协议通过 stdio 和子进程通信。5.3 把 MCP 工具适配成内部工具MCP 返回的工具定义格式和我们的ToolDefinition不完全一样需要做一层适配function adaptMCPTool(mcpTool: MCPTool, client: MCPClient): ToolDefinition { return { name: mcpTool.name, description: mcpTool.description, parameters: jsonSchemaToZod(mcpTool.inputSchema), execute: async (args) { return await client.callTool(mcpTool.name, args); }, }; }jsonSchemaToZod这个转换函数需要自己写因为 MCP 用的是 JSON Schema我们内部用 zod。大部分基础类型string、number、boolean、object、array的转换很直接复杂类型oneOf、anyOf需要递归处理。我建议先支持基础类型遇到不支持的 schema 就退化成z.any()保证不阻塞主流程。5.4 工具命名冲突的处理当你同时接入多个 MCP server 时工具名冲突是必然的。我的处理方式是给工具名加前缀function namespaceTools(tools: ToolDefinition[], namespace: string): ToolDefinition[] { return tools.map(t ({ ...t, name: ${namespace}__${t.name}, })); }用双下划线分隔既避免了冲突又保持了可读性。在调用时把前缀去掉再转发给对应的 MCP server 就行。6. 实操中踩过的坑与排查技巧6.1 模型不按格式输出怎么办这是最常见的问题。你明明在系统提示词里写了要输出 JSON模型偏偏给你返回一段自然语言。我的经验是提示词要给出具体示例不要只说输出 JSON 格式而是给一个完整的输入输出示例。降低 temperature推理阶段用 0 到 0.3 之间的值输出稳定性会好很多。用 few-shot 示例在系统提示词里放两三个正确调用的例子效果立竿见影。如果这些都做了还是不稳定那就说明这个模型本身指令遵循能力不够考虑换模型比继续调 prompt 更划算。6.2 工具执行超时与错误处理工具执行失败是常态网络请求会超时文件可能不存在API 可能限流。关键是要把错误信息结构化地返回给模型而不是直接抛异常中断循环async function executeTool(call: ToolCall, tools: ToolDefinition[]): Promisestring { const tool tools.find(t t.name call.name); if (!tool) { return 错误未找到工具 ${call.name}; } try { const validated tool.parameters.parse(call.arguments); const result await Promise.race([ tool.execute(validated), new Promisestring((_, reject) setTimeout(() reject(new Error(工具执行超时)), 30000) ), ]); return result; } catch (error) { return 错误${error instanceof Error ? error.message : String(error)}; } }把错误信息作为 observation 返回给模型模型往往能自己纠正。比如参数格式错了它看到错误信息后会重新构造参数再试一次。这比直接中断循环智能得多。6.3 上下文长度爆炸多轮工具调用之后对话历史会变得很长。如果不加控制很快就会超出模型的上下文窗口。我的处理策略是策略适用场景实现复杂度滑动窗口对话轮次多但每轮内容短低摘要压缩历史信息重要但不能全保留中工具结果截断工具返回内容过长低分层记忆需要长期记忆的复杂场景高我目前用的是滑动窗口加工具结果截断的组合。保留最近 N 轮完整对话更早的只保留用户消息和最终答案。工具返回结果超过 2000 字符就截断加个省略标记。6.4 常见问题速查表现象可能原因排查方向Agent 一直循环不结束工具返回结果模型无法理解检查 observation 格式是否清晰工具调用参数总是错的schema 描述不清晰补充参数描述和示例值模型忽略工具直接回答系统提示词不够强硬加强工具使用的引导语流式输出解析乱码SSE 分块边界处理不当用 eventsource-parser 处理类型报错但运行正常zod schema 和 TS 类型不同步用 z.infer 统一类型来源提示调试 Agent 时把每一轮的完整 prompt 和模型原始输出都打到日志里。虽然日志量大但出问题时能省下大量猜测时间。我习惯用一个环境变量控制详细日志的开关开发时打开生产环境关掉。7. 框架的扩展方向与我的一些体会这个最小框架跑通之后扩展方向其实很多。我目前在做的是给它加一个简单的记忆模块把每次对话的关键信息存到本地文件下次对话时作为上下文注入。另外流式输出的支持也在完善中让用户能实时看到 Agent 的推理过程体验会好很多。还有一个值得投入的方向是评估体系。Agent 的行为不像传统函数那样有确定的输入输出怎么判断它这次任务完成得好不好是个挺难的问题。我现在的做法是准备一批标准测试用例每个用例有预期的工具调用序列跑完之后对比实际序列和预期序列的匹配度。粗糙但有效。说实话自己造框架这件事最大的收获不是框架本身而是对 Agent 运转机制的透彻理解。以前用现成框架时遇到问题只能猜现在每一行代码都是自己写的出问题能直接定位到根因。如果你也想真正搞懂 Agent 是怎么回事我强烈建议你动手写一遍哪怕最后不用它这个过程本身就值回票价。
阅读完成 · 觉得有帮助?
咨询建站