AI创客项目开发教程 –AID101-1-3-05 – 并行多工具调用(parallel tool calls)实例

导航栏:首页 / AI教程目录 / AI创客项目开发教程目录 / 第1篇 基础知识篇 / AID101-1-3-05 – 并行多工具调用(parallel tool calls)实例

本节内容简介

在前几节的课程中,我们已经掌握了 Tool Calls 的基本原理、Follow-up 机制的实现方法。细心的朋友可能已经发现:前面几节课程中的示例程序都只处理单个 Tool Call——要么打开 LED,要么调节亮度,每次只执行一个操作。

但在实际的智能家居场景中,用户常常会说一些组合指令,例如:

  • “请打开客厅的灯,并把亮度调到最亮”
  • “关闭所有设备”
  • “打开空调并设置温度为26度”

这些指令包含多个动作,需要 ESP32 一次性执行多个工具调用。本节课程将讲解如何让 ESP32 处理 AI 平台在一次响应中返回的多个 Tool Calls,并依次执行所有操作,最后通过 Follow-up 获取整合后的自然语言反馈。

请注意:不同人工智能大模型的并行工具调用能力有所不同,有些人工智能大模型可以很好的一次性处理多个工具调用,但是有些人工智能大模型则不具备一次性处理多个工具调用的能力,甚至有些人工智能大模型完全不具备工具调用的能力。因此,您在实际操作中,需要留意使用的大模型是否具备一次性处理多个工具调用的能力

什么是多 Tool Calls?

核心概念

多 Tool Calls 是指 AI 平台在一次响应中返回多个工具调用指令,ESP32 需要依次解析并执行每一个指令。

ai-multiple-tool-calls-flow-chart

为什么需要多 Tool Calls?

场景单 Tool Call 处理多 Tool Calls 处理
“打开LED并调到最亮”只能打开LED或只能调亮度先打开LED,再调到最亮
“关闭所有灯和风扇”只能关闭一种设备依次关闭灯和风扇
“把客厅灯打开,卧室灯关闭”只能控制一个灯同时控制客厅和卧室灯

多 Tool Calls 让 AI 助手能够处理更复杂、更自然的用户指令,不再局限于单一操作。

🔁 多 Tool Calls 完整流程

整个流程分为五个步骤:

  1. 第一次请求:ESP32 发送用户指令 + 可用工具列表
  2. AI 返回多个 Tool Calls:AI 返回包含多个 Tool Calls 的响应信息
  3. 依次执行多个 Tool Calls:ESP32 解析 AI 返回的多个工具调用,依次执行所有操作
  4. 第二次请求(Follow-up):ESP32 将所有 Tool Calls 的执行结果一起发送给 AI
  5. 获取整合的自然语言反馈:AI 根据所有执行结果生成一段整合后的人话回复

⚠️ 关键理解:与上一节不同,本节的第二次请求中,messages 数组里会包含多个 role: "tool" 消息(每个 Tool Call 对应一个),以及一个包含多个 tool_calls 的 role: "assistant" 消息

在下面的内容中,我们将详细讲解这五个阶段的详细内容。

第一阶段:第一次请求(与上一节相同)

第一次请求与上一节课程的 tool_calls_follow_up_1.ino 一致。ESP32 向 AI 平台发送用户指令和工具定义,AI 分析后返回需要调用的函数和参数。

请求 JSON

{
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手,可以控制LED的开关和亮度。"
    },
    {
      "role": "user",
      "content": "请打开LED并将亮度调节到最大"
    }
  ],
  "tools": [
    { /* controlLEDOnOFF 工具定义 */ },
    { /* controlLEDBrightness 工具定义 */ }
  ],
  "tool_choice": "auto",
  "temperature": 0.1
}

与上一节的关键区别:用户指令变成了组合指令 "请打开LED并将亮度调节到最大",这句话同时包含”打开”和”调节亮度”两个动作。

第二阶段:AI 返回多个 Tool Calls

当 AI 收到组合指令后,会分析出需要调用多个工具来完成用户的请求。

多 Tool Calls 响应示例

{
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "message": {
        "content": "",
        "role": "assistant",
        "tool_calls": [
          {
            "function": {
              "arguments": "{\"action\": \"on\"}",
              "name": "controlLEDOnOFF"
            },
            "id": "call_abaee57e921a4021829447",
            "index": 0,
            "type": "function"
          },
          {
            "function": {
              "arguments": "{\"level\": 5}",
              "name": "controlLEDBrightness"
            },
            "id": "call_f21c48f98191408f8e5854",
            "index": 1,
            "type": "function"
          }
        ]
      }
    }
  ]
}

响应解析

与单 Tool Call 相比,多 Tool Calls 的响应有以下特点:

字段单 Tool Call多 Tool Calls
tool_calls 数组长度12 或更多
每个元素的 index00, 1, 2… 依次递增
每个元素的 id一个唯一 ID每个 Tool Call 有独立的唯一 ID

关键点

  • tool_calls 是一个数组,可以包含任意数量的工具调用
  • 每个工具调用都有独立的 id,后续 Follow-up 时需要一一对应
  • 工具调用会按照 index 顺序排列,ESP32 应该按顺序依次执行

第三阶段:ESP32 依次执行多个 Tool Calls

ESP32 收到包含多个 Tool Calls 的响应后,需要遍历 tool_calls 数组,依次执行每一个工具调用。

执行流程

检测到 2 个工具调用

[工具调用 1/2]
函数名: controlLEDOnOFF
Tool Call ID: call_abaee57e921a4021829447
参数: {"action": "on"}
>>> 执行Tool Call 1...
执行动作: on
控制LED开关: on
 - LED已打开

[工具调用 2/2]
函数名: controlLEDBrightness
Tool Call ID: call_f21c48f98191408f8e5854
参数: {"level": 5}
>>> 执行Tool Call 2...
执行亮度调节: 5
控制LED亮度: 级别 5
 - 亮度已设置为最亮级别

>>> 所有Tool Calls执行完成

为什么需要按顺序执行?

在这个例子中,如果先执行亮度调节再打开 LED,可能会出现逻辑问题:

  1. 先调亮度controlLEDBrightness(5) 发现 LED 是关闭的,会自动打开 LED 并设置亮度
  2. 再开灯controlLEDOnOFF("on") 再次打开 LED(虽然已经是打开状态)

虽然这个例子中顺序影响不大,但在更复杂的场景中(如”先关闭旧设备,再打开新设备”),执行顺序至关重要。因此 ESP32 应该严格按照 AI 返回的 index 顺序执行。

第四阶段:构造第二次请求(多 Tool Calls Follow-up)⭐

这是本节课程的核心内容。与上一节的单 Tool Call Follow-up 相比,多 Tool Calls 的第二次请求有以下变化:

第二次请求 JSON 示例

