ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Agent记忆革新:用Neo4j构建GraphRAG实现结构化长期记忆的完整实践

Agent记忆革新:用Neo4j构建GraphRAG实现结构化长期记忆的完整实践

做AI Agent应用时,我最深的体会是:大部分Agent不是死在模型能力上,而是死在"记忆"上。多轮对话塞满上下文窗口之后,Agent开始胡言乱语;换一个会话,它又装作完全不认识你。早期的RAG方案靠向量检索缓解了一部分,但当问题涉及多跳推理——"A公司收购了B公司,B公司之前的CTO现在去了哪"——纯向量检索就彻底抓瞎了。这就是我转向GraphRAG的出发点,而Neo4j是这套方案里最关键的那块拼图。

这篇文章我会完整记录我用Neo4j搭建GraphRAG、把知识图谱变成Agent长期记忆的完整过程,包括架构设计、核心代码、以及在生产环境里踩过的那些坑。如果你正准备给Agent做"结构化记忆",或者已经在用向量检索但觉得效果到了天花板,这篇文章应该能给你一条可以落地的路径。

1. 为什么Agent需要GraphRAG:纯向量检索的体验上限

先说一个我在实际跑业务场景时遇到的对比。同样一批文档,我用ChromaDB做了一套纯向量检索的RAG,后来又用Neo4j做了GraphRAG,两个系统回答同一个问题:"2019年我们和哪家公司签过独家代理协议?后续三年的合作关系发生了什么变化?"

向量RAG的表现是:找到了包含"独家代理"和"2019"的文本块,但回答很零散——它不知道协议涉及的双方是谁,不知道"合作关系变化"分散在多份不同年份的文档里,更无法把这几件事串联成一条时间线。GraphRAG的表现则完全不同:实体"独家代理协议"、两家公司、签署时间、后续的续签和终止事件都以节点和关系的形式存在图里,Agent做一次图遍历就能把整条完整链路拎出来。

1.1 向量检索的两个天然盲区

第一,向量检索擅长"找相似",但找不了"找关联"。向量把一段文本压缩成一个语义向量,邻居意味着语义相近。可现实中大量问题依赖的是"关系路径":这个人和那家公司什么关系、这个产品依赖哪些组件、这笔交易经过了哪些审批节点。这些信息在文本里往往是分散的,靠向量相似度根本拉不出完整链路。

第二,向量检索没有多跳推理能力。所谓多跳,就是回答一个问题需要经过多个逻辑步骤。比如"谁在收购了A公司的B公司里负责C业务",你需要先确定B收购了A,再确定C业务线属于哪个部门,再找到该部门的负责人。这个推理过程每一步都在"跳"到下一个实体,纯向量检索即使Top-K取值调得很大,返回的也只是一堆碎片化的文本块,而不是一条连续的推理链。

1.2 GraphRAG到底解决了什么问题

GraphRAG的完整工作流是:用LLM从非结构化文本里抽取实体、关系、属性,构造成一张知识图谱存入图数据库;回答问题时,先从问题中识别种子实体,再在图里做遍历扩展,拿到一个以种子实体为中心的子图,把子图的节点和边拼接成上下文喂给LLM生成答案。

这个方案的威力在于三点:一是查询范围可控——图遍历是精确检索,不会像Top-K那样把不相干的内容硬塞进来;二是天然支持多跳推理——关系路径就是推理路径,在图上写一次遍历就能把需要"想三步"的问题变成一次查询;三是上下文密度更高——实体和关系的密度远高于原始文本块,同样的Token预算可以塞进多得多的有效信息。我实测下来,同等条件下GraphRAG的回答准确率比纯向量RAG高出一截,尤其是涉及实体关联关系的问题。

2. 为什么选Neo4j:原生图数据库和它的实战优势

并不是所有图存储都能舒服地支撑GraphRAG。我一开始用NetworkX纯内存图做原型,文档一多,内存直接吃紧;排重、查询、持久化都得自己写,完全不是生产级方案。转向Neo4j之后,才真正感觉到"图数据库"这四个字落在实处。

