← 博客

对话流式传输与「思考区」调试记录

记录日期:2026-04-01

背景

神秘人聊天对接 AG-UI SSE:推理应走 THINKING_ 事件,用户可见正文走 TEXT_MESSAGE_。端上需要灰色可折叠思考区 + 真流式(RN 用 react-native-sse)。实际迭代中问题分几层暴露出来。

方案一:只调 UI 状态

做法:收束推理时用 thinkingExpanded: false,首轮正文一到就把思考体收起,只剩黑色 Markdown。

问题:看起来像「思考一出来就变纯黑、收起条没了」——其实是思考体被折叠后视觉焦点全在正文,兼有多通道重复时更像「整段黑字」。

调整:首段正文到达时先保持 thinkingExpanded: true,整轮结束再默认收起;并去掉「占位 early return」与主分支的结构分裂,减少切换时的表现抖动。

方案二:客户端启发式去重

做法:流式中若 content === thinkingContent,不渲染那段 Markdown,避免双份黑字 + 灰字。

问题:依赖字符串全等,后端若「前缀相同后接答案」或空白不一致就失效;属于协议不清时的补丁,不可当成架构。

方案三:前端状态机收敛( reducer )

做法:抽出 assistant-stream-reducer.ts + agent-chat-types.tsonThinkingDelta / onChunk / 收尾 / 错误只做归约;思考区展示用 assistantThinkingPresentation;MySQL 增加 thinking_contentthinking_duration_sec 并与持久化对齐。

价值:多端状态修改单入口,长期可测、可演进。

未解决:若模型根本不吐 THINKING_*,只把围栏写进正文通道, reducer 再漂亮也拿不到「真推理事件」,只能继续在正文里事后剥离。

方案四:正文内围栏的客户端剥离

做法leaked-reasoning-in-content.ts 解析 ` thinking 成对哨兵、代码块、XML、未闭合尾段等,合并进 thinkingContent`,正文只留可见部分。

问题:能救 UI,但本质是 C 端擦屁股;若在系统提示里 禁止模型写围栏,则是 回避问题(不让模型表达推理结构),而不是在协议上承接。

方案五(当前):服务端按 AG-UI 真分流

做法

结果:模型仍可按习惯输出带围栏的推理,SSE 上会出现标准 THINKING 事件;端上 onThinkingDelta / onChunk 各司其职;客户端剥离、Markdown 安全与流式 carry 作为双保险。

小结

| 阶段 | 核心 | 局限 |

| UI 布尔 | 展开/收起时机 | 无法解决「推理不在正确通道」 | | 字符串去重 | 少一块黑字 | 脆弱 | | Reducer + 库表 | 状态与持久化清晰 | 不替代协议分流 | | 客户端剥围栏 | 救显示和旧数据 | 不是「解决问题」的终点 | | 服务端改事件 | 协议层对齐 AG-UI | 依赖部署与环境一致 |

教训:先弄清「黑字」是样式 bug、重复渲染,还是 TEXT 里混进了本该走 THINKING 的内容;最后一类必须在 Emitter 侧改,而不是禁写或只做前端解析。

相关路径(便于跳转)

附录:后续缺陷与补丁(简要)

| 现象 | 原因 | 修复 |

| 思考区与正文内容对调 | split_fence_thinking_from_plain_text 返回 (可见正文, 推理),patch 里误解包成 think_full, pub_full | 改为 pub_full, think_full = split(...);单测锁住顺序 | | 全部进正文、无 THINKING_* | 模型输出的是 think / thinking 尖括号块(非反引号 thinking),旧逻辑不认 | thinking_fence_split 与客户端 stripThinkXmlTags 对齐;服务端 split_fence_thinking_from_stream_buffer | | 流式过程中正文闪错字,结束后又对 | Delta 把开闭标签拆在两包,半截被当正文解析 | peel_incomplete_xml_think_suffix;端上 thinkStreamCarry + mergeLeakedIntoThinkingStream | | 推理尾句重复进正文 | 同 tick 事件顺序;TEXT 首段与 think_full 后缀重叠 | 先 t_newTHINKING_ENDTEXT_trim_pub_prefix_if_think_suffix_overlap;端上 trimPublicGhostPrefixn === len(pub) 时整段剪掉,避免最后一个 delta 被清空 | | 正文头露出 </think>、尾段像被截断 | 流式断包导致孤立闭标签;Markdown-it 默认 html: true</think> 当 HTML 解析坏后续 | 行首/标点后 stripVisibleThinkCloseArtifacts(正则,多轮剥);SafeMarkdown 使用 html: false;渲染前 agent-chat.tsx 再保底.strip | | 正文停在「还记得我们聊到」类半截 | ① 最后一块流常带 finish_reason,原先用其跳过则整包未进 fence;② 流结束仍 peel 掉了末尾字节且无下一包 | 不按 finish_reason 跳过raw 的 chunk;OnChatModelEnd 对完整 _fence_bufsplit_fence_thinking_from_plain_text 算剩余量并 _yield_fence_thinking_and_text 补发 | | 文首 XML 闭标签字面量 | _strip_visible_think_close_artifacts 与服务端/端上 strip 对齐 | thinking_fence_split 出口清洗 + 上表 strip |

涉及文件thinking_fence_split.pyag_ui_fence_thinking_patch.py(含 _yield_fence_thinking_and_text、End 补发)、leaked-reasoning-in-content.tsstripVisibleThinkCloseArtifactsmergeLeakedIntoThinkingStream)、assistant-stream-reducer.tsthinkStreamCarry)、SafeMarkdown.tsxagent-chat.tsx

当前状态(截至联调通过)

流式 THINKING / TEXT 分流、思考区展示、正文无孤立闭标签字面量、尾句与 STATE_SNAPSHOT 一致;服务端需保持已部署含 fence patch + End 补发的云托管镜像,端上需包含上述 Markdown 与 strip 改动。