会话保持:中断、恢复与状态一致
聊到一半切出去接了个电话,回来点开 App,界面还在,最后一条消息停在 正在执行命令 。
会话保持:中断、恢复与状态一致
聊到一半切出去接了个电话,回来点开 App,界面还在,最后一条消息停在”正在执行命令”。
再点一次发送,它说工具不存在。或者更糟:它把那条根本没跑完的命令当成已经成功,接着往下推理。
一、会话不是一个文件,是一串状态
直觉上,保存会话就是把消息存进数据库,打开时读出来。
实际要存的东西比这多得多:消息列表、每条消息里的工具块(参数、状态、结果)、当前是否在流式输出、有没有等待用户确认的权限请求、压缩产生的摘要、以及”上次运行到哪一步”。
任何一项没存对,恢复出来就是另一个状态。
所以会话保持的真正难点不是”存”,是恢复之后还能不能继续跑。存一份聊天记录很容易,存一个可以接着执行的状态很难。
二、什么算”一条消息完成了”
流式输出是边收边显示的:模型吐一个字,界面加一个字。但落库不能这么做,否则一次崩溃会留下几十条半截消息。
常见的做法是分批写:文本块攒到一定长度刷一次,工具块等状态变化时写一次。于是数据库里永远可能存在”写了一半”的记录。
恢复时就要判断:这条消息是完整的,还是写到一半被切断的?
判断依据不应该是”有没有内容”,而是状态字段。一条助手消息只有在流式结束、所有工具块都处于终态时,才算完整。其余的都算在飞。
这个区分必须显式建模,不能靠推断。因为”内容为空”也可能是一条合法的空回复,”只有一个工具块”也可能是正常的一轮。
三、孤儿工具调用
最麻烦的中间态是工具执行到一半。
一条助手消息里有一个 tool_use,但对应的 tool_result 还没产生。这在协议层是非法的:模型看到的是一次”我说要调用工具,然后没有下文”。多数服务端会直接拒绝这种请求。
崩溃、强杀、断电,都会留下这种记录。
处理方式只有一条:恢复时把在飞的工具块全部丢弃——参数还在流式接收的、已派发但没返回的、正在执行的,一个不留。
代价是用户看到的内容会往回退一点:刚才明明显示”正在执行”,恢复后那个块消失了。但这是必须付的代价,因为保留一个没有结果的调用,比丢掉它更糟——它会让整个会话再也发不出去。
取舍的原则很清楚:宁可少显示,不可留下非法状态。
四、两种中断,不能用同一种处理
同样是”没跑完”,有两种来源:
实时中断:用户点了停止,或者切换了模型,或者 App 被杀。这时进程还活着,内存里有完整的状态,可以精确收尾。
历史中断:几天前崩溃过,今天打开会话才发现尾部是断的。这时内存里什么都没有,只能从数据库的痕迹反推。
这两种情况的处理不能混。最典型的错误是刷新”中断时间”:实时中断应该更新徽标上的时间戳,历史中断不应该——它早就发生了,刷新会让用户以为刚刚又断了一次。
实现上要专门标记”这次检测到的是历史遗留,不是新发生的中断”。而这个标记本身还有坑:如果用布尔值,赋值后立刻清除,异步的观察者很可能看到的是清除后的值,标记就丢了。所以要用一个只增不减的计数器,消费方比较代际号,才能确认自己处理的是哪一次。
这类细节看起来琐碎,但它们决定了恢复是”看起来对了”还是”真的对了”。
五、恢复不等于重放
最容易想错的语义是:恢复就是把上次的请求再发一遍。
如果上一轮的工具是读文件、查天气,重放没什么问题。但如果上一轮已经写了文件、发了消息、执行了部署,重放就是重复副作用。
正确的语义是”从断点继续”:
- 已完成的工具调用,结果已经在历史里,不再执行;
- 在飞的调用被丢弃,模型重新决定要不要再做一次;
- 用户看到一条明确的说明:上次中断在哪,现在从哪继续。
这就要求历史记录能区分”这个工具调用了,并且成功了”和”这个工具调用了,但不知道结果”。前者可以信任,后者必须重来。
六、持久化的顺序
还有一个隐蔽的问题:先写库还是先更新界面。
先更新界面再写库,崩溃时用户看到了、库里没有,恢复后内容消失。 先写库再更新界面,崩溃时库里有、界面没显示,恢复后内容出现。
两种都不完美,但后者更安全——以库为准,界面只是投影。所以正常情况下应该先落库,再渲染;异常情况下以数据库重建界面。
这也是为什么很多实现在启动时要做一次”历史修补”:把数据库里不合法的尾部截掉、把孤儿块清掉、把状态字段归位。这些修补代码平时不显眼,但它们是恢复能用的前提。
七、重启之后,缓存就没了
会话能恢复,还有一个代价容易被忽略:服务端的缓存不认你的数据库。
模型的 prompt cache 是按字节前缀匹配的。你把历史原样重发一遍,前缀一致,缓存还能命中;但只要中间有一个字节不同——时间戳变了、消息顺序变了、某条结果被重新格式化过——整段前缀就失效,全部按新输入计价。
所以”恢复会话”不只是能不能继续聊,还包括恢复得够不够像。差一个空格,成本就是另一回事。
下一篇专门讲这件事:为什么 Agent 的账单里,缓存命中率比 token 总数更值得盯。