2.1 Neo4j在GraphRAG链路里的四个核心价值

第一是Cypher查询语言。这是Neo4j的看家本领,一段声明式的图查询只要几行就能表达跨多跳的关系路径。相比自己在代码里写遍历逻辑,Cypher的匹配效率高得多,而且查询计划还能被Neo4j缓存优化。

第二是原生图存储和索引机制。Neo4j的底层存储结构是按节点和关系的邻接表设计的,遍历邻居节点不需要像关系型数据库那样做昂贵的JOIN操作,一跳一跳扩散时性能非常稳。这个特性在Agent回答需要3跳以上关系路径的问题时尤其关键。

第三是生态组件。APOC提供了大量图算法和工具函数,比如apoc.periodic.iterate可以做批量写入、apoc.text.clean可以做实体名标准化。如果要跑PageRank、社区发现之类的图算法做"图谱总结",Neo4j也直接内置了GDS库。这些在构建和维护知识图谱时能省下大量自己造轮子的时间。

第四是可视化能力。这不是锦上添花。我在调试实体抽取效果时,直接在Neo4j Browser里看节点和关系的分布,哪个实体被抽成孤点、哪两个实体被错误地关联,一眼就能发现。调试体验对迭代速度的影响比你想象的大得多。

2.2 和纯向量数据库的选型边界

我见过不少人问:"ChromaDB加一列metadata不也能存关系吗?"这个想法在数据量小的时候确实够用,但metadata本质上还是扁平标签,无法表达"任意两节点间可变的、带类型的、多跳可遍历的"关系。当数据结构从列表变成图,查询模式从"过滤"变成"遍历"时,就必须用真正的图数据库。

我也对比过JanusGraph这类分布式图数据库。它们强在超大规模数据和团队协作,但对于一个单体项目来说,部署复杂度直接劝退。Neo4j的定位是社区版单机也能跑得很舒服,数据量在千万级节点以下时完全够用,这是它适合做Agent记忆层的原因之一。

3. 环境准备:Neo4j的安装、版本选型和连接配置

看到热搜里有"neo4j 3.5 哪里可以下载"这种问题,我得先提个醒:做GraphRAG别再用3.5了。3.5是相当老的版本,不支持向量索引,很多APOC函数也缺失,GraphRAG链路里最重要的混合检索能力它给不了。我建议直接用Neo4j 5.x版本,最好是5.13以上,向量索引和全文索引功能都比较完善。

3.1 Docker Compose部署方案

我用的是Docker Compose,这是目前最省心的部署方式。关键是要挂载插件目录,否则后面装APOC会折腾死人。

services: neo4j: image: neo4j:5.19.0-community container_name: neo4j-graphrag ports: - "7474:7474" - "7687:7687" environment: - NEO4J_AUTH=neo4j/YourPassword123 - NEO4J_PLUGINS=["apoc"] - NEO4J_dbms_security_procedures_unrestricted=apoc.* volumes: - ./neo4j/data:/data - ./neo4j/logs:/logs - ./neo4j/import:/var/lib/neo4j/import - ./neo4j/plugins:/plugins restart: unless-stopped healthcheck: test: ["CMD", "neo4j", "status"] interval: 10s timeout: 5s retries: 5

这里有两个容易被忽略的点。第一,NEO4J_PLUGINS=["apoc"]这个环境变量在官方镜像里内置了插件安装机制,但我实际用下来,它会从网络下载插件,网络不稳时经常失败,所以我更推荐手动下载APOC的jar包放进plugins目录。第二,dbms_security_procedures_unrestricted一定要配,否则APOC里很多管理类函数会被安全策略拦掉,报错信息还特别隐晦。

3.2 Python驱动的连接管理