{
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手,可以控制LED的开关和亮度。请根据Tool Calls的执行结果,提供友好的自然语言反馈。"
    },
    {
      "role": "user",
      "content": "请打开LED并将亮度调节到最大"
    },
    {
      "role": "assistant",
      "tool_calls": [
        {
          "id": "call_abaee57e921a4021829447",
          "type": "function",
          "function": {
            "name": "controlLEDOnOFF",
            "arguments": "{\"action\": \"on\"}"
          }
        },
        {
          "id": "call_f21c48f98191408f8e5854",
          "type": "function",
          "function": {
            "name": "controlLEDBrightness",
            "arguments": "{\"level\": 5}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_abaee57e921a4021829447",
      "content": "LED已成功打开"
    },
    {
      "role": "tool",
      "tool_call_id": "call_f21c48f98191408f8e5854",
      "content": "LED亮度已成功调节到最亮级别"
    }
  ],
  "temperature": 0.1
}

与单 Tool Call Follow-up 的关键区别

部分单 Tool Call多 Tool Calls
assistant 的 tool_calls包含 1 个对象包含多个对象
tool 消息数量1 条多条(每条对应一个 Tool Call)
tool_call_id一个 ID多个不同的 ID,必须与 assistant.tool_calls 一一对应

messages 数组的结构

第二次请求的 messages 数组包含多条消息:

顺序role内容
1system设定 AI 角色
2user用户原始指令
3assistantAI 的第一次响应,包含所有 Tool Calls
4tool第一个 Tool Call 的执行结果
5tool第二个 Tool Call 的执行结果
tool更多 Tool Call 的执行结果…

💡 为什么 tool 消息要紧跟在 assistant 消息后面? 因为 tool 消息是对 assistant 消息中 tool_calls 的回应。AI 平台需要看到完整的上下文:”AI 要求做什么 → ESP32 执行的结果是什么”。

第五阶段:解析 AI 的整合自然语言反馈

AI 收到包含多个执行结果的第二次请求后,会分析所有信息,生成一段整合后的自然语言回复。

第二次响应示例

{
  "choices": [
    {
      "finish_reason": "stop",
      "message": {
        "role": "assistant",
        "content": "已为您打开LED,并将亮度调节到最大级别,现在灯光最亮!"
      }
    }
  ]
}

注意: AI 的回复将两个操作的结果整合成一句话,而不是分别回复”LED已打开”和”亮度已调到最大”。这就是多 Tool Calls Follow-up 的价值——AI 能够理解完整的操作上下文,给出更自然、更人性化的反馈。

代码解析:关键变化点

本节课程的示例程序是在上一节课示例程序基础上修改而来,主要改动是将单 Tool Call 处理扩展为多 Tool Calls 处理

注意

本程序需要配合my_info.h代码使用,否则程序将无法正常编译。
请点击以下链接前往该代码页面,复制下载该代码:

http://ai.taichi-maker.com/index.php/homepage/ai-tutorial-index/ai-maker-project-tutorial-index/my_info_h-code-description/

/* 
 * ESP32 AI平台调用 Follow-up Tool Calls 示例 - 多工具调用版
 * 
 * 功能描述:
 * 本程序专门为ESP32-S3-DevKitC-1开发板设计,演示如何实现LLM模型的多Tool Calls及Follow-up功能。
 * 程序支持AI平台在一次响应中调用多个工具(如同时打开LED并调节亮度),
 * ESP32会依次执行所有工具调用,然后将所有执行结果通过Follow-up请求发送给LLM,
 * 获取整合后的自然语言反馈。
 * 
 * Follow-up Tool Calls流程:
 * 1. 第一次请求:发送用户指令,获取Tool Calls响应(可能包含多个工具调用)
 * 2. 执行Tool Calls:依次控制LED的开关或亮度
 * 3. 第二次请求:将所有Tool Calls响应和执行结果发送给LLM
 * 4. 获取自然语言反馈:LLM根据所有执行结果生成自然语言回复
 * 
 * 本程序支持的指令类型:
 * - 打开/关闭LED(如"打开LED"、"关闭LED")
 * - 调节LED亮度(如"把LED调到亮度最大"、"将LED亮度调到中等")
 * - 组合指令(如"请打开LED并将亮度调节到最大")
 * 
 * 作者:Taichi-Maker
 * 作者官网:http://ai.taichi-maker.com
 * 创建日期:2026年06月27日
 * 版本:1.1.3
 * 
 * 硬件要求:
 * - ESP32-S3-DevKitC-1开发板(内置一颗WS2812 LED,数据引脚连接到GPIO 48)
 * 
 * 所需软件库:
 * - FastLED库
 * - ArduinoJson库(版本7.0.0或更高)--用于处理JSON数据
 * 
 * 配置说明:
 * 1. 请先通过Arduino IDE的库管理器安装FastLED、ArduinoJson库。
 * 2. 在my_info.h文件中填写您的Wi-Fi名称(ssid)和密码(password)。
 * 3. 在my_info.h文件中将ai_api_key替换为您从AI平台获取的有效API密钥(切勿泄露!)。
 * 4. 确保开发板已正确连接到电脑,并选择正确的开发板型号与端口。
 * 
 * 使用方法:
 * 1. 将本程序上传至ESP32-S3-DevKitC-1开发板。
 * 2. 打开串口监视器(波特率设置为115200)。
 * 3. 程序会自动连接Wi-Fi并发送用户指令给AI平台。
 * 4. 观察第一次Tool Calls响应(可能包含多个工具调用)和LED控制执行过程。
 * 5. 观察第二次请求和LLM生成的自然语言反馈。
 * 
 * 注意事项:
 * - 本例使用client.setInsecure()跳过了SSL证书验证,仅适用于测试环境;
 *   在生产环境中应使用有效证书以确保通信安全。
 * - 亮度级别为1-5级,其中1为最暗,5为最亮。
 * 
 * 兼容性说明:
 * 本程序专为ESP32-S3-DevKitC-1开发板设计,已在ESP32-S3-DevKitC-1开发板上测试通过。
 * 
 * 许可证:MIT License
 * 
 * 程序源:
 * 本程序源自太极创客团队精心开发的《AI创客项目开发教程》。该教程专为热爱科技创新、热衷于动手实践
 * 的创客爱好者与初学者量身打造,是一套完全免费、开源且注重实战的AIoT(人工智能物联网)入门学习资源。
 * 
 * 通过本教程,您可以系统地掌握从项目构思、方案设计、软硬件选型,到实际搭建、系统集成与调试优化的
 * 整个开发流程。
 * 您可以通过以下链接获得更多关于本教程的详细信息:
 * http://ai.taichi-maker.com/index.php/homepage/ai-tutorial/
 */

#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include "my_info.h"

// 引入FastLED库  
#include <FastLED.h>

// LED配置 - ESP32-S3-DevKitC-1开发板内置WS2812 LED连接到GPIO 48
#define DATA_PIN 48
#define NUM_LEDS 1

// FastLED LED数组    
CRGB leds[NUM_LEDS];

/* 
 * -------------- 用户指令配置 --------------
 * 
 * 在此处修改用户指令,整个程序将使用这个指令进行第一次和第二次LLM调用
 * 
 * 示例指令:
 * - "请打开LED并将亮度调节到最大"
 * - "打开LED"
 * - "关闭LED"
 * - "把LED调到中等亮度"
 * - "将LED亮度调到最暗"
 * 
 * 注意:修改此处的指令后,程序会自动在所有相关位置使用新指令,
 * 无需在其他地方进行修改
 */
 String userCommand = "请打开LED并将亮度调节到最大";

/* 
 * -------------- 第一次请求:带有两个Tool Call的JSON请求体模板--------------
 * 
 * 此处使用C++11的原始字符串字面量(Raw String Literal)语法R"rawliteral(...)rawliteral"
 * 来定义一个多行JSON字符串模板,避免手动转义双引号和换行符。
 * 
 * 该JSON符合OpenAI API兼容格式,包含以下关键字段:
 * - model: 指定要调用的AI模型名称
 * - messages: 对话历史,包含系统角色和用户输入
 * - tools: 定义两个独立的LED控制工具函数:
 *   1. controlLEDOnOFF - 控制LED开关
 *   2. controlLEDBrightness - 控制LED亮度
 * - tool_choice: 设置为"auto"让模型自动决定是否使用工具
 * 
 * 注意:
 * - 模板中的{{USER_COMMAND}}占位符将在运行时被userCommand变量的值替换
 * - 这种方式使第一次请求的结构更加直观,便于学习者理解
 */
const char* ai_payload_first_template = R"rawliteral({
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手,可以控制LED的开关和亮度。"
    },
    {
      "role": "user",
      "content": "{{USER_COMMAND}}"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "controlLEDOnOFF",
        "description": "控制LED的打开或关闭",
        "parameters": {
          "type": "object",
          "properties": {
            "action": {
              "type": "string",
              "description": "LED控制动作",
              "enum": ["on", "off"]
            }
          },
          "required": ["action"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "controlLEDBrightness",
        "description": "控制LED的亮度级别",
        "parameters": {
          "type": "object",
          "properties": {
            "level": {
              "type": "integer",
              "description": "亮度级别,1-5级,其中1为最暗,5为最亮",
              "minimum": 1,
              "maximum": 5
            }
          },
          "required": ["level"]
        }
      }
    }
  ],
  "tool_choice": "auto",
  "temperature": 0.1
})rawliteral";

