鸿蒙上跑通第一个 AI Agent:5 分钟接入智谱/DeepSeek

一、前言:从"能跑"到"跑对"

很多开发者在鸿蒙上接入大模型的方式是:写一个 fetch 请求调 Chat Completions API,拿到 JSON,解析 choices[0].message.content,显示到 Text 组件上。这能"跑通",但跑不对:

  1. 没有流式输出:用户等 5 秒看到一整段文字,体验差。

  2. 没有工具调用:Agent 不能查数据库、不能算数学、不能操作系统功能。

  3. 没有状态管理:切后台回来历史消息全没了。

  4. API Key 不安全:写在 rawfile 或 preferences 里,HAP 可被提取。

  5. 没有取消/恢复:用户点"停止"没反应,切后台继续烧 token。

ArkAgent 的 AgentRuntime 把这些都包好了。你要做的只是:

  • 配置 Provider(选智谱还是 DeepSeek,传 API Key)

  • 注册业务工具(定义工具名、参数 Schema、执行器)

  • 创建 Runtime,调 runStream,处理事件

本文用一个完整的"烘焙助手"案例,走通从 HAR 接入到真机运行的全流程。


二、环境准备与 HAR 接入

2.1 环境要求

  • DevEco Studio 及 HarmonyOS SDK

  • ArkAgent 1.0 已在 HarmonyOS 6.1.1 / API 24 验证

  • 你的工程需要网络权限

  • 智谱或 DeepSeek 的 API Key

2.2 构建 HAR

在 ArkAgent 仓库根目录构建 HAR 产物:

复制代码
cd /Users/wangzhe/work/app/ArkAgent

DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw \
  assembleHar --no-daemon

查找构建产物:

复制代码
find agent_core/build -name 'agent_core.har'

2.3 将 HAR 接入你的工程

在你的工程 entry 模块下建立 libs 目录并复制 HAR:

复制代码
MyHarmonyApp/
└── entry/
    ├── libs/
    │   └── agent_core.har      ← 复制到这里
    ├── oh-package.json5
    └── src/

编辑 entry/oh-package.json5,声明本地依赖:

复制代码
{
  "name": "entry",
  "version": "1.0.0",
  "dependencies": {
    "@arkagent/core": "file:./libs/agent_core.har"
  }
}

安装依赖:

复制代码
/Applications/DevEco-Studio.app/Contents/tools/ohpm/bin/ohpm install

2.4 添加网络权限

entry/src/main/module.json5

复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

2.5 验证接入

在你的页面里 import 一下,能编译通过就说明 HAR 接入成功:

复制代码
import { AgentRuntime } from '@arkagent/core'

三、Provider 与 LLMClient 组装

3.1 核心组件关系

先理解组装链路------从 API Key 到可用的 LLMClient:

复制代码
API Key(运行时输入)
  ↓
StaticBearerTokenProvider     ← 包装 Key,提供 Bearer Token
  ↓
ProviderConfig                ← 组合 baseUrl + token + 超时 + 模型
  ↓
ZhipuProviderProfile / DeepSeekProviderProfile   ← Provider 差异
  ↓
HarmonyHttpTransport          ← HarmonyOS HTTP 传输层
  ↓
OpenAICompatibleClient        ← 最终的 LLMClient 实例

3.2 选择 Provider Profile

ArkAgent 1.0 内置两个一级 Provider:

Provider base URL 建议模型 特点
智谱 GLM open.bigmodel.cn/api/paas/v4/ glm-5.2 支持图片输入(glm-4.6v)、thinking 扩展体
DeepSeek api.deepseek.com deepseek-v4-flash 支持 thinking.type / reasoning_effort

选择方式很简单------new 对应的 Profile 类:

复制代码
import { ZhipuProviderProfile, DeepSeekProviderProfile } from '@arkagent/core'

// 智谱
const profile = new ZhipuProviderProfile()

// DeepSeek
const profile = new DeepSeekProviderProfile()

3.3 组装 ProviderConfig

ProviderConfig 把 baseUrl、Token、超时和模型组合在一起:

复制代码
import {
  ProviderConfig, StaticBearerTokenProvider, ZhipuProviderProfile
} from '@arkagent/core'

const profile = new ZhipuProviderProfile()
const apiKey = '...'  // 运行时输入,不要硬编码