连接Neo4j用官方驱动neo4j,不是py2neo。py2neo虽然上手快,但维护状态不佳,异步支持和连接池管理都不如官方驱动。我在项目里用异步驱动:

from neo4j import AsyncGraphDatabase class Neo4jConnection: def __init__(self, uri, user, password, database="neo4j"): self.driver = AsyncGraphDatabase.driver( uri, auth=(user, password), connection_timeout=30, max_connection_lifetime=3600, max_connection_pool_size=50 ) self.database = database async def close(self): await self.driver.close() async def query(self, cypher, params=None): async with self.driver.session(database=self.database) as session: result = await session.run(cypher, params or {}) return [record.data() async for record in result]

关于连接池参数,我多说一句。max_connection_pool_size的默认值是100,但如果你是在FastAPI这类异步框架里用,每个请求都会去连接池取连接,值太小会排队,值太大又会占满数据库的连接资源。我一般设在50左右,配合后面的查询缓存,压测下来吞吐表现不错。

4. 从文档到知识图谱:实体抽取、关系构建与批量写入

装好Neo4j之后,最核心的一步来了:怎么把一堆文档变成图谱。这一步的质量直接决定后续检索效果,而且它是最需要反复调优的环节。

4.1 文本分块:图谱质量的第一道门槛

实体抽取不是拿整个文档丢给LLM,而是先分块。分块策略和做向量RAG时完全不同——向量RAG追求块内有独立语义,GraphRAG则追求块内实体关系密度高、且有足够的上下文锚点。

我的做法是:先用Markdown标题结构做第一层切分,把文档拆成章节级的大块;然后按段落继续切,尽量让每个块在500到800字之间。块太小时,实体间关系会断掉,LLM抽不出有用的边;块太大时,LLM容易漏抽实体,而且JSON输出容易截断。我试过用LangChain的各种Splitter,最终发现还是RecursiveCharacterTextSplitter配合自定义分隔符列表最稳。

分块之后我会做一步预处理:把文本里明显重复的段落、页眉页脚、版权信息清洗掉。这些噪音文本如果进了抽取管线,会产生一批垃圾实体和关系,污染图谱。这一步看似简单,实际对最终效果的影响权重极高。

4.2 实体抽取Prompt的设计思路

实体抽取的本质是让LLM扮演"知识图谱构建器",从非结构化文本中结构化输出实体和关系。我最初用的是通用Prompt,结果抽出来的实体五花八门:同一个公司名有时候带"股份有限公司"后缀,有时候不带;"AI"和"人工智能"被抽成两个完全独立的节点。这个问题的根源在于LLM不知道你的图谱schema长什么样。

解决办法是给LLM一个明确的schema约束。我现在的Prompt结构是这样设计的:

你是一个知识图谱构建助手。请从给定的文本中抽取出符合以下schema的实体和关系,并以JSON格式输出。 实体类型: - Person: 人名,包括个人的职位、头衔属性 - Organization: 公司、机构、团队 - Product: 产品或服务 - Technology: 技术名词 - Concept: 抽象概念 关系类型: - WORKS_AT: Person -> Organization - DEVELOPS: Person/Organization -> Product/Technology - USES: Person/Organization -> Technology - PARTNER_WITH: Organization -> Organization - ACQUIRED: Organization -> Organization - MENTIONS: 实体对实体的泛化提及 输出格式要求: { "entities": [ {"name": "实体名", "type": "实体类型", "properties": {"属性名": "属性值"}} ], "relations": [ {"source": "源实体名", "target": "目标实体名", "type": "关系类型"} ] } 处理规则: 1. 实体名必须是原文中出现的名称,不要翻译、不要改写 2. 如果同一个实体出现多次,只输出一个节点 3. 关系必须有文本依据,不要推断原文没有明确表述的关系 4. 属性只抽取文本中明确提到的信息

省流版经验:Prompt里最有效的是"实体名必须是原文中出现的名称"和"关系必须有文本依据"这两条规则。它们直接压掉了LLM最爱犯的"自由发挥"毛病,让图谱的忠实度大幅提升。

