拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

带记忆智能体的数据层设计:从实体拆解到pgvector建表落地

带记忆智能体的数据层设计:从实体拆解到pgvector建表落地

智能体项目做到第三天,终于开始动数据层了。JchatMind的定位是一个带长期记忆的个人知识型智能体,不是那种聊完就忘的玩具对话机器人。今天这一整天基本都在做数据模型设计——从实体拆解、字段规划到建表SQL落地,我没有急着写业务接口,而是先把存储层的地基夯清楚。本文就是第三天开发过程的完整记录,给正在做智能体开发、尤其是想自己搭记忆和知识库机制的朋友一个可以直接参考的方案。

这篇文章会覆盖几个实际避不开的问题:一个带记忆的智能体到底需要哪些数据表、消息和会话怎么存才能支撑流式输出、长期记忆的向量化存储怎么做、以及我在建表过程中踩到的几个坑。适合已经跑通大模型API调用、准备往完整应用形态进发的开发者。

1. 为什么第三天就要动数据模型

项目立项那天定了大方向之后,前两天的精力基本都放在了大模型API的连通性测试、流式响应的调试、以及把最简对话链路跑通上面。等到第三天,当我想把临时对话升级成真正的智能体应用时,发现所有功能都卡在同一件事上:数据没法落盘。

1.1 JchatMind到底要做成一个什么样的智能体

JchatMind这个名字拆开看就是Chat加Mind,一个能记住用户、能对用户的资料做知识管理的聊天智能体。它解决的核心问题是:通用大模型用完即忘,每次对话都是一张白纸,用户重复提供背景信息的体验非常糟糕。

所以JchatMind的核心能力我规划成三块:第一,持久化的多轮对话,聊天记录随时可回溯;第二,长期记忆,系统能自动从历史对话中提取用户偏好、关键事实,并在后续对话中主动引用;第三,私有知识库,用户可以上传自己的文档资料,问答时检索相关内容辅助生成。

有了这三个能力目标,数据模型设计的边界就清楚了。我不需要为一个单纯的聊天玩具做复杂设计,但所有支撑"记得住"和"查得到"的数据结构,都必须在这一天里定下来。我给自己定的原则是:业务代码可以后面重构,存储层一旦上线,迁移成本极高,必须在早期想清楚。

1.2 先画数据结构再写业务代码省下的时间

很多开发者的习惯是先写接口、再建表,缺什么字段补什么字段。这种模式在小项目里问题不大,但智能体应用有个特殊性:它涉及的不仅是普通业务数据,还有消息的流式写入、向量检索、上下文摘要这样偏底层的机制。业务代码写到一半发现缺字段,往往要连带改好几层。

我在JchatMind项目上第三天做的第一件事,是画一张实体关系草图。画完之后,后面几天写鉴权、对话接口、记忆抽取、知识库上传这些功能时,基本不用回头动表结构,效率能提升一大截。而且数据模型一旦稳定,前端同学也可以提前并行开发,他知道会话列表会返回什么字段、消息对象的形状长什么样,联调阶段几乎没有返工。

2. 数据实体拆解:一个智能体需要几张核心表

上午花了大半个小时梳理实体,最后落到五张核心域:用户域、对话域、记忆域、知识库域、智能体配置域。每个域拆成若干张表,基本原则是从业务语义上解耦,但尽量少做无意义的过度拆分。

2.1 用户域:别把用户信息全堆在一张表里

很多项目习惯建一张users表把所有字段塞进去,注册时间、积分、偏好、分组全放一起。短期能用,一旦要做多端登录、支付订阅、个性化设置,就开始乱了。

JchatMind的用户域拆成了三张表:

users是注册主体,存账号凭证和基础资料,比如邮箱、密码哈希、显示名称、账号状态。user_settings是用户的个性化配置,比如默认模型、输出语言、记忆开关、检索条数,全部用JSONB存,因为这类偏好字段变动频繁但查询不多,用单独表存JSON比频繁加列更划算。user_agents是用户创建的智能体实例列表,一个用户未来可以按场景创建多个不同性格、不同知识库的智能体,这一层从设计上就为多智能体能力留了口子。

用户域的设计里最关键的决策是:把频繁变化和低频变化的数据分开。频繁变化的偏好设置走JSONB,基础账号信息走固定列结构,这样用户更新偏好时不需要动主表,锁竞争和缓存失效范围都更小。

2.2 对话域:会话、消息、上下文摘要的三角关系