const providerConfig = new ProviderConfig(
  'zhipu',                              // providerId
  profile.defaultBaseUrl,               // 'https://open.bigmodel.cn/api/paas/v4/'
  new StaticBearerTokenProvider(apiKey), // 密钥包装
  30000,                                 // 连接超时 30s
  120000,                                // 读取超时 120s
  1,                                     // 最大重试次数
  'glm-5.2'                              // 模型 ID
)

关键点

  • StaticBearerTokenProvider 包装 API Key,SDK 在调用时通过它取 Token。Key 不进入 ModelConfigAgentState

  • 模型 ID 作为配置传入,不写死在 SDK 的 enum 里------因为模型 ID 会随 Provider 更新而变化。

  • 超时分开配:连接超时和读取超时独立设置,流式响应需要较长的读取超时。

3.4 创建 LLMClient

复制代码
import {
  OpenAICompatibleClient, HarmonyHttpTransport
} from '@arkagent/core'

const client = new OpenAICompatibleClient(
  new HarmonyHttpTransport(),   // HarmonyOS HTTP 传输层
  profile,                       // Provider Profile(差异转换)
  providerConfig                 // 配置(baseUrl + token + 模型)
)

OpenAICompatibleClient 实现了 LLMClient 接口,提供两个核心方法:

  • generate(request) ------ 一次性生成(非流式)

  • stream(request, observer) ------ 流式生成

Agent 默认用 stream,这样用户能看到逐字输出。

3.5 一个完整的工厂方法

实际工程中,建议把 Provider 组装收敛到一个工厂方法里:

复制代码
private buildClient(keys: ProviderKeys): OpenAICompatibleClient | undefined {
  if (this.provider === 'deepseek') {
    if (keys.deepseekApiKey.length === 0) {
      return undefined  // 缺 Key,不让继续
    }
    const profile = new DeepSeekProviderProfile()
    const config = new ProviderConfig('deepseek', profile.defaultBaseUrl,
      new StaticBearerTokenProvider(keys.deepseekApiKey),
      30000, 120000, 1, keys.deepseekModel)
    return new OpenAICompatibleClient(new HarmonyHttpTransport(), profile, config)
  }

  if (keys.zhipuApiKey.length === 0) {
    return undefined
  }
  const profile = new ZhipuProviderProfile()
  const config = new ProviderConfig('zhipu', profile.defaultBaseUrl,
    new StaticBearerTokenProvider(keys.zhipuApiKey),
    30000, 120000, 1, keys.zhipuModel)
  return new OpenAICompatibleClient(new HarmonyHttpTransport(), profile, config)
}

这样 Provider 切换只需改一个字段,组装逻辑集中在一处。


四、工具定义与注册

这是 Agent 能"做事"的关键。没有工具的 Agent 只能聊天;有工具的 Agent 能查数据、算数学、操作系统。

4.1 工具的两半:Definition + Executor

每个工具由两部分组成:

复制代码
ToolDefinition(告诉模型这个工具是什么)
  - name: 工具名
  - description: 工具说明(模型据此决定是否调用)
  - parameters: JSON Schema 参数描述
  - riskLevel: 风险等级(safe / dangerous)

ToolExecutor(由你的 App 真正执行)
  - execute(arguments, context): Promise<ToolResult>

4.2 示例一:读取烘焙记录

复制代码
import {
  ContentPart, JsonObject, JsonValue, ToolContext,
  ToolDefinition, ToolExecutor, ToolResult
} from '@arkagent/core'

// ------ Definition ------

export function readBakingRecordsDefinition(): ToolDefinition {
  return new ToolDefinition(
    'read_baking_records',                          // name
    '读取三条烘焙记录(含湿重与干重,单位克)',          // description
    new JsonObject()                                // parameters (JSON Schema)
      .set('type', JsonValue.string('object'))
      .set('properties', JsonValue.object(new JsonObject()))
  )
}

// ------ Executor ------

export class ReadBakingRecordsTool implements ToolExecutor {
  async execute(_args: JsonObject, _ctx: ToolContext): Promise<ToolResult> {
    const lines: string[] = []
    for (let i = 0; i < BAKING_RECORDS.length; i++) {
      const r = BAKING_RECORDS[i]
      lines.push(`${r.id}|${r.name}|wet=${r.wetWeightG}|dry=${r.dryWeightG}`)
    }
    return new ToolResult([ContentPart.text(lines.join('; '))])
  }
}

