1. 项目概述与核心价值最近在折腾大语言模型应用发现很多朋友对Agent智能体这个概念很感兴趣但市面上的框架要么过于庞大复杂要么封装得太好导致想深入理解其内部运作机制变得困难。这让我想起了自己刚开始学习编程时总想亲手“造轮子”的经历——只有从零开始搭建一遍才能真正吃透一个技术的精髓。因此我决定动手实现一个最经典的Agent范式ReActReasoning Acting。这个项目llm-ReAct就是一个纯粹的、教学向的、从零开始的TypeScript实现。它不依赖任何复杂的Agent框架库只使用最基础的OpenAI API完整地走通了ReAct Agent的核心流程任务规划、工具调用和短期记忆。如果你对LLM如何通过“思考-行动”循环来解决问题感到好奇或者想为自己的项目添加一个轻量级、可完全掌控的智能决策模块那么这个项目会是一个绝佳的起点。通过亲手实现你将彻底理解Prompt工程如何引导模型推理、工具系统如何被调度以及一个简单的记忆机制如何让Agent在对话中保持连贯性。2. ReAct框架核心原理与设计思路拆解在深入代码之前我们必须先搞清楚ReAct到底在解决什么问题以及它是如何工作的。传统的大语言模型调用往往是“一次性”的你给一个输入它给一个输出。但对于复杂任务比如“查询北京今天的天气然后根据天气建议我是否要带伞”这种单次交互就显得力不从心。模型可能需要先执行查询再基于结果进行推理这涉及多个步骤和外部数据的介入。2.1 ReAct范式将思考过程“外化”ReAct的核心思想非常直观让模型将内部的推理过程Reasoning以文本形式“说”出来并基于此决定下一步要执行的动作Acting然后观察Observation动作的结果如此循环直到任务完成。这个过程形成了一个经典的循环Thought - Action - Observation。Thought思考模型分析当前状况、历史记录和任务目标规划下一步该做什么。例如“用户想了解天气并得到建议。我需要先获取北京的天气信息。”Action行动模型根据思考决定调用哪个工具Tool以及传入什么参数。它会输出一个结构化的动作指令如Action: SearchWeather[location“北京”]。Observation观察执行工具调用后将结果可能是成功的数据或错误信息返回给模型。例如Observation: 北京今天晴转多云气温15-25度无降水。模型接收到观察结果后开始新一轮的思考决定是继续调用工具还是给出最终答案。这个循环的妙处在于它将模型的黑盒推理过程变成了可监控、可引导的文本流。我们作为开发者可以通过精心设计的Prompt模板极大地提升模型任务分解和工具调用的准确率。2.2 本项目架构设计清晰的责任分离基于ReAct范式我设计了以下几个核心组件确保每个部分职责单一易于理解和扩展Agent智能体这是大脑。它不关心具体的网络请求或工具实现只负责两件事接收结构化的上下文包含任务、历史、可用工具列表然后生成符合ReAct格式的文本输出即包含Thought和Action的字符串。它是对接LLM的抽象层。Executor执行器这是中枢神经系统和指挥中心。它维护着事件循环Event Loop负责组装每次调用Agent所需的上下文包括短期记忆解析Agent输出的文本以提取动作指令调用对应的工具并将结果作为新的观察拼接到历史中然后决定循环是否继续。Tool工具这是手脚。每个工具都是一个独立的类有明确的名称、描述和参数定义。当Executor解析出动作指令后就找到对应的工具实例并执行其call方法。LLM大语言模型客户端这是感官输入。一个极简的OpenAI API封装只处理发送请求和接收响应保持底层通信的纯粹性。Prompt提示词模板这是思维模式。它定义了发送给LLM的文本的固定结构告诉模型它现在是一个ReAct Agent它有哪些工具可用历史对话是什么当前任务是什么以及它应该以什么格式回复。这种设计使得增加新工具、更换模型供应商比如从OpenAI换成Azure OpenAI或本地模型变得非常容易只需修改或替换相应组件而不会影响核心的ReAct逻辑。3. 核心组件深度解析与实现要点让我们打开src目录逐一拆解每个核心文件看看它们是如何协作的。我会在解释代码逻辑的同时穿插我在实现过程中踩过的坑和总结的经验。3.1 工具系统tool.ts与toolkit.ts工具是Agent与外界交互的唯一途径。一个设计良好的工具接口是系统可扩展性的基石。tool.ts- 工具抽象基类// 这是一个简化的示例展示核心设计 export abstract class Tool { name: string; // 工具唯一标识如 “SearchWeather” description: string; // 给模型看的工具功能描述 parameters: Recordstring, any; // 参数JSON Schema constructor(config: { name: string; description: string }) { this.name config.name; this.description config.description; } // 核心方法执行工具逻辑 abstract call(args: any): Promisestring; // 将工具信息格式化为模型可理解的文本用于拼接到Prompt中 formatForPrompt(): string { return - ${this.name}: ${this.description}; } }实现要点与避坑指南描述description至关重要这个描述是给模型看的“说明书”。它必须清晰、无歧义地说明工具的功能、输入和输出。例如“获取指定城市的当前天气信息”就比“查询天气”要好。好的描述能显著提升模型调用工具的准确率。错误处理在call方法内部完成工具执行可能失败如网络错误、参数无效。call方法应捕获这些异常并返回一个格式化的错误字符串如Error: Network timeout。切记不要抛出异常到Executor因为对Agent来说执行失败也是一个有效的“Observation”它可以根据这个错误进行下一步思考例如重试或提示用户。参数设计对于复杂工具可以定义详细的parametersJSON Schema。在简单实现中可以约定args是一个对象模型输出的动作指令需要包含一个能解析成该对象的字符串如JSON。toolkit.ts- 具体工具实现这里实现了几个示例工具如计算器、搜索、获取时间等。我们以计算器为例import { Tool } from ./tool; export class CalculatorTool extends Tool { constructor() { super({ name: Calculator, description: 执行数学计算。输入一个数学表达式字符串如 “(2 3) * 4”。返回计算结果。, }); } async call(args: any): Promisestring { try { // 安全提示在实际生产中直接eval极其危险这里仅作演示。 // 应使用安全的数学表达式解析库如 math.js。 const expression typeof args string ? args : args?.expression; if (!expression) { return Error: 请提供有效的数学表达式。; } // 这是一个极简示例实际项目务必替换 const result eval(expression); return Result: ${result}; } catch (error) { return Error: 计算失败 - ${error.message}; } } }重要安全警告上述示例中的eval仅用于演示ReAct流程的简洁性。在真实、可公开访问的服务中绝对不允许使用eval来执行用户或模型提供的字符串这将造成严重的代码注入安全漏洞。必须使用像math.js这类安全的表达式求值库。3.2 智能体核心agent.tsAgent组件是纯逻辑的它接收所有必要信息调用LLM并返回原始文本。import { LLM } from ./llm; import { Tool } from ./tool; import { getReActPrompt } from ./prompt; export class Agent { private llm: LLM; constructor(llm: LLM) { this.llm llm; } async run({ task, history, tools, }: { task: string; history: Array{ thought?: string; action?: string; observation?: string }; tools: Tool[]; }): Promisestring { // 1. 构建Prompt const prompt getReActPrompt(task, history, tools); // 2. 调用LLM const response await this.llm.generate(prompt); // 3. 返回模型的原始回复 return response; } }关键设计决策输入标准化history是一个数组每个元素代表一轮“思考-行动-观察”。这种结构化的历史记录比纯文本拼接更清晰也便于后续扩展比如加入时间戳、置信度等。职责单一Agent只负责“输入文本 - 输出文本”。它不解析动作也不关心输出格式的具体含义。解析工作交给下游的Executor。这样分离使得我们可以轻松替换不同的输出解析器比如解析JSON格式的Action而不仅仅是文本。3.3 提示词工程prompt.ts这是整个系统的“灵魂”。Prompt的质量直接决定了Agent的表现。我们的Prompt需要清晰定义角色、任务、工具、历史、以及最重要的——输出格式指令。export function getReActPrompt( task: string, history: Array{ thought?: string; action?: string; observation?: string }, tools: Tool[] ): string { // 工具列表描述 const toolDescriptions tools.map(tool tool.formatForPrompt()).join(\n); // 格式化历史记录 let historyText ; for (const entry of history) { if (entry.thought) historyText Thought: ${entry.thought}\n; if (entry.action) historyText Action: ${entry.action}\n; if (entry.observation) historyText Observation: ${entry.observation}\n; } return 你是一个善于思考和利用工具解决问题的AI助手。请遵循以下格式回答问题 任务${task} 你可以使用的工具 ${toolDescriptions} 历史记录 ${historyText} 请开始思考。你必须严格按照以下格式输出 Thought: [你在这里进行思考分析当前情况和下一步该做什么] Action: [工具名称][输入参数] // 例如Calculator[“22”] 或 Search[“OpenAI官网”] 当你认为任务已经完成需要给出最终答案时请输出 Thought: 我已经得到所有必要信息可以给出最终答案。 Answer: [你的最终答案] 现在开始处理任务${task} ; }Prompt设计心得格式指令必须强硬且明确使用“必须严格按照以下格式输出”这样的措辞。在示例中明确给出Action:的格式包括工具名和参数如何书写如Calculator[“22”]。模型对示例格式非常敏感。提供“结束循环”的明确路径必须告诉模型如何表示任务完成。我们定义了当模型输出Answer:时循环终止。没有这个Agent可能会陷入无限循环。历史记录的呈现方式将历史记录原样回放给模型相当于给了它一个“短期记忆”。模型可以基于之前的思考和观察进行连贯推理。3.4 执行器与事件循环executor.tsExecutor是粘合剂它驱动整个ReAct循环运转。import { Agent } from ./agent; import { Tool } from ./tool; export class Executor { private agent: Agent; private tools: Mapstring, Tool; // 工具名称到实例的映射 constructor(agent: Agent, tools: Tool[]) { this.agent agent; this.tools new Map(); tools.forEach(tool this.tools.set(tool.name, tool)); } async run(task: string, maxIterations: number 10): Promisestring { const history: Array{ thought?: string; action?: string; observation?: string } []; for (let i 0; i maxIterations; i) { // 1. 调用Agent进行思考 const agentResponse await this.agent.run({ task, history, tools: Array.from(this.tools.values()) }); // 2. 解析Agent的回复 const { thought, action, answer } this.parseResponse(agentResponse); // 记录本次思考 history.push({ thought }); // 3. 判断是否结束 if (answer ! undefined) { history.push({ observation: Task completed with answer: ${answer} }); return answer; // 返回最终答案 } // 4. 执行动作 if (action) { const { toolName, input } this.parseAction(action); const tool this.tools.get(toolName); if (!tool) { const errorObs Error: 未知工具 ${toolName}.; history.push({ action, observation: errorObs }); continue; // 遇到未知工具将错误作为Observation进入下一轮循环 } // 记录行动 history.push({ action }); // 执行工具并获取观察结果 const observation await tool.call(input); history.push({ observation }); } else { // 如果没有解析出Action也没有Answer可能模型格式错误注入一个错误观察 history.push({ observation: Error: 响应格式不符合要求未找到有效的Action或Answer。 }); } } throw new Error(任务未在${maxIterations}轮内完成。); } private parseResponse(response: string): { thought?: string; action?: string; answer?: string } { // 使用正则表达式从文本中提取 Thought, Action, Answer // 这是一个简化的解析器实际应用中需要更健壮的处理 const thoughtMatch response.match(/Thought:\s*(.*?)(?\nAction:|\nAnswer:|$)/s); const actionMatch response.match(/Action:\s*(.*?)(?\nThought:|\nAnswer:|$)/s); const answerMatch response.match(/Answer:\s*(.*?)$/s); return { thought: thoughtMatch?.[1]?.trim(), action: actionMatch?.[1]?.trim(), answer: answerMatch?.[1]?.trim(), }; } private parseAction(actionStr: string): { toolName: string; input: any } { // 解析类似 “Calculator[“22”]” 的字符串 // 实际实现需要处理各种边界情况如参数带引号、嵌套等 const match actionStr.match(/^([\w])\[(.*)\]$/); if (!match) { throw new Error(无法解析动作指令: ${actionStr}); } const [, toolName, inputStr] match; let input; try { // 尝试将参数解析为JSON input JSON.parse(inputStr); } catch { // 如果解析失败则视为普通字符串 input inputStr; } return { toolName, input }; } }Executor实现的关键细节与经验循环终止条件必须有maxIterations限制防止模型陷入死循环或任务无法完成时消耗过多资源。健壮的解析器parseResponse和parseAction是系统的脆弱点。模型输出可能存在格式偏差多空格、换行、标点。这里的正则表达式是简化版。在生产环境中建议要求模型输出严格的JSON格式而非自由文本这样解析更可靠。或者使用一个更宽容的多行正则匹配并做好错误处理当解析失败时给模型一个友好的错误观察让它有机会纠正。错误即观察无论是工具未找到还是工具执行出错都将错误信息作为Observation放入历史。这符合ReAct的设计——模型需要从错误中学习并调整策略。历史管理history数组在每一轮循环后都被更新。注意我们将thought、action、observation作为独立的步骤记录这比拼接成一个字符串更清晰也便于未来做更复杂的记忆处理如摘要、过滤。3.5 大模型客户端llm.ts这是一个极简封装保持灵活性。import OpenAI from openai; // 假设使用OpenAI官方SDK export class LLM { private client: OpenAI; private model: string; constructor(apiKey: string, model: string gpt-3.5-turbo) { this.client new OpenAI({ apiKey }); this.model model; } async generate(prompt: string): Promisestring { try { const response await this.client.chat.completions.create({ model: this.model, messages: [{ role: user, content: prompt }], temperature: 0.1, // 低温度保证输出格式稳定 max_tokens: 500, }); return response.choices[0]?.message?.content?.trim() || ; } catch (error) { console.error(LLM调用失败:, error); throw new Error(LLM生成失败: ${error.message}); } } }参数调优建议temperature温度设置为较低值如0.1-0.3。对于需要严格遵守输出格式的Agent任务低温度能减少随机性使模型输出更稳定、更可预测。max_tokens最大令牌数需要根据Prompt长度和预期回复长度设置。设置过小会导致回复被截断破坏格式。可以适当设大一些并在Prompt中提醒模型回复要简洁。错误处理网络超时、额度不足、模型过载等都可能发生。良好的错误处理能让Executor有机会进行降级处理或给用户友好提示。4. 从零搭建与运行完整实操指南理论讲完了我们动手把它跑起来。假设你已经有Node.js环境和TypeScript基础。4.1 环境准备与项目初始化首先创建一个新目录并初始化项目mkdir my-react-agent cd my-react-agent npm init -y安装必要的依赖。我们使用TypeScript、OpenAI官方SDK以及一些开发工具。npm install openai npm install -D typescript ts-node types/node初始化TypeScript配置npx tsc --init在生成的tsconfig.json中确保target是ES2020或更高module是commonjsNode.js环境并设置outDir为./dist。4.2 编写核心代码与配置文件按照前面章节解析的结构创建src目录以及agent.ts,executor.ts,llm.ts,prompt.ts,tool.ts,toolkit.ts文件将上面的示例代码填充进去。创建项目根目录下的config.json用于存放敏感配置切记不要提交到版本控制{ apiKey: 你的OpenAI API Key, model: gpt-3.5-turbo }创建一个入口文件index.ts将所有组件组装起来并运行一个示例任务import { config } from ./config.json; import { LLM } from ./src/llm; import { Agent } from ./src/agent; import { Executor } from ./src/executor; import { CalculatorTool, SearchTool, GetCurrentTimeTool } from ./src/toolkit; async function main() { // 1. 初始化LLM客户端 const llm new LLM(config.apiKey, config.model); // 2. 初始化Agent const agent new Agent(llm); // 3. 准备工具集 const tools [ new CalculatorTool(), new SearchTool(), // 假设这是一个模拟搜索工具 new GetCurrentTimeTool(), ]; // 4. 初始化执行器 const executor new Executor(agent, tools); // 5. 运行一个复杂任务 const task “请计算圆周率π的近似值使用22/7然后告诉我现在是什么时间”; console.log(开始执行任务: ${task}); try { const finalAnswer await executor.run(task); console.log(\n任务完成最终答案\n${finalAnswer}); } catch (error) { console.error(执行失败:, error.message); } } main();4.3 编译与运行在package.json中添加启动脚本scripts: { start: ts-node index.ts, build: tsc }现在运行你的第一个ReAct Agentnpm start你应该能在控制台看到Agent的思考过程、工具调用和最终答案。5. 常见问题、调试技巧与进阶优化在实际搭建和运行过程中你几乎一定会遇到下面这些问题。这里是我总结的排查清单和优化方向。5.1 问题排查速查表问题现象可能原因解决方案模型不输出Action或Answer格式1. Prompt格式指令不够强硬或清晰。2. temperature设置过高输出随机性大。3. 模型上下文理解有误。1. 强化Prompt中的格式指令添加更明确的示例。2. 将temperature降至0.1。3. 在Prompt开头更强调“你必须以指定格式回复”。解析Action时出错1. 模型输出的Action字符串格式与parseAction函数期望的不符。2. 参数中包含特殊字符导致解析失败。1. 在Prompt中提供更精确的Action示例如Action: Calculator[“expression”]。2. 增强parseAction函数的鲁棒性尝试多种解析方式或要求模型输出JSON。Agent陷入无限循环1. 任务本身无法通过现有工具完成。2. 模型无法正确判断何时该结束输出Answer。3. 工具执行结果未能提供有效信息。1. 检查任务是否合理工具是否完备。2. 在Prompt中更清晰地定义任务完成的标志。3. 为工具添加更明确的错误和结果描述帮助模型理解。工具调用结果不准确1. 工具描述description不清晰。2. 模型对参数的理解有偏差。1. 优化工具描述明确输入输出格式和示例。2. 在Prompt中提供工具调用的成功案例。API调用超时或失败1. 网络问题。2. API Key无效或额度不足。3. 请求频率超限。1. 在llm.ts中添加重试逻辑和超时设置。2. 检查配置和账单。3. 在代码中实现简单的速率限制。5.2 调试与日志记录心得在开发阶段详细的日志是定位问题的生命线。我强烈建议在Executor的循环中打印每一轮的关键信息// 在Executor的run循环中添加 console.log(\n 迭代 ${i 1} ); console.log(发送给Agent的Prompt预览\n${prompt.substring(0, 500)}...); // 打印部分Prompt console.log(Agent原始回复\n${agentResponse}); console.log(解析结果, { thought, action, answer }); if (action) { console.log(准备执行工具${toolName}, 参数, input); console.log(工具执行结果${observation}); }这样你能清晰地看到模型“想”了什么、“做”了什么、以及“看”到了什么很容易定位是Prompt问题、解析问题还是工具问题。5.3 性能与进阶优化方向当这个基础框架跑通后你可以从以下几个方向进行深化和优化记忆机制升级目前的“短期记忆”只是简单的历史会话列表。当对话轮次很长时会消耗大量Token并可能超出模型上下文长度。可以引入记忆摘要Memory Summarization技术定期将冗长的历史压缩成简洁的要点或者实现一个向量数据库记忆将历史信息存入向量库在需要时进行相关性检索。工具验证与安全当前模型可以任意调用任何工具。你需要增加一个工具调用验证层。例如在执行工具前检查当前用户是否有权限调用该工具或者对输入参数进行严格的清洗和验证防止注入攻击特别是在使用eval或执行系统命令的工具时。输出格式标准化将模型的输出格式从自由文本强制转换为JSON Schema。这能极大提高解析的可靠性。你可以使用OpenAI的function calling功能或提示模型输出指定结构的JSON。支持流式输出Streaming对于耗时较长的任务可以将模型的“思考”过程流式地输出给前端提升用户体验。集成更多工具尝试集成真实的API如网络搜索、数据库查询、发送邮件等构建真正有用的智能助手。更换模型后端将llm.ts抽象成接口轻松接入Azure OpenAI、Anthropic Claude、或本地部署的Ollama、LM Studio中的模型。从零搭建这个ReAct框架的过程让我对Agent的工作原理有了刻骨铭心的理解。它不仅仅是一个调用LLM的代码更是一个设计精巧的、模拟人类“感知-思考-行动”循环的系统。最大的收获在于我认识到了Prompt工程、工具设计、错误处理循环这三者之间的微妙平衡。下次当你使用LangChain或AutoGen这类高级框架时你会清楚地知道在那些简洁的API调用背后正是这样一个不断循环的“思考-行动”引擎在默默工作。
从零实现ReAct智能体:基于TypeScript的LLM Agent核心架构与实战
发布时间:2026/7/6 11:18:50
1. 项目概述与核心价值最近在折腾大语言模型应用发现很多朋友对Agent智能体这个概念很感兴趣但市面上的框架要么过于庞大复杂要么封装得太好导致想深入理解其内部运作机制变得困难。这让我想起了自己刚开始学习编程时总想亲手“造轮子”的经历——只有从零开始搭建一遍才能真正吃透一个技术的精髓。因此我决定动手实现一个最经典的Agent范式ReActReasoning Acting。这个项目llm-ReAct就是一个纯粹的、教学向的、从零开始的TypeScript实现。它不依赖任何复杂的Agent框架库只使用最基础的OpenAI API完整地走通了ReAct Agent的核心流程任务规划、工具调用和短期记忆。如果你对LLM如何通过“思考-行动”循环来解决问题感到好奇或者想为自己的项目添加一个轻量级、可完全掌控的智能决策模块那么这个项目会是一个绝佳的起点。通过亲手实现你将彻底理解Prompt工程如何引导模型推理、工具系统如何被调度以及一个简单的记忆机制如何让Agent在对话中保持连贯性。2. ReAct框架核心原理与设计思路拆解在深入代码之前我们必须先搞清楚ReAct到底在解决什么问题以及它是如何工作的。传统的大语言模型调用往往是“一次性”的你给一个输入它给一个输出。但对于复杂任务比如“查询北京今天的天气然后根据天气建议我是否要带伞”这种单次交互就显得力不从心。模型可能需要先执行查询再基于结果进行推理这涉及多个步骤和外部数据的介入。2.1 ReAct范式将思考过程“外化”ReAct的核心思想非常直观让模型将内部的推理过程Reasoning以文本形式“说”出来并基于此决定下一步要执行的动作Acting然后观察Observation动作的结果如此循环直到任务完成。这个过程形成了一个经典的循环Thought - Action - Observation。Thought思考模型分析当前状况、历史记录和任务目标规划下一步该做什么。例如“用户想了解天气并得到建议。我需要先获取北京的天气信息。”Action行动模型根据思考决定调用哪个工具Tool以及传入什么参数。它会输出一个结构化的动作指令如Action: SearchWeather[location“北京”]。Observation观察执行工具调用后将结果可能是成功的数据或错误信息返回给模型。例如Observation: 北京今天晴转多云气温15-25度无降水。模型接收到观察结果后开始新一轮的思考决定是继续调用工具还是给出最终答案。这个循环的妙处在于它将模型的黑盒推理过程变成了可监控、可引导的文本流。我们作为开发者可以通过精心设计的Prompt模板极大地提升模型任务分解和工具调用的准确率。2.2 本项目架构设计清晰的责任分离基于ReAct范式我设计了以下几个核心组件确保每个部分职责单一易于理解和扩展Agent智能体这是大脑。它不关心具体的网络请求或工具实现只负责两件事接收结构化的上下文包含任务、历史、可用工具列表然后生成符合ReAct格式的文本输出即包含Thought和Action的字符串。它是对接LLM的抽象层。Executor执行器这是中枢神经系统和指挥中心。它维护着事件循环Event Loop负责组装每次调用Agent所需的上下文包括短期记忆解析Agent输出的文本以提取动作指令调用对应的工具并将结果作为新的观察拼接到历史中然后决定循环是否继续。Tool工具这是手脚。每个工具都是一个独立的类有明确的名称、描述和参数定义。当Executor解析出动作指令后就找到对应的工具实例并执行其call方法。LLM大语言模型客户端这是感官输入。一个极简的OpenAI API封装只处理发送请求和接收响应保持底层通信的纯粹性。Prompt提示词模板这是思维模式。它定义了发送给LLM的文本的固定结构告诉模型它现在是一个ReAct Agent它有哪些工具可用历史对话是什么当前任务是什么以及它应该以什么格式回复。这种设计使得增加新工具、更换模型供应商比如从OpenAI换成Azure OpenAI或本地模型变得非常容易只需修改或替换相应组件而不会影响核心的ReAct逻辑。3. 核心组件深度解析与实现要点让我们打开src目录逐一拆解每个核心文件看看它们是如何协作的。我会在解释代码逻辑的同时穿插我在实现过程中踩过的坑和总结的经验。3.1 工具系统tool.ts与toolkit.ts工具是Agent与外界交互的唯一途径。一个设计良好的工具接口是系统可扩展性的基石。tool.ts- 工具抽象基类// 这是一个简化的示例展示核心设计 export abstract class Tool { name: string; // 工具唯一标识如 “SearchWeather” description: string; // 给模型看的工具功能描述 parameters: Recordstring, any; // 参数JSON Schema constructor(config: { name: string; description: string }) { this.name config.name; this.description config.description; } // 核心方法执行工具逻辑 abstract call(args: any): Promisestring; // 将工具信息格式化为模型可理解的文本用于拼接到Prompt中 formatForPrompt(): string { return - ${this.name}: ${this.description}; } }实现要点与避坑指南描述description至关重要这个描述是给模型看的“说明书”。它必须清晰、无歧义地说明工具的功能、输入和输出。例如“获取指定城市的当前天气信息”就比“查询天气”要好。好的描述能显著提升模型调用工具的准确率。错误处理在call方法内部完成工具执行可能失败如网络错误、参数无效。call方法应捕获这些异常并返回一个格式化的错误字符串如Error: Network timeout。切记不要抛出异常到Executor因为对Agent来说执行失败也是一个有效的“Observation”它可以根据这个错误进行下一步思考例如重试或提示用户。参数设计对于复杂工具可以定义详细的parametersJSON Schema。在简单实现中可以约定args是一个对象模型输出的动作指令需要包含一个能解析成该对象的字符串如JSON。toolkit.ts- 具体工具实现这里实现了几个示例工具如计算器、搜索、获取时间等。我们以计算器为例import { Tool } from ./tool; export class CalculatorTool extends Tool { constructor() { super({ name: Calculator, description: 执行数学计算。输入一个数学表达式字符串如 “(2 3) * 4”。返回计算结果。, }); } async call(args: any): Promisestring { try { // 安全提示在实际生产中直接eval极其危险这里仅作演示。 // 应使用安全的数学表达式解析库如 math.js。 const expression typeof args string ? args : args?.expression; if (!expression) { return Error: 请提供有效的数学表达式。; } // 这是一个极简示例实际项目务必替换 const result eval(expression); return Result: ${result}; } catch (error) { return Error: 计算失败 - ${error.message}; } } }重要安全警告上述示例中的eval仅用于演示ReAct流程的简洁性。在真实、可公开访问的服务中绝对不允许使用eval来执行用户或模型提供的字符串这将造成严重的代码注入安全漏洞。必须使用像math.js这类安全的表达式求值库。3.2 智能体核心agent.tsAgent组件是纯逻辑的它接收所有必要信息调用LLM并返回原始文本。import { LLM } from ./llm; import { Tool } from ./tool; import { getReActPrompt } from ./prompt; export class Agent { private llm: LLM; constructor(llm: LLM) { this.llm llm; } async run({ task, history, tools, }: { task: string; history: Array{ thought?: string; action?: string; observation?: string }; tools: Tool[]; }): Promisestring { // 1. 构建Prompt const prompt getReActPrompt(task, history, tools); // 2. 调用LLM const response await this.llm.generate(prompt); // 3. 返回模型的原始回复 return response; } }关键设计决策输入标准化history是一个数组每个元素代表一轮“思考-行动-观察”。这种结构化的历史记录比纯文本拼接更清晰也便于后续扩展比如加入时间戳、置信度等。职责单一Agent只负责“输入文本 - 输出文本”。它不解析动作也不关心输出格式的具体含义。解析工作交给下游的Executor。这样分离使得我们可以轻松替换不同的输出解析器比如解析JSON格式的Action而不仅仅是文本。3.3 提示词工程prompt.ts这是整个系统的“灵魂”。Prompt的质量直接决定了Agent的表现。我们的Prompt需要清晰定义角色、任务、工具、历史、以及最重要的——输出格式指令。export function getReActPrompt( task: string, history: Array{ thought?: string; action?: string; observation?: string }, tools: Tool[] ): string { // 工具列表描述 const toolDescriptions tools.map(tool tool.formatForPrompt()).join(\n); // 格式化历史记录 let historyText ; for (const entry of history) { if (entry.thought) historyText Thought: ${entry.thought}\n; if (entry.action) historyText Action: ${entry.action}\n; if (entry.observation) historyText Observation: ${entry.observation}\n; } return 你是一个善于思考和利用工具解决问题的AI助手。请遵循以下格式回答问题 任务${task} 你可以使用的工具 ${toolDescriptions} 历史记录 ${historyText} 请开始思考。你必须严格按照以下格式输出 Thought: [你在这里进行思考分析当前情况和下一步该做什么] Action: [工具名称][输入参数] // 例如Calculator[“22”] 或 Search[“OpenAI官网”] 当你认为任务已经完成需要给出最终答案时请输出 Thought: 我已经得到所有必要信息可以给出最终答案。 Answer: [你的最终答案] 现在开始处理任务${task} ; }Prompt设计心得格式指令必须强硬且明确使用“必须严格按照以下格式输出”这样的措辞。在示例中明确给出Action:的格式包括工具名和参数如何书写如Calculator[“22”]。模型对示例格式非常敏感。提供“结束循环”的明确路径必须告诉模型如何表示任务完成。我们定义了当模型输出Answer:时循环终止。没有这个Agent可能会陷入无限循环。历史记录的呈现方式将历史记录原样回放给模型相当于给了它一个“短期记忆”。模型可以基于之前的思考和观察进行连贯推理。3.4 执行器与事件循环executor.tsExecutor是粘合剂它驱动整个ReAct循环运转。import { Agent } from ./agent; import { Tool } from ./tool; export class Executor { private agent: Agent; private tools: Mapstring, Tool; // 工具名称到实例的映射 constructor(agent: Agent, tools: Tool[]) { this.agent agent; this.tools new Map(); tools.forEach(tool this.tools.set(tool.name, tool)); } async run(task: string, maxIterations: number 10): Promisestring { const history: Array{ thought?: string; action?: string; observation?: string } []; for (let i 0; i maxIterations; i) { // 1. 调用Agent进行思考 const agentResponse await this.agent.run({ task, history, tools: Array.from(this.tools.values()) }); // 2. 解析Agent的回复 const { thought, action, answer } this.parseResponse(agentResponse); // 记录本次思考 history.push({ thought }); // 3. 判断是否结束 if (answer ! undefined) { history.push({ observation: Task completed with answer: ${answer} }); return answer; // 返回最终答案 } // 4. 执行动作 if (action) { const { toolName, input } this.parseAction(action); const tool this.tools.get(toolName); if (!tool) { const errorObs Error: 未知工具 ${toolName}.; history.push({ action, observation: errorObs }); continue; // 遇到未知工具将错误作为Observation进入下一轮循环 } // 记录行动 history.push({ action }); // 执行工具并获取观察结果 const observation await tool.call(input); history.push({ observation }); } else { // 如果没有解析出Action也没有Answer可能模型格式错误注入一个错误观察 history.push({ observation: Error: 响应格式不符合要求未找到有效的Action或Answer。 }); } } throw new Error(任务未在${maxIterations}轮内完成。); } private parseResponse(response: string): { thought?: string; action?: string; answer?: string } { // 使用正则表达式从文本中提取 Thought, Action, Answer // 这是一个简化的解析器实际应用中需要更健壮的处理 const thoughtMatch response.match(/Thought:\s*(.*?)(?\nAction:|\nAnswer:|$)/s); const actionMatch response.match(/Action:\s*(.*?)(?\nThought:|\nAnswer:|$)/s); const answerMatch response.match(/Answer:\s*(.*?)$/s); return { thought: thoughtMatch?.[1]?.trim(), action: actionMatch?.[1]?.trim(), answer: answerMatch?.[1]?.trim(), }; } private parseAction(actionStr: string): { toolName: string; input: any } { // 解析类似 “Calculator[“22”]” 的字符串 // 实际实现需要处理各种边界情况如参数带引号、嵌套等 const match actionStr.match(/^([\w])\[(.*)\]$/); if (!match) { throw new Error(无法解析动作指令: ${actionStr}); } const [, toolName, inputStr] match; let input; try { // 尝试将参数解析为JSON input JSON.parse(inputStr); } catch { // 如果解析失败则视为普通字符串 input inputStr; } return { toolName, input }; } }Executor实现的关键细节与经验循环终止条件必须有maxIterations限制防止模型陷入死循环或任务无法完成时消耗过多资源。健壮的解析器parseResponse和parseAction是系统的脆弱点。模型输出可能存在格式偏差多空格、换行、标点。这里的正则表达式是简化版。在生产环境中建议要求模型输出严格的JSON格式而非自由文本这样解析更可靠。或者使用一个更宽容的多行正则匹配并做好错误处理当解析失败时给模型一个友好的错误观察让它有机会纠正。错误即观察无论是工具未找到还是工具执行出错都将错误信息作为Observation放入历史。这符合ReAct的设计——模型需要从错误中学习并调整策略。历史管理history数组在每一轮循环后都被更新。注意我们将thought、action、observation作为独立的步骤记录这比拼接成一个字符串更清晰也便于未来做更复杂的记忆处理如摘要、过滤。3.5 大模型客户端llm.ts这是一个极简封装保持灵活性。import OpenAI from openai; // 假设使用OpenAI官方SDK export class LLM { private client: OpenAI; private model: string; constructor(apiKey: string, model: string gpt-3.5-turbo) { this.client new OpenAI({ apiKey }); this.model model; } async generate(prompt: string): Promisestring { try { const response await this.client.chat.completions.create({ model: this.model, messages: [{ role: user, content: prompt }], temperature: 0.1, // 低温度保证输出格式稳定 max_tokens: 500, }); return response.choices[0]?.message?.content?.trim() || ; } catch (error) { console.error(LLM调用失败:, error); throw new Error(LLM生成失败: ${error.message}); } } }参数调优建议temperature温度设置为较低值如0.1-0.3。对于需要严格遵守输出格式的Agent任务低温度能减少随机性使模型输出更稳定、更可预测。max_tokens最大令牌数需要根据Prompt长度和预期回复长度设置。设置过小会导致回复被截断破坏格式。可以适当设大一些并在Prompt中提醒模型回复要简洁。错误处理网络超时、额度不足、模型过载等都可能发生。良好的错误处理能让Executor有机会进行降级处理或给用户友好提示。4. 从零搭建与运行完整实操指南理论讲完了我们动手把它跑起来。假设你已经有Node.js环境和TypeScript基础。4.1 环境准备与项目初始化首先创建一个新目录并初始化项目mkdir my-react-agent cd my-react-agent npm init -y安装必要的依赖。我们使用TypeScript、OpenAI官方SDK以及一些开发工具。npm install openai npm install -D typescript ts-node types/node初始化TypeScript配置npx tsc --init在生成的tsconfig.json中确保target是ES2020或更高module是commonjsNode.js环境并设置outDir为./dist。4.2 编写核心代码与配置文件按照前面章节解析的结构创建src目录以及agent.ts,executor.ts,llm.ts,prompt.ts,tool.ts,toolkit.ts文件将上面的示例代码填充进去。创建项目根目录下的config.json用于存放敏感配置切记不要提交到版本控制{ apiKey: 你的OpenAI API Key, model: gpt-3.5-turbo }创建一个入口文件index.ts将所有组件组装起来并运行一个示例任务import { config } from ./config.json; import { LLM } from ./src/llm; import { Agent } from ./src/agent; import { Executor } from ./src/executor; import { CalculatorTool, SearchTool, GetCurrentTimeTool } from ./src/toolkit; async function main() { // 1. 初始化LLM客户端 const llm new LLM(config.apiKey, config.model); // 2. 初始化Agent const agent new Agent(llm); // 3. 准备工具集 const tools [ new CalculatorTool(), new SearchTool(), // 假设这是一个模拟搜索工具 new GetCurrentTimeTool(), ]; // 4. 初始化执行器 const executor new Executor(agent, tools); // 5. 运行一个复杂任务 const task “请计算圆周率π的近似值使用22/7然后告诉我现在是什么时间”; console.log(开始执行任务: ${task}); try { const finalAnswer await executor.run(task); console.log(\n任务完成最终答案\n${finalAnswer}); } catch (error) { console.error(执行失败:, error.message); } } main();4.3 编译与运行在package.json中添加启动脚本scripts: { start: ts-node index.ts, build: tsc }现在运行你的第一个ReAct Agentnpm start你应该能在控制台看到Agent的思考过程、工具调用和最终答案。5. 常见问题、调试技巧与进阶优化在实际搭建和运行过程中你几乎一定会遇到下面这些问题。这里是我总结的排查清单和优化方向。5.1 问题排查速查表问题现象可能原因解决方案模型不输出Action或Answer格式1. Prompt格式指令不够强硬或清晰。2. temperature设置过高输出随机性大。3. 模型上下文理解有误。1. 强化Prompt中的格式指令添加更明确的示例。2. 将temperature降至0.1。3. 在Prompt开头更强调“你必须以指定格式回复”。解析Action时出错1. 模型输出的Action字符串格式与parseAction函数期望的不符。2. 参数中包含特殊字符导致解析失败。1. 在Prompt中提供更精确的Action示例如Action: Calculator[“expression”]。2. 增强parseAction函数的鲁棒性尝试多种解析方式或要求模型输出JSON。Agent陷入无限循环1. 任务本身无法通过现有工具完成。2. 模型无法正确判断何时该结束输出Answer。3. 工具执行结果未能提供有效信息。1. 检查任务是否合理工具是否完备。2. 在Prompt中更清晰地定义任务完成的标志。3. 为工具添加更明确的错误和结果描述帮助模型理解。工具调用结果不准确1. 工具描述description不清晰。2. 模型对参数的理解有偏差。1. 优化工具描述明确输入输出格式和示例。2. 在Prompt中提供工具调用的成功案例。API调用超时或失败1. 网络问题。2. API Key无效或额度不足。3. 请求频率超限。1. 在llm.ts中添加重试逻辑和超时设置。2. 检查配置和账单。3. 在代码中实现简单的速率限制。5.2 调试与日志记录心得在开发阶段详细的日志是定位问题的生命线。我强烈建议在Executor的循环中打印每一轮的关键信息// 在Executor的run循环中添加 console.log(\n 迭代 ${i 1} ); console.log(发送给Agent的Prompt预览\n${prompt.substring(0, 500)}...); // 打印部分Prompt console.log(Agent原始回复\n${agentResponse}); console.log(解析结果, { thought, action, answer }); if (action) { console.log(准备执行工具${toolName}, 参数, input); console.log(工具执行结果${observation}); }这样你能清晰地看到模型“想”了什么、“做”了什么、以及“看”到了什么很容易定位是Prompt问题、解析问题还是工具问题。5.3 性能与进阶优化方向当这个基础框架跑通后你可以从以下几个方向进行深化和优化记忆机制升级目前的“短期记忆”只是简单的历史会话列表。当对话轮次很长时会消耗大量Token并可能超出模型上下文长度。可以引入记忆摘要Memory Summarization技术定期将冗长的历史压缩成简洁的要点或者实现一个向量数据库记忆将历史信息存入向量库在需要时进行相关性检索。工具验证与安全当前模型可以任意调用任何工具。你需要增加一个工具调用验证层。例如在执行工具前检查当前用户是否有权限调用该工具或者对输入参数进行严格的清洗和验证防止注入攻击特别是在使用eval或执行系统命令的工具时。输出格式标准化将模型的输出格式从自由文本强制转换为JSON Schema。这能极大提高解析的可靠性。你可以使用OpenAI的function calling功能或提示模型输出指定结构的JSON。支持流式输出Streaming对于耗时较长的任务可以将模型的“思考”过程流式地输出给前端提升用户体验。集成更多工具尝试集成真实的API如网络搜索、数据库查询、发送邮件等构建真正有用的智能助手。更换模型后端将llm.ts抽象成接口轻松接入Azure OpenAI、Anthropic Claude、或本地部署的Ollama、LM Studio中的模型。从零搭建这个ReAct框架的过程让我对Agent的工作原理有了刻骨铭心的理解。它不仅仅是一个调用LLM的代码更是一个设计精巧的、模拟人类“感知-思考-行动”循环的系统。最大的收获在于我认识到了Prompt工程、工具设计、错误处理循环这三者之间的微妙平衡。下次当你使用LangChain或AutoGen这类高级框架时你会清楚地知道在那些简洁的API调用背后正是这样一个不断循环的“思考-行动”引擎在默默工作。