对话域是JchatMind最基础的数据承载。这里的核心关系是三张表:conversations、messages,以及一个容易被忽视的conversation_memories。

conversations与会话列表页对应,存用户ID、标题、所属智能体、状态、创建和更新时间。messages存单轮消息,每一条消息记录所属会话ID、角色、内容、Token数、模型名、生成耗时、状态和父消息ID。

为什么需要父消息ID?因为JchatMind的对话不是简单的一问一答线性排布,未来要支持多分支探索能力——用户可以在某条消息之后重新回复,形成一条新的分支。有了parent_id,查询时可以按分支过滤;不加这个字段,做对话回溯和分支跳转就非常痛苦。

conversation_memories存会话的滚动摘要。长对话的上下文会被自动压缩提炼,摘要本身也是一条有版本的有效数据。这张表我加了version字段,每次压缩生成新版本时递增,之后要做"摘要回滚"或者审计时都能追踪到原始版本。

2.3 记忆域:短期记忆和长期记忆分开存

做记忆机制前必须分清两个概念。短期记忆就是当前会话内的上下文,这是大模型API上下文窗口里的内容,临时容量小、跟随会话走;长期记忆才是真正让智能体"越来越懂用户"的部分。

长期记忆我单独设计了memory_entries表。每条记忆记录包含几个关键字段:用户ID、来源会话ID、记忆类型、内容、向量表达式、重要度评分。记忆类型目前定位三类:用户偏好、关键事实、对话摘要。这样设计的好处是召回时有依据,比如用户问"我是不是喜欢看科幻片",系统优先命中用户偏好类记忆,而不是在一堆对话摘要里模糊搜索。

重要度评分是这套表设计的点睛之笔。每条记忆在写入时会由大模型给出一个0到1的重要度分数,加上access_count和last_reviewed_at两个字段记录访问次数和最后复习时间,系统定期做一次衰减计算,低于阈值的冷记忆进入归档状态。这样长期记忆表不会无限膨胀,检索质量也能保持稳定。

2.4 知识库域:文档、分段、向量三件套

知识库是JchatMind做RAG问答的基础。这个域我拆得比较细,一共三张表:

knowledge_bases存知识库元信息,名称、描述、所属用户、文档数量。knowledge_docs存每次上传的文档,记录文件名、大小、分段数量、解析状态(排队中、解析中、完成、失败),解析是后台异步任务,所以必须有状态管理。knowledge_chunks是核心,每个文档被切分成若干文本块,每条记录存块的顺序索引、正文内容、向量化后的embedding、以及来源文档ID。

初次做知识库的朋友容易犯一个错误:把整个文档做成一条记录,检索时把大段文本塞给模型。标准做法是先切块再向量化,切块策略(分块大小和重叠区间)直接影响检索效果。我在存储层把chunk_index和doc_id作为组合索引,将来做文档更新时可以整体删除旧块再写新块,不会留下孤儿数据。

2.5 智能体配置域:把系统提示词和技能做成可配置项

再往下走是智能体配置域。既然这个项目叫智能体,就不能把系统提示词硬编码在代码里。我设计了agents表,存智能体的名字、头像、系统提示词模板、模型配置、生成参数,以及启用状态。

同时设计了agent_tools关联表,把智能体可调用的外部工具抽成独立的配置记录。比如JchatMind预留了联网搜索、计算器、查询天气这类插件能力,每个工具的启用与否、参数模板、限流策略都独立存储。这样把工具权限做成数据而非代码逻辑,后续想让智能体支持某个新工具,只需要在管理后台加一行配置,不需要重新发布服务。

3. 建表实操:从选型到核心DDL的完整记录

实体拆完,下午进入了真正的建表阶段。这部分我记录了我的选型逻辑和几张核心表的完整设计,方便直接照抄。

3.1 数据库选型为什么是PostgreSQL加pgvector

智能体应用需要同时处理两类数据:普通业务关系数据(用户、会话、消息)和高维向量数据(记忆、知识块)。我的选型标准很简单:稳定可靠、上手成本低、能在一个技术栈里解决两类需求。

在我的实践经验里,MySQL加Redis加一个独立的向量数据库(比如Milvus、Qdrant)组合是能用的,但运维组件的数量翻倍了。JchatMind这种体量的项目,不需要上分布式向量库。我最终选定PostgreSQL,再加官方扩展pgvector解决向量存储和检索。

