适合谁看
-
正在做鸿蒙端 AI 聊天页的人
-
犹豫要不要做流式输出的人
-
想理解流式输出对页面状态设计和鸿蒙语音联动影响的人
问题背景
不做流式输出,AI 页面通常会变成这样:
-
用户发问
-
页面显示 loading
-
等很久
-
一次性跳出整段文本
这在技术上当然可行,但在体验上会有几个问题:
-
等待期间没有内容反馈
-
用户很难判断系统是在思考、搜工具,还是卡住了
-
工具调用和最终回答之间的过渡很硬
-
鸿蒙端的语音播报不知道什么时候该开始 --- 如果等整段文本一次性出来再播报,延迟会非常明显
-
鸿蒙设备内存更紧张 --- 一次性拼接大段文本比增量处理更容易触发内存峰值
所以流式输出的价值,不只是"像大模型产品",而是帮助页面把等待过程变得可感知,同时让鸿蒙端的语音体验更流畅。
项目中的真实场景
食界探味当前这条流式链路涉及的文件:
| 文件 | 流式链路中的角色 |
|---|---|
app/lib/core/ai/agent_service.dart |
底层流式 chunk 消费和分发 |
app/lib/core/ai/ai_explore_coordinator.dart |
chunk 累积为 streamingText,状态流转 |
app/lib/core/ai/models/ai_session_state.dart |
AiSessionStatus 状态枚举 + streamingText 字段 |
app/lib/features/ai_assistant/screens/ai_assistant_screen.dart |
流中内容渲染 + 完成后归档到历史 |
app/lib/features/ai_assistant/widgets/ai_message_bubble.dart |
流式气泡 UI(含 loading 动画) |
其中最关键的几个点是:
-
chatWithToolsStream()--- 底层流式对话方法 -
streamingText--- 正在生成的文本缓冲 -
AiSessionStatus.responding--- 表示"正在生成回复"
这说明当前项目已经不是"一次性拿完整结果再渲染"的做法,而是边来边显示。
核心实现
先说结论:
AI 助手页面支持流式输出,最大的价值不是炫技,而是让"等待中的 AI"也成为一种可被页面表达的状态。
一、流式输出先改变的是等待体验
在食界探味的协调器里,submitQuery() 当前会在 onContent 回调里不断累积文本:
// ai_explore_coordinator.dart
Future<void> submitQuery(String text) async {
state = state.copyWith(
status: AiSessionStatus.parsing,
streamingText: '',
);
final buffer = StringBuffer();
await _agentService.chatWithToolsStream(
message: text,
onContent: (chunk) {
if (!mounted) return;
buffer.write(chunk);
state = state.copyWith(
status: AiSessionStatus.responding,
streamingText: buffer.toString(),
);
},
// ...
);
}
每次 onContent 触发时,buffer 里就多了一个 chunk,streamingText 也随之更新。页面通过 ref.watch 监听状态变化,每次 streamingText 变了就重新渲染。
从用户体验角度看,这个变化非常大:
不支持流式:
用户提问 → [loading 3秒] → "为你找到了3道牛肉吃法:红烧牛腩、日式牛肉寿喜锅、阿根廷烤肉。这三道菜风格差异很大..."
支持流式:
用户提问 → "为" → "为你" → "为你找到了" → "为你找到了3道" → "为你找到了3道牛肉吃法" → ...
用户在第一秒就能看到内容在"长出来",知道系统还在工作。所以流式输出首先不是技术问题,而是等待体验问题。
二、它让状态机不再只有"空闲"和"完成"
看 AiSessionStatus 就会发现,当前状态已经细分成 7 种:
enum AiSessionStatus {
idle, // 空闲
listening, // 正在语音识别
parsing, // 正在理解用户意图
searching, // 正在搜索菜品(工具调用中)
responding, // 正在流式生成回复
speaking, // 正在 TTS 播报
error, // 出错
}
而 streamingText 又专门承接了正在生成的文本。这说明一旦支持流式输出,页面状态设计也会更细:不是单纯"还没回答 / 已经回答",而是"正在理解、正在查、正在生成、已经生成完"。
页面根据这些状态展示不同的提示文字:
// ai_assistant_screen.dart → _buildStatusBubble()
switch (sessionState.status) {
case AiSessionStatus.listening:
return AiMessageBubble(text: '正在聆听...', isStreaming: true);
case AiSessionStatus.parsing:
return AiMessageBubble(text: '正在理解你的需求...', isStreaming: true);
case AiSessionStatus.searching:
return AiMessageBubble(text: '正在探索全球美食...', isStreaming: true);
case AiSessionStatus.responding:
return AiMessageBubble(
text: sessionState.streamingText.isEmpty
? '正在组织推荐...'
: sessionState.streamingText,
isStreaming: true,
);
default:
return SizedBox.shrink();
}
注意 responding 状态下的处理:如果 streamingText 还是空的(模型刚开始生成),就显示"正在组织推荐...";一旦有了内容,就显示实际文本。这个细节让用户在模型思考的头几百毫秒也不会觉得页面卡住了。
三、为什么它特别适合带工具调用的 AI 页面
食界探味不是一个纯聊天机器人,它在中间还会做工具调用:
onToolCall: (toolCall) {
AppLogger.info(
'[AI助手] 工具调用: ${toolCall.name}(${toolCall.arguments})',
);
state = state.copyWith(status: AiSessionStatus.searching);
},
这意味着一次完整的交互会经历多个阶段:
用户: "推荐牛肉吃法"
↓
[parsing] 正在理解你的需求...
↓
[searching] 正在探索全球美食... ← 模型调用 search_dishes 工具
↓
[responding] 正在组织推荐... ← 工具返回结果,模型开始生成回复
↓
[responding] 为你找到了3道牛肉吃法:红烧牛腩、日式牛肉寿喜锅...
↓
[idle] 完成
如果没有流式输出,这些中间过渡会显得特别硬------用户看到的只是 loading 然后突然一大段文字。而有了流式输出,每个状态切换都有对应的 UI 反馈,用户能清楚地感知到"系统在做什么"。
所以越是工具型 AI 页面,流式输出越有价值。它把一个黑盒的等待过程变成了一个有节奏的交互过程。
四、页面层为什么要区分"流中内容"和"历史完成内容"
这是整个流式实现中最精妙的设计之一。在 ai_assistant_screen.dart 里,当前设计并没有把所有文本都直接堆进历史,而是做了两层处理:
class _AiAssistantScreenState extends ConsumerState<AiAssistantScreen> {
final List<_ChatEntry> _history = []; // 已完成的历史消息
String? _lastStreamingText; // 上一轮流式文本的快照
然后在 build() 方法里,有一个关键的归档逻辑:
// 当 AI 回复流式输出结束,归入历史
if (sessionState.status == AiSessionStatus.idle &&
sessionState.streamingText.isNotEmpty &&
_lastStreamingText != sessionState.streamingText) {
final capturedDishes = List<Dish>.unmodifiable(
sessionState.matchedDishes,
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_history.add(
_ChatEntry(
isUser: false,
text: sessionState.streamingText,
dishes: capturedDishes,
),
);
_lastStreamingText = sessionState.streamingText;
});
}
});
}
这段代码的触发条件非常精确:
-
status == idle--- 流式输出已完成 -
streamingText.isNotEmpty--- 有实际内容 -
_lastStreamingText != sessionState.streamingText--- 内容确实变了(避免重复归档)
只有三个条件同时满足,才会把当前轮的回复正式归入 _history。
然后在渲染时,列表的 itemCount 会多加一个:
final hasStreamingBubble =
isStreaming ||
(sessionState.streamingText.isNotEmpty &&
sessionState.status != AiSessionStatus.idle);
final itemCount = history.length + (hasStreamingBubble ? 1 : 0);
这意味着:
-
流式进行中 :
_history是之前的消息,最后一个是正在流出的"临时气泡" -
流式完成后 :临时气泡消失,内容已经进入
_history,变成正式的历史消息
这个设计让"半成品文本"和"正式消息"永远不会混在一起。
五、气泡组件如何感知流式状态
AiMessageBubble 组件有一个 isStreaming 参数,它控制两个关键 UI 行为:
// ai_message_bubble.dart
class AiMessageBubble extends StatelessWidget {
final String text;
final bool isStreaming;
final VoidCallback? onSpeak;
final bool isSpeaking;
@override
Widget build(BuildContext context) {
return Container(
child: Column(
children: [
Text(text, ...),
// 流式中:显示 loading 圈
if (isStreaming)
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: AppColors.primary.withValues(alpha: 0.5),
),
),
// 流式完成后:显示语音播报按钮
if (!isStreaming && text.isNotEmpty && onSpeak != null)
GestureDetector(
onTap: onSpeak,
child: Row(
children: [
Icon(isSpeaking ? Icons.stop_circle_outlined : Icons.volume_up_rounded),
Text(isSpeaking ? '停止播报' : '语音播报'),
],
),
),
],
),
);
}
}
这个设计很巧妙:
-
流式中:气泡底部有一个小 loading 圈,告诉用户"还在生成"
-
完成后:loading 圈消失,出现"语音播报"按钮
用户不需要看状态栏就知道当前处于什么阶段。
六、流式输出为什么更适合 AI 助手这种"陪伴式"页面
食界探味的 AI 助手定位不是冷冰冰的接口调用页,而更像一个陪用户探索美食的助手。
在这种产品语境下,流式输出还有一个额外好处:它让回复过程更像对话。
不是:
用户: "推荐牛肉吃法"
[等待5秒]
AI: "为你找到了3道牛肉吃法:红烧牛腩、日式牛肉寿喜锅、阿根廷烤肉。这三道菜风格差异很大..."
而是:
用户: "推荐牛肉吃法"
AI: "为" → "为你找到了" → "为你找到了3道牛肉吃法" → ":红烧牛腩、日式牛肉寿喜锅、阿根廷烤肉"
第二种体验明显更像"有个助手在回应你",而不是"在等一个接口返回"。对于食界探味这种强调"温暖、有趣,像朋友推荐美食"的产品定位,这种节奏感非常重要。
七、流式输出如何和鸿蒙语音能力衔接
这是鸿蒙端特有的问题。当流式输出完成后,用户可能会点击"语音播报"按钮,触发鸿蒙 TTS:
// ai_assistant_screen.dart
void _toggleSpeak(String text) async {
if (_isSpeaking) {
await TextToSpeechChannel.stop(); // 鸿蒙 TTS 停止
setState(() => _isSpeaking = false);
} else {
setState(() => _isSpeaking = true);
await TextToSpeechChannel.speak(text); // 鸿蒙 TTS 播报
}
}
关键点在于:TTS 播报的文本是已经完成的完整文本 ,不是流式中的半成品。这是因为 onSpeak 只在 !isStreaming 时才会出现(气泡组件的逻辑)。所以流式输出的设计天然保证了:用户不会在文本还在生成时就触发播报。
但如果以后要做"边生成边播报"的实时语音体验,就需要在协调器层面做额外处理------比如等 streamingText 积累到一句话的边界再触发 TTS。这是流式输出为鸿蒙语音体验打下的基础。
八、它也在逼着协调器和页面做更清楚的职责分层
流式输出不是只改 UI,它会倒逼你把下面几件事分清:
第一层:谁负责拿到底层 chunk?
→ AgentService.chatWithToolsStream(),通过 chatStreamRaw 消费底层流式 chunk。
第二层:谁负责把 chunk 累积成字符串?
→ AiExploreCoordinator.submitQuery(),用 StringBuffer 累积,通过 state.copyWith(streamingText: ...) 更新状态。
第三层:谁负责决定什么时候把内容正式归档到历史?
→ AiAssistantScreen.build(),在 status == idle 且 streamingText 非空且内容变化时,通过 addPostFrameCallback 归档。
第四层:谁负责渲染流式气泡?
→ _ChatListView + _buildStatusBubble(),根据当前状态展示不同的提示文字或正在生成的文本。
这就是一个很典型的"功能复杂度反过来逼结构更清楚"的例子。如果一开始就想把所有逻辑塞在一个地方,流式输出会把那里变成一团浆糊。
完整的流式数据流图
┌─────────────────────────────────────────────────────────┐
│ AgentService │
│ │
│ chatStreamRaw(message) │
│ │ │
│ ├─ chunk.reasoningContent → onThinking (已禁用) │
│ ├─ chunk.content → onContent ──────────────────┐ │
│ ├─ chunk.toolCalls → onToolCall ──────────┐ │ │
│ └─ chunk.isDone → 记录 token 消耗 │ │ │
│ │ │ │
│ executeToolsAndContinue() │ │ │
│ └─ chunk.content → onContent ─────────────┼────┘ │
│ └─ chunk.isDone → onComplete ─────────────┼────┐ │
│ │ │ │
├──────────────────────────────────────────────┼────┼──────┤
│ ▼ ▼ │
│ Coordinator │
│ │
│ onContent: buffer.write(chunk) │
│ state = state.copyWith( │
│ status: responding, │
│ streamingText: buffer.toString(), │
│ ) │
│ │
│ onToolCall: state = state.copyWith(status: searching) │
│ │
│ onComplete: state = state.copyWith(streamingText: full) │
│ │
│ 流式结束后: │
│ state = state.copyWith(status: idle, │
│ streamingText: buffer.toString())│
│ │
├──────────────────────────────────────────────────────────┤
│ │
│ 页面层 │
│ │
│ ref.watch(coordinator) → sessionState │
│ │
│ streamingText 非空 && status != idle │
│ → 渲染临时气泡 (isStreaming: true, 显示 loading 圈) │
│ → 同时渲染 matchedDishes 卡片 │
│ │
│ status == idle && streamingText 非空 │
│ → 归档到 _history (isStreaming: false, 显示播报按钮) │
│ → 临时气泡消失,正式消息出现 │
│ │
└──────────────────────────────────────────────────────────┘
关键代码位置
| 文件 | 作用 |
|---|---|
app/lib/core/ai/agent_service.dart |
底层流式 chunk 消费 |
app/lib/core/ai/ai_explore_coordinator.dart |
chunk 累积 + 状态流转 |
app/lib/core/ai/models/ai_session_state.dart |
状态枚举 + streamingText |
app/lib/features/ai_assistant/screens/ai_assistant_screen.dart |
流式渲染 + 历史归档 |
app/lib/features/ai_assistant/widgets/ai_message_bubble.dart |
流式气泡 UI |
app/lib/core/platform/text_to_speech_channel.dart |
鸿蒙 TTS 通道 |
鸿蒙侧与流式输出的协作关系
虽然流式输出的核心逻辑在 Flutter 侧,但它对鸿蒙端体验有直接影响:
状态提示的节奏感
在鸿蒙设备上,用户对响应速度的感知更敏感(鸿蒙系统本身以流畅著称)。流式输出让每个状态切换都有对应的 UI 反馈:
[parsing] → "正在理解你的需求..." ← 0.5秒内出现
[searching] → "正在探索全球美食..." ← 工具调用时出现
[responding] → "正在组织推荐..." ← 模型开始生成
[responding] → "为你找到了3道牛肉吃法..." ← 文本逐步出现
[idle] → 显示完整回复 + 播报按钮 ← 完成
如果没有流式输出,鸿蒙用户看到的可能是 3-5 秒的空白 loading,然后突然一大段文字。这和鸿蒙系统"快、流畅"的品牌认知是矛盾的。
TTS 播报的衔接
流式输出完成后,用户可以点击"语音播报"。但更进一步的优化是:在流式输出过程中就开始播报已经生成的部分。这需要:
-
协调器检测到
streamingText积累到一句话的边界(句号、问号等) -
通过
TextToSpeechChannel.speak()开始播报已生成的部分 -
流式结束后播报剩余部分
这种"边生成边播报"的体验在鸿蒙设备上会非常自然,因为鸿蒙的 TTS 引擎支持流式音频输入。
资源释放
流式输出过程中会持有网络连接和内存缓冲。在鸿蒙端,如果用户快速退出页面:
@override
void dispose() {
if (_isSpeaking) {
TextToSpeechChannel.stop().catchError((_) {});
}
super.dispose();
}
协调器的 dispose() 会停止 TTS。同时 Riverpod 的 autoDispose 会触发 AgentService.dispose(),释放底层 Provider 和流式任务。这个清理链路在鸿蒙端必须完整,否则可能导致音频后台播放或内存泄漏。
常见坑
-
页面只做 loading,不区分 parsing/searching/responding → 用户不知道系统在做什么,尤其是鸿蒙用户对卡顿更敏感
-
流中内容直接写进最终历史 ,导致消息结构混乱 → 一定要区分"临时气泡"和"正式历史",用
isStreaming标记 -
只有页面在处理 chunk,协调器和服务层没有分工 → 三层各司其职:AgentService 拿 chunk,Coordinator 累积,页面渲染
-
过早把流式输出做进 UI ,却没设计好状态收口 → 先设计好
AiSessionStatus状态机,再接 UI -
流式过程中没有 mounted 检查 ,页面退出后继续更新状态 → 协调器每个回调开头加
if (!mounted) return -
TTS 播报时机没控制好 ,在文本还在生成时就触发播报 → 确保
onSpeak只在!isStreaming时可点击 -
归档逻辑没有防重复 ,同一轮回复被多次加入历史 → 用
_lastStreamingText做去重
可复用模板
流式链路总结
AgentService.chatStreamRaw()
→ onContent(chunk) // 增量文本
→ onToolCall(toolCall) // 工具调用
→ onComplete(full) // 完成
Coordinator.submitQuery()
→ buffer.write(chunk)
→ state.copyWith(streamingText: buffer.toString())
→ 流式结束后: state.copyWith(status: idle)
页面.build()
→ if isStreaming: 渲染临时气泡
→ if idle && streamingText 非空: 归档到 _history
协调器流式处理模板
Future<void> submitQuery(String text) async {
state = state.copyWith(
status: AiSessionStatus.parsing,
streamingText: '',
);
final buffer = StringBuffer();
await agentService.chatWithToolsStream(
message: text,
onContent: (chunk) {
if (!mounted) return;
buffer.write(chunk);
state = state.copyWith(
status: AiSessionStatus.responding,
streamingText: buffer.toString(),
);
},
onToolCall: (toolCall) {
if (!mounted) return;
state = state.copyWith(status: AiSessionStatus.searchening);
},
onComplete: (full) {
if (!mounted) return;
state = state.copyWith(streamingText: full);
},
);
if (!mounted) return;
state = state.copyWith(
status: AiSessionStatus.idle,
streamingText: buffer.toString(),
);
}
页面流式渲染模板
// 1. 状态判断
final isStreaming =
sessionState.status == AiSessionStatus.responding ||
sessionState.status == AiSessionStatus.parsing ||
sessionState.status == AiSessionStatus.searching;
// 2. 归档逻辑
if (sessionState.status == AiSessionStatus.idle &&
sessionState.streamingText.isNotEmpty &&
_lastStreamingText != sessionState.streamingText) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_history.add(_ChatEntry(
isUser: false,
text: sessionState.streamingText,
));
_lastStreamingText = sessionState.streamingText;
});
}
});
}
// 3. 列表渲染
final itemCount = history.length + (hasStreamingBubble ? 1 : 0);
状态提示文案模板
Widget _buildStatusBubble() {
switch (sessionState.status) {
case AiSessionStatus.listening:
return AiMessageBubble(text: '正在聆听...', isStreaming: true);
case AiSessionStatus.parsing:
return AiMessageBubble(text: '正在理解你的需求...', isStreaming: true);
case AiSessionStatus.searching:
return AiMessageBubble(text: '正在探索...', isStreaming: true);
case AiSessionStatus.responding:
return AiMessageBubble(
text: sessionState.streamingText.isEmpty
? '正在组织推荐...'
: sessionState.streamingText,
isStreaming: true,
);
default:
return SizedBox.shrink();
}
}
本篇总结
AI 助手页面支持流式输出,真正带来的不是"看起来更像大模型产品",而是:
-
等待过程变得可感知 --- 用户在第一秒就能看到内容在"长出来"
-
工具调用过渡更自然 --- parsing → searching → responding 每个阶段都有对应 UI
-
页面状态更清楚 --- 7 种状态枚举让状态机不再只有"空闲"和"完成"
-
对话体验更连贯 --- 像朋友在边想边说,而不是甩一大段文字
-
鸿蒙语音衔接更顺畅 --- 流式完成后自然衔接 TTS 播报,未来可扩展为边生成边播报
食界探味当前这条流式链路之所以值得拆出来讲,就是因为它已经把这些收益落到了真实页面结构里------从底层 chunk 消费到中间状态累积,再到页面增量渲染和历史归档,每一层都有清晰的职责边界。在鸿蒙设备上,这种设计让用户感受到的是"快"和"流畅",而不是"在等"。
