AI 流式输出时「思考中」总被正文盖住:最新活动优先的状态行做法
学习目标
读完这篇,你会给流式 AI 界面做一条永远显示"正在干什么"的状态行:模型在思考就显示思考,在调工具就显示工具名,在说话就显示正文尾巴——而不是永远卡在第一句话上。方法来自一个真实修过的 bug(boundless 的 src/ai/panel.rs),你照着思路搬到自己的项目里就行。
开场:最气人的不是慢,是不知道它在干嘛
画图 Agent 有个经典场面:你发一句"画个标题图",它先回一句"马上开画…“,然后就没动静了。实际上它正在一轮一轮地思考、调工具、画图,可状态行上永远停在那句"马上开画…”。用户看到的,就是"它卡住了"。
这不是模型慢,也不是网络卡,是状态行的优先级写反了。下面说怎么修,以及修之前怎么证明问题不在链路里。
1. 前置知识:三个概念先说清
流式事件三类:模型输出过程中,事件分三类——Reasoning(思考过程)、Tool(工具调用)、Text(正文叙述)。这三类是状态行的全部输入。
步骤序列 steps:按执行顺序排列的事件列表。steps 的最后一项,就是模型"正在干的事"。这个"最后一项"很关键,后面所有规则都建立在它上面。
buffer:已经流到的正文增量文本。它和 steps 是两条线:正文增量可能先于"生成 Text 步骤"的 flush 到达,也就是说,buffer 里有字的时候,steps 里可能还没有对应的 Text 步骤。
状态行和完整消息列表是两回事:消息列表要全,状态行只要"当前活动"这一行。
2. 分步骤实操:四步修好一条状态行
第 1 步:先定位——用探针证伪,别一上来改渲染
翻译成人话:状态行不显示思考,可能的原因有两个——(A)思考事件根本没到达前端;(B)到达了,但显示逻辑把它盖住了。两个原因修法完全不同,先分清再动手。
排查实录是这样的:在 BoundlessAgent::stream 上挂探针实测,128 个 Reasoning 事件正常到达;rig 0.38 的 OpenAI 兼容层解析 reasoning_content 无误;泵 / flush / 渲染链路插桩验证全通。更关键的是对照:纯问答场景本就正常,只有画图场景看不到思考。
结论:链路没问题,问题在状态行的优先级逻辑。这一步省下的,是"把渲染链路重写一遍"的无用功。怀疑链路时,先在边界上挂探针证伪,再动显示逻辑。
第 2 步:定规则——最新活动优先,而不是"说过的话优先"
旧逻辑长这样:
// 旧逻辑:buffer 优先
if !buffer.trim().is_empty() {
显示 buffer 的尾部
} else if let Some(step) = steps.last() {
按末步类型显示
}
问题一眼就能看出来:buffer 是"本轮说过的话",不是"正在做的事"。画图代理通常先叙述一句"马上开画…“,这句话一进 buffer,本轮之后所有轮次的思考尾部就再也显示不出来了——因为 buffer 永远非空。用户感受就是"思考过程不会显示”。
新规则只有一句话:看 steps 的最后一项,它就是当前活动。
fn streaming_status_text(steps: &[AssistantStep], buffer: &str) -> String {
if let Some(step) = steps.last() {
match step {
// 末步是思考:显示思考尾部,空思考显示"思考中…"
Reasoning { text } => 空则"思考中…",否则取 text 尾部,
// 末步是工具:显示工具名
Tool { name, .. } => "正在调用工具:{工具名}",
// 末步是正文:显示文本尾部,空文本显示"生成中…"
Text { text } => 空则"生成中…",否则取 text 尾部,
}
} else if !buffer.trim().is_empty() {
// steps 为空、buffer 有字:增量先于 flush 到达,显示 buffer 尾
取 buffer 尾部
} else {
"正在思考…"
}
}
注意那个 else if 分支:steps 为空但 buffer 有字时,说明正文增量已经到了、但生成 Text 步骤的 flush 还没到。这时候显示 buffer 尾巴,状态行就不会卡住。这个兜底分支不能删——删了,增量先到的那几百毫秒里状态行就是死的。
第 3 步:把逻辑抽成纯函数,尾部截取单独处理
状态文本的计算不碰任何 UI 状态,只依赖 steps 和 buffer 两个输入,抽成纯函数 streaming_status_text。UI 层只负责调用它,算错了也只在一个地方。
尾部截取单独一个小函数 tail_chars(s, 64):先 trim,换行压成空格,再取末尾 64 个字符。状态行只有一行,换行必须压掉;取尾部而不是头部,因为用户要看的是"最新进展",不是"开头说了什么"。
第 4 步:用单测锁定修复,别靠肉眼复查
修完要加两个测试,锁定这个行为:
#[test]
fn compact_status_shows_latest_reasoning_even_after_text() {
// 画图场景:模型先叙述(text 已进 buffer),再开始新一轮思考。
// 旧实现 buffer 优先,之后的思考全部被遮蔽——本测试锁定修复。
let steps = vec![
AssistantStep::Text { text: "马上开画…".into() },
AssistantStep::Reasoning { text: "先画一个圆角矩形作为标题底".into() },
];
assert_eq!(
streaming_status_text(&steps, "马上开画…"),
"先画一个圆角矩形作为标题底"
);
}
#[test]
fn compact_status_follows_step_sequence() {
assert_eq!(streaming_status_text(&[], ""), "正在思考…");
// buffer 已有字但 step 尚未 flush → 显示 buffer 尾
assert_eq!(streaming_status_text(&[], "hello"), "hello");
// 空思考步 → 思考中
assert_eq!(streaming_status_text(&[Reasoning { text: "" }], ""), "思考中…");
// 工具步 → 工具名(断言以前缀"正在调用工具"开头)
// 文本步 → 文本尾
assert_eq!(streaming_status_text(&[Text { text: "done" }], "done"), "done");
}
第一个测试是关键:它复现的正是画图场景——先叙述、后思考,修复后的状态行必须显示思考内容,而不是那句"马上开画…"。以后谁再把优先级改回去,这个测试会第一时间拦住。
3. 为什么这样设计
状态行显示的是"当前活动",不是"本轮说过什么"。 buffer 优先的旧逻辑,把"说过"当成了"正在做"。一旦分清这两个概念,规则就只有"看最后一项"这一条,没有第二条。
画图场景是这个 bug 的放大器。 纯问答场景里,模型往往思考完再说话,buffer 优先和最新活动优先表现一样,所以 bug 藏得住。画图代理的节奏是"先叙述、再思考、再调工具",叙述一进 buffer,之后的思考全被盖住。场景节奏不同,同一个优先级 bug 的可见度完全不同——这也是为什么排查时要拿画图场景做复现,而不是纯问答。
纯函数 + 单测,是这类 UI 逻辑的标准形态。 状态计算是纯逻辑,不依赖渲染时机、不依赖 UI 状态,抽出来可测、可锁定。靠肉眼复查的修复,下次重构还会翻车。
4. 避坑清单
- 状态行别直接读 buffer:buffer 是"说过的话",不是"正在做的事"。拿它做第一优先级,思考过程会被永久遮蔽。
- 增量先于 flush 要留兜底:steps 为空但 buffer 有字时显示 buffer 尾,别让状态行在那几百毫秒里卡死。
- 空状态要有占位文案:空思考显示"思考中…“,空文本显示"生成中…”,什么都不显示时显示"正在思考…"。别让状态行出现空行,用户会当成卡死。
- 尾部截取要压换行:状态行只有一行,换行不压掉,布局会乱。
- 怀疑链路先探针证伪:在事件到达的边界上挂探针,数一下事件到底到了几个。链路没问题就别重写渲染。
- 修完用单测锁定:把复现场景写成测试,第一个测试就是"先叙述、后思考必须显示思考"。靠肉眼验证的修复,等于没修。
5. 总结:给开发者的建议
- 流式状态行的规则只有一条:显示 steps 最后一项对应的活动。
- 优先级按"正在做"排,不按"说过什么"排;buffer 只做 steps 为空时的兜底。
- 状态计算抽成纯函数,输入只有步骤序列和 buffer,UI 层只调用。
- 空思考、空文本、全空三种情况都要有占位文案,别留空行。
- 修之前先在事件边界挂探针,证明事件到达了,再动显示逻辑。
- 把复现场景写成单测,锁定修复,防止下次重构改回去。
状态行只有一行,但它回答的是用户最关心的问题:它现在到底在干嘛。这一行对了,用户才愿意等;这一行错了,模型跑得再快,用户也觉得它卡住了。
245



被折叠的 条评论
为什么被折叠?