这套组合最大的优势是统一。向量数据和业务数据在同一套事务机制里,备份、恢复、权限控制都统一管理。写业务逻辑时可以直接用一条SQL同时过滤业务条件加按向量相似度排序,不用在业务库和向量库之间做应用层的扇出合并。数据量在百万级以内,pgvector的HNSW索引性能完全够用,这也是中小型智能体应用的主流选择。

3.2 核心表结构完整设计

先看会话和消息这两张最核心的表。建表SQL大概长这样:

CREATE TABLE conversations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), agent_id UUID REFERENCES agents(id), title VARCHAR(200) DEFAULT '新会话', status SMALLINT NOT NULL DEFAULT 1, context_summary TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_conversations_user_updated ON conversations (user_id, updated_at DESC);

再就是消息表:

CREATE TABLE messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id), parent_id UUID REFERENCES messages(id), role VARCHAR(20) NOT NULL, content JSONB NOT NULL, content_text TEXT, token_count INTEGER, model_name VARCHAR(100), latency_ms INTEGER, status VARCHAR(20) DEFAULT 'completed', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_messages_conversation_time ON messages (conversation_id, created_at);

消息内容我刻意做了双写:content字段用JSONB保留完整的结构化数据(比如工具调用的参数和结果),content_text单独存纯文本。这样做的好处是,渲染聊天记录时直接读content_text,轻量且安全;要做上下文拼接或向量化时也直接拿纯文本,不用每次从JSON里提取。代价是多占一点存储,但消息是追加写为主的数据,这点成本完全可接受。

记忆表和知识块表的设计则是这样:

CREATE TABLE memory_entries ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id), source_conversation_id UUID REFERENCES conversations(id), memory_type VARCHAR(20) NOT NULL, content TEXT NOT NULL, embedding VECTOR(1536), importance_score FLOAT DEFAULT 0.5, access_count INTEGER DEFAULT 0, status SMALLINT DEFAULT 1, last_reviewed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_memory_user_type ON memory_entries (user_id, memory_type, status);
CREATE TABLE knowledge_chunks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), doc_id UUID NOT NULL REFERENCES knowledge_docs(id) ON DELETE CASCADE, chunk_index INTEGER, content TEXT NOT NULL, embedding VECTOR(1536), metadata JSONB );

embedding字段我刚开始都填了1536维,这是按当前选择的Embedding模型的输出维度来的。这里特别提醒一句:字段类型一旦写入就不能随意改维度,所以模型选型必须先定死,后面我会详细说这个坑。

3.3 字段设计里的几个关键决策

建表过程中几个决策我认为直接影响后续开发体验。

第一,主键选择UUID而不是自增整数。自增主键在单机低并发下没问题,但智能体应用的消息写入是高频操作,而且我计划后续可能拆分服务或做多Region部署,一旦涉及多实例生成ID,自增方案会撞车或需要引入发号器。UUID可以全局无协调生成,概率上不会冲突。我用的版本是UUID v7,它带时间有序性,对数据库索引的B树插入更友好,不像纯随机UUID那样导致频繁的页分裂。

第二,时间字段统一使用TIMESTAMPTZ。智能体服务大概率会涉及到跨时区用户,TIMESTAMPTZ存储的是UTC标准时间,应用层按用户时区格式化展示。这个细节我见过不少项目栽跟头,存了本地时间结果跨时区用户看到的时间全错。

第三,业务状态字段用数字小整数。比如status列我不定义成字符串,而是1、2、3对应不同含义,然后在应用层把状态枚举和数字做映射。字符串可读性好,但浪费存储且容易拼写错误;数字语义模糊,但配合代码常量管理,实际项目里更灵活。我在对话、记忆、知识文档三张表里都用了SMALLINT存状态,后续加状态不需要改表结构。

3.4 索引设计:先想清楚查询怎么走

索引设计这块,我主张"从真实查询倒推",而不是把所有字段全建上索引。JchatMind的核心查询有几类:会话列表(按用户和更新时间倒序)、消息列表(按会话和创建时间顺序)、记忆召回(按用户过滤后做向量检索)、知识块向量最近邻查询。

针对这些查询,我建了几个关键索引:会话表加(user_id, updated_at DESC)复合索引,消息表加(conversation_id, created_at)索引,记忆表加(user_id, memory_type)条件索引。向量检索的高效实现是不能用普通B树索引的,必须给embedding列建HNSW近似最近邻索引,建索引的SQL如下:

CREATE INDEX ON memory_entries USING hnsw (embedding vector_cosine_ops);