这个工具没有参数(properties 为空),模型只要决定"调用它"就行。返回值是分号分隔的记录文本。

4.3 示例二:计算失重率

这个工具有参数------模型需要告诉它算哪条记录:

复制代码
function stringProp(): JsonObject {
  return new JsonObject().set('type', JsonValue.string('string'))
}

export function calculateWeightLossDefinition(): ToolDefinition {
  const props = new JsonObject().set('recordId', JsonValue.object(stringProp()))
  return new ToolDefinition(
    'calculate_weight_loss',
    '按烘焙记录计算失重率(百分比)',
    new JsonObject()
      .set('type', JsonValue.string('object'))
      .set('properties', JsonValue.object(props))
      .set('required', JsonValue.array([JsonValue.string('recordId')]))
  )
}

export class CalculateWeightLossTool implements ToolExecutor {
  async execute(args: JsonObject, _ctx: ToolContext): Promise<ToolResult> {
    const recordId = args.get('recordId')?.asString() ?? ''
    let found: BakingRecord | undefined = undefined
    for (let i = 0; i < BAKING_RECORDS.length; i++) {
      if (BAKING_RECORDS[i].id === recordId) {
        found = BAKING_RECORDS[i]
        break
      }
    }
    if (found === undefined) {
      return new ToolResult([ContentPart.text(`unknown record: ${recordId}`)], true)
        //                                                isError=true ↑
    }
    const loss = found.wetWeightG - found.dryWeightG
    const rate = found.wetWeightG > 0 ? (loss / found.wetWeightG) * 100 : 0
    return new ToolResult([ContentPart.text(
      `${found.id} ${found.name} loss=${loss}g rate=${rate.toFixed(2)}%`
    )])
  }
}

要点:

  • parameters 是 JSON Schema。模型会根据 Schema 生成参数。

  • required 标注必填字段。

  • ToolResult 的第二个参数 isError 标记工具执行失败------不要伪装成功,让模型知道工具出错了,它可以换一条记录重试。

4.4 示例三:危险工具(需用户确认)

复制代码
import { ToolRiskLevel } from '@arkagent/core'

export function clearBakingCacheDefinition(): ToolDefinition {
  return new ToolDefinition(
    'clear_baking_cache',
    '清空本地烘焙缓存(危险操作,需用户确认)',
    new JsonObject()
      .set('type', JsonValue.string('object'))
      .set('properties', JsonValue.object(new JsonObject()
        .set('confirm', JsonValue.object(stringProp())))),
    ToolRiskLevel.dangerous    // ← 标记为危险
  )
}

ToolRiskLevel.dangerous 的工具不会自动执行------Runtime 会暂停,发出 approvalRequired 事件,等待用户在 UI 上点"批准"或"拒绝"。

4.5 注册到 ToolRegistry

复制代码
import { ToolRegistry } from '@arkagent/core'

const registry = new ToolRegistry()
registry.register(readBakingRecordsDefinition(), new ReadBakingRecordsTool())
registry.register(calculateWeightLossDefinition(), new CalculateWeightLossTool())
registry.register(clearBakingCacheDefinition(), new ClearBakingCacheTool())

ToolRegistry 会校验:

  • 工具名称唯一

  • 参数 Schema 合法

  • 执行器不为空


五、AgentRuntime 创建与运行

5.1 组装 AgentRuntimeConfig

把前面的 client、registry、system prompt 组合成一个 Config:

复制代码
import {
  AgentRuntimeConfig, ModelConfig, ToolChoice, ToolChoiceMode
} from '@arkagent/core'

const runtimeConfig = new AgentRuntimeConfig(
  client,                                              // LLMClient
  new ModelConfig('glm-5.2'),                          // ModelConfig
  registry,                                            // ToolRegistry
  'my-agent-session-001',                              // sessionId
  '你是烘焙助手。必须先调用 read_baking_records 读取三条记录,' +
    '再调用 calculate_weight_loss 计算至少一条失重率,' +
    '最后用中文给出简短结论。',                          // systemPrompt
  new ToolChoice(ToolChoiceMode.auto),                 // toolChoice
  8                                                     // maxTurns
)

AgentRuntimeConfig 的完整参数(按位置):

