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

相关推荐
爱勇宝2 小时前
如何评价 Claude Fable 5 全球暂停访问?
人工智能·程序员
装不满的克莱因瓶2 小时前
自然语言处理常见任务——从文本理解到生成式AI的完整任务体系
人工智能·pytorch·python·深度学习·ai·自然语言处理
朱大喜2 小时前
AI 数据分析实战:大模型驱动的自动化报表生成,从数据到洞察的工程化链路
人工智能
wb043072012 小时前
阿明的二次创业——从阿明用 AI 开第二家店,看 AI 原生创业的四阶段方法论
大数据·人工智能·架构
Godspeed Zhao2 小时前
Level 4自动驾驶系统设计0——功能与场景0
人工智能·机器学习·自动驾驶
Dola_Zou2 小时前
边缘智能的“黑暗森林”:工业 AI 模型下沉的资产防护与变现密码
人工智能·安全·自动化·软件工程·软件加密
青岛前景互联信息技术有限公司2 小时前
前景互联·新一代智能接处警系统:AI+大模型+Agent智能接处警一体化解决方案
大数据·人工智能·物联网
xin_yao_xin2 小时前
Claude Code 安装与 DeepSeek-V4 模型配置(2026 最新)
人工智能·ai·大模型·deepseek·claude code
北京软秦科技有限公司2 小时前
通用零部件来料材质证书智能把关,IACheck搭配AI报告审核通审Agent版比对订单与报告参数
人工智能·材质