公司动态

TypeScript Agent框架设计:图记忆与工具调用的工程实践

📅 2026/8/30 22:33:54
TypeScript Agent框架设计:图记忆与工具调用的工程实践
在实际的 TypeScript Agent 项目中最容易被低估的不是提示词本身而是模型调用、工具执行、记忆读写和外部系统集成这四个环节如何稳定地串成一个循环。OneRingAI v1 是一个以 TypeScript 为核心语言、以 graph memory图记忆为记忆形态的 Agent 框架它把“模型决定下一步做什么、工具负责执行、图记忆负责保存实体和关系”作为运行主线适合用来构建多步骤任务型助手、企业内部信息查询 Agent 和需要跨多次会话保持上下文的自动化流程。这篇文章围绕 OneRingAI v1 的设计思路拆开一个 TypeScript Agent 框架的各个组成部分并用可运行的示例代码演示如何实现类似的 Agent 循环、集成机制和图记忆仓储。读完以后你能理解这类框架的模块边界在哪里也能在自己的项目里亲手搭出一个最小版本。1. 先理解 Agent 框架的四个核心环节1.1 模型调用只是起点真正的难点是循环控制单个 LLM 调用本质上是一个“文本进、文本出”的接口。用户传入 prompt模型返回一个回复这次交互就结束了。但在 Agent 场景里模型往往不具备直接执行能力它不能自己去查数据库不能调用你公司的内部 API也不能把上一步查询结果记住。真正让 Agent 区别于普通聊天机器人的是模型、工具、记忆、外部环境之间的循环。这个循环可以简化成四步模型接收用户目标和当前上下文生成下一步动作意图。框架解析意图决定是直接回复用户还是调用某个工具。框架执行工具把执行结果作为观察observation返回给模型。模型结合观察结果继续推理直到认为任务完成。代码层面这就是一个while循环。OneRingAI v1 在设计上遵循同样的结构只是在每个环节上都做了可替换的抽象。下面这段伪代码能直观展示 Agent 循环的最小骨架while (!taskFinished) { const action await model.decide(messages); if (action.type finalAnswer) { taskFinished true; return action.content; } const observation await toolRegistry.execute(action); messages.push({ role: tool, content: observation }); }关键点在于模型永远不直接执行代码它只输出意图。是框架负责把意图翻译成真实的函数调用和 API 请求。这也解释了为什么工具描述、参数校验、错误处理在 Agent 框架里如此重要因为它们直接决定了模型输出的意图能否被安全、准确地执行。1.2 TypeScript 为什么适合写 Agent 框架Agent 框架的复杂度不在模型端而在工具端和记忆端。工具会有输入参数、返回结构、超时时间、错误码记忆会有节点、关系、属性、时间戳。这些结构如果全部用无类型 JavaScript 维护项目一旦超过几个工具就会失控。TypeScript 的价值主要体现在三个地方工具输入输出可以被静态类型约束。模型返回的 JSON 会被解析成具体类型工具执行前能校验必填字段。内部模块之间的接口清晰。ModelClient、ToolRegistry、MemoryStore 各自有明确的类型签名替换实现类时不会影响其他模块。对 JSON Schema 和结构化输出的支持更顺。主流模型服务普遍支持 JSON 输出TypeScript 类型可以在编译期和运行期双向对齐。经常有人问 TypeScript 和 JavaScript 在 Agent 开发里的差别。简单说JavaScript 适合快速写脚本TypeScript 适合写框架。如果你要写一个只有几十行的调用脚本用 JavaScript 没问题但如果要维护工具注册表、图记忆仓储、内置集成包静态类型能帮你避免大量低级错误。还要注意 TypeScript 的const断言在配置类场景里很有用。比如定义一个工具类别表时export const ToolCategory { READONLY: readonly, WRITE: write, } as const; export type ToolCategory typeof ToolCategory[keyof typeof ToolCategory];这样写其他模块引用ToolCategory.WRITE时能获得字面量类型而不是string避免把拼错的字符串传进工具注册流程。1.3 OneRingAI v1 的组件划分OneRingAI v1 在架构上把职责拆成了几个核心模块。下面的表格可以用于理解每个组件在 Agent 循环中的位置组件职责典型实现方式ModelClient封装模型调用接口支持流式和非流式调用 OpenAI 兼容接口或本地模型服务ToolRegistry注册、发现、执行工具维护一个Mapstring, ToolMemoryStore保存实体、关系、历史摘要图数据库或内存邻接表AgentLoop编排模型、工具、记忆的执行顺序负责循环终止条件和异常处理IntegrationPack对接第三方系统把外部 API 封装成标准 Tool模块划分的核心原则是单向依赖AgentLoop 依赖 ModelClient、ToolRegistry、MemoryStore但三个底层模块之间尽量不互相依赖。这样替换记忆后端或者新增一个集成工具时不需要改动整个循环逻辑。2. 图记忆为什么 Agent 需要保存实体和关系2.1 从向量记忆到图记忆聊天机器人常用的记忆方案是向量数据库。把历史对话切块、向量化检索时做相似度匹配。这种方式对“用户上次问了什么”这类问题很有效因为可以按语义相似度把相关片段捞出来。但向量记忆有一个明显短板它不擅长表达关系。比如“张三负责 A 项目的后端李四负责 A 项目的前端A 项目下周上线”。如果用户问“张三下周要配合哪些人”向量检索可能只能捞回包含“张三”的片段却不能直接回答张三和谁存在协作关系。因为这种问题需要的是一张关系网而不是一段相似文本。图记忆解决的就是这个问题。它把实体存成节点把实体间的关系存成边。上面例子会得到三个节点张三、李四、A 项目以及两条边张三-参与-A 项目李四-参与-A 项目。回答“张三下周要配合哪些人”只需要从张三节点出发找到他参与的项目再找参与同一项目的其他人。OneRingAI v1 把图记忆作为一等公民意思是记忆不是对话历史的大杂烩而是经过结构化的知识库。模型在推理时可以从图里抽取当前任务相关的子图把它转换成 prompt 里的一段上下文。2.2 用邻接表理解图记忆的最小结构图记忆的底层存储可以是图数据库但在学习阶段用邻接表就能把概念讲清楚。邻接表的核心是节点集合和边集合export interface GraphNode { id: string; type: string; properties: Recordstring, unknown; } export interface GraphEdge { id: string; from: string; to: string; relation: string; properties: Recordstring, unknown; } export class MemoryGraph { private nodes new Mapstring, GraphNode(); private edges new Mapstring, GraphEdge(); private adjacency new Mapstring, string[](); addNode(node: GraphNode): void { this.nodes.set(node.id, node); if (!this.adjacency.has(node.id)) { this.adjacency.set(node.id, []); } } addEdge(edge: GraphEdge): void { this.edges.set(edge.id, edge); this.adjacency.get(edge.from)?.push(edge.to); this.adjacency.get(edge.to)?.push(edge.from); } getNeighbors(nodeId: string): GraphNode[] { const ids this.adjacency.get(nodeId) ?? []; return ids.map((id) this.nodes.get(id)).filter(Boolean) as GraphNode[]; } }这里给边加了relation字段因为不同关系在 Agent 推理中的意义完全不同。“张三-参与-A 项目”和“张三-负责-A 项目”虽然都连接张三和 A 项目但后续处理方式不同。生产环境建议使用真正的图数据库例如 Neo4j 或 Apache AGE因为大规模图查询需要索引和遍历优化纯内存邻接表只适合验证概念。2.3 图记忆的写入与读取时机图记忆不宜每次都全量写入也不宜在每次请求时全量加载。写入时机和读取时机需要单独设计。写入时机通常有三类对话结束时从本轮对话里抽取关键实体和关系。工具返回结构化数据后比如数据库查询结果、订单信息、人员信息。用户显式提到“记住这个配置”“以后都按这个来”时。读取时机通常在两个位置模型生成回复之前检索当前任务相关的子图注入 prompt。工具执行之前用来补全参数。比如用户说“查一下李四的进度”图记忆里已经有李四对应的项目 ID工具参数就能自动带上。这里很容易犯的错是把整张图都塞进 prompt。图一旦变大token 消耗和模型注意力都会被拖垮。正确做法是只抽取两跳以内的子图再加一层最新对话摘要。注意图记忆的价值不是“存得越多越好”而是“在需要的时候能找到准确关系”。写入前想清楚这段关系后续是否会被复用比盲目抽取所有实体更重要。3. 准备环境与项目基础结构3.1 运行环境要求在开始写代码之前先把环境对齐。OneRingAI v1 这类 TypeScript 项目通常要求以下环境依赖建议版本说明Node.js20 及以上高版本对 ES Modules 和 fetch 的支持更完整TypeScript5.5 及以上使用新语法和最新类型能力npm / pnpmnpm 10 或 pnpm 9推荐 pnpm依赖安装更快模型服务OpenAI 兼容接口或本地模型可以通过环境变量配置地址可以用下面的命令检查本机状态node -v npm -v tsc -v如果tsc没有安装先执行项目的依赖安装npm install -D typescript3.2 tsconfig.json 配置要点TypeScript 配置对 Agent 项目的开发体验影响很大。这里给出一份适合 Node.js 服务端项目的配置{ compilerOptions: { target: ES2022, module: NodeNext, moduleResolution: NodeNext, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, outDir: dist, rootDir: src, declaration: true, resolveJsonModule: true }, include: [src] }这里有两个容易被忽略的点。第一moduleResolution使用NodeNext后模块导入路径要符合 Node.js 的 ESM 规则局部导入通常需要带.js后缀。第二不要在 tsconfig 里轻易设置baseUrl。TypeScript 7.0 中baseUrl选项已经被标记弃用并会停止运行。如果你只是想在paths里使用别名可以直接在paths中使用相对于tsconfig.json的路径而不依赖baseUrl。{ compilerOptions: { paths: { /*: [./src/*] } } }项目里的 LLM 地址、API Key 属于运行时配置和 TypeScript 的baseUrl不是一回事。运行时配置建议通过环境变量注入不要写死在代码里。3.3 项目目录结构一个可扩展的目录结构可以这样组织one-ring-ai/ ├─ src/ │ ├─ agent/ │ │ ├─ loop.ts │ │ └─ types.ts │ ├─ llm/ │ │ └─ client.ts │ ├─ memory/ │ │ ├─ graph.ts │ │ └─ store.ts │ ├─ tools/ │ │ ├─ registry.ts │ │ ├─ weather.ts │ │ └─ http.ts │ ├─ integrations/ │ │ └─ internal-api.ts │ └─ index.ts ├─ .env.example ├─ package.json └─ tsconfig.jsonagent目录放编排逻辑llm目录放模型客户端memory目录放图记忆实现tools目录放通用工具integrations目录放和具体业务系统相关的集成。这样分层的意义在于通用能力与业务能力隔离换掉模型客户端不会影响工具层。4. 实现最小可运行的 Agent 循环4.1 定义消息与工具类型先定义所有模块共享的类型。消息类型参考主流模型服务的格式// src/agent/types.ts export type Role system | user | assistant | tool; export interface Message { role: Role; content: string; toolCallId?: string; name?: string; } export interface ToolParameterSchema { type: string; properties: Recordstring, unknown; required?: string[]; } export interface Tool { name: string; description: string; parameters: ToolParameterSchema; execute(args: Recordstring, unknown): Promisestring; }Message里的toolCallId和name是为工具调用设计的。模型在生成“调用某个工具”的意图时一般会返回一个调用 ID框架执行完成后要用同一个 ID 把结果回传给模型模型才能把结果和之前的调用对应上。4.2 模型客户端封装模型客户端只需要暴露两个能力生成回复、识别工具调用意图。这里使用一个通用示例// src/llm/client.ts export interface ModelRequest { messages: Message[]; tools?: Tool[]; } export interface ToolCall { id: string; name: string; arguments: Recordstring, unknown; } export interface ModelResult { content?: string; toolCalls?: ToolCall[]; } export interface LlmClient { complete(req: ModelRequest): PromiseModelResult; }具体实现需要对接实际模型服务。常见的做法是通过 OpenAI 兼容的 SDK把baseURL指向本地或内部服务import OpenAI from openai; export class OpenAiLlmClient implements LlmClient { private client: OpenAI; constructor() { this.client new OpenAI({ baseURL: process.env.LLM_BASE_URL, apiKey: process.env.LLM_API_KEY, }); } async complete(req: ModelRequest): PromiseModelResult { const params: OpenAI.Chat.Completions.ChatCompletionCreateParams { model: process.env.LLM_MODEL ?? gpt-4o-mini, messages: req.messages.map((m) ({ role: m.role, content: m.content, })), tools: req.tools?.map((t) ({ type: function, function: { name: t.name, description: t.description, parameters: t.parameters, }, })), tool_choice: auto, }; const resp await this.client.chat.completions.create(params); const choice resp.choices[0]?.message; const toolCalls choice?.tool_calls?.map((tc) ({ id: tc.id, name: tc.function.name ?? , arguments: JSON.parse(tc.function.arguments || {}), })); return { content: choice?.content ?? undefined, toolCalls, }; } }这里的tools参数对象是给模型看的它会被序列化成模型能识别的 JSON 结构。模型不是一个代码执行器所以工具描述写得越清楚模型就越不容易传错参数。4.3 工具注册与执行工具注册表的核心是一个Map// src/tools/registry.ts export interface ToolRegistry { register(tool: Tool): void; get(name: string): Tool | undefined; list(): Tool[]; execute(name: string, args: Recordstring, unknown): Promisestring; } export class SimpleToolRegistry implements ToolRegistry { private tools new Mapstring, Tool(); register(tool: Tool): void { this.tools.set(tool.name, tool); } get(name: string): Tool | undefined { return this.tools.get(name); } list(): Tool[] { return [...this.tools.values()]; } async execute(name: string, args: Recordstring, unknown): Promisestring { const tool this.tools.get(name); if (!tool) { throw new Error(Tool not found: ${name}); } return tool.execute(args); } }一个具体的只读工具示例// src/tools/http.ts export const httpGetTool: Tool { name: http_get, description: 向指定 URL 发起 GET 请求并返回响应文本只能访问 allowlist 内的域名。, parameters: { type: object, properties: { url: { type: string, description: 完整的请求地址 }, }, required: [url], }, async execute(args) { const url String(args.url); const allowed [https://api.example.com]; if (!allowed.some((prefix) url.startsWith(prefix))) { return URL 不在允许列表中; } const resp await fetch(url); return resp.text(); }, };这个示例演示了一个非常重要的工程点工具的执行能力需要做边界限制。模型生成的参数来自不可信输入如果直接允许任意 URL工具就变成 SSRF 的入口。生产环境必须对工具的输入做白名单、长度限制和权限校验。4.4 图记忆仓储内存版图记忆仓储的作用是保存节点和关系并支持按实体抽取子图。这里在之前MemoryGraph的基础上增加一个生成 prompt 上下文的方法// src/memory/store.ts export class InMemoryGraphMemory implements MemoryGraph { // ... 前面提到的 addNode、addEdge、getNeighbors 实现 buildContext(entityId: string, maxDepth 2): string { const visited new Setstring(); const lines: string[] []; const walk (id: string, depth: number) { if (depth maxDepth || visited.has(id)) return; visited.add(id); const node this.nodes.get(id); if (node) { lines.push(${node.type}:${node.id} ${JSON.stringify(node.properties)}); } this.getNeighbors(id).forEach((neighbor) { walk(neighbor.id, depth 1); }); }; walk(entityId, 0); return lines.join(\n); } }buildContext把一个实体周边的两跳关系转换成文本后续可以合成到 system prompt 里。这里限制最大深度是防止图规模过大时导致 prompt 爆炸。4.5 串起主循环主循环负责把前面模块串起来。它的逻辑不考虑具体业务只关心“模型说调工具就调模型说给答案就返回”// src/agent/loop.ts export class AgentLoop { constructor( private llm: LlmClient, private tools: ToolRegistry, private memory: InMemoryGraphMemory, ) {} async run(userMessage: string, maxSteps 5): Promisestring { const messages: Message[] [ { role: system, content: this.systemPrompt() }, { role: user, content: userMessage }, ]; for (let step 0; step maxSteps; step) { const result await this.llm.complete({ messages, tools: this.tools.list(), }); if (result.content !result.toolCalls?.length) { this.rememberFromConversation(userMessage, result.content); return result.content; } if (result.toolCalls?.length) { for (const call of result.toolCalls) { try { const output await this.tools.execute(call.name, call.arguments); messages.push({ role: tool, toolCallId: call.id, name: call.name, content: output, }); } catch (err) { messages.push({ role: tool, toolCallId: call.id, name: call.name, content: 工具执行失败: ${err instanceof Error ? err.message : String(err)}, }); } } } } return 达到最大执行步数任务未完成; } private systemPrompt(): string { return 你是 TypeScript Agent 框架中的助手。你可以调用工具获取信息也可以直接回答用户。当前图记忆上下文如下如果有; } private rememberFromConversation(input: string, output: string): void { // 简化场景把关键实体写进内存 const userEntity { id: user: hash(input), type: request, properties: { text: input.slice(0, 100) } }; const answerEntity { id: answer: hash(output), type: answer, properties: { text: output.slice(0, 100) } }; this.memory.addNode(userEntity); this.memory.addNode(answerEntity); this.memory.addEdge({ id: edge:${Date.now()}, from: userEntity.id, to: answerEntity.id, relation: answered_by, properties: { timestamp: Date.now() }, }); } }这里有几个设计决策值得注意。第一工具执行异常不会被吞掉而是作为一条消息回传给模型模型可以据此尝试修改参数或换一种方法。第二maxSteps必须有上限否则模型陷入循环时请求会无限增加。第三记忆写入发生在任务完成之后而不是每次工具调用之后避免在图里写入大量中间状态。5. 集成机制把外部 API 变成 Agent 可调用的工具5.1 工具协议设计OneRingAI v1 的集成机制核心是“万物皆工具”。第三方系统接入前需要完成三件事把外部 API 的认证参数放到环境变量或配置中心。把 API 的输入参数映射为 JSON Schema。把 API 的响应转换成一段适合模型阅读的文本。工具协议设计要特别注意description。这段文字不直接执行而是给模型看的。写清楚“参数含义、单位、边界条件、返回结构”能显著减少模型传参错误。5.2 示例接入一个 HTTP 查询服务假设有一个内部员工服务需要 Token 认证输入员工姓名返回员工所属项目列表// src/integrations/internal-api.ts export const employeeProjectTool: Tool { name: employee_project_query, description: 根据员工姓名查询其参与的项目列表。入参 name 为员工中文姓名。返回 JSON 数组。, parameters: { type: object, properties: { name: { type: string, description: 员工姓名 }, }, required: [name], }, async execute(args) { const res await fetch(${process.env.INTERNAL_API_BASE}/employee/projects, { headers: { Authorization: Bearer ${process.env.INTERNAL_API_TOKEN}, Content-Type: application/json, }, body: JSON.stringify({ name: args.name }), method: POST, }); if (!res.ok) { return 请求失败: HTTP ${res.status}; } const json (await res.json()) as unknown; return JSON.stringify(json); }, };注册方式registry.register(employeeProjectTool);模型看到工具名和描述后会在需要查询员工项目时自动构造{ name: 李四 }这样的参数。框架不需要为每个业务单独写调用逻辑只需要保证协议一致。5.3 集成参数速查表配置项含义注意事项INTERNAL_API_BASE内部 API 地址必须配置在服务端不能暴露给前端INTERNAL_API_TOKEN访问令牌定期轮换避免写进代码仓库ALLOWED_TOOL_DOMAINS工具允许访问的域名白名单防止模型把请求打到内网地址TOOL_TIMEOUT_MS工具执行超时按工具类型区分查询类 5 秒批量类 30 秒MAX_RESPONSE_LENGTH返回模型的文本长度上限防止响应过长撑爆上下文集成时最容易忽略的是超时和重试。外部 API 可能临时不可用工具执行可能耗时长。合理做法是给execute方法包一层超时控制并对失败的幂等请求做有限重试。注意不是所有第三方 API 都适合直接暴露给模型。会修改数据的写操作、需要人工审批的操作应当在工具侧加入确认机制或者在框架层设置“人工放行”的中断点。6. 运行验证从输出日志到图记忆记录6.1 运行一个多步任务把各个模块装配起来写一个简单的入口// src/index.ts import { AgentLoop } from ./agent/loop.js; import { OpenAiLlmClient } from ./llm/client.js; import { SimpleToolRegistry } from ./tools/registry.js; import { httpGetTool } from ./tools/http.js; import { InMemoryGraphMemory } from ./memory/store.js; const llm new OpenAiLlmClient(); const registry new SimpleToolRegistry(); registry.register(httpGetTool); const memory new InMemoryGraphMemory(); const agent new AgentLoop(llm, registry, memory); const answer await agent.run(查询 api.example.com 上的用户列表并总结); console.log(answer);运行npm run build node dist/index.js预期会出现两种情况如果模型认为需要调用http_get日志里会先出现工具调用记录然后才是最终答案如果模型判断可以直接回答最终答案会直接输出。整个过程结束后程序会正常退出不会出现无终止循环。6.2 验证工具调用链路为了确认工具确实被调用可以在执行器里加入日志async execute(name: string, args: Recordstring, unknown): Promisestring { console.log([tool:execute] ${name}, JSON.stringify(args)); const tool this.tools.get(name); if (!tool) { throw new Error(Tool not found: ${name}); } const start Date.now(); try { const result await tool.execute(args); console.log([tool:done] ${name} cost${Date.now() - start}ms); return result; } catch (err) { console.error([tool:error] ${name}, err); throw err; } }验证条件[tool:execute]日志出现说明模型成功产生了工具调用意图。[tool:done] cost...ms出现说明工具真正执行完成。最终答案里包含工具返回的关键信息说明模型正确读取了观察结果。6.3 验证图记忆写入图记忆写入需要额外的检查入口。可以临时写一个打印函数把内存图输出function dumpMemory(memory: InMemoryGraphMemory) { console.log([memory] nodes, memory.listNodes().map((n) n.id)); console.log([memory] edges, memory.listEdges().map((e) ${e.from}-${e.relation}-${e.to})); } dumpMemory(memory);在跑了一次多步任务之后如果记忆写入逻辑生效你会看到至少两个节点和一条边。如果没有任何节点说明rememberFromConversation没有被触发通常是主循环在result.content为空且没有toolCalls时提前退出导致的。7. 常见问题TypeScript 配置、类型报错和依赖兼容7.1 TypeScript 7.0 中 baseUrl 弃用现象项目升级 TypeScript 版本后编译出现提示option baseurl is deprecated and will stop functioning in typescript 7.0。原因TypeScript 7.0 的原生版本中baseUrl被标记弃用。官方的建议是避免在baseUrl基础上拼接路径别名而是直接使用相对于tsconfig.json的paths映射。处理方式{ compilerOptions: { paths: { agent/*: [./src/agent/*], tools/*: [./src/tools/*] } } }然后删除baseUrl字段将之前写成agent/*: [src/agent/*]的路径改成./src/agent/*。这里的坑在于有些工具链依赖baseUrl解析裸模块名删掉后需要同时确认paths的写法是全相对路径。7.2 模型返回的 JSON 解析失败现象模型声明的tool_calls里arguments是 JSON 字符串但JSON.parse偶尔抛异常导致 Agent 循环中断。原因部分模型在生成 JSON 时会加入多余的前缀文本比如反引号、换行、或注释内容并非每次都是严格 JSON。处理方式function safeJsonParse(text: string): Recordstring, unknown { try { return JSON.parse(text); } catch { const cleaned text .replace(/json|/g, ) .replace(/^\s|\s$/g, ); return JSON.parse(cleaned); } }预防建议是在调用模型时开启 JSON 输出模式或者要求模型使用结构化输出参数。代码侧仍然要保留容错因为任何模型服务都不能保证 100% 返回合法 JSON。7.3 工具执行超时和重复副作用现象同一个写操作工具被调用两次产生重复数据。另一个现象是工具调远程接口时长时间没有响应Agent 循环卡住。原因模型在没收到确认响应时可能会重试同一个工具外部接口响应慢时没有超时机制会一直挂起。处理方式给execute方法包一层Promise.race或AbortController超时。对非幂等操作要求工具实现方在参数里带上请求 ID服务端做幂等处理。在 AgentLoop 里记录每个工具调用次数超过阈值时停止继续调用同一个工具。async function withTimeoutT(promise: PromiseT, ms: number): PromiseT { const ctrl new AbortController(); const timer setTimeout(() { ctrl.abort(); }, ms); try { return await Promise.race([ promise, new PromiseT((_, reject) { setTimeout(() reject(new Error(timeout after ${ms}ms)), ms); }), ]); } finally { clearTimeout(timer); } }7.4 图记忆出现重复节点和关系现象每次对话结束都写入节点但用户提到同一个实体时图里出现了多个内容相同的节点子图上下文越来越冗余。原因写入逻辑没有先做实体查找。正确的流程是先检查实体是否已存在存在则更新属性不存在才新增节点。处理方式在addNode前先按实体的业务唯一键查询private findByKey(type: string, key: Recordstring, unknown): GraphNode | undefined { for (const node of this.nodes.values()) { if (node.type ! type) continue; if (Object.entries(key).every(([k, v]) node.properties[k] v)) { return node; } } return undefined; }预防建议是设计图记忆时先约定每个节点类型的唯一键。比如员工的唯一键是员工 ID项目的唯一键是项目编号。这样能避免靠名称去重带来的误判。8. 生产落地建议与扩展方向8.1 学习环境与生产环境的差异本地跑通一个 Agent 循环并不等于它能在生产环境稳定运行。下面这张表对比了学习环境和生产环境的关键差异关注点学习环境生产环境记忆存储内存邻接表图数据库如 Neo4j、Apache AGE模型地址本机环境变量配置中心支持多环境隔离工具权限白名单域名按服务账号最小授权日志console.log结构化日志包含 requestId 和 traceId错误处理直接抛出分类处理支持重试、降级、人工介入监控无工具调用次数、成功率、延迟、token 消耗并发单用户连续调用多会话并发记忆读写加锁或使用事务发布重启进程灰度发布兼容旧版本提示词和工具协议生产环境里图记忆的并发写入尤其需要留意。多个会话同时写同一个项目节点时如果没有事务或唯一约束很容易出现部分更新的问题。图数据库一般支持事务内存实现则需要自己加锁。8.2 可复用的 Agent 发布前检查清单以下是向生产环境发布一个 TypeScript Agent 服务前建议完成的检查项确认tsconfig.json中没有baseUrlpaths全部使用相对路径写法。确认所有密钥通过环境变量或配置中心注入仓库中没有明文 Token。列出所有工具的输入白名单确认模型不能通过工具访问内网地址、读取任意文件或执行任意命令。为每个工具设置超时时间并确认错误信息可以被回传给模型继续修正。为 AgentLoop 设置最大步数上限防止模型陷入无限工具调用。在写操作工具前加入审批或者二次确认机制。确认图记忆写入不会产生重复节点实体唯一键设计明确。对模型返回内容做格式校验尤其是需要 JSON 解析的部分。记录关键调用日志模型请求、工具调用、记忆读写、最终回复。分配独立的服务账号让 Agent 能访问的资源范围最小化。8.3 下一步扩展方向OneRingAI v1 这类框架继续深化的方向通常是四个。第一个方向是可观测性。当 Agent 一次会话调用多个工具、重复多轮时光靠console.log已经不够。可以把每个步骤的模型输出、工具参数、执行耗时、记忆读取结果都写入追踪系统形成完整的执行时间线。第二个方向是中断与人工审批。很多任务并不适合让模型全自动完成比如发送邮件、修改权限、创建订单。可以借鉴 deep agents interrupt 的思路在 Agent 循环里引入“待确认”状态工具调用先进入待执行队列人工确认后才真正执行。第三个方向是自改进机制。self-improving agents 这类探索关注的是 Agent 如何从历史经验中学习。在本框架里可以把每次运行后的成功路径和失败路径写入图记忆后续在用户提出相似任务时让模型优先参考历史成功路径。第四个方向是记忆分层。图记忆并不适合保存所有原始文本更合理的设计是多层记忆短期对话摘要、长期实体关系图、外部知识库。不同的记忆形态对应不同的检索时机和检索方式。站在工程落地角度TypeScript Agent 框架的复杂度从来不在某个单一模块而在模块之间的协议和边界。模型调用、工具集成、图记忆每一个环节都可以独立替换和演进但只有把它们组合成稳定的循环时Agent 才真正具备可用性。对新手来说最有价值的练习不是追逐最新的 Agent 概念而是把本文的最小循环亲手跑通再加上一个自定义工具和一个简单图记忆观察模型如何从调用工具到给出最终回答。这样一轮下来你对 Agent 框架的理解会比只看任何介绍都扎实。