位置 参数 类型 说明
1 llmClient LLMClient LLM 客户端
2 modelConfig ModelConfig 模型配置
3 toolRegistry ToolRegistry 工具注册表
4 sessionId string 会话 ID(必须稳定且唯一)
5 systemPrompt string 系统提示词
6 toolChoice ToolChoice 工具选择策略
7 maxTurns number 最大循环次数
8-10 strategy/clock/idGen ------ 可选,有默认值
11 stateStorage StateStorage 状态持久化(可选)
12 autoSave boolean 是否自动保存
后续 hooks/controller/policy/... ------ 高级配置

5.2 创建 Runtime

复制代码
import { AgentRuntime } from '@arkagent/core'

const runtime = await AgentRuntime.create(runtimeConfig)

AgentRuntime.create 是异步的,因为它可能需要从 Storage 加载历史状态。

5.3 最简单的运行:run()

如果你不需要流式输出,用 run() 一步到位:

复制代码
import { AgentMessage, ContentPart, MessageRole } from '@arkagent/core'

const result = await runtime.run([
  new AgentMessage(MessageRole.user, [ContentPart.text('帮我查烘焙记录并算失重率')])
])

if (result.ok) {
  console.info(result.finalText)  // Agent 的最终回答
} else {
  console.error(result.error?.message)
}

AgentRunResult 包含:

字段 说明
ok 是否成功(completed 或 stopFlag)
finalText 最终文本回答
stopReason 停止原因(completed / maxTurnsExceeded / cancelled / ...)
history 完整消息历史
usages 每轮的 token 用量
turnCount 实际循环了多少轮
error 错误信息(如果有)
resumable 是否可恢复(挂起后)

5.4 流式运行:runStream()

生产级应用用 runStream(),这样用户能看到逐字输出和工具调用过程:

复制代码
import {
  AgentEvent, AgentEventType, StreamObserver, AgentRunSubscription
} from '@arkagent/core'

const observer: StreamObserver<AgentEvent> = {
  onNext: (event: AgentEvent) => {
    switch (event.type) {
      case AgentEventType.textDelta:
        // 逐字增量 ------ 追加到 UI 的 Text 组件
        console.info(event.payload.text)
        break

      case AgentEventType.reasoningDelta:
        // 推理过程增量(thinking),可单独显示或忽略
        break

      case AgentEventType.toolStart:
        // 工具开始执行
        console.info(`工具开始: ${event.payload.toolCall?.name}`)
        break

      case AgentEventType.toolResult:
        // 工具执行完毕
        console.info(`工具结果: ${event.payload.toolCall?.name}`)
        break

      case AgentEventType.runComplete:
        // 运行完成
        console.info(`完成: ${event.payload.stopReason}`)
        break

      case AgentEventType.runError:
        // 运行出错
        console.error(`错误: ${event.payload.error?.message}`)
        break

      case AgentEventType.approvalRequired:
        // 危险工具等待用户确认
        console.info(`等待审批: ${event.payload.approvalIds}`)
        break

      case AgentEventType.runSuspended:
        // 被挂起(比如切后台)
        console.info(`已挂起: ${event.payload.stopReason}`)
        break
    }
  },
  onError: (error: Error) => {
    console.error(`Stream 错误: ${error.message}`)
  },
  onComplete: () => {
    console.info('Stream 完成')
  }
}

const subscription: AgentRunSubscription = await runtime.runStream(
  [new AgentMessage(MessageRole.user, [ContentPart.text('帮我查烘焙记录并算失重率')])],
  observer
)

5.5 取消运行

复制代码
await subscription.cancel('user_cancel')

取消后 Runtime 进入 cancellingfailed(或 cancelled),finally 路径清理所有资源。

5.6 AgentEvent 完整事件清单

事件类型 何时触发 UI 怎么处理
runStart 运行开始 显示 loading
modelStart 模型调用开始 ------
reasoningDelta 推理增量 可选:显示 thinking
textDelta 正文增量 追加到回答区
toolCallDelta 工具调用增量 ------
usage token 用量 可选:显示消耗
modelComplete 模型响应完成 ------
toolStart 工具开始执行 显示"正在执行 XX"
toolResult 工具执行完毕 可选:显示结果摘要
approvalRequired 危险工具等待确认 弹出审批 UI
loopDetected 循环检测命中 显示循环提示
runComplete 运行完成 隐藏 loading
runError 运行出错 显示错误
runCancelled 被取消 隐藏 loading
runSuspended 被挂起 显示"已暂停"

