新闻详情

新闻详情

首页 / 资讯中心 / 详情

TypeScript从零搭建极简Agent框架:核心循环、工具与记忆管理

发布时间:2026/9/28 16:06:58来源:尧图网络
TypeScript从零搭建极简Agent框架:核心循环、工具与记忆管理
去年接了一个内部小项目要做一个能自主调研、写报告、调接口的小助手。当时第一反应是找个现成的 Agent 框架省事嘛。但翻了几个框架的文档和源码之后想法反而变了它们把循环逻辑、工具调度、记忆管理都封装成了层层抽象配置项多到让人头皮发麻一旦出了问题你根本分不清是模型的问题、提示词的问题还是框架内部哪一步走了岔路。于是我说服了团队用 TypeScript 从零搭一个极简的 Agent 框架。先说结论这个决定没有让我后悔。核心代码控制在几百行左右整套体系都握在自己手里后面接业务需求时反而比调研过的现成方案更顺手。这篇帖子就聊聊完整的搭建过程包括消息体系、主循环、工具注册、记忆管理和可靠性设计适合对 Agent 原理感兴趣、不想被框架抽象牵着走的人。1. 为什么还要自己搭一个 Agent 框架很多人看过那个经典的 Agent 教程里面提到的 planning、memory、tool use 几个模块听起来都很简单实际一上手就发现现成框架把简单事情弄复杂了。1.1 现成框架的“抽象税”我无意全盘否定现成框架。大团队、多模型生态、想快速出 Demo 时用现成方案确实省事。但在 Agent 这个赛道上我觉得现成框架的抽象层已经有点过于厚重了。Agent 框架本质上要解决的只有四件事模型调用、工具调度、记忆管理和任务编排。可现在很多框架在这四件事外面又包了一层又一层Chain、Graph、Runnable、Executor、AgentExecutor、Toolkit……查文档的时间比写业务代码还长。更难受的是底层逻辑藏得深。我调研时遇到过一个典型场景某个框架从一个版本升到下一个主版本原本的 AgentExecutor 类整个被 Graph 对象取代之前写好的几百行业务逻辑基本无法平移。这种破坏性升级在 Agent 框架里非常常见因为这个领域还远没到标准收敛的阶段。对团队来说这就是实打实的隐形成本。1.2 从零搭框架的本质把“循环”做对其实 Agent 的核心并不复杂就是 ReAct 风格的三段式循环想一下要做什么思考动手调工具行动看一眼结果观察然后带着新信息进入下一轮。这个循环跟人做事的习惯很像——你做饭时也是先看冰箱里有什么搜索再决定做哪道菜规划炒完尝一口观察不合适就再调调味修正。这个循环自己写主流程也许五十行代码就够。剩下的工作是把每个环节的输入输出定义清楚把工具注册做成类型安全的把记忆管理做成可插拔的。这些工作不难但非常值得亲自动手做一遍。1.3 我给自己定的需求清单动手前我写了一份需求清单后面所有设计都围绕它展开模型层可替换。只要后端支持 Chat Completions 风格接口就能通过 adapter 接进来。工具定义与执行分离。工具的 schema 要能推断出 TypeScript 类型运行时还要能用同一份 schema 做参数校验。每个执行步骤可观测、可复现。日志要能完整还原某一轮调用中模型看到了什么、工具返回了什么。记忆策略可插拔。短期记忆、长期记忆、上下文压缩分开实现按场景切换。主循环可控可终止。必须有 maxSteps 安全阀绝不能因为模型抽风导致无限循环。做个简单对比不太严谨但能说明方向差异对比维度现成框架自研极简框架抽象层数量多且深薄且浅一眼看穿调试体验跨七八个文件打断点一个循环看全局版本升级风险高API 频繁变动完全受控改自己代码上手成本要先学框架概念只需要懂 TypeScript生态能力丰富需要自己补齐对一个小团队做内部工具来说看得见摸得着比生态丰富重要得多。2. Agent 骨架把“思考-行动-观察”循环做成类型安全的核心引擎2.1 消息体系设计第一步把所有 Agent 运行过程中会出现的消息类型定义清楚。这是整个框架的地基后面主循环和记忆管理都依赖这些类型。工具调用时assistant 消息是特殊的它除了 content还带一个 tool_calls 数组。工具执行完结果会以role: tool的消息加回对话历史。用 TypeScript 的联合类型来建模非常合适export type Role system | user | assistant | tool; export interface TextMessage { role: Role; content: string; } export interface ToolCallMessage extends TextMessage { role: assistant; tool_calls: ToolCall[]; } export interface ToolResultMessage { role: tool; tool_call_id: string; name: string; content: string; } export type ChatMessage TextMessage | ToolCallMessage | ToolResultMessage; export interface ToolCall { id: string; name: string; arguments: string; // 模型返回的 JSON 字符串需要解析 }这里为什么不干脆用一个宽松的对象因为 Message 的三种形态在后续所有逻辑里都会被分派处理循环里要判断有没有 tool_calls、解析工具结果时要按 tool_call_id 匹配、渲染给模型时要按不同 role 组织格式。联合类型搭配类型收窄分支处理在编译期就被强制覆盖省掉大量字符串判断和防御式写法。2.2 模型层抽象我定义的模型接口尽量贴近 Chat Completions 风格这样无论是 OpenAI、Anthropic 还是本地 vLLM都能靠一个 adapter 接进来export interface ChatResponse { message: ChatMessage; raw?: unknown; } export interface LLMProvider { chat(messages: ChatMessage[]): PromiseChatResponse; }这个接口故意做得非常窄。框架内所有逻辑只依赖chat(messages)这一个方法具体模型返回什么元数据都封装在 adapter 里。这样替换模型时上层业务完全不受影响。这里踩过一个坑不同模型对 tool_calls 的字段名和返回方式并不一致。有的模型一次返回多个 tool_call有的只能返回一个有的模型在返回 tool_calls 的同时 content 是空字符串有的会在 content 里写一段我先查一下再回答的废话。框架内部要把这些差异全部吸收掉上层业务才不会被厂商绑定绑架。顺带一提编译环境如果你已经在用 TypeScript 5.5 以上的版本会发现 tsconfig 里moduleResolution: node10会显示弃用警告并且会在 7.0 被移除。我直接把它改成了bundler配合moduleDetection: force新项目的配置清爽很多。这个细节和 Agent 无关但避免写框架时被编译器告警打断思路。2.3 Agent 主循环 run 方法主循环是整个框架的心脏。忽略日志和统计代码核心逻辑是这样的export interface AgentConfig { model: LLMProvider; tools: Tool[]; systemPrompt: string; maxSteps: number; } export interface RunResult { messages: ChatMessage[]; steps: number; finalAnswer: string; } export async function runAgent( config: AgentConfig, history: ChatMessage[], ): PromiseRunResult { const messages: ChatMessage[] [ { role: system, content: config.systemPrompt }, ...history, ]; for (let step 0; step config.maxSteps; step) { const response await config.model.chat(messages); const message response.message; // 没有工具调用说明模型决定直接回答循环结束 if (!message.tool_calls?.length) { return { messages: [...messages, message], steps: step 1, finalAnswer: message.content || , }; } // 有工具调用把 assistant 消息追加进历史 messages.push(message); // 逐个执行工具把结果以 tool 消息回填 for (const call of message.tool_calls) { const tool config.tools.find((t) t.name call.name); const content tool ? await tool.execute(call.arguments) : 未找到工具: ${call.name}; messages.push({ role: tool, tool_call_id: call.id, name: call.name, content, }); } } // 达到最大步数返回一个明确的终态而不是抛异常 return { messages, steps: config.maxSteps, finalAnswer: 已达到最大步数 ${config.maxSteps}任务未能完成。, }; }循环的核心判断只有一条这次的 assistant 消息里有没有 tool_calls。没有说明模型决定直接给出答案循环结束有就把消息追加进历史逐个执行工具把结果塞回去继续下一轮模型调用。有一个容易忽略的点工具结果一定要按消息顺序回填。有些模型对 tool_call_id 的次序很敏感如果结果顺序乱了下一轮就可能把参数搞混。for...of天然保持顺序避免了这个坑。2.4 终止条件和步数控制maxSteps 是所有 Agent 框架都需要的安全阀。没有这个参数模型可能在一次长任务里循环三十轮token 烧到怀疑人生。这里我特意做了一个设计达到 maxSteps 时不是抛异常而是返回一个未完成的终态让上层决定是让用户接管、还是挂到后台异步重试。因为 Agent 任务经常是半开放的有些任务本来就不可能在一个循环里跑完把未完成当成可处理的业务状态比当成崩溃好得多。另外要提醒的是最终答案的取值。如果模型在返回 tool_calls 的同时 content 为空字符串那最后一条 assistant 消息的 content 就不能直接用。我加了一层兜底如果 content 为空就用倒数第二条非 tool 消息的 content 作为最终答案。3. 工具系统让模型调用的每个函数都在编译期可查3.1 工具定义与运行时校验工具系统的设计直接决定了框架好不好用。我的策略是用 zod 定义参数 schema在工具真正执行之前先做一次safeParse校验。模型传参就算瞎编也不可能直接打进业务函数。import { z } from zod; export interface ToolTSchema extends z.ZodType { name: string; description: string; params: TSchema; execute: (params: z.inferTSchema, ctx: ToolContext) Promisestring; } // 示例搜索工具 const SearchParams z.object({ query: z.string().min(1), limit: z.number().int().min(1).max(10).optional(), }); export const searchTool: Tooltypeof SearchParams { name: web_search, description: 搜索互联网返回网页标题和摘要, params: SearchParams, execute: async ({ query, limit 5 }, ctx) { const results await ctx.searchEngine.search(query, limit); return formatResults(results); }, };为什么用 zod 而不是手写 JSON SchemaAgent 框架的目标是让模型拿到工具描述后自动生成参数所以我们需要的是一个可估值、能校验的 schema。zod 的z.object定义既能推断出 TypeScript 类型又能在运行时用safeParse校验还能方便地转换成模型的 tools 描述格式。传统interface只解决编译期类型运行时直接失效这在工具执行场景里完全不够用。3.2 校验失败后的错误回传机制工具执行错误要作为工具结果、带着对模型友好的消息内容回传到对话里而不是直接 throw。举个例子。模型调 search 工具把 limit 参数传成了字符串2。如果工具直接抛出异常整个 Agent 就跪了。但如果返回一条这样的 tool 消息参数校验失败: limit 应为数字收到 2模型下一轮大概率会自己修正参数再调一次。我在实际项目中遇到过多次模型把枚举值写成近似文本的情况比如状态参数该传active却传了active!。校验错误回传后模型几乎总能自我纠正这比在 prompt 里反复强调枚举格式有用得多。实现上框架在调用工具前统一做一次校验const parsed tool.params.safeParse(JSON.parse(call.arguments)); if (!parsed.success) { const detail parsed.error.issues .map((issue) ${issue.path.join(.)}: ${issue.message}) .join(; ); return 参数校验失败: ${detail}; } const content await tool.execute(parsed.data, ctx);这样一个机制同时解决了两个问题一是保护业务函数不收到畸形参数二是给模型提供了迭代修正的机会。后者常被忽略但其实这才是 Agent 容错的核心竞争力。3.3 工具注册与依赖注入工具函数自然需要存取外部资源比如数据库、搜索服务、缓存。我的做法是把外部依赖收敛到一个ToolContext里通过工具的execute参数传入而不是用闭包隐式共享export interface ToolContext { searchEngine: SearchEngine; db: Database; logger: Logger; requestId: string; }这样有两个好处一是工具之间不会互相踩共享变量二是每个 Agent 实例启动时可以注入不同上下文。比如同一个框架代码A 租户注入 A 的数据库连接B 租户注入 B 的配置工具逻辑完全不用改。工具注册我保持了一个朴素的数组方案不去自创复杂的依赖注入容器。每当模型请求工具时框架用find在数组里匹配名字。几十个工具以内这个方案性能完全没问题代码可读性最好调试起来也直观。4. 记忆与上下文管理从无状态聊天到有状态 Agent4.1 短期记忆的窗口裁剪很多新手容易犯一个错误只按条数裁剪上下文比如只保留最后 10 条消息。但不同消息的长度天差地别——工具返回的搜索摘要可能有几千 token而一问一答可能只有几十 token。按条数裁剪很容易导致 token 超限。正确做法是按 token 预算计算滑动窗口。框架内维护一个预算值比如 8000 token然后从最新的消息往前累计超过预算的部分全部裁掉function trimMessages(messages: ChatMessage[], maxTokens: number) { const trimmed: ChatMessage[] []; let total 0; for (let i messages.length - 1; i 0; i--) { const msg messages[i]; const tokens estimateTokens(msg.content || ); if (total tokens maxTokens) { break; } trimmed.unshift(msg); total tokens; } return trimmed; }裁剪时机也有讲究。不要等模型接口报 400 错误才处理而是定期估算整个 messages 数组的 token 总量在快接近模型上限时提前裁剪。我给自己的实现加了一个 threshold把 contextLimit 的 80% 作为预警线超过预警线就触发裁剪。4.2 上下文压缩策略纯裁剪的问题在于被丢掉的信息可能后续还要用。所以记忆管理要做两档第一档是裁剪过期信息第二档是压缩历史。压缩策略我的实现是调用一个小模型或同一个模型把旧消息压缩成一段 summary插进系统提示词然后把旧消息从上下文移除。压缩操作本身也要作为一条 tool 消息返回给模型告诉它历史已被折叠——否则模型以为之前的搜索结果凭空消失了会一脸疑惑。这里有个很实用的技巧对工具结果做大小限制。比如搜索工具返回的内容如果有 10 万字符token 裁剪做得再好也会被撑爆。我在工具层面对返回内容做了截断默认上限 3000 字符超出部分直接丢弃。这对 Agent 的稳定性和成本控制都有帮助。4.3 长期记忆轻量向量检索的落地方案任务需要持久记忆时可以自建一个极简的向量存储。不用一上来就上重型数据库内存数组加 JSON 文件就够了几万条数据完全跑得动export class SimpleMemory { private items: Array{ text: string; vector: number[]; meta?: Recordstring, unknown; } []; async add(embedding: number[], text: string, meta?: Recordstring, unknown) { this.items.push({ text, vector: embedding, meta }); } search(queryVector: number[], topK 3) { return this.items .map((item) ({ ...item, score: cosineSimilarity(queryVector, item.vector), })) .sort((a, b) b.score - a.score) .slice(0, topK); } }cosineSimilarity 自己写也就是两个向量的点积除以模长几十行代码。嵌入向量可以用模型提供的 embedding 接口统一生成。记忆框架选型这条路上我发现最值得关注的不是工具多强大而是你能否清楚说出各种记忆的读写时机。短期记忆随请求走长期记忆按需检索摘要记忆定期落盘。把这些时机定义好你就是不用专门的记忆框架也能实现一套可用的记忆系统。5. 可靠性工程让 Agent 从能跑变成可交付5.1 结构化输出的容错解析Agent 经常需要模型输出结构化结果比如 JSON。但实际运行中模型偶尔会给出带 Markdown 代码块包裹、带尾随逗号、甚至把思路和 JSON 混在同一个输出里的文本。我的做法是写一个容错解析函数思路不复杂但非常有用先把可能的 Markdown 代码块标记剥掉json ... 。再用正则提取最外层花括号之间的内容。最后 JSON.parse并捕获每一个字段的解析错误。解析失败时把错误信息和原始文本塞回对话让模型重新输出一次。这个让模型自己修正的回路我已经在多个场景里验证过效果比任何规则兜底都好。5.2 重试、超时与降级模型接口经常会不稳定。框架层必须加指数退避重试、超时控制、重试次数上限。我的重试策略是第一次失败等 1 秒重试第二次等 2 秒第三次等 4 秒最多重试三次。超过三次就直接把错误返回给上层业务而不是无限等下去。另外还有一个容易被忽略的降级策略如果模型在连续多次循环里反复请求同一个工具、参数几乎没变化说明它陷入死循环了。我加了一个检测逻辑——同一个工具连续调用超过三次框架自动终止工具调用降级为不使用工具直接回答。这样即便模型卡在某个输出模式里用户也能拿到一个最终结果而不是眼睁睁看着 token 被烧完。5.3 可观测性设计为了排查和复现问题我要求框架把所有请求消息、模型输出、工具执行结果、耗时都记录到结构化日志里。每步组成一行 JSON Lines哪一步出了问题直接把日志灌回一个 debug 脚本里逐帧回放。日志字段大概长这样{ traceId: req_123, step: 1, modelIn: system: ..., toolCalls: web_search, toolResult: 搜到 5 条结果, modelOut: 下一步我需要查数据库..., durationMs: 1234 }多 Agent 协作时每个 Agent 实例带同一个 traceId子 Agent 调用会加上 parentStep 字段关联到父 Agent 的某一次工具调用。这样整个调用链都能在日志里追踪到。追加日志的代价很小收益却很大。有一次生产环境里 Agent 在租户 A 的配置下表现良好在租户 B 却总是选错工具。把两边日志对比之后才发现是租户 B 的系统提示词里工具描述顺序不同模型被前面的长描述带偏了。这种问题如果没有日志根本无从查起。5.4 安全边界工具调用的权限不能裸奔。在框架层我加了白名单机制每个 Agent 实例启动时指定可用工具敏感工具默认不注册。如果模型试图调用未注册工具会拿到一条未找到工具的 tool 消息不会真的执行任何东西。对写操作类工具我引入了 human-in-the-loop先把参数展示给用户确认确认后再真正执行。比如发送邮件这种工具模型生成收件人和正文后框架会先暂停等用户点确认按钮。这个机制是生产环境和 Demo 环境的重大区别一开始就要放进框架后期补会很别扭。6. 从单 Agent 到多 Agent 编排6.1 把 Agent 变成工具单 Agent 是循环。多 Agent 其实就是把另一个 Agent 的run方法包装成一个工具让模型把它当作一个可调用的函数。我实现了一个 delegate 工具export function createDelegateTool( agentConfig: AgentConfig, ctx: ToolContext, ): Toolany { return { name: delegate_task, description: 把子任务交给一个专用子 Agent 处理返回它的最终答案, params: z.object({ task: z.string().describe(给子 Agent 的完整任务描述), constraints: z.string().optional().describe(约束条件), }), execute: async ({ task, constraints }) { const result await runAgent( { ...agentConfig, systemPrompt: ${agentConfig.systemPrompt}\n本次任务${task}, }, [], ); return result.finalAnswer; }, }; }由于工具系统已经统一了接口这一步几乎是零成本接入。模型只要觉得任务太复杂就会自己调用 delegate_task把锅甩给子 Agent。6.2 路由与协作模式在多 Agent 场景里两种模式最常用。第一种是 Supervisor 模式一个路由 Agent 负责拆解任务、分配子 Agent、汇总结果。实现上就是让路由 Agent 持有多个 delegate 工具每个工具指向不同的子 Agent。第二种是并行 Worker 模式父 Agent 一次性发起多个工具调用每个子 Agent 独立处理一个子任务最后父 Agent 合并输出。因为主循环已经支持多 tool_calls这个模式在实现上就是并行的Promise.all调度而已。这里要提醒一个容易踩的坑子 Agent 运行时的消息上下文不能和父 Agent 混用。我遇到过上下文污染导致的幻觉问题——子 Agent 把父 Agent 的历史当成自己的回答问题就牛头不对马嘴。正确的做法是每个子 Agent 启动时只接收自己任务相关的系统提示和输入不接触父 Agent 的完整消息列表。6.3 记忆与状态的传递多 Agent 场景下每个子 Agent 应该有自己的短期记忆共享信息要放进一个只读的共享存储。我的实现里父 Agent 通过参数把共享上下文传给子 Agent 的工具调用子 Agent 只读不写。这样能严格控制信息流避免一个子 Agent 的错误输出污染另一个子 Agent 的判断。如果要在多个 Agent 之间共享长期记忆我倾向把共享记忆做成一个独立工具所有子 Agent 都可以检索和写入。这个工具内部做权限控制哪些 Agent 有写权限、哪些只能读都由上层配置决定。最后分享一点个人体会。这次自研 Agent 框架最大的收获不是代码量而是对 Agent 各个环节的掌控感。第一次跑通主循环只花了两个多小时但真正让它能在生产环境稳定运行靠的是后面整整一周在工具校验、上下文裁剪、错误重试这些不性感的细节上打补丁。这个框架后来支撑了几个内部自动化任务总代码量也就一千多行。如果你也对 Agent 框架感兴趣我的建议是先从最小可跑通的循环开始跑通之后根据业务需求一点点把工具、记忆、安全这几层叠上去。你会发现所谓智能体框架其实没有外面传的那么玄乎。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Codex 插件与 CLI 实战:从安装到 MCP 排错的完整指南 2026/9/28 17:41:51

Codex 插件与 CLI 实战:从安装到 MCP 排错的完整指南

1. 装完不等于会用:Codex 插件落地的真实门槛很多人第一次接触 Codex 插件,心态都差不多:装完就完事了,打开面板,敲几个字,等着它把活干完。结果往往是——要么插件根本没连上后端,要么连上了但…

阅读更多 →
Codex 安装配置保姆级教程:从环境准备到实战避坑 2026/9/28 17:41:51

Codex 安装配置保姆级教程:从环境准备到实战避坑

1. 先搞清楚 Codex 到底是个什么东西1.1 它和 ChatGPT、Work Buddy 的区别在哪很多人第一次听到 Codex 这个名字,会下意识觉得它是不是又一个套壳聊天工具。我刚开始也这么想,直到真正把它跑起来、接进日常开发流程之后才发现,它和普通的对话…

阅读更多 →
STM32 HAL库SPI通信实战:8位与16位数据发送详解及避坑指南 2026/9/28 17:41:38

STM32 HAL库SPI通信实战:8位与16位数据发送详解及避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
deepagents task03 实战:虚拟文件系统、Backend、权限与沙箱全解析 2026/9/28 17:41:38

deepagents task03 实战:虚拟文件系统、Backend、权限与沙箱全解析

1. 从 task03 看 deepagents 的真实能力边界第一次看到 "deepagents in action---task03" 这个标题,我脑子里冒出来的第一个念头是:又是一个把 agent 框架包装成万能药的项目。但真正把 task03 这一环拆开看,会发现它其实踩中了当前…

阅读更多 →
RGMII时序调试实战:从飞腾D2000到RK3568的PHY迁移与CRC排查 2026/9/28 17:41:38

RGMII时序调试实战:从飞腾D2000到RK3568的PHY迁移与CRC排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
Codex插件市场中文使用指南:宿主汉化与插件翻译策略 2026/9/28 17:41:19

Codex插件市场中文使用指南:宿主汉化与插件翻译策略

1. 从“界面全是英文”说起:Codex 插件市场的中文困境到底卡在哪第一次打开 Codex 的插件市场,很多人都会愣一下:左侧是分类导航,右侧是插件卡片,按钮、描述、权限说明清一色英文。对于英文阅读没障碍的人来说这不算事…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