跳转至

流式增量没被最终答案替换,界面停在中间 JSON

现象

新版界面的结构化输出链路上,真实浏览器验收发现:回答在流式过程中逐步到达,界面把一段一段的中间 JSON 拼着显示;完成事件已经带回了最终答案,页面却仍停留在增量 JSON 上,没有替换成最终文本。复制或导出拿到的也可能和屏幕上不是同一版本。

同一次验收还发现了一个相邻问题:终态处理过早清空了活动运行,后面的清理逻辑失去目标,回答已经显示完,"停止"按钮还留在界面上。

排查

这条链路流式期间到达的增量是结构化 JSON 的片段,最终答案只随完成事件到达,所以先确认完成事件本身:事件到了,output 里的最终答案齐全。这一步把链路传输和生成侧排除掉,疑点收敛到界面更新。

接下来对照同一次回答的两份文本:流式期间逐段拼出来的增量,和完成事件 output 里的最终答案。对照结果确认界面停在增量上,完成事件的最终输出没有被用来更新展示,问题定位到答案更新路径。

停止按钮的问题顺着现象反推:回答已经显示完,按钮还停在"停止",说明界面的运行状态没有被复位。查状态释放的位置,发现释放动作不在统一的清理阶段,而是在处理终态的 handler 里提前执行了,活动运行先被清掉,后面依赖它的清理落空。

根因

两个界面问题加一个代理竞态:

  • 结构化输出的流式增量是一段一段的 JSON 片段,界面边收边拼、原样显示。完成事件的 output 里装的才是最终答案文本。答案更新路径只跟着增量走,没有用完成事件的最终输出替换已经显示的内容,中间 JSON 就留在了页面上。
  • 复制和导出读的不是屏幕上这份最终文本。更新路径没有一处做统一替换,展示、复制、导出三处内容可能各是各的。
  • 停止按钮的发送/停止样式和忙碌标志由活动运行状态驱动,清理阶段复位按钮的前提是活动运行还指向当前这一轮。终态 handler 提前清掉活动运行后,这个前提不成立,复位被跳过,所以回答显示完了,按钮还留着。
  • Go 代理的取消靠一张从 turn 到响应标识的映射找目标。流式请求退出时用 defer 删映射项:
// 请求退出路径
defer client.clearTurn(turnID)

// 取消路径
responseID := client.responses[turnID]
if responseID == "" {
    return nil // 映射里查不到目标,直接返回,不发取消
}

异步取消晚于删除到达时,映射里已经查不到这一项,取消动作直接返回,不发送请求。

修复

界面在最终清理阶段(finally)统一释放运行状态;完成事件到达后直接用它的最终输出更新正文展示与导出记录,屏幕显示的、复制的、导出的是同一份结果。Go 侧把代理的取消动作改到请求标识映射清理之前完成,异步取消到达时映射还在。

验证

真实浏览器验收确认了这两个问题的原始现象和修复后的行为;内核全量测试与 WebUI 状态测试通过。同轮还有一条相关回归:最终证据引用按最终的条目标识关联完成事件,排除了工具调用前的临时文字对引用提取的干扰。

同类问题的检查顺序固定为:首个增量、工具阶段、最终事件、重新打开历史后的内容,四处分别对照。