
1. 项目缘起为什么我要从零造一个 Agent 运行时最近几年AI Agent 这个概念火得不行从学术论文到创业公司好像不提 Agent 就落伍了。我作为一个在一线摸爬滚打了十多年的老码农自然也免不了被这股浪潮裹挟。市面上各种 Agent 框架层出不穷LangChain、AutoGen、CrewAI……名字一个比一个酷炫功能一个比一个丰富。一开始我也挺兴奋拿着这些框架做原型、搭 demo感觉生产力爆棚。但用着用着不对劲的感觉就来了。我发现很多框架为了追求“大而全”封装了一层又一层抽象得让人头晕。我想实现一个简单的“根据用户问题先搜索再总结”的链式调用结果要翻半天文档配置一堆我可能永远用不上的组件。更头疼的是当我想深入定制某个环节的逻辑或者排查一个诡异的问题时常常被框架内部的复杂机制挡在门外调试起来像在走迷宫。性能也是个问题一些框架在简单任务上开销不小感觉“杀鸡用了牛刀”还不好拆。于是我开始思考一个 Agent 运行时的核心到底是什么抛开那些华丽的营销词汇它本质上是不是就是一个任务调度器加上一个状态管理器负责接收一个目标Goal拆解成可执行的步骤Action调用合适的工具Tool去执行然后根据执行结果更新状态并决定下一步做什么。这个循环听起来并不复杂为什么现有的方案让我感觉如此“重”呢我决定与其在别人的框架里缝缝补补不如自己动手从头造一个轻量、透明、可插拔的 Agent 运行时。我的目标很明确它不追求功能最多但追求核心路径最清晰不追求兼容所有 LLM但追求与主流模型的集成最简单不追求解决所有问题但追求在遇到问题时我能一眼看到底知道问题出在哪一层。这个系列就是我造轮子的全过程记录我会把每一步的设计思路、技术选型、踩过的坑以及最终的代码都原原本本地写下来。一方面给自己留个备忘另一方面也希望能给那些同样对 Agent 底层原理感兴趣或者受困于现有框架复杂性的开发者们提供一个不同的视角和一套可用的参考实现。2. 核心设计一个极简 Agent 运行时的骨架长什么样在动手写代码之前得先把设计图搞清楚。我不想一开始就陷入复杂的类继承和设计模式中所以先定义最核心的几个概念。在我看来一个最小化的 Agent 运行时只需要四个核心组件Agent智能体、Tool工具、Planner规划器和Runtime运行时引擎。Agent是执行主体它持有身份比如“数据分析专家”、目标Goal和一段系统提示词System Prompt用来定义它的行为和思考方式。Tool是 Agent 可以调用的外部能力比如搜索网络、查询数据库、执行代码等。每个 Tool 都有明确的输入、输出和执行函数。Planner是大脑它根据当前目标、历史对话和可用工具决定下一步该调用哪个 Tool或者直接给出最终答案。最简单的 Planner 可以直接让 LLM 根据提示词做决策。Runtime则是驱动整个循环的引擎它负责维护对话状态State调用 Planner 获取决策执行 Tool并将结果反馈回去直到任务完成或达到终止条件。这个设计的核心思想是“单向数据流”和“明确的状态变更”。整个运行过程可以看作一个状态机初始化状态 - Planner决策 - 执行Action - 更新状态 - 判断终止 - 循环。状态State是一个不可变的数据结构记录了当前的目标、历史消息、工具执行结果等。每一次循环都会基于旧状态生成一个新状态。这样做的好处是状态变化清晰可追溯非常利于调试和实现“回滚”、“重试”等高级功能。在技术选型上我选择了 TypeScript/Node.js 环境。原因有几个一是 JS/TS 生态繁荣无论是调用 LLM APIOpenAI, Anthropic, 本地模型还是集成各种 Web 工具Fetch, 浏览器自动化都非常方便。二是异步编程模型async/await天生适合这种需要大量等待网络 IO调用 LLM、工具的场景。三是它足够轻量启动快适合快速迭代和作为微服务部署。我们不涉及复杂的计算密集型任务Node.js 的事件驱动模型完全够用。注意这个设计是“模型无关”的。Planner 的核心是调用 LLM但具体是 GPT-4、Claude 还是本地部署的 Llama只是一个配置项。这为我们后续切换或对比不同模型的能力留下了空间。2.1 定义核心接口用 TypeScript 类型守住底线写代码的第一步是用 TypeScript 接口Interface把上述核心概念约束下来。这就像建筑图纸能确保后续施工不跑偏。首先是Agent它至少需要名字和系统提示词interface Agent { id: string; // 唯一标识 name: string; // 如 “Research Assistant” systemPrompt: string; // 定义角色和能力的提示词 // 后续可以扩展记忆、知识库等属性 }接着是Tool这是扩展 Agent 能力的关键。每个工具必须清晰定义它能做什么、需要什么参数interface Tool { name: string; // 工具名如 “web_search” description: string; // 给 LLM 看的工具描述 parameters: Recordstring, { // 输入参数定义 type: string | number | boolean; description: string; required?: boolean; }; execute: (args: any) Promiseany; // 执行函数 }然后是Planner它的输入是当前状态和可用工具列表输出是一个决策interface Plan { reasoning?: string; // 模型的思考过程如果支持 action: tool_call | final_answer; toolName?: string; // 如果 action 是 tool_call toolArgs?: any; // 调用工具的参数 response?: string; // 如果 action 是 final_answer } interface Planner { plan(state: State, tools: Tool[]): PromisePlan; }最后是State和Runtime的核心循环逻辑。State记录了运行时的所有上下文interface State { agentId: string; goal: string; // 初始目标 messages: Array{ // 对话历史 role: user | assistant | tool; content: string; toolCallId?: string; // 关联的工具调用 ID }; toolResults: Array{ // 工具执行结果历史 toolName: string; args: any; result: any; success: boolean; }; isFinished: boolean; finalAnswer?: string; }Runtime的接口则定义了如何驱动一个 Agent 执行任务interface Runtime { run(agent: Agent, goal: string, tools?: Tool[]): PromiseState; }把这些接口定义好整个项目的骨架就立起来了。它们就像宪法后续的所有实现都必须遵守。这样做最大的好处是强制清晰任何模糊地带都会在编译阶段被 TypeScript 揪出来。比如如果你新增了一个工具但忘了在parameters里定义某个必要参数类型检查就会报错避免了运行时才发现参数不对的尴尬。3. 实现第一步打造一个“听话”的 LLM 规划器Planner有了设计图接下来就要实现最核心的“大脑”——Planner。我的第一个版本打算实现一个基于 OpenAI GPT 系列模型的 Planner。它不搞什么花哨的复杂规划算法就老老实实地让 LLM 根据当前对话历史和可用工具列表决定下一步做什么。这里的关键在于提示词工程。我们需要给 LLM 一套清晰的指令让它扮演好“规划者”的角色。提示词通常由系统指令System Instruction和用户消息User Message组成。系统指令是固定的定义了规划器的角色和能力范围用户消息则包含了当前的状态信息目标、对话历史、可用工具。我设计的系统提示词大致如下你是一个任务规划引擎。你的唯一职责是分析当前对话状态和可用工具决定下一步行动。 你有两种行动选择 1. 调用工具如果你认为需要调用某个工具来获取信息以推进任务请以特定格式回复。 2. 直接回答如果你认为已有足够信息回答用户问题请直接给出最终答案。 可用工具列表 {tools_list} 请严格按照以下 JSON 格式输出你的决策 { reasoning: 你的思考过程解释为什么做出这个选择, action: tool_call 或 final_answer, toolName: 如果 action 是 tool_call这里填工具名, toolArgs: {arg1: value1, ...}, // 如果 action 是 tool_call这里填参数对象 response: 如果 action 是 final_answer这里填你的回答 }用户消息则会把当前的goal和最近的几条messages填充进去。这里有一个细节我们不能把全部历史消息都塞给 LLM因为上下文长度有限。一个常见的策略是只保留最近 N 轮交互或者使用更复杂的“摘要记忆”技术。在第一个版本中我采用简单策略只传入初始目标和最近 3 条消息。实现这个 Planner 的代码如下import OpenAI from openai; export class OpenAIPlanner implements Planner { private client: OpenAI; private model: string; constructor(apiKey: string, model: string gpt-3.5-turbo) { this.client new OpenAI({ apiKey }); this.model model; } async plan(state: State, tools: Tool[]): PromisePlan { const systemPrompt this.buildSystemPrompt(tools); const userMessage this.buildUserMessage(state); const response await this.client.chat.completions.create({ model: this.model, messages: [ { role: system, content: systemPrompt }, { role: user, content: userMessage } ], temperature: 0.1, // 低随机性保证决策稳定 response_format: { type: json_object } // 强制 JSON 输出 }); const content response.choices[0]?.message?.content; if (!content) { throw new Error(Planner did not return content.); } try { const plan: Plan JSON.parse(content); // 简单的验证 if (![tool_call, final_answer].includes(plan.action)) { throw new Error(Invalid action: ${plan.action}); } if (plan.action tool_call (!plan.toolName || !plan.toolArgs)) { throw new Error(Tool call requires toolName and toolArgs.); } return plan; } catch (error) { console.error(Failed to parse planner response:, content); throw new Error(Planner output is not valid JSON: ${error.message}); } } private buildSystemPrompt(tools: Tool[]): string { const toolsList tools.map(t - ${t.name}: ${t.description}. Args: ${JSON.stringify(t.parameters)} ).join(\n); // 返回上面定义的系统提示词这里省略具体字符串拼接 return ...${toolsList}...; } private buildUserMessage(state: State): string { const recentMessages state.messages.slice(-3); // 简单截取最近3条 return Goal: ${state.goal}\nRecent conversation:\n${recentMessages.map(m ${m.role}: ${m.content}).join(\n)}; } }实操心得在让 LLM 输出结构化数据如 JSON时一定要使用模型的response_format参数如果支持或在提示词中强烈要求。同时在代码中必须对返回的 JSON 做有效性校验并准备好解析失败的 fallback 策略。我见过太多因为 LLM 偶尔“放飞自我”输出了一段非 JSON 文本而导致整个流程崩溃的例子。4. 构建运行时引擎让 Agent 真正“跑”起来Planner 能思考了Tool 也准备好了现在需要把它们组装起来形成一个可以自动运转的循环。这就是 Runtime 引擎的工作。它的run方法是实现状态机循环的地方。这个循环的逻辑并不复杂但细节决定成败初始化状态基于传入的 Agent 和 Goal创建初始 State。循环开始当state.isFinished为 false 时继续循环。调用规划器将当前 state 和 tools 传给 Planner获取一个 Plan。执行决策如果 Plan.action 是tool_call找到对应的 Tool用 Plan.toolArgs 调用其execute方法。将工具调用和结果作为消息添加到 state 中。如果 Plan.action 是final_answer将 Plan.response 设为最终答案并标记任务完成 (isFinished true)。更新状态基于执行结果生成一个新的 State 对象遵循不可变原则。安全检查为了避免死循环必须设置最大循环次数比如 20 次。达到上限后强制终止。以下是第一版 Runtime 的核心实现export class SimpleRuntime implements Runtime { private maxSteps: number; constructor(maxSteps: number 20) { this.maxSteps maxSteps; } async run(agent: Agent, goal: string, tools: Tool[] []): PromiseState { // 1. 初始化状态 let state: State { agentId: agent.id, goal, messages: [ { role: user, content: goal } ], toolResults: [], isFinished: false, }; const planner new OpenAIPlanner(process.env.OPENAI_API_KEY!); // 简化实际应注入 let step 0; // 2. 主循环 while (!state.isFinished step this.maxSteps) { step; console.log([Step ${step}] Planning...); try { // 3. 获取规划 const plan await planner.plan(state, tools); // 4. 执行决策 if (plan.action tool_call plan.toolName plan.toolArgs) { console.log( - Calling tool: ${plan.toolName}, plan.toolArgs); // 4a. 执行工具调用 const tool tools.find(t t.name plan.toolName); if (!tool) { throw new Error(Tool not found: ${plan.toolName}); } // 将工具调用记录为 assistant 消息模拟 AI 决定调用工具 state.messages.push({ role: assistant, content: I will use the ${plan.toolName} tool., toolCallId: call_${step} }); let toolResult; let success true; try { toolResult await tool.execute(plan.toolArgs); } catch (error) { toolResult Tool execution failed: ${error.message}; success false; } // 记录工具结果 state.toolResults.push({ toolName: plan.toolName, args: plan.toolArgs, result: toolResult, success }); // 将工具结果记录为 tool 消息 state.messages.push({ role: tool, content: JSON.stringify(toolResult), toolCallId: call_${step} }); } else if (plan.action final_answer plan.response) { // 4b. 输出最终答案 console.log( - Final answer reached.); state.messages.push({ role: assistant, content: plan.response }); state.finalAnswer plan.response; state.isFinished true; } else { throw new Error(Invalid plan received: ${JSON.stringify(plan)}); } } catch (error) { console.error(Error at step ${step}:, error); // 在状态中记录错误并可能终止循环或尝试恢复 state.messages.push({ role: assistant, content: An error occurred: ${error.message}. I cannot proceed. }); state.isFinished true; // 出错则终止 state.finalAnswer Task failed due to an error: ${error.message}; } } if (step this.maxSteps !state.isFinished) { state.messages.push({ role: system, content: Task terminated after reaching maximum steps (${this.maxSteps}). }); state.isFinished true; state.finalAnswer The agent could not complete the task within the allowed steps.; } return state; } }这个SimpleRuntime已经具备了最基础的自动规划与执行能力。你可以创建一个 Agent给它几个 Tool比如一个计算器工具一个获取天气的模拟工具然后让它去解决“北京和上海的气温加起来是多少度”这样的问题。它会先规划调用“获取天气”工具两次拿到数据后再规划调用“计算器”工具进行相加最后给出答案。注意事项在这个简单循环中我直接把 Planner 的实例化写在了run方法里。在实际项目中这应该通过依赖注入Dependency Injection的方式传入这样能方便地替换不同的 Planner比如换成 Claude 的或者本地模型的也便于单元测试。这是第一个值得改进的设计点。5. 踩坑实录与进阶思考从“能跑”到“好用”第一个可运行的版本出来后我迫不及待地跑了几个测试用例。结果嘛自然是踩了一路的坑。我把这些典型问题和解决方案记录下来这可能是比代码本身更有价值的部分。坑一LLM 的“格式叛逆”与 JSON 解析失败尽管在提示词和 API 参数中都要求返回 JSON但 LLM 偶尔还是会在 JSON 前后加上解释性的文字比如 “Here is my plan in JSON format: {...}”。这会导致JSON.parse直接报错。解决方案不能完全信任 LLM 的输出。需要在解析前做一层预处理。我写了一个简单的正则表达式来提取第一个完整的 JSON 对象function extractJson(str: string): string | null { const jsonMatch str.match(/\{[\s\S]*\}/); return jsonMatch ? jsonMatch[0] : null; } // 在 plan 方法中使用 const jsonString extractJson(content); if (!jsonString) { /* fallback 处理 */ } const plan: Plan JSON.parse(jsonString);更健壮的做法是使用专门的 JSON 修复库或者切换到支持严格结构化输出的模型/API如 OpenAI 的 JSON Mode或 Anthropic 的 Tool Use 功能。坑二工具参数类型不匹配LLM 输出的toolArgs永远是字符串类型因为文本交互但我们的 Tool 定义里参数可能是number或boolean。直接传给tool.execute会导致类型错误。解决方案在调用工具前需要根据 Tool 定义中的parameters对toolArgs进行类型转换。我实现了一个简单的转换函数function castArgsToSchema(args: any, schema: Tool[parameters]): any { const casted: any {}; for (const [key, def] of Object.entries(schema)) { if (args[key] ! undefined) { if (def.type number) { casted[key] Number(args[key]); if (isNaN(casted[key])) throw new Error(Parameter ${key} must be a number.); } else if (def.type boolean) { casted[key] String(args[key]).toLowerCase() true; } else { casted[key] String(args[key]); } } else if (def.required) { throw new Error(Missing required parameter: ${key}); } } return casted; }这确保了工具接收到的参数类型是正确的。坑三无限循环与无效规划Agent 有时会陷入死循环比如反复调用同一个工具却得不到进展或者规划出不合逻辑的动作序列比如在已经得到答案后仍调用工具。解决方案除了设置最大步数这个“硬保险”外还需要更智能的终止判断。我引入了两个机制状态去重检查最近几次的state是否高度相似例如工具调用历史和最近消息相同。如果陷入短循环则主动终止。Planner 反馈强化在系统提示词中明确加入对无效行为的警告例如“避免重复调用已证明无效的工具。如果连续两次工具调用未能获得新信息请尝试其他方法或直接给出当前已知的最佳答案。”坑四上下文管理混乱随着对话轮数增加全部历史消息都塞给 Planner 会很快耗尽上下文窗口而且无关的历史信息会干扰决策。解决方案实现一个StateCompressor组件。它的职责是在每次调用 Planner 前对state.messages进行压缩。初级版本可以简单截断只保留最近 N 条。进阶版本可以实现“摘要记忆”即用一个更小的 LLM 将冗长的历史对话总结成一段精炼的摘要然后将摘要和最近几条消息一起传给 Planner。这能极大扩展 Agent 处理长对话的能力。坑五错误处理与状态回滚工具执行可能失败网络错误、API 限流Planner 也可能出错。简单的try-catch记录错误并终止体验很糟糕。解决方案设计一个更优雅的错误处理策略。例如当工具执行失败时可以将错误信息作为tool角色的消息加入历史然后让 Planner 重新规划。Planner 看到“上次调用失败了”可能会选择重试、换一个工具或者向用户报告错误。这需要 Runtime 能处理“非致命错误”并继续循环。通过解决这些坑我对 Agent 运行时的复杂性有了更深的认识。它不是一个简单的while循环而是一个需要精心设计状态管理、错误恢复和资源限制的控制系统。下一步我将围绕这些痛点进行重构和增强比如引入依赖注入容器来管理 Planner 和 Tools实现一个可插拔的中间件系统用于日志、监控、状态压缩等并设计一个更强大的状态管理模块来支持更复杂的 Agent 能力比如长期记忆和知识库检索。这些内容将是本系列下一篇笔记的重点。