适合谁看
正在做鸿蒙端 AI 聊天页的人
犹豫要不要做流式输出的人
想理解流式输出对页面状态设计和鸿蒙语音联动影响的人
问题背景
不做流式输出,AI 页面通常会变成这样:
用户发问
页面显示 loading
等很久
一次性跳出整段文本
这在技术上当然可行,但在体验上会有几个问题:
等待期间没有内容反馈
用户很难判断系统是在思考、搜工具,还是卡住了
工具调用和最终回答之间的过渡很硬
鸿蒙端的语音播报不知道什么时候该开始— 如果等整段文本一次性出来再播报,延迟会非常明显
鸿蒙设备内存更紧张— 一次性拼接大段文本比增量处理更容易触发内存峰值
所以流式输出的价值,不只是"像大模型产品",而是帮助页面把等待过程变得可感知,同时让鸿蒙端的语音体验更流畅。
项目中的真实场景
食界探味当前这条流式链路涉及的文件:
文件 | 流式链路中的角色 |
|---|---|
| 底层流式 chunk 消费和分发 |
| chunk 累积为 |
|
|
| 流中内容渲染 + 完成后归档到历史 |
| 流式气泡 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, 显示播报按钮) │ │ → 临时气泡消失,正式消息出现 │ │ │ └──────────────────────────────────────────────────────────┘关键代码位置
文件 | 作用 |
|---|---|
| 底层流式 chunk 消费 |
| chunk 累积 + 状态流转 |
| 状态枚举 + streamingText |
| 流式渲染 + 历史归档 |
| 流式气泡 UI |
| 鸿蒙 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) returnTTS 播报时机没控制好,在文本还在生成时就触发播报 → 确保
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 消费到中间状态累积,再到页面增量渲染和历史归档,每一层都有清晰的职责边界。在鸿蒙设备上,这种设计让用户感受到的是"快"和"流畅",而不是"在等"。