【共创稿事节】HarmonyOS 7 Agent 开发实战 03:Tool Calling 让 AI 查实时订单与库存

前两篇把云端大模型接进了 HarmonyOS 7(API 26),也搭好了 Agent 脚手架和对话 UI。但只要把这套东西拿去做点真实业务,问题立刻暴露:用户问"我的订单到哪了",模型张口就是一句"您的订单正在派送中,请耐心等待"——听着像模像样,其实一个字的真数据都没有。模型根本不知道订单状态,它只是在按语言概率编一个"像答案的答案"。
这就是大模型落地业务系统时最典型的困境:会说话,但不掌握事实。本篇用 Tool Calling 解决它:把"查订单"“查库存"注册成工具交给模型,模型判断需要时返回一次"调用请求”,端侧执行真实查询、把结果回传,模型再基于结果组织最终回答。整套闭环(ToolRegistry + ToolAgent)在一个可开箱即跑的 DevEco 工程里,纯逻辑全部配了不联网就能跑的 Hypium 单测。
一、Tool Calling 到底解决了什么问题
先看没有工具时,想让 AI 答对订单状态只有两条歪路:
- 把数据塞进提示词:每轮对话前先查好数据拼进上下文。用户没问订单也得查,token 费用白白涨;问的内容一多,提示词迅速膨胀到失控。
- 硬编码意图识别:自己写 if-else 判断"这句话是不是查订单"。换个说法就漏,维护成本指数上升。
Tool Calling 把"什么时候要数据"的决策权交还给模型:请求时随消息带上一份工具清单(名称 + 描述 + 参数 schema),模型自己判断该不该调、调哪个、参数填什么。它返回的不是最终答案,而是一个结构化的 tool_calls;端侧执行完,把结果以 role=tool 消息回传,模型基于真实数据给最终回答。
整个过程是"要工具 → 本地执行 → 回传 → 再问"的闭环。一句话总结:模型负责决策与表达,端侧负责事实与执行——各干各的专业的事。

