头图

前两篇把云端大模型接进了 HarmonyOS 7(API 26),也搭好了 Agent 脚手架和对话 UI。但只要把这套东西拿去做点真实业务,问题立刻暴露:用户问"我的订单到哪了",模型张口就是一句"您的订单正在派送中,请耐心等待"——听着像模像样,其实一个字的真数据都没有。模型根本不知道订单状态,它只是在按语言概率编一个"像答案的答案"。

这就是大模型落地业务系统时最典型的困境:会说话,但不掌握事实。本篇用 Tool Calling 解决它:把"查订单"“查库存"注册成工具交给模型,模型判断需要时返回一次"调用请求”,端侧执行真实查询、把结果回传,模型再基于结果组织最终回答。整套闭环(ToolRegistry + ToolAgent)在一个可开箱即跑的 DevEco 工程里,纯逻辑全部配了不联网就能跑的 Hypium 单测。

一、Tool Calling 到底解决了什么问题

先看没有工具时,想让 AI 答对订单状态只有两条歪路:

  1. 把数据塞进提示词:每轮对话前先查好数据拼进上下文。用户没问订单也得查,token 费用白白涨;问的内容一多,提示词迅速膨胀到失控。
  2. 硬编码意图识别:自己写 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;
}

三个设计点:

  1. maxRounds 上限必须有。模型偶尔会陷入"调工具→再调工具"的循环,没有上限,一轮对话能把上下文窗口和 token 费用一起打爆。达到上限就返回固定兜底话术,落库收口。
  2. 助手消息和工具结果都要落库。模型要求调工具的那条消息(含 tool_calls)必须原样进历史,紧跟着每条 role=tool 的结果消息——这是协议要求,跳过任何一半,下一轮请求就会被网关拒绝。
  3. 每轮都重新带 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 条断言,全部不发起网络请求。

七、跑起来:三步

  1. DevEco Studio 26 打开工程,右键 ohosTest → Run,确认 27 条断言全绿(也可先 node verify-node.cjs 在命令行验证纯逻辑)。
  2. 打开 pages/Index.ets,把 buildAgent() 里的 baseUrl / apiKey / model 换成自己的网关配置;确认你的云端模型支持 OpenAI 风格的 tools / tool_calls 字段(盘古及主流兼容网关均已支持)。
  3. 连真机或模拟器运行,输入"我的订单 20261010 到哪了?“或"SKU001 还有货吗”,观察紫色工具提示条里的调用与结果。实际对话表现以真机实测为准。

以下是 V 哥真机测试效果图:
在这里插入图片描述

八、五个高频坑(接 Tool Calling 前先看)

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

避坑清单

参考与出处

  • 上篇《HarmonyOS 7 Agent 开发实战 02:Agent 工程脚手架与对话 UI 骨架》(本工程在其脚手架上扩展)
  • OpenAI Function Calling 规范(tools / tool_calls / role=tool 消息格式,盘古等兼容网关同构)
  • 华为开发者官网 · NetworkKit(@ohos.net.http)、应用测试框架 Hypium

最后一句:Tool Calling 不是让模型变得更聪明,而是教会它承认"我不知道"——把不知道交给工具去查,把知道留给回答去说,需要完整测试源码的兄弟私我。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