/* 
 * -------------- 第二次请求:Follow-up Tool Calls的JSON请求体模板--------------
 * 
 * 此处使用C++11的原始字符串字面量(Raw String Literal)语法R"rawliteral(...)rawliteral"
 * 来定义一个多行JSON字符串模板,包含以下关键字段:
 * - model: 指定要调用的AI模型名称
 * - messages: 完整的对话历史,包括:
 *   1. 系统角色消息
 *   2. 用户原始消息
 *   3. 助手第一次响应(包含Tool Calls)
 *   4. Tool Calls执行结果(可能包含多个)
 * 
 * 注意:
 * - 模板中的占位符将在运行时被实际值替换
 * - {{USER_COMMAND}}占位符将被userCommand变量的值替换
 * - {{ASSISTANT_TOOL_CALLS}}占位符将被完整的assistant tool_calls JSON数组替换
 * - {{TOOL_RESULTS}}占位符将被所有tool角色消息替换
 * - 这种方式使第二次请求的结构更加直观,便于学习者理解多Tool Calls Follow-up的实现
 */
const char* ai_payload_second_template = R"rawliteral({
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手,可以控制LED的开关和亮度。请根据Tool Calls的执行结果,提供友好的自然语言反馈。"
    },
    {
      "role": "user",
      "content": "{{USER_COMMAND}}"
    },
    {
      "role": "assistant",
      "tool_calls": {{ASSISTANT_TOOL_CALLS}}
    },
    {{TOOL_RESULTS}}
  ],
  "temperature": 0.1
})rawliteral";

// 创建一个安全的Wi-Fi客户端(用于HTTPS连接)
WiFiClientSecure client;

// 创建HTTP客户端对象,用于发送请求
HTTPClient https;

// 全局变量,用于存储第一次Tool Calls响应
String firstToolCallResponse = "";

// 定义最大支持的工具调用数量
#define MAX_TOOL_CALLS 5

// 存储多个Tool Call信息的结构体数组
struct ToolCallInfo {
  String id;
  String name;
  String arguments;
  String result;
};
ToolCallInfo toolCalls[MAX_TOOL_CALLS];
int toolCallCount = 0;

void setup() {
  Serial.begin(115200);
  delay(1000);

  // 初始化FastLED
  FastLED.addLeds<WS2812, DATA_PIN, GRB>(leds, NUM_LEDS);
  FastLED.setBrightness(128);
  leds[0] = CRGB::White;
  FastLED.show();
  Serial.println("FastLED initialized successfully!");

  // WiFi connection (unchanged)
  Serial.print("Connecting WiFi");
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println();

  client.setInsecure();
  client.setTimeout(15);

  Serial.println();
  Serial.println("---------- AI Platform Config ----------");
  Serial.print("AI Platform API Endpoint: ");
  Serial.print(ai_host);
  Serial.println(ai_endpoint);
  Serial.println();
  Serial.print("一切就绪,等待几秒钟,便于您观察板上LED状态.");
  delay(5000);

  Serial.println("========== 多Tool Calls Follow-up 示例开始 ==========");
  callAIPlatformFirst();
  callAIPlatformSecond();
  Serial.println("\n========== 多Tool Calls Follow-up 示例结束 ==========");
}

// 主循环留空,因为本例只需发送一次请求
void loop() {
  delay(10);   
}

/* 
 * -------------- LED开关控制函数 --------------
 * 
 * 该函数根据AI模型返回的参数控制LED的开关状态
 * 
 * 参数:
 * - action: 字符串,"on"表示打开LED,"off"表示关闭LED
 * 
 * 返回值:
 * - String: 执行结果的描述,用于发送给LLM生成自然语言反馈
 */
String controlLEDOnOFF(String action) {
  Serial.print("控制LED开关: ");
  Serial.println(action);
  
  String resultDescription = "";
  if (action == "on") {
    leds[0] = CRGB::White;
    FastLED.show();
    resultDescription = "LED已成功打开";
  } else if (action == "off") {
    leds[0] = CRGB::Black;
    FastLED.show();
    resultDescription = "LED已成功关闭";
  } else {
    resultDescription = "LED控制失败:无效的控制指令";
  }
  return resultDescription;
}

/* 
 * -------------- LED亮度控制函数 --------------
 * 
 * 该函数根据AI模型返回的参数控制LED的亮度级别
 * 
 * 参数:
 * - level: 整数,亮度级别,1-5级,其中1为最暗,5为最亮
 * 
 * 返回值:
 * - String: 执行结果的描述,用于发送给LLM生成自然语言反馈
 */
String controlLEDBrightness(int level) {
  Serial.print("控制LED亮度: 级别 ");
  Serial.println(level);
  
  String resultDescription = "";
  if (level >= 1 && level <= 5) {
    uint8_t brightness = map(level, 1, 5, 50, 255);
    FastLED.setBrightness(brightness);
    leds[0] = CRGB::White;
    FastLED.show();
    
    String levelDescription;
    switch (level) {
      case 1: levelDescription = "最暗"; break;
      case 2: levelDescription = "较暗"; break;
      case 3: levelDescription = "中等"; break;
      case 4: levelDescription = "较亮"; break;
      case 5: levelDescription = "最亮"; break;
    }
    resultDescription = "LED亮度已成功调节到" + levelDescription + "级别";
  } else {
    resultDescription = "亮度调节失败:无效的亮度级别";
  }
  return resultDescription;
}

/* 
 * -------------- 第一次向AI平台发送HTTPS请求的函数 --------------
 * 
 * 该函数完成以下步骤:
 * 1. 构造完整的HTTPS请求URL(基于my_info.h中定义的主机、端口和路径)
 * 2. 设置请求头:Content-Type为application/json,Authorization为Bearer + API密钥
 * 3. 发送POST请求,携带包含Tool Call定义的JSON请求体
 * 4. 接收并打印HTTP响应状态码和响应内容
 * 5. 解析响应中的所有Tool Call参数并依次执行相应操作
 * 6. 存储所有Tool Calls响应信息,用于第二次请求
 */
