项目仓库:https://atomgit.com/nutpi/EchoForge
做 EchoForge 时,我遇到的麻烦不是“Flutter 怎么请求 HTTP”,而是本地 AI 服务到底由谁来管。
EchoForge 的语音能力运行在 Python/FastAPI 进程里,桌面界面用 Flutter。开发阶段在终端手动启动后端当然没问题,但做成应用后,用户不会先开一个命令行窗口。进程路径、端口占用、启动超时、标准错误、退出回收都得由应用处理。
最后采用的办法,是让 Rust 成为 Flutter 和 Python Sidecar 之间的进程管理层,再用 flutter_rust_bridge(下文简称 FRB)把生命周期和事件流交给 Dart。
做完后的界面

这张图来自 HarmonyOS PC 真机(设备分辨率 3120×2080)。我在设备上启动 sh.echoforge.app,等待 Sidecar 从 starting 进入工作区,界面右上角出现“引擎在线”后再抓取系统截图。发布图只裁掉桌面背景,没有重绘、拼接或替换应用内容。图中的 6.0 mother voice 是应用实际读取到的声音档案,不是为了截图写进 Widget 的假数据。
先把仓库中的几条线认清
第一次接手这类工程,不建议从页面往下追。EchoForge 同时有 Flutter、Rust、Python 三套启动入口,先看目录反而更省时间:
EchoForge/
|– backend/ Python/FastAPI 业务服务
|– flutter_app/lib/app/bootstrap/ 应用启动与依赖装配
|– flutter_app/lib/features/server/ Sidecar 状态页面和控制器
|– flutter_app/lib/shared/native/ Dart 侧 NativeBridge 抽象
|– flutter_app/native/echoforge_frb/src/api/
| FRB 手写入口和进程管理 API
`– flutter_app/lib/src/rust/ FRB 自动生成的 Dart 绑定
定位问题时我通常先看 server_controller.dart,再看 native_bridge.dart,最后才进入 Rust。这样能先确认是界面状态没更新、Dart 适配错误,还是子进程真的没有起来。生成目录只用于核对签名,不在里面修业务。
为什么中间还要放一层 Rust
如果 Flutter 直接 Process.start(),短期代码确实少一些,但很快会碰到几个边界:同一时间只能有一个服务实例;端口上可能已经有外部服务;子进程输出要持续回传;窗口退出时要判断是否保留服务;macOS、Windows 和 HarmonyOS 的能力又不完全一样。
EchoForge 的运行关系如下:
#mermaid-svg-vPSHTvBZTbwBKYq9{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vPSHTvBZTbwBKYq9 .error-icon{fill:#552222;}#mermaid-svg-vPSHTvBZTbwBKYq9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vPSHTvBZTbwBKYq9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .marker.cross{stroke:#333333;}#mermaid-svg-vPSHTvBZTbwBKYq9 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vPSHTvBZTbwBKYq9 p{margin:0;}#mermaid-svg-vPSHTvBZTbwBKYq9 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .cluster-label text{fill:#333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .cluster-label span{color:#333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .cluster-label span p{background-color:transparent;}#mermaid-svg-vPSHTvBZTbwBKYq9 .label text,#mermaid-svg-vPSHTvBZTbwBKYq9 span{fill:#333;color:#333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .node rect,#mermaid-svg-vPSHTvBZTbwBKYq9 .node circle,#mermaid-svg-vPSHTvBZTbwBKYq9 .node ellipse,#mermaid-svg-vPSHTvBZTbwBKYq9 .node polygon,#mermaid-svg-vPSHTvBZTbwBKYq9 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vPSHTvBZTbwBKYq9 .rough-node .label text,#mermaid-svg-vPSHTvBZTbwBKYq9 .node .label text,#mermaid-svg-vPSHTvBZTbwBKYq9 .image-shape .label,#mermaid-svg-vPSHTvBZTbwBKYq9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-vPSHTvBZTbwBKYq9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vPSHTvBZTbwBKYq9 .rough-node .label,#mermaid-svg-vPSHTvBZTbwBKYq9 .node .label,#mermaid-svg-vPSHTvBZTbwBKYq9 .image-shape .label,#mermaid-svg-vPSHTvBZTbwBKYq9 .icon-shape .label{text-align:center;}#mermaid-svg-vPSHTvBZTbwBKYq9 .node.clickable{cursor:pointer;}#mermaid-svg-vPSHTvBZTbwBKYq9 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .arrowheadPath{fill:#333333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vPSHTvBZTbwBKYq9 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vPSHTvBZTbwBKYq9 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vPSHTvBZTbwBKYq9 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vPSHTvBZTbwBKYq9 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vPSHTvBZTbwBKYq9 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vPSHTvBZTbwBKYq9 .cluster text{fill:#333;}#mermaid-svg-vPSHTvBZTbwBKYq9 .cluster span{color:#333;}#mermaid-svg-vPSHTvBZTbwBKYq9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vPSHTvBZTbwBKYq9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vPSHTvBZTbwBKYq9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-vPSHTvBZTbwBKYq9 .icon-shape,#mermaid-svg-vPSHTvBZTbwBKYq9 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vPSHTvBZTbwBKYq9 .icon-shape p,#mermaid-svg-vPSHTvBZTbwBKYq9 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vPSHTvBZTbwBKYq9 .icon-shape .label rect,#mermaid-svg-vPSHTvBZTbwBKYq9 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vPSHTvBZTbwBKYq9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vPSHTvBZTbwBKYq9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vPSHTvBZTbwBKYq9 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
FRB 调用
spawn / stop / health check
HTTP 127.0.0.1:17493
State / stdout / stderr / ready
Flutter Workspace
echoforge_frb
SidecarManager
Python FastAPI Sidecar
语音 / 模型 / 历史 API
FRB StreamSink
这里有意保留了 HTTP。FRB 负责“本地能力和进程”,业务 API 继续走 HTTP,这样 Python 后端可以独立调试,Flutter 也不需要把每个 FastAPI DTO 再复制成一套 FFI 类型。
第一步:只暴露手写 API
项目的 FRB 配置很短:
rust_input: crate::api
rust_root: native/echoforge_frb/
dart_output: lib/src/rust
type_64bit_int: true
rust_input 指向 crate::api,意味着生成器只从 API 模块收集可导出的类型和函数。自动生成的 frb_generated.rs、frb_generated.dart 不适合手改,也不是业务逻辑所在地。
启动参数要跨语言传递,所以先定义稳定的数据结构:
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NativeSidecarConfig {
pub executable_path: String,
pub data_directory: String,
pub port: u16,
pub remote: bool,
pub models_directory: Option<String>,
pub startup_timeout_ms: u64,
}
#[flutter_rust_bridge::frb(init)]
pub fn init_app() {
flutter_rust_bridge::setup_default_user_utils();
}
pub fn start_server(config: NativeSidecarConfig) -> Result<NativeStartResult> {
validate_config(&config)?;
let manager = manager_for(&config)?;
let outcome = manager.start().map_err(|e| anyhow!(e.to_string()))?;
Ok(outcome.into())
}
pub fn stop_server() -> Result<()> {
let manager = {
let guard = runtime().lock()
.map_err(|_| anyhow!("native bridge runtime is poisoned"))?;
guard.as_ref().map(|value| value.manager.clone())
};
if let Some(manager) = manager {
manager.stop().map_err(|e| anyhow!(e.to_string()))?;
}
Ok(())
}
这里没有把 std::process::Child 暴露给 Dart。Flutter 只拿到 URL、PID 和是否连接外部服务这些可序列化信息,真实的子进程句柄留在 Rust 的 OnceLock<Mutex<Option<BridgeRuntime>>> 中。
这个取舍很重要。跨 FFI 长期持有平台句柄,往往比真正的业务还难维护。
第二步:把启动日志做成事件流
服务从“启动命令已执行”到“可以接请求”之间有一段时间。只返回一个 Future,界面无法区分正在加载模型、端口冲突还是进程已经退出。因此桥接层注册 StreamSink:
static EVENT_SINKS: OnceLock<Mutex<Vec<StreamSink<NativeEvent>>>> =
OnceLock::new();
pub fn subscribe_native_events(
sink: StreamSink<NativeEvent>,
) -> Result<()> {
let mut sinks = event_sinks()
.lock()
.map_err(|_| anyhow!("native event sink registry is poisoned"))?;
sinks.push(sink);
Ok(())
}
NativeEventKind 中包含 StateChanged、Stdout、Stderr、Ready、Exited 和 Error。Dart 侧不直接把生成代码散落到页面里,而是再包一层项目自己的接口:
abstract class NativeBridge {
Stream<NativeLog> get logStream;
Stream<NativeRuntimeEvent> get eventStream;
Future<void> startServer({required Uri baseUrl});
Future<void> stopServer();
}
class FrbNativeBridge implements NativeBridge {
Stream<NativeRuntimeEvent>? _sidecarEvents;
Stream<NativeRuntimeEvent> get _sidecarEventStream =>
_sidecarEvents ??= rust_native
.subscribeNativeEvents()
.map(_mapNativeEvent)
.asBroadcastStream();
Future<void> startServer({required Uri baseUrl}) async {
final port = baseUrl.hasPort ? baseUrl.port : 80;
await rust_native.startServer(
config: rust_native.NativeSidecarConfig(
executablePath: config.executablePath,
dataDirectory: config.dataDirectory,
port: port,
remote: config.remote,
modelsDirectory: config.modelsDirectory,
startupTimeoutMs: config.startupTimeout.inMilliseconds,
),
);
}
}
页面依赖的是 NativeBridge,不是 FRB 生成类。测试时换成 FakeNativeBridge,不需要真的启动 Python、占用端口或者加载语音模型。
实操:把这条链路跑起来
在 flutter_app 目录执行:
flutter pub get
flutter_rust_bridge_codegen generate
cargo test –manifest-path native/echoforge_frb/Cargo.toml
flutter test test/features/workspace/workspace_input_test.dart
flutter run -d macos
本地 Sidecar 默认监听 127.0.0.1:17493。启动后我会观察四件事:
不要只用“HTTP 请求成功一次”作为验收。Sidecar 最难的问题通常发生在第二次启动、异常退出和应用关闭时。
一次完整启动到底发生了什么
把启动按钮按下后,理想状态不是立刻返回 running,而是按顺序经历 stopped -> starting -> ready。Rust 先解析可执行文件和数据目录,再判断端口是否已有服务;如果需要创建进程,就同时接管 stdout、stderr 和退出状态。健康检查成功后发出 Ready,Dart 控制器才允许生成按钮进入可用状态。
这段时序里最容易写错的是超时。超时并不自动等于“子进程已经死了”:有时模型仍在加载,只是没在限定时间内通过健康检查。因此超时分支要先终止由本次启动创建的进程,再回收读日志线程,最后发布 Error。如果连接的是外部实例,则只能断开管理关系,不能杀掉不属于当前应用的 PID。
我用下面这组动作做回归,比单纯看一次绿色状态更可靠:
动作 1:冷启动应用
期望:先出现正在启动,随后出现引擎在线
动作 2:在在线状态再次请求启动
期望:返回现有实例,不创建第二个 Python 进程
动作 3:停止后再次启动
期望:端口被释放,日志订阅继续工作,PID 发生合理变化
动作 4:把 executablePath 指向不存在的文件
期望:页面收到可读错误,状态回到 stopped/failed,不永久停在 starting
动作 5:窗口退出再重开
期望:根据“保留服务”配置决定复用或回收,不出现孤儿进程
HarmonyOS PC 真机取证
本次截图不是测试运行器产生的。我使用已经安装的正式应用包启动并抓取:
HDC=/Applications/DevEco-Studio.app/Contents/sdk/default/openharmony/toolchains/hdc
$HDC list targets
$HDC shell aa start -a EntryAbility -b sh.echoforge.app
$HDC shell uitest dumpLayout -b sh.echoforge.app \\
-p /data/local/tmp/echoforge-layout.json
$HDC shell snapshot_display \\
-f /data/local/tmp/echoforge-running.jpeg
界面树里实际出现了“引擎在线”“文本转语音”“声音档案”和 6.0 mother voice,这比只截一张图多了一层可核查信息。截图证明的是:Flutter 应用已经在设备运行、Sidecar 健康检查通过、工作区读取到了档案。它不单独证明任意模型都能生成音频;模型文件、推理依赖和输出播放仍要按交付环境逐项验收。
错误处理不要只留一条字符串
实际排查时,一条 failed to start 几乎没用。建议至少在 Dart 侧保留阶段、时间、PID、端口和最近一条 stderr;在 Rust 侧为路径不存在、权限不足、端口冲突、健康检查超时和子进程提前退出分别编码。页面给用户看短消息,完整上下文进入活动日志。
还有一个细节:日志流不能反过来控制状态。某个 Python 版本可能打印 ready,另一个版本换了文案,甚至第三方库也可能输出同样单词。状态只能由进程结果与 HTTP 健康检查共同决定,日志仅用于解释。
先分清“原生库没载入”和“Python 没启动”
这两个错误在页面上都可能表现为引擎离线,但处理方向完全不同。RustLib.init() 就失败时,应检查 HAP/桌面包里是否包含目标架构的 FRB 动态库、库名是否与生成绑定一致;FRB 已能返回能力信息而 startServer 失败时,才去看 Python 可执行文件、数据目录和端口。
一个很实用的启动探针,是在 Sidecar 之前调用不依赖 Python 的同步 API,例如读取 native capabilities,并把成功结果记到活动日志。这样验收人员看到“native bridge ready, sidecar starting”,就能立即把故障范围缩到进程管理层。反过来,如果连这个探针都没有返回,再反复修改 FastAPI 端口只会浪费时间。
打包阶段还要检查可执行文件权限。开发目录里能运行的 Python 或 launcher,复制进应用资源后可能丢失执行位;路径中含空格时必须通过参数数组传给 Command,不要拼成一整条 shell 字符串。后者既容易转义失败,也会把用户可控路径变成命令注入入口。
验收时我会怎样判定
| FRB 已装载 | 应用启动后原生 API 可调用,事件流持续返回 | 只看到生成文件 |
| Sidecar 已运行 | 健康检查通过且 UI 显示引擎在线 | 只看到 Python 进程存在 |
| 重复启动安全 | 第二次启动复用现有实例 | 第一次启动成功 |
| 停止完整 | 端口释放、进程退出、订阅结束 | 按钮变灰 |
| 外部实例不被误杀 | external=true 时退出应用,外部服务仍存活 | 代码里写了 if 分支 |
| 真实工作区 | 真机界面树与系统截图相符 | Fake Bridge 的 Widget 截图 |
这张表也解释了为什么此前那张测试控制器截图不能继续使用:它最多证明页面能画出来,不能证明 FRB 和 Sidecar 在运行。
实际踩过的边界
端口已有服务不一定是错误。 如果健康检查证明它是可用的 EchoForge 实例,可以把启动结果标为 external: true,此时停止应用不应误杀外部进程。
stdout 不是 ready 信号。 模型日志打印出来,并不代表路由已经可用。可靠做法仍是健康检查加超时。
Stream 要转广播。 页面状态、日志面板可能同时监听同一来源,单订阅流会在第二个监听者出现时抛错。
HarmonyOS 需要额外平台边界。 当前实现中,Python 后端仍由 Rust Sidecar 管理;HarmonyOS 特有的文件、音频和输入能力通过平台通道补齐。两者在 Dart 适配层合流,不把平台差异塞进业务页面。
为什么最终没有把 Python 能力硬塞进 Flutter
方案评审时其实考虑过三条路。第一条是让用户自行安装 Python 环境,应用只保存服务地址;第二条是把核心推理能力改写成 Dart 或原生插件;第三条就是现在的 Sidecar。第一条开发成本最低,却把最麻烦的版本、依赖和启动顺序都推给了用户。第二条看起来最“原生”,实际意味着要重写已经稳定的模型加载、音频处理和 FastAPI 业务层,短期风险最高。Sidecar 不是最炫的结构,但它能保留现有 Python 资产,又把运行环境收进安装包,是当时更务实的选择。
这个选择也带来明确代价。应用包会变大,首次启动可能要做资源解压,杀毒软件可能关注新创建的子进程,Windows 和 macOS 对可执行权限、代码签名的要求也不同。换句话说,Rust 管理进程并没有让部署问题消失,它只是把问题集中到了一个可以测试、可以记录状态的地方。相比让页面各处零散地判断端口和进程,这种集中仍然值得。
我后来判断 Sidecar 设计是否合理,主要看两件事。一是 Python 服务能否脱离 Flutter 单独启动和调试;二是 Flutter 是否只依赖稳定的健康检查和业务 API,而不是依赖 Python 控制台输出的某句话。如果这两点都满足,后端团队升级模型时通常不需要改 UI,UI 改版时也不会触碰进程管理。三个技术栈虽然增加了工程数量,却没有把职责搅在一起。
真机联调时看到的启动过程
这次在 HarmonyOS PC 上重新取证,最开始进入的不是最终工作区,而是运行控制台。状态先显示“正在启动”,活动日志里出现 Sidecar 从 stopped 切换到 starting,端点是本机回环地址。继续等待后,页面才进入生成工作区,并显示“引擎在线”。这段等待很有价值,因为它说明界面没有在进程创建成功的一瞬间就宣布服务可用。
如果只是为了得到一张好看的图,完全可以在 starting 阶段强行把状态改成绿色。但甲方真正关心的是应用重启之后能不能自己把后端带起来,所以我保留了从启动页到工作区的完整观察。界面树和系统截图在同一轮操作中获取,画面里的声音档案也是服务启动后读取到的数据。这个过程比组件测试慢得多,却能把 Flutter、FRB、Rust 进程管理和 Python 健康检查连成一条真实证据链。
联调中还要留意一个容易误判的现象:端口能连接,不代表连到的一定是本应用的服务。开发机上可能残留上一轮进程,也可能有别的软件恰好使用同一端口。健康接口最好返回应用标识、协议版本和实例信息,Rust 验证这些字段后才能把外部服务判定为可复用。只做 TCP connect 的“健康检查”会让界面显示在线,随后所有业务请求却返回完全不相关的内容。
从开发目录走到安装包,最容易漏掉什么
开发阶段的路径通常很宽松:源码目录、虚拟环境和模型目录都在当前用户账户下,终端也继承了完整环境变量。安装包启动时则没有这些便利。Sidecar 的路径应该从应用资源目录或数据目录计算,不能依赖当前工作目录;模型目录要区分只读内置资源和可更新数据;日志目录则必须保证普通用户可写。路径只要有一处仍写死开发机位置,换台机器就会在启动阶段失败。
版本匹配也需要显式处理。Flutter 页面、Rust bridge 和 Python API 最好各自带协议版本,启动后先比较主版本,再读取能力列表。页面需要某个新接口,而 Sidecar 仍是旧版本时,应提示安装包内容不一致,而不是把 404 显示成普通网络错误。对于增量升级,还要考虑旧进程仍占用端口的情况:新应用不能直接连接旧服务并假定所有接口兼容。
日志保留策略同样属于交付内容。stdout 和 stderr 如果无限追加,长期运行后会占满用户磁盘;如果完全不落盘,客户现场的偶发启动失败又无法复盘。比较合适的做法是内存保留最近若干条供页面展示,磁盘日志按大小滚动,并在导出诊断包时去掉用户文本、模型输入和敏感路径。日志的目的应是解释状态,而不是把所有业务数据复制一份。
这套结构给后续维护带来的变化
以前遇到“生成按钮没反应”,排查会同时怀疑页面、HTTP、模型和 Python 环境。现在至少可以先看原生桥是否初始化,再看 Sidecar 状态和健康检查,最后才进入具体语音接口。每一层都有能够单独验证的入口,问题范围明显缩小。对接手项目的人来说,这种可定位性比少写几十行代码更有价值。
当然,FRB 并不天然保证稳定。真正起作用的是我们没有把进程句柄、平台对象和业务页面互相暴露,而是只传递配置、启动结果和事件。只要这个边界继续保持,未来即使 Python 服务换成另一个本地引擎,Flutter 看到的生命周期仍然可以不变。反过来,如果为了省事不断往桥接类型里塞平台细节,几年后它也会变成另一个难以替换的耦合层。
当前状态
目前仓库已经具备 FRB 初始化、Sidecar 启停、原生事件订阅、桌面能力探测和用于自动化测试的可替换 Bridge。图中记录的是 sh.echoforge.app 在 HarmonyOS PC 真机进入“引擎在线”状态后的实际界面。真正的语音生成仍依赖本地 Python 运行时和模型文件,发布部署时必须一并打包,并重新验证路径、权限、模型可用性和音频输出。
这套结构最有价值的地方,不是“Flutter 调到了 Rust”,而是把进程所有权说清楚了:Flutter 管交互,Rust 管本地生命周期,Python 专注模型与业务 API。三层各自都能单测,也都能单独定位故障。