这里我选择余弦相似度作为向量距离度量,配的是vector_cosine_ops操作符。选余弦而不是欧几里得,是因为我的目标是从语义相似度上匹配文本,余弦相似度对向量的模长不敏感,更适合处理长短不一、表达方式各异的自然语言片段。HNSW索引的参数我只留默认值线上观察,后续召回变慢再调优m值和ef_search值。

4. 消息链路、记忆召回与向量检索的实现设计

表结构定下来只是第一步,数据在实践中怎么流动才更重要。这一部分我说说消息状态、记忆提取、以及一次问答背后完整的数据链路设计。

4.1 流式消息的状态机与Token核算

因为JchatMind的对话响应是流式输出的,所以消息记录不能是"写完才算数",而是一个状态机过程。我定义了消息的几种状态:streaming、completed、failed、cancelled。

流式输出开始前,先插入一条status为streaming的消息记录占位,拿到消息ID返回给前端;输出过程中每收到一个增量,前端按ID做流式累加渲染,后端只做透传不做频繁写库;输出结束后一次性UPDATE把完整内容、Token数和耗时写进去。这样做的好处是避免流式输出过程中频繁写数据库把能压垮性能,坏处是消息表会短暂存在未完成记录,查询业务逻辑需要注意过滤状态。

Token核算这里有个教训:大模型返回的usage字段不是每次都可信。有些厂商接口在流式模式下根本不返回usage,需要自己按文本重新估算。我目前的方案是优先读API返回的usage,读取不到就退回到本地统计函数粗略估算,把估算结果单独存token_count字段,并记录model_name,方便后续精算。

4.2 长期记忆的提取与归档策略

记忆写入的时机和策略,是今天我认为设计得最核心的部分。我规划了一套流程:每轮对话结束后,如果当前会话的消息累计轮数超过10轮或累计Token数超过阈值,就触发一次记忆提取任务。

提取任务调一次大模型,输入是当前会话的消息序列,输出是一段结构化的候选记忆列表,每条带类型标签和重要度。系统把候选记忆先经过一次"查重",拿新候选的embedding与已有记忆做相似度检索,相似度超过0.92就认为是重复信息,不重复写入。这一步非常关键,不加它的话,聊几次天记忆表就会充满大量语义重复的垃圾记录。

记忆归档策略用的是衰减评分。每条记忆的活跃分由三个变量决定:重要度、最近访问时间、访问频率。后台每日任务里统一计算,分数低于阈值的记忆置为归档状态,不再参与默认召回。如果后续用户主动提到相关话题,检索到了归档记忆,可以自动重新激活。这套机制让记忆表始终保持在可控量级,同时兼顾了长期留存价值。

4.3 一次问答背后的完整数据链路

把前面几块串起来,一次完整问答的数据流大概是这样的:用户发消息进来,先写入messages表并标记用户角色;然后系统并行做两件事——从memory_entries召回与当前问题相关的长期记忆,从knowledge_chunks召回关联的知识块;两层结果合并去重后,连同当前会话的最近N条历史消息一起拼装成系统提示词;大模型流式生成期间逐步落盘;对话结束后异步检查是否需要触发新的记忆提取。

这个链路里值得注意的细节是并行召回的先后顺序设计。记忆召回和知识检索互不依赖,所以应该并行执行,网络IO重叠之后整体响应时间能缩短接近一半。而工作流引擎在执行时,建议把普通文本写入和后续的提取任务解耦,用异步队列处理记忆提取,不能让用户等待记忆生成完成才收到响应。

5. 落地过程中踩过的坑与排查实录

这天下午最花时间的不是写建表SQL,而是解决几个实操过程中暴露出来的设计问题。我把它们记录下来,基本都是网上教程不会告诉你的事。

5.1 软删除和唯一索引打架

一开始所有状态型表都设计了软删除,约定deleted_at字段有值时表示记录已删除。问题很快出现:我给知识库配置加了唯一索引(user_id, agent_id, config_key),当用户删除了某条配置再重建时,因为旧记录还物理存在于表里,唯一索引冲突,插入新记录直接报错。

排查起来倒不复杂,但解释了为什么不少人质疑软删除。我的解决方式是设计唯一约束时使用部分索引,只对未删除的记录生效。PostgreSQL里可以用WHERE条件做部分唯一索引,比如:

CREATE UNIQUE INDEX uniq_agent_tool_config ON agent_tools (agent_id, tool_name) WHERE deleted_at IS NULL;

