大模型 langchain-组件学习(中)

三、Memory(记忆)

Memory组件是LangChain中用于管理和维护对话或交互历史的核心模块,它使 LLM 能够记住之前的交互信息,实现上下文感知的对话

Memory组件主要负责:

  • 存储历史对话消息

  • 管理上下文信息

  • 为后续交互提供记忆能力

  • 支持多种记忆

常用的 Memory 类型有以下两种:

ConversationBufferMemory(对话缓冲记忆)

这是最简单直接的记忆方式,也是最传统的,它将所有过去的对话消息原封不动地保存在一个缓冲区(Buffer)里,优点是信息完整,不会丢失任何细节;缺点是对话越长,消耗的 Token 就越多,可能导致成本上升或超出模型的上下文窗口限制,而且最重要的是,这个类只提供了保存和读取对话历史的方法,但不会自动与每次 chain(链)的调用挂钩,所以要手动存储记忆,代码如下:

python 复制代码
from langchain.memory import ConversationBufferWindowMemory
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core._api import LangChainDeprecationWarning
from my_chat.my_chat_model import ChatModel
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.chains import LLMChain

import warnings

# 过滤掉LangChain的弃用警告
warnings.filterwarnings("ignore", category=LangChainDeprecationWarning)


class CompatibleConversationBufferWindowMemory(ConversationBufferWindowMemory):
    @property
    def messages(self):
        """
        兼容性补丁:将load_memory_variables的结果转换为messages格式
        """
        memory_data = self.load_memory_variables({})
        return memory_data.get(self.memory_key, [])

    def add_messages(self, messages):
        """实现必须的add_messages方法"""
        for message in messages:
            if isinstance(message, HumanMessage):
                self.save_context({"input": message.content}, {"output": ""})


def test1():
    # k=3表示存入最近的3次对话
    memory = CompatibleConversationBufferWindowMemory(
        k=3,
        return_messages=True,
        memory_key="history"  # 明确指定内存键名
    )

    # 创建一个对象
    chat = ChatModel()
    llm = chat.get_online_model()

    # 创建提示模版 - 这里变量名要和memory_key一致
    prompt = ChatPromptTemplate.from_messages([
        ("system", "你是一个有趣的小助手"),
        MessagesPlaceholder(variable_name="history"),  # 这里使用"history"
        ("human", "{input}")  # 用户的输入
    ])

    # 创建一个chain对象,LLMChain会自动管理memory
    chain = LLMChain(
        prompt=prompt,
        llm=llm,
        memory=memory,  # LLMChain会自动处理记忆的保存和加载
        verbose=False  # 设为False避免冗长输出
    )

    # 直接使用chain,不需要RunnableWithMessageHistory
    print("=== 开始对话 ===")

    # 提问1
    res1 = chain.invoke({"input": "我叫Olivia, 今年21岁"})
    print(f"Q1: 我叫Olivia, 今年21岁")
    print(f"A1: {res1['text']}\n")

    # 提问2
    res2 = chain.invoke({"input": "我叫什么名字"})
    print(f"Q2: 我叫什么名字")
    print(f"A2: {res2['text']}\n")

    # 提问3
    res3 = chain.invoke({"input": "我今年多大"})
    print(f"Q3: 我今年多大")
    print(f"A3: {res3['text']}\n")

    # 查看历史对话信息
    history = memory.load_memory_variables({})
    print("=== 完整历史记录 ===")
    for i, msg in enumerate(history["history"]):
        if isinstance(msg, HumanMessage):
            print(f"用户说{i // 2 + 1}: {msg.content}")
        elif isinstance(msg, AIMessage):
            print(f"AI说{i // 2 + 1}: {msg.content}")

    return chain

if __name__ == '__main__':
    chain = test1()

    # 继续对话
    print("\n=== 继续对话 ===")
    res4 = chain.invoke({"input": "我们刚才聊了什么?"})
    print(f"Q: 我们刚才聊了什么?")
    print(f"A: {res4['text']}")

运行结果:

RunnableWithMessageHistory(记忆调度器)

RunnableWithMessageHistory 不是存记忆,而是在对话的正确时间,把记忆放进去,再拿出来,解决的是时序问题+隔离问题+自动化问题,功能有:

  • 会话管理:将普通的 Chain 包装成支持多会话记忆的组件。

  • 隔离会话 :通过 session_id 区分不同对话的独立记忆。

  • 自动记忆管理:自动调用 Memory 的读取/存储方法。

