本篇回答四个问题:程序里变化的数据怎么管理?关掉程序对话怎么找回?AI 改坏了文件怎么退回?花了多少钱是怎么记的?
8.1 程序里有两个"记事本":一个给机器看、一个给界面看
一个运行中的助手,内部有大量不断变化的数据:当前工作目录、累计花费、当前模型、聊天消息、你在输入框打了一半的字、界面主题、弹窗状态......这些数据放哪、怎么管,直接决定程序好不好维护。
项目的做法是两个记事本分开,这一点第 2 篇埋过伏笔,这里讲透。
记事本一:全局档案(给整个程序用)
就是第 2 篇讲的"全局小本本"。它记的是低频变化、和界面无关、到处都要用的数据:
- 工作目录、项目根目录;
- 累计花费、网络请求总耗时、工具执行总耗时、增删行数;
- 当前模型、会话编号;
- 是否交互模式、各种模式开关。
它的特点:变化慢(很多数据一场对话才变几次)、要给核心循环/工具/对接层等各处读取、不涉及界面渲染。读写必须通过专门的函数(不能谁都直接改),而且项目严格控制往里加东西。
记事本二:界面状态(给 React 用)
界面相关的数据放在另一个地方(用 Zustand 这类界面状态库管理,你只需要知道它是"给 React 用的集中状态"):
- 聊天消息列表(界面要渲染);
- 输入框当前内容(敲一个字变一次);
- 是否正在思考(转圈要不要显示);
- 主题、弹窗、待办面板、权限请求队列;
- 界面开关(显不显示花费、显不显示提示)。
它的特点:变化极快(输入框一秒变十次)、只有界面关心、驱动着第 6 篇讲的 React 重绘。
为什么必须分开
如果混在一起,会两头受罪:
- 界面卡顿:React 是"数据一变就重绘相关部分"。如果把"输入框内容"和"累计花费"放一起,你每敲一个字,订阅了"累计花费"的组件也可能被牵连重算;
- 全局污染:界面状态变化极频繁且随意(输入框草稿这种东西毫无重要性),如果和"当前模型""累计花费"这种关键数据混在一个全局对象里,关键数据会被高频、无序的界面更新淹没,出问题极难追查。
打个比方:记事本一像公司档案室 (人事档案、财务总账,一天不变一次,谁都能查但只有专人能改);记事本二像员工桌面上的便利贴和白板(今天的草稿、便签,随便涂随便擦)。你不会把财务总账写在可擦白板上,也不会把电话留言登记进人事档案。
两者怎么联动
数据流动是单向的:核心循环干活时产生的结果(新消息、花费增长),由调度员写进全局档案,同时通知界面状态更新("有新消息了"),React 收到后重绘。反过来,界面不会直接改全局档案------用户的操作(切换模型、发消息)走的是"调用调度员的功能"这条路,由调度员统一改档案。这样"数据从哪改的"永远只有一个方向,不会两个记事本互相打架。
8.2 关掉程序再打开,上次的对话怎么找回来
第 3 篇讲过聊天记录逐条落盘,这一章讲清楚这套机制。
每一场对话都是一个"会话档案"
你启动一次助手、聊一段内容,这叫一个会话(session)。每个会话在磁盘上对应一个文件,里面按时间顺序记录每一条消息(用户说的、助手说的、工具调用和结果、系统通知),每行一条,格式是结构化的(JSON,一种"标签: 值"的通用数据格式)。
这种"一行一条记录、不断往后追加"的文件格式有个专门的叫法(JSONL,JSON Lines)。好处是:
- 追加写入:新消息往文件末尾加一行就行,不用重写整个文件,快且不容易坏;
- 天然有序:行的顺序就是时间顺序;
- 崩了损失小:即使程序中途崩溃,已经写入的行都还在,最多丢最后没写完的一点点;
- 好读好查:想找某条消息,按行扫描即可。
消息里存的不只是文字
为了能完整恢复,每条消息存的信息很全:消息类型、内容(包括图片引用、文件路径)、工具调用的名字和参数、工具结果、当时的模型、时间戳、这条消息在界面上怎么显示的信息等。目标是:把这个文件读回来,就能把当时的对话现场一模一样地重建出来。
恢复会话
启动时你可以选择"继续上次的对话"或从历史列表里挑一个。程序读出对应文件,把消息逐行还原成内部的聊天记录,界面重新渲染出当时的内容,就能接着聊。
会话分叉
还有个进阶能力:你可以在历史对话的某一条消息处"岔开"------回到那个时间点,然后朝另一个方向重新聊(类似代码里的分支概念)。因为每条消息都有完整记录,程序可以从任意一条消息处截断、开新会话,老会话不受影响。
和记忆的区别
注意区分:会话档案是"这场对话完整发生了什么 "(流水账,全量、详细);第 5 篇讲的记忆是"跨会话提炼出的长期规矩"(精炼、少量)。一个是完整录像,一个是总结笔记。恢复会话靠录像,长期记住你的偏好靠笔记。
8.3 "后悔药"机制:它改坏了文件怎么退回去
AI 会改文件,改坏了怎么办?项目有一套文件历史机制,让你能"读档"回到改动前。
改文件前先拍快照
关键策略:任何会修改文件的工具,在动手前先给文件拍一张"快照"。
- 快照保存文件在被改之前的完整内容;
- 快照和会话关联,存在项目的一个隐藏目录里;
- 每次修改都拍,于是一个文件在一场对话里可能有一串快照(改之前 → 改一次 → 再改一次......)。
这和第 4 篇讲的"只读工具不用拍快照、会写的工具才拍"对应上了------读文件不改变任何东西,没有后悔的必要。
回退(rewind)
当你说"退回到改这个文件之前",程序做两件事:
- 文件回退:把文件内容恢复成当时那个快照的样子;
- 对话回退:把聊天记录也退回到那个时间点(8.2 的会话档案让这成为可能)。
于是整个现场------文件和对话------一起回到过去,就像游戏读档。你可以退回几步、换个说法重新让它试。
快照成本的控制
每次改文件都存一份完整内容,会不会很占空间?几个缓解措施:
- 只对"会修改文件"的动作拍,只读操作不拍;
- 快照可以做去重(内容没变就不重复存);
- 旧会话的快照可以定期清理。
和 git 的关系
你可能会问:这不就是 git 干的事吗?思路类似(都是"保存历史版本、可回退"),但定位不同:
- git 是你主动提交的版本管理,粒度粗(一个功能提交一次),而且 AI 改了一半的中间状态通常不会提交;
- 快照是每次工具修改自动拍的,粒度细,覆盖"AI 正在改、还没改好"的中间状态------正是你最可能想后悔的时刻。
实践中两者配合:git 管正式版本,快照管"AI 动手过程中的反悔"。快照机制也不碰你的 git 历史,它是独立的一层安全网。
工作区(worktree)隔离
更进一步,有些模式下 AI 干活时会用 git 的 worktree 功能开一个"平行工作区":在一个独立的目录副本里改,你的主工作目录完全不受影响,改好了再合并,改砸了直接扔掉副本。这是比"事后回退"更主动的隔离思路。
8.4 花了多少钱、用了多少电量:账是怎么记的
用模型是按用量付费的(按处理的字数计价),所以程序必须精确记账。这一章讲账单怎么算。
用量数据从哪来
模型每次回复,都会在流式事件的末尾(第 3 篇讲的 message_delta 事件)附带这次的用量:
- 输入了多少字(你发给它的所有内容,包括手册、聊天记录);
- 输出了多少字(它生成的内容);
- 缓存命中了多少字(第 5 篇讲的省钱缓存,这部分单价低);
- 缓存新建了多少字。
程序每收到一次用量就累加起来。
累计记哪些账
记账分几个维度:
- 按模型分:这次用了哪个模型、各用了多少字------因为不同模型单价不同;
- 按时段/会话分:这场对话花了多少、今天花了多少、总共花了多少;
- 换算成钱:用字数 × 对应单价,折算成金额;
- 时间账:网络请求累计耗时、工具执行累计耗时(不直接花钱,但反映效率);
- 改动量:加了多少行代码、删了多少行。
这些数据就是存在 8.1 说的"全局档案"里的累计数字。
为什么输入字数是大头
一个反直觉的点:输入比输出贵得多 。因为每次提问都要把系统提示手册(几万字)+ 全部聊天记录重新发一遍。这正是第 5 篇那套机制(提示缓存、上下文压缩、工具结果截断、记忆按需取)的意义所在------它们本质上都是在压低输入字数:
- 缓存让重复的手册部分按低价计费;
- 压缩把聊天记录变短;
- 截断防止工具结果把输入撑爆;
- 记忆按需取而不是全塞。
所以"上下文工程"(第 5 篇)和"省钱"是同一件事的两面。记账数据反过来也能帮你发现问题:如果某次对话输入字数异常高,可能是缓存没命中、或者某个工具返回了过多内容。
钱花哪了能看到
界面上可以随时查看当前会话的花费明细(/cost、/usage 这类命令):各模型用了多少字、缓存省了多少、折算多少钱。这让成本对用户透明,也帮助判断"这个任务用更贵的模型值不值"。
未知价格的处理
有些模型(比如用户自己接的小众模型)程序不知道确切单价,这种情况下金额算不准,程序会明确标记"这个模型的花费是估算/未知",而不是给你一个看似精确实则错误的数字------账可以不全,但不能假。
本篇小结
- 数据分两个记事本:全局档案(低频、关键、全程序用,通过专门函数读写)和界面状态(高频、只给 React 用);数据单向流动,避免互相污染。
- 每场对话是一个会话档案,消息逐行追加落盘(崩了损失小),读回来能完整重建现场,支持恢复和分叉。
- 后悔药靠"改文件前自动拍快照 + 对话记录回退",文件和对话一起读档;git 管正式版本,快照管 AI 改动的中间状态;worktree 提供更主动的隔离。
- 记账靠模型每次回复附带的用量数据逐次累加,按模型/会话统计、折算金额;输入是成本大头,所以第 5 篇的缓存、压缩、截断本质都是省钱;未知单价的模型如实标记为估算。
下一篇讲扩展:斜杠命令、钩子、插件、子助手------怎么给这个助手加新能力。