对话流式传输与「思考区」调试记录
AG-UI SSE THINKING/TEXT 分流、NoSQL 会话读写、确认流图拓扑、SSE payload 补丁等 11 个子问题
背景
神秘人聊天对接 AG-UI SSE:推理应走 THINKING_* 事件,用户可见正文走 TEXT_MESSAGE_*。端上需要灰色可折叠思考区 + 真流式(RN 用 react-native-sse)。实际迭代中问题分几层暴露出来。
方案演进
方案一:只调 UI 状态
做法:收束推理时用 thinkingExpanded: false,首轮正文一到就把思考体收起。
问题:看起来像「思考一出来就变纯黑、收起条没了」——其实是思考体被折叠后视觉焦点全在正文,兼有多通道重复时更像「整段黑字」。
调整:首段正文到达时先保持 thinkingExpanded: true,整轮结束再默认收起。
方案二:客户端启发式去重
做法:流式中若 content === thinkingContent,不渲染那段 Markdown,避免双份黑字 + 灰字。
问题:依赖字符串全等,后端若「前缀相同后接答案」或空白不一致就失效;属于协议不清时的补丁。
方案三:前端状态机收敛(reducer)
做法:抽出 assistant-stream-reducer.ts + agent-chat-types.ts,onThinkingDelta / onChunk / 收尾 / 错误只做归约。
未解决:若模型根本不吐 THINKING_*,只把围栏写进正文通道,reducer 再漂亮也拿不到「真推理事件」。
方案四:正文内围栏的客户端剥离
做法:leaked-reasoning-in-content.ts 解析 `thinking` 成对哨兵、代码块、XML、未闭合尾段等,合并进 thinkingContent。
问题:能救 UI,但本质是 C 端擦屁股;若在系统提示里禁止模型写围栏,则是回避问题。
方案五(当前):服务端按 AG-UI 真分流
做法:
thinking_fence_split.py:与前端规则对齐的切分(含 think/thinking 尖括号块、peel、流结束时的正文头闭标签清洗等)。ag_ui_fence_thinking_patch.py:拦截仅有正文增量、无结构化reasoning的流;累计_fence_buf→split_fence_thinking_from_stream_buffer→ 先发THINKING_*,再TEXT_*。OnChatModelEnd用完整buf再跑一次切分,按已发送偏移补发尾句。
结果:模型仍可按习惯输出带围栏的推理,SSE 上会出现标准 THINKING 事件;端上 onThinkingDelta / onChunk 各司其职。
小结
| 阶段 | 核心 | 局限 |
|---|---|---|
| UI 布尔 | 展开/收起时机 | 无法解决「推理不在正确通道」 |
| 字符串去重 | 少一块黑字 | 脆弱 |
| Reducer + 库表 | 状态与持久化清晰 | 不替代协议分流 |
| 客户端剥围栏 | 救显示和旧数据 | 不是「解决问题」的终点 |
| 服务端改事件 | 协议层对齐 AG-UI | 依赖部署与环境一致 |
教训
先弄清「黑字」是样式 bug、重复渲染,还是 TEXT 里混进了本该走 THINKING 的内容;最后一类必须在 Emitter 侧改,而不是禁写或只做前端解析。
后续缺陷修复表
| 现象 | 原因 | 修复 |
|---|---|---|
| 思考区与正文内容对调 | split_fence_thinking_from_plain_text 返回顺序误解 | 改为 pub_full, think_full = split(...) |
全部进正文、无 THINKING_* | 模型输出 think 尖括号块,旧逻辑不认 | 服务端 split_fence_thinking_from_stream_buffer |
正文头露出 | 流式断包导致孤立闭标签被当 HTML 解析 | stripVisibleThinkCloseArtifacts + SafeMarkdown 用 html: false |
| 正文停在半截 | 最后一块流常带 finish_reason,用其跳过则整包未进 fence | 不按 finish_reason 跳过含 raw 的 chunk;OnChatModelEnd 补发 |