只同步权威源,派生态各端自己重建——AI 对话的跨栈同步
Shellby 复盘:把 AI 对话历史在 Apple 与 Flutter 双栈间同步。一个对话既有原始消息、又有步骤转录,但只有原始消息该同步——步骤是派生态,在每端由消息重建。外加一个「鸿蒙收不到」的版本字段坑。
Shellby 的 AI Agent 会话历史要在 Apple(Swift) 和 Flutter 双栈之间同步。听着是「把对话记录搬过去」,但对话在两端的形态不一样,逼出一个值得写的原则:只同步权威源,派生出来的东西各端自己重建。
一个对话有两份数据
Flutter 端一个对话里有两样东西:
- 原始消息
history: [AIMessage]——喂给模型的权威上下文,文本、工具调用、工具结果,一条不少; - 步骤转录
steps——给人看的 UI 视图流:cmd(执行了什么命令)、file(改了什么文件)、q/a(问用户 / 用户答)、user/assistant文本。
而 Apple 端只持久化 messages,根本不存 steps——它显示时自己从 messages 重建。
于是「同步什么」有了个陷阱。如果把 steps 也当独立数据同步,Apple 侧永远是空的(它不存这个),跨栈过去就丢了步骤级 UI。正确答案是分清主次:messages 是权威事实,steps 是它的派生态。派生态不作为独立事实进同步通道,而在每一端由 messages 现算。
派生:编码时把 messages 翻成 steps
具体做法是不对称但对称:Apple 编码时由 messages 派生出 steps(用和 App 内 AIAgentSession.reconstruct 完全相同的映射),输出成 Flutter 认的步骤格式;Apple 导入时只读 history,自己重建显示。发出去带派生态给对端用,收回来只认权威源自己重建——两个方向刚好对称。
派生映射逐个工具对齐,枚举名双栈一致:run_command → cmd 步、write_file → file 步、ask_user → q(/a) 步。其中 risk 是现算的——用 CommandClassifier.classify 对命令、classifyWritePath 对路径分类,枚举 readOnly/mutating/destructive 双栈字面量一致;status 取对应工具结果 isError 与否。派生前先扫一遍 messages 建一张 toolUseID → (output, isError) 映射,再回填到每一步。
case AgentToolName.runCommand:
guard let inp = try? JSONDecoder().decode(RunCommandInput.self, from: input) else { return [] }
return [.object([
("t", .string("cmd")),
("command", .string(inp.command)),
("rationale", .string(inp.rationale)),
("risk", .string(CommandClassifier.classify(inp.command).rawValue)), // 现算
("status", .string(isErr ? "failed" : "ok")), // 取结果
("output", .string(out)),
])]
好处是:步骤转录永远和当前的分类规则一致。哪天风险分类规则改了,重新同步一次,旧对话的步骤风险标注跟着更新——因为它不是一份存下来会过期的快照,而是每次现算的派生。
鸿蒙收不到:一个版本字段的缺口
功能接通后冒出一个诡异现象:鸿蒙收不到 Apple 同步过来的 AI 对话。
根因是一个前向兼容检查用力过猛。Flutter 的 AgentTranscript.fromJson 第一行就卡版本:
// v 缺失按 v1 处理——跨栈:Apple 的编码器不写 v 字段,若强判 v==1 会把
// Apple 同步来的对话历史整条丢弃。仅显式的未知高版本才拒绝。
final v = json['v'];
if (v != null && v != 1) return null;
改之前那行是 if (json['v'] != 1) return null——而 Apple 端的编码器压根没写 v 字段,于是 json['v'] 是 null、不等于 1,整条对话被当成「未知版本」丢弃。修法是放宽语义:v 缺失按 v1 处理,只有显式的、未知的更高版本才拒绝。前向保护(挡未来的破坏性格式)保留,但不再误杀「没写版本号」的合法数据。
配套还堵了 aiConfig 的一个近亲坑:套用远端配置做 LWW(last-write-wins)比较时,如果 json 里的 aiConfigUpdatedAt 缺失或为 0,回退到记录顶层的 updatedAt——否则远端配置的时间戳恒为 0、永远比不过本地,同步来的配置永不生效。
两个防漂移的细节
扩展键尾追,不动冻结的字节向量。 同步载荷原本有 5 个基础集合(hosts / groups / identities / forwardRules / secrets),字节序被冻结成确定性测试向量。加 AI 数据时,aiConfig / aiChats 这些扩展键是尾追在基础键之后、且非字母序——这样一个不含 AI 数据的载荷字节完全不变,不破坏已冻结的向量。合并逻辑也从「硬编码集合列表」改成「遍历两端集合键的并集」,扩展集合自动纳入记录级合并,加新集合不用再改合并代码。
套用远端时不回环 bump。 套用同步来的配置时,抑制掉「配置变更 → bump 时间戳」的逻辑。否则「收到远端 → 本地被改 → 时间戳更新 → 又推出去」会震荡成一个同步回环。
复盘
- 同步权威源,派生态各端重建。一份数据如果能从另一份算出来,就别把它当独立事实同步——同步它只会在「不存它的那一端」制造空洞或不一致。发出去可以带上派生态给对端用,但收回来只认权威源、自己重算;
- 派生态跟着规则走,比存快照更对。步骤的风险标注是每次由当前分类器现算的,规则一改、重新同步就自动更新;存成快照则会定格在旧规则上;
- 前向兼容检查别误杀「没写版本号」。
v != 1一刀切会把「缺省版本」也拒掉;正确是「缺失按最低版本、只拒显式的未知高版本」。跨栈时一端不写、另一端强判,就是这类幽灵的温床; - LWW 的时间戳缺失要有回退,否则「时间戳恒为 0」的一端永远输,远端更新永不落地;
- 加扩展数据别动已冻结的序列化。扩展键尾追且只序列化实际存在的集合,让老载荷字节不变;合并遍历键的并集而非硬编码列表——新数据类型接入是加数据,不是改协议。
留言