void callAIPlatformFirst() {
  Serial.println("\n>>> 第一次请求:发送用户指令,获取Tool Calls响应...");

  // 构造Authorization请求头:格式为"Bearer <your-api-key>"
  String auth = "Bearer ";
  auth += ai_api_key;

  // 构造第一次请求的JSON体
  String firstPayload = buildFirstPayload();
  
  Serial.println("----- 第一次请求JSON -----");
  Serial.println(firstPayload);
  Serial.println("----- 请求JSON结束 -----");

  // 初始化HTTPS连接(使用client、主机名、端口、API路径)
  if (https.begin(client, ai_host, ai_port, ai_endpoint)) {

    // 设置超时时间(毫秒)- 增加超时时间以处理更复杂的请求
    https.setTimeout(20000); // 20秒超时
    https.setConnectTimeout(10000); // 10秒连接超时

    Serial.println("HTTPS连接已初始化,发送第一次请求...");

    // 添加必要的HTTP请求头
    https.addHeader("Content-Type", "application/json");
    https.addHeader("Authorization", auth);

    // 发送POST请求,并获取HTTP状态码
    int httpCode = https.POST(firstPayload);

    if (httpCode > 0) {
      // 打印HTTP响应状态码(如200表示成功)
      Serial.printf("HTTP响应状态码: %d\n", httpCode);

      // 如果响应成功(HTTP 200 OK)
      if (httpCode == HTTP_CODE_OK) {
        // 获取完整的响应字符串
        String resp = https.getString();
        Serial.println("----- 第一次响应 -----");
        Serial.println(resp);  // 打印AI返回的完整JSON
        Serial.println("----- 响应结束 -----");
        
        // 存储第一次响应,用于第二次请求
        firstToolCallResponse = resp;
        
        // 解析Tool Call响应并执行所有操作
        parseToolCallsResponse(resp);
      }
    } else {
      // 打印HTTP错误信息(如连接失败、超时等)
      Serial.printf("HTTP错误: %s\n", https.errorToString(httpCode).c_str());
      Serial.printf("错误代码: %d\n", httpCode);
    }

    // 结束本次HTTP会话,释放资源
    https.end();
  } else {
    // 如果无法建立HTTPS连接,打印错误提示
    Serial.println("无法连接到服务器");
  }
}

/* 
 * -------------- 第二次向AI平台发送HTTPS请求的函数 --------------
 * 
 * 该函数完成以下步骤:
 * 1. 构造包含所有Tool Calls响应和执行结果的JSON请求体
 * 2. 设置请求头:Content-Type为application/json,Authorization为Bearer + API密钥
 * 3. 发送POST请求,携带包含对话历史和执行结果的JSON请求体
 * 4. 接收并打印HTTP响应状态码和响应内容
 * 5. 解析并显示LLM生成的自然语言反馈
 */
void callAIPlatformSecond() {
  Serial.println("\n>>> 第二次请求:发送Tool Calls响应和执行结果,获取自然语言反馈...");

  // 构造Authorization请求头:格式为"Bearer <your-api-key>"
  String auth = "Bearer ";
  auth += ai_api_key;

  // 构造第二次请求的JSON体
  String secondPayload = buildSecondPayload();
  
  Serial.println("----- 第二次请求JSON -----");
  Serial.println(secondPayload);
  Serial.println("----- 请求JSON结束 -----");

  // 初始化HTTPS连接(使用client、主机名、端口、API路径)
  if (https.begin(client, ai_host, ai_port, ai_endpoint)) {

    // 设置超时时间(毫秒)
    https.setTimeout(20000); // 20秒超时
    https.setConnectTimeout(10000); // 10秒连接超时

    Serial.println("HTTPS连接已初始化,发送第二次请求...");

    // 添加必要的HTTP请求头
    https.addHeader("Content-Type", "application/json");
    https.addHeader("Authorization", auth);

    // 发送POST请求,并获取HTTP状态码
    int httpCode = https.POST(secondPayload);

    if (httpCode > 0) {
      // 打印HTTP响应状态码(如200表示成功)
      Serial.printf("HTTP响应状态码: %d\n", httpCode);

      // 如果响应成功(HTTP 200 OK)
      if (httpCode == HTTP_CODE_OK) {
        // 获取完整的响应字符串
        String resp = https.getString();
        Serial.println("----- 第二次响应 -----");
        Serial.println(resp);  // 打印AI返回的完整JSON
        Serial.println("----- 响应结束 -----");
        
        // 解析并显示自然语言反馈
        parseSecondResponse(resp);
      }
    } else {
      // 打印HTTP错误信息(如连接失败、超时等)
      Serial.printf("HTTP错误: %s\n", https.errorToString(httpCode).c_str());
      Serial.printf("错误代码: %d\n", httpCode);
    }

    // 结束本次HTTP会话,释放资源
    https.end();
  } else {
    // 如果无法建立HTTPS连接,打印错误提示
    Serial.println("无法连接到服务器");
  }
}

/* 
 * -------------- 构造第一次请求的JSON体 --------------
 * 
 * 该函数完成以下步骤:
 * 1. 使用预定义的JSON模板(ai_payload_first_template)
 * 2. 替换模板中的占位符为实际值
 * 3. 返回完整的第一次请求JSON体
 * 
 * 返回值:
 * - String: 构造好的第一次请求JSON体
 */
String buildFirstPayload() {
  // 从模板开始构建请求体
  String payload = ai_payload_first_template;
  
  // 替换模板中的占位符
  payload.replace("{{USER_COMMAND}}", userCommand);
  
  return payload;
}

/* 
 * -------------- 构造第二次请求的JSON体 --------------
 * 
 * 该函数完成以下步骤:
 * 1. 使用预定义的JSON模板(ai_payload_second_template)
 * 2. 构造assistant的tool_calls数组JSON字符串
 * 3. 构造所有tool角色消息的JSON字符串
 * 4. 替换模板中的占位符为实际值
 * 5. 返回完整的第二次请求JSON体
 * 
 * 返回值:
 * - String: 构造好的第二次请求JSON体
 */
String buildSecondPayload() {
  // 从模板开始构建请求体
  String payload = ai_payload_second_template;
  
  // 构造assistant的tool_calls数组JSON字符串
  String assistantToolCalls = buildAssistantToolCallsJson();
  
  // 构造所有tool角色消息的JSON字符串
  String toolResults = buildToolResultsJson();
  
  // 替换模板中的占位符
  payload.replace("{{USER_COMMAND}}", userCommand);
  payload.replace("{{ASSISTANT_TOOL_CALLS}}", assistantToolCalls);
  payload.replace("{{TOOL_RESULTS}}", toolResults);
  
  return payload;
}

/* 
 * -------------- 构造assistant的tool_calls数组JSON字符串 --------------
 * 
 * 根据存储的所有Tool Call信息,构造完整的tool_calls数组JSON字符串
 * 
 * 返回值:
 * - String: 格式如 [{"id":"xxx","type":"function","function":{"name":"xxx","arguments":"xxx"}}, ...]
 */