六、API Key 安全管理

这是最容易被忽视、但最重要的一环。

6.1 ⚠️ 红线:Key 永远不能出现在这些地方

位置 能不能存 Key 原因
rawfile/ 打入 HAP,可被解包提取
preferences 明文存储在设备 filesDir
filesDir 文件 设备备份可被提取
AgentState State 会被持久化到文件
日志(hilog) 日志可被收集
错误信息 可能显示给用户或记入日志
Git 提交 版本控制泄漏
进程内存 唯一允许的位置

6.2 RuntimeCredentials:内存专用的密钥持有器

ArkAgent 示例工程的 RuntimeCredentials 是一个最佳实践参考:

复制代码
export class RuntimeCredentials {
  private static instance?: RuntimeCredentials

  private zhipuApiKey: string = ''
  private deepseekApiKey: string = ''
  // 模型 ID 是非敏感配置,可以单独管理
  private zhipuModel: string = 'glm-5.2'
  private deepseekModel: string = 'deepseek-v4-flash'

  static shared(): RuntimeCredentials {
    if (RuntimeCredentials.instance === undefined) {
      RuntimeCredentials.instance = new RuntimeCredentials()
    }
    return RuntimeCredentials.instance
  }

  /** 设置 API Key(仅内存,不日志、不持久化、不回显) */
  setApiKey(provider: string, key: string): void {
    const trimmed = key.trim()
    if (provider === 'deepseek') {
      this.deepseekApiKey = trimmed
    } else {
      this.zhipuApiKey = trimmed
    }
  }

  /** 取 Key(仅供网络调用使用) */
  getApiKey(provider: string): string {
    return provider === 'deepseek' ? this.deepseekApiKey : this.zhipuApiKey
  }

  /** UI 状态文字(不含 Key 内容) */
  keyStatusLabel(provider: string): string {
    return this.hasApiKey(provider) ? '已输入(仅内存)' : '未输入'
  }

  /** 清零------页面/Ability 销毁时调用 */
  clearAll(): void {
    this.zhipuApiKey = ''
    this.deepseekApiKey = ''
    // 保留非敏感的模型默认值
  }
}

设计要点:

  • 单例:全 App 共享一个密钥持有器,避免多处散落。

  • Key 只进内存setApiKey 只赋值私有字段,不日志、不持久化。

  • UI 不回显 KeykeyStatusLabel 只返回"已输入"或"未输入",不返回 Key 内容。

  • 清零接口clearAll 把 Key 置空,在页面 aboutToDisappear 或 Ability onDestroy 时调用。

6.3 页面集成中的密钥流转

复制代码
// UI 输入 Key
TextInput({ placeholder: '运行时输入 API Key', text: this.apiKeyInput })
  .type(InputType.Password)
  .onChange((value: string) => this.apiKeyInput = value)

// 点击"应用 Key"按钮
private applyRuntimeKey(): void {
  SessionCoordinator.shared().setRuntimeApiKey(this.apiKeyInput)
  this.apiKeyInput = ''  // ← 立即清空 UI 缓冲
  this.refreshKeyStatus()
}

// 页面销毁
aboutToDisappear(): void {
  this.apiKeyInput = ''                          // 清空 UI
  SessionCoordinator.shared().clearSecrets()     // 清空内存 Key
}

关键 :Key 输入后立即清空 UI 输入框缓冲,不让 Key 在 @State 里多留一秒。


七、SessionCoordinator:推荐的工程封装

不要让每个页面直接创建 Provider 和 Runtime。ArkAgent 示例工程用了一个 SessionCoordinator 作为中间层,这是推荐的做法。

7.1 为什么需要 Coordinator?

如果不用 Coordinator 用 Coordinator
每个页面自己 new Runtime Runtime 生命周期集中管理
Provider 切换要改每个页面 改一个字段,全局生效
Key 散落在多个组件 Key 只从 Coordinator 进出
后台处理逻辑各页面不同 统一"切后台 → requestSuspend"

7.2 Coordinator 的核心职责

