返回列表

博客

让会话历史交给服务器:移动端 AI 编程的对话恢复设计

为什么 Tactic Remote 1.8.2 把聊天历史从手机端搬到了服务器,以及这个决定如何让一整类顺序错乱和历史丢失的问题从根上消失。

发布于 标签: engineering / architecture / ios / reliability / websocket
博客

继续查看该栏目下的产品与工程更新。

浏览此分类

这篇文章讲一个架构决定:Tactic Remote 1.8.2 为什么把聊天的对话历史,从手机端搬到了服务器,以及这个决定如何让一批我们一直在反复修补的问题,从根源上消失。

一套注定会失败的做法

在 1.8.2 之前,聊天历史完全由手机端实时拼接。服务器把 agent 的流式事件推给手机,手机把这些事件依次渲染成气泡,攒成你看到的对话。

在电脑上,这套做法还算够用,因为连接是稳定的。但到了移动端,它有一个根本缺陷:历史只存在于当前这一条连接、这一次会话里。于是问题接连出现。

  • 重连之后,新连接拿不到旧事件,历史一片空白,或者残缺不全。
  • 杀掉应用再打开,手机内存里的对话没了,只能从头来过。
  • 多台设备连同一个会话时,每台只看得到自己接入之后的事件,各说各话。

还有一个更隐蔽的问题,关于顺序。发送消息时,手机端会先乐观地把用户气泡显示出来,而 agent 的第一条回复,要等它的进程真正启动之后才会到达。中间只要出现一点时序抖动,比如排队的消息在重连后才姗姗来迟,用户气泡就可能排到回答的下方去。这个“用户气泡跑到底部”的问题,我们修过好几次,但每次都只是在治标。因为它的根子是结构性的:在整套设计里,没有任何一方,为“这段对话真正的顺序”负责。

借鉴 tmux 的思路

Tactic Remote 的终端模式一直很稳,靠的是 tmux。tmux 的模型简单而有力:终端缓冲区是唯一的事实来源,你接上去(attach),就是把这份事实回放给你。断线、换设备、重开,都只是重新接入同一个缓冲区,历史自然还在。

聊天模式缺的,恰恰就是这一层。所以 1.8.2 的设计,就是把这套思路搬过来:

让服务器保管一份权威的对话记录,客户端接入时,把它完整恢复出来。

一旦服务器成了顺序的权威,前面那些问题,就不再是需要逐个去修的 bug,而是从结构上不再可能发生。

具体是怎么做的

服务端:每个会话一份环形缓冲

服务器为每个聊天会话维护一份对话记录。每一个广播出去的事件,都按顺序追加进一个环形缓冲区。缓冲区有两道上限,一道限事件条数,一道限总字节数,超出就从最旧的开始裁剪。但会话开头那条初始化事件是恢复的锚点,永远不裁。一旦发生裁剪,缓冲区会被打上“已截断”的标记,这样恢复时,客户端就能在顶部如实地显示“更早的消息已省略”,而不是假装从头开始。

这里有关键的一步:用户自己发出的消息,现在也由服务器生成一条有序事件,写进这份记录。在过去,用户消息只存在于手机端的乐观渲染里,服务器对此一无所知;运行中途插入的消息(steer),更是连记录都没有。如今服务器成了顺序的权威,用户的每一轮发言,都落在它真实发生的位置上。“用户气泡跑到底部”这个老问题,也就从结构上消失了。

恢复:用一个信封,而不是重发原始事件

最初的方案,是接入时把原始事件重新发一遍。这个想法很快被否掉了。因为旧版客户端只会忽略它不认识的事件,而这些被重发的,恰恰是它认识的事件,会被当成新的实时消息再追加一次,历史直接翻倍。

真正采用的方案,是把整段记录装进一个全新的事件里下发:structured_replay,里面带着序号、是否为最后一批、是否已截断、当前是否在运行,以及事件列表。旧版客户端不认识这个新事件,会直接忽略,安然无事;新版客户端则把这个“信封”拆开,把里面的事件重新喂给同一条处理流程,去重建整段对话。

由于 iOS 的 WebSocket 单帧接收上限约为 1 MiB,恢复时会按 256 KB 分片下发,用序号标记先后。至于已经处理过的工具审批,恢复时会补盖上“已允许 / 已拒绝”的印记,免得一张历史审批卡因为状态缺失,被误渲染成一张还在等你操作的卡片。

客户端:接入即重建,同时屏蔽副作用

客户端收到序号为 0 的那批恢复数据时,先清空本地对话,再把事件列表依次喂回同一个处理函数去重建。也就是说,恢复走的是和实时完全一样的渲染路径,不需要另写一套逻辑。

这里有个必须处理的细节:恢复过程中,那些只应在实时场景触发的副作用,必须被屏蔽掉,比如队列的自动发送、超时看门狗、运行状态的翻转、审批的自动就绪。我们用一个标志位把它们统一挡住,直到最后一批数据到达,再采纳信封里那个权威的运行状态。

至于发送时的乐观用户气泡,我们没有取消它。因为第一条消息要等 agent 进程启动一两秒,纯靠等待恢复会有一段空白,体验并不好。我们的做法,是把它改成一个“待确认”的占位:服务器的回执一到,就移除这个占位,按权威记录里的真实位置重新插入。错乱,也就在结构上无法再持续存在。

兼容性:为什么“忽略未知事件”是一个设计决定

恢复方案能够安全落地,很大程度上依赖一个早已埋下的约定:客户端对不认识的事件,一律直接忽略。正是这一条,让服务器可以引入 structured_replay 这样的新事件,而完全不会惊动尚未升级的旧客户端。它们看不见恢复功能,但也绝不会因此出错。

在一个由 iOS 应用、Mac / Linux / Windows 三端服务器、外加一个 npm CLI 各自独立发布、版本长期参差的系统里,这种“只增不改、遇到未知就忽略”的协议纪律,正是让服务端先行、客户端随后跟上的前提。

边界,以及诚实的取舍

这套设计并非没有边界,我们更愿意把它们讲清楚。

  • 服务器缓冲有上限。 超长会话最早的一段历史会被裁掉,恢复时以“更早的消息已省略”明确告知,而不是假装完整。
  • 被回收的会话,从零开始。 会话长时间无人订阅后,服务器会将其回收;之后通过 claude --resume 恢复的会话,缓冲从空白重建,更早的历史不再回放。这是一个有意的取舍:用有限的历史留存,换取一个可控的内存占用。
  • 客户端本地持久化,是后续的事。 目前这份权威记录只在服务器在线期间存在。把历史进一步存到客户端本地,是下一步的增强,不在这个版本里。

与其等你某天发现历史突然少了一截、再自己去猜原因,我们更愿意提前把这些边界写明白。


想体验这种“接入即恢复”的效果?iOS 应用已在 App Store 免费上架,配套应用与 CLI 同样免费。如果你对这个版本里另一块硬骨头感兴趣,也就是那次“不在业务代码里”的界面卡死,可以读《追查一次界面卡死》。

立即体验 Tactic Remote

用手机远程控制编程 Agent

连接 Mac、Windows 或 Linux 电脑上的 Claude Code、Codex 等 Agent,在 iPhone 或 iPad 上查看进展并发送下一条要求。