String buildAssistantToolCallsJson() {
  String json = "[";
  
  for (int i = 0; i < toolCallCount; i++) {
    if (i > 0) {
      json += ",";
    }
    
    // 转义arguments中的双引号
    String escapedArgs = toolCalls[i].arguments;
    escapedArgs.replace("\"", "\\\"");
    
    json += "{";
    json += "\"id\":\"" + toolCalls[i].id + "\",";
    json += "\"type\":\"function\",";
    json += "\"function\":{";
    json += "\"name\":\"" + toolCalls[i].name + "\",";
    json += "\"arguments\":\"" + escapedArgs + "\"";
    json += "}";
    json += "}";
  }
  
  json += "]";
  return json;
}

/* 
 * -------------- 构造所有tool角色消息的JSON字符串 --------------
 * 
 * 根据存储的所有Tool Call执行结果,构造所有tool角色消息的JSON字符串
 * 多个tool消息之间用逗号分隔
 * 
 * 返回值:
 * - String: 格式如 {"role":"tool","tool_call_id":"xxx","content":"xxx"}, {...}
 */
String buildToolResultsJson() {
  String json = "";
  
  for (int i = 0; i < toolCallCount; i++) {
    if (i > 0) {
      json += ",";
    }
    
    json += "{";
    json += "\"role\":\"tool\",";
    json += "\"tool_call_id\":\"" + toolCalls[i].id + "\",";
    json += "\"content\":\"" + toolCalls[i].result + "\"";
    json += "}";
  }
  
  return json;
}

/* 
 * -------------- 解析第一次Tool Calls响应的函数(多工具调用版)--------------
 * 
 * 该函数完成以下步骤:
 * 1. 使用ArduinoJson库解析JSON响应
 * 2. 检查响应中是否包含tool_calls字段
 * 3. 遍历所有tool_calls,提取每个工具调用的函数名称和参数
 * 4. 依次调用相应的控制函数执行操作
 * 5. 存储所有Tool Calls信息用于第二次请求
 */
void parseToolCallsResponse(String jsonResponse) {
  // 解析JSON响应
  DynamicJsonDocument doc(8192);
  DeserializationError error = deserializeJson(doc, jsonResponse);
  
  if (error) {
    Serial.print("JSON解析失败: ");
    Serial.println(error.c_str());
    return;
  }
  
  // 检查是否有tool_calls
  if (doc.containsKey("choices") && doc["choices"].size() > 0) {
    JsonObject choice = doc["choices"][0];
    JsonObject message = choice["message"];
    
    if (message.containsKey("tool_calls") && message["tool_calls"].size() > 0) {
      // 获取tool_calls数组
      JsonArray toolCallsArray = message["tool_calls"];
      toolCallCount = toolCallsArray.size();
      
      Serial.print("检测到 ");
      Serial.print(toolCallCount);
      Serial.println(" 个工具调用");
      
      // 限制最大数量
      if (toolCallCount > MAX_TOOL_CALLS) {
        Serial.print("工具调用数量超过最大值,只处理前 ");
        Serial.print(MAX_TOOL_CALLS);
        Serial.println("");
        toolCallCount = MAX_TOOL_CALLS;
      }
      
      // 遍历所有tool_calls
      for (int i = 0; i < toolCallCount; i++) {
        JsonObject toolCall = toolCallsArray[i];
        JsonObject function = toolCall["function"];
        String functionName = function["name"].as<String>();
        
        // 存储Tool Call信息
        toolCalls[i].id = toolCall["id"].as<String>();
        toolCalls[i].name = functionName;
        toolCalls[i].arguments = function["arguments"].as<String>();
        
        Serial.print("\n[工具调用 ");
        Serial.print(i + 1);
        Serial.print("/");
        Serial.print(toolCallCount);
        Serial.println("]");
        Serial.print("函数名: ");
        Serial.println(functionName);
        Serial.print("Tool Call ID: ");
        Serial.println(toolCalls[i].id);
        Serial.print("参数: ");
        Serial.println(toolCalls[i].arguments);
        
        // 执行对应的操作
        Serial.print(">>> 第二步:执行Tool Call ");
        Serial.print(i + 1);
        Serial.println("...");
        
        if (functionName == "controlLEDOnOFF") {
          // 解析LED开关函数参数
          DynamicJsonDocument argsDoc(512);
          DeserializationError argsError = deserializeJson(argsDoc, toolCalls[i].arguments);
          
          if (argsError) {
            Serial.print("参数解析失败: ");
            Serial.println(argsError.c_str());
            toolCalls[i].result = "LED控制失败:参数解析错误";
            continue;
          }
          
          String action = argsDoc["action"].as<String>();
          Serial.print("执行动作: ");
          Serial.println(action);
          
          // 调用LED开关控制函数并保存结果
          toolCalls[i].result = controlLEDOnOFF(action);
          
        } else if (functionName == "controlLEDBrightness") {
          // 解析LED亮度函数参数
          DynamicJsonDocument argsDoc(512);
          DeserializationError argsError = deserializeJson(argsDoc, toolCalls[i].arguments);
          
          if (argsError) {
            Serial.print("参数解析失败: ");
            Serial.println(argsError.c_str());
            toolCalls[i].result = "亮度调节失败:参数解析错误";
            continue;
          }
          
          int level = argsDoc["level"].as<int>();
          Serial.print("执行亮度调节: ");
          Serial.println(level);
          
          // 调用LED亮度控制函数并保存结果
          toolCalls[i].result = controlLEDBrightness(level);
          
        } else {
          Serial.print("未知函数: ");
          Serial.println(functionName);
          toolCalls[i].result = "未知工具:" + functionName;
        }
      }
      
      Serial.println("\n>>> 所有Tool Calls执行完成");
      
    } else {
      // 没有tool_calls,可能是普通文本响应
      Serial.println("未检测到tool_calls");
      if (message.containsKey("content")) {
        String content = message["content"].as<String>();
        Serial.print("模型回复: ");
        Serial.println(content);
      }
    }
  }
}

/* 
 * -------------- 解析第二次响应的函数--------------
 * 
 * 该函数完成以下步骤:
 * 1. 使用ArduinoJson库解析JSON响应
 * 2. 提取LLM生成的自然语言反馈
 * 3. 显示反馈内容
 */
void parseSecondResponse(String jsonResponse) {
  // 解析JSON响应
  DynamicJsonDocument doc(4096);
  DeserializationError error = deserializeJson(doc, jsonResponse);
  
  if (error) {
    Serial.print("第二次响应JSON解析失败: ");
    Serial.println(error.c_str());
    return;
  }
  
  // 提取并显示自然语言反馈
  if (doc.containsKey("choices") && doc["choices"].size() > 0) {
    JsonObject choice = doc["choices"][0];
    JsonObject message = choice["message"];
    
    if (message.containsKey("content") && !message["content"].as<String>().isEmpty()) {
      String content = message["content"].as<String>();
      Serial.println(">>> LLM自然语言反馈:");
      Serial.println(content);
    } else {
      Serial.println("LLM没有提供自然语言反馈");
    }
  } else {
    Serial.println("无法解析第二次响应");
  }
}


