对话流式传输与「思考区」调试记录
记录日期: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.ts,onThinkingDelta / onChunk / 收尾 / 错误只做归约;思考区展示用 assistantThinkingPresentation;MySQL 增加 thinking_content、thinking_duration_sec 并与持久化对齐。
价值:多端状态修改单入口,长期可测、可演进。
未解决:若模型根本不吐 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_;事件顺序为「先吐完本轮t_new再THINKING_END,再发正文」。不得因finish_reason跳过 chunk(最后一块常与stop同包)。OnChatModelEnd用完整buf再跑一次split_fence_thinking_from_plain_text(不 peel),按已发送偏移 补发*t_tail/p_tail,避免 peel 截尾后无下一包导致尾句丢失。app.py在 dict patch 之后注册;删掉agent.py里「禁止思维链围栏」类提示。
结果:模型仍可按习惯输出带围栏的推理,SSE 上会出现标准 THINKING 事件;端上 onThinkingDelta / onChunk 各司其职;客户端剥离、Markdown 安全与流式 carry 作为双保险。
小结
| 阶段 | 核心 | 局限 |
| UI 布尔 | 展开/收起时机 | 无法解决「推理不在正确通道」 | | 字符串去重 | 少一块黑字 | 脆弱 | | Reducer + 库表 | 状态与持久化清晰 | 不替代协议分流 | | 客户端剥围栏 | 救显示和旧数据 | 不是「解决问题」的终点 | | 服务端改事件 | 协议层对齐 AG-UI | 依赖部署与环境一致 |
教训:先弄清「黑字」是样式 bug、重复渲染,还是 TEXT 里混进了本该走 THINKING 的内容;最后一类必须在 Emitter 侧改,而不是禁写或只做前端解析。
相关路径(便于跳转)
- 端上:
zhiyuxing-app/src/lib/agent-chat.ts、assistant-stream-reducer.ts、leaked-reasoning-in-content.ts、app/(tabs)/agent-chat.tsx、zhiyuxing-app/src/components/SafeMarkdown.tsx - Agent:
shenmiren-agent/ag_ui_fence_thinking_patch.py、thinking_fence_split.py、ag_ui_dict_tool_patch.py、app.py
附录:后续缺陷与补丁(简要)
| 现象 | 原因 | 修复 |
| 思考区与正文内容对调 | 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_new 再 THINKING_END 再 TEXT;_trim_pub_prefix_if_think_suffix_overlap;端上 trimPublicGhostPrefix;不在 n === 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_buf 用 split_fence_thinking_from_plain_text 算剩余量并 _yield_fence_thinking_and_text 补发 | | 文首 XML 闭标签字面量 | _strip_visible_think_close_artifacts 与服务端/端上 strip 对齐 | thinking_fence_split 出口清洗 + 上表 strip |
涉及文件:thinking_fence_split.py、ag_ui_fence_thinking_patch.py(含 _yield_fence_thinking_and_text、End 补发)、leaked-reasoning-in-content.ts(stripVisibleThinkCloseArtifacts、mergeLeakedIntoThinkingStream)、assistant-stream-reducer.ts(thinkStreamCarry)、SafeMarkdown.tsx、agent-chat.tsx。
当前状态(截至联调通过)
流式 THINKING / TEXT 分流、思考区展示、正文无孤立闭标签字面量、尾句与 STATE_SNAPSHOT 一致;服务端需保持已部署含 fence patch + End 补发的云托管镜像,端上需包含上述 Markdown 与 strip 改动。