Nexora 用 Rust 处理 AI 流式回复:FRB 桥接实战记录
为什么要在 Rust 里收流
聊天应用最简单的版本是发 HTTP 请求,等完整文本返回再塞进消息气泡。真用起来问题陆续冒出来:首字等太久、用户想中途停止、页面离开要清理任务、网络断了还得保留已收到的内容。
Nexora 选择把麻烦事放在 Rust 里做。模型服务商配置、网络流解析、取消控制和 SQLite 持久化都交给 Rust,再通过 flutter_rust_bridge(FRB)把生成过程转成 Dart Stream。Flutter 只管界面:三栏工作区、会话列表、消息展示和停止按钮。
这么做有几个直接好处。跨语言接口可以围绕业务动作命名,页面用 createTurn、streamResponse 和 cancelResponse,不用自己拼底层通信。多个服务商的字段和结束标记各不相同,Rust 统一转成增量、完成、暂停、失败四种事件,Flutter 只消费标准事件。生成任务用助手消息的 message_id 做身份,取消、持久化、查记录都用同一个 ID,能防住对同一条消息重复启动流。
旋武社区与 FRB 项目
开放原子旋武开源社区是开放原子开源基金会旗下的 Rust 专项社区,提供工具链、学习资料、在线编码和开源项目。准备用 Flutter + Rust 开发的同学,可以先从这里了解 Rust 基础,再结合具体仓库学跨语言开发。
flutter_rust_bridge 是连接 Flutter/Dart 与 Rust 的绑定工具。开发者在 Rust 定义 API 和数据结构,代码生成器产出绑定后,Dart 就能调用接口、处理错误和流式事件。鸿蒙平台的接入说明和问题反馈,可以从该仓库查阅。
环境准备与版本对齐
基础安装参考两篇教程:Flutter-OH 环境搭建,以及 VS Code 配置 Rust 开发环境。复现 Nexora 时,还要按项目 README 对齐 FRB 版本、OHOS target 与签名配置。
本文代码对应 Nexora 仓库 a453a69 提交,关键要求如下:
- Flutter:使用支持 OHOS 的发行版
- Dart:
^3.9.2 - FRB Dart/Rust 依赖与 codegen:固定
2.13.0-beta.6 - Rust target:
aarch64-unknown-linux-ohos - 鸿蒙 SDK:API 22
检查命令:
flutter --version
rustc --version
cargo --version
rustup target add aarch64-unknown-linux-ohos
cargo install flutter_rust_bridge_codegen --version 2.13.0-beta.6 --lockedFRB 的 Dart 依赖、Rust 依赖和生成器必须配套。只升级生成器而保留旧依赖,会出现生成失败或运行时异常。
获取项目并生成绑定:
git clone https://atomgit.com/nutpi/Nexora.git
cd Nexora
flutter pub get
flutter_rust_bridge_codegen generateflutter_rust_bridge.yaml 配置:
rust_input: crate::api
rust_root: rust/
dart_output: lib/src/rust生成器读取 Rust 对外 API,产出 Dart 绑定和 Rust 桥接代码。业务修改写在 API、仓库、页面或存储层,再重新生成。
核心实现:从发送到历史恢复
完整功能链路
Nexora 的聊天主线:配置服务商与模型 → 创建会话 → 发送消息 → Rust 接收流 → Flutter 更新正文 → 完成或停止 → SQLite 保存 → 重启读取历史。
创建消息与启动生成分开。assistantMessage.id 既是任务身份,也是取消、保存和错误定位的入口。Rust 先写数据库终态,再通知页面;持久化失败时错误通过 Result 返回,不能把调用结束直接当成保存成功。
仓库接口:页面只调业务方法
应用入口先初始化 Flutter 绑定和 Rust 库,再注入 NativeChatRepository。
聊天仓库三个关键接口:
abstract interface class ChatRepository {
Future<native.ChatTurnSnapshot> createTurn(
native.CreateChatTurnRequest request,
);
Stream<ai.ChatStreamEvent> streamResponse(String messageId);
Future<bool> cancelResponse(String messageId);
}NativeChatRepository 直接转发给 FRB:
@override
Stream<ai.ChatStreamEvent> streamResponse(String messageId) =>
ai.streamChatResponse(messageId: messageId);
@override
Future<bool> cancelResponse(String messageId) =>
ai.cancelChatStream(messageId: messageId);生产用原生实现,测试可替换仓库,让页面无需连接真实模型就能验证状态。
Rust 流式入口
跨语言事件定义:
pub enum ChatStreamEventKind {
Delta,
Completed,
Failed,
Paused,
}
pub struct ChatStreamEvent {
pub message_id: String,
pub kind: ChatStreamEventKind,
pub delta: String, // 本次新增
pub text: String, // 截至当前完整正文
pub error_code: Option<String>,
pub error_message: Option<String>,
}stream_chat_response 实现要点:
pub async fn stream_chat_response(
message_id: String,
sink: StreamSink<ChatStreamEvent>,
) -> Result<(), NativeError> {
let context = load_chat_generation_context(&message_id)?;
let provider = resolve_chat_provider(&context.model_record_id)?;
let mut cancellation = register_stream(&message_id)?;
let _guard = ActiveStreamGuard::new(message_id.clone());
let mut text = String::new();
match receive_response(/* ... */).await {
Ok(ReceiveResult::Completed) => {
if text.trim().is_empty() {
return finish_failed(/* empty_response */);
}
persist_assistant_stream_state(&message_id, &text, ChatMessageStatus::Success)?;
let _ = sink.add(ChatStreamEvent {
message_id,
kind: ChatStreamEventKind::Completed,
delta: String::new(),
text,
error_code: None,
error_message: None,
});
Ok(())
}
Ok(ReceiveResult::Paused) => {
persist_assistant_stream_state(&message_id, &text, ChatMessageStatus::Paused)?;
// 发送 Paused 事件
Ok(())
}
Err(error) => finish_failed(&message_id, &sink, text, error),
}
}三个关键点:先准备上下文再注册活动流,完成与暂停都先写数据库,空响应与失败有独立处理。ActiveStreamGuard 的 Drop 在函数退出时清理活动表。
协议解析与部分保存
Rust 内部把 OpenAI、Anthropic、Google、Ollama 等协议转成统一 ParsedFrame。
网络 chunk 可能截断在 UTF-8 字节中间。StreamDecoder 先缓存字节,遇到换行再解析整行:
fn push(&mut self, bytes: &[u8]) -> Result<Vec<ParsedFrame>, NativeError> {
self.buffer.extend_from_slice(bytes);
let mut frames = Vec::new();
while let Some(newline) = self.buffer.iter().position(|b| *b == b'\n') {
let line = self.buffer.drain(..=newline).collect::<Vec<_>>();
if let Some(frame) = parse_stream_line(self.protocol, &line[..line.len() - 1])? {
frames.push(frame);
}
}
Ok(frames)
}当前处理常见单行 JSON SSE 和 Ollama JSON 行。若一个 SSE 事件由多条 data: 行组成 JSON,还需补充事件级聚合。
部分保存阈值:
const MAX_RESPONSE_CHARS: usize = 200_000;
const PARTIAL_SAVE_BYTES: usize = 512;
const PARTIAL_SAVE_INTERVAL: Duration = Duration::from_millis(500);新增至少 512 字节,或距上次保存 500ms,就写入 Pending 快照。500ms 是循环内检查阈值,没有独立计时任务,不能承诺异常退出最多只损失 500ms 文本。
停止生成与历史恢复
用户点击停止后,Flutter 调用 cancelResponse(messageId)。Rust 从活动流表取得 Tokio watch 发送端发出信号;接收循环用 tokio::select! 同时等网络数据和取消通知,收到后返回 Paused。
cancelResponse 返回 true 只说明信号发送成功,不代表已落盘。页面要等流退出或重新查询消息确认结果。
| 流事件 | 数据库状态 | Flutter 处理 |
|---|---|---|
| Delta | 达到阈值时 Pending | 用完整 text 更新消息 |
| Completed | Success | 显示最终正文,结束生成 |
| Paused | Paused | 保留停止前文本,结束生成 |
| Failed | Error | 保留部分文本或错误提示,显示失败原因 |
工作区用 await for 消费事件,按消息 ID 更新气泡;finally 中重新读取当前话题的持久化消息。StreamSubscription.cancel() 只取消 Dart 订阅,不能替代 Rust 取消 API。
重启后从历史读取。恢复已保存消息与恢复中断的网络流是两件事:当前不能将历史恢复理解为自动续传,也不能保证异常退出后遗留的 Pending 已自动修复。
真机运行
构建与安装
DevEco Studio 打开 ohos/,配置签名后构建:
flutter build hap --debug --target-platform ohos-arm64 --codesign签名产物在 build/ohos/hap/entry-default-signed.hap。安装并启动:
hdc -t <设备ID> install -r build/ohos/hap/entry-default-signed.hap
hdc -t <设备ID> shell aa start -a EntryAbility -b com.cherryhq.cherry_studio验证步骤
- 配置可用服务商和模型,确认设备能访问接口
- 新建会话,发送
Reply with exactly: DEEPSEEK_OK,核对返回 - 发送第二条消息,检查新消息正常出现
- 发起长回复,观察增量更新;中途停止,核对部分正文保留且状态为
Paused - 关闭重启应用,检查已保存的会话和消息内容
- 用无效凭据测试,检查失败提示和状态复位
- 分别验证页面销毁、网络中断、应用异常退出,记录终态及重启表现
原有记录显示 DeepSeek 首轮回复通过,历史恢复通过;主动取消、多协议联调、异常退出等需专项复测。
问题排查与贡献
提 Issue
先确定层次:Nexora 业务问题进 Nexora Issues;FRB 生成失败、类型映射异常进 FRB Issues。
至少包含:环境信息(主机、Flutter-OH、Dart、Rust、FRB 版本、SDK、设备型号)、复现步骤、预期与实际行为、完整日志、最小复现仓库。
提 PR
- 阅读仓库贡献指南,在 Issue 说明修复思路
- Fork 仓库,从目标分支创建修复分支
- 修改源码,补充回归测试,重新生成绑定
- 执行格式、静态检查和测试,在原报错设备验证
- 推送到个人 Fork,在 AtomGit 提 PR
- 写明触发条件、根因、修复行为、验证结果、关联 Issue
常见问题速查
| 现象 | 优先检查 | 处理 |
|---|---|---|
flutter doctor 未识别鸿蒙工具链 |
Flutter 路径与 SDK 配置 | 使用 Flutter-OH,配置本机 SDK |
| FRB 生成后接口不匹配 | 版本对齐 | 统一 2.13.0-beta.6,重新生成 |
| 找不到 Clang 或 OHOS target | Native SDK 与 Rust target | 安装对应 SDK 和 aarch64-unknown-linux-ohos |
| HAP 安装签名校验失败 | 包名、证书、Profile、UDID | 为当前设备配置匹配签名 |
| 启动时原生库加载失败 | HAP 架构与动态库 | 检查 arm64 包内 so 文件与构建集成 |
| HTTP 401/403 | 凭据与权限 | 核对设置页凭据,避免重发错误请求 |
| HTTP 429 | 额度或频率限制 | 降低频率或稍后重试 |
| 空响应 | 协议类型与输出字段 | 检查解析分支,空正文转 chat.empty_response |
| 多行 SSE 解析失败 | 事件边界聚合 | 补充按 SSE 空行聚合逻辑 |
| 首包前停止不够快 | 取消信号覆盖范围 | 把请求发送阶段纳入取消等待 |
| 停止后底层还在请求 | 是否调 Rust 取消 API | 使用 cancelResponse,核对终态 |
chat.stream_already_active |
重复启动检查 | 防住前端重复触发和并行流 |
| 连接关闭后标为成功 | EOF 与结束标记区分 | 补充截断判定和回归测试 |
| 超长回复保存失败 | 字符上限与失败路径 | 按字符边界限制,验证错误终态 |
| 重启看不到历史 | 应用身份与数据目录 | 使用同一包名,区分覆盖安装与卸载重装 |
异常退出后 Pending 残留 |
中断状态处理 | 启动恢复需补充遗留记录处理,不能当成运行中任务 |
项目入口
Nexora 后续可继续完善:请求发送阶段的取消、严格区分协议结束与网络截断、异常退出后的遗留状态处理、长回复的事件传输优化。扩展工具调用、本地模型等能力时,也可复用消息 ID、统一事件和持久化边界。