和 Memory 类的区别如下:

对比项 RunnableWithMessageHistory ConversationBufferMemory
定位 中间件 / 调度器 具体实现
多会话 ✅ 原生支持 ❌ 很弱
Agent 兼容 ✅ 官方推荐 ⚠️ 不稳定
Prompt 控制 ✅ 完全自己写 ❌ 框架帮你写
工程化 ⭐⭐⭐⭐⭐ ⭐⭐

具体代码示例:

python 复制代码
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core._api import LangChainDeprecationWarning
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_community.chat_message_histories import ChatMessageHistory
from my_chat.my_chat_model import ChatModel
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
import warnings

warnings.filterwarnings("ignore", category=LangChainDeprecationWarning)


class ModerChatBot:
    def __init__(self):
        # 创建聊天模型
        chat = ChatModel()
        self.llm = chat.get_online_model()

        # 创建提示模板
        self.prompt = ChatPromptTemplate.from_messages([
            ("system", "你是一个有趣的小助手"),
            MessagesPlaceholder(variable_name="history"), #相当于占位符,会被自动替换成历史信息
            ("human", "{input}")
        ])

        # 创建简单的链(没有memory),此时就是输入 → Prompt → LLM → 输出
        self.chain = self.prompt | self.llm

        # 会话存储,一个session_id对应一个对话存储
        self.session_store = {}

    #记忆的仓库管理员
    def get_session_history(self, session_id: str) -> BaseChatMessageHistory:
        """获取或创建会话历史"""
        if session_id not in self.session_store:
            self.session_store[session_id] = ChatMessageHistory()
        return self.session_store[session_id]

    #给链装上记忆(核心)
    def create_conversational_chain(self):
        """创建带历史管理的对话链"""
        return RunnableWithMessageHistory(
            self.chain,  # 简单链
            self.get_session_history,  # 获取历史的函数
            input_messages_key="input",
            history_messages_key="history"
        )

    #一次对话的完整流程
    def chat(self, session_id: str, message: str):
        """发送消息到指定会话"""
        chain = self.create_conversational_chain()

        response = chain.invoke(
            {"input": message},
            config={"configurable": {"session_id": session_id}}
        )

        return response.content

    def get_chat_history(self, session_id: str):
        """获取指定会话的聊天历史"""
        if session_id in self.session_store:
            return self.session_store[session_id].messages #调试用接口
        return []


def test3():
    """测试多对话系统"""
    bot = ModerChatBot()

    # 配置会话ID
    config_a = {"configurable": {"session_id": "olivia_session"}}
    config_b = {"configurable": {"session_id": "david_session"}}

    print("=== Olivia的会话 ===")
    response1 = bot.chat("olivia_session", "我叫Olivia,今年21岁")
    print(f"Olivia: 我叫Olivia,今年21岁")
    print(f"AI: {response1}")

    response2 = bot.chat("olivia_session", "我叫什么名字?")
    print(f"Olivia: 我叫什么名字?")
    print(f"AI: {response2}")

    print("\n=== David的新会话 ===")
    response3 = bot.chat("david_session", "我叫什么名字?")
    print(f"David: 我叫什么名字?")
    print(f"AI: {response3}")  # 应该不知道 说明会话隔离成功

    response4 = bot.chat("david_session", "我叫David,25岁")
    print(f"David: 我叫David,25岁")
    print(f"AI: {response4}")

    response5 = bot.chat("david_session", "我多大了?")
    print(f"David: 我多大了?")
    print(f"AI: {response5}")

    print("\n=== 查看历史 ===")
    print("Olivia的历史:", [f"{type(m).__name__}: {m.content[:30]}..." for m in bot.get_chat_history("olivia_session")])
    print("David的历史:", [f"{type(m).__name__}: {m.content[:30]}..." for m in bot.get_chat_history("david_session")])


if __name__ == '__main__':
    test3()

运行结果:

可以看到是把两类对话分开的

四、Index(检索)

