无账号P2P私密AI聊天:本地优先架构设计与实践

无账号P2P私密AI聊天:本地优先架构设计与实践 在 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(): PromisePrivateKey { return generateKeyPair(Ed25519) } export function fingerprint(privateKey: PrivateKey): string { return peerIdFromPrivateKey(privateKey).toString() } export async function signMessage( privateKey: PrivateKey, contentBytes: Uint8Array ): Promisestring { const sig await privateKey.sign(contentBytes) return Buffer.from(sig).toString(base64) } export async function verifyMessage( publicKeyBytes: Uint8Array, contentBytes: Uint8Array, sigBase64: string ): Promiseboolean { // 用公钥构造验签器验证 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 Mapstring, 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 }): PromiseChatMessage { // 调用模型服务 // 将模型返回文本包装为 kind: ai 的消息 // seq 需要在写入前单调递增 throw new Error(按实际模型服务 SDK 补齐) }必须强调如果模型请求发往远程网络层可以全程加密但模型服务商仍然能看到文本明文这和端到端加密是两回事。产品文档里不应该把“连接加密”描述成“内容绝对保密”。这是隐私架构里最基本的诚实边界。4. 同步为什么难冲突、乱序与同步回环本地优先架构里最容易出问题的不是网络连不通而是两台设备各自写入后重新合并得到的日志不符合预期。4.1 为什么不能直接用本地时间戳排序两台设备的系统时间可能不一致也可能出现时钟回拨。消息 A 在设备甲写入时时钟快 5 分钟消息 B 在设备乙写入时时钟准合并时按时间戳排序会出现 B 在 A 前面而用户实际看到的顺序是 A 先、B 后。本地时钟还有一个更深的问题它不能描述因果关系。设备甲的 seq3 引用 seq2这个父子关系才是逻辑顺序时间戳只是展示层参考不能作为排序主键。推荐做法是保留 ts 字段用于 UI 展示用 parentId 链表达“谁是谁的前驱”。如果会话里只有一个发送方seq 也可以作为顺序依据。多人多设备场景下更好的是为每个 author 建一个独立递增链再定义全局合并规则。4.2 多人并发写入时需要明确的冲突解决策略设备 A 和 B 离线后各写一条 seq5 的消息内容都引用 seq4。重新连接时两条消息同时存在合并器不能简单地丢弃其中一条。此时至少有一类规则必须被明确两条消息都保留只是顺序要给出稳定比较键例如 author seq 排序如果需要单一线性历史可以选择 last-write-wins但要注意 LWW 不是用本地时钟判断而是用逻辑时钟或发送者优先级如果涉及“编辑同一条消息内容”那要从追加日志升级到真正的 CRDT例如 Automerge 或 Yjs。原型优先采用“都保留、按稳定规则排序”的策略因为它不会丢失用户输入行为可预期代码也简单。撤回、编辑可以后续用追加一条 tombstone 消息实现不需要直接删除物理记录。4.3 同步回环两个节点互相推送同一批消息同步回环的典型场景是A 向 B 推送消息集合 SB 合并后把 S 回推给 AA 再次合并后发现自己已有这些消息又回传给 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 回复建模为 kindai 的普通消息避免另建一套同步通道。恢复设计私钥导出与恢复机制写明备份建议。存储治理预留快照、压缩、媒体外置能力。合规对外发布前明确内容安全、个人信息收集与删除机制。7.2 继续深入的方向如果想让这个骨架变得像真正的产品下一步最值得做的四件事是引入真正的 CRDT把消息从“追加日志”升级为可并发编辑的结构化文档支持编辑、撤回和多媒体排列。实现群组会话的成员授权把“设备对设备”扩展成“人对人不同设备共享同一会话”。增加推送唤醒通道让离线设备被新消息唤醒而不是必须手动重连。加入 AI 会话的本地索引例如全文检索和向量检索形成不依赖云的“私人知识库”。这些方向都建立在同一条主线上身份可以不在服务端数据可以不在云端但正确性、可信性和可恢复性必须通过协议设计补回来。真正有难度的不是让两个节点互相发消息而是让用户在无账号、无云备份的情况下依然能确信“哪条消息是谁写的、是否被改动过、我的历史不会因为丢一台设备就彻底消失”。PearPie 这类项目把这些问题放到了具体产品里这也正是本地优先与 AI 结合时最值得投入精力研究的工程命题。