复制代码
SessionCoordinator
  ├── bindContext(context)        绑定 Ability context(获取 filesDir)
  ├── setRuntimeApiKey(key)       内存注入 Key
  ├── setProvider(provider)       切换 Provider(销毁旧 Runtime)
  ├── startRun()                  组装 Runtime + 发送 prompt + runStream
  ├── cancelRun()                 取消当前运行
  ├── requestSuspend()            挂起(切后台用)
  ├── resumeRun()                 恢复挂起的会话
  ├── reloadSession()             从 Storage 重新加载
  ├── deleteSession()             删除持久化会话
  ├── approveFirstPending()       批准危险工具
  ├── denyFirstPending()          拒绝危险工具
  ├── onBackground()              Ability 后台 → requestSuspend
  ├── onForeground()              Ability 前台 → refresh(不自动恢复)
  └── clearSecrets()              清零 Key

7.3 Coordinator 的事件处理

Coordinator 实现 StreamObserver<AgentEvent>,把 Runtime 事件翻译成 UI 快照:

复制代码
private handleEvent(event: AgentEvent): void {
  if (event.type === AgentEventType.textDelta && event.payload.text !== undefined) {
    this.answerText = `${this.answerText}${event.payload.text}`
  } else if (event.type === AgentEventType.toolStart) {
    this.appendLog(`tool_start name=${event.payload.toolCall?.name}`)
  } else if (event.type === AgentEventType.toolResult) {
    this.appendLog(`tool_result name=${event.payload.toolCall?.name}`)
  } else if (event.type === AgentEventType.runComplete) {
    this.stopReason = event.payload.stopReason ?? 'completed'
  } else if (event.type === AgentEventType.approvalRequired) {
    this.stopReason = 'awaiting_approval'
  } else if (event.type === AgentEventType.runSuspended) {
    this.stopReason = 'suspended'
  }
  this.emit()  // 通知 UI 刷新
}

7.4 推荐的工程目录结构

复制代码
entry/src/main/ets/
├── pages/
│   └── Index.ets                  ← UI 页面(只管展示和输入)
├── session/
│   ├── SessionCoordinator.ets     ← Runtime 生命周期、ask/cancel/resume
│   └── RuntimeCredentials.ets     ← 仅内存保存 Key
├── baking/
│   └── BakingTools.ets            ← 业务 Tool 定义与执行器
└── skills/
    └── Phase6Skills.ets           ← 可选:技能定义

八、状态持久化与会话恢复

8.1 启用持久化

传入 StateStorage 即可启用:

复制代码
import { FileStateStorage, HarmonyFileSystem } from '@arkagent/core'

// 在 Ability 中获取 filesDir
const storage = new FileStateStorage(
  new HarmonyFileSystem(),
  `${context.filesDir}/agent_sessions`
)

// 传入 RuntimeConfig(第 11 个参数)
const runtimeConfig = new AgentRuntimeConfig(
  client,
  new ModelConfig('glm-5.2'),
  registry,
  'my-agent-session-001',   // ← sessionId 必须稳定
  systemPrompt,
  new ToolChoice(ToolChoiceMode.auto),
  8,
  undefined,                 // strategy
  undefined,                 // clock
  undefined,                 // idGenerator
  undefined,                 // services
  storage                    // ← 第 11 个参数:StateStorage
  // autoSave 默认 true
)

8.2 恢复会话

复制代码
const runtime = await AgentRuntime.create(runtimeConfig)

if (runtime.isResumable()) {
  // 有未完成的会话可以恢复
  await runtime.resumeStream(observer)
}

8.3 挂起与恢复

切后台时挂起(安全 checkpoint),回前台时手动恢复:

复制代码
// Ability onBackground
async onBackground(): Promise<void> {
  if (this.runtime !== undefined && this.runtime.isRunning()) {
    await this.runtime.requestSuspend('ability_on_background')
  }
}

// Ability onForeground(不自动恢复!)
async onForeground(): Promise<void> {
  await this.reloadSession(false)
  // 用户需要手动点"恢复"才继续
}

关键 :前台恢复时不自动 resume------因为恢复会发起新的网络请求(产生费用),必须由用户主动触发。


九、完整案例:烘焙助手的运行流程

把前面的所有内容串起来,看一次完整的 Agent 运行:

复制代码
用户操作                            Runtime 内部                         UI 表现
─────────────────────────────────────────────────────────────────────────────
1. 输入 API Key → 应用 Key          RuntimeCredentials.setApiKey()       "已输入(仅内存)"
                                   StaticBearerTokenProvider 包装

