鸿蒙HarmonyOS AI 工具调用实战 —— ToolDefinition、Schema 校验、风险分级全流程
一、前言:工具调用的"五个难题"
假设你在鸿蒙上做了一个聊天应用,现在要加"AI 助手能查烘焙记录"的功能。你的第一反应可能是:
// 注册一个工具,让模型能调它
function readBakingRecords(): string {
return '法棍 500g, 吐司 680g, 贝果 320g'
}
然后你把这个函数名和说明丢给模型,等模型返回"我要调 readBakingRecords"。但真到实现时,你会发现五个难题接踵而至:
-
怎么让模型"调"一个 ArkTS 函数? ArkTS 严格模式不支持反射式调用(没有
Function.apply)。模型返回的是工具名 + JSON 参数字符串,你怎么安全地分发到对应的执行器? -
模型生成的参数可信吗? 模型可能返回
{"recordId": 123}(数字)而你期望的是字符串,或者返回{"recordId": "r1", "extra": "hack"}(多了字段),或者干脆返回残缺的 JSON。你要不要校验?怎么校验? -
危险工具怎么办? 如果有个"清空数据库"的工具,模型决定调它,你直接执行吗?需不需要用户确认?确认的流程怎么设计?
-
Agent 会不会死循环? 模型反复调同一个工具、用同样的参数,陷入死循环。你怎么检测?检测到后怎么处理?
-
工具出错怎么办? 工具执行抛了异常,是让整个 Agent 崩溃,还是把错误信息返回给模型让它换个策略?
这五个问题不是独立的——参数校验影响执行安全,风险分级影响执行时机,循环检测影响执行上限,错误处理影响执行后续。你需要的是一个端到端的工具调用体系,而不是五个补丁。
ArkAgent 的工具调用体系覆盖了从定义、注册、校验、审批、执行、错误回填到循环检测的完整链路。本文就拆解这套体系。
二、工具契约:五字段签名 + 对象参数
先看地基——工具的契约设计。这是 ADR-0005 冻结的核心决策,定义在 domain/Tool.ets。
2.1 工具的两半:Definition + Executor
每个工具由两部分组成,职责严格分离:
ToolDefinition(告诉模型"这个工具是什么")
├── name: string ← 工具名(模型据此调用)
├── description: string ← 工具说明(模型据此决定是否调用)
├── parameters: JsonObject ← JSON Schema 参数描述
└── riskLevel: ToolRiskLevel ← 风险等级(影响审批策略)
ToolExecutor(由你的 App 真正执行)
└── execute(arguments, context): Promise<ToolResult>
ToolDefinition 是给模型看的"菜单"——它会被序列化成 OpenAI tools 格式发给 Provider。ToolExecutor 是你的业务实现——SDK 永远不会看到它的内部,只调用它的 execute 方法。
2.2 源码定义
export class ToolDefinition {
readonly name: string
readonly description: string
readonly parameters: JsonObject
readonly riskLevel: ToolRiskLevel
constructor(name: string, description: string, parameters: JsonObject,
riskLevel: ToolRiskLevel = ToolRiskLevel.safe) {
this.name = name
this.description = description
this.parameters = parameters
this.riskLevel = riskLevel
}
}
export interface ToolExecutor {
execute(argumentsValue: JsonObject, context: ToolContext): Promise<ToolResult>
}
四个要点:
第一,parameters 是 JsonObject 不是 string。 它直接复用了上一篇博客讲的类型安全 JSON 体系。你用 JsonObject + JsonValue 构造 Schema,而不是写字符串再 parse。这保证了 Schema 定义本身就是类型安全的。
第二,riskLevel 默认 safe。 这个默认值是为了向后兼容——早期注册的工具没有 riskLevel 字段,默认安全。但这不意味着"所有工具都安全",你注册危险工具时必须显式标 ToolRiskLevel.dangerous。
第三,execute 接收的是 JsonObject 不是 string。 模型返回的 argumentsJson 字符串,由 ToolRegistry 在执行前解析成 JsonObject 再传给你的执行器。你的执行器不需要自己 parse JSON——它拿到的是已经解析好、已经校验过的结构化对象。
第四,execute 返回 Promise<ToolResult>,不抛异常。 如果工具执行失败,返回 ToolResult(isError: true);如果执行器内部抛了异常,ToolRegistry 会捕获并转成结构化错误。工具失败不是崩溃,是"给模型的反馈"。
2.3 ⚠️ 踩坑一:为什么不用反射式调用
症状:最初可能想模仿其他语言的 Agent 框架,用反射注册任意函数:
// ❌ 想象中的"优雅"写法
registry.register('read_baking_records', (recordId: string) => { ... })
// 模型返回 read_baking_records({"recordId":"r1"})
// 框架自动把 JSON 参数映射到函数参数
根因:ArkTS 严格模式不支持 Function.apply 和动态参数分发。你拿到模型返回的工具名和参数 JSON 后,没法"动态地把 JSON 字段映射到函数形参"——这需要反射,而 ArkTS 没有。ADR-0005 明确记录了这个决策:
"Dart Tool 默认依赖 Function.apply,运行上下文依赖 Zone。ArkTS ToolExecutor 只接受已解码 JsonObject 和显式 ToolContext。"
修复:用对象参数 + 显式执行器接口。所有工具共享同一个签名 execute(JsonObject, ToolContext),参数解析由执行器自己负责:
// ✅ 正确:统一的签名,执行器自己取参数
export class ReadBakingRecordsTool implements ToolExecutor {
async execute(args: JsonObject, ctx: ToolContext): Promise<ToolResult> {
// 执行器自己从 JsonObject 取需要的字段
const recordId = args.get('recordId')?.asString() ?? ''
// ... 业务逻辑 ...
return new ToolResult([ContentPart.text('...')])
}
}
教训:ArkTS 的类型安全哲学是"显式优于隐式"。反射式调用虽然写起来短,但它把"参数匹配"推迟到运行期,且绕过了类型检查。对象参数 + 显式执行器虽然多写几行,但每个参数的类型和来源都清清楚楚。
三、ToolContext:显式上下文的五个字段
执行器拿到参数后,还需要上下文信息——当前是哪个会话、能不能取消、有没有共享服务。这些信息通过 ToolContext 显式传入。
export class ToolContext {
readonly sessionId: string
readonly state: ReadonlyAgentState
readonly batchCallId: string
readonly signal: CancellationSignal
readonly services: ToolServiceRegistry
constructor(sessionId: string, state: ReadonlyAgentState, batchCallId: string,
signal: CancellationSignal, services: ToolServiceRegistry) { ... }
}
3.1 五个字段的设计意图
|
字段 |
类型 |
用途 |
安全约束 |
|---|---|---|---|
|
|
string |
标识当前会话 |
只读 |
|
|
ReadonlyAgentState |
只读状态快照 |
只读,工具不能改 Agent 状态 |
|
|
string |
同一批工具共享 |
用于日志关联 |
|
|
CancellationSignal |
取消信号 |
工具应协作检查 |
|
|
ToolServiceRegistry |
有限服务注册表 |
白名单,不是任意 Service Locator |
关键设计决策一:state 是只读的。 ReadonlyAgentState 只暴露 schemaVersion、sessionId、metadata——工具不能修改 Agent 的历史消息、计划、技能状态。这防止了工具"偷偷改 Agent 内部状态"导致的状态不一致。
关键设计决策二:services 是有限白名单。 ToolServiceRegistry 不是任意 Service Locator(不能 get('anything')),而是开发者预先注册的有限服务集合。阶段报告的安全基线明确写了:"ToolContext 不暴露 API Key 和任意 Service Locator。"
export interface ToolService {
readonly serviceId: string
}
export class ToolServiceRegistry {
private readonly services: Map<string, ToolService> = new Map<string, ToolService>()
register(service: ToolService): boolean {
if (this.services.has(service.serviceId)) return false // 重复注册返回 false
this.services.set(service.serviceId, service)
return true
}
get(serviceId: string): ToolService | undefined {
return this.services.get(serviceId)
}
}
3.2 为什么不提供全局 current context
有些框架提供"全局上下文"——工具执行时从全局获取当前会话、当前用户、当前权限。ArkAgent 明确拒绝了这种设计,ADR-0005 记录了原因:
"Zone current context → 显式 ToolContext,原因:权限和依赖可见。"
全局上下文有三个问题:一是不可测试(测试时没法 mock 全局状态);二是不安全(全局可访问意味着任何代码都能读敏感数据);三是不可追溯(上下文从哪来的、谁设置的,全靠"约定")。显式传入让权限和依赖完全可见——工具能用什么、不能用什么,看 ToolContext 的类型定义就知道了。
四、ToolRegistry:注册三步校验 + 执行四道关卡
ToolRegistry 是工具体系的调度中心。它负责注册校验、参数解析、Schema 校验、执行和错误转换。
4.1 注册时的三步校验
export class ToolRegistry {
private readonly tools: Map<string, RegisteredTool> = new Map<string, RegisteredTool>()
register(definition: ToolDefinition, executor: ToolExecutor): Result<boolean, ArkAgentError> {
// 第一步:名字不能为空
if (definition.name.trim().length === 0) {
return Result.failure<boolean, ArkAgentError>(
ArkAgentError.config('tool_name_empty', 'Tool name must not be empty'))
}
// 第二步:名字不能重复
if (this.tools.has(definition.name)) {
return Result.failure<boolean, ArkAgentError>(ArkAgentError.config(
'tool_name_duplicate', `Tool already registered: ${definition.name}`))
}
// 第三步:Schema 本身必须合法(注意:校验 Schema 定义,不校验值)
const schemaResult = SchemaValidator.validateSchema(definition.parameters)
if (!schemaResult.ok) return schemaResult
this.tools.set(definition.name, new RegisteredTool(definition, executor))
return Result.success<boolean, ArkAgentError>(true)
}
}
三步校验在注册时就完成,而不是等到执行时。这意味着:如果 Schema 定义有错误,应用启动时就会报错,而不是等模型调用时才发现。"早报错"是工程可靠性的基础。
注意第三步只校验 Schema 定义本身的合法性(比如 type 字段有没有、关键字支不支持),不校验"某个值是否匹配 Schema"——后者在执行时做,因为值是模型生成的,注册时还没有。
4.2 执行时的四道关卡
当模型返回一个 Tool Call 后,ToolRegistry.execute 要过四道关卡:
async execute(call: ToolCall, context: ToolContext): Promise<Result<ToolResult, ArkAgentError>> {
// 关卡一:工具必须已注册
const registered = this.tools.get(call.name)
if (registered === undefined) {
return Result.failure<ToolResult, ArkAgentError>(ArkAgentError.tool(
'tool_not_found', `Tool not found: ${call.name}`))
}
// 关卡二:参数 JSON 必须可解析
const argumentsResult = JsonCodec.parseObject(call.argumentsJson)
if (!argumentsResult.ok || argumentsResult.value === undefined) {
return Result.failure<ToolResult, ArkAgentError>(argumentsResult.error as ArkAgentError)
}
// 关卡三:参数必须通过 Schema 校验
const validation = SchemaValidator.validate(argumentsResult.value, registered.definition.parameters)
if (!validation.ok) {
return Result.failure<ToolResult, ArkAgentError>(validation.error as ArkAgentError)
}
// 关卡四:取消信号检查
if (context.signal.cancelled) {
return Result.failure<ToolResult, ArkAgentError>(new ArkAgentError(
ErrorLayer.cancelled, 'tool_cancelled', context.signal.reason ?? 'Tool cancelled'))
}
// 四关全过 → 执行
try {
return Result.success<ToolResult, ArkAgentError>(
await registered.executor.execute(argumentsResult.value, context))
} catch (caught) {
// 执行器抛异常 → 结构化错误(不崩溃)
if (caught instanceof ArkAgentError) {
return Result.failure<ToolResult, ArkAgentError>(caught)
}
const message = caught instanceof Error ? caught.message : 'Tool execution failed'
return Result.failure<ToolResult, ArkAgentError>(
ArkAgentError.tool('tool_execution_failed', message))
}
}
四道关卡的含义:
|
关卡 |
失败原因 |
错误码 |
错误层 |
|---|---|---|---|
|
1. 已注册 |
工具名不存在 |
|
tool |
|
2. JSON 可解析 |
参数不是合法 JSON |
|
decode |
|
3. Schema 校验 |
参数类型/结构不对 |
|
tool |
|
4. 未取消 |
用户已取消 |
|
cancelled |
为什么不信任模型生成的参数? 因为模型的输出是不可控的。它可能返回 {"recordId": 123}(数字而非字符串)、{"recordId": null}(null)、{}(缺失必填)、甚至返回一段不是 JSON 的文本。关卡二和关卡三就是拦截这些问题的两道防线——先确保是合法 JSON,再确保匹配 Schema。
4.3 执行器异常的结构化处理
注意 execute 的 try-catch:
try {
return Result.success(...await registered.executor.execute(...))
} catch (caught) {
if (caught instanceof ArkAgentError) {
return Result.failure<ToolResult, ArkAgentError>(caught) // 保留原始错误码
}
const message = caught instanceof Error ? caught.message : 'Tool execution failed'
return Result.failure<ToolResult, ArkAgentError>(
ArkAgentError.tool('tool_execution_failed', message))
}
两种异常处理策略:
-
ArkAgentError:执行器主动抛出的领域错误(比如
plan_todo_status_unknown),保留原始错误码,不包装。这样上层能根据错误码做精确处理。 -
其他异常:统一包装成
tool_execution_failed。这防止了执行器的内部实现细节(比如某个库的特定异常类型)泄漏到上层。
4.4 Registry 的衍生能力
ToolRegistry 还有几个衍生方法,服务于子 Agent 和安全场景:
// 复制所有注册到目标 Registry(用于子 Agent 继承父 Agent 的工具)
copyInto(target: ToolRegistry): Result<boolean, ArkAgentError>
// 复制时排除指定工具(用于子 Agent 剥离危险工具,如 delegate_task)
copyIntoExcluding(target: ToolRegistry, excludedNames: string[]): Result<boolean, ArkAgentError>
// 注销工具(用于运行时动态移除)
unregister(name: string): boolean
copyIntoExcluding 是子 Agent 安全的关键——阶段 7 的报告记录了一个安全加固:Worker 基座 Registry 仍然可能包含 delegate_task(委派工具),恶意 factory 可能自定义执行器绕过审批。解法是 buildWorkerToolRegistry() 用 copyIntoExcluding 过滤掉 delegate_task,确保子 Agent 不能再委派(防止递归失控)。
五、SchemaValidator:白名单校验策略
SchemaValidator(tool/SchemaValidator.ets)是工具参数校验的核心。它的设计哲学是:只支持 JSON Schema 的一个安全子集,不支持的关键字必须报错。
5.1 两个白名单
const SUPPORTED_KEYS: string[] = ['type', 'properties', 'required', 'items', 'description']
const SUPPORTED_TYPES: string[] = ['string', 'number', 'integer', 'boolean', 'object', 'array', 'null']
只支持 5 个关键字和 7 种类型。这意味着:
-
✅
type、properties、required、items、description—— 支持 -
❌
enum、oneOf、anyOf、minimum、maximum、pattern、format—— 不支持,报错
5.2 ⚠️ 踩坑二:不支持的关键字必须报错,不能静默忽略
症状:开发者定义了一个带 enum 的工具参数 Schema:
const params = new JsonObject()
.set('type', JsonValue.string('object'))
.set('properties', JsonValue.object(new JsonObject()
.set('status', JsonValue.object(new JsonObject()
.set('type', JsonValue.string('string'))
.set('enum', JsonValue.array([ // ← enum 不在白名单里
JsonValue.string('pending'),
JsonValue.string('done')
]))
))
))
错误做法:如果 SchemaValidator 静默忽略 enum,开发者会以为"参数会被校验为 pending 或 done",但实际上模型返回 status: "hack" 也会通过校验。这就是"虚假的安全感"——你以为有校验,其实没有。
正确做法:SchemaValidator 对 enum 报 schema_unsupported_keyword 错误,让开发者立即知道"这个关键字不被支持"。阶段 1 的实施心得把这条沉淀成了规则:
"Schema 不支持的关键字必须报错,不能静默忽略后产生虚假的安全感。"
修复后的校验逻辑:
private static validateSchemaNode(schema: JsonObject, root: boolean): Result<boolean, ArkAgentError> {
const keys = schema.keys()
for (let index = 0; index < keys.length; index++) {
if (SUPPORTED_KEYS.indexOf(keys[index]) < 0) {
return Result.failure<boolean, ArkAgentError>(ArkAgentError.config(
'schema_unsupported_keyword', `Unsupported JSON Schema keyword: ${keys[index]}`))
}
}
// ... 继续校验 type、properties、items ...
}
教训:校验器的"诚实"比"宽容"更重要。宁可拒绝并报错,也不能静默跳过——静默跳过会让开发者基于错误的假设写代码。如果你需要 enum 校验,要么在执行器里自己做,要么等 SDK 扩展白名单。
5.3 两层校验:先 Schema 后值
SchemaValidator 提供两个入口,对应两层校验:
// 第一层:校验 Schema 定义本身合法(注册时调)
static validateSchema(schema: JsonObject): Result<boolean, ArkAgentError> {
return SchemaValidator.validateSchemaNode(schema, true)
}
// 第二层:校验值是否匹配 Schema(执行时调)
static validate(value: JsonObject, schema: JsonObject): Result<boolean, ArkAgentError> {
const schemaResult = SchemaValidator.validateSchema(schema) // 先验 Schema
if (!schemaResult.ok) return schemaResult
return SchemaValidator.validateValue(JsonValue.object(value), schema, '$') // 再验值
}
为什么 validate 要先验 Schema?因为执行时 Schema 可能已经变了的场景(比如动态修改),重新校验 Schema 是防御性编程。正常流程下,注册时已校验过 Schema,执行时的 validateSchema 会快速通过。
5.4 值校验:递归匹配
validateValue 递归校验值和 Schema 的匹配:
private static validateValue(value: JsonValue, schema: JsonObject, path: string): Result<boolean, ArkAgentError> {
const typeName = schema.get('type')?.asString() as string
// 类型匹配检查
if (!SchemaValidator.matchesType(value, typeName)) {
return Result.failure<boolean, ArkAgentError>(ArkAgentError.tool(
'tool_argument_type_mismatch', `${path} must be ${typeName}`))
}
// object:检查 required + 递归 properties
if (typeName === 'object') {
const object = value.asObject() as JsonObject
const required = schema.get('required')?.asArray() ?? []
for (let index = 0; index < required.length; index++) {
const name = required[index].asString()
if (name === undefined) {
return Result.failure(...'schema_invalid_required'...)
}
if (!object.has(name)) {
return Result.failure<boolean, ArkAgentError>(ArkAgentError.tool(
'tool_argument_required', `Missing required argument: ${name}`))
}
}
// 递归校验每个 property
const properties = schema.get('properties')?.asObject()
if (properties !== undefined) {
const names = properties.keys()
for (let index = 0; index < names.length; index++) {
const childValue = object.get(names[index])
if (childValue === undefined) continue // 可选字段不存在 → 跳过
const childSchema = properties.get(names[index])?.asObject() as JsonObject
const childResult = SchemaValidator.validateValue(childValue, childSchema, `${path}.${names[index]}`)
if (!childResult.ok) return childResult
}
}
}
// array:用 items schema 校验每个元素
if (typeName === 'array') { ... 递归 items ... }
return Result.success<boolean, ArkAgentError>(true)
}
错误路径带 $ 路径:${path} must be ${typeName},比如 $.recordId must be string 或 $.items[0].name must be string。这让调试时能快速定位是哪个参数的哪个层级出了问题。
integer 的特殊处理:
private static matchesType(value: JsonValue, typeName: string): boolean {
// ...
if (typeName === 'integer') {
const number = value.asNumber()
return value.kind === JsonKind.numberValue && number !== undefined && Number.isInteger(number)
}
// ...
}
integer 不只是"是数字",还必须是"整数"(Number.isInteger)。这防止了模型返回 1.5 作为 index 参数。
六、ToolResult:成功、错误、停止三态
工具执行的结果是 ToolResult,它有三个关键布尔字段:
export class ToolResult {
readonly contents: ContentPart[]
readonly isError: boolean
readonly stopFlag: boolean
readonly metadata?: JsonObject
constructor(contents: ContentPart[], isError: boolean = false,
stopFlag: boolean = false, metadata?: JsonObject) { ... }
}
6.1 三态的含义
|
场景 |
isError |
stopFlag |
含义 |
|---|---|---|---|
|
正常执行 |
false |
false |
结果反馈给模型,Agent 继续循环 |
|
执行失败 |
true |
false |
错误反馈给模型,模型可以换策略 |
|
主动停止 |
false |
true |
结果反馈给模型,Agent 循环终止 |
isError:不要伪装成功。 工具执行失败时(比如记录不存在、权限不足),返回 isError: true,让模型知道"这条路走不通"。如果你伪装成功(返回空数据 + isError: false),模型可能基于空数据继续推理,产生幻觉。
// ✅ 正确:失败时标 isError
if (found === undefined) {
return new ToolResult([ContentPart.text(`unknown record: ${recordId}`)], true)
// isError=true ↑
}
stopFlag:工具主动终止。 有些工具(比如"任务完成了"标记工具)执行后应该让 Agent 停止循环,不再调模型。stopFlag: true 就是这个信号——Runtime 收到后会进入 Turn Completion。
6.2 工具错误的结构化回填
阶段 3 的一个关键决策(报告踩坑记录三)是:工具错误回填为 ToolResult,而不是抛异常终止 Run。
工具执行出错时的两种策略:
策略 A(错误):抛异常 → Run 终止 → 用户看到崩溃
用户:"Agent 怎么崩了?"
策略 B(正确):回填 ToolResult(isError:true) → 模型看到错误 → 模型换策略
模型:"read_baking_records 失败了,我换个方法试试"
用户:"Agent 自己处理了错误,真智能"
但这有个例外——取消错误不回填。如果工具被取消(signal.cancelled),不回填 ToolResult,而是直接终止 Run。因为取消意味着"用户不想要这次结果了",回填反而会让模型在取消后继续"思考"。
七、风险分级与审批流
这是工具体系里最"安全敏感"的部分。不是所有工具都应该自动执行——有些工具(删除数据、发送消息、支付)需要用户确认。
7.1 三档风险等级
export enum ToolRiskLevel {
safe = 'safe',
sensitive = 'sensitive',
dangerous = 'dangerous'
}
|
等级 |
含义 |
默认策略 |
典型工具 |
|---|---|---|---|
|
|
只读、无副作用 |
自动执行 |
查询记录、计算 |
|
|
有副作用但可逆 |
需确认 |
修改配置、发通知 |
|
|
不可逆或高风险 |
需确认 |
删除数据、支付 |
默认 safe,保持向后兼容。但注册敏感或危险工具时必须显式标注:
// 危险工具:清空缓存
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 // ← 显式标注危险
)
}
7.2 审批策略:可注入的 Policy
审批逻辑通过 ToolApprovalPolicy 接口注入,默认实现是 DefaultToolApprovalPolicy:
export interface ToolApprovalPolicy {
decide(call: ToolCall, definition: ToolDefinition | undefined): ApprovalPolicyResult
}
export class DefaultToolApprovalPolicy implements ToolApprovalPolicy {
decide(call: ToolCall, definition: ToolDefinition | undefined): ApprovalPolicyResult {
// 未知工具 → 放行(让 Registry 返回 tool_not_found 结构化错误)
if (definition === undefined) {
return ApprovalPolicyResult.allow('unknown tool → registry structured error')
}
// safe → 自动执行
if (definition.riskLevel === ToolRiskLevel.safe) {
return ApprovalPolicyResult.allow('safe tool')
}
// sensitive → 需确认
if (definition.riskLevel === ToolRiskLevel.sensitive) {
return ApprovalPolicyResult.requireConfirmation(
`Sensitive tool "${call.name}" requires confirmation`)
}
// dangerous → 需确认
return ApprovalPolicyResult.requireConfirmation(
`Dangerous tool "${call.name}" requires confirmation`)
}
}
三种决策(ApprovalAction):
-
allow:放行执行 -
deny:直接拒绝(不执行,模型收到拒绝反馈) -
requireConfirmation:冻结,等待用户确认
7.3 ⚠️ 踩坑三:未知工具不应该触发审批
症状:阶段 5 验收时发现,tool_not_found(模型调了一个不存在的工具)的用例进入了 awaiting_approval 状态——Runtime 冻结了,等待用户审批一个根本不存在的工具。
根因:最初的审批策略把"未知工具"也当成了 requireConfirmation。但未知工具没有执行器,审批它毫无意义——就算用户"批准"了,Registry 还是返回 tool_not_found。
修复:DefaultToolApprovalPolicy 对未知工具返回 allow,让它直接走到 Registry,由 Registry 返回结构化的 tool_not_found 错误。这个错误会回填给模型,模型知道"这个工具不存在"后可以换策略。
// 修复后:未知工具 → allow(交给 Registry 处理)
if (definition === undefined) {
return ApprovalPolicyResult.allow('unknown tool → registry structured error')
}
教训:安全策略必须区分"未注册"和"已注册危险"两种情况。未注册工具不产生副作用(没有执行器),不需要审批;已注册的危险工具才有副作用风险,才需要审批。阶段 5 报告把这条沉淀成了预防规则:"安全策略区分「未注册」与「已注册危险」"。
7.4 PendingApproval:可持久化的审批
审批不是"弹个对话框等用户点"那么简单——用户可能切到后台、杀掉应用、明天再回来。ArkAgent 的审批是可持久化的。
export enum ApprovalDecision {
pending = 'pending',
approved = 'approved',
denied = 'denied'
}
export class PendingApprovalItem {
readonly approvalId: string
readonly toolCall: ToolCall
readonly riskLevel: ToolRiskLevel
readonly reason: string
readonly createdAtMs: number
decision: ApprovalDecision
denyReason?: string
decidedAtMs?: number
// ...
}
export class PendingApprovalBatch {
readonly batchId: string
readonly runId: string
readonly items: PendingApprovalItem[]
readonly modelMessage: AgentMessage // ← 用于恢复时继续
readonly createdAtMs: number
}
关键设计:审批状态进入 AgentState(schemaVersion=2)。 这意味着审批可以跨进程恢复——杀掉应用后重开,未决的审批还在,用户可以继续批准或拒绝。PendingApprovalItem 的注释明确写了:"Never stores callbacks, executors, or secrets."——审批数据是纯可序列化值,不含执行器引用或密钥。
7.5 整 batch 冻结
审批的一个重要设计是整 batch 原子冻结:
模型返回 3 个工具调用:
[A: safe, B: dangerous, C: safe]
审批策略判断:
A → allow
B → requireConfirmation
C → allow
执行策略:
❌ 不会:先执行 A 和 C,等 B 的审批
✅ 而是:整 batch 冻结,一个都不执行,等 B 的审批结果
用户批准 B → 执行 A、B、C
用户拒绝 B → B 回填拒绝结果,A、C 仍不执行(batch 原子)
为什么整 batch 冻结?因为"先执行一部分副作用"是不安全的。如果用户最终拒绝了 B,但 A 和 C 已经执行了(产生了副作用),状态就不一致了。整 batch 冻结保证了"零提前副作用"——要么全执行,要么一个都不执行。
阶段 5 报告记录了这条规则:"batch 任一 defer → 整批冻结;先落盘再 approvalRequired + suspended"。注意"先落盘"——冻结状态先持久化,再发 approvalRequired 事件。这保证了即使发事件后应用崩溃,重启后审批状态仍然在。
八、Hook 干预:beforeToolCall 与 afterToolCall
工具执行的前后,可以通过 Hook 进行干预。Hook 是 ArkAgent 控制体系的核心,AgentHook 基类有 9 个可重写方法,其中两个和工具直接相关。
8.1 beforeToolCall:四种决策
beforeToolCall 在工具执行前调用,可以返回四种决策:
|
决策 |
含义 |
效果 |
|---|---|---|
|
|
放行 |
正常执行 |
|
|
拒绝 |
注入 syntheticResult(伪造一个结果给模型),不执行 |
|
|
延迟 |
配合审批冻结 |
|
|
中止 |
立即终止整个 Run |
典型用途:
class MyHook extends AgentHook {
beforeToolCall(context: ToolCallHookContext): ToolCallHookResult {
// 示例:审计日志
if (context.toolCall.name === 'delete_record') {
console.info(`审计:尝试删除 ${context.toolCall.argumentsJson}`)
}
// 示例:参数改写(比如注入审计 ID)
const rewritten = context.toolCall.withArguments(
JSON.stringify({ ...JSON.parse(context.toolCall.argumentsJson), auditId: 'xxx' }))
return ToolCallHookResult.proceed(rewritten)
// 示例:拒绝执行
// return ToolCallHookResult.deny(syntheticResult)
// 示例:中止整个 Run
// return ToolCallHookResult.abort('安全策略禁止此工具')
}
}
8.2 afterToolCall:改写与停止
afterToolCall 在工具执行后调用,可以改写结果或停止循环:
|
决策 |
含义 |
效果 |
|---|---|---|
|
|
放行 |
结果正常进入历史 |
|
|
停止 |
结果进入历史,但 Agent 循环终止 |
|
|
中止 |
立即终止 Run |
此外,afterToolCall 支持 injectedMessages——在工具结果后追加额外消息(比如系统提醒)。
8.3 串行短路策略
Hook Pipeline 的执行规则(ADR-0011 冻结):
1. 严格按注册顺序执行(不是并行)
2. 改写结果传递给下一个 Hook
3. 第一个短路决策(abort/deny/defer/stop)立即生效,后续 Hook 不执行
4. 工具改写必须保留原始 call id(preserveToolCallId)
为什么串行而不是并行?因为 Hook 之间有顺序依赖——第一个 Hook 改写了参数,第二个 Hook 看到的是改写后的参数。并行执行无法保证这种依赖。
为什么第一个短路立即生效?因为安全决策不能"被覆盖"——如果一个 Hook 决定 abort(安全策略禁止),后续的 Hook 不应该能"取消"这个 abort。安全优先。
工具改写保 id 是一个微妙但重要的规则。模型返回的每个 Tool Call 都有一个唯一 id,Tool Result 必须用相同的 id 回填。如果 Hook 改写了工具参数,id 不能变——否则模型收到的 Result 对不上原来的 Call,会导致历史不一致。
九、循环检测:LoopDetector
Agent 可能陷入死循环——反复调同一个工具、用同样的参数。LoopDetector 负责检测这种情况并终止 Run。
9.1 双策略检测
LoopDetector 有两种检测策略:
策略一:工具签名检测(确定性,默认启用)
签名 = name:canonicalJson
滑动窗口内连续 N 次签名相同 → 判定循环
默认 N = 5
策略二:LLM 诊断(启发式,可选)
无 tool 响应后,超过阈值轮次触发
让 LLM 判断"是不是在循环"
is_loop && confidence > 0.8 → 判定循环
9.2 工具签名:规范化 JSON
签名的核心是"规范化 JSON"——把参数 JSON 的 key 排序后序列化,使得 {a:1,b:2} 和 {b:2,a:1} 产生相同的签名:
static signatureOf(call: ToolCall): string {
const normalized = DefaultLoopDetector.canonicalizeJsonString(call.argumentsJson)
return `${call.name}:${normalized}`
}
private static canonicalizeValue(value: JsonValue): string {
// ... null/boolean/number/string 直接序列化 ...
if (value.kind === JsonKind.objectValue) {
const obj = value.asObject() ?? new JsonObject()
const keys = obj.keys().sort() // ← 关键:key 排序
const parts: string[] = []
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const child = obj.get(key)
if (child === undefined) continue
parts.push(`${JSON.stringify(key)}:${DefaultLoopDetector.canonicalizeValue(child)}`)
}
return `{${parts.join(',')}}`
}
// array 保持顺序(不排序)
}
为什么要规范化?因为模型可能每次返回参数的 key 顺序不同,但语义相同:
第 1 次:get_weather({"city":"北京","unit":"c"}) 签名 = get_weather:{"city":"北京","unit":"c"}
第 2 次:get_weather({"unit":"c","city":"北京"}) 签名 = get_weather:{"city":"北京","unit":"c"} ← 相同!
如果不规范化,这两次会被当成不同签名,循环检测失效。规范化后,它们产生相同的签名,连续 5 次就能被检测出来。
9.3 滑动窗口检测
private checkToolCallLoop(): LoopDetectorResult {
if (this.recentToolCalls.length < this.config.toolLoopThreshold) {
return LoopDetectorResult.notLoop() // 不够 5 次,不判断
}
const first = this.recentToolCalls[0].signature
for (let i = 1; i < this.recentToolCalls.length; i++) {
if (this.recentToolCalls[i].signature !== first) {
return LoopDetectorResult.notLoop() // 有不同的,不是循环
}
}
return LoopDetectorResult.loop(
`Tool call loop detected: same signature ${this.config.toolLoopThreshold} times`,
'tool_signature')
}
滑动窗口(默认长度 5)记录最近的工具调用签名。窗口内所有签名全相同 → 判定循环。
流式去重:同一个 Tool Call 的流式更新不会重复计数。阶段 5 报告记录了这条规则:"同 Tool Call ID 流式更新不重复计数"。因为流式 tool call 的参数是分片到达的,同一个 call.id 会多次更新签名(参数越来越完整),只有最终的完整签名才该被计数。
9.4 LLM 诊断:降级策略
第二层检测是 LLM 诊断——让另一个 LLM 看 conversation history,判断"是不是在循环":
private async checkWithLlm(): Promise<LoopDetectorResult> {
// ...
const systemPrompt =
'You are an AI Loop Diagnosis Expert.\n' +
'Analyze the following conversation history.\n' +
'Determine if the Agent is stuck in a meaningless loop.\n' +
'Return JSON: { "is_loop": true/false, "reason": "...", "confidence": 0.0-1.0 }'
// ...
if (parsed.is_loop && parsed.confidence > this.config.llmConfidenceThreshold) {
return LoopDetectorResult.loop(reason, 'llm')
}
}
LLM 诊断的触发条件较严格(默认 30 轮后才考虑、间隔 10 轮检查一次),且需要 confidence > 0.8 才判定循环。如果 LLM 诊断自身失败(比如返回的不是合法 JSON),降级为"不是循环"——不因为诊断失败而误判。
重要约束:LLM 循环诊断永不进入默认离线 CI——因为它需要真实 API 调用,且结果不确定。它只在真机在线测试中验证(智谱和 DeepSeek 均于 2026-07-12 人工通过)。
9.5 命中循环后
循环检测命中后,Run 以 loop_detected 错误码终止(runtime 层)。这个终态不可被 Hook 跳过——即使 beforePersistState Hook 想跳过持久化,循环检测的状态仍然必须落盘。ADR-0011 明确了这条规则:终态(cancel/complete/error/loop_detected/abort)的持久化是强制的。
十、完整流程:一次工具调用的全链路
把前面的内容串起来,看一次包含审批的工具调用完整流程:
用户:"清空烘焙缓存"
│
▼
[1] 模型返回 tool_calls: clear_baking_cache({"confirm":"yes"})
│
▼
[2] beforeToolCall Hook(串行执行)
│ 审计 Hook → proceed
│ 安全 Hook → proceed(不 abort)
│
▼
[3] 审批策略判断
│ DefaultToolApprovalPolicy.decide()
│ clear_baking_cache.riskLevel === dangerous
│ → requireConfirmation
│
▼
[4] 整 batch 冻结
│ 生成 PendingApprovalBatch
│ 先落盘(AgentState 持久化)
│ 发 approvalRequired 事件
│ Runtime 进入 suspended 状态
│
▼
[5] UI 弹出审批对话框
│ "clear_baking_cache 是危险操作,是否执行?"
│
├── 用户点"批准" ─────────────────────────┐
│ │
├── 用户点"拒绝" ──────────────┐ │
│ │ │
▼ ▼ ▼
[6] 审批决策落盘 deny approve
│ │
▼ ▼
[7a] 拒绝回填 [7b] 执行工具
ToolResult( ToolRegistry.execute()
"用户拒绝了操作", → 关卡1: 已注册 ✓
isError:true → 关卡2: JSON 可解析 ✓
) → 关卡3: Schema 校验 ✓
→ 进入历史 → 关卡4: 未取消 ✓
→ Agent 继续循环 → 执行清空
→ 模型换策略 → ToolResult("已清空")
→ afterToolCall Hook
→ 进入历史
→ Agent 继续循环
整个流程中,审批是可中断的——用户可以切后台、杀进程、明天再回来。PendingApproval 持久化保证了审批状态不丢失。阶段 5 的真机验收场景正是:"确认 → 杀进程 → 重载 → 批准/拒绝"。
十一、最佳实践清单
11.1 工具定义
-
✅
description要清晰——模型据此决定是否调用。 -
✅ 参数用 JSON Schema(
JsonObject)描述,必填字段标required。 -
✅ 危险工具显式标
ToolRiskLevel.dangerous。 -
✅
parameters只用白名单关键字(type/properties/required/items/description)。 -
❌ 不要用
enum/oneOf/pattern等未支持关键字(报schema_unsupported_keyword)。 -
❌ 不要用反射式函数注册(ArkTS 不支持
Function.apply)。
11.2 执行器
-
✅
execute接收JsonObject(已解析已校验),不接收原始字符串。 -
✅ 执行器内部仍应做业务校验(Schema 只校验类型结构,不校验业务逻辑)。
-
✅ 失败时返回
isError: true,不伪装成功。 -
✅ 任务完成时返回
stopFlag: true主动终止循环。 -
✅ 执行器应协作检查
context.signal.cancelled,安全终止长任务。 -
❌ 不要从
ToolContext外部获取上下文(不用全局 current context)。 -
❌ 不要修改
context.state(它是只读的)。
11.3 Schema 校验
-
✅ 注册时校验 Schema 定义(
validateSchema)。 -
✅ 执行时校验参数值(
validate)。 -
✅ 不信任模型生成的参数,全部过 Schema。
-
✅ 需要
enum/pattern等高级校验时,在执行器里自己做。 -
❌ 不要静默忽略未知的 Schema 关键字。
11.4 风险与审批
-
✅ 只读工具标
safe,有副作用的标sensitive或dangerous。 -
✅ 审批状态可持久化(PendingApproval 进入 AgentState)。
-
✅ 整 batch 冻结——一个需确认,全 batch 等待。
-
✅ 先落盘再发 approvalRequired 事件。
-
✅ UI 处理
approvalRequired事件,弹出审批入口。 -
✅ 未知工具不触发审批(交给 Registry 返回
tool_not_found)。 -
❌ 不要"先执行一部分副作用,等其余审批"。
11.5 Hook
-
✅ Hook 用显式 class 实现(不用对象字面量)。
-
✅ 安全决策放
beforeToolCall,审计放afterToolCall。 -
✅ 工具改写时保留原始 call id。
-
✅ 短路决策(abort/deny)立即生效。
-
❌ 不要在
onNext内启动不 await 的业务 Hook(竞态风险)。
11.6 循环检测
-
✅ 工具签名用规范化 JSON(key 排序)。
-
✅ 流式更新不重复计数。
-
✅ LLM 诊断有 confidence 阈值(默认 0.8)。
-
✅ LLM 诊断失败时降级(不误判)。
-
✅ LLM 诊断不进默认 CI(需在线验证)。
十二、常见错误对照表
|
错误做法 |
问题 |
正确做法 |
|---|---|---|
|
反射式注册任意函数 |
ArkTS 无 |
|
|
执行器接收原始 JSON 字符串 |
每个执行器自己 parse,重复且不安全 |
Registry 统一 parse 后传 |
|
不校验模型生成的参数 |
类型错误导致执行器崩溃 |
|
|
Schema 用了 |
报 |
在执行器里自己做 enum 校验 |
|
静默忽略不支持的 Schema 关键字 |
虚假安全感,开发者以为有校验 |
报错,让开发者知道不支持 |
|
工具失败返回假成功 |
模型基于假数据继续推理 |
返回 |
|
危险工具不标 riskLevel |
自动执行危险操作 |
标 |
|
未知工具触发审批 |
审批不存在的工具毫无意义 |
未知工具 |
|
先执行部分副作用再等审批 |
拒绝后状态不一致 |
整 batch 冻结,零提前副作用 |
|
审批状态不持久化 |
杀进程后审批丢失 |
PendingApproval 进入 AgentState |
|
用对象字面量实现 Hook |
|
显式 class extends AgentHook |
|
Hook 改写工具时改了 call id |
Tool Result 对不上 Call |
保留原始 call id |
|
循环检测不规范化 JSON |
key 顺序不同导致漏检 |
|
|
流式 tool call 重复计数 |
参数分片到达,签名多次更新 |
同 id 更新不重复计数 |
|
LLM 诊断进默认 CI |
需要真实 API,结果不确定 |
只在真机在线测试验证 |
|
全局 current context |
不可测试、不安全、不可追溯 |
显式 |
|
工具修改 AgentState |
状态不一致 |
|
|
取消后仍回填 ToolResult |
取消的 Run 继续"思考" |
取消错误直接终止,不回填 |
十三、验证清单(真机 Review)
工具定义与注册
-
正常工具注册成功
-
空名字报
tool_name_empty -
重复名字报
tool_name_duplicate -
非法 Schema 报
schema_*错误
参数校验
-
类型不匹配报
tool_argument_type_mismatch -
缺失必填报
tool_argument_required -
integer 拒绝浮点数
-
残缺 JSON 报 decode 错误
-
未支持关键字报
schema_unsupported_keyword
执行与错误
-
正常执行返回结果
-
执行器抛异常转
tool_execution_failed -
isError: true正确回填给模型 -
stopFlag: true终止循环 -
取消信号终止执行
风险与审批
-
safe 工具自动执行
-
dangerous 工具触发
approvalRequired -
整 batch 冻结(零提前副作用)
-
杀进程后审批状态恢复
-
未知工具不触发审批
-
批准后继续执行
-
拒绝后回填拒绝结果
循环检测
-
连续 5 次相同签名判定循环
-
不同参数不误判
-
key 顺序不同但参数相同判定为相同签名
-
流式更新不重复计数
-
loop_detected终态强制持久化
Hook
-
beforeToolCall 串行执行
-
abort 短路生效
-
工具改写保留 call id
-
afterToolCall 注入消息追加
十四、构建验证
# 构建 agent_core HAR
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 assembleHar --no-daemon
# 运行工具相关单元测试
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 test --no-daemon
构建结果:
CompileArkTS passed
BUILD SUCCESSFUL
工具体系的测试覆盖:ToolRuntime.test.ets(注册/执行/校验)、ControlSafety.test.ets(审批/风险/stopFlag)、LoopDetector.test.ets(签名检测/LLM 诊断)、HookPipeline.test.ets(beforeToolCall/afterToolCall 短路)。审批的跨进程恢复在真机验收场景"确认 → 杀进程 → 重载 → 批准/拒绝"中验证。
十五、写在最后
工具调用是 Agent 从"会说"到"会做"的关键能力。但在鸿蒙上做好工具调用,要处理从定义、注册、校验、审批、执行、错误回填到循环检测的完整链路。每个环节都有自己的陷阱:ArkTS 不支持反射、模型参数不可信、危险工具需要审批、审批要可持久化、循环要能检测、错误要能回填。
ArkAgent 的工具体系把这些环节收敛成一条清晰的链路:
模型返回 Tool Call
↓
beforeToolCall Hook(串行,可改写/拒绝/中止)
↓
审批策略(safe → 放行;dangerous → 冻结等确认)
↓
ToolRegistry.execute 四道关卡(已注册 → JSON 可解析 → Schema 校验 → 未取消)
↓
执行器执行(接收已校验 JsonObject + 只读 ToolContext)
↓
afterToolCall Hook(可改写/停止/追加消息)
↓
ToolResult 回填历史(isError 不伪装,stopFlag 可终止)
↓
循环检测(签名规范化,5 次相同 → 终止)
记住这几条口诀:
工具定义两半分,Definition 给模型,Executor 自己跑。 对象参数不反射,ArkTS 没有 Function.apply。 ToolContext 显式传,state 只读 services 白名单。 注册三步校验早,执行四关不能少。 Schema 白名单五个词,不支持的要报错。 模型参数不可信,类型校验必须过。 工具失败不伪装,isError 要标 true。 危险工具标 riskLevel,审批冻结整 batch。 未知工具不审批,交给 Registry 报错。 审批状态可持久化,杀进程后不丢失。 Hook 串行短路快,安全决策第一个。 工具改写保 call id,Result 配对不能错。 循环检测规范化,key 排序后比签名。 五次相同就终止,loop_detected 必落盘。
本文是 ArkAgent 鸿蒙教程系列的第六篇。前五篇讲了架构全景、快速接入、流式输出、JSON 类型安全,这篇拆解了 Agent "做事"的核心——工具调用体系。后续文章会逐层深入状态持久化、控制安全、记忆子 Agent、评估框架——每一层都建立在本文的工具契约之上。
如果你正在鸿蒙上做需要工具调用的 AI 应用,可以把这套"定义 → 注册 → 校验 → 审批 → 执行 → 回填 → 检测"的全链路直接作为你的工具体系基线。不要从"模型返回什么就执行什么"开始——那是安全事故的开始。
更多推荐




所有评论(0)