Langchain 中,Index 检索是从"聊天"迈向"知识系统/RAG/Agent"的分水岭,它是将非结构化文档转化为可检索知识的组件体系,它的目标是解决一个问题:如何让大模型"查资料",而不是"瞎编",Index 组件就通过向量化+相似度检索,让 LLM 在生成答案前,先从外部知识中检索相关内容,这也正是RAG的基础。

Index 整体可拆成四步:原始文档 -> 文档加载 -> 字符分割 -> 向量存储 -> 向量数据库检索,当然这里面的文档来源、分割策略、向量库、检索方式都可以换,Index 是一个可组合系统,不是黑盒,下面就简单看一下这四步:

文档加载

作用是将外部数据源统一转换为 Langchain 的对象,这样的作用是统一数据格式,保留元信息(来源、页码、路径等),为后续切分和溯源提供基础,代码如下:

python 复制代码
from langchain_community.document_loaders import Docx2txtLoader, CSVLoader, TextLoader, PyPDFLoader, WebBaseLoader

#加载txt文件
def test1():
    loader = TextLoader("../data/example.txt", encoding="utf-8")
    docs = loader.load()
    print(docs)
    for doc in docs:
        print(doc.page_content)

#加载pdf文件
def test2():
    loader = PyPDFLoader("../data/example.pdf")
    docs = loader.load()
    print(docs)
    for doc in docs:
        print(doc.page_content)

#加载网页文件
def test3():
    loader = WebBaseLoader("https://www.gaokao.com/e/20250604/684012cf1c697.shtml")
    docs = loader.load_and_split()
    print(docs)
    for doc in docs:
        print(doc.page_content)

#csv文件
def test4():
    loader = CSVLoader("../data/example.csv", encoding="utf-8")
    docs = loader.load_and_split() #分词方法有问题
    print(docs)
    for doc in docs:
        print(doc.page_content)

#word文件
def test5():
    loader = Docx2txtLoader("../data/example.docx")
    docs = loader.load_and_split()
    print(docs)
    for doc in docs:
        print(doc.page_content)

if __name__ == '__main__':
    # test1()
    # test2()
    test3()
    # test4()
    # test5()

首先 Loader 不理解,只负责"读数据",然后不同格式的文档用不同的 Loader 加载,但是最后返回的都是同一种数据结构,也就是 List[Document],无论加载的是 TXT / PDF / 网页 / CSV / Word

最终都会变成 Document,所以每次读取完后统一写:

复制代码
for doc in docs:
    print(doc.page_content)

完全不需要关心原始格式,这也是后续组件都能无缝衔接的前提。

至于每个 Loader 这里就不细讲了,应该都能明白比如 TextLoader 是加载纯文本文件的(记得指定encoding="utf-8"),PyPDFLoader 加载PDF文件等等,重点说一下 WebBaseLoader 和 CSVLoader。

首先 WebBaseLoader 用于加载网页内容,内部会请求网页 -> 提取正文,然后直接拆分,所以用的是 loader.load_and_split() 而不是 loader.load(),因为 Web 数据天然是"长文本 + 杂质多",Loader 通常会和简单切分逻辑绑定使用;至于 CSVLoader,在 docs = loader.load_and_split() 代码后面标注"分词有问题"是因为CSV 本质是结构化数据,每一行并不一定是"自然语言句子",直接按字符切分容易破坏语义,检索效果通常不好,所以在真实的项目中CSV 往往需要自定义 Loader,或转成自然语言描述再送入 Index,苯人这里只是演示。

最后讲一下 load() 和 load_and_split(),前者只加载不切分,后者加载后默认切分,但后者只是便捷方法,实际工程中,切分通常交给独立的 Text Splitter 控制,也就是下一个 "字符分割"。

总之,文档加载阶段的核心价值,不在于"处理文本",而在于将异构数据统一抽象为标准 Document 对象,这使得后续的文本切分、向量化和检索可以完全解耦于数据来源。

字符分割

首先字符分割决定了"模型看到的知识颗粒度",切不好的话后面全白搭,而 Langchain 里最常用的两种切分器是 CharacterTextSplitter 和 RecursiveCharacterTextSplitter,下面用代码来示例:

CharacterTextSplitter

python 复制代码
from langchain_community.document_loaders import TextLoader
from langchain.text_splitter import CharacterTextSplitter, RecursiveCharacterTextSplitter

