1. 为什么我要把生成式AI插件搬到鸿蒙:一次跨端项目的真实处境
我这段时间一直在做一件听起来有些"硬啃"的事:把 Flutter 侧常用的生成式语言 API 插件 google_generative_language_api 的能力,完整搬到鸿蒙(HarmonyOS)环境里。项目背景是一款已经上线的跨端智能助手,原本依赖 Flutter 调用云端大模型接口,做文本生成、流式对话和基础的多轮记忆。整体架构里最关键的一环,就是这个封装好 Dart 接口、底层走原生网络请求的插件。结果一跑到鸿蒙设备上,调用直接抛MissingPluginException,页面能渲染,但所有 AI 能力全部瘫痪。
鸿蒙端能跑 Flutter 这件事,本身已经不是问题。社区的鸿蒙适配引擎已经把 Dart 侧的 UI 渲染、事件分发、基础插件通道都打通了,问题是插件生态。大量 Flutter 包在 Android/iOS 上没问题,但只要依赖了原生能力,鸿蒙端就变成一块空白。google_generative_language_api 恰好属于这一类:它在 Dart 侧定义了漂亮的generateContent、streamGenerateContent、countTokens等接口,真正干活的却是 Android/iOS 原生代码。鸿蒙没有现成实现,Dart 侧调用就找不到对应的方法处理器。
有人可能会问:既然它本质是 HTTP 请求,为什么不直接在 Dart 侧用 dio 重写一遍?这个问题我当时也认真考虑过。原插件的价值在于把流式响应、超时管理、token 统计、异常类型都打包好了,上层业务已经基于这套接口写了大量逻辑,重写意味着所有调用层都要跟着变。更合理的路线,是在鸿蒙侧补一个原生实现,让 Dart 侧代码一行不改,同时把多模型调度和推理治理能力加进去——这样既解决了当前适配问题,也顺手把架构升级了一次。
1.1 从插件的 MissingPluginException 说起
先说清楚最初的问题现象。在鸿蒙设备上跑原有 Flutter 工程,进入对话页面,点击发送按钮后,日志里出现MissingPluginException: No implementation found for method generateContent on channel ...。这个异常说明 Dart 侧通过MethodChannel发起调用时,鸿蒙侧没有任何代码响应这条通道。Android 和 iOS 都有对应的插件注册类,鸿蒙侧没有,因为原插件发布时压根没考虑过这个平台。
我当时的第一个想法是找找有没有别人做过的鸿蒙适配版本。翻了一圈,只找到一些个人仓库的雏形,有的只做了文本补全,有的连流式响应都没实现,更别提模型调度和治理这类上层能力。在这种前提下,与其依赖不稳定的第三方补丁,不如自己动手做一个可以持续维护的适配层。
1.2 "能调通"和"能落地"之间隔着一层治理
如果只是让接口调用不报错,那适配工作大概一天就能完成:在鸿蒙侧注册一个 MethodCallHandler,收到请求后发个 HTTP 请求,把 JSON 返回给 Dart。但我很快意识到,真实业务场景里,"能调通"只是底线。生成式 AI 接口和我们平常调的普通 REST API 不一样,它有流式输出、长连接、token 消耗、限流、超时重试、成本核算、内容安全过滤等一系列特殊问题。
举一个最简单的情况:用户连续发消息,如果没有在端上做限流,某个异常的循环调用很可能瞬间打爆云端的配额,账单直接失控。再比如多模型场景,团队可能同时接入了多个大模型供应商,价格和响应速度差异很大,硬编码只调一个模型的做法在业务扩大后一定会返工。这些都是"治理"层面的问题,不是适配阶段能视而不见的。
所以整个项目的实际范围变成了两层:第一层是 google_generative_language_api 的鸿蒙适配,解决能不能用的问题;第二层是在这个插件之上构建调度与治理层,解决好不好用、可不可控的问题。这也是这篇文章标题里"大语言模型调度与全场景智能推理治理架构"的由来。
2. 方案总览:插件通道层、模型调度层、治理层怎么分工
动手写代码之前,我先在文档里把整体结构拆成了三层。这个划分非常重要,它决定了后续每一项改动应该落在哪个模块里,也决定了上层业务代码最终会依赖什么样的接口。
2.1 接入层:业务代码只认一套接口
接入层直接面向业务方。它暴露的不是某个具体模型的 SDK 方法,而是一套统一的大模型调用接口。调用方只需要描述"我要生成一段文本""我要流式对话""我要统计 token",不需要关心底层到底走的是鸿蒙原生 HTTP 请求,还是 Android 的 OkHttp,也不需要关心当前路由到了哪个模型。
这一层的核心价值是稳定。业务代码写完之后,不应该因为底层切换模型或替换实现而重写。我在 Dart 侧定义了LLMClient抽象类,提供generateContent、streamGenerateContent、countTokens、cancelStreaming等核心方法,底层通过PlatformInterface分发到对应平台的实现。鸿蒙适配完成后,Dart 侧只是多了一个平台实现类,业务代码感知不到任何变化。
2.2 桥接层:用通道协议把鸿蒙原生能力暴露给 Dart
桥接层是这次适配的重点工程。Flutter 和鸿蒙原生之间通过MethodChannel和EventChannel通信,前者用于一问一答的调用,后者用于连续的事件流推送。
桥接层需要定义一套清晰的协议:方法名是什么、入参结构是什么、返回值结构是什么、错误怎么表达。比如generateContent的入参是"请求内容 + 模型参数",返回值是"候选文本 + token 统计 + 状态信息";streamGenerateContent的入参相同,但结果不是一次性返回,而是通过事件通道逐块推送。协议一旦确定,Dart 侧和鸿蒙侧就可以并行开发,各自按契约实现。
2.3 调度与治理层:让每一条请求都有明确的策略归属
调度与治理层处在接入层之下、桥接层之上,是一个完全由 Dart 实现的中间件层。它不关心具体平台,只关心策略。
这一层做的事情包括:请求进来后先解析出目标意图和参数,交给路由模块决定使用哪个模型;发出请求前经过限流器和审计模块;响应回来后经过降级判断和内容安全过滤。下面这个表格列出了各层关注的核心问题,方便对照理解:
| 层级 | 关注点 | 典型问题 |
|---|---|---|
| 接入层 | 接口统一性 | 业务方只需要调用streamGenerateContent,不需要知道底层细节 |
| 调度层 | 模型选择与容错 | 多个模型可用时,路由到哪个;请求失败后是否切换 |
| 治理层 | 成本、限流、安全 | 并发超限怎么办;token 消耗如何核算;敏感内容如何拦截 |
| 桥接层 | 平台通信 | 鸿蒙侧如何接收 Dart 请求;流式数据如何逐块回传 |
三层划分完成后,整个适配路径就非常清晰了:先打通桥接层,让接口能跑通;然后下沉调度层,解决多模型路由;最后补充治理层,让系统处于可控状态。
3. 鸿蒙适配实战:桥接契约、流式通道与原生请求落地
这一章是整篇内容的硬核部分。我会把 google_generative_language_api 鸿蒙适配过程中最关键的几个技术节点拆开讲,包括插件骨架怎么搭、通道协议怎么设计、流式响应怎么通过事件通道回传,以及鸿蒙侧网络请求有哪些需要注意的细节。
3.1 建立鸿蒙平台的插件骨架
Flutter 插件的标准目录结构是android/、ios/、web/等平台子目录。要为鸿蒙增加支持,需要在插件根目录下新增ohos/目录,里面放鸿蒙侧的模块工程结构。
Dart 侧的逻辑相对简单,先定义一个抽象接口:
abstract class GenerativeLanguageApi { Future<GenerateContentResponse> generateContent(GenerateContentRequest request); Stream<GenerateContentChunk> streamGenerateContent(GenerateContentRequest request); Future<int> countTokens(CountTokensRequest request); }然后通过PlatformInterface完成默认实现分发。鸿蒙侧要做的,就是注册一个方法通道处理器,接收从 Dart 侧发来的方法调用,执行实际的网络请求。鸿蒙环境里,一个典型的方法是:
import { MethodCall, MethodChannel } from '@ohos/flutter_ohos'; const CHANNEL_NAME = 'plugins/generative_language_api/llm'; const methodChannel = new MethodChannel(CHANNEL_NAME); methodChannel.setMethodCallHandler((call: MethodCall) => { if (call.method === 'generateContent') { return handleGenerateContent(call.arguments); } else if (call.method === 'streamGenerateContent') { handleStreamGenerateContent(call.arguments); return null; } else if (call.method === 'countTokens') { return handleCountTokens(call.arguments); } throw new Error(`unsupported method: ${call.method}`); });这里有一个容易被忽略的点:streamGenerateContent这种流式方法,不能在处理方法里阻塞等待完整结果再返回,因为用户要看到逐步生成的效果。正确做法是立刻返回,把后续的生成结果通过事件通道异步推回 Dart 侧。
3.2 方法通道协议:不是随便定义几个字符串就行
通道协议的设计质量,直接影响调试成本。我见过不少插件的方法名东一个西一个,入参字段命名也随意,出了问题排查半天。这次我把协议固定成一张表,开发时双方都按这张表对齐。
| 方法名 | Dart 入参 | Dart 返回 | 鸿蒙侧行为 |
|---|---|---|---|
| generateContent | { prompt, modelName, temperature, maxOutputTokens, topK, topP, safetySettings } | { candidates, usage } | 发起普通 HTTP 请求,等待完整响应 |
| streamGenerateContent | { prompt, modelName, temperature, maxOutputTokens } | Stream<GenerateContentChunk> | 发起流式请求,按事件通道推送分块 |
| countTokens | { prompt, modelName } | { tokenCount } | 向模型接口查询 token 数量 |
| cancelStreaming | { requestId } | null | 取消正在进行的流式请求 |
入参统一用字符串和数字类型,避免因为平台间类型映射不一致导致的问题。鸿蒙侧收到 Dart 的Map后,转成 ArkTS 对象处理;返回时同样把对象序列化成Map。保持字段名两端完全一致是一个好习惯,比如温度参数在 Dart 侧叫temperature,鸿蒙侧就叫temperature,不要改成temp。
3.3 流式响应通道:EventChannel 的正确打开方式
非流式的generateContent实现很简单,发个请求、解析 JSON、返回即可。真正的复杂度在streamGenerateContent。如果只用MethodChannel,Dart 侧调用一个方法后只能收到一个最终结果,无法在生成过程中持续拿数据。必须借助EventChannel。
Dart 侧先创建一个事件流监听:
final _eventChannel = EventChannel('plugins/generative_language_api/llm_stream'); Stream<GenerateContentChunk> _receiveStream() { return _eventChannel.receiveBroadcastStream().map((event) { return GenerateContentChunk.fromJson(event); }); }鸿蒙侧则在收到streamGenerateContent调用后,创建一个EventChannelSink,当流式 HTTP 请求的每个 chunk 到达时,把解析后的数据通过 sink 推送出去:
const eventChannel = new EventChannel('plugins/generative_language_api/llm_stream'); const eventSink = eventChannel.createEventSink(); httpRequest.onDataReceive((chunk) => { const parsed = JSON.parse(chunk); const candidates = parsed.data.candidates; eventSink.success({ text: candidates[0]?.content?.parts?.map((part) => part.text).join('') ?? '', finishReason: parsed.data.finishReason ?? '', requestId: currentRequestId, }); });事件通道的坑在于生命周期管理。流式请求可能被用户主动取消,也可能因为网络错误中断。这两种情况下,都要调用eventSink.error()或eventSink.done(),否则 Dart 侧的订阅会一直挂起,内存和事件资源都得不到释放。我在实现里专门维护了一个 requestId 到 sink 的映射,取消请求时根据 requestId 找到对应 sink 并关闭。
3.4 鸿蒙侧网络任务与 JSON 处理的取舍
鸿蒙侧发送 HTTP 请求,我使用的是@ohos.net.http模块。基本流程是:创建HttpRequestOptions,设置 URL、请求头、请求体,然后调用http.createHttp().request()。
这里有几个参数值得说清楚。连接超时我设为 10 秒,读超时设为 60 秒,因为生成式 AI 的首 token 延迟通常比普通接口高。对于流式接口,HttpRequestOptions里要开启expectDataType: 1配合onDataReceive回调,才能逐块拿到数据,而不是等服务端完全关闭连接后再一次性返回。
JSON 处理方面,我坚持所有响应在鸿蒙侧直接解析成结构化对象,再映射为 Dart 侧的Map。不要图省事把原始 JSON 字符串直接透传给 Dart,那样会让问题定位变得困难:鸿蒙侧能发现的结构问题,Dart 侧根本看不到。
还需要注意一个细节:生成式 AI 接口返回的 JSON 可能很大,包含多轮对话上下文时尤其明显。解析超大 JSON 字符串时,建议使用流式读取加分段解析,避免一次加载导致内存抖动。实测下来,在低端鸿蒙设备上,一次性JSON.parse一个数兆字节的响应文本,会造成明显的卡顿甚至闪退。
4. 大模型调度层:多模型接入、路由策略与流式降级
插件适配打通之后,接下来是一个更偏架构设计的问题:当系统里不止一个大模型时,请求应该如何路由?当某个模型不稳定时,系统应该如何降级?
4.1 统一请求模型:把不同供应商的差异挡在外面
不同的大模型服务,请求参数和响应结构差异很大。有的支持topP,有的只支持temperature,有的最大输出长度只有几百 token,有的能输出几万字。如果业务代码针对每个模型写一套逻辑,维护成本会迅速失控。
我在调度层定义了一个统一的LLMRequest模型,包含以下关键字段:
class LLMRequest { final String prompt; final String requestId; final LLMCapability capability; // textOnly, multimodal, functionCalling final double temperature; final int maxOutputTokens; final String priority; // low, normal, high final Duration timeout; }业务方提交的请求是这个统一模型,网关内部再根据路由到的目标模型,把参数转换为该模型接口能识别的格式。温差转换、输出长度截断、能力不支持时的降级处理,全部封装在网关内部,业务方无感知。
4.2 路由策略:成本、延迟、稳定性的三角权衡
路由模块需要回答一个问题:当前这个请求,到底该用哪个模型?
我实现了三种可配置的路由策略,线上运行时可以动态切换。
| 策略 | 选择逻辑 | 适用场景 |
|---|---|---|
| 成本优先 | 选择单次请求成本最低的模型 | 日常闲聊、非关键任务 |
| 时延优先 | 选择历史上首 token 时间最短的模型 | 实时对话、客服响应 |
| 质量优先 | 选择效果评分最高的模型 | 内容创作、复杂推理 |
每种策略背后都有一份模型状态表,实时记录每个模型的健康度。连续失败率达到阈值时,自动把该模型标记为不健康,路由模块会暂时绕开它,等恢复后重新纳入。这个机制和注册中心的健康检查思路类似,但它作用在端上,不需要额外的基础设施。
4.3 流式场景下的动态调度:TTFT 和 token 速率是新的平衡点
流式对话场景下,路由和降级策略不能简单套用普通请求的逻辑。普通请求如果超时,可以整体重试;但流式请求如果已经输出了几个字,用户已经看到了内容,此时切换模型会造成体验割裂。
我的做法是引入首帧时间(TTFT, Time To First Token)监控。当一个模型接手流式请求后,如果超过阈值还没有输出第一个 token,则立即切换到备用模型,并且把切换事件作为一条审计日志记录下来。已经输出的内容继续呈现,切换操作对用户透明。
与此同时,调度层还会统计每个模型的 token 输出速率。如果某个模型平均每秒只能输出 5 个 token,而另一个模型能输出 30 个 token,在"速度优先"模式下,调度层会倾向于把长文本生成任务交给后者。这个指标需要在真实的鸿蒙设备上持续采集,不能只看服务端报告的响应时长,因为端侧解析和渲染同样占用时间。
5. 智能推理治理:限流、审计与内容安全的一体化设计
调度层解决的是"选对模型"的问题,治理层解决的是"别出事"的问题。生成式 AI 接入生产环境,最怕三件事:成本失控、调用异常无据可查、内容安全失守。
5.1 令牌桶限流:在端上做第一道防线
限流不一定非要在服务端做,端上做一个基础限流同样重要。鸿蒙设备上可能出现的问题很多:用户快速连点、某个页面在后台不断重试、多个模块同时发起未收敛的调用。这些情况如果没有端上保护,会直接冲击服务端配额。
我用令牌桶算法实现了一个轻量限流器,完全使用 Dart 编写,不依赖任何平台能力:
class TokenBucket { final double capacity; final double refillRatePerSecond; double _tokens; DateTime _lastRefill; TokenBucket(this.capacity, this.refillRatePerSecond) : _tokens = capacity, _lastRefill = DateTime.now(); bool tryAcquire(int count) { _refill(); if (_tokens >= count) { _tokens -= count; return true; } return false; } void _refill() { final now = DateTime.now(); final delta = now.difference(_lastRefill).inMilliseconds / 1000; _tokens = (_tokens + delta * refillRatePerSecond).clamp(0, capacity); _lastRefill = now; } }实际使用中,我按用户维度维护了一个Map<String, TokenBucket>,对话模式下每秒补充 1 个 token,突发容量 5 个 token。业务方每次调用前先tryAcquire(1),拿不到令牌就直接在端上返回"请求过于频繁"的提示,不再发起网络请求。
5.2 审计与成本:Token 记账和请求指纹
生成式 AI 的每一次调用都有成本,而且成本是动态的,和输入输出的 token 数量直接挂钩。如果不记录,月底账单来了都不知道钱花在哪里。我在治理层加了一个审计模块,每次请求完成后记录以下信息:
- 请求 ID、时间戳、设备标识
- 使用的模型名称和路由策略
- 输入 token 数、输出 token 数
- 完整响应耗时和首 token 耗时
- 方法名和最终结果状态
隐私是审计必须考虑的问题。完整 prompt 和完整响应我都不落盘,只保存一个基于内容计算的哈希值。这样既能追踪请求链路,又不会把用户对话内容写到日志里。
成本统计基于 request 模型名称和 token 单价表,按天汇总,生成成本报表的曲线数据。当某天的预算消耗超过预设阈值时,治理层会自动把优先级为 low 的请求路由到免费的本地模型或降级模型,保住核心场景。
5.3 内容安全与隐私保护:不能只依赖模型本身
安全过滤不能把责任全部推给模型。有些敏感场景,规则过滤比模型判断更快、成本更低、结果更可预期。我的实现是在请求发出前和响应返回后各加一道过滤器。
请求侧过滤关注两类问题:一是 prompt 注入,比如用户试图通过指令改写系统角色;二是隐私数据泄露,比如输入中夹带手机号、身份证号等个人信息。发现疑似注入时,直接拒绝请求并记录审计日志;发现隐私数据时,先做脱敏替换再发送。
响应侧过滤关注生成内容的合规性。对于文本内容,维护一个敏感词与违禁模式的本地规则库,命中规则时使用一段预置的安全兜底文案替换。需要说明的是,这类规则库只能作为兜底防线,主要过滤仍然依赖模型自带的 safety settings。开发时可以在 Dart 侧设置多档安全阈值,鸿蒙适配层只需要把参数原样透传即可。
6. 鸿蒙环境下的高频踩坑清单与排查记录
适配过程中踩的坑不少。这一节我挑几个最典型、最影响开发体验的记录下来,希望能帮后来者少走弯路。
6.1 事件通道重复注册导致的事件流错乱
现象:用户从对话页退到首页再重新进入,新对话里的 token 内容会混入上一轮的残留内容,甚至出现同一个 chunk 被打印两次。
排查过程:我先怀疑是鸿蒙侧 EventChannel 的回调没有在页面销毁时注销。检查代码后确认,鸿蒙侧确实在每次请求结束时关闭了 sink,但 Dart 侧的问题更隐蔽——页面每次重建,receiveBroadcastStream()都会重新订阅一次,旧的订阅没有取消。
解决方案:把事件流订阅提升为全局单例,只在应用启动时注册一次,业务页面通过广播分发接收。相关代码变为:全局持有StreamController<GenerateContentChunk>,事件通道把数据推入这个 controller,页面只在生命周期内监听。
6.2 通道传输体积限制带来的大包问题
现象:当请求中包含 base64 编码的图片数据时,通过MethodChannel.invokeMethod发送,鸿蒙侧偶尔接收到不完整的数据,解析失败。
排查过程:查日志发现,大体积字符串在跨通道传递时,Flutter 鸿蒙引擎会对消息做序列化和拷贝,数据量超过一定阈值后出现截断。具体阈值在不同鸿蒙版本上还不完全一致。
解决方案:大体积数据不再走方法通道,改为写入临时文件,然后只把文件路径传给鸿蒙侧,由鸿蒙侧以流式方式读取。这样既绕开了通道传输限制,也改善了内存占用。目前适配层里,凡是超过 200KB 的请求体,一律走文件通道。
6.3 系统版本差异引发的行为不一致
现象:同一个插件,在较新的鸿蒙版本上流式输出正常,在较老的版本上连接经常中断,有时候首个 token 要等十几秒。
排查过程:对比两个版本的网络栈行为后发现,老版本对 HTTP 长连接的支持不一致,部分连接在空闲时被提前关闭。生成式 AI 接口的流式响应持续时间长,连接稳定性要求高,这个问题就被放大了。
解决方案:在鸿蒙侧网络模块中启用连接自动重试机制,并强制设置更长的空闲超时。同时把请求头里的连接保持参数调整为兼容模式,避免使用不同系统版本支持不一致的扩展头。
下面用表格汇总这几个问题,方便速查:
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 流式 chunk 重复或混轮次 | Dart 侧重复订阅事件通道 | 全局单例订阅 + 页面广播分发 |
| 大请求体传输截断 | 方法通道传输体积限制 | 超过 200KB 走临时文件通道 |
| 老系统流式中断 | HTTP 长连接兼容性差异 | 启用自动重试,调整空闲超时 |
| 低端设备流式卡顿 | 主线程 JSON 解析压力过大 | 后台 isolate 解析,SendPort 回传 |
6.4 流式解析卡顿:低端设备上的性能优化
在低端鸿蒙设备上,如果流式响应的每个 chunk 到达后,都在主 Isolate 里做 JSON 解析和模型字段映射,很容易出现掉帧。实测现象是:文字还在滚动输出,但界面的滑动操作已经明显迟滞。
我把解析逻辑放到了后台 Isolate,鸿蒙侧收到 chunk 后只做原始字符串推送,Dart 侧用compute函数在后台解析成结构化对象,再把结果传回主 Isolate 更新 UI。这个改动让掉帧问题基本消失。需要注意,后台解析的对象必须是可以跨 isolate 传输的普通 Map,不能携带非可序列化的句柄。
7. 适配完成后我留下的扩展点
整个项目跑稳之后,我复盘了一遍,觉得有几个方向值得持续投入,也正好作为这篇文章的收尾。
7.1 多模态与工具调用
目前的适配层只处理了文本生成和流式文本输出。多模态方向,比如图片理解、图片生成、音频分析,在鸿蒙设备上的需求越来越明显。适配层的数据结构已经预留了multimodal能力类型,后续增加新的方法通道即可扩展。
工具调用(Function Calling / Agent 场景)是另一个大方向。大模型返回结构化指令,端上执行后再把结果回传给模型。现有适配层的事件通道架构可以复用,只是需要增加一个方法用来透传工具定义和执行结果。
7.2 端云协同的推理路径
我在治理层预留了"模型来源"字段,不只是云端模型,也可以是端侧量化模型。对于简单任务,比如格式转换、短文本分类,完全可以走端侧推理,省成本、保隐私、降低延迟。这个方向对鸿蒙设备特别有意义,端侧 NPU 的算力没有被充分利用。
7.3 插件边界的半衰期
最后说一点我个人体会最深的东西:插件适配层一定要保持"薄"。它只做平台能力转换,不做业务判断。所有调度策略、限流策略、路由策略、审计策略,都放在 Dart 侧的治理层。这样做的原因是——底层生成式 AI API 会变,模型会换,云端接口的形态也会演化,但业务方的需求相对稳定。薄适配层出了问题容易替换,治理层出了问题最多改策略配置。如果把它们混在一起,后续每一次模型升级都要翻一遍原生代码,那才是真正痛苦的维护处境。
目前这个架构在我手上的鸿蒙验证工程里已经稳定运行了一段时间,流式对话、多模型切换、限流审计都按预期工作。如果你也在做类似的跨端 AI 集成,建议从通道协议的契约表开始,先明确数据边界,再谈具体实现。契约稳定了,后面的路会顺很多。