这样既保留了软删除的审计能力,又不会阻挡正常重建。这个坑提醒我:任何表设计里的软删除都不是免费的,它在与唯一约束、外键级联等机制交互时都需要仔细推演。

5.2 会话并发写入导致上下文错乱

测试时发现一个偶现问题:用户在JchatMind里快速连续发送两条消息时,第三条回复的上下文窗口里有时会混入第二条回复的内容,甚至顺序颠倒。检查后发现是消息表的写入没有做会话级别的顺序约束,WebSocket和HTTP两条通道同时写入了同一会话。

定位思路是先在消息表里加了会话维度的自增序号,然后写入逻辑里对同一会话的插入做串行化处理。我的做法是通过数据库行锁解决,在conversations表加一个version字段,更新消息前先比对版本号,冲突就重试。实测下来95%的并发冲突都能在一次重试内解决,也顺便给会话列表的缓存失效提供了版本依据。

5.3 向量索引被embedding维度改变废掉

这个坑是我见过最隐蔽的。一开始测试阶段为了省成本,我用的是一个低维度的轻量Embedding模型,向量维度是384维。后来觉得效果不够好,想切换主流的高质量Embedding服务,导入1536维的新向量。结果发现旧表字段类型是VECTOR(384),数据库直接报错拒绝写入。

我把新文本向量化之后往表里写的时候,才发现这事没法平滑升级——VECTOR字段的维度在PostgreSQL里是一旦定义、不可动态修改的,要么重建新表做数据迁移,要么放弃历史向量全部重新生成。最终我选择的方法是新建了一张embedding_model_metadata表记录当前用的模型版本和维度,将来真正要换模型时,全量重算所有向量再切换索引。换embedding模型这个操作,在数据层面是伤筋动骨的,做之前一定要想清楚。

5.4 数据模型设计常见问题速查

把今天遇到的问题整理成速查表,方便自己也方便读者以后直接查阅:

问题现象根因建议方案
消息顺序错乱流式响应内容颠三倒四会话写入无顺序保障引入会话版本号加乐观锁重试
记忆表无限膨胀检索耗时飙升、内容重复多无查重与衰减机制写入前做相似度查重,定期归档冷数据
向量字段维度冲突切换模型后全部写入失败VECTOR维度不可变记录模型维度,切换模型时全量重建
软删除与唯一索引冲突删除后重建记录报错软删除记录仍在表内使用部分唯一索引只约束未删数据
上下文超长长对话后请求报错或费用飙升未做会话摘要压缩定期生成滚动摘要并替换原始上下文
Token统计不准计费与用量对不上流式接口缺失usage信息按消息文本估算并记录模型和精度

这几类问题基本涵盖了智能体应用在数据层的常见难点。建议读者在设计阶段就把对应机制加进去,比上线后再回头补要省心得多。

6. 给同样在搭智能体数据层的你几条建议

数据模型设计这件事做久了你会发现,它没有所谓完美答案,只有不断在当下需求和未来可能之间找平衡。JchatMind第三天的数据层落地,整体的目标可以用一句话概括:把每一种智能体的"状态"都变成可查询、可演进、可审计的数据。无论是用户的一句偏好、一轮对话的历史,还是一份文档切出来的几个片段,只有它们都是明确的数据,智能体才能越来越聪明。

我个人的几条建议,送给正在搭智能体数据层的朋友:

先定Embedding模型再建向量表。这是今天最深刻的教训。向量维度、距离度量这些基础参数一旦写进库表就基本锁死,切换模型的成本远超预期。提前把模型版本和向量维度做成可配置的元数据,会给将来留出退路。

能JSONB就不新加表。智能体应用的个性化配置很容易膨胀字段,我见过每个新配置加一张表最后十几张表全是单列主键的项目。低频访问且结构多变的配置,用JSONB装在设置表里,维护成本最低。

一切异步任务都要有状态列。解析文档、提取记忆、生成摘要,这类后台任务都要设计明确的排队、执行、失败状态。没有状态任务重启后会重复运行,有状态配合幂等键才能让系统稳定可靠。

少想多跑。数据模型好与不好,有些问题要写几行真实数据才能暴露出来。今天那个软删除和唯一索引冲突的坑,就是数据量到了几千条才发现的。设计阶段多造点测试数据跑一跑,总比线上翻车强。

按这个节奏,明天开始写对话接口和记忆提取工作流时,存储层基本不用再动了。数据模型设计本来就是这种性质的工作,累在当下,但后面真能省出几天的时间。

返回列表