4.3 MERGE写入:去重是图谱生产线的命脉

实体抽取完,下一步就是写入Neo4j。我强烈建议写入操作全部用MERGE而不是CREATE。MERGE在Neo4j里的语义是"存在就匹配,不存在才创建",配合唯一约束,可以从源头避免同一个实体被重复建节点。

先建约束(约束能显著提升MERGE的匹配速度):

CREATE CONSTRAINT entity_name_unique IF NOT EXISTS FOR (n:Entity) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (n:Person) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT org_name_unique IF NOT EXISTS FOR (n:Organization) REQUIRE n.name IS UNIQUE;

再写每个块的写入逻辑。我习惯把每个块单独封装成一个异步任务,便于批量调度:

async def write_chunk_to_graph(driver, chunk, entities, relations): async with driver.session() as session: for entity in entities: await session.run( """ MERGE (e:Entity {name: $name}) SET e.type = $type SET e += $properties """, name=entity["name"], type=entity["type"], properties=entity.get("properties", {}) ) for rel in relations: await session.run( """ MATCH (s:Entity {name: $source}) MATCH (t:Entity {name: $target}) MERGE (s)-[r:RELATED {type: $rel_type}]->(t) """, source=rel["source"], target=rel["target"], rel_type=rel["type"] )

这里要注意一个细节:MERGE (s)-[r:RELATED {type: $rel_type}]->(t)中的{type: ...}是关系上的属性,也是MERGE去重的依据。如果不加这个属性,同一个关系类型不同的语义就会被合并,产生"假关系"。

4.4 大批量写入的提速方案

文档一多,逐条MERGE会慢到让人怀疑人生。我处理过一批约3000个文本块的项目,第一次用上面的逐条写入跑了将近两个小时,优化后压缩到15分钟内完成,关键是用了两个手段。

第一个手段是UNWIND批量提交。把一次会话内的多条写入聚合成一个批次,在Cypher层面用UNWIND展开:

UNWIND $batch AS row MERGE (e:Entity {name: row.name}) SET e.type = row.type

一次塞200到500条左右,既不会让单个事务过大,又能显著减少网络往返和事务提交次数。

第二个手段是APOC的apoc.periodic.iterate。它是Neo4j里做大批量处理的利器,可以把一个大查询分成多个批次执行,避免事务过大导致内存溢出:

CALL apoc.periodic.iterate( 'MATCH (s:Entity)-[r:RELATED]->(t:Entity) WHERE r.embedding IS NULL RETURN s, r, t', 'SET r.type = s.type + "_" + t.type', {batchSize: 500, parallel: true} )

对执行超时的任务,还能给iterate加batchSize限制和parallel参数。这也是为什么我在环境准备时强调必须装APOC——没有它,很多批量操作你就得自己写应用层循环,效率差了一个量级。

4.5 实体对齐:不要指望LLM一次就抽对

实体抽取结果一定要做后处理对齐,这是我从实践中得到的最大教训。同一个实体的多种写法在LLM输出里几乎必然出现,比如"OpenAI"和"OPENAI"、"Apple"和"Apple Inc."。我的做法是三层清洗:

第一层是大小写和空格标准化,name.strip().lower()后用apoc.text.capitalize统一格式。第二层是后缀剥离,把"有限公司""股份有限公司""Inc."这类公司后缀单独提取为属性,实体名保留主体部分。第三层是别名映射表,比如维护一个"实体名 -> 标准名"的字典,在写入前做一次把别名替换为标准名的转换。

这一套做下来,图谱的节点数可能反而变少,但每个节点的关系密度大幅提升,后续检索时匹配到的实体质量完全不一样。宁可节点少而精,不要多而杂。

5. Agent的"图谱工具箱":混合检索与工具编排

