news 2026/6/13 2:12:52

鸿蒙 + Flutter 下 AI 助手为什么要支持流式输出

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
鸿蒙 + Flutter 下 AI 助手为什么要支持流式输出

适合谁看

  • 正在做鸿蒙端 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; }); } }); }

这段代码的触发条件非常精确:

  1. status == idle— 流式输出已完成

  2. streamingText.isNotEmpty— 有实际内容

  3. _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 == idlestreamingText非空且内容变化时,通过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 播报的衔接

流式输出完成后,用户可以点击"语音播报"。但更进一步的优化是:在流式输出过程中就开始播报已经生成的部分。这需要:

  1. 协调器检测到streamingText积累到一句话的边界(句号、问号等)

  2. 通过TextToSpeechChannel.speak()开始播报已生成的部分

  3. 流式结束后播报剩余部分

这种"边生成边播报"的体验在鸿蒙设备上会非常自然,因为鸿蒙的 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 消费到中间状态累积,再到页面增量渲染和历史归档,每一层都有清晰的职责边界。在鸿蒙设备上,这种设计让用户感受到的是"快"和"流畅",而不是"在等"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/13 2:12:50

Skills实战:从0到1封装一个“登录鉴权”Skill,拿来即用

很多人已经开始感觉到&#xff0c;2024年AI Agent的热潮退去之后&#xff0c;剩下的不是泡沫&#xff0c;而是一地没解决的问题。 最典型的一个场景&#xff1a;你花了三天时间调教出来的AI助手&#xff0c;换个系统就废了。让它登录一下企业内部系统&#xff0c;它说“请提供用…

作者头像 李华
网站建设 2026/6/13 2:12:36

拯救老旧电视的终极方案:MyTV-Android让老设备焕发新生

拯救老旧电视的终极方案&#xff1a;MyTV-Android让老设备焕发新生 【免费下载链接】mytv-android 使用Android原生开发的视频播放软件 项目地址: https://gitcode.com/gh_mirrors/my/mytv-android 你是否有一台运行缓慢的老旧安卓电视&#xff0c;系统版本停留在4.x时代…

作者头像 李华
网站建设 2026/6/13 2:12:34

Adobe-GenP 3.0:专业级Adobe软件激活工具深度指南

Adobe-GenP 3.0&#xff1a;专业级Adobe软件激活工具深度指南 【免费下载链接】Adobe-GenP Adobe CC 2019/2020/2021/2022/2023 GenP Universal Patch 3.0 项目地址: https://gitcode.com/gh_mirrors/ad/Adobe-GenP Adobe-GenP 3.0是一款专为创意专业人士设计的Adobe Cr…

作者头像 李华