消息与完成标记分开提交,失败回合的消息进入下一轮历史¶
一次回答失败后,下一轮对话里模型仍能看到这轮失败留下的消息:页面显示该轮失败,但失败的用户消息和助手消息仍出现在模型输入历史中。
故障注入复现¶
排查从写入路径入手。旧循环在回合结束时有两次相互独立的存储写入:先追加 finalized 消息(用户消息和助手消息),再追加 completed 运行标记,两次写入之间没有原子边界。边界上只要出错——第二次写入失败,或进程恰好死在两次之间——存储就停在“消息已落盘、运行没标完成”的中间态。故障注入压的就是这个边界:在第二次持久化写入处注入 OSError 后检查存储,消息文件里用户消息和助手消息都在,这轮运行被标为失败,和预判的中间态一致。
到这步只确认了存储里两个状态对不上,还没确认它对用户的影响。下一步重新打开存储、调用历史投影,这些消息照常进入下一轮的模型上下文:失败轮被当成正常历史,跟在成功轮后面交给模型。
投影为什么分不清失败轮,问题在历史投影本身:它把存储里的消息项逐条翻译成模型消息,只跳过运行记录那一类条目,既不查消息属于哪一轮,也不看这一轮最后成功还是失败。页面按运行状态显示“失败”,模型历史按投影照常拼接,同一份存储状态两个读法各给各的。
根因是写入顺序:旧的循环先追加最终消息,再独立追加完成标记,两次写入没有原子边界。标记写入失败时,消息已落盘但运行状态是失败,两个状态不一致,而历史投影只看消息、不看运行状态。
修复与验证¶
修复把消息与完成标记合并为一个原子批次,一起提交或都不生效,内存、JSONL 文件和 PostgreSQL 三种存储后端执行同一套整批提交逻辑。提交顺序固定为:
finalize 放在写存储之前,原因有两个。一是业务 finalize 会做证据与输出校验,确实可能拒绝模型输出;如果先标完成再 finalize,就重演了这次的矛盾——对外报失败,存储里却留着完整消息。二是 finalize 会规范化并重新序列化输出文本,先存原始文本、对外发 finalize 后文本,客户端看到的答案和下一轮模型历史就不是同一个字符串。
批次内容按回合结果分两种。完成的回合,批次里是 finalized 消息加 completed 运行记录;失败的回合,原始消息根本不进批次,只写一条带失败现场的运行记录。失败现场(输入、工具尝试、原始终态)另存到失败记录里供诊断,不拼进下一轮的模型历史——失败轮不进历史是靠批次构造排除的,不再指望投影去过滤。
这个原子批次只管消息和标记这一对写入,不扩展成整轮的原子事务。JSONL 和 PostgreSQL 的写入窗口仍按崩溃恢复合同兜底:重启后如实归类 interrupted 或 unknown,不伪装完成。
验证覆盖多个存储后端的故障注入与重启边界。真实请求、冷启动读取、同链续聊分别确认:诊断信息可读,模型历史里没有失败轮的数据。该轮内核全量测试、WebUI 状态测试、Go 全部含测试包、产品测试均通过。
同一轮复核发现的两个提交歧义¶
其一,缺省运行标识为空时,第二轮写入前的取消会误命中第一轮的完成记录。复现的是缺省标识下连续运行再取消的时序:第二轮还没写入任何自己的记录,取消按标识查到的是第一轮留下的 completed,第一轮的完成状态被当成了取消对象。修复是每轮开始时生成唯一的缺省标识,空标识不再出现。
其二,文件替换和同步都成功、但目录同步报错时,流式接口报失败,重新查询却显示完成。JSONL 后端的一次提交分四步:写临时文件、同步文件内容、原子替换、同步所在目录。前三步走完,新内容已经通过替换生效,重新查询和冷启动读到的都是完成记录;目录同步报错发生在最后一步,却让写入路径整体抛出异常,流式接口按异常报失败。一个出口看写入过程有没有报错,一个出口看盘上内容,同一份数据给出两个终态。复现办法是在文件替换成功后注入目录同步错误,再检查盘上是否已有持久完成证据。修复是按持久化记录定公开终态:能找到该运行完整持久化的完成记录,就保持终态为完成,同时保留原异常供诊断——完成证据已在盘上,重开存储每次读到的也是完成,终态跟盘上内容才对得上。找不到就仍按失败处理,不重试、不重放。