知识图谱建好了,接下来就是把它封装成Agent可以调用的工具。这里的核心设计思想是:不要让Agent直接写Cypher,而是给Agent一组精心设计过的"图谱检索函数"。让LLM写Cypher看起来炫酷,实际在生产环境里非常不可控——生成了语法错误、查出了超大数据集、匹配了错误的关系类型都是常见问题。

5.1 检索层的三种基础能力

我封装了三个基础工具,分别应对不同类型的Agent问题。

第一个是实体定位工具。给定用户的自然语言问题,先用NER提取问题里的关键实体名,再到图谱里做模糊匹配,找到种子节点。这个工具是整个检索链路的第一步,所有后续扩展都以它返回的种子节点为起点。

第二个是子图扩展工具。给定一个种子节点和跳数(通常限制在2跳以内,超过3跳上下文会爆炸),返回以该节点为中心、指定路径长度的子图。这是整套系统最核心的工具:

async def subgraph_extract(driver, entity_name: str, max_hops: int = 2) -> list: cypher = """ MATCH (start:Entity {name: $name}) CALL { WITH start MATCH path = (start)-[r*1..{max_hops}]-(neighbor:Entity) RETURN path, r, neighbor LIMIT 200 } RETURN path """ records = await driver.query( cypher, {"name": entity_name, "max_hops": max_hops} ) # 后续把records里的路径转换为子图结构

我实测的经验是:这个函数一定要加LIMIT,并且跳数要写死最大3跳。没有LIMIT时,一个高连接度节点的2跳邻居可以扩张到上万个,生成的上下文直接超出模型输入上限。

第三个是全文检索工具。Neo4j 5.x内置了全文索引,可以在图谱里做关键词级别的搜索,找到那些实体名称可能与用户问题不完全一致的节点。

CALL db.index.fulltext.queryNodes('entity_name_index', '组织股份关系', {limit: 10}) YIELD node, score RETURN node.name AS name, score

全文索引的本质价值是兜底——当NER识别出的实体名和图谱里的标准化实体名对不上时,全文搜索可以作为最后一道"模糊匹配"防线。

5.2 混合检索的编排策略

在实际的Agent流程里,我不会只调用一个工具,而是把三种工具串联成一条流水线:先做实体提炼,拿到种子实体集合;再用子图扩展工具拉出每个种子实体的局部子图;最后把多个子图合并、去重、裁剪,控制上下文规模。

对扩展出来的子图,我会按"关联强度"排序——关系类型出现次数越多、两个节点间路径数越多,排序越靠前。这个排序规则不复杂,但效果很好,它本质上是把图结构信息转化为文本上下文的关键性信号。

所有检索结果最终都会被格式化成统一的文本格式喂给Agent:

实体: 某某科技有限公司 类型: Organization 相关实体: 某某资本(投资方, 2020年5月) 相关实体: 张三(法定代表人, 2020年至今) ...

我把这段格式化逻辑单独放到了工具函数内部,Agent拿到的是已经"组装好"的文本上下文,而不是原始图数据。这样能保证无论检索结果长什么样,进入Prompt的数据结构都是稳定的,模型回答的稳定性也随之提高。

5.3 在LangGraph里的Agent编排

我用LangGraph来编排Agent,因为它对"带状态的多步流程"和"工具分支"的支持非常清晰。核心思路是让Agent自主决定调用哪些图谱工具,并允许它在必要时多次调用:

from langchain.tools import tool from langgraph.graph import StateGraph, END @tool def graph_search_fallback(query: str) -> str: """全文搜图谱,用于实体定位和兜底检索""" # 调Neo4j全文索引 @tool def expand_subgraph(entity_name: str, hops: int = 2) -> str: """扩展指定实体的子图""" # 调子图扩展 def agent_router(state): # 根据state里的意图分类,决定调用哪个工具 return {"next_action": state["intent"]} workflow = StateGraph(AgentState) workflow.add_node("entity_extract", entity_extract_node) workflow.add_node("graph_search", graph_search_node) workflow.add_node("subgraph_expand", subgraph_expand_node) workflow.add_node("answer_generate", answer_generate_node) workflow.add_edge("entity_extract", "graph_search") workflow.add_conditional_edges("graph_search", agent_router, { "expand": "subgraph_expand", "generate": "answer_generate" })

