你如果最近在关注 LLM Agent 生态,大概会注意到一个趋势:Python 仍然是训练和实验的主力,但真正要把 Agent 部署到业务系统里时,越来越多团队开始选择 TypeScript。这种情况下,看到 OneRingAI v1 这个项目出现在 Hacker News 上,很容易让人眼睛一亮。它的定位非常干脆:TypeScript agents with integrations and graph memory。
先给一个判断:这类项目真正值得关注的点,不是“又出了一个新的 Agent 框架”,而是它把一个经常被忽略的问题摆到了台面上——Agent 产品要落地,必须同时解决外部工具接入和长期记忆两个问题。只接工具但没有记忆,Agent 就像失忆的实习生;有记忆但接不上业务系统,它又只是一堆闲聊代码。OneRingAI 把这两件事放在一起做,方向是对的,但工程上能不能站稳,还要看它的记忆抽象、工具协议和类型设计。
这篇文章不会假装自己有一份官方文档然后替你翻译。公开材料有限的情况下,更重要的是把 OneRingAI 背后代表的设计逻辑讲清楚:为什么 TypeScript 适合写 Agent,graph memory 到底解决了什么,integrations 在真实工程里应该怎么设计。然后我会带你实现一个可运行的最小 TypeScript Agent,包含图记忆和工具调用,你可以直接跑起来验证。想要踩坑的人,也能在后面的常见问题部分找到排查方向。
1. 为什么 TypeScript 正在成为 Agent 开发的新选择
1.1 Python 阵营的优势与隐性成本
过去两年,大部分 Agent 项目的起点都是 Python。原因很明显:模型 SDK、向量库、LangChain、LlamaIndex 这些生态最早都在 Python 社区成熟,论文复现和实验脚本也都是 Python 写的。如果你只想快速验证“大模型能不能完成某个任务”,用 Python 写几十行脚本确实是最短路径。
但问题在于,短路径不代表好的工程路径。当一个 Agent 从 Notebook 走进生产环境,它需要面对的不再是“模型会不会答”,而是权限控制、接口协议、日志链路、配置管理、部署方式、团队协作这些琐碎问题。在这些方面,Python 的动态类型带来很大负担。一个 function call 的参数到底有哪些字段,IDE 无法帮你检查,只能靠运行时日志。Agents 的调用链路特别长,模型返回、工具执行、记忆命中、异常重试,每一步都是隐式数据结构,类型一塌糊涂,排查问题就像在断电线里找哪根有问题。
1.2 TypeScript 的真正优势不是类型,而是同构
TypeScript 在 LLM 编程里的优势,经常被简单概括成“有类型”。但更准确地说,它的优势是“端到端类型 + 全栈同构”。
业务系统如果是 Node.js 或前端体系,用 TypeScript 写 Agent,意味着从浏览器端的事件捕获、到后端 API、到 Agent 的 tool 定义、再到数据库访问层,可以共用同一套类型定义。比如一个“查询订单”的工具,前端表单字段和后端查询参数可以同一套 interface 约束。对这种场景,客户端、服务端、Agent 工具三者不必在 JSON 上来回手写转换,TypeScript 的 inference 和 compile-time check 能把集成错误提前拦住一大半。
另外,越来越多的应用运行环境本身就基于 JS 运行时。Electron 桌面端、Edge 插件、Chrome Extension、小程序前端、Node 服务端,都可以直接在已有代码里嵌入一个 TypeScript Agent。Python 在这个链路里通常只能作为独立服务部署,集成成本高出一个数量级。
1.3 什么时候不要用 TypeScript 写 Agent
需要说清楚的是,TypeScript 不是万能选择。如果你的核心任务是研究模型推理、做 prompt 实验、训练小模型,或者依赖 only 存在于 Python 生态的库,那就不要为了统一语言而硬切。TypeScript Agent 更适合的是“把模型能力包进已有软件系统”的场景,而不是“从零研究模型能力”的场景。
一个更稳妥的组合方式是:Python 侧负责离线实验和模型服务,TypeScript 侧负责 Agent 编排、工具调用和记忆管理。很多团队就是这么做的,OpenAI SDK 和各类开放模型服务都支持在 Node.js 里直接调用,并没有想象中那么困难。
2. OneRingAI v1 的定位:三个关键词拆解
2.1 TypeScript agents
把 “TypeScript agents” 单独拿出来看,它要表达的意思其实是:Agent 的构建过程应该是有类型约束的,而不是把所有数据结构都塞进一个宽松的any。
一个 Agent 的典型状态包括:对话历史、工具调用记录、记忆上下文、任务状态、结果缓存。如果每个状态都是一个没有 schema 的 JSON 对象,项目大了以后几乎没有可维护性。TypeScript Agent 的意义就在这里,它强迫你在写主循环之前先定义消息类型、工具返回值类型、记忆节点类型。这些定义本身就是文档,也是协作边界。
2.2 integrations
Agent 如果只会对话,那么它的价值非常有限。真实业务里,用户问“帮我查一下订单物流”,Agent 需要调订单服务;用户问“给客户发一封邮件”,Agent 需要调邮箱;用户问“分析这个仓库的安全风险”,Agent 需要调代码扫描 API。这些都是 integrations。
OneRingAI 把 integrations 作为一级关键词,说明它希望覆盖的不仅是文本对话,而是能操作真实系统的 Agent。这种设计方向是对的,但集成能力要做到“开箱即用”并不容易。不同服务有不同的鉴权方式、限流策略、错误码和数据结构,统一抽象越深,通用性越好,但具体服务的个性能力越容易丢失。这里面的平衡点是判断一个 Agent 框架是否成熟的关键。
2.3 graph memory
记忆是 OneRingAI 的第二个关键词,也是它最值得研究的部分。普通 Agent 记忆通常是一个文本列表或一个向量库,但 graph memory 把记忆描述成节点和边:用户是一个节点,电影是一个节点,“喜欢”是一条边。这种结构的最大好处是支持多跳关联查询。
举个例子:用户说“我喜欢《沙丘》”。如果只存入向量库,再过几天用户问“推荐一部氛围类似的电影”,向量检索大概率能找到“科幻、史诗”这类相似语义。但如果用户问“我上次提到的那位导演的另一部作品”,向量库就很难处理了,因为它不是一个语义相似问题,而是一个关系路径问题:用户 -> 喜欢 -> 电影 -> 导演 -> 另一部作品。只有图结构能高效回答这种问题。
2.4 它和 LangGraph.js、MCP 的关系
很多人看到 “agents + graph memory” 会想到 LangGraph.js。区别在于,LangGraph.js 是一个完整的图编排框架,它的核心是控制流的图:节点是状态转换,边是状态跳转。OneRingAI 从项目名称看,重点更像是“记忆图 + 外部工具”,控制流不一定那么复杂。这其实也是对的方向,因为大量 Agent 任务并不需要复杂状态机,只需要一个可靠的主循环。
同时我也希望它支持 MCP 这类开放式工具协议。如果工具定义能兼容 MCP,那么它就能以极低成本接入社区里越来越多的 MCP Server。如果你正在评估这个项目,建议优先看它的工具抽象有没有走这种开放协议,而不是内置了几个固定的 API 集成。
3. Agent 记忆:从 Buffer 到 Vector,再到 Graph
3.1 没有记忆的 Agent 是什么状态
很多入门项目把大模型 API 封装成一个问答接口,用户每次提问都直接调模型。这种 Agent 的问题非常明显:用户刚说了自己的偏好,下一次提问又忘了。你问它“我老板姓什么”,它会一本正经地告诉你“我不记得我们聊过这个问题”。这种体验在一次性问答场景里可以接受,但任何涉及个性化、多轮任务、长期服务的场景都不能接受。
最简单的记忆实现是滑动窗口,把最近几轮对话拼进上下文字符串。它实现成本低,LLM 能够通过上下文看到刚才的内容。但问题也明显:窗口太短记不住远的事实,窗口太长又会超过模型上下文限制,并且成本成倍增长。
3.2 三种记忆方案对比
| 记忆方案 | 实现成本 | 关联查询能力 | 适合场景 |
|---|---|---|---|
| 无状态 | 最低 | 无 | 一次性问答 |
| 滑动窗口 | 低 | 只能记住最近几轮 | 连续对话、客服助手 |
| 向量检索 | 中 | 按语义相似度召回 | 知识库问答、长文档检索 |
| 图记忆 | 高 | 支持多跳关系查询 | 用户画像、关系推理、跨会话记忆 |
注意,这里的“高”不代表不能做。对于中小型项目,graph memory 可以先用内存中的邻接表实现,等规模上来再换成 Neo4j 或 ApsaraDB 图引擎。关键是先让业务跑通,而不是一开始就引入重组件。
3.3 graph memory 为什么适合 Agent
图记忆的价值可以用一个真实场景说明。假设你在做一个 CRM 助手,用户是销售,他在三天前提过“下个月要拜访华东区客户”。今天他问:“把华东区客户里,上个月和我吃过饭的人都列出来。”这个问题有两个约束条件:地域在华东、关系是上个月吃过饭。如果用向量库,你只能把两个条件拼成一个 query 去检索,召回结果可能不完整。
如果用图记忆,你会预先存储:销售和客户之间有一顿“聚餐”的边,时间属性写着“2025-05”。查询时从销售节点出发,先通过“聚餐”边找到关联客户,再用地域属性过滤,整个过程清晰、可控、可解释。更重要的是,图记忆可以持续增长,每次对话都往里面加节点和边,Agent 的知识就越来越丰富。
3.4 graph memory 的代价
代价也不能回避。图记忆最大的问题不是存储,而是“谁来建图”。如果你让 LLM 从对话里抽取实体和关系,抽取质量会直接影响后续查询。抽错一个实体,可能把完全不相干的两个信息连在一起。此外,图数据也会膨胀,如果不做剪枝和过期处理,几十万节点之后,BFS 查询也会变慢。所以工程上必须在写入阶段做过滤,而不是把每句话都塞进记忆图。
4. 环境准备与最小工程结构
4.1 环境要求
本项目的示例代码不需要 GPU,只需要一个可访问的模型 API。建议环境如下:
- Node.js 18 及以上版本,当前使用 20 或 22 会更稳
- TypeScript 5.x 最新版本
- 一个 OpenAI 兼容的模型服务,直接使用 OpenAI API Key 也可以
- npm 或 pnpm 等任意包管理工具
如果你所在团队已经有内部模型网关,只要它兼容 Chat Completions 协议,同样可以复用下面的代码,只需要把baseURL指向你自己的服务。
4.2 初始化项目
先创建目录并初始化项目:
mkdir ts-agent-graph-memory-demo cd ts-agent-graph-memory-demo npm init -y npm install openai npm install -D typescript tsx @types/nodeopenai是官方 Node SDK,后面生成对话、调用工具都靠它。tsx用来直接运行 TypeScript 文件,避免每次先编译再运行,开发时很高效。
4.3 tsconfig 注意事项
执行npx tsc --init会生成一个默认配置文件,但建议改成下面这样:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "dist", "rootDir": "src", "skipLibCheck": true }, "include": ["src"] }注意到这个配置里没有baseurl。原因是近期 TypeScript 已经明确把baseurl标记为不建议继续使用,未来可能在 TypeScript 7.0 中停止生效。旧项目的tsconfig.json里经常会见到baseurl: "./"配合paths使用,新项目不要再依赖这个写法,尽量用 NodeNext 模块解析或直接使用相对路径。
4.4 依赖安装后的提示
安装完成后建议先看一次package.json内容,确认type: "module"是否设置。如果你希望用 ESM 方式写代码,可以把type加进去:
{ "name": "ts-agent-graph-memory-demo", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "tsx src/index.ts", "build": "tsc", "start": "node dist/index.js" } }如果type不加,代码里又使用import语法,tsx 也能运行,但 Node 直接跑编译产物时可能报错。这里建议统一使用 ESM。
5. 完整示例:用 TypeScript 实现带图记忆的 Agent
这一节我们实现一个最小但可运行的 Agent。它会包含两块:图记忆的存储和查询,以及 Agent 调用 LLM 生成回答的主循环。为了让代码可控,我故意没有引入 LangChain 这类重型框架,你跑通以后可以再对比 OneRingAI 的抽象方式。
5.1 定义 MemoryGraph
文件路径:src/memoryGraph.ts
export interface MemoryNode { id: string; type: string; properties: Record<string, unknown>; } export interface MemoryEdge { source: string; target: string; relation: string; properties?: Record<string, unknown>; } export class MemoryGraph { private nodes = new Map<string, MemoryNode>(); private edges: MemoryEdge[] = []; get size(): number { return this.nodes.size; } addNode(node: MemoryNode): void { this.nodes.set(node.id, node); } addEdge(edge: MemoryEdge): void { if (!this.nodes.has(edge.source) || !this.nodes.has(edge.target)) { throw new Error(`添加边失败:节点 ${edge.source} 或 ${edge.target} 不存在`); } this.edges.push({ ...edge }); } search(keyword: string): MemoryNode[] { const lower = keyword.toLowerCase(); return [...this.nodes.values()].filter((node) => JSON.stringify(node).toLowerCase().includes(lower) ); } neighbors(nodeId: string, maxDepth = 2): Set<string> { const visited = new Set<string>(); if (!this.nodes.has(nodeId)) return visited; const queue: Array<{ nodeId: string; depth: number }> = [{ nodeId, depth: 0 }]; visited.add(nodeId); while (queue.length > 0) { const current = queue.shift()!; if (current.depth >= maxDepth) continue; for (const edge of this.edges) { if (edge.source === current.nodeId && !visited.has(edge.target)) { visited.add(edge.target); queue.push({ nodeId: edge.target, depth: current.depth + 1 }); } if (edge.target === current.nodeId && !visited.has(edge.source)) { visited.add(edge.source); queue.push({ nodeId: edge.source, depth: current.depth + 1 }); } } } return visited; } recall(question: string, maxDepth = 2): string { const matchedNodes = this.search(question); const collected = new Set<string>(); for (const node of matchedNodes) { collected.add(node.id); for (const neighborId of this.neighbors(node.id, maxDepth)) { collected.add(neighborId); } } return [...collected] .map((id) => JSON.stringify(this.nodes.get(id))) .join("\n"); } }这个实现很短,但包含了图记忆的核心:节点存储、边存储、关键词命中、邻接扩展。recall方法先用关键词找到种子节点,再通过 BFS 扩展到最多两层邻居,最终把相关节点拼成上下文文本。这就是“图记忆召回”的最小形态。
5.2 用 LLM 把事实写入图记忆
文件路径:src/agent.ts
光有图结构还不够,还需要把对话中的事实写进图里。最简单的方式是让 LLM 做“知识抽取”,把一句话变成节点和边。
import OpenAI from "openai"; import { MemoryGraph } from "./memoryGraph"; const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY ?? "no-key", }); export async function saveFactToGraph(fact: string, graph: MemoryGraph) { const extraction = await client.chat.completions.create({ model: process.env.OPENAI_MODEL ?? "gpt-4o-mini", response_format: { type: "json_object" }, messages: [ { role: "system", content: "你是知识抽取器。把用户事实转换成 JSON,格式:" + '{"nodes":[{"id":"实体唯一ID","type":"实体类型","properties":{"name":"展示名"}}],"edges":[{"source":"主体ID","target":"客体ID","relation":"关系"}]}。' + "只输出 JSON。" }, { role: "user", content: fact } ] }); const content = extraction.choices[0].message.content ?? "{}"; const parsed = JSON.parse(content) as { nodes?: Array<{ id: string; type: string; properties: Record<string, unknown> }>; edges?: Array<{ source: string; target: string; relation: string }>; }; for (const node of parsed.nodes ?? []) { graph.addNode(node); } for (const edge of parsed.edges ?? []) { graph.addEdge(edge); } return { ok: true, nodes: parsed.nodes ?? [], edges: parsed.edges ?? [] }; }这里用到了response_format: { type: "json_object" },它要求模型按 JSON 输出。使用 JSON mode 时,系统提示词里必须包含“JSON”这个词,所以我在提示词里明确写出了“把用户事实转换成 JSON”。
5.3 Agent 主循环:召回 → 思考 → 工具调用 → 写入
继续编辑src/agent.ts,加入 Agent 主循环和 remember 工具。
const SYSTEM_PROMPT = "你是一个带图记忆的智能助手。请结合“记忆上下文”中给出的用户事实回答问题。" + "如果上下文为空,就正常回答。回答要简洁、直接。"; const tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [ { type: "function", function: { name: "remember", description: "把一条重要事实保存到图记忆,例如用户偏好、人物关系、项目背景。", parameters: { type: "object", properties: { fact: { type: "string", description: "要保存的完整事实描述。" } }, required: ["fact"] } } } ]; export async function runAgentLoop( userInput: string, graph: MemoryGraph ): Promise<string> { const recalled = graph.recall(userInput, 2); const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [ { role: "system", content: `${SYSTEM_PROMPT}\n\n记忆上下文:\n${recalled || "(无)"}` }, { role: "user", content: userInput } ]; for (let step = 0; step < 5; step++) { const response = await client.chat.completions.create({ model: process.env.OPENAI_MODEL ?? "gpt-4o-mini", messages, tools, }); const message = response.choices[0].message; messages.push({ role: "assistant", content: message.content, tool_calls: message.tool_calls as never }); if (!message.tool_calls || message.tool_calls.length === 0) { return message.content ?? ""; } for (const call of message.tool_calls) { if (call.function.name === "remember") { const args = JSON.parse(call.function.arguments || "{}") as { fact?: string }; const saved = await saveFactToGraph(args.fact ?? "", graph); messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(saved) }); } } } throw new Error("Agent 达到最大步数限制"); }这段代码逻辑很简单:每次进入循环,先根据用户输入从图记忆里召回相关实体,再调用模型。如果模型返回tool_calls,说明它想保存新事实,代码就执行saveFactToGraph写入图记忆,然后把结果作为 tool 消息推回给模型。如果模型没有要求调用工具,就直接返回回答。
5.4 入口文件与运行验证
文件路径:src/index.ts
import { MemoryGraph } from "./memoryGraph"; import { runAgentLoop } from "./agent"; async function main() { if (!process.env.OPENAI_API_KEY) { console.error("请先设置 OPENAI_API_KEY 环境变量"); process.exit(1); } const graph = new MemoryGraph(); graph.addNode({ id: "user-1", type: "person", properties: { name: "访客" } }); graph.addNode({ id: "movie-dune", type: "movie", properties: { title: "沙丘", genre: "科幻" } }); graph.addEdge({ source: "user-1", target: "movie-dune", relation: "likes", properties: { time: "今天" } }); console.log("记忆图节点数量:", graph.size); const question = process.argv[2] || "我上次说我喜欢什么电影?请推荐一部氛围类似的。"; const answer = await runAgentLoop(question, graph); console.log("\nAgent 回复:\n" + answer); } main().catch((error) => { console.error(error); process.exit(1); });运行命令:
export OPENAI_API_KEY=你的密钥 export OPENAI_MODEL=gpt-4o-mini npx tsx src/index.ts