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

很多开发者在鸿蒙上接入大模型的方式是:写一个 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 开发最关键的一步。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