实测下来这个结构很扎实:每个节点做单一职责的事,图谱检索和答案生成解耦,方便单独替换或升级某个环节。热搜里提到的"基于rust语言ai agent",我也关注过,Rust生态确实在性能和并发上有优势,但当前Agent编排这块还是Python系(LangGraph、LlamaIndex)的生态更成熟,组件也更丰富。我的建议是:只要不是并发量到了必须上Rust的程度,优先用Python把业务逻辑跑通,性能问题靠架构优化解决比换语言来得快。

6. 生产环境的坑:从并发僵死到版本陷阱

跑通Demo只是第一步,真正让GraphRAG支撑起生产流量,是一连串踩坑和修复的过程。我把最典型的几个问题列出来,每一个都是我付过时间成本换来的教训。

6.1 每次请求都new Driver的严重教训

这是并发压测时遇到的第一个坑。最初图省事,我在FastAPI的每个请求处理函数里都手动创建了一次AsyncGraphDatabase.driver()。结果压测到第300个并发请求时,Neo4j服务直接僵死,客户端报出一堆"Unable to acquire connection from pool"错误。

问题根源在于每一个Driver实例都会创建自己的连接池。请求量一大,内存里堆了几百个连接,数据库连接数直接被打满。正确做法是:Driver是重量级对象,整个进程生命周期内只初始化一次。我在系统里做了一个模块级单例:

driver = None def get_driver(): global driver if driver is None: driver = AsyncGraphDatabase.driver(...) return driver

这个改动做完,压测从300并发直接扛到2000并发,Neo4j端连接数始终稳定在连接池上限之内。

6.2 全文索引的创建时机

另一个隐蔽的坑是全文索引的创建时机。我在写入数据之前先建了索引,然后又用MERGE往图谱里批量塞节点。看起来没问题,但全文索引在大量写入时会有索引更新延迟,导致刚写入的节点无法被全文检索立即查到。如果Agent问的是一个刚入库的问题,就可能匹配不到东西。

后来我把索引创建放到了批量写入完成之后,并加了db.awaitIndexes()等待索引状态变成ONLINE再对外提供服务。这一步让数据的一致性和可用性都有了保证。其实这就是生产系统和玩具Demo之间的一道分水岭:索引状态必须可观测、可等待。

6.3 Neo4j版本相关的"考古"问题

热搜里"neo4j 3.5 哪里可以下载"这类关键词让我想起一个踩坑场景:有些老项目用了3.5,后来想接GraphRAG却发现向量索引完全没有——Neo4j的向量索引是5.x才支持的能力。还有一个常见问题是APOC版本不匹配,4.x和5.x的APOC是完全不同的发布线,装错版本会导致一堆存储过程不可用。

我的建议很直接:新项目一律上5.x,最好是5.19以上的社区版。社区版的功能对GraphRAG来说完全够用,完全没有必要一开始就上企业版。如果迁移老数据,用neo4j-admin database dump和load做整库迁移,别试图在旧版本上打补丁,性价比太低。

6.4 Cypher查询超时的调优思路

有时候子图扩展查询会跑很久,尤其是高连接度节点(比如某个大公司节点关联了几百个其他节点)的2跳扩展。这其实是图谱结构的天然特征:幂律分布——少数节点连接了绝大多数的边。对这种"明星节点",2跳扩展的规模是爆炸性的。

我的解法是在Cypher里加两层限制:第一层是路径长度和关系类型双重过滤,把不可能相关的关系类型直接排除;第二层是加LIMIT和子查询内部提前终止,在1跳时就筛掉得分最低的分支。单条查询控制在2秒内是我给自己定的硬指标,超过就砍范围,绝不硬扛。