#字符分割
def test1():
    loader = TextLoader("../data/example.txt", encoding="utf-8")
    docs = loader.load()
    print(docs)
    # 文本分割
    text = CharacterTextSplitter(
        separator="\n", #分割字符 先按换行符切,常用于诗歌或段落清晰的文本
        chunk_size=8, #每段分割的最大长度 如果想要把一首七言诗分成两段 要在[17-25]之间 这里的8是分成了四段
        chunk_overlap=0, #重叠长度(相邻块不共享内容) 0表示无需重叠
    )
    #分割文档
    docs_split = text.split_documents(docs)
    # print(docs_split)
    for doc in docs_split:
        print("----------------------------------")
        print(doc.page_content)

运行的结果是:

如果把 chunk_size 改为 18,结果就为:

所以,CharacterTextSplitter 的优点是行为完全可预测,性能好,实现简单,但缺点是不理解语义,容易把一句话切断,更适合结构明确、格式稳定的文本

RecursiveCharacterTextSplitter(常用)

python 复制代码
#递归分割
def test2():
    loader = TextLoader("../data/example.txt", encoding="utf-8")
    docs = loader.load()
    print(docs)
    # 文本分割
    text = RecursiveCharacterTextSplitter(
        # separator="\n", #这里不用设置分割字符因为会自动检测字符
        chunk_size=8, #每段分割的最大长度
        chunk_overlap=0, #重叠长度 0表示无需重叠
    )
    #分割文档
    docs_split = text.split_documents(docs)
    # print(docs_split)
    for doc in docs_split:
        print("----------------------------------")
        print(doc.page_content)

这种方法被称为"递归分割","递归"的意思就是尽量用"更自然的方式"切,如果不行,再退而求其次,流程大概是:首先尝试按段落切 -> 按行切(如果还太长) -> 按词切(如果还不行) -> 按字符切(最后兜底),总结就是语义优先,长度兜底,同时这种方法也是RAG的"默认推荐",是 LangChain 中更偏"工程实用"的切分策略,在大多数非结构化文本场景下表现更稳定。

然后解释一下两种方法中都有的步骤:一、docs_split = text.split_documents(docs),这里它不仅是切文本,还复制 metadata(元数据,比如路径、页码、url这些)、保留文档来源信息、每个 chunk 仍然是 Document,这保证了后续可以做来源追溯,这点在RAG里很重要;二、chunk_size / overlap 的设计,chunk_size太小会导致语义破碎,太大会检索不精准,而 chunk_overlap 会防止上下文断裂,但是会增加向量数量和成本,在项目中常见的经验设置是

chunk_size: 200~500

overlap: 20~50

总之,文本切分并不是一个简单的字符串操作,而是在**上下文完整性、检索精度和系统性能之间的权衡,**合理的切分策略,是高质量 RAG 系统的关键前提。

向量存储

向量存储阶段负责将文本知识映射为向量空间中的点,并提供高效的相似度检索能力,是 RAG 系统中"知识可查询化"的关键步骤,到这里为止,知识已经不再是文本,而是"数学意义上的向量",下面用代码示例,以存入txt文件为例:

python 复制代码
def test_txt():
    # 存储txt文件
    loader = TextLoader("../data/example.txt", encoding="utf-8")
    docs = loader.load()
    print(docs)
    # 文本分割
    text = RecursiveCharacterTextSplitter(
        # separator="\n", #这里不用设置分割字符因为会自动检测字符
        chunk_size=8,  # 每段分割的长度
        chunk_overlap=0,  # 重叠长度 0表示无需重叠
    )
    # 分割文档
    docs_split = text.split_documents(docs)
    #把文档保存到向量数据库中
    chat = ChatModel()
    #获取向量模型
    embedding = chat.get_embedding_model()

    chroma = Chroma.from_documents(
        docs_split, #文档列表
        embedding, #向量模型
        persist_directory="../chroma_db", #向量数据库的路径
        collection_name="shi", #向量数据库集合名称
        collection_metadata={"hnsw:space": "cosine"}
    )
    print("存入成功!")

首先前面的文档加载+切分就不用多说了,直接从 chat = ChatModel() 开始看,

python 复制代码
#把文档保存到向量数据库中
 chat = ChatModel()
 #获取向量模型
 embedding = chat.get_embedding_model()