/*
【附录:本程序运行结果】
========== 多Tool Calls Follow-up 示例开始 ==========
>>> 第一步:发送第一次请求,获取Tool Calls响应...

>>> 第一次请求:发送用户指令,获取Tool Calls响应...
----- 第一次请求JSON -----
{
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手,可以控制LED的开关和亮度。"
    },
    {
      "role": "user",
      "content": "请打开LED并将亮度调节到最大"
    }
  ],
  "tools": [
  {
    "type": "function",
    "function": {
      "name": "controlLEDOnOFF",
      "description": "控制LED的打开或关闭",
      "parameters": {
        "type": "object",
        "properties": {
            "action": {
              "type": "string",
              "description": "LED控制动作",
              "enum": ["on", "off"]
            }
          },
          "required": ["action"]
        }
      }
    },
    {
      "type": "function",
      "function": {
        "name": "controlLEDBrightness",
        "description": "控制LED的亮度级别",
        "parameters": {
          "type": "object",
          "properties": {
            "level": {
              "type": "integer",
              "description": "亮度级别,1-5级,其中1为最暗,5为最亮",
              "minimum": 1,
              "maximum": 5
            }
          },
          "required": ["level"]
        }
      }
    }
  ],
  "tool_choice": "auto",
  "temperature": 0.1
}

----- 请求JSON结束 -----
HTTPS连接已初始化,发送第一次请求...
HTTP响应状态码: 200
----- 第一次响应 -----
{
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "message": {
        "content": "",
        "role": "assistant",
        "tool_calls": [
          {
            "function": {
              "arguments": "{\"action\": \"on\"}",
              "name": "controlLEDOnOFF"
            },
            "id": "call_abaee57e921a4021829447",
            "index": 0,
            "type": "function"
          },
          {
            "function": {
              "arguments": "{\"level\": 5}",
              "name": "controlLEDBrightness"
            },
            "id": "call_f21c48f98191408f8e5854",
            "index": 1,
            "type": "function"
          }
        ]
      }
    }
  ],
  "created": 1777905141,
  "id": "chatcmpl-225c67f3-df54-914a-8571-36cfbea54c09",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 43,
    "prompt_tokens": 278,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 321
  }
}
----- 响应结束 -----    
检测到 2 个工具调用

[工具调用 1/2]
函数名: controlLEDOnOFF
Tool Call ID: call_abaee57e921a4021829447
参数: {"action": "on"}
>>> 第二步:执行Tool Call 1...
执行动作: on
控制LED开关: on
 - LED已打开

[工具调用 2/2]
函数名: controlLEDBrightness
Tool Call ID: call_f21c48f98191408f8e5854
参数: {"level": 5}
>>> 第二步:执行Tool Call 2...
执行亮度调节: 5
控制LED亮度: 级别 5
 - 亮度已设置为最亮级别

>>> 所有Tool Calls执行完成

>>> 第三步:发送第二次请求,获取自然语言反馈...

>>> 第二次请求:发送Tool Calls响应和执行结果,获取自然语言反馈...
----- 第二次请求JSON -----

{
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手,可以控制LED的开关和亮度。请根据Tool Calls的执行结果,提供友好的自然语言反馈。"
    },
    {
      "role": "user",
      "content": "请打开LED并将亮度调节到最大"
    },
    {
      "role": "assistant",
      "tool_calls": [
        {
          "id": "call_abaee57e921a4021829447",
          "type": "function",
          "function": {
            "name": "controlLEDOnOFF",
            "arguments": "{\"action\": \"on\"}"
          }
        },
        {
          "id": "call_f21c48f98191408f8e5854",
          "type": "function",
          "function": {
            "name": "controlLEDBrightness",
            "arguments": "{\"level\": 5}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_abaee57e921a4021829447",
      "content": "LED已成功打开"
    },
    {
      "role": "tool",
      "tool_call_id": "call_f21c48f98191408f8e5854",
      "content": "LED亮度已成功调节到最亮级别"
    }
  ],
  "temperature": 0.1
}

----- 请求JSON结束 -----
HTTPS连接已初始化,发送第二次请求...
HTTP响应状态码: 200
----- 第二次响应 -----
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "content": "已为您打开LED,并将亮度调节到最大级别,现在灯光最亮!",
        "role": "assistant"
      }
    }
  ],
  "created": 1777905142,
  "id": "chatcmpl-bcc15c17-bf0a-9089-865c-6caf79bac3d9",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 17,
    "prompt_tokens": 124,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 141
  }
}
----- 响应结束 -----
>>> LLM自然语言反馈:
已为您打开LED,并将亮度调节到最大级别,现在灯光最亮!
========== 多Tool Calls Follow-up 示例结束 ==========
 */

下面逐段讲解新增和修改的部分。

1. 全局变量:使用结构体数组存储多个 Tool Call 信息(第223-231行)

上一节使用四个独立的 String 变量存储单个 Tool Call 的信息:

// 上一节的单 Tool Call 存储方式
String firstToolCallId = "";
String firstToolCallName = "";
String firstToolCallArguments = "";

本节改为使用结构体数组,可以存储多个 Tool Call 的信息:

// 定义最大支持的工具调用数量
#define MAX_TOOL_CALLS 5

// 存储多个Tool Call信息的结构体数组
struct ToolCallInfo {
  String id;         // Tool Call 的唯一标识
  String name;       // 调用的函数名称
  String arguments;  // 调用的参数(JSON字符串)
  String result;     // 执行结果描述
};

ToolCallInfo toolCalls[MAX_TOOL_CALLS];
int toolCallCount = 0;
变量/常量用途
MAX_TOOL_CALLS定义最多支持多少个工具调用(这里设为5个)
ToolCallInfo结构体,封装单个 Tool Call 的所有信息
toolCalls[]结构体数组,存储所有 Tool Call 的信息, 如:调用工具函数的名称,参数等
toolCallCount实际检测到的 Tool Call 数量

为什么需要 MAX_TOOL_CALLS 限制?因为 ESP32 的内存有限,预先分配固定大小的数组可以避免动态内存分配带来的风险。

2. 第二次请求模板的变化(第191-209行)

上一节的第二次请求模板使用固定的单个 tool_calls 和单个 tool 消息:

// 上一节的模板(单Tool Call)
const char* ai_payload_second_template = R"rawliteral({
  ...
  "tool_calls": [
    {
      "id": "{{TOOL_CALL_ID}}",
      ...
    }
  ]
  ...
  {
    "role": "tool",
    "tool_call_id": "{{TOOL_CALL_ID}}",
    ...
  }
})rawliteral";

本节改为使用占位符替换整个数组,因为 Tool Calls 的数量是动态的:

const char* ai_payload_second_template = R"rawliteral({
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手,可以控制LED的开关和亮度。请根据Tool Calls的执行结果,提供友好的自然语言反馈。"
    },
    {
      "role": "user",
      "content": "{{USER_COMMAND}}"
    },
    {
      "role": "assistant",
      "tool_calls": {{ASSISTANT_TOOL_CALLS}}
    },
    {{TOOL_RESULTS}}
  ],
  "temperature": 0.1
})rawliteral";

模板中有三个占位符:

占位符替换内容
{{USER_COMMAND}}用户原始指令
{{ASSISTANT_TOOL_CALLS}}完整的 tool_calls JSON 数组
{{TOOL_RESULTS}}所有 tool 角色消息的 JSON

💡 设计思路:不再为每个 Tool Call 单独设置占位符,而是将整个数组作为一块内容插入。这样无论 AI 返回多少个 Tool Calls,模板都能适应。

3. 构造 assistant 的 tool_calls 数组:buildAssistantToolCallsJson()(第545-569行)⭐

这是全新增加的函数,用于根据存储的所有 Tool Call 信息,构造 assistant 消息中的 tool_calls 数组 JSON 字符串:

String buildAssistantToolCallsJson() {
  String json = "[";
  
  for (int i = 0; i < toolCallCount; i++) {
    if (i > 0) {
      json += ",";
    }
    
    // 转义arguments中的双引号
    String escapedArgs = toolCalls[i].arguments;
    escapedArgs.replace("\"", "\\\"");
    
    json += "{";
    json += "\"id\":\"" + toolCalls[i].id + "\",";
    json += "\"type\":\"function\",";
    json += "\"function\":{";
    json += "\"name\":\"" + toolCalls[i].name + "\",";
    json += "\"arguments\":\"" + escapedArgs + "\"";
    json += "}";
    json += "}";
  }
  
  json += "]";
  return json;
}

函数逻辑解析

  1. 遍历所有 Tool Call:使用 for 循环遍历 toolCalls 数组
  2. 处理逗号分隔:第一个元素前不加逗号,后续元素前加逗号
  3. 转义双引号arguments 字段的值(如 {"level": 5})包含双引号,需要转义为 \",否则破坏 JSON 格式
  4. 拼接 JSON 字符串:手动构造每个 Tool Call 对象的 JSON

输出示例

[
  {
    "id": "call_abaee57e921a4021829447",
    "type": "function",
    "function": {
      "name": "controlLEDOnOFF",
      "arguments": "{\"action\": \"on\"}"
    }
  },
  {
    "id": "call_f21c48f98191408f8e5854",
    "type": "function",
    "function": {
      "name": "controlLEDBrightness",
      "arguments": "{\"level\": 5}"
    }
  }
]

4. 构造所有 tool 角色消息:buildToolResultsJson()(第580-596行)⭐

这是全新增加的函数,用于构造所有 tool 角色消息的 JSON 字符串:

String buildToolResultsJson() {
  String json = "";
  
  for (int i = 0; i < toolCallCount; i++) {
    if (i > 0) {
      json += ",";
    }
    
    json += "{";
    json += "\"role\":\"tool\",";
    json += "\"tool_call_id\":\"" + toolCalls[i].id + "\",";
    json += "\"content\":\"" + toolCalls[i].result + "\"";
    json += "}";
  }
  
  return json;
}

函数逻辑解析

  1. 遍历所有 Tool Call:使用 for 循环遍历 toolCalls 数组
  2. 处理逗号分隔:多个 tool 消息之间用逗号分隔
  3. 一一对应关系:每个 tool 消息的 tool_call_id 必须与 assistant.tool_calls 中对应元素的 id 一致

输出示例

{
  "role": "tool",
  "tool_call_id": "call_abaee57e921a4021829447",
  "content": "LED已成功打开"
},
{
  "role": "tool",
  "tool_call_id": "call_f21c48f98191408f8e5854",
  "content": "LED亮度已成功调节到最亮级别"
}

⚠️ 关键注意tool_call_id 必须与 assistant.tool_calls 中的 id 严格一一对应。如果顺序错乱或 ID 不匹配,AI 平台可能无法正确理解对话上下文。

5. 构造第二次请求体:buildSecondPayload()(第519-535行)

与上一节相比,本节的 buildSecondPayload() 函数不再解析第一次响应的 JSON,而是直接使用已存储的 Tool Call 信息

String buildSecondPayload() {
  // 从模板开始构建请求体
  String payload = ai_payload_second_template;
  
  // 构造assistant的tool_calls数组JSON字符串
  String assistantToolCalls = buildAssistantToolCallsJson();
  
  // 构造所有tool角色消息的JSON字符串
  String toolResults = buildToolResultsJson();
  
  // 替换模板中的占位符
  payload.replace("{{USER_COMMAND}}", userCommand);
  payload.replace("{{ASSISTANT_TOOL_CALLS}}", assistantToolCalls);
  payload.replace("{{TOOL_RESULTS}}", toolResults);
  
  return payload;
}

与上一节的区别

步骤上一节(单Tool Call)本节(多Tool Calls)
解析第一次响应需要再次解析 JSON不需要,信息已存储在结构体数组中
构造 assistant.tool_calls从解析的 JSON 中提取从 toolCalls[] 数组构造
构造 tool 消息构造单条消息构造多条消息
转义双引号对单个 arguments 转义在 buildAssistantToolCallsJson() 中对每个 arguments 转义

6. 解析多个 Tool Calls:parseToolCallsResponse()(第608-726行)⭐

这是本节改动最大的函数。上一节的解析函数只处理单个 Tool Call,本节改为遍历整个 tool_calls 数组

void parseToolCallsResponse(String jsonResponse) {
  // 解析JSON响应
  DynamicJsonDocument doc(8192);
  DeserializationError error = deserializeJson(doc, jsonResponse);
  
  if (error) {
    Serial.print("JSON解析失败: ");
    Serial.println(error.c_str());
    return;
  }
  
  // 检查是否有tool_calls
  if (doc.containsKey("choices") && doc["choices"].size() > 0) {
    JsonObject choice = doc["choices"][0];
    JsonObject message = choice["message"];
    
    if (message.containsKey("tool_calls") && message["tool_calls"].size() > 0) {
      // 获取tool_calls数组
      JsonArray toolCallsArray = message["tool_calls"];
      toolCallCount = toolCallsArray.size();
      
      Serial.print("检测到 ");
      Serial.print(toolCallCount);
      Serial.println(" 个工具调用");
      
      // 限制最大数量
      if (toolCallCount > MAX_TOOL_CALLS) {
        Serial.print("工具调用数量超过最大值,只处理前 ");
        Serial.print(MAX_TOOL_CALLS);
        Serial.println("");
        toolCallCount = MAX_TOOL_CALLS;
      }
      
      // 遍历所有tool_calls
      for (int i = 0; i < toolCallCount; i++) {
        JsonObject toolCall = toolCallsArray[i];
        JsonObject function = toolCall["function"];
        String functionName = function["name"].as<String>();
        
        // 存储Tool Call信息
        toolCalls[i].id = toolCall["id"].as<String>();
        toolCalls[i].name = functionName;
        toolCalls[i].arguments = function["arguments"].as<String>();
        
        Serial.print("\n[工具调用 ");
        Serial.print(i + 1);
        Serial.print("/");
        Serial.print(toolCallCount);
        Serial.println("]");
        Serial.print("函数名: ");
        Serial.println(functionName);
        Serial.print("Tool Call ID: ");
        Serial.println(toolCalls[i].id);
        Serial.print("参数: ");
        Serial.println(toolCalls[i].arguments);
        
        // 执行对应的操作
        Serial.print(">>> 第二步:执行Tool Call ");
        Serial.print(i + 1);
        Serial.println("...");
        
        if (functionName == "controlLEDOnOFF") {
          // 解析LED开关函数参数
          DynamicJsonDocument argsDoc(512);
          DeserializationError argsError = deserializeJson(argsDoc, toolCalls[i].arguments);
          
          if (argsError) {
            Serial.print("参数解析失败: ");
            Serial.println(argsError.c_str());
            toolCalls[i].result = "LED控制失败:参数解析错误";
            continue;
          }
          
          String action = argsDoc["action"].as<String>();
          Serial.print("执行动作: ");
          Serial.println(action);
          
          // 调用LED开关控制函数并保存结果
          toolCalls[i].result = controlLEDOnOFF(action);
          
        } else if (functionName == "controlLEDBrightness") {
          // 解析LED亮度函数参数
          DynamicJsonDocument argsDoc(512);
          DeserializationError argsError = deserializeJson(argsDoc, toolCalls[i].arguments);
          
          if (argsError) {
            Serial.print("参数解析失败: ");
            Serial.println(argsError.c_str());
            toolCalls[i].result = "亮度调节失败:参数解析错误";
            continue;
          }
          
          int level = argsDoc["level"].as<int>();
          Serial.print("执行亮度调节: ");
          Serial.println(level);
          
          // 调用LED亮度控制函数并保存结果
          toolCalls[i].result = controlLEDBrightness(level);
          
        } else {
          Serial.print("未知函数: ");
          Serial.println(functionName);
          toolCalls[i].result = "未知工具:" + functionName;
        }
      }
      
      Serial.println("\n>>> 所有Tool Calls执行完成");
      
    } else {
      // 没有tool_calls,可能是普通文本响应
      Serial.println("未检测到tool_calls");
      if (message.containsKey("content")) {
        String content = message["content"].as<String>();
        Serial.print("模型回复: ");
        Serial.println(content);
      }
    }
  }
}

