不是没存,是存得太晚
一句话结论:判断"有没有做持久化"是错的问题。该问的是"什么时候做的"——只要有一个瞬间,库里的状态和用户看到的不一致,那个瞬间就会变成"东西没了"。
起因
给主智能体发一条消息,它开始流式回复。回复到一半,切到别的页面,或者切到另一个会话,再切回来——
这一轮不见了。 消息、回复、正在生成的文字,全没了。要等它整个跑完,再切一次,才看得到。
用户描述得很准确:「只有 main 完成才看得到」。
排查过程
第一反应是查"有没有落库"。查下来很意外:落库函数有,而且不止一处。整个对话链路里有一个统一的持久化函数,被四个地方调用:
- 直答路径一处;
- 工具循环路径一处(前端发主智能体走的就是这条);
- 同步降级一处;
- 工作流一处。
四个路径覆盖了所有出口。所以"没做持久化"这个判断是错的。
问题在于这四处全都在流或循环结束之后。
再看流式过程本身:整个过程中没有任何数据库操作。回复 chunk 只是往一个内存缓冲区里累积,然后推给前端。库里此刻什么都没有。
最狠的一处是会话本身。 新会话的那一行记录,也是跟消息同一个时刻写入的。也就是说,在回复完成之前,这个会话在库里根本不存在——不只是消息看不到,会话列表里也不会有它。
那为什么切回来是空白,而不是"显示旧内容"?因为前端的行为是必然回查:消息列表不放在全局状态里,切会话和页面挂载都会去调一次历史接口,直接查库。库是空的,界面就是空的。
而切走的时候,前端并不会中断后台的流——它只是把监听摘掉了。所以后端还在正常跑、正常把结果写进库,前端却已经渲染成空白,而且没有任何通知机制告诉它"你漏了一轮,要不要重拉"。
用户能做的只有手动刷新,或者再切一次。
三个连带的坑,同一个根因:
- 出错就永久丢失。 出错分支直接从函数返回,跳过了落库。而落库函数本身用的是独立上下文,只要能被调用就不受连接断开影响——它压根没被调用。
- 跨会话串写。 切会话不中断流,而写入时用的是"第几条消息"的下标。切过去之后整个列表被替换了,旧会话剩下的 chunk 会写进新会话的对应位置。两个会话的内容混在一起。
- 用户消息也全程没落库。 一旦中断,整轮连"我问过什么"都不留。
结论
结论很直白:问题的形状不是"缺功能",是"时序错"。 所以修法不是一个补丁,是把落库时机重新排一遍:
- 请求进来的那一刻就落用户消息,并把会话行一起建好——会话不应该等到回复完成才存在;
- 助手消息改为增量、节流写入,并给消息表加一个状态列(生成中 / 完成 / 失败),让"未完成"变成一个可以表达的状态;
- 出错分支先落库,再返回错误;
- 前端切会话或卸载时中断请求;
- 落库之后通过长连接推一条通知,让前端自己重拉。
第 2 条是关键。表里没有"生成中"这个状态,等于系统认为"消息只有完成态";一旦承认有中间态,后面所有问题都变成"怎么表达中间态",而不是"怎么防止用户切走"。
还没解决的
这套改动当天只出了方案,没有落码。
后来这个问题被拆成了两批:状态化和增量落库先做,启动恢复(进程重启时把"生成中"标记为失败)后做。分开是对的——它们一个是"过程可见",一个是"崩溃可恢复",混在一个包里会让验收标准说不清。
还有一处至今没解决的边角:用户切走的时候,前端怎么知道自己错过了。 通知机制能覆盖"还开着页面"的情况,但"关掉页面过一会儿再打开"仍然靠重新拉历史——那时候如果消息还没落库,看到的仍然是空的。
只要落库还是节流的而不是每 chunk 立即写,就永远有一个几秒的窗口。这个窗口能不能接受,取决于产品怎么定义"实时"。
本文由 Lyncore 生成,经人工审核后发布。