二、把工具交给模型:ToolDefinition 与 ToolRegistry
每个工具是一对"元信息 + 执行逻辑":元信息(名称、描述、JSON Schema 参数说明)喂给模型做决策,handler 是端侧真实执行的代码。把两者打包成 ToolDefinition,集中登记进 ToolRegistry:
// feature/agent/ToolRegistry.ets(节选)
export class ToolDefinition {
readonly name: string;
readonly description: string;
readonly parameters: Object; // JSON Schema,喂给模型
readonly handler: (args: Object) => string; // 本地执行,返回字符串结果
constructor(name: string, description: string,
parameters: Object, handler: (args: Object) => string) {
this.name = name;
this.description = description;
this.parameters = parameters;
this.handler = handler;
}
toSchema(): ToolSchema {
return { type: 'function', function: {
name: this.name, description: this.description, parameters: this.parameters } };
}
}
export class ToolRegistry {
private tools: Map<string, ToolDefinition> = new Map();
register(tool: ToolDefinition): void { this.tools.set(tool.name, tool); }
schemas(): ToolSchema[] { // 喂给模型的工具清单
const out: ToolSchema[] = [];
this.tools.forEach((t) => out.push(t.toSchema()));
return out;
}
// 找不到工具或执行抛错,都兜底成 JSON 错误串
execute(name: string, args: Object): string {
const tool = this.tools.get(name);
if (tool === undefined) {
return JSON.stringify({ error: '未知工具: ' + name });
}
try {
return tool.handler(args);
} catch (e) {
return JSON.stringify({ error: '工具执行失败: ' + (e as Error).message });
}
}
}
execute 的兜底值得单独说:模型可能调一个没注册的工具名,handler 也可能抛异常(后端超时、参数类型不对)。这两种情况都不该让整条链路崩掉——把错误信息序列化成字符串回传给模型,它大多数时候会修正参数再调一次。把错误也当成一种"查询结果",是 Tool Calling 稳定性的关键设计。
演示工程里注册了两个工具,handler 用本地 mock 数据(真实环境换成 @ohos.net.http 调你们后端接口即可,编排零改动):
// pages/Index.ets(节选)
registry.register(new ToolDefinition(
'query_order',
'查询订单状态与物流信息。参数 order_id:订单号字符串。',
{ type: 'object',
properties: { order_id: { type: 'string', description: '订单号' } },
required: ['order_id'] },
(args: Object): string => {
const id = (args as Record<string, Object>)['order_id'] as string;
const db: Record<string, Object> = {
'20261010': { status: '已发货', logistics: '顺丰 SF123456', eta: '2026-10-12' }
};
return db[id] !== undefined ? JSON.stringify(db[id])
: JSON.stringify({ error: '订单不存在: ' + id });
}
));
三、闭环编排:ToolAgent 的"要工具 → 执行 → 回传 → 再问"
有了注册表,编排层就是把这个闭环跑起来。ToolAgent.chat() 一次用户输入可能对应多轮模型调用:
// feature/agent/ToolAgent.ets(节选)
async chat(userText: string): Promise<string> {
this.store.addUser(userText);
let rounds = 0;
while (rounds < this.maxRounds) {
const req: ChatRequest = {
model: this.model,
messages: this.store.getMessages(),
temperature: this.temperature,
tools: this.registry.schemas() // 每轮都带全量工具清单
};
const resp: ChatResponse = await this.client.chat(req);
// 模型不再要工具 → 这就是最终答案
if (resp.toolCalls === undefined || resp.toolCalls.length === 0) {
this.store.addAssistant(resp.text);
return resp.text;
}
// 记录这条"带工具调用"的助手消息,然后逐个执行
this.store.addAssistantTools(resp.text, resp.toolCalls);
for (const tc of resp.toolCalls) {
const result = this.registry.execute(tc.name, tc.parsedArgs);
this.store.addToolResult(tc.id, tc.name, result);
}
rounds++;
}
// 兜底:多轮仍未收口,避免无限循环打爆上下文和费用
const fallback = '已多次调用工具仍未得到结论,请稍后重试或联系人工客服。';
this.store.addAssistant(fallback);
return fallback;
}
三个设计点:
- maxRounds 上限必须有。模型偶尔会陷入"调工具→再调工具"的循环,没有上限,一轮对话能把上下文窗口和 token 费用一起打爆。达到上限就返回固定兜底话术,落库收口。
- 助手消息和工具结果都要落库。模型要求调工具的那条消息(含
tool_calls)必须原样进历史,紧跟着每条role=tool的结果消息——这是协议要求,跳过任何一半,下一轮请求就会被网关拒绝。 - 每轮都重新带 tools 清单。工具注册表是动态的,回传历史时清单跟着最新状态走。
四、底座扩展:请求带 tools,响应解析 tool_calls
上一篇的 LlmClient 抽象不用动,只需要两处小扩展。请求侧,buildPayload 在有工具时追加 tools 字段(顺手把温度压低,工具调用要的是稳定不是创意):
// llmclient/PanguClient.ets / OpenAiClient.ets(节选)
if (req.tools !== undefined && req.tools.length > 0) {
payload['tools'] = req.tools;
payload['temperature'] = req.temperature ?? 0.2; // 工具场景建议压低
}
响应侧,ResponseParser 增加对 message.tool_calls 的解析:取 id、工具名、参数 JSON 串,参数尽量 JSON.parse 成对象,解析失败兜底为 {}(交给 Registry 的错误兜底去处理):
// llmclient/ResponseParser.ets(节选)
static extractToolCallsFromMessage(message): ToolCall[] {
const rawCalls = message['tool_calls'];
if (rawCalls === undefined || !Array.isArray(rawCalls)) return [];
const result: ToolCall[] = [];
for (const item of rawCalls as Array<Object>) {
const call = item as Record<string, Object>;
const fn = call['function'] as Record<string, Object> | undefined;
if (fn === undefined) continue;
const argsRaw = (fn['arguments'] as string) ?? '{}';
let parsedArgs: Object = {};
try { parsedArgs = JSON.parse(argsRaw); } catch (e) { parsedArgs = {}; }
result.push({ id: (call['id'] as string) ?? '', type: 'function',
name: (fn['name'] as string) ?? '', arguments: argsRaw,
parsedArgs });
}
return result;
}
解析出的每个 ToolCall 带 id——它就是回传时的"回执编号"。
五、对话记忆扩展:role=tool 消息落库
ConversationStore 加两个方法,把工具往返原样存进历史:
// feature/agent/ConversationStore.ets(节选)
// 模型这条"带工具调用"的助手消息(不裁剪,保证与工具结果紧挨着)
addAssistantTools(text: string, toolCalls: ToolCall[]): void {
this.messages.push({ role: Role.ASSISTANT, content: text, toolCalls });
}
// 一次工具执行结果:role=tool,回引用 toolCallId
addToolResult(toolCallId: string, name: string, content: string): void {
this.messages.push({ role: Role.TOOL, content, toolCallId, name });
}
注意 addAssistantTools 和 addToolResult 都不触发裁剪:助手消息和它对应的工具结果一旦被裁开,发给模型的序列就不完整了。Role 枚举相应增加 TOOL = 'tool'。
六、页面串联与单测
演示页把注册表、ToolAgent、UI 串起来。每轮对话结束后从记忆容器同步消息列表,工具结果以中立提示样式呈现——用户能直接看到"AI 调了什么、返回了什么",这比纯文字答案更能建立信任:
// pages/Index.ets(节选)
private async handleSend(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) return;
this.inputText = '';
this.sending = true;
try {
await this.agent.chat(text); // 闭环可能多轮,全部跑完再刷新
this.syncFromStore(); // 从记忆容器同步(含工具消息)
} catch (e) {
// 追加错误气泡……
} finally {
this.messages = [...this.messages];
this.sending = false;
}
}
单测方面,工具循环用一个 MockToolLlmClient 按序返回"工具调用响应 → 最终答案响应",验证整条链路:解析出 tool_calls → Registry 调度 → role=tool 结果带 tool_call_id 落库 → 第二轮请求把工具结果带回给模型 → 返回最终答案。另有一条反向用例:注册表里故意不注册被调用的工具,验证链路不崩、错误串落库后仍能收口;还有一条验证 maxRounds 兜底。加上解析非法参数 JSON 兜底 {}、handler 抛错兜底等用例,工程共 27 条断言,全部不发起网络请求。
七、跑起来:三步
- DevEco Studio 26 打开工程,右键
ohosTest → Run,确认 27 条断言全绿(也可先node verify-node.cjs在命令行验证纯逻辑)。 - 打开
pages/Index.ets,把buildAgent()里的baseUrl/apiKey/model换成自己的网关配置;确认你的云端模型支持 OpenAI 风格的tools/tool_calls字段(盘古及主流兼容网关均已支持)。 - 连真机或模拟器运行,输入"我的订单 20261010 到哪了?“或"SKU001 还有货吗”,观察紫色工具提示条里的调用与结果。实际对话表现以真机实测为准。
以下是 V 哥真机测试效果图:

