大家好,我是十三!欢迎来到十三Tech。

AI Agent 的"记忆"该怎么设计?简单的键值存储撑不起复杂查询,传统大宽表又缺乏扩展性。Coze Studio 的 memory 模块给了一个大胆而精巧的答案——为每一个"记忆体"动态创建独立的物理 MySQL 表。这本质上是在应用内部实现了一套微型 DBaaS(Database-as-a-Service)。它怎么保证安全?怎么管理版本?怎么避免"库表爆炸"?这篇文章拆开源码逐一回答。

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

Coze memory 模块最核心的设计,是把用户的每一个"记忆数据库"映射为一张后端的物理 MySQL 表——相当于给每个需要记忆的 Agent 配一个专属、量身定制的数据库。创建请求到达领域服务时的核心逻辑长这样:

// `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
}

两个直接收益:真正的结构化——记忆不再是无模式的 JSON,而是有严格数据类型、索引和约束的真实表,精确查询和高效扫描都成立;天然的数据隔离——每个记忆的数据在物理上完全独立,权限和访问控制被极大简化。

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

直接拿用户输入的字段名当物理列名会带来 SQL 注入、关键字冲突等一连串问题。Coze 用一层抽象规避这些风险:用户定义的字段名(如 product_name)被映射成内部物理名(如 f_1),每张动态表还自动加上四个系统标准字段。

逻辑字段与物理字段:用户视图与系统视图的解耦

映射规则极其简单:

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

每张动态表自动获得四个系统字段,用于内部管理:

// `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"),
		},
	}
}

这种设计把用户的"逻辑视图"和底层"物理实现"彻底解耦——用户字段名怎么取都不会撞 SQL 关键字,物理列名怎么变也不影响用户接口。

风险与权衡:如何驾驭"库表爆炸"

任何不谈权衡的架构都是耍流氓。动态建表如果没约束,就是一场灾难。Coze 用"双保险"驾驭这匹野马。

第一层保险:明确粒度 + 应用层硬限制。"记忆体"的粒度不是单条记忆,而是用户在界面上创建的一个完整的"记忆数据库"——动态建表只在创建记忆库时发生,频率极低。更关键的是代码里的硬性限制:

// `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 维护两者的关联:

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

草稿与线上:双物理表支撑安全迭代

这套设计的收益很直接:开发者在草稿表里任意改数据、调结构,对正在服务的 Bot 毫无影响;调试完成后执行"发布"操作,把草稿表的数据和结构变更原子化同步到线上表,完成一次安全可靠的版本迭代。本质上是把软件工程里"开发-生产"环境分离的成熟实践,迁移到了数据表层面。

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

Coze memory 模块是经典分层架构 + 依赖倒置的优秀范例。跟随一次"创建记忆"的请求,能清楚看到它如何在四层之间流转:

四层调用链:API → Application → Domain → Infrastructure

Application 层扮演协调者,编排业务流程并处理应用级任务(如发布领域事件):

// 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)也只需要替换 infra 实现,业务代码一行不动。

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

ApplicationService 里有个值得拆开看的细节:成功创建数据库后,它会通过 eventbus 发布一个 ResourceDomainEvent 事件。这是一套精巧的异步解耦设计。

事件驱动:memory 与 search 的异步解耦

memory 模块完成核心职责后只是"广而告之"——不关心谁在听、也不等待后续处理。系统的其他模块(比如 search)订阅这类事件,监听到新记忆创建后在后台异步为这张新表的数据建全文检索索引,让用户后续可以做模糊搜索。这种发布-订阅模式避免了模块间的直接调用和紧耦合,提升了响应速度和整体可扩展性——新增一个订阅者不需要改动发布方一行代码。代价是引入了最终一致性,需要事件幂等和重试机制兜底。

总结

Coze memory 模块远不是一个简单的信息存储单元,它是一套设计精良、功能完备的企业级"数据库即服务"系统。核心可借鉴的设计有四条:动态物理表实现真正的结构化与数据隔离;逻辑-物理字段映射保证安全与灵活;草稿-线上双版本支持安全迭代;事件驱动实现模块解耦。这些做法不只适用于 AI Agent 记忆系统,对任何"用户可定义数据结构"的场景都有直接参考价值。


关于十三Tech

资深服务端研发工程师,AI 编程实践者。 专注分享真实的技术实践经验,相信 AI 是程序员的最佳搭档。 希望能和大家一起写出更优雅的代码!

联系方式569893882@qq.com GitHub@TriTechAI VX:TriTechAI(备注:十三 Tech)