2. 点"开始"                        buildRuntime() 组装 Config           "运行中"
                                   AgentRuntime.create(config)
                                   runtime.runStream([prompt], observer)

3. [Agent Loop 第 1 轮]             modelStart                           ------
  模型返回 tool_calls:              reasoningDelta(如果有)             thinking 显示
  read_baking_records               modelComplete                       ------
                                    toolStart: read_baking_records      "正在读取记录..."
                                    toolResult: r1|法棍|wet=500|dry=420;  ------
                                                  r2|吐司|wet=680|dry=610;
                                                  r3|贝果|wet=320|dry=280

4. [Agent Loop 第 2 轮]             modelStart                           ------
  模型返回 tool_calls:              modelComplete                       ------
  calculate_weight_loss(r1)         toolStart: calculate_weight_loss    "正在计算失重率..."
                                    toolResult: r1 法棍 loss=80g         ------
                                                rate=16.00%

5. [Agent Loop 第 3 轮]             modelStart                           ------
  模型返回纯文本(无工具调用)        textDelta: "根据烘焙记录..."          逐字显示回答
                                    textDelta: "法棍失重率..."            逐字显示
                                    textDelta: "建议..."                 逐字显示
                                    modelComplete                       ------
                                    runComplete: completed              "完成"

整个过程中,用户看到的是:

  1. "正在读取记录..."

  2. "正在计算失重率..."

  3. "根据烘焙记录,法棍的失重率为 16.00%..."

这就是一个真正的 AI Agent------不是"你问我答",而是"Agent 自己决定调什么工具、怎么组合结果"。


十、最佳实践清单

10.1 接入

  • ✅ 用 HAR 本地依赖(file:./libs/agent_core.har),不依赖源码目录。

  • ✅ 在实际使用 HAR 的模块(通常是 entry)的 oh-package.json5 声明依赖。

  • ✅ 确保有 ohos.permission.INTERNET 权限。

  • ✅ 显式设置 DEVECO_SDK_HOME,用 --no-daemon

10.2 Provider 组装

  • ✅ Provider 组装收敛到一个工厂方法,不散落在页面里。

  • ✅ Provider 切换时销毁旧 Runtime。

  • ✅ 模型 ID 作为配置传入,不硬编码。

  • ✅ 连接超时和读取超时分开配置。

10.3 工具定义

  • ✅ 工具 description 要清晰------模型据此决定是否调用。

  • ✅ 参数用 JSON Schema 描述,必填字段标 required

  • ✅ 工具失败时返回 isError=true,不伪装成功。

  • ✅ 危险工具标记 ToolRiskLevel.dangerous,让 Runtime 拦截等待审批。

  • ✅ ToolExecutor 不信任模型参数,执行前自行校验。

10.4 安全

  • ✅ API Key 只在运行时内存输入,不写入 rawfile/preferences/filesDir/Git。

  • ✅ Key 输入后立即清空 UI 缓冲。

  • ✅ 页面 aboutToDisappear 和 Ability onDestroy 调用 clearAll()

  • ✅ 错误信息不拼接 Authorization 或完整请求体。

  • ✅ 发布前跑 scan-hap-secrets.sh

  • ✅ 一个 AgentRuntime 同时只允许一个 active run。

10.5 工程

  • ✅ 用 SessionCoordinator 中间层管理 Runtime 生命周期。

  • ✅ UI 只管展示和输入,不做 Runtime 决策。

  • ✅ sessionId 稳定且唯一。

  • ✅ 切后台时 requestSuspend,回前台不自动恢复。


十一、常见错误对照表

