适合谁看
-
正在给鸿蒙 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;
}
各参数的职责:
| 参数 | 作用 | 为什么放在服务层 |
|---|---|---|
systemPrompt |
定义 AI 的角色和行为规范 | 避免每个页面重复写 prompt |
tools |
注册业务工具(搜索菜品、详情等) | 工具注册逻辑统一收口 |
model |
指定模型名称,默认用 Provider 配置 | 支持按场景切换模型 |
maxMessages |
记忆窗口大小,控制历史消息保留数量 | 移动端内存有限,必须主动控制 |
enableAutoToolExecution |
是否自动执行工具调用 | 避免协调器手动管理工具执行 |
注意 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 个业务工具:
| 工具 | 功能 | 回调 |
|---|---|---|
SearchDishesTool |
根据食材/口味/地域搜索菜品 | onDishesFound → 更新 matchedDishes |
GetDishDetailTool |
获取某道菜的详细信息 | 无(直接返回文本) |
GetRandomDishTool |
随机推荐一道菜 | onDishesFound → 更新 matchedDishes |
GetDishesByIngredientTool |
按食材查同食材的其他吃法 | onDishesFound → 更新 matchedDishes |
注意工具的 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 自动处理创建、缓存、销毁的生命周期
关键代码位置
| 文件 | 作用 |
|---|---|
app/lib/core/ai/agent_service.dart |
AI 会话服务层 |
app/lib/core/ai/agent_providers.dart |
模型配置、Provider、成本追踪 |
app/lib/core/ai/ai_explore_coordinator.dart |
协调器,消费 AgentService |
app/lib/core/ai/models/ai_session_state.dart |
会话状态模型 |
app/lib/core/ai/tools/ |
4 个业务工具 |
app/lib/core/platform/speech_recognition_channel.dart |
语音识别通道(协调器调用) |
app/lib/core/platform/text_to_speech_channel.dart |
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 组件销毁,内存管理更严格 | dispose() 显式释放 |
| 流式中断 | 网络断开,chunk 停止 | 网络切换(WiFi→移动数据),chunk 可能中断 | mounted 检查 + 异常捕获 |
| 后台切回 | onResume,状态恢复 | 前后台切换,Ability 状态恢复 | Riverpod autoDispose 自动管理 |
| 内存不足 | 系统杀进程 | 系统回收 Ability | clearCurrentAgent() 主动释放 |
常见坑
-
页面层直接 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 能力,同时保持了清晰的职责边界和资源管理。
