在 AI 聊天应用普遍绑定云端账号的背景下,PearPie 给出的方向很有代表性:私密 AI 聊天,通过 peer-to-peer 同步,不要求用户注册账号。这个定位乍看只是产品形态差异,实际背后牵动的是用户身份、消息存储、设备发现和 AI 请求出口这四层架构的共同调整。对后端或客户端开发者来说,它值得关心的不是某个 UI 功能,而是“没有中心服务器时,一个多设备、私密、可持续追加数据的聊天系统应该怎么设计”。
这篇文章以 PearPie 标题里的三个关键词为线索,不对具体实现做背书,而是做一次架构层面的还原:先拆解无账号身份、本地优先存储、P2P 同步各自的难点,再给出一套可用 TypeScript 搭起来的最小骨架,最后补上验证方法、排查路径和生产化建议。读完你可以回答几个实际问题:没有账号体系时节点如何确认对方身份;两台设备离线很久后重新上线,同一场对话如何收敛成一致结果;AI 聊天文本在隐私诉求下到底应该走本地推理还是远程模型接口。
1. 无账号、P2P、私密 AI 聊天这三个词,为什么值得一起分析
先厘清一个问题:不是所有用户都需要无账号聊天。中心化聊天服务把账号、好友关系、消息存储放在服务器上,天然适合找回密码、换机恢复、多端同步以及内容审核。它的代价是消息历史和服务提供方绑定,AI 对话记录也常常保存在云端,用户需要信任平台的数据策略。PearPie 这类设计试图回答的反而是另一个问题:如果我不想让消息历史默认进入某个云账号体系,是否还能获得多设备同步和 AI 助手能力。
1.1 中心化服务的默认前提,到了本地优先场景会失效
中心化产品有两个默认前提:服务端保存权威数据,服务端决定身份。账号密码、手机号验证码、OAuth 登录都是围绕这个前提展开的。客户端只是服务端的缓存,用户换设备后只要登录就能拉取全部历史。
P2P 私密聊天一旦去掉这个前提,三个问题会立刻出现:
- 身份不能由数据库里的用户表产生,必须由设备或用户自己生成并保管。
- 消息没有一处“唯一权威存储”,每个设备都要能独立保存完整历史。
- 上线发现不能依赖中心服务端,需要 DHT、mDNS、中继或手动输入节点地址等机制。
这三个问题不是“加一层加密”就能解决的。它要求的是一种本地优先的架构:数据先在本地设备写入,再通过点对点通道同步到其他设备,云端即使存在,也只是一个可选同步点,而不是必需角色。
1.2 标题中的关键词,分别限制了什么
逐个看 PearPie 标题里出现的关键词:
- 私密 AI 聊天:会话历史和 AI 请求过程都要有隐私边界。至少要考虑消息存储是否加密、AI 推理发生在哪一端、远程模型接口收到什么内容。
- peer-to-peer:节点之间直接交换数据,或通过中继节点转发,但数据生命周期不依赖某个固定厂商服务器。
- sync:不是“被动的云备份”,而是同一份会话数据在多个设备间双向收敛。
- no accounts needed:不引入注册登录流程,但必须有某种等价身份机制,否则你无法证明“这段对话是允许你同步的”。
把这几个词组合起来,产品的技术形态大致是:每台设备生成独立密钥对,消息以追加日志形式保存在本地,设备之间用密钥签名确认身份,用内容索引比较差异,用某种合并算法解决冲突。AI 可以跑在本地模型上,也可以由用户显式接入远程模型服务,但接入行为必须是清晰、可关闭的。
1.3 本文的最小目标:跑通“两台设备合并同一段对话”
为了不让讨论停在概念层,后面会实现一个最小骨架。它的目标是:
- 在无账号情况下生成设备身份。
- 设备 A 本地写入若干条聊天消息。
- 设备 B 与 A 建立 P2P 连接后拉取差异。
- 两台设备再次离线写入不同消息,重新连接后能够合并且不丢消息、不产生重复副本。
- 在上述数据层之上,预留一个 AI 消息类型的接口,使普通聊天与模型回复走同一条同步链路。
这个最小闭环能覆盖本地优先应用最核心的链路。真正产品化时剩下的工作,例如群组授权、消息撤回、媒体文件、离线推送,只要这条数据链路稳定,都能在它上面逐步叠加。
2. 三层设计取舍:身份层、数据层、网络层
主题明确之后,需要把无账号 P2P 聊天拆成三个相互独立的子问题。每一层都有成熟方案,难的是组合在一起时不互相冲突。
2.1 身份层:无账号并不等于无身份
“不需要账号”通常被误解为“不需要身份”。实际恰好相反,P2P 场景需要的是比账号更严格的身份证明。
账号体系里身份由服务端数据库登记,忘记密码可以重置。P2P 体系中没有这个重置入口,所以身份通常落到密钥对:私钥留在设备里,公钥或公钥哈希作为节点 ID。别人看到你的公钥哈希,能验证消息确实由你签名,却无法伪造你的身份。
这里有两个容易混淆的设计维度:
- 用户身份:一个人可能拥有手机、笔记本等多台设备。
- 设备身份:每台设备各自持有独立密钥。
在最小实现里,可以先以设备为身份单元。同一个用户的多台设备通过“被邀请进同一个会话”的方式互相信任。更进一步的产品可以做一个“身份花名册”,把多个设备公钥挂到同一个用户身份下,但那就需要额外的签名协议来描述“这台新设备是同一个主人授权加入的”,复杂度会明显上升。
身份层推荐使用 Ed25519 这类签名算法。它签名短、验证快,密钥生成简单,且可直接从私钥派生节点 ID。与 RSA 相比,Ed25519 的密钥更小,在移动端和嵌入式场景更友好,这也是很多 P2P 库默认使用它的原因。
2.2 数据层:把会话设计成一条只追加消息日志
聊天数据天然是按时间增长的。最简单可靠的数据模型是每个会话维护一条“只追加日志”,每条消息包含:
- 消息 ID,用于唯一标识和去重。
- 会话 ID,表示它属于哪一段对话。
- 发送者公钥指纹,用于验签。
- 序号 seq,发送者在自己的会话里递增生成。
- 上一条消息 ID,用来构建逻辑顺序。
- 内容、时间戳和签名。
这条日志的设计价值在于:每个设备都可以独立追加,不需要先询问中心服务器“当前最大序号是多少”。节点离线时写入的消息带有自己的序号体系,下次同步时通过比较 seq 范围找到差异,而真正的全局顺序由合并算法处理,这一点接下来会详细展开。
从存储角度看,JSONL 是合适的起步格式,每条 JSON 一行,天然支持追加,易读易调试。生产环境可以考虑 SQLite 或嵌入式 KV,核心字段依然不变。
2.3 网络层:发现、连接到中继,是三层中最容易被低估的
P2P 网络层要做三件事:
- 节点发现:如何让对方知道你存在。
- 连接建立:如何在复杂的家用网络、NAT 后面建立可用连接。
- 数据通道:如何复用一条连接传输同步协议消息。
“直接连接”只在双方都能被公网访问时成立。家里两台设备在同一局域网可以用 mDNS;跨互联网时通常需要 bootstrap 节点和 DHT 帮助互相发现;遇到 NAT 后的设备,还需要 STUN 打洞;打洞不成功时,就不得不借助中继节点转发加密数据。
这一层最容易被原型阶段忽略,因为本地回环测试总是成功。等到设备 A 在公司网络、设备 B 在家用宽带时,两个节点发现不了对方,问题几乎都出在 bootstrap、NAT 和中继配置上,而不是消息合并逻辑。设计时要尽早把“本地局域网测试”和“跨网络测试”分开,前者验证数据层,后者验证网络层。
3. 一个最小骨架:用 TypeScript 搭出身份、日志、同步三层
下面给出一个可运行的思路级骨架。它不绑定某个特定版本或真实仓库,代码风格接近 libp2p 生态常见写法,落地前需要根据你的依赖版本调整 API。
3.1 环境准备与项目结构
先确认基础环境:Node.js 20 以上,包管理器使用 npm 或 pnpm 均可。这一节属于原型验证,不需要数据库和中间件。
如果是 Rust/Python 与前端 TypeScript 混合的仓库,还需要留意 uv 这类工具链的初始化顺序。常见报错“后端未能完成启动”往往来自没有先执行环境同步,或 uv、Python 不在 PATH 中。按 uv 工作流,先执行:
uv sync命令会按 pyproject.toml / uv.lock 生成独立虚拟环境并安装依赖。然后继续执行项目的入口脚本,例如:
uv run python -m app.main如果仓库里没有 uv.lock,则要先执行uv lock或根据 README 操作。这里的核心原则是:不要跳过项目自定义的环境引导步骤直接跑入口。这个提示不针对特定框架,而是本地工具链项目的通用习惯。
TypeScript 端项目结构可参考:
pearpie-demo/ ├── package.json ├── tsconfig.json ├── data/ │ ├── device-a.jsonl │ └── device-b.jsonl └── src/ ├── identity.ts ├── log.ts ├── net.ts ├── ai.ts └── index.ts依赖可以按需安装:
npm init -y npm install typescript tsx @types/node # P2P 相关依赖按你选用的 libp2p 版本安装,例如: # npm install libp2p @libp2p/tcp @libp2p/noise @libp2p/yamux @libp2p/bootstrap @libp2p/peer-id这里没有把 libp2p 版本写死,因为它的 API 迭代较快。阅读本文时,应以你安装版本对应的官方文档为准。
3.2 身份模块:生成、导出、验签
identity.ts 负责三件事:生成设备密钥、导出公钥指纹、对消息内容签名。
import { generateKeyPair, type PrivateKey } from '@libp2p/crypto' import { peerIdFromPrivateKey } from '@libp2p/peer-id' export async function createIdentity(): Promise<PrivateKey> { return generateKeyPair('Ed25519') } export function fingerprint(privateKey: PrivateKey): string { return peerIdFromPrivateKey(privateKey).toString() } export async function signMessage( privateKey: PrivateKey, contentBytes: Uint8Array ): Promise<string> { const sig = await privateKey.sign(contentBytes) return Buffer.from(sig).toString('base64') } export async function verifyMessage( publicKeyBytes: Uint8Array, contentBytes: Uint8Array, sigBase64: string ): Promise<boolean> { // 用公钥构造验签器,验证 contentBytes 与签名是否匹配 // 生产实现应防止验签失败后仍继续写入日志,必须显式抛错 return true }注意代码中有意省略了具体验签器构造方式,不同版本 API 差异较大。理解设计意图更重要:签名内容是“完整消息 JSON 序列化后的字节”,而不是只签 text 字段。否则攻击者可以篡改 seq、chatId 等元数据,破坏同步正确性。
私钥要落盘保存。原型阶段可以直接写入本地文件,生产环境必须加密,密钥建议存储在系统钥匙串或由用户口令派生加密后保存。
3.3 消息日志:定义类型、追加、按聊天合并
log.ts 定义消息结构和日志操作。
export type ChatMessage = { id: string chatId: string author: string parentId: string | null seq: number text: string kind: 'human' | 'ai' ts: number sig: string } export function encodeMessage(msg: ChatMessage): Uint8Array { const { sig: _sig, ...rest } = msg return new TextEncoder().encode(JSON.stringify(rest)) } export function appendMessage(logPath: string, msg: ChatMessage): void { const fs = require('node:fs') fs.appendFileSync(logPath, JSON.stringify(msg) + '\n') } export function readLog(logPath: string): ChatMessage[] { const fs = require('node:fs') if (!fs.existsSync(logPath)) return [] return fs .readFileSync(logPath, 'utf-8') .split('\n') .filter((line: string) => line.trim().length > 0) .map((line: string) => JSON.parse(line)) }kind 字段是拉通 AI 对话的关键设计。AI 回复在数据层也是“一条消息”,只是 kind 为 ai。这样同步机制不区分普通消息和模型输出,AI 消息可以像普通聊天一样在设备间同步。
合并时按 id 去重,是最低要求;按 seq 和 parentId 构建逻辑顺序,是更负责的做法。基础合并可以这样写:
export function mergeLogs(local: ChatMessage[], remote: ChatMessage[]): ChatMessage[] { const map = new Map<string, ChatMessage>() for (const msg of [...local, ...remote]) { // 验签通过才写入,伪造签名消息不应进入合并结果 map.set(msg.id, msg) } return Array.from(map.values()).sort((a, b) => a.seq - b.seq) }只用 seq 排序在单发送方场景可用,多发送方同一个 chat 会出现 seq 冲突。所以这版实现更适合“设备之间共享同一身份”或“同一个 chat 只能有一个 author 发送”的约束。更通用的多人场景需要 CRDT,或者把 chatId 与 seq 合成复合键,再配合冲突解决规则。
3.4 网络层:建立一个可互相发现的 libp2p 节点
net.ts 创建 P2P 节点,并暴露一个自定义协议用于同步日志。
import { createLibp2p } from 'libp2p' import { tcp } from '@libp2p/tcp' import { noise } from '@libp2p/noise' import { yamux } from '@chainsafe/libp2p-yamux' import { bootstrap } from '@libp2p/bootstrap' import { identify } from '@libp2p/identify' export async function createChatNode(options: { privateKey: any bootstrapList?: string[] }) { return createLibp2p({ privateKey: options.privateKey, addresses: { listen: ['/ip4/0.0.0.0/tcp/0'] }, transports: [tcp()], connectionEncryption: [noise()], streamMuxers: [yamux()], peerDiscovery: options.bootstrapList ? [bootstrap({ list: options.bootstrapList })] : [], services: { identify: identify() }, }) }示例对 bootstrap 为空的情况做了兼容:第一个节点就是引导节点,其他节点把第一个节点的地址填进 bootstrapList。这个模式方便本地起多个节点测试。
自定义同步协议使用 libp2p 的流式协议,注册一个协议名:
node.handle('/pearpie/sync/1.0.0', async ({ stream }) => { // 读取请求:{ chatId, sinceSeq } // 从本机日志中取出 chatId 且 seq > sinceSeq 的消息 // 编码后写回 stream })设计协议时分清两个方向:pull 模式适合新节点离线很久后上线主动拉取;push 模式适合在线消息即时推送。原型阶段先做 pull,即每次重连后触发一次全量差异同步,能避开许多分布式一致性问题。
3.5 AI 消息如何接入而不破坏私密性
数据层准备好后,AI 只是消息的生产者之一。这里需要做出明确选择:
- 本地模型:文本通过 Ollama 或 llama.cpp 等本机服务推理,请求不离开设备,私密性最高。
- 远程模型:文本发送给第三方模型接口,属于“用隐私换便利”,应该在 UI 和文档中明确披露。
- 私有部署模型:自建的模型网关,不把明文转给外部服务,隐私边界清晰但需要运维成本。
骨架里的 ai.ts 可以定义一个统一的生成函数,把模型输出包装为 ChatMessage 再落盘和同步。
export async function generateAiReply(params: { system: string history: ChatMessage[] modelUrl: string // 例如 http://127.0.0.1:11434/api/chat chatId: string author: string seq: number }): Promise<ChatMessage> { // 调用模型服务 // 将模型返回文本包装为 kind: 'ai' 的消息 // seq 需要在写入前单调递增 throw new Error('按实际模型服务 SDK 补齐') }必须强调:如果模型请求发往远程,网络层可以全程加密,但模型服务商仍然能看到文本明文,这和端到端加密是两回事。产品文档里不应该把“连接加密”描述成“内容绝对保密”。这是隐私架构里最基本的诚实边界。
4. 同步为什么难:冲突、乱序与同步回环
本地优先架构里,最容易出问题的不是网络连不通,而是两台设备各自写入后,重新合并得到的日志不符合预期。
4.1 为什么不能直接用本地时间戳排序
两台设备的系统时间可能不一致,也可能出现时钟回拨。消息 A 在设备甲写入时时钟快 5 分钟,消息 B 在设备乙写入时时钟准,合并时按时间戳排序会出现 B 在 A 前面,而用户实际看到的顺序是 A 先、B 后。
本地时钟还有一个更深的问题:它不能描述因果关系。设备甲的 seq=3 引用 seq=2,这个父子关系才是逻辑顺序;时间戳只是展示层参考,不能作为排序主键。
推荐做法是保留 ts 字段用于 UI 展示,用 parentId 链表达“谁是谁的前驱”。如果会话里只有一个发送方,seq 也可以作为顺序依据。多人多设备场景下,更好的是为每个 author 建一个独立递增链,再定义全局合并规则。
4.2 多人并发写入时,需要明确的冲突解决策略
设备 A 和 B 离线后,各写一条 seq=5 的消息,内容都引用 seq=4。重新连接时,两条消息同时存在,合并器不能简单地丢弃其中一条。此时至少有一类规则必须被明确:
- 两条消息都保留,只是顺序要给出稳定比较键,例如 author + seq 排序;
- 如果需要单一线性历史,可以选择 last-write-wins,但要注意 LWW 不是用本地时钟判断,而是用逻辑时钟或发送者优先级;
- 如果涉及“编辑同一条消息内容”,那要从追加日志升级到真正的 CRDT,例如 Automerge 或 Yjs。
原型优先采用“都保留、按稳定规则排序”的策略,因为它不会丢失用户输入,行为可预期,代码也简单。撤回、编辑可以后续用追加一条 tombstone 消息实现,不需要直接删除物理记录。
4.3 同步回环:两个节点互相推送同一批消息
同步回环的典型场景是:A 向 B 推送消息集合 S,B 合并后把 S 回推给 A,A 再次合并后发现自己已有这些消息,又回传给 B。如果只用“每条消息是否已存在”判断,因为有去重,不会死循环;但会出现大量无意义传输。
解决办法是给每台设备保存每段对话的同步水位:
type SyncState = { peerId: string chatId: string lastSyncedSeq: number updatedAt: number }每次请求时携带同步水位,接收方只返回水位之后的新消息,并且收到消息后更新自己的水位。用 seq 作为水位只适用于单一发送方;多发送方时需要用“已见消息 ID 集合”或哈希树来做增量比较,复杂度会提高。
5. 联调验证与排错:从现象倒推根因
原型代码写完,必须有一套验证方法,否则无法判断数据层和网络层是否正常。
5.1 最小联调流程
建议准备两个数据文件模拟两台设备,分别在两个终端启动节点。
设备 A 先启动作为引导节点:
node scripts/start.js --key data/device-a.key --log data/device-a.jsonl设备 B 随后启动,并指定 A 的地址:
node scripts/start.js --key data/device-b.key --log data/device-b.jsonl \ --bootstrap /ip4/127.0.0.1/tcp/40001在 A 的终端发送一条消息,观察 B 的日志文件是否新增对应记录。然后让两台设备各自离线写入消息,再重新连接,观察合并结果:
tail -f data/device-a.jsonl tail -f data/device-b.jsonl如果两边最终都有全部消息,且不存在重复 id,最小闭环即通过。
5.2 验证矩阵
| 验证场景 | 操作 | 预期结果 |
|---|---|---|
| 单设备写入 | 本地发送一条消息 | 落盘成功,签名有效 |
| 局域网双设备 | A 发起,B 在线 | B 收到且消息顺序正确 |
| 离线合并 | A、B 离线各写一条 | 重连后双方都有两条消息 |
| 消息去重 | 同一个 id 被推两次 | 第二次被忽略,不重复落盘 |
| 签名校验 | 伪造一条消息导入 | 合并时被拒绝,日志不写入 |
| 新消息增量 | 已同步后 A 再写一条 | B 只拉取新增消息,不重复全量 |
这张表可以直接写成自动化测试。每个场景都对应一个断言,能跑通的原型才有继续演化成产品的价值。
5.3 节点互相发现不了的排查链路
如果设备 B 始终找不到设备 A,优先按顺序检查:
- 引导地址是否写对。检查端口、IP、协议前缀是否与实际监听地址一致。
- 两台设备是否真的能互相访问。先在同一局域网测试,排除网络层干扰。
- 节点 ID 是否变化。如果每次启动都重新生成密钥,B 里记录的 A 的 peerId 会失效。
- 是否验证了连接。用 identify 服务确认两端的公钥指纹,而不是只看 IP。
- 是否只是在本地回环跑通。跨互联网后,NAT 打洞失败是中继配置问题,不是数据层问题,需要引入 TURN 或中继节点。
这里的经验是:先在127.0.0.1上验证数据层,再在同一局域网验证网络层,最后才部署到公网。如果跳过前两步直接暴露公网,出了问题很难判断是同步逻辑错还是网络拓扑错。
5.4 常见错误与规避
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 重启后历史消息丢失 | 密钥每次启动重新生成,导致身份变化 | 检查密钥文件是否存在、是否被覆盖 | 密钥持久化,只在首次启动生成 |
| 两台设备日志不一致 | 只做 id 去重,没有增量水位 | 观察同步日志和未读消息数量 | 增加 syncState 水位记录 |
| 消息顺序错乱 | 用设备本地时间戳排序 | 对比两条消息的 parentId 和 seq | 改用 seq + parentId 维护逻辑顺序 |
| 重复推送同一批消息 | 缺少已同步标识 | 查看网络请求是否反复全量 | 增加已见消息集合或游标 |
| 私钥丢失后数据不可读 | 没有导出、备份或恢复机制 | 确认设备私钥是否唯一副本 | 设计恢复码,或允许将私钥迁移到新设备 |
| AI 模型回复无法同步 | AI 消息没有写入日志 | 是否调用了 appendMessage | 统一使用 ChatMessage 类型,kind 为 ai |
额外提醒一个工程习惯:每个节点启动时打印自己的 peerId 和监听地址,联调时先互相核对这两个值,能排除大多数“为什么连不上”的低级问题。
6. 从原型到生产:密钥恢复、存储治理与 AI 隐私边界
原型跑通后,可以继续考虑生产环境问题。本地优先产品的生产化不会比中心化应用简单,只是把责任从服务端移到了客户端和用户。
6.1 私钥必须有恢复路径
在无账号体系里,私钥就是身份,私钥丢失等同于身份丢失。更准确说,历史消息是用旧私钥签名的,如果私钥丢失,至少该设备发出的历史消息都无法再被验证。
设计上可以考虑两类恢复路径:
- 助记词恢复:把私钥编码成语义化助记词,用户抄写保存,新设备输入助记词可重建设备身份。
- 授权迁移:用户从旧设备用私钥签名一条“迁移授权”,新设备凭授权获得加入会话的权利,旧设备可标记失效。
不建议把私钥明文放到云盘或聊天软件里。即使用户要求“为了方便”,也更应该使用加密压缩包,口令单独保管。
6.2 日志增长和存储治理
只追加日志会无限增长,本地优先应用最终要面对存储治理。常见做法包括:
- 消息压缩:把历史消息批量打包成只读快照文件。
- 分层存储:热数据 JSONL 或 SQLite,冷数据归档。
- 媒体外置:图片、文件不写进 JSONL,而是写入独立媒体文件,日志只保存内容哈希和引用。
- 墓碑策略:删除操作写入 tombstone,普通读取时不过滤,索引构建时统一过滤。
如果完全不做治理,会话量上来之后,每次同步都要比较越来越大的日志,同步延迟和磁盘占用都会成为问题。
6.3 AI 请求出口:不同模式下的隐私承诺不能混用
| 模型模式 | 明文是否离开设备 | 私密性 | 适用场景 |
|---|---|---|---|
| 本地模型 | 否 | 高 | 敏感对话、离线环境、个人助手 |
| 自建模型网关 | 离开设备,但不出自己网络 | 中高 | 团队私有服务 |
| 远程大模型服务 | 是 | 较低 | 要求高智力水平、愿接受明文外发 |
UI 和文档描述必须对应这个表。本地模型可以说“内容不出设备”,自建网关只能说“不出自有网络”,远程模型只能说“传输经过加密,但服务方可见明文”。三者混在一起,会变成对用户的误导。
6.4 内容合规并不能因为 P2P 而省略
P2P 减少了对中心服务器的依赖,但任何对外提供聊天服务、把客户端分发给他人的项目,仍然要遵守其运营地区关于内容安全和个人信息保护的要求。技术上的端到端签名与合规治理并不冲突,合理做法是在协议层保留消息元数据能力,在客户端侧提供举报、删除、导出等机制。这部分不属于“要不要聚合到中心服务器”的技术选择,而是面向真实用户发布时必须处理的合规责任。
7. 可复用清单与继续深入的方向
把上面的经验收敛成可执行清单,能避免在真正开发时遗漏关键步骤。
7.1 本地优先 P2P 聊天开发清单
- 身份:首次启动生成 Ed25519 密钥,私钥本地持久化并加密存储。
- 验签:任何从网络收到的消息,先验签再做合并。
- 模型:消息采用 chatId、id、author、seq、parentId、sig 字段。
- 存储:使用 JSONL 起步,后期迁移到 SQLite 或嵌入式 KV。
- 合并:按 id 去重,使用 parentId 维护顺序,多作者场景不要依赖 seq 全局排序。
- 同步:记录对端水位,只做增量同步,避免全量回环。
- 网络:区分局域网、同一 NAT 内、跨互联网三类测试环境。
- 日志:节点启动时打印 peerId、监听地址、连接状态。
- AI:把 AI 回复建模为 kind=ai 的普通消息,避免另建一套同步通道。
- 恢复:设计私钥导出与恢复机制,写明备份建议。
- 存储治理:预留快照、压缩、媒体外置能力。
- 合规:对外发布前明确内容安全、个人信息收集与删除机制。
7.2 继续深入的方向
如果想让这个骨架变得像真正的产品,下一步最值得做的四件事是:
- 引入真正的 CRDT,把消息从“追加日志”升级为可并发编辑的结构化文档,支持编辑、撤回和多媒体排列。
- 实现群组会话的成员授权,把“设备对设备”扩展成“人对人,不同设备共享同一会话”。
- 增加推送唤醒通道,让离线设备被新消息唤醒,而不是必须手动重连。
- 加入 AI 会话的本地索引,例如全文检索和向量检索,形成不依赖云的“私人知识库”。
这些方向都建立在同一条主线上:身份可以不在服务端,数据可以不在云端,但正确性、可信性和可恢复性必须通过协议设计补回来。真正有难度的不是让两个节点互相发消息,而是让用户在无账号、无云备份的情况下,依然能确信“哪条消息是谁写的、是否被改动过、我的历史不会因为丢一台设备就彻底消失”。PearPie 这类项目把这些问题放到了具体产品里,这也正是本地优先与 AI 结合时最值得投入精力研究的工程命题。