解构 Coze Studio:为 AI Agent 实现微型 DBaaS 的架构艺术

👋 大家好,我是十三!

在我之前的文章中,从宏观上领略了 Coze Studio 优雅的架构设计。今天,我们将深入其中的数据存储模块------memory

如何让 AI Agent 拥有长期、结构化且可扩展的记忆?一个简单的键值存储显然无法满足复杂的业务需求。我们希望 Agent 能记住一张商品信息表,并能对其进行精确查询;我们希望 Bot 的知识库可以随时安全地迭代,而不影响线上服务。

在探索 Coze 的源码时,我发现 memory 模块提供了一个极其精巧的解决方案。它没有采用传统的、在单一巨大表中通过 JSON 字段存储数据的方式,而是大胆地为每一个"记忆体"动态地创建了一张独立的物理数据库表。

这种设计,实质上是在应用内部为 AI Agent 实现了一套"数据库即服务"(Database-as-a-Service, DBaaS)。这背后究竟隐藏着怎样的"黑科技"?让我们一起深入源码,一探究竟!

1. 核心设计:一个记忆,一张物理表

Coze memory 模块最核心、最大胆的设计,就是将用户的每一个"记忆数据库"映射为一张后端的物理 MySQL 表。这就像是为每个需要记忆的 Agent 都配备了一个专属的、量身定制的数据库。

当一个创建记忆的请求到达领域服务时,其核心逻辑清晰地展示了这一过程:

go 复制代码
// `domain/memory/database/service/database_impl.go:83-117`
func (d databaseService) CreateDatabase(ctx context.Context, req *CreateDatabaseRequest) (*CreateDatabaseResponse, error) {
	// 1. 将用户定义的逻辑字段转换为物理表的列定义
	fieldItems, columns := physicaltable.CreateFieldInfo(req.Database.FieldList)

	// 2. 调用基础设施层,动态创建一张物理的草稿表
	draftPhysicalTableRes, err := physicaltable.CreatePhysicalTable(ctx, d.rdb, columns)
	// ...

	// 3. 同样地,再创建一张物理的线上表
	onlinePhysicalTableRes, err := physicaltable.CreatePhysicalTable(ctx, d.rdb, columns)
	// ...

	// 4. 在元数据表中记录这次创建,并关联两张物理表
	tx := query.Use(d.db).Begin()
	// ...
	_, err = d.draftDAO.CreateWithTX(ctx, tx, draftEntity, draftID, onlineID, draftPhysicalTableRes.Table.Name)
	onlineEntity, err = d.onlineDAO.CreateWithTX(ctx, tx, onlineEntity, draftID, onlineID, onlinePhysicalTableRes.Table.Name)
	// ...
	err = tx.Commit()

	return &CreateDatabaseResponse{
		Database: onlineEntity,
	}, nil
}

这种设计的巧妙之处在于:

  1. 真正的结构化:用户的记忆不再是无模式的 JSON,而是拥有严格数据类型、索引和约束的真实数据表,为精确、高效的查询奠定了基础。
  2. 天然的数据隔离:每个记忆的数据存储在独立的表中,物理上完全隔离,极大地简化了数据权限和访问控制。

物理与逻辑的解耦:优雅的字段抽象

直接将用户输入的字段名作为物理表的列名,会带来 SQL 注入、关键字冲突等一系列问题。Coze 通过一层巧妙的抽象规避了这些风险。

这种逻辑字段到物理字段的映射关系,以及系统自动添加的标准字段,可以用下图清晰地展示:

