适合谁看
正在给鸿蒙 Flutter 应用接 AI 会话的人
想把模型调用从页面层抽出来的人
想理解移动端(尤其是鸿蒙端)AI 会话管理该收在哪一层的人
想了解 AgentService 如何和协调器、工具层、鸿蒙原生能力协作的人
问题背景
很多 AI 页面初版都会直接这样写:
页面按钮点一下
直接发请求
把结果塞回状态
这种方式在 Demo 阶段很快,但一旦开始加入:
工具调用
流式输出
会话记忆
页面退出释放
鸿蒙端的语音输入输出联动
Token 消耗统计和成本追踪
页面层就会很快变得过重。
真正的难点不是"怎么发一次聊天请求",而是:
怎么让移动端里的 AI 会话成为一个可管理对象——在鸿蒙设备上尤其如此,因为会话生命周期还要和鸿蒙原生引擎的生命周期对齐。
项目中的真实场景
食界探味当前专门抽了:
app/lib/core/ai/agent_service.dart— AI 会话服务层
它上面连接:
app/lib/core/ai/agent_providers.dart— 模型配置和 Provider
下面被:
app/lib/core/ai/ai_explore_coordinator.dart— 协调器消费
同时它间接触发鸿蒙侧的能力:
SpeechRecognitionPlugin.ets— 语音识别TextToSpeechPlugin.ets— TTS 播报
这说明当前项目并没有让页面直接依赖 AI Provider,而是先放了一层专门的会话服务。这个服务层不感知底层是鸿蒙还是 Android,但它的生命周期管理直接影响鸿蒙端的体验。
核心实现
先说结论:
AgentService的价值不在"帮你少写几行调用代码",而在"把 AI 会话的创建、使用、续跑和释放都变成可管理的结构"。
一、它先把"当前会话对象"收成了显式状态
在AgentService里,最关键的一个字段是:
class AgentService { final OpenAIProvider _provider; AIAgent? _currentAgent;这说明项目当前对 AI 会话的理解不是"随手发一次模型请求",而是"当前有一个会话中的 agent"。这一步很重要,因为它让创建 agent、使用 agent、清掉 agent 这些动作都有了明确落点。
二、Provider 层:模型配置和成本追踪
在深入AgentService之前,先看它依赖的 Provider 层:
// app/lib/core/ai/agent_providers.dart final zhipuAIProvider = Provider<OpenAIProvider>((ref) { return OpenAIProvider( config: OpenAIConfig( baseUrl: '${EnvConfig.apiBaseUrl}/zhipu/paas/v4', apiKey: 'proxy-managed', model: 'glm-4.7', ), ); });这里有几个值得注意的设计:
API Key 由后端代理管理— 客户端持有的是
proxy-managed占位符,真实的 API Key 在后端注入。这对鸿蒙端的安全性尤其重要,避免密钥暴露在客户端代码中通过后端代理转发— 请求走
${EnvConfig.apiBaseUrl}/zhipu/paas/v4,而不是直连智谱 API。这意味着鸿蒙设备即使在受限网络环境下也能正常访问模型可切换— 项目维护了一个允许使用的模型列表:
const zhipuAllowedModels = [ 'glm-4v-flash', 'glm-4-flash', 'glm-4v', 'glm-4v-plus', 'glm-4.5v', 'glm-4.6', 'glm-4.6v', 'glm-4.7', ];Token 成本追踪— 每次对话完成后,
AgentService会记录消耗的 token 数量和费用:
if (chunk.isDone && chunk.usage != null) { AppLogger.info( '[AgentService] 消耗: ${ZhipuPricing.formatCostLog(chunk.usage!)}', ); }成本计算逻辑:
class ZhipuPricing { static const double inputPricePerMillion = 3.0; // 输入 ¥3/百万 token static const double outputPricePerMillion = 14.0; // 输出 ¥14/百万 token static String formatCostLog(TokenUsage usage) { final inputCost = usage.promptTokens * inputPricePerMillion / 1000000; final outputCost = usage.completionTokens * outputPricePerMillion / 1000000; final totalCost = inputCost + outputCost; return '输入: ${usage.promptTokens} tokens (¥${inputCost.toStringAsFixed(6)}), ' '输出: ${usage.completionTokens} tokens (¥${outputCost.toStringAsFixed(6)}), ' '总计: ${usage.totalTokens} tokens (¥${totalCost.toStringAsFixed(6)})'; } }在鸿蒙端调试时,这些日志能帮你快速判断:模型调用是否正常、token 消耗是否合理、成本是否可控。
三、createAgent()让会话初始化集中发生
createAgent()当前做的事情并不只是"new 一个对象",而是把一组会话初始条件都收在了一起:
AIAgent createAgent({ required String systemPrompt, List<Tool>? tools, String? model, int maxMessages = 50, bool enableAutoToolExecution = false, }) { final agent = AIAgent( provider: _provider, config: AIAgentConfig( systemPrompt: systemPrompt, model: model, enableAutoToolExecution: enableAutoToolExecution, additionalParams: {'thinking': {'type': 'disabled'}}, ), memoryManager: ConversationMemory(maxMessages: maxMessages), ); if (tools != null) { for (final tool in tools) { agent.addTool(tool); } } _currentAgent = agent; return agent; }各参数的职责:
参数 | 作用 | 为什么放在服务层 |
|---|---|---|
| 定义 AI 的角色和行为规范 | 避免每个页面重复写 prompt |
| 注册业务工具(搜索菜品、详情等) | 工具注册逻辑统一收口 |
| 指定模型名称,默认用 Provider 配置 | 支持按场景切换模型 |
| 记忆窗口大小,控制历史消息保留数量 | 移动端内存有限,必须主动控制 |
| 是否自动执行工具调用 | 避免协调器手动管理工具执行 |
注意additionalParams: {'thinking': {'type': 'disabled'}}— 这禁用了模型的思考链输出。在移动端,思考链会增加延迟和 token 消耗,禁用后响应更快。
这说明AgentService当前其实已经在承担agent 工厂 + 会话策略入口的职责。页面层和协调器层不再需要自己拼这些初始条件。
四、为什么记忆窗口要留在这层
在createAgent()里,ConversationMemory(maxMessages: maxMessages)是一个很值得注意的点。
它说明项目已经意识到:移动端 AI 会话不是无限上下文,而应该主动控制会话窗口大小和历史消息保留数量。
食界探味在协调器中实际使用的配置:
// ai_explore_coordinator.dart _agentService.createAgent( systemPrompt: _systemPrompt, tools: [...], enableAutoToolExecution: true, maxMessages: 30, // 最多保留 30 条历史消息 );为什么是 30?因为:
鸿蒙端设备内存有限,过多历史消息会占用大量内存
菜品推荐场景不需要太长的对话历史
30 条足够覆盖 10-15 轮对话,满足"探索 → 细化 → 推荐"的典型流程
这类决策如果直接塞进页面层,后面会很难统一。放在AgentService里就更合理,因为它更接近"会话策略",而不是"页面渲染策略"。
五、为什么流式对话也应该收在这层
chatWithToolsStream()是这份代码里最关键的方法之一:
Future<void> chatWithToolsStream({ required String message, AIAgent? agent, void Function(String)? onThinking, void Function(String)? onContent, void Function(ToolCall)? onToolCall, void Function(String)? onComplete, }) async { final targetAgent = agent ?? _currentAgent; if (targetAgent == null) { throw StateError('没有可用的 Agent,请先调用 createAgent()'); } await for (final chunk in targetAgent.chatStreamRaw(message)) { // 1. 思考内容(已禁用,但保留接口) if (chunk.reasoningContent != null && onThinking != null) { onThinking(chunk.reasoningContent!); } // 2. 增量文本(流式输出给页面展示) if (chunk.content != null && onContent != null) { onContent(chunk.content!); } // 3. 工具调用(模型决定调用业务工具) if (chunk.toolCalls != null && chunk.toolCalls!.isNotEmpty && onToolCall != null) { for (final toolCall in chunk.toolCalls!) { onToolCall(toolCall); } } // 4. 完成时记录 token 消耗 if (chunk.isDone && chunk.usage != null) { AppLogger.info( '[AgentService] 消耗: ${ZhipuPricing.formatCostLog(chunk.usage!)}', ); } }这段代码把流式的四个阶段统一收口了:
用户消息 → [reasoningContent] → [content] → [toolCalls] → [isDone + usage] ↑ 思考(已禁用) ↑ 文本输出 ↑ 工具调用 ↑ 完成统计协调器只需要关注回调,不需要理解底层 chunk 结构:
// ai_explore_coordinator.dart await _agentService.chatWithToolsStream( message: text, onContent: (chunk) { // 只关心:文本增量来了,更新页面 buffer.write(chunk); state = state.copyWith( status: AiSessionStatus.responding, streamingText: buffer.toString(), ); }, onToolCall: (toolCall) { // 只关心:模型在调用工具,更新状态为"搜索中" state = state.copyWith(status: AiSessionStatus.searching); }, onComplete: (full) { // 只关心:回复完成 state = state.copyWith(status: AiSessionStatus.idle, streamingText: full); }, );这样做的好处是:协调器层不需要直接理解底层流对象细节,页面层更不需要直接碰这些底层 chunk 结构。
六、工具调用为什么也放在这里处理
chatWithToolsStream()在流式对话结束后,还会自动检测并执行待执行的工具调用:
// 如果有待执行的工具调用,自动执行并继续对话 if (targetAgent.pendingToolCalls != null && targetAgent.pendingToolCalls!.isNotEmpty) { await for (final chunk in targetAgent.executeToolsAndContinue()) { if (chunk.content != null && onContent != null) { onContent(chunk.content!); } if (chunk.isDone && chunk.fullContent != null && onComplete != null) { onComplete(chunk.fullContent!); } if (chunk.isDone && chunk.usage != null) { AppLogger.info( '[AgentService] 工具回调消耗: ${ZhipuPricing.formatCostLog(chunk.usage!)}', ); } } }这意味着一次完整的 AI 交互可能是这样的:
用户: "推荐牛肉吃法" ↓ AgentService: 调用 chatWithToolsStream ↓ 模型: 返回 tool_call: search_dishes({ingredients: ["牛肉"]}) ↓ AgentService: 检测到 pendingToolCalls,自动执行 executeToolsAndContinue ↓ 工具: 查询数据库,返回 5 道牛肉菜品 ↓ 模型: 基于工具结果生成推荐文案 ↓ AgentService: 通过 onContent 回调把文案流式输出 ↓ 协调器: 收到文案 + matchedDishes,更新页面状态关键在于:工具执行和继续对话的逻辑完全在 AgentService 内部完成,协调器不需要手动管理这个循环。如果这部分留在页面层或者每个协调器里各自拼,后面很容易变成每个页面有自己的一套工具续跑逻辑。
七、为什么clearCurrentAgent()很关键
很多移动端 AI 功能一开始都会忽略这一点:页面退出了,会话要不要清。
食界探味当前已经明确给了:
void clearCurrentAgent() { _currentAgent?.clearHistory(); // 清除对话历史 _currentAgent = null; // 释放引用 }协调器在两个场景下会调用它:
重置会话— 用户点击"新对话"按钮:
// ai_explore_coordinator.dart void reset() { _agentService.clearCurrentAgent(); _agentInitialized = false; if (mounted) { state = const AiSessionState(); } }重新初始化— 每个 coordinator 实例首次调用时,先清掉旧 agent 再创建新的:
void _ensureAgent() { if (_agentInitialized) return; _agentInitialized = true; _agentService.clearCurrentAgent(); // 先清旧的 _agentService.createAgent( // 再建新的 systemPrompt: _systemPrompt, tools: [...], enableAutoToolExecution: true, maxMessages: 30, ); }这说明当前项目并没有把 AI 会话默认当成永久全局状态,而是允许某个页面会话结束后重置。这对鸿蒙端来说尤其重要,因为:
鸿蒙设备内存管理更严格,长期悬挂的会话对象会占用不可回收的内存
鸿蒙的页面生命周期和 Android 不完全一致,需要更主动地管理资源
用户在鸿蒙设备上切换应用再回来时,会话状态应该被正确恢复或清理
八、为什么dispose()必须放在服务层
在当前代码里,dispose()最后还会调用_provider.dispose():
void dispose() { _currentAgent = null; _provider.dispose(); // 释放底层模型 Provider }同时 Riverpod Provider 也在onDispose时触发了这件事:
final agentServiceProvider = Provider<AgentService>((ref) { final provider = ref.watch(zhipuAIProvider); final service = AgentService(provider); ref.onDispose(() => service.dispose()); // Provider 销毁时自动释放 return service; });这说明当前结构非常明确地把 AI 资源释放责任收到了服务层,而不是页面层自己猜什么时候释放。这一步很重要,因为 AI Provider、会话对象和流式任务都可能比普通页面状态更重。
在鸿蒙端,资源释放尤其关键:
鸿蒙的内存回收机制和 Android 不同— 不能依赖 GC 自动回收大对象
流式任务必须显式取消— 如果页面退出时还有 pending 的流式请求,必须中止
Provider 的 dispose 时机和页面对齐— Riverpod 的
autoDispose会自动处理,但前提是服务层的dispose()实现正确
九、协调器如何消费 AgentService
理解了 AgentService 的设计后,再看协调器是怎么用它的:
class AiExploreCoordinator extends StateNotifier<AiSessionState> { final AgentService _agentService; final FoodRepository _foodRepository; // ... 初始化 ... void _ensureAgent() { if (_agentInitialized) return; _agentInitialized = true; _agentService.clearCurrentAgent(); _agentService.createAgent( systemPrompt: _systemPrompt, tools: [ SearchDishesTool(_foodRepository, onDishesFound: _onDishesFound), GetDishDetailTool(_foodRepository), GetRandomDishTool(_foodRepository, onDishesFound: _onDishesFound), GetDishesByIngredientTool(_foodRepository, onDishesFound: _onDishesFound), ], enableAutoToolExecution: true, maxMessages: 30, ); }协调器向 AgentService 注册了 4 个业务工具:
工具 | 功能 | 回调 |
|---|---|---|
| 根据食材/口味/地域搜索菜品 |
|
| 获取某道菜的详细信息 | 无(直接返回文本) |
| 随机推荐一道菜 |
|
| 按食材查同食材的其他吃法 |
|
注意工具的onDishesFound回调——它直接把查询到的菜品数据推给协调器的状态,协调器再通过 Riverpod 通知页面渲染菜品卡片。这就是"AI 推荐 + 业务卡片"联动的关键链路。
十、AgentService 在整体架构中的位置
从整体架构看,AgentService 的位置非常清晰:
┌─────────────────────────────────────────────────┐ │ 页面层 │ │ AiAssistantScreen │ │ │ │ │ ▼ │ │ 协调器层 │ │ AiExploreCoordinator │ │ │ │ │ ▼ │ │ 服务层 │ │ AgentService ← agentServiceProvider │ │ │ │ │ ▼ │ │ Provider 层 │ │ zhipuAIProvider → OpenAIProvider │ │ │ │ │ ▼ │ │ 工具层 │ │ SearchDishesTool / GetDishDetailTool / ... │ │ │ ├─────────────────────────────────────────────────┤ │ 鸿蒙原生层(间接) │ │ SpeechRecognitionPlugin TextToSpeechPlugin │ │ ← 通过 Channel 被协调器调用,AgentService 不直接 │ │ 感知,但会话生命周期影响语音体验 │ └─────────────────────────────────────────────────┘核心要点:
AgentService 不感知鸿蒙— 它是纯 Flutter/AI 服务层,不 import 任何鸿蒙相关代码
协调器是鸿蒙和 AI 的桥梁— 它同时调用 AgentService(AI 能力)和 Channel(鸿蒙能力)
页面层最轻— 只负责 UI 渲染和用户交互,所有 AI/语音逻辑都委托给下层
Provider 层管理依赖— Riverpod 自动处理创建、缓存、销毁的生命周期
关键代码位置
文件 | 作用 |
|---|---|
| AI 会话服务层 |
| 模型配置、Provider、成本追踪 |
| 协调器,消费 AgentService |
| 会话状态模型 |
| 4 个业务工具 |
| 语音识别通道(协调器调用) |
| TTS 通道(协调器调用) |
鸿蒙侧与 AgentService 的协作关系
虽然 AgentService 本身是纯 Flutter 层,但它在鸿蒙端的行为有特殊意义:
生命周期对齐
鸿蒙页面创建 → AiAssistantScreen initState → coordinator 创建(Riverpod autoDispose) → AgentService 创建(Provider) → zhipuAIProvider 创建 → OpenAIProvider 初始化 鸿蒙页面销毁 → AiAssistantScreen dispose → coordinator dispose(自动) → AgentService dispose → _currentAgent = null → _provider.dispose()语音联动时序
用户按住语音按钮 → coordinator.startVoiceInput() → SpeechRecognitionChannel.startListening() [鸿蒙原生] → 用户说话... → 鸿蒙识别完成,返回文本 → coordinator.submitQuery(text) → AgentService.chatWithToolsStream() → 模型推理 + 工具调用 → 流式输出 → coordinator.speakText(response) → TextToSpeechChannel.speak() [鸿蒙原生] → 鸿蒙 TTS 引擎播报 → 用户退出页面 → coordinator dispose → TextToSpeechChannel.stop() [鸿蒙原生] → 鸿蒙 TTS 引擎停止在这个流程中,AgentService 不直接调用任何鸿蒙 API,但它的chatWithToolsStream是整个链路的核心——所有 AI 推理和工具调用都在这里完成。协调器负责把鸿蒙的语音输入喂给 AgentService,再把 AgentService 的输出喂给鸿蒙的 TTS。
资源管理对比
场景 | Android 行为 | 鸿蒙行为 | AgentService 的应对 |
|---|---|---|---|
页面退出 | Activity 销毁,GC 回收 | Ability 组件销毁,内存管理更严格 |
|
流式中断 | 网络断开,chunk 停止 | 网络切换(WiFi→移动数据),chunk 可能中断 |
|
后台切回 | onResume,状态恢复 | 前后台切换,Ability 状态恢复 | Riverpod autoDispose 自动管理 |
内存不足 | 系统杀进程 | 系统回收 Ability |
|
常见坑
页面层直接 new agent,导致会话生命周期四处散落 → 一定要通过 AgentService 集中管理
工具调用逻辑分散在多个页面或协调器里→ 统一注册到 AgentService,通过
enableAutoToolExecution自动执行流式输出直接在页面层消费底层 chunk→ 用 AgentService 的回调封装,页面只关心文本增量
没有显式清理当前会话,导致状态长期悬挂 → 调用
clearCurrentAgent(),尤其是页面退出时鸿蒙端不主动释放 Provider,导致内存泄漏 → 确保
ref.onDispose正确注册API Key 硬编码在客户端→ 走后端代理,客户端只持占位符
记忆窗口设得太大,鸿蒙设备内存吃紧 → 移动端建议 30-50 条,按场景调整
流式任务没有 mounted 检查,页面退出后继续更新状态 → 协调器每个回调开头加
if (!mounted) return
可复用模板
如果你要在自己的鸿蒙 + Flutter 项目里做类似的 AI 会话管理,可以参考这个结构:
AgentService 模板
class AgentService { final OpenAIProvider _provider; AIAgent? _currentAgent; AgentService(this._provider); AIAgent? get currentAgent => _currentAgent; AIAgent createAgent({ required String systemPrompt, List<Tool>? tools, String? model, int maxMessages = 50, bool enableAutoToolExecution = false, }) { final agent = AIAgent( provider: _provider, config: AIAgentConfig( systemPrompt: systemPrompt, model: model, enableAutoToolExecution: enableAutoToolExecution, ), memoryManager: ConversationMemory(maxMessages: maxMessages), ); if (tools != null) { for (final tool in tools) { agent.addTool(tool); } } _currentAgent = agent; return agent; } Future<void> chatWithToolsStream({ required String message, AIAgent? agent, void Function(String)? onContent, void Function(ToolCall)? onToolCall, void Function(String)? onComplete, }) async { final targetAgent = agent ?? _currentAgent; if (targetAgent == null) { throw StateError('没有可用的 Agent,请先调用 createAgent()'); } await for (final chunk in targetAgent.chatStreamRaw(message)) { if (chunk.content != null && onContent != null) { onContent(chunk.content!); } if (chunk.toolCalls != null && chunk.toolCalls!.isNotEmpty) { for (final toolCall in chunk.toolCalls!) { onToolCall?.call(toolCall); } } } // 自动执行工具调用并继续对话 if (targetAgent.pendingToolCalls != null && targetAgent.pendingToolCalls!.isNotEmpty) { await for (final chunk in targetAgent.executeToolsAndContinue()) { if (chunk.content != null && onContent != null) { onContent(chunk.content!); } if (chunk.isDone && chunk.fullContent != null) { onComplete?.call(chunk.fullContent!); } } } } void clearCurrentAgent() { _currentAgent?.clearHistory(); _currentAgent = null; } void dispose() { _currentAgent = null; _provider.dispose(); } }Riverpod Provider 模板
final agentServiceProvider = Provider<AgentService>((ref) { final provider = ref.watch(zhipuAIProvider); final service = AgentService(provider); ref.onDispose(() => service.dispose()); return service; });协调器消费模板
class AiCoordinator extends StateNotifier<AiSessionState> { final AgentService _agentService; bool _agentInitialized = false; void _ensureAgent() { if (_agentInitialized) return; _agentInitialized = true; _agentService.clearCurrentAgent(); _agentService.createAgent( systemPrompt: '你的系统提示词...', tools: [/* 你的工具列表 */], enableAutoToolExecution: true, maxMessages: 30, ); } Future<void> submitQuery(String text) async { _ensureAgent(); final buffer = StringBuffer(); await _agentService.chatWithToolsStream( message: text, onContent: (chunk) { buffer.write(chunk); state = state.copyWith(streamingText: buffer.toString()); }, onToolCall: (toolCall) { state = state.copyWith(status: AiSessionStatus.searching); }, onComplete: (full) { state = state.copyWith(status: AiSessionStatus.idle); }, ); } void reset() { _agentService.clearCurrentAgent(); _agentInitialized = false; state = const AiSessionState(); } }本篇总结
AgentService在食界探味里的价值,是把 AI 从"页面里发一次请求"提升成了"移动端里一个可管理的会话对象"。
这层一旦单独成立:
页面层只关心 UI 渲染,不碰模型细节
协调器层专注于产品流程编排,通过 AgentService 的回调驱动状态
工具层统一注册到 AgentService,不分散在各处
鸿蒙原生能力由协调器直接调用,AgentService 保持平台无关
资源生命周期由 Riverpod Provider 统一管理,确保鸿蒙端不泄漏
在鸿蒙设备上,这套分层让 AI 助手既能享受 Flutter 的跨平台 UI 能力,又能无缝接入鸿蒙的语音识别和 TTS 能力,同时保持了清晰的职责边界和资源管理。