ARTICLE DETAIL

资讯详情

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

基于聊天软件的私人AI助理:会话搜索与云端协作实战

基于聊天软件的私人AI助理:会话搜索与云端协作实战 1. 从记不住话的机器人到住进聊天软件的私人助理上个月我把自己的私人AI助理项目更新到了2.0版核心是两个能力会话搜索和云端协作。这个项目是开源的做的事情其实很简单——让一个基于大模型API的AI助理长期住在聊天软件里随时能调出之前的对话也能在多台设备之间保持一致。1.0版的时候它还只是个能回复消息的机器人2.0版之后我开始把它当成真正的私人助理用提醒我交房租、帮我查项目文档、在家庭群里帮所有人记住物业电话是多少。项目最初不叫ChatPal叫ChatBot后来觉得它不应该只是能聊天就改成了ChatPal。代码放在GitHub上配置好飞书机器人之后私聊窗口就是你和助理的专属通路拉一个群进去它就变成了共享助理。这个定位听起来很普通但实际使用后你会发现搜索和同步这两件事决定了它到底是玩具机器人还是私人助理。如果你也在做AI应用开发或者单纯想给自己搭一个私有助理这篇文章里的设计和踩坑过程应该能帮上忙。我会从为什么做、怎么实现、怎么部署一直讲到那些文档里不会写的坑。1.1 为什么我放弃了网页端把助理放进聊天软件两年多以前我先做了一个网页版的AI助手输入框加聊天记录界面谈不上好看但也能用。结果不到一个月就放弃了。原因特别现实浏览器里开一个标签页和手机上的聊天软件常驻使用成本完全不一样。网页版需要我主动想起来去打开聊天软件却是我每天都会看好几次的地方。把助理放进聊天软件等于让助理住在用户本来就会打开的入口里这一步省掉的不只是开发量还有最容易被低估的被使用概率。聊天软件的第二个优势是多端同步和通知。手机、桌面、平板全部都有客户端消息直接推系统通知栏不需要自己再写一套多端UI。对个人项目来说这能省掉大量前端工作。飞书的机器人API支持长连接模式不需要公网回调地址本地跑个进程就能连上权限模型也清楚私聊消息、群消息、机器人这些事件都有明确标识。项目里预留了钉钉和开源IM的适配层只要是Bot API能覆盖的平台都能接进来。更重要的一点是这个助理不只是一个问答机器人。我给它加了一些工具调用比如查天气、设置提醒、查数据库、执行白名单里的shell命令。也就是说它可以按意图去调用工具再基于结果回复。这其实就是现在常说的AI agent的最小形态。但agent要真正好用不能每一次都从空白开始它需要记住之前聊过什么、执行过什么。这就是会话搜索和云端协作要解决的问题。1.2 1.0版跑通之后最让我抓狂的三个问题1.0版上线之后一开始觉得能聊就够了。但用了两三天问题就全暴露了。第一个问题是没有记忆。大模型上下文窗口再大也是有限的我只能把最近十几条消息拼进prompt。一旦聊得长助理就开始失忆。前一天我让它记录一个服务器路径第二天再问它完全不记得。后来我试过把历史记录全部塞进prompt结果token暴涨回复延迟高费用也翻了好几倍根本不可持续。第二个问题是没有搜索。就算我把消息存在数据库里会话一多靠人工翻聊天记录效率也极低。我经常想找上个月讨论的那个接口文档链接聊天软件自带的聊天记录只覆盖人和人之间的消息机器人的回复内容在另一端我只能一条条往上翻非常痛苦。第三个问题是没有同步。聊天软件本身有云端能同步你和机器人的对话显示但机器人的记忆不在聊天软件里而在运行它的服务器上。我换一台电脑部署之前所有会话都带不走。更不用说想把助理共享给家人或者同事一起用完全没有概念。这三个痛点合在一起让2.0版本的方向非常明确把会话数据变成可搜索、可同步、有权限控制的记忆库让助理住进聊天软件之后真正拥有回忆。2. 2.0版功能拆解会话搜索搜的是什么云端协作协的是什么先给结论会话搜索不是聊天软件自带的搜索框云端协作也不是把数据扔给第三方AI平台。这两个能力本质上都是围绕会话数据做文章。搜索解决的是如何快速找到历史上某段内容云端协作解决的是这些内容如何安全地分布在多个设备、多个成员之间。2.1 会话搜索从关键词命中到语义召回很多人以为搜索就是SQL里的LIKE查询或者用Elasticsearch建个索引。但在聊天场景里搜索需求比想象中复杂。用户会问我上次让你记的那个数据库密码是什么原始消息可能是记录一下生产库密码Admin123这两个文本之间几乎没有字面重叠。靠关键词很难搜到。所以ChatPal 2.0的搜索是双通道的。第一通道是关键词全文搜索适合精确匹配比如搜服务器IPMeeting ID这类无歧义内容。第二通道是语义搜索把每条消息的内容先通过Embedding模型转成向量搜索时把用户当前的问题也转成向量用余弦相似度召回语义相近的消息。两个通道的结果会做合并排序保证精确词命中和语义相关的内容都能出来。搜索时还可以限定范围比如时间范围、某个会话、某个成员、assistant还是user的消息。默认情况下每个用户只能搜索自己参与的会话群聊和共享会话则按照成员权限来控制。这个权限设计看起来简单实际却很容易被忽略——如果没有它一个群成员就能通过搜索摸到其他所有人的私聊记录那私人助理就变成隐私灾难了。2.2 云端协作多设备同步和共享会话的边界云端协作分两层。第一层是多设备同步。我在服务器上跑着ChatPal数据和搜索索引都存在服务器本地。手机上通过飞书机器人发消息会话记录会实时写入服务器电脑上同样接入同一个机器人两边看到的就是同一份记忆。同步服务负责把新消息增量推送给所有已连接的客户端离线期间的变更会在重连后补齐。这样换手机、换电脑都不影响助理的回忆。第二层是共享会话。把机器人拉进一个群这个群里的所有消息和回复都会落到同一个session里。群里的每个成员都可以机器人提问机器人会基于整个群的共享历史来回答。家庭场景里所有人都能问上次说的物业电话是多少小团队场景里所有人都能查昨天开会定的截止时间。边界在于同步的是会话记录不是模型调用本身。模型API仍然是每个部署者自己配置的数据不会交给某个公共云AI平台。同步服务也是自托管的消息通过HTTPS和访问令牌保护。这样云端协作对用户来说是有隐私边界的协作不是裸奔上云。要理解这个边界可以把它类比成AI助理的大脑还在你自己手里只是把日记本同步给了你信任的另一台设备。2.3 升级后的整体架构组件职责我的选型聊天适配层对接飞书/钉钉/Rocket.Chat等IM内置adapter长连接模式会话存储消息、用户、会话、共享成员关系SQLite单机自托管/ PostgreSQL多人协作搜索索引关键词FTS 语义向量SQLite FTS5 sqlite-vecLLM网关调用模型API统一OpenAI兼容格式OpenAI兼容接口 / 本地Ollama工具执行意图识别、函数调用、命令执行白名单Python async tasks同步服务增量同步、游标管理、通知REST API WebSocket整个流程是用户消息从聊天软件进来适配层把它转成统一事件事件同时进入存储层和LLM网关LLM判断是否需要调用工具如果需要就执行并拿结果生成回复回复发回聊天软件同时落库、更新索引、触发同步通知。搜索和同步是旁路不阻塞聊天主流程。这样设计有个好处即使搜索服务器暂时挂了聊天的基本功能也不受影响。3. 关键技术实现让搜索和协作不拖垮聊天软件这一章是全文最硬核的部分。我会把存储模型、搜索实现、同步机制拆开讲讲为什么这样设计以及哪些地方容易翻车。3.1 会话存储模型设计分区、去重与全文索引先看表结构。messages表是核心我用自增id作为主键同时维护一个全局唯一的msg_uuid用来做多客户端去重和同步幂等。session_id是会话ID由适配层在消息进入时确定。role字段区分user、assistant、tool和system方便搜索时按角色过滤。created_at存的是Unix时间戳所有时间比较都用它避免时区问题。CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, msg_uuid TEXT NOT NULL UNIQUE, session_id TEXT NOT NULL, sender_id TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN (user,assistant,tool,system)), content TEXT NOT NULL, content_seg TEXT NOT NULL DEFAULT , created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_messages_session_time ON messages(session_id, created_at);这里有个关键细节content_seg列是给FTS5用的。SQLite自带的FTS5分词器对中文支持不好所以我不用触发器而是在应用层写入消息时先用jieba对content分词把分词结果连同原始内容一起存进content_seg。搜索时也走同样的分词函数这样FTS5匹配的就是词粒度而不是一整个长串字符。这个坑在第五章会专门展开。FTS5虚拟表用外部内容模式不冗余存储原始内容只存分词后的索引CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( content_seg, contentmessages, content_rowidid );外部内容表的好处是消息更新、删除时索引更容易维护。但要注意外部内容表需要配合触发器或者应用层在写入/删除时同步操作FTS5表。我在项目里选择在应用层统一处理因为分词本来就在应用层再写一套触发器反而割裂。有人会问为什么不用Elasticsearch或者Meilisearch个人助理场景下消息量级通常不会大到需要独立搜索引擎SQLite一个文件就能搞定备份也简单。等消息量真到几十万条再迁移到独立搜索引擎也不迟。3.2 搜索实现SQLite FTS5 向量召回的双通道关键词搜索的部分SQL很简单SELECT m.session_id, m.created_at, m.content, bm25(messages_fts) AS score FROM messages_fts JOIN messages m ON m.id messages_fts.rowid WHERE messages_fts MATCH ? ORDER BY score LIMIT 20;bm25()是FTS5内置的排序函数效果接近搜索引擎的BM25算法适合聊天文本。MATCH里面传入的是分词后的查询词比如会议 改期。分词后的查询词和content_seg必须用同一套分词逻辑否则会出现搜不到的诡异情况这一点一定要在单元测试里覆盖。语义搜索的部分我用了轻量级的方案消息写入时调用Embedding模型生成向量存到向量表或者单独的sqlite-vec扩展里。搜索时把用户当前问题编码成向量然后计算余弦相似度def semantic_search(query: str, session_id: str, top_k: int 20): q_vec embedding_model.encode(query) results vector_store.search(q_vec, session_idsession_id, top_ktop_k) return results # [{msg_id, score}]双通道的结果合并我没有用复杂的加权公式而是用RRFReciprocal Rank Fusion倒数排名融合def rrf(ranked_lists, k60): score {} for ranked in ranked_lists: for pos, doc_id in enumerate(ranked): score[doc_id] score.get(doc_id, 0) 1 / (k pos 1) return sorted(score.items(), keylambda x: -x[1])RRF的好处是它不依赖两个通道分数的量纲是否一致只看排名。实测下来关键词结果和语义结果各自的前20名融合后top5基本就是用户想找的内容。比如搜一下上周说的那本书这种话关键词通道基本抓瞎语义通道能从数据结构与算法那条消息里召回RRF再把两个结果合并用户看到的排序就理性很多。3.3 云端同步自托管同步服务的增量合并策略同步服务的核心是一个游标机制。每个客户端维护一个last_sync_id每次拉取时带上这个游标服务端返回自游标以来的新消息接口方法作用/sync/messages?since123session_idxxGET增量拉取消息/sync/messagesPOST推送本端新消息/sync/notificationsWebSocket服务端主动通知有新消息本地写入消息时客户端先生成msg_uuid再发到同步服务同步服务按服务器时间排序后分配全局自增ID再广播给其他客户端。因为聊天消息基本是append-only绝大多数情况下不会冲突。真正容易冲突的是收藏置顶标记已读这类状态字段。我的做法是统一用updated_at做last-write-wins谁后更新谁生效。这里有个小细节时间戳必须用服务器时间不能用客户端本地时间否则手机时钟不准就会导致旧状态覆盖新状态。同步时最需要注意的是幂等。客户端可能因为网络重试同一个msg_uuid被POST两次。我在messages表上给msg_uuid加了UNIQUE约束重复插入时直接忽略这样就不会产生重复消息。另一个细节是同步服务不能只同步消息正文搜索索引也要跟着更新。收到远端消息后本机要重新写入本地消息表并更新FTS5和向量库。如果你的架构是同步服务和搜索索引两套独立模块很容易漏掉这一步结果就是聊天记录都有了搜索却搜不到远端同步过来的内容。4. 部署与接入从零开始跑起一个2.0实例讲完设计进入实操。我尽量按我现在拿到一台干净服务器的流程来写。4.1 准备工作机器人App、模型服务与项目代码第一步创建飞书机器人。在飞书开放平台创建一个企业自建应用拿到App ID和App Secret在权限管理里开通接收消息和发送消息的权限在事件订阅里选择长连接模式订阅im.message.receive_v1事件。这里用长连接而不是Webhook是为了避免公网回调地址的麻烦部署在本地NAS或者内网服务器上也能用。第二步准备模型服务。如果你有云端模型API用兼容OpenAI格式的接口就行如果没有可以在服务器上装Ollama跑一个开源模型比如qwen2.5或llama3。ChatPal的LLM网关统一走OpenAI兼容格式所以只需要改base_url和model。我自己的环境里就常年跑着Ollama把模型拉下来之后API key填个无意义的字符串就行数据完全不出本机。第三步获取项目代码。仓库里提供了Docker镜像和源码两种方式。建议先用Docker跑通再去看源码改逻辑。4.2 配置文件与启动命令项目目录下有一个config.yaml模板关键配置如下adapter: type: feishu app_id: ${CHATPAL_APP_ID} app_secret: ${CHATPAL_APP_SECRET} llm: provider: openai_compatible base_url: http://host.docker.internal:11434/v1 api_key: unused model: qwen2.5:7b search: enabled: true semantic: true embedding_model: bge-m3 sync: enabled: true data_dir: /app/data/sync这里api_key用环境变量注入不要直接写进仓库。本地Ollama的话base_url在Docker容器里要写host.docker.internalWindows/macOS没问题Linux需要加--add-host参数或者直接用宿主机IP。启动用docker composeservices: chatpal: image: ghcr.io/chatpal/chatpal:2.0 restart: unless-stopped volumes: - ./data:/app/data - ./config.yaml:/app/config.yaml environment: CHATPAL_APP_ID: ${CHATPAL_APP_ID} CHATPAL_APP_SECRET: ${CHATPAL_APP_SECRET}然后执行docker compose up -d看日志docker compose logs -f chatpal如果看到feishu adapter connected和sync service started说明机器人和同步服务都已经起来了。提示如果日志里一直看不到im.message.receive_v1相关记录先别急着查代码多半是飞书开放平台里的事件订阅没有启用或者应用没有发布版本。机器人App没发布事件是推不过来的。4.3 在飞书里验证基本对话打开飞书搜索你创建的应用名称进入机器人会话。发送/start如果一切正常机器人会返回欢迎语。然后发一句普通问题比如你好请介绍一下你自己观察日志里有没有LLM调用记录。注意一点飞书机器人默认可能需要在应用详情页打开机器人能力并发布应用版本否则不能被其他成员发现。首次调试时权限没配好最常见的问题就是机器人收不到消息或者回复发送失败。这时候优先去看开放平台的事件订阅日志确认im.message.receive_v1事件有没有被正确投递到长连接。验证基本对话时我建议一步一步来先不开搜索和同步单独验证消息进来-LLM回复这条链路。等这条链路稳定了再打开search.enabled最后再开sync.enabled。一次只动一个开关出问题的时候才能快速定位。4.4 实测会话搜索与云端协作的完整流程对话功能跑通后我们来验证核心功能。搜索验证给机器人发送记录下周三下午三点和客户开需求评审会会议号8848然后过几秒发送帮我搜一下会议号。机器人应该通过关键词搜索返回原始记录。再发送搜一下评审的时间这句话里没有会议号但语义上关联语义搜索通道会把它召回。协作验证建一个群把机器人和另一位成员拉进群。在群里机器人发送记录物业电话是010-12345678然后让另一位成员也在群里机器人问物业电话是多少。只要群成员权限配好机器人会在共享会话的搜索里找到这条消息并回答。如果本地开了两个客户端或者同时用手机和桌面端飞书你会发现另一个端在机器人回复后也会同步收到这条记录。同步状态可以通过日志里的sync push success确认。想要更严谨地验证可以强行断网一台设备发几条消息后再连回来看它是否能补拉离线期间的消息。这样就能确认增量同步不是只在网络畅通时好使。5. 踩坑实录中文搜索、同步冲突和聊天软件限流这章是这次升级里最想分享的部分。很多问题不看源码和运行日志根本查不出来。5.1 中文分词为什么明明有消息却搜不到第一个让我头疼的问题就是中文搜索。FTS5默认的unicode61分词器会把连续汉字当成一个token。也就是说消息下周三下午开会会被索引成一个整体下周三下午开会我再搜开会MATCH匹配不到。明明数据库里有这条记录搜索就是返回空。解决方案就是content_seg列。写入时用jieba分词把下周三下午开会变成下周三 下午 开会再存入FTS5索引搜索时把查询词也做同样处理。实测效果立竿见影。这里还有一个坑jieba对专有名词、人名、产品名的分词效果不稳定。比如ChatPal可能会被切成Chat和Pal导致搜ChatPal时匹配不到。解决办法是维护一个自定义词典在启动时加载jieba.load_userdict(custom_dict.txt)文件里每行一个词比如ChatPal 100 nz。做私人助理时把自己经常提到的项目名、同事名字、小区名称加进去搜索准度会提升一大截。我一开始偷懒没加词典结果搜ChatPal搜不到很久之后才意识到是分词问题。5.2 同步冲突两台设备同时改一个会话多设备同步一开第一个暴露的问题就是顺序错乱。我在手机上发了一条消息在电脑上几乎是同时发了一条同步服务收到后按服务器时间排序结果因为网络抖动电脑那条先到最终顺序和用户发出的顺序不一致。聊天消息本身不是强一致需求顺序略微错乱可以接受但如果影响AI回复上下文就会很怪。我的处理方式比较折中消息落库后按created_at排序同时把客户端时间戳统一转换为服务器时间。对于用户主动标记的状态比如收藏完成这类字段用updated_at做last-write-wins。不管哪台设备最后改都以服务器收到的时间为准而不是客户端本地时间避免手机时钟不准造成的覆盖。测这个问题的简单方法是准备两个客户端同时发消息再对比最终会话内容。如果出现重复消息优先查msg_uuid的幂等逻辑如果出现状态覆盖查LWW的时间戳来源。当时我排查了很久才发现电脑和手机的时钟差了将近30秒导致线上状态被一台旧设备覆盖改成服务器时间后问题立刻消失。5.3 聊天平台API限流与重试飞书机器人API有频控限制尤其是群消息多的时候回复太快会被429。第一个版本我在AI回复生成后直接调用send API结果高峰期经常丢消息。后来加入了发送队列和指数退避重试def send_with_retry(msg, max_retries5): for i in range(max_retries): try: return adapter.send(msg) except RateLimitError: time.sleep(min(2 ** i random.random(), 30)) return None指数退避里加一点随机抖动是为了避免多个进程同时重试造成重试风暴。除了消息发送LLM网关调用也要做限流。本地Ollama一次只能处理有限并发如果飞书群里一下子来好几个直接在代码里用信号量限制并发请求数否则本地模型会排队排到超时。AI回复比较长时还要按聊天软件的消息长度限制分片发送。飞书单条消息长度有上限我一开始把整个回复直接发送结果被平台截断。后来改为按字符数分片同时保留markdown格式的完整性。这个细节看起来小但直接影响机器人回复的阅读体验。5.4 性能与隐私几个掏心窝的建议最后聊点建议。如果只是个人使用每天几百条消息SQLite完全够用。但一旦开启语义搜索向量库的数据量会快速膨胀。我的经验是消息量超过10万条之后不要再用纯SQLite存向量应该换成更专门的向量存储比如LanceDB或者Chroma虽然部署会多一个依赖但检索速度和内存占用都不一样。隐私方面即便聊天平台本身有加密也要把API Key和App Secret通过环境变量注入日志里不要打印任何token。同步服务器如果部署在公网务必启用HTTPS并在配置里开启每设备访问令牌。这样即使同步数据库泄漏攻击者也无法直接拿到其他会话的明文内容至少在传输层和访问层有两个独立保障。如果对隐私要求极高可以考虑在客户端做端到端加密后再同步但那样搜索索引也得在本地解密后重建复杂度会明显上升我目前只把它作为可选实验功能。备份同样重要。我一开始只备份了SQLite文件后来发现向量库和自定义词典没备份恢复后搜索功能是残废的。直接备份整个data目录才是最省心的方式建议用定时快照或者文件同步工具把它同步到另一台机器上。我在实际使用中还会在每周备份后手动搜一次上周的对话确保备份不是文件在那里躺着、实际却不可用。最后再分享一点如果你也想做一个住进聊天软件的私人助理我建议把顺序反过来先做会话存储和搜索再去做花哨的工具调用。我的1.0版就是先加了各种工具结果助理很能干但记不住事越能干越像金鱼。搜索和同步看着不起眼但它们是让助理真正记住你的前提。希望这篇记录能帮你少踩几个坑。
返回列表