八、五个高频坑(接 Tool Calling 前先看)
- 工具描述写得含糊,模型乱填参数:描述里写清每个参数的名称、类型、格式,最好给示例值;描述是模型的唯一依据。
- 带 tools 时温度太高:工具调用要的是稳定可复现,建议
temperature ≤ 0.2。 - 工具不存在/执行抛错直接崩链路:Registry 统一兜底成 JSON 错误串回传,让模型自我修正。
- tool 结果没按
tool_call_id回传:每条role=tool消息必须带回引用 id,否则模型无法对齐是哪次调用的结果。 - 没有 maxRounds 上限:模型可能反复调工具不收口,设上限兜底,保住上下文和费用。

参考与出处
- 上篇《HarmonyOS 7 Agent 开发实战 02:Agent 工程脚手架与对话 UI 骨架》(本工程在其脚手架上扩展)
- OpenAI Function Calling 规范(tools / tool_calls / role=tool 消息格式,盘古等兼容网关同构)
- 华为开发者官网 · NetworkKit(@ohos.net.http)、应用测试框架 Hypium
最后一句:Tool Calling 不是让模型变得更聪明,而是教会它承认"我不知道"——把不知道交给工具去查,把知道留给回答去说,需要完整测试源码的兄弟私我。
更多推荐




所有评论(0)