鸿蒙上跑通第一个 AI Agent:5 分钟接入智谱/DeepSeek
一、前言:从"能跑"到"跑对"
很多开发者在鸿蒙上接入大模型的方式是:写一个 fetch 请求调 Chat Completions API,拿到 JSON,解析 choices[0].message.content,显示到 Text 组件上。这能"跑通",但跑不对:
-
没有流式输出:用户等 5 秒看到一整段文字,体验差。
-
没有工具调用:Agent 不能查数据库、不能算数学、不能操作系统功能。
-
没有状态管理:切后台回来历史消息全没了。
-
API Key 不安全:写在 rawfile 或 preferences 里,HAP 可被提取。
-
没有取消/恢复:用户点"停止"没反应,切后台继续烧 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 |
|
|
支持图片输入( |
|
DeepSeek |
|
|
支持 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 不进入ModelConfig或AgentState。 -
模型 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 包含:
|
字段 |
说明 |
|---|---|
|
|
是否成功(completed 或 stopFlag) |
|
|
最终文本回答 |
|
|
停止原因(completed / maxTurnsExceeded / cancelled / ...) |
|
|
完整消息历史 |
|
|
每轮的 token 用量 |
|
|
实际循环了多少轮 |
|
|
错误信息(如果有) |
|
|
是否可恢复(挂起后) |
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 进入 cancelling → failed(或 cancelled),finally 路径清理所有资源。
5.6 AgentEvent 完整事件清单
|
事件类型 |
何时触发 |
UI 怎么处理 |
|---|---|---|
|
|
运行开始 |
显示 loading |
|
|
模型调用开始 |
—— |
|
|
推理增量 |
可选:显示 thinking |
|
|
正文增量 |
追加到回答区 |
|
|
工具调用增量 |
—— |
|
|
token 用量 |
可选:显示消耗 |
|
|
模型响应完成 |
—— |
|
|
工具开始执行 |
显示"正在执行 XX" |
|
|
工具执行完毕 |
可选:显示结果摘要 |
|
|
危险工具等待确认 |
弹出审批 UI |
|
|
循环检测命中 |
显示循环提示 |
|
|
运行完成 |
隐藏 loading |
|
|
运行出错 |
显示错误 |
|
|
被取消 |
隐藏 loading |
|
|
被挂起 |
显示"已暂停" |
六、API Key 安全管理
这是最容易被忽视、但最重要的一环。
6.1 ⚠️ 红线:Key 永远不能出现在这些地方
|
位置 |
能不能存 Key |
原因 |
|---|---|---|
|
|
❌ |
打入 HAP,可被解包提取 |
|
|
❌ |
明文存储在设备 filesDir |
|
|
❌ |
设备备份可被提取 |
|
|
❌ |
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 不回显 Key:
keyStatusLabel只返回"已输入"或"未输入",不返回 Key 内容。 -
清零接口:
clearAll把 Key 置空,在页面aboutToDisappear或 AbilityonDestroy时调用。
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 "完成"
整个过程中,用户看到的是:
-
"正在读取记录..."
-
"正在计算失重率..."
-
"根据烘焙记录,法棍的失重率为 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和 AbilityonDestroy调用clearAll()。 -
✅ 错误信息不拼接 Authorization 或完整请求体。
-
✅ 发布前跑
scan-hap-secrets.sh。 -
✅ 一个
AgentRuntime同时只允许一个 active run。
10.5 工程
-
✅ 用 SessionCoordinator 中间层管理 Runtime 生命周期。
-
✅ UI 只管展示和输入,不做 Runtime 决策。
-
✅ sessionId 稳定且唯一。
-
✅ 切后台时
requestSuspend,回前台不自动恢复。
十一、常见错误对照表
|
错误做法 |
问题 |
正确做法 |
|---|---|---|
|
Key 写入 rawfile |
HAP 可被解包提取 |
运行时内存输入 |
|
Key 留在 UI |
页面销毁前一直暴露 |
应用后立即清空 |
|
每个页面自己 new Runtime |
生命周期混乱 |
用 Coordinator 集中管理 |
|
模型 ID 硬编码在 enum 里 |
模型更新需改 SDK |
作为配置字符串传入 |
|
工具失败返回假成功 |
模型基于假数据继续 |
返回 |
|
危险工具不标 riskLevel |
自动执行危险操作 |
标 |
|
同一实例并行 runStream |
状态冲突 |
等当前 run 结束再发起 |
|
切后台不挂起 |
继续烧 token |
|
|
回前台自动恢复 |
未经用户同意产生费用 |
用户手动点"恢复" |
|
错误信息显示完整请求体 |
泄漏 prompt / Authorization |
只显示错误码和简短说明 |
|
|
无法恢复历史会话 |
用稳定 ID(如 |
|
用 |
没有逐字输出 |
用 |
|
不处理 |
危险工具卡住无响应 |
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 开发最关键的一步。
更多推荐




所有评论(0)