graph TD subgraph "用户视角:逻辑模型 (商品信息)" L[" 商品ID (Number)
商品名称 (Text)
价格 (Float)
"] end subgraph "系统视角:物理表 (table_xyz)" P[" --- 系统字段 (自动添加) ---
bstudio_id (BIGINT, PK)
bstudio_connector_uid (VARCHAR)
bstudio_connector_id (VARCHAR)
bstudio_create_time (TIMESTAMP)

--- 用户字段 (映射后) ---
f_1 (BIGINT)
f_2 (TEXT)
f_3 (DOUBLE)
"] end L -- "转换与映射" --> P

用户定义的字段名(如 product_name)并不会直接用作列名,而是被映射为一个内部的物理名称,如 f_1, f_2

go 复制代码
//`domain/memory/database/internal/physicaltable/physical.go:120-122`
func GetFieldPhysicsName(fieldID int64) string {
	return fmt.Sprintf("f_%d", fieldID)
}

同时,每张动态创建的表都会自动获赠一套"豪华装修"------四个系统标准字段,用于内部管理。

go 复制代码
// `domain/memory/database/internal/physicaltable/physical.go:89-113`
func getDefaultColumns() []*entity3.Column {
	return []*entity3.Column{
		{
			Name:          database.DefaultIDColName, // "bstudio_id"
			DataType:      entity3.TypeBigInt,
			NotNull:       true,
			AutoIncrement: true,
		},
		{
			Name:     database.DefaultUidColName, // "bstudio_connector_uid"
			DataType: entity3.TypeVarchar,
			NotNull:  true,
		},
		{
			Name:     database.DefaultCidColName, // "bstudio_connector_id"
			DataType: entity3.TypeVarchar,
			NotNull:  true,
		},
		{
			Name:         database.DefaultCreateTimeColName, // "bstudio_create_time"
			DataType:     entity3.TypeTimestamp,
			NotNull:      true,
			DefaultValue: ptr.Of("CURRENT_TIMESTAMP"),
		},
	}
}

这种设计将用户定义的"逻辑视图"和底层的"物理实现"完全解耦,提供了极高的安全性和灵活性。这个大胆的设计固然灵活,但这样动态建表,数据库不会爆炸吗?

风险与权衡:如何驾驭"库表爆炸"这匹野马?

任何不谈权衡的架构设计都是"耍流氓"。"动态建表"这个方案如果缺少约束,无疑会成为一场灾难。Coze 通过一个"双保险"策略,完美地驾驭了这匹野马。

第一层保险:明确粒度与应用层限制

首先,"记忆体"的粒度并非单条记忆,而是用户在界面上创建的一个完整的"记忆数据库"。动态建表的操作仅在此刻发生,频率很低。

更重要的是,Coze 在代码中施加了硬性限制。

go 复制代码
 // `application/memory/database.go:51-62`
func (d *DatabaseApplicationService) GetModeConfig(...) (*table.GetModeConfigResponse, error) {
	return &table.GetModeConfigResponse{
		// ...
		MaxTableNum:   3,        // 每个 Bot 最多能创建 3 个记忆数据库
		MaxColumnNum:  20,       // 每个表最多 20 个字段
		MaxRowNum:     100000,   // 每个表最多 10 万行
	}, nil
}

一个 Bot 最多只能创建 3 个记忆数据库,这个严格的限制从根本上杜绝了单个 Bot 无限创建表导致"库表爆炸"的可能性。

第二层保险:面向未来的多租户架构

在几乎所有相关操作中,我们都看到了 SpaceID 这个字段,这是实现多租户隔离的关键。这意味着对于大型公有云部署,Coze 可以为每个租户(团队或企业)分配独立的数据库实例,将动态创建的表分散到各个租户自己的数据库中,从而在宏观上控制了表的总规模。

2. 安全的迭代:"草稿"与"线上"的双版本哲学

解决了存储的灵活性问题后,下一个挑战接踵而至:如何安全地迭代和更新这些"活的"数据表?Coze 的答案是------为每一次变更都提供一个安全的沙箱。

系统在创建之初就生成了两张结构完全相同的物理表:一张用于草稿环境,一张用于线上环境。

Database 实体通过 DraftIDOnlineID 来维护这两个版本的关联关系。

go 复制代码
// `api/model/crossdomain/database/database.go:117-144`*
type Database struct {
	ID          int64
	// ...
	DraftID     *int64
	OnlineID    *int64
	// ...
}

一个逻辑上的"记忆数据库"是如何通过元数据关联到两张独立的物理表,以及"发布"操作的实质,可以通过下图清晰地展现:

graph TD subgraph "用户视角" MemoryDB[记忆数据库: 商品信息] end subgraph "物理实现 (MySQL)" OnlineTable(线上物理表
table_1001) DraftTable(草稿物理表
table_1002) end subgraph "元数据表" Meta(元数据记录
ID: db_abc
OnlineID: table_1001
DraftID: table_1002) end MemoryDB -- 读写 --> Meta Meta -- 线上环境读 --> OnlineTable Meta -- 开发环境读写 --> DraftTable DraftTable -- "发布操作: 数据与结构同步" --> OnlineTable

这个设计的巧妙之处在于:

  • 安全的迭代环境:开发者可以在与线上环境完全隔离的"草稿"表中任意增删改数据、甚至调整表结构,而不会对正在服务的 Bot 产生任何影响。
  • 原子化的发布操作:当草稿版本调试完成后,开发者可以执行"发布"操作。该操作会将草稿表的数据和结构变更同步到线上表,从而完成一次安全、可靠的版本迭代。

3. 架构之美:一次请求的优雅之旅

Coze memory 模块的实现,是经典分层架构和依赖倒置原则的优秀范例。让我们跟随一次"创建记忆"的请求,看看它是如何在清晰的层次间优雅地流转的。

调用链示意图:

graph TD subgraph "API Layer" A["table.AddDatabaseRequest"] end subgraph "Application Layer" B["DatabaseApplicationService"] end subgraph "Domain Layer" C["Database Service Interface"] end subgraph "Infrastructure Layer" D["RDB Interface"] end subgraph "Database" E["MySQL Database"] end A -->|"convert"| B B -->|"d.DomainSVC.CreateDatabase()"| C C -->|"physicaltable.CreatePhysicalTable()"| D D -->|"mysqlService.CreateTable()"| E
  • Application 层 (应用的心脏) : 扮演着"协调者"的角色。它负责编排业务流程(调用 Domain Service)和处理应用级任务(如发布领域事件)。

    go 复制代码
    // application/memory/database.go
    func (d *DatabaseApplicationService) AddDatabase(...) (*table.SingleDatabaseResponse, error) {
        // ...
        res, err := d.DomainSVC.CreateDatabase(ctx, convertAddDatabase(req))
        // ...
        err = d.eventbus.PublishResources(ctx, &searchEntity.ResourceDomainEvent{...})
        // ...
    }
  • Domain 层 (业务的灵魂) : 系统的"大脑"。它定义了 Database 领域服务的接口,封装了最核心的业务规则,但完全不关心底层是用 MySQL 还是其他数据库实现的。

  • Infrastructure 层 (强壮的肌肉) : 系统的"四肢"。它提供了 rdb.RDB 接口的具体实现 (mysqlService),将领域层的业务需求(如"创建一个表")翻译成具体的 SQL 语句并执行。其实现是通用的,可以操作任何表,而非为特定业务写死的 DAO。

这种设计使得系统高度解耦,核心业务逻辑稳定,易于测试和维护,并且未来可以方便地替换底层技术栈(例如,将 MySQL 更换为 PostgreSQL)。

4. 解耦的艺术:事件驱动的"广而告之"

ApplicationService 中,我们注意到一个细节:在成功创建数据库后,它会通过 eventbus 发布一个 ResourceDomainEvent 事件。

这是一个非常巧妙的异步解耦设计。memory 模块在完成自己的核心职责后,只是"广而告之"有一个新的记忆被创建了,它并不关心谁需要这个消息,也不需要等待后续处理完成。

系统的其他模块(例如 search 模块)可以订阅此类事件。当监听到新记忆创建的事件后,search 模块就可以在后台异步地为这张新表的数据创建全文检索引擎的索引,以便用户未来可以进行模糊搜索。

这种模式避免了模块间的直接调用和紧耦合,提升了系统的响应速度和整体可扩展性。

这个异步的事件发布与订阅流程,可以用下面的时序图清晰地展示出来:

sequenceDiagram participant AppService as Application Service
(memory module) participant EventBus as Event Bus participant SearchModule as Search Module
(subscriber) participant SearchIndex as Search Index AppService->>+EventBus: Publish(ResourceDomainEvent) Note right of AppService: "广而告之" - 我已完成创建,
不关心谁在监听。 EventBus-->>-SearchModule: Notify(event) Note left of SearchModule: 监听到事件,开始异步处理。 SearchModule->>+SearchIndex: Update Index SearchIndex-->>-SearchModule: OK

通过对 Coze memory 模块的源码进行深度挖掘,我们发现它远非一个简单的信息存储单元。它是一个设计精良、功能完备、企业级的"数据库即服务"系统,其核心设计思想和工程实践非常值得我们学习:

  1. 动态物理表:为每个记忆实例创建独立物理表,实现了真正的结构化、高性能和数据隔离。
  2. 版本化管理:通过"草稿-线上"双表模式,为 AI 应用开发提供了安全可靠的迭代和发布工作流。
  3. 分层与抽象:经典的四层架构和依赖倒置原则,构建了高内聚、低耦合、易于维护的系统。
  4. 异步与解耦:利用事件驱动机制,实现了模块间的异步通信,提升了系统的可扩展性。

Coze 的这一实现,为我们展示了如何为复杂的 AI Agent 构建一个既灵活又健壮的长期记忆系统,是后端架构设计的一次精彩演绎。


👨‍💻 关于十三Tech

资深服务端研发工程师,AI 编程实践者。

专注分享真实的技术实践经验,相信 AI 是程序员的最佳搭档。

希望能和大家一起写出更优雅的代码!

📧 联系方式569893882@qq.com

🌟 GitHub@TriTechAI

💬 VX:TriTechAI(备注:十三 Tech)

相关推荐
夜影风25 分钟前
RabbitMQ核心架构与应用
分布式·架构·rabbitmq
奥格列的魔法拖鞋~1 小时前
Docker-LNMP架构 创建多项目- 单个ngixn代理多个PHP容器服务
nginx·docker·eureka·架构·php·lnmp
泉城老铁2 小时前
在秒杀场景中,如何通过动态调整线程池参数来应对流量突增
后端·架构
前端日常开发2 小时前
焕新扫雷体验,Trae如何让童年游戏更现代?
trae
前端日常开发2 小时前
记忆中的打地鼠游戏居然是这样实现的,Trae版实现
trae
技术老金3 小时前
给你的AI应用“降本增效”:吃透模型级联、智能缓存等三大成本优化策略
人工智能·架构
泉城老铁3 小时前
在高并发场景下,如何优化线程池参数配置
spring boot·后端·架构
oioihoii3 小时前
架构需求规格说明(ARD):项目成功的隐形引擎
架构
博一波3 小时前
【企业级架构】企业战略到技术落地的全流程【第一篇】
架构·ea