基于聊天软件的私人AI助理:会话搜索与云端协作实战
2026/9/9 8:01:17 网站建设 项目流程

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建个索引。但在聊天场景里,搜索需求比想象中复杂。用户会问"我上次让你记的那个数据库密码是什么",原始消息可能是"记录一下生产库密码,Admin@123",这两个文本之间几乎没有字面重叠。靠关键词很难搜到。

所以ChatPal 2.0的搜索是双通道的。第一通道是关键词全文搜索,适合精确匹配,比如搜"服务器IP""Meeting ID"这类无歧义内容。第二通道是语义搜索,把每条消息的内容先通过Embedding模型转成向量,搜索时把用户当前的问题也转成向量,用余弦相似度召回语义相近的消息。两个通道的结果会做合并排序,保证精确词命中和语义相关的内容都能出来。

搜索时还可以限定范围,比如时间范围、某个会话、某个成员、assistant还是user的消息。默认情况下,每个用户只能搜索自己参与的会话,群聊和共享会话则按照成员权限来控制。这个权限设计看起来简单,实际却很容易被忽略——如果没有它,一个群成员就能通过搜索摸到其他所有人的私聊记录,那私人助理就变成隐私灾难了。

2.2 云端协作:多设备同步和共享会话的边界

云端协作分两层。

第一层是多设备同步。我在服务器上跑着ChatPal,数据和搜索索引都存在服务器本地。手机上通过飞书机器人发消息,会话记录会实时写入服务器;电脑上同样接入同一个机器人,两边看到的就是同一份记忆。同步服务负责把新消息增量推送给所有已连接的客户端,离线期间的变更会在重连后补齐。这样换手机、换电脑都不影响助理的"回忆"。

第二层是共享会话。把机器人拉进一个群,这个群里的所有消息和回复都会落到同一个session里。群里的每个成员都可以@机器人提问,机器人会基于整个群的共享历史来回答。家庭场景里,所有人都能问"上次说的物业电话是多少";小团队场景里,所有人都能查"昨天开会定的截止时间"。

边界在于:同步的是会话记录,不是模型调用本身。模型API仍然是每个部署者自己配置的,数据不会交给某个公共云AI平台。同步服务也是自托管的,消息通过HTTPS和访问令牌保护。这样"云端协作"对用户来说是有隐私边界的协作,不是裸奔上云。要理解这个边界,可以把它类比成:AI助理的大脑还在你自己手里,只是把"日记本"同步给了你信任的另一台设备。

2.3 升级后的整体架构

组件职责我的选型
聊天适配层对接飞书/钉钉/Rocket.Chat等IM内置adapter,长连接模式
会话存储消息、用户、会话、共享成员关系SQLite(单机自托管)/ PostgreSQL(多人协作)
搜索索引关键词FTS + 语义向量SQLite FTS5 + sqlite-vec
LLM网关调用模型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, content='messages', content_rowid='id' );

外部内容表的好处是消息更新、删除时索引更容易维护。但要注意,外部内容表需要配合触发器,或者应用层在写入/删除时同步操作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_id=session_id, top_k=top_k) return results # [{msg_id, score}]

双通道的结果合并,我没有用复杂的加权公式,而是用RRF(Reciprocal Rank Fusion,倒数排名融合):

def rrf(ranked_lists, k=60): 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(), key=lambda x: -x[1])

RRF的好处是它不依赖两个通道分数的量纲是否一致,只看排名。实测下来,关键词结果和语义结果各自的前20名,融合后top5基本就是用户想找的内容。比如"搜一下上周说的那本书"这种话,关键词通道基本抓瞎,语义通道能从"数据结构与算法"那条消息里召回,RRF再把两个结果合并,用户看到的排序就理性很多。

3.3 云端同步:自托管同步服务的增量合并策略

同步服务的核心是一个游标机制。每个客户端维护一个last_sync_id,每次拉取时带上这个游标,服务端返回自游标以来的新消息:

接口方法作用
/sync/messages?since=123&session_id=xxGET增量拉取消息
/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_urlmodel。我自己的环境里就常年跑着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.internal,Windows/macOS没问题,Linux需要加--add-host参数或者直接用宿主机IP。

启动用docker compose:

services: 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 connectedsync 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_retries=5): 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版就是先加了各种工具,结果助理很能干但记不住事,越能干越像金鱼。搜索和同步看着不起眼,但它们是让助理真正"记住"你的前提。希望这篇记录能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询