与上一节的关键区别

部分上一节(单Tool Call)本节(多Tool Calls)
获取 Tool CalltoolCalls[0] 直接取第一个使用 for 循环遍历整个数组
存储信息存到独立的全局变量存到 toolCalls[i] 结构体数组
执行操作执行一次依次执行多次
错误处理失败直接返回使用 continue 跳过当前,继续执行下一个
数量检查不检查检查是否超过 MAX_TOOL_CALLS

关键代码解析

获取 Tool Calls 数量

JsonArray toolCallsArray = message["tool_calls"];
toolCallCount = toolCallsArray.size();

使用 size() 方法获取数组长度,知道有多少个工具调用需要处理。

数量限制保护

if (toolCallCount > MAX_TOOL_CALLS) {
  toolCallCount = MAX_TOOL_CALLS;
}

防止 AI 返回过多 Tool Calls 导致数组越界。

遍历执行

for (int i = 0; i < toolCallCount; i++) {
  JsonObject toolCall = toolCallsArray[i];
  // ...提取信息...
  // ...执行操作...
  toolCalls[i].result = controlLEDOnOFF(action);
}

使用 for 循环依次处理每个 Tool Call,并将执行结果保存到对应的结构体中。

错误处理

if (argsError) {
  toolCalls[i].result = "LED控制失败:参数解析错误";
  continue;  // 跳过当前,继续执行下一个Tool Call
}

使用 continue 而不是 return,确保即使某个 Tool Call 执行失败,其他 Tool Call 仍能继续执行。

完整交互流程示例

下面以用户指令 “请打开LED并将亮度调节到最大” 为例,展示 ESP32、AI 平台和硬件之间的完整交互流程。

第一步:ESP32 发送第一次请求

POST /compatible-mode/v1/chat/completions
{
  "model": "qwen-flash",
  "messages": [
    {"role": "system", "content": "你是一个智能家居助手..."},
    {"role": "user", "content": "请打开LED并将亮度调节到最大"}
  ],
  "tools": [ /* 两个LED控制工具 */ ],
  "tool_choice": "auto",
  "temperature": 0.1
}

第二步:AI 返回多个 Tool Calls

{
  "choices": [
    {
      "finish_reason": "tool_calls",
      "message": {
        "role": "assistant",
        "tool_calls": [
          {
            "id": "call_abaee57e921a4021829447",
            "function": {
              "name": "controlLEDOnOFF",
              "arguments": "{\"action\": \"on\"}"
            }
          },
          {
            "id": "call_f21c48f98191408f8e5854",
            "function": {
              "name": "controlLEDBrightness",
              "arguments": "{\"level\": 5}"
            }
          }
        ]
      }
    }
  ]
}

第三步:ESP32 依次执行所有 Tool Calls

  1. Tool Call 1controlLEDOnOFF("on") → LED 打开
  2. Tool Call 2controlLEDBrightness(5) → 亮度调到最亮

第四步:ESP32 发送第二次请求(Follow-up)

POST /compatible-mode/v1/chat/completions
{
  "model": "qwen-flash",
  "messages": [
    {"role": "system", "content": "你是一个智能家居助手..."},
    {"role": "user", "content": "请打开LED并将亮度调节到最大"},
    {
      "role": "assistant",
      "tool_calls": [
        {
          "id": "call_abaee57e921a4021829447",
          "function": {"name": "controlLEDOnOFF", "arguments": "{\"action\": \"on\"}"}
        },
        {
          "id": "call_f21c48f98191408f8e5854",
          "function": {"name": "controlLEDBrightness", "arguments": "{\"level\": 5}"}
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_abaee57e921a4021829447",
      "content": "LED已成功打开"
    },
    {
      "role": "tool",
      "tool_call_id": "call_f21c48f98191408f8e5854",
      "content": "LED亮度已成功调节到最亮级别"
    }
  ],
  "temperature": 0.1
}

第五步:AI 返回整合后的自然语言反馈

{
  "choices": [
    {
      "finish_reason": "stop",
      "message": {
        "content": "已为您打开LED,并将亮度调节到最大级别,现在灯光最亮!"
      }
    }
  ]
}

单 Tool Call vs 多 Tool Calls 对比总结

对比项单 Tool Call多 Tool Calls
适用指令“打开LED”“打开LED并调到最亮”
响应中的 tool_calls1 个元素多个元素
存储方式独立全局变量结构体数组
执行方式执行一次for 循环依次执行
Follow-up 的 tool 消息1 条多条
AI 反馈特点针对单个操作整合多个操作的结果

扩展思考

1. 如何处理更多工具?

本示例只定义了两个工具(开关和亮度),但实际项目中可能需要更多工具:

  • controlLEDColor:控制 LED 颜色
  • controlLEDWaver:控制 LED 闪烁效果
  • readTemperature:读取温度传感器
  • readHumidity:读取湿度传感器

只需要在 tools 数组中继续添加工具定义,并在 parseToolCallsResponse() 的 for 循环中增加对应的处理分支即可。

2. 如何处理 Tool Call 之间的依赖关系?

有些场景下,Tool Call 之间存在依赖关系。例如:

  • “把灯打开并设置为红色”:先执行 controlLEDOnOFF("on"),再执行 controlLEDColor("red")
  • “如果温度高于30度就打开风扇”:先执行 readTemperature(),根据结果决定是否执行 controlFan("on")

对于简单的顺序依赖,AI 平台通常会自动按正确顺序排列 Tool Calls。对于条件依赖,则可能需要在 ESP32 端增加逻辑判断。

3. 内存优化建议

ESP32 的内存有限,当 Tool Calls 数量很多时,需要注意:

  • 控制 MAX_TOOL_CALLS 的大小:根据实际需求设置,不要过大
  • 减小 JSON 文档容量DynamicJsonDocument 的容量参数(如 4096)应根据实际响应大小调整
  • 及时释放资源:使用 https.end() 关闭连接,避免内存泄漏

总结

本节课程讲解了 ESP32 处理多 Tool Calls 的完整流程:

  1. 使用结构体数组存储多个 Tool Call 信息:替代独立变量,支持动态数量
  2. 遍历 tool_calls 数组依次执行:使用 for 循环处理每个工具调用
  3. 构造包含多个 tool 消息的 Follow-up 请求:确保每个 Tool Call 都有对应的执行结果
  4. 获取 AI 整合后的自然语言反馈:让 AI “既会做事,也会说话”

理解了这个流程后,您就可以构建更复杂的 AIoT 应用:同时控制多个设备、执行多种操作,让 AI 助手真正成为智能管家。