这里发生的事是每一个切割后的 Document.page_content 都会被送进 embedding 模型,而Embedding 模型负责将自然语言映射到向量空间,使得"语义相近"的文本在向量空间中距离更近,(这里我把 embedding 模型单独封装在 ChatModel 里了,这段代码就不展示了),然后接下来就是向量入库的核心:

python 复制代码
chroma = Chroma.from_documents(
    docs_split,
    embedding,
    persist_directory="../chroma_db",
    collection_name="shi",
    collection_metadata={"hnsw:space": "cosine"}
)

先解释下 Chroma,Chroma 是一个独立的向量数据库(Vector Database),专门用于存储向量并进行相似度搜索,它就是负责存向量、建索引、做近邻搜索,下面解释参数:

首先 docs_split 和 embedding 就不多说了;persist_directory 就是向量数据的保存地址,这意味着向量数据库是持久化的,程序退出后数据仍然存在下一次可以直接加载,持久化向量库是生产环境 RAG 系统的必要条件;collection_name 非常重要,一个 collection 可以理解为一个知识域,将不同知识库隔离方便做多知识源检索;collection_metadata={"hnsw:space": "cosine"} 表示 使用 HNSW 索引,cosine 表示使用 余弦相似度,在语义检索场景中,余弦相似度通常比欧式距离更稳定。这段代码的运行结果如下:

然后我们再写个查询数据库的函数来验证一下:

python 复制代码
#查询数据库
def test2():
    #链接向量数据库
    client = chromadb.PersistentClient(path="../chroma_db")
    #查询集合的数据
    collections = client.get_collection(name="shi")
    # collections = client.get_collection(name="gaokao")

    print(f"数据的长度为{collections.count()}")
    #获取数据,查询所有数据
    data = collections.get()
    print(data)
    #打印具体数据
    for i in range(len(data["ids"])):
        print(f"第{i+1}条数据")
        print(f"ids:{data["ids"][i]}")
        print(f"数据:{data["documents"][i]}")
        print("-----------------------------------------")

运行结果:

当然还有对数据库的一些操作,比如删除指定数据和删除整个集合:

python 复制代码
#删除指定数据
def test3():
    # 链接向量数据库
    client = chromadb.PersistentClient(path="../chroma_db")
    # 查询集合的数据
    collections = client.get_collection(name="shi")
    #删除指定的ids数据
    collections.delete(ids="73fcebaa-d980-46c0-94be-554e2dbad774")
    print("删除成功!")

#删除所有数据
def test4():
    # 链接向量数据库
    client = chromadb.PersistentClient(path="../chroma_db")
    #删除集合
    client.delete_collection(name="gaokao")
    print("删除成功!")

这里的名字叫gaokao的 collection 是我测试的存储网页HTML弄的集合,运行结果就是显示删除成功。

向量存储阶段不仅是将文本"转成向量",更是构建一个支持语义检索、可维护、可扩展的知识索引系统,合理的切分策略、稳定的 Embedding 模型以及合适的向量库配置,共同决定了 RAG 系统的最终效果。

向量数据库检索

首先要清楚向量数据库检索的本质,是将"用户问题"映射到向量空间中,并找到语义上最接近的文本片段,而不是关键词匹配,到这里为止 LLM 还没开始生成,系统只是在做一件事:帮模型找资料,下面还是代码示例,先来一半:

python 复制代码
from my_chat.my_chat_model import ChatModel
from langchain_chroma import Chroma

#检索
def test1():
    #创建向量模型
    chat = ChatModel()
    emb_model = chat.get_embedding_model()
    #加载已有的集合数据
    store = Chroma(
        persist_directory="../chroma_db",
        embedding_function=emb_model,
        collection_name="animal",
        collection_metadata={
            "hnsw:space": "cosine"
        }
    )

这里依然用的是 Chroma,这里承担的是存储文档向量、构建 HNSW 索引、提供相似度搜索能力,它本身不懂问题,只懂"给我一个向量,我帮你找最像的几个向量",接下来就是核心:

python 复制代码
    # 创建一个检索器
    # search_type = "similarity" 表示根据文档的余弦相似度进行查询,k表示返回的文档数量
    re = store.as_retriever(search_type="similarity", search_kwargs={"k": 1})
    #检索
    docs = re.invoke("描述一下猫咪")
    print(docs)
    print(f"数据类型{type(docs)}")
    for x in docs:
        print(f"文档来源:{x.metadata['source']}")
        print(f"文档内容:{x.page_content}")