错误做法 问题 正确做法
Key 写入 rawfile HAP 可被解包提取 运行时内存输入
Key 留在 UI @State 页面销毁前一直暴露 应用后立即清空
每个页面自己 new Runtime 生命周期混乱 用 Coordinator 集中管理
模型 ID 硬编码在 enum 里 模型更新需改 SDK 作为配置字符串传入
工具失败返回假成功 模型基于假数据继续 返回 isError=true
危险工具不标 riskLevel 自动执行危险操作 ToolRiskLevel.dangerous
同一实例并行 runStream 状态冲突 等当前 run 结束再发起
切后台不挂起 继续烧 token requestSuspend
回前台自动恢复 未经用户同意产生费用 用户手动点"恢复"
错误信息显示完整请求体 泄漏 prompt / Authorization 只显示错误码和简短说明
sessionId 每次随机 无法恢复历史会话 用稳定 ID(如 bake-lifecycle-zhipu
generate 做流式 没有逐字输出 stream / runStream
不处理 approvalRequired 事件 危险工具卡住无响应 UI 弹出审批入口

十二、验证清单(真机 Review)

基础对话

  • 运行时输入智谱 API Key,能完成文本生成

  • 切换到 DeepSeek,同样能完成

  • 流式输出逐字显示

  • 多轮对话历史保持

工具调用

  • Agent 先调 read_baking_records,再调 calculate_weight_loss

  • 工具结果正确显示在回答中

  • 危险工具 clear_baking_cache 触发审批弹窗

  • 批准后危险工具执行

  • 拒绝后 Agent 不再尝试该工具

状态

  • 切后台再回来,历史消息不丢失

  • 点"恢复"能继续未完成的会话

  • 取消后 Runtime 停止

安全

  • Key 输入后输入框立即清空

  • 退出页面后 Key 不保留

  • 错误信息不包含 Key / Authorization

  • scan-hap-secrets.sh 无命中

  • 设备 filesDir 无 Key 文件


十三、构建验证

复制代码
# 构建示例 HAP
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHap --no-daemon

构建结果:

复制代码
CompileArkTS passed
PackageHap passed
BUILD SUCCESSFUL

如果遇到 NODE_HOME is not set

复制代码
NODE_HOME=/Applications/DevEco-Studio.app/Contents/tools/node \
  DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk \
  /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw assembleHap --no-daemon

十四、写在最后

跑通第一个 AI Agent,看似只是"调一个 API",背后却涉及 Provider 组装、工具定义、流式事件处理、密钥安全、状态持久化和工程封装等多个维度。如果这些问题留给每个页面自己解决,代码会迅速退化。

ArkAgent 的设计哲学是:

你只管"做什么"(工具定义、系统提示词),SDK 管"怎么跑"(流式、工具循环、状态机、安全)。

记住这几条口诀:

Provider 组装一处管,切换 Profile 改一行。 工具 Definition 加 Executor,Schema 校验不能少。 危险工具标 riskLevel,审批弹窗拦一道。 Key 只进内存不落盘,页面销毁就清零。 Coordinator 做中间层,UI 只管展示事。 runStream 逐字蹦,事件分派要齐全。 切后台 requestSuspend,回前台不自动恢复。 sessionId 要稳定,历史恢复靠它找。

本文是 ArkAgent 鸿蒙教程系列的第二篇。第一篇讲了架构全景,这篇讲了快速接入。后续文章会逐层深入------流式输出的 SSE 解析、JSON 类型安全、工具调用全流程、状态持久化、记忆子 Agent、评估框架------每一层都有完整的实战代码和踩坑记录。

如果你已经跟着本文跑通了"烘焙助手",恭喜------你已经迈出了鸿蒙 AI Agent 开发最关键的一步。

相关推荐
JJJennie7777 小时前
当AI开始学会欺骗
网络·人工智能
糖果店的幽灵7 小时前
大模型测评DeepEval快速入门-安全与通用指标详解
人工智能·安全·langgraph·大模型测评·deepeval
江畔柳前堤13 小时前
roLabelImg 详细安装教程
开发语言·人工智能·后端·云原生
阿里云大数据AI技术13 小时前
分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
人工智能·flink
陕西企来客13 小时前
2026年7月AI智能搜索曝光趋势研判
大数据·人工智能·机器学习·ai智能搜索曝光
咱入行浅13 小时前
汽车之家联合HarmonyOS SDK,深度构建鸿蒙生态体系
华为·汽车·harmonyos
阿里云大数据AI技术14 小时前
从算力到智能体,面向 Agentic AI 的基础设施演进
人工智能·agent
hangyuekejiGEO14 小时前
GEO技术服务选型指南
大数据·人工智能·python
阿里云大数据AI技术14 小时前
EMR Serverless Spark AI Function 的双维降本实践
人工智能·sql·spark
维基框架15 小时前
GitHub源码处理提速 一趟扫描反而更慢
人工智能·github