新闻详情

新闻详情

首页 / 资讯中心 / 详情

本地大模型客服系统实战:Ollama+LangChain+Chroma多轮对话RAG

发布时间:2026/9/30 19:51:44来源:尧图网络
本地大模型客服系统实战:Ollama+LangChain+Chroma多轮对话RAG
这一篇是本地大模型客服系统系列的第四篇。讲到这儿前面三篇的底子已经打好了Ollama 本地推理环境能跑通LangChain 的最基础调用链路也串了起来Chroma 向量库里已经装进了我们的业务文档。这篇要做的就是拿 Python 把这三块粘成一个能真正接客的多轮对话客服系统。如果你一路跟着写到这里这算是收官如果你是直接看到标题进来的也不用回头翻——我会把前面涉及的关键环节在相应位置用最简方式重新交代一遍保证你能跟着代码走通。真正能用的客服系统和单轮问答 demo 最大的区别在于它必须记住用户上一句说了什么知道这一句是在追问还是在换话题还得能随时翻出知识库里的政策条文来回答。这篇文章适合两类人一类是想把本地大模型落地成内部客服、销售助手或教学问答机器人的开发者另一类是已经搭好 demo、但一遇到“多轮”和“检索”就不知道怎么接的初学者。接下来我按架构拆解、会话记忆、知识库细节、完整实现、问题排查这个顺序往下写全程都是能直接复制的 Python 代码和参数经验。1. 多轮客服系统的整体架构与设计思路1.1 从单轮问答到多轮对话到底多出来什么很多人做客服系统一开始直接裸调大模型输入一句用户问题它就回答效果看着不错就以为够了。但真实用户不会老老实实把话一次性说完。举个例子用户上来就说“我要退款”系统回复“请提供订单号”用户接着发来一串数字。如果系统不记得上一轮在聊退款这串数字大概率会被当成一个莫名其妙的问题答非所问。多轮对话本质上要解决三件事记住前面聊过什么、在恰当的时机利用这些信息、在用户切换话题时及时更新状态。具体到客服场景这三个问题会变得更具体。会话状态意味着你要知道这个用户当前在办什么业务是退货、改地址还是开发票上下文管理决定了你能不能把“那个订单”里的“那个”解析成前面对话里出现的具体订单知识库检索则负责随时把最新的售后政策、运费规则、商品参数捞出来塞给模型。这三块单独拎出来都不难难的是把它们组织在一个工程里还不出乱子。整个系统的调用次序是这样的用户输入进入会话管理器按 customer_id 取出历史消息以本次用户问题去 Chroma 检索最相关的知识片段将“系统角色提示词 历史对话 检索片段 用户新问题”拼成消息序列交给 Ollama 本地模型生成回复把 user/assistant 两轮消息写回会话存储再返回给前端这套链路看起来不复杂但每一环都有容易踩坑的细节。会话记忆怎么存、怎么截断、检索怎么调参、提示词怎么拼这些决定了整个系统到底是“能用”还是“真的能上生产”。1.2 技术选型为什么是 Ollama Chroma LangChain这套组合不是唯一的方案但它是把“本地化、轻量、可修改”这三件事平衡得比较好的组合。Ollama 负责本地推理好处是用户对话不出内网对客服系统这种涉及交易和隐私的场景很重要它把模型运行封装成 API和 OpenAI 协议兼容换模型也只是改一个名字的事。Chroma 是纯本地的向量数据库安装就是一个 pip 包数据落到本地文件适合文档规模在几万块以内的知识库起步阶段完全没必要上 Milvus 或 Elasticsearch 那套重武器。LangChain 在这里的角色更像胶水负责文档加载、文本分块、prompt 模板和组织调用链省去很多手写样板代码。下面这张表是我当时选型时的对比。组件职责定位常见替代方案我最终选择的原因Ollama本地大模型推理vLLM、llama.cpp、LM Studio模型管理最简单API 调试直观单卡跑得动Chroma向量存储与检索FAISS、Milvus、Qdrant库轻量PersistentClient 重启不丢数据几万块文本足够LangChain流程编排与集成LlamaIndex、手写胶水代码文档加载、分块、prompt 模板都有现成 API生态匹配 Python需要提醒的是LangChain 的版本迭代非常快网上一搜一大把老代码。我在下面所有代码里默认你用的是当前这代 APIlangchain、langchain-ollama、langchain-chroma、chromadb 按最新稳定版安装。如果看到ConversationChain、LLMChain这类老写法大概率是两三年前的文章照着抄容易翻车。2. 会话记忆与上下文管理这层做不好多轮就是空话2.1 会话隔离业务 Session 与技术 Memory 的分工大模型本身是没状态的。你每次调用 Ollama 的接口它只看得到你这次请求里带的内容。所谓“记忆”本质就是把历史消息作为输入再发一遍。所以做多轮客服系统的第一行醒悟代码不是任何 LangChain 组件而是一个普通的会话存储。客服系统几乎都是多用户并发这里就有一个新手最容易踩的坑把聊天记录放在一个全局列表里。后果是两个用户同时提问历史互相串用户 A 的退款单号跑到用户 B 的对话里去了。正确做法是按客户 ID 或会话 ID 做隔离。我先用内存字典写一个最简的 SessionManager生产环境换成 Redis 或数据库只是换存储实现的问题。from collections import defaultdict class SessionManager: def __init__(self, max_rounds10): self.max_rounds max_rounds self._history defaultdict(list) def get_history(self, session_id): return self._history[session_id] def append(self, session_id, role, content): history self._history[session_id] history.append({role: role, content: content}) # 只保留最近 max_rounds 轮每轮占 user assistant 两条 history history[-self.max_rounds * 2:] self._history[session_id] history def reset(self, session_id): self._history.pop(session_id, None)这个类有几个设计点append 之后把历史截断到 max_rounds 轮避免无限增长reset 用于会话结束后清理。如果你后续上 Redis把self._history的读写换成redis.hset或者 list 操作就好思路完全不变。2.2 LangChain 里三种常用记忆机制怎么选LangChain 官方提供过好几套记忆类网上教程里最常见的是这三种BufferMemory、BufferWindowMemory、SummaryMemory。它们解决的是同一个问题的不同层面但很多初学者分不清什么时候该用哪个。方案机制优点缺点适合场景ConversationBufferMemory直接缓存全部历史文本实现最简单信息不丢失上下文无限涨容易把本地模型窗口打爆只在很短对话里用ConversationBufferWindowMemory只缓存最近 N 轮控制 token 成本保证最近内容完整太早的关键信息会被丢掉客服系统默认选项ConversationSummaryMemory用 LLM 把旧对话压缩成摘要可保留长对话的关键信息每次操作都要多调一次模型延迟高长会话、有耐心等客服系统我一般先用窗口记忆把最近 8~10 轮原样保留如果单次会话很长再引入摘要机制把更早的对话压成两三句话塞进上下文。不要一上来就用复杂方案先看窗口能不能扛住扛不住再升级。需要特别注意摘要方案在本地模型上会有额外代价压缩摘要本身也要消费上下文并且多一次模型推理轮一多延迟和显存都会上来。2.3 新版 LangChain 推荐写法手动拼历史必要时做历史改写现在很多新项目已经不太推荐把ConversationBufferMemory这类类直接往新的 LCEL 链路里塞了。原因很简单LCEL 里一个 Runnable 的输入输出都是数据对象记忆类的副作用藏在内部出了问题不好调试。我自己现在更习惯的做法是把历史当成普通列表在调用模型前拼成 messages。代码一眼能看穿也方便按 customer_id 取不同用户的历史。from langchain_core.messages import SystemMessage, HumanMessage, AIMessage def build_messages(system_prompt, history, user_input): messages [SystemMessage(contentsystem_prompt)] for item in history: if item[role] user: messages.append(HumanMessage(contentitem[content])) elif item[role] assistant: messages.append(AIMessage(contentitem[content])) messages.append(HumanMessage(contentuser_input)) return messages如果你希望“结合历史重新生成一个更适合检索的独立问题”LangChain 也提供了现成的create_history_aware_retriever。思路是先用一个 prompt 把用户的模糊问题比如“那它呢”改写成带上下文的完整问题再拿改写后的问题去 Chroma 检索。这个机制在用户频繁指代上一轮内容时很管用代码也不复杂就是多一个 condense 提示词的事。from langchain.chains import create_history_aware_retriever from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder condense_prompt ChatPromptTemplate.from_messages([ (system, 根据聊天历史和用户最新问题生成一个适合搜索知识库的独立问题。), MessagesPlaceholder(history), (human, {question}), ]) history_aware_retriever create_history_aware_retriever(llm, retriever, condense_prompt)这里假设llm和retriever已经定义好后面第 4 节会给完整代码。先记住有这个东西检索命中率不够的时候再上别一上来就给自己增加调试负担。3. Chroma 知识库检索让模型从“懂话术”变成“懂业务”3.1 入库之前的分块与向量化选型Chroma 本身不会替你理解文档它只按向量相似度找东西。所以知识库效果好不好一半在分块一半在 Embedding 模型。很多人在这一步随便设个参数就入库回头发现回答质量差跑来调模型参数其实问题根本不在模型。默认情况下我习惯用RecursiveCharacterTextSplitterchunk_size500、chunk_overlap80。这四个参数看着简单其实很讲究。块太短答案可能被截断后半句没进库块太长一个块里夹着好几句话检索时噪声会把真正答案淹没。overlap 的意义是让被切开的句子有上下文残留避免“订单号是 A123”这种关键信息刚好落在两块交界处而两边都不完整。如果是 FAQ 问答对我建议干脆不要按字数切一个问题加一个回答整体作为一块检索直接命中一问一答效果最好。Embedding 模型的选择也要看场景。我实际对比过的方案是下面这几个。Embedding 模型向量维度中文效果获取方式备注nomic-embed-text768一般ollama pull 即可轻量英文为主bge-m31024较好ollama 或 sentence-transformers中英文综合更好体积偏大bge-small-zh512中上sentence-transformers体积小纯中文场景够用纯中文客服场景我优先推荐 bge-m3。下面是完整的入库代码用 LangChain 的 DirectoryLoader 加载目录下的文本文件切块后灌入 Chroma。from langchain_community.document_loaders import DirectoryLoader, TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter from langchain_ollama import OllamaEmbeddings from langchain_chroma import Chroma loader DirectoryLoader(./kb_docs, glob**/*.txt, loader_clsTextLoader) docs loader.load() splitter RecursiveCharacterTextSplitter(chunk_size500, chunk_overlap80) chunks splitter.split_documents(docs) embeddings OllamaEmbeddings(modelbge-m3, base_urlhttp://localhost:11434) vectorstore Chroma.from_documents( documentschunks, embeddingembeddings, collection_namecustomer_service_kb, persist_directory./kb_store, )入库是一次性操作跑完之后kb_store目录里就是持久化好的向量数据。下次服务启动不要再用from_documents重新入库直接Chroma(collection_name..., persist_directory..., embedding_function...)恢复集合就行。3.2 检索参数、降噪与兜底设计入库之后就要面对检索环节的调参。我的常用配置是这样vectorstore.as_retriever(search_typesimilarity, search_kwargs{k: 3})。k 取 3 到 5 之间不要取 10。检索是召回不是全给片段越多模型越容易把不相关的东西也编进去客服场景下更容易一本正经地胡说八道。知识库越干净、FAQ 越规范k 可以越小。相似度阈值这块因人而异因为不同的 Embedding 模型产出的分数区间完全不一样。我的做法是先压测二三十个真实客服问题看命中的分数分布再定阈值。比如 bge-m3 的得分普遍偏高0.5 都算低nomic 则普遍偏低。低于阈值时不要硬编答案在系统提示词里写清楚“如果检索内容没有提供明确依据请告知用户暂时无法确认并引导转人工或留下联系方式”这个兜底话术比任何模型调优都管用。如果后面还想提升检索精度可以考虑两段式Chroma 先粗召回 10 条再用重排模型比如 BAAI/bge-reranker精排取前 3 条。这在本地也可以跑但会多几百毫秒延迟不是所有场景都需要。另外客服文档最好区分集合一份 FAQ 问答对一份产品文档一份售后政策分开检索再合并比全塞一个 collection 更可控。4. 从零串起来LangChain 串联 Ollama 与 Chroma 的多轮客服链路4.1 最小可运行核心代码五步串起整条链路下面这段代码是整篇文章的心脏。我默认你的 Ollama 已经在本地 11434 端口跑起来了模型qwen2.5:7b或者你手头任何对话模型和 Embedding 模型bge-m3都能正常调用Chroma 里也已经入库了业务文档。如果还没有回到上一节的入库代码先跑一遍。from langchain_ollama import ChatOllama, OllamaEmbeddings from langchain_chroma import Chroma from langchain_core.messages import SystemMessage, HumanMessage, AIMessage # 1. 对话模型本地推理 llm ChatOllama( modelqwen2.5:7b, base_urlhttp://localhost:11434, temperature0.2, num_predict1024, ) # 2. 向量检索从本地持久化目录恢复集合 embeddings OllamaEmbeddings(modelbge-m3, base_urlhttp://localhost:11434) vectorstore Chroma( collection_namecustomer_service_kb, persist_directory./kb_store, embedding_functionembeddings, ) retriever vectorstore.as_retriever( search_typesimilarity, search_kwargs{k: 3}, ) # 3. 系统提示词 SYSTEM_PROMPT 你是「青橙数码」的售后客服助手。 请基于下面提供的知识库片段回答用户问题不要编造知识库之外的规则。 如果用户问题在知识库中没有明确依据请说明情况并引导转人工。 【知识库片段】 {context} def chat_with_kb(session: dict, user_input: str) - str: # 4. 检索知识片段 docs retriever.invoke(user_input) context \n\n.join(doc.page_content for doc in docs) # 5. 拼接历史消息 messages [SystemMessage(contentSYSTEM_PROMPT.format(contextcontext))] for item in session[history]: if item[role] user: messages.append(HumanMessage(contentitem[content])) else: messages.append(AIMessage(contentitem[content])) messages.append(HumanMessage(contentuser_input)) # 6. 调用本地模型 response llm.invoke(messages).content # 7. 回写历史 session[history].append({role: user, content: user_input}) session[history].append({role: assistant, content: response}) return response系统提示词里只放了两段一段角色定位一段知识库片段。注意 context 是用检索结果动态填充的每次用户问题不同检索结果不同提示词也随之变化。这就是 RAG 和纯模型直接回答的本质区别模型不用背文档只需要在回答前“看一眼”相关资料。temperature0.2是我在客服场景常用的值。太低会重复太高会飘0.2 算是“稳定但不说车轱辘话”的平衡点。num_predict1024限制单次生成长度客服回答一般几百字就够给 1024 可以防止模型长篇大论也避免本就不富裕的显存上下文被回答撑爆。4.2 多轮会话的落库、上限管理与扩展上面代码里的 session 就是一个普通 dict类似{history: []}。真实项目里别用全局 dict 硬扛。哪怕只是单机部署我也建议用 Redis 存会话Key 用 customer_idvalue 存历史的 JSON如果不想引入 RedisSQLite 也可以。原因有两个一是服务重启后内存数据全丢用户正聊着天突然断了历史没了很尴尬二是客服系统往往有审计需求对话记录要能按人、按时间查。上下文窗口是有限资源。以 qwen2.5:7b 在个人显卡上跑 4k~8k 上下文为例中文下大约一个字符对应 1~2 个 token。如果你保留 10 轮对话按每轮 100~200 字算大约 2000~4000 token再塞入检索片段和系统提示词压力已经不小。所以 SessionManager 里那个max_rounds10不是拍脑袋写的先确定自己能接受的上下文长度再反推保留轮数。宁可牺牲一点长期记忆也不能让上下文超窗否则模型直接报错或胡言乱语。想要更精细可以在历史接近上限时把最老的几轮用 LLM 压成摘要替换掉原文再继续塞新对话。这就是前面提到的 Summary 思路。注意摘要本身也别太长两三句话就好否则省下的 token 又还回去了。4.3 实测效果一个多轮消费者咨询样本写完代码别急着上生产先拿脚本模拟几段典型对话。我习惯每个模块都做三组测试常规问答、连续追问、知识库未命中。下面是我当时用 qwen2.5:7b bge-m3 跑出来的对话记录问题都是照着真实售后场景写的。用户你们店里的蓝牙耳机支持7天无理由退货吗 客服根据知识库里的售后政策商品在签收后7天内可以申请无理由退货但需要确保包装和配件完整。您可以直接发起退货申请。 用户那这个耳机盒丢了还能退吗 客服无法申请无理由退货。知识库资料显示缺少包装或配件的退货申请会被驳回。您需要找一下原包装盒或者联系人工客服协商是否有其他方案。 用户如果只退充电线呢包装完整也一样不能退吗 客服只退配件的情况比较特殊。目前的退货规则是针对整套商品单独退充电线需要先联系人工客服确认知识库里没有明确说明配件单独退货的条款。第一轮是标准 RAG 回答检索命中售后政策。第二轮里的“这个耳机盒”和“丢了”都依赖上一轮的商品上下文系统通过历史拼接把该信息传给了模型。第三轮是一个知识库没有明确答案的问题模型没有硬编而是选择了“联系人工客服确认”这就是兜底话术起了作用。如果你测出来哪一轮答得不对先看检索返回了什么内容再看提示词里是不是把该说的边界说清楚了。5. 常见问题排查与性能优化实录5.1 高频报错速查表下面是这个系统从搭建到运行我遇到频率最高的几类问题直接整理成速查表。很多问题不是你的代码写错了而是环境、版本、资源的问题。报错现象可能原因处理思路Ollama 调用返回 500日志里提示 llama-server process 崩溃显存不够或模型文件损坏降低模型参数量或换量化版本删掉旧模型重新加载更新 Ollama 版本检查显卡驱动检索结果为空或匹配明显不对Embedding 模型与文档不匹配或分块太碎换 bge-m3 这类中文模型调大 chunk_size检查 top_k 和相似度阈值AttributeError: module langchain has no attribute ...旧教程基于老版本 API以 langchain-ollama、langchain-chroma 的新导入路径为准不要在旧代码上硬改Chroma 重启后数据丢失误用临时目录或内存模式使用 PersistentClient 固定绝对路径 persist_directoryOllama 模型下载一直失败或很慢在线拉取不稳定从 ModelScope 社区下载 GGUF 权重用 Modelfile 导入 Ollama 离线部署还有一种很隐蔽的情况Ollama 服务起来了但模型没加载第一次请求特别慢。这是正常现象Ollama 默认按需加载模型首次推理会把权重读入显存后面就快了。别当成 bug要么提前ollama run热一下模型要么在调用接口时设置 keep_alive。5.2 性能与稳定性调优流式输出、并发与离线模型导入客服系统在前端通常希望有打字机一样的流式输出LangChain 里直接for chunk in llm.stream(messages)就行逐字返回。流式输出不只是体验问题它还能让用户在模型还在推理时就看到反应心里不慌。后端如果走 WebSocket 或 SSE把每个 chunk 推给前端即可。并发这块要注意本地单卡别贪。Ollama 的并发数由OLLAMA_NUM_PARALLEL控制默认值在某些版本里会根据显存自动调但 7B 模型在个人显卡上建议手动限制为 1~2。并发太高最直接的后果是显存 OOMOllama 进程直接崩掉整个客服线全挂。宁可排队不要崩溃。关于模型下载慢我给一个不依赖在线拉取的稳妥姿势去 ModelScope魔搭社区找对应模型的 GGUF 权重文件下载到本地后写一个 Modelfile再执行ollama create 你的模型名 -f Modelfile导入。导入完成后使用方式和ollama pull来的完全一样。这招不仅解决下载问题还能把整套模型文件拷到内网机器上离线部署对很多不能连外网的生产环境反而是刚需。# Modelfile 示例本地导入 qwen2.5-7b-instruct # 具体 TEMPLATE 内容以模型官方仓库给的为准 FROM /data/models/qwen2.5-7b-instruct.gguf TEMPLATE {{ if .System }}|im_start|system {{ .System }}|im_end| {{ end }}|im_start|user {{ .Prompt }}|im_end| |im_start|assistant SYSTEM 你是客服助手检索延迟方面每次请求都要对用户问题做一次 Embedding这个操作本身很快通常几十毫秒。真正慢的是把大量文档第一次灌进 Chroma 的入库阶段那是离线操作别放进用户请求链路里。如果 collection 越来越大可以关注 Chroma 的 HNSW 参数调整 M 和 efConstruction但这些默认值对中小知识库已经够用。5.3 运行小半年后重新审视这套方案这套系统跑了小半年我最深的体会是本地大模型客服系统的性能瓶颈不在模型智商而在上下文拼接和知识检索这两个工程细节。模型答得好不好一半靠检索有没有把对的资料找出来一半靠提示词有没有把边界说清楚模型本身反而是最不操心的部分。很多教程喜欢一上来就整智能体、整 LangGraph、整各种重型架构但对绝大多数客服场景先把普通 RAG 做稳定比堆概念重要得多。最后分享一个我的迭代习惯先用最小闭环跑通再逐步加功能。第一次上线SessionManager 用内存 dict 完全可以检索就用 top_k3 加阈值兜底等流量上来了再把存储换成 Redis把检索升级成粗召回加重排最后才谈要不要用 LangGraph 做对话状态机。每一步都有明确的优化动机而不是因为“别人的架构里有”。希望这篇能把你的本地客服系统往前推进一大步遇到问题欢迎按第 5 节的排查表对照着查。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