首先这里的 as_retriever() 是"向量数据库的查询接口抽象",负责把用户问题转化为检索行为,所以 re 它不是数据库也不是模型,而是一个"查询策略对象",后面的 search_type="similarity"表示使用向量相似度搜索,也就是余弦相似度(cosine),在语义检索中,相似度搜索比关键词匹配更能捕捉语义相关性;k=1 就是返回最相似的1个文档 chunk,k小上下文更精,k大信息更全

后面的 docs = re.invoke("描述一下猫咪") ,这里发生了四步隐式流程:

文本问题转入 embedding -> 根据embedding进行Chroma 搜索 -> 通过相似度排序 -> 返List[Document],最后返回的这个List[Document]会被自动塞进 Prompt 里的上下文材料,但现在模型还没有被调用,我只是在做检索。

最后打印的两行就能体现 metadata 在检索阶段的真正价值了,检索不是智能给模型看,也要能给人看,而 metadata 支持答案溯源、支持可解释性、支持可信 RAG,所以上麦那那段代码的运行结果为:

还有一种方法可以看到模型计算的相似度分数:

python 复制代码
#查看文档分数 分数越小相似度越高
def test2():
    # 创建向量模型
    chat = ChatModel()
    emb_model = chat.get_embedding_model()
    # 加载已有的集合数据
    store = Chroma(
        persist_directory="../chroma_db",
        embedding_function=emb_model,
        collection_name="animal",
        collection_metadata={
            "hnsw:space": "cosine" #hnsw :近似最近邻算法
        }
    )
    #查看提问的问题和文档的相似度得分
    docs = store.similarity_search_with_score("解释一下金鱼", k=4)
    print(docs)
    for x in docs:
        print(f"文档来源:{x[0].metadata['source']}")
        print(f"文档内容:{x[0].page_content}")
        print(f"文档的相似度分数:{x[1]}")
        print("------------------------------------------")

运行结果:

为什么分数越小越相似是因为 Chroma 使用的是距离度量,cosine distance 越小就越相似,而项目中看分数的原因是可以设置相似度阈值(低于就不要)、可以过滤"看起来像但其实不相关"的 chunk等,相似度分数用于衡量问题与文档在向量空间中的距离,是检索结果排序的重要依据。

总之,向量数据库检索阶段并不直接参与答案生成,而是通过语义相似度搜索,为大模型提供最相关的上下文信息。Retriever 作为检索策略的抽象,使得向量存储与查询逻辑解耦,从而构建出灵活、可扩展的 RAG 系统。

好吧由于某些原因现在这里断一下,下篇一定结束langchain。。以上有问题可以指出 (๑•̀ㅂ•́)و✧

相关推荐
╭⌒若隐_RowYet——大数据2 小时前
AI Agent(智能体)简介
人工智能·ai·agent
Evand J2 小时前
【课题推荐】基于视觉(像素坐标)与 IMU 的目标/自身运动估计(Visual-Inertial Odometry, VIO),课题介绍与算法示例
人工智能·算法·计算机视觉
麦麦大数据2 小时前
F051-vue+flask企业债务舆情风险预测分析系统
前端·vue.js·人工智能·flask·知识图谱·企业信息·债务分析
haiyu_y2 小时前
Day 45 预训练模型
人工智能·python·深度学习
【建模先锋】2 小时前
基于CNN-SENet+SHAP分析的回归预测模型!
人工智能·python·回归·cnn·回归预测·特征可视化·shap 可视化分析
Robot侠2 小时前
视觉语言导航从入门到精通(四)
人工智能·深度学习·transformer·rag·视觉语言导航·vln
四谎真好看2 小时前
MySQL 学习笔记(进阶篇2)
笔记·学习·mysql·学习笔记
思迈特Smartbi2 小时前
思迈特软件斩获鲲鹏应用创新大赛(华南赛区) “最佳原生创新奖”
人工智能·ai·数据分析·bi·商业智能
科技小金龙2 小时前
小程序/APP接入分账系统:4大核心注意事项,避开合规与技术坑
大数据·人工智能·小程序