7. 并发优化与知识入库的工程化方向

最后聊一下如何让这套系统真正扛住生产流量,以及知识图谱如何持续演进。毕竟GraphRAG的价值不只是"搭建时的那一刻",而是它能否在长期运行中保持新鲜和准确。

7.1 三层缓存策略

第一层是entity_name的规范化缓存。每次NER识别出的实体名在查库前都要做一次标准化和去重,这个操作在瓶颈期会是高频调用。我用Redis做了个"原始名 -> 标准实体名"的映射缓存,命中率大约在60%到70%,能省掉大量的查询。

第二层是子图查询结果缓存。对相同实体加相同跳数的子图扩展,结果在一个小时内几乎不会变化。我把子图序列化后的文本做了带TTL的缓存,以实体名为key,TTL设一小时。当用户接连问同一家公司的不同问题,第二个及后续请求都直接走缓存,Agent响应时间从2秒级压到300毫秒级。

第三层是embedding向量缓存。如果检索链路里用了向量索引,embedding计算是昂贵开销。我对每个文档块做了内容的hash,相同内容的块直接复用embedding结果,也避免了反复调用API产生费用。

7.2 入库流水线的异步化

知识入库不能做成同步流程。我把它拆成了两个阶段:离线构建和在线增量。

离线构建阶段处理的是历史文档:读文档、分块、LLM抽取、批量写库,全部走Celery异步任务队列。在线增量阶段处理的是Agent运行过程中新产生的知识。比如用户问了一个问题、Agent给了一个答案,这个问答对里如果能抽出稳定的实体关系,就把它增量MERGE进图谱。这套"对话建图"机制让图谱具备了自生长的能力,越用越聪明,才是Agent记忆的真正含义。

我建议增量入库的触发条件要严格,只抽取置信度高的实体关系,宁缺毋滥,避免对话噪音把图谱搞脏。

7.3 FastAPI接入层的并发模型

整套系统我用FastAPI承载,异步配合WebSocket做流式输出。这里最需要注意的是:绝对不要在异步路由里做阻塞操作。有一次我把driver.query写成同步版本仍正常工作,但抛出的问题就是event loop被阻塞,Agent的流式响应出现明显卡顿。后来全部改成async/await,FastAPI的异步性能才真正发挥出来。

我在uvicorn启动参数里设置了--workers 4,但Neo4j驱动是进程内的连接池,每个worker都会建自己的池,连接上限要统筹考虑。测试时我调整max_connection_pool_size为12,4个worker加起来48个连接,配合数据库端能承受的连接数,整体吞吐依然稳健。

7.4 从ChatGPT式问答到主动知识服务的延伸

GraphRAG建好的图谱,用途远不止回答问题。这半年我把它延伸成了两个东西:一是"知识话题聚类"——用GDS库的社区发现算法,找出图谱里隐式存在但没人直接提过的话题群组;二是"关系异常检测"——例如两个本应独立的供应商之间突然出现了新的关系路径,系统可以自动生成预警。这些都是图谱一旦建成就自动具备的能力,Agent只是把这个能力包装成"主动服务"而不是"被动问答"。

回到最初的问题:Agent到底需要什么样的记忆?我的答案是,向量存储给它的是"感觉记忆",而图数据库给它的是"结构化记忆"。前者负责快速联想,后者负责把联想组织成有逻辑的推理链。对任何想认真做Agent长期记忆的人来说,Neo4j这条路值得投入。

最后分享一个实操细节:如果你是从零开始搭这套系统,我建议先用100篇左右的小规模文档跑通全流程,把实体抽取Prompt、子图扩展的LIMIT阀值、缓存TTL这些参数全部调顺,再扩展到全量数据。不要一开始就追求大图谱——图谱的质量是在小规模迭代中积累出来的,而不是靠批量堆数据堆出来的。

返回列表