动态口令认证价格 2026/9/30 21:02:40

动态口令认证价格

动态口令认证价格 动态口令认证价格为什么问三家能报出三个量级?因为"动态口令"这四个字底下压着六种完全不同的成本项,而大多数报价只报了其中两三项。 同一句需求,三家供应商的回执: A:按用户数年费&…

阅读更多 →
在 Cursor 与 VSCode 中切换主题:用 TaoToken 统一配置 settings.json 的完整指南 2026/9/30 21:02:34

在 Cursor 与 VSCode 中切换主题:用 TaoToken 统一配置 settings.json 的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
嵌入式驱动开发:从能跑到量产的工程化思维 2026/9/30 21:02:34

嵌入式驱动开发:从能跑到量产的工程化思维

1. 从“点灯成功”到“量产翻车”:一个让无数驱动开发者破防的瞬间 如果你在嵌入式这行待过哪怕半年,大概率经历过这样一个场景:板子刚打回来,你花了一个下午把驱动调通,串口打印出“init success”,LED 按…

阅读更多 →
开源利器!让DeepSeek V4 Flash在Terminal-Bench上超越Fable 5,还省11倍——TaoToken统一Key接入实战 2026/9/30 21:02:07

开源利器!让DeepSeek V4 Flash在Terminal-Bench上超越Fable 5,还省11倍——TaoToken统一Key接入实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
黑通道协议可自定义吗?解析工业功能安全的配置边界 2026/9/30 21:02:00

黑通道协议可自定义吗?解析工业功能安全的配置边界

1. 黑通道协议不是“黑盒”,而是工业安全通信的底层契约“可以自定义黑通道协议吗?”——这个问题在自动化工程师群里一抛出来,往往立刻引发两极反应:有人秒回“绝对不行,这是安全红线”,也有人困惑&#x…

阅读更多 →
Agent Skill: react-best-practices 实战大纲:把 React 最佳实践封装成可复用技能 2026/9/30 21:02:00

Agent Skill: react-best-practices 实战大纲:把 React 最佳实践封装成可复用技能

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