AI创客项目开发教程 –AID101-1-3-04 – 后续工具调用实例

导航栏:首页 / AI教程目录 / AI创客项目开发教程目录 / 第1篇 基础知识篇 / AID101-1-3-04 – 后续工具调用实例

本节内容简介

在前几节的课程中,我们已经学会了如何通过 Tool Calls 让 AI 模型控制 ESP32 的 LED 开关和亮度。但细心的朋友可能已经发现了一个问题:AI 模型只会返回结构化的工具调用指令,而不会用自然语言与我们交流

比如当用户说”把LED调到亮度最大”时,AI 智能返回包含有用于控制LED亮度的工具函数信息,如下所示:

{
  "name": "controlLEDBrightness",
  "arguments": "{\"level\": 5}"
}

虽然 ESP32 能够解析并执行这个json信息并且用于控制LED亮度,但对于我们人类用户来说,AI并没有提供任何像”好的,已经把LED调到最亮了”这样友好的自然语言回复。这种”只做事、不说话”的交互方式,在智能家居等应用场景中显得不够自然和人性化。

本节课程将讲解 Follow-up Tool Calls(后续工具调用) 技术,让 ESP32 在执行完工具调用后,把执行结果再次发送给 AI 模型,由 AI 生成一段自然语言的反馈。通过本节课程,您将学会如何让 AI “既会做事,也会说话”。

什么是 Follow-up Tool Calls?

核心概念

Follow-up Tool Calls 是 Tool Calls 机制的扩展流程。它在标准 Tool Calls 的三阶段(请求 → 分析响应 → 执行)基础上,增加了第四阶段——获取自然语言反馈

AI-Follow-Up-Tool-Calls-Flow-Chart

为什么需要 Follow-up?

场景仅有 Tool Calls加上 Follow-up
用户说”打开LED”无回复,LED直接亮“好的,已经为您打开LED”
用户说”把灯调亮”无回复,亮度变化“已帮您把LED调到最亮”
执行失败用户不知道发生了什么“抱歉,LED控制失败,请重试”

Follow-up 让 AI 助手更像一个真正的”智能管家”——不仅听得懂指令、做得到操作,还能给用户明确的反馈

🔁 Follow-up Tool Calls 完整流程

整个流程分为四个阶段:

  1. 第一次请求:ESP32 发送用户指令 + 可用工具列表,获取 Tool Calls 响应
  2. 执行 Tool Calls:ESP32 解析 AI 的工具调用指令,执行实际的硬件操作
  3. 第二次请求(Follow-up):ESP32 将完整的对话历史(包括 Tool Calls 和执行结果)再次发送给 AI
  4. 获取自然语言反馈:AI 根据执行结果生成友好的人话回复在下面的内容中,我们将详细讲解这四个阶段的详细内容。

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

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

第一次请求与上一节课程AID101-1-3-03 – 多工具定义实例 中的示例程序中所发送的请求Json完全一致。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
}

这部分内容在上一节已经详细讲解过,此处不再赘述。如果您还不熟悉 Tool Calls 的基本流程,建议先回顾本教程 第三章 AI大模型工具调用(tools call) 前三节课程内容。

第二阶段:执行 Tool Calls(与上一节相同)

ESP32 收到 AI 的第一次响应后,解析出函数名和参数,调用相应的函数控制 LED。这一步也与上一节完全相同。

例如,当 AI 返回调用函数 controlLEDBrightness、并且参数为 {"level": 5} 时,ESP32 会执行 controlLEDBrightness(5),将 LED 调到最亮。

但这里有一个关键变化:为了进行 Follow-up tool calls,ESP32 需要保存 AI 的第一次 Tool Calls 响应中的以下信息,包括:

  • tool_calls_id:工具调用的唯一标识
  • function_name:调用的函数名称
  • arguments:调用参数

这些信息将在第二次请求也就是follow-up tool calls中使用。为了便于您学习,我们将第一次 Tool Calls 响应的Json内容列在下面:

{
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "message": {
        "content": "",
        "role": "assistant",
        "tool_calls": [
          {
            "function": {
              "arguments": "{\"level\": 5}",
              "name": "controlLEDBrightness"
            },
            "id": "call_a4b2372b98b1492681255a",
            "index": 0,
            "type": "function"
          }
        ]
      }
    }
  ],
  "created": 1782587266,
  "id": "chatcmpl-e5b396d-99d7-4702-ac18-ed87f61aefb0f",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 21,
    "prompt_tokens": 275,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 296
  }
}

第三阶段:构造第二次请求(Follow-up 请求)⭐

这是本节课程的核心内容。第二次请求的 JSON 结构与第一次有很大不同,它不再包含 tools 字段,而是包含完整的对话历史,包括一个特殊的 role: "tool" 消息。

第二次请求 JSON 示例

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

messages 数组的四个角色

第二次请求的 messages 数组包含四条消息,它们共同构成了完整的对话上下文

顺序role作用
1system设定 AI 的角色,并明确要求它”根据执行结果提供自然语言反馈”
2user用户的原始指令(与第一次请求相同)
3assistantAI 的第一次响应,包含它要求调用的 Tool Calls。注意这里不是 content,而是 tool_calls
4toolESP32 的执行结果反馈,告诉 AI “我已经执行了操作,这是结果”

重点解析:role: "tool" 消息

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

这是 Follow-up 机制中最关键的部分:

  • role: “tool”:声明这是一条”工具执行结果”消息。AI 平台看到这个角色,就知道这不是用户说的话,而是某个 Tool Call 的执行结果。
  • tool_call_id:必须与第一次响应中 AI 返回的 tool_calls[0].id 完全一致。这是 AI 平台匹配”哪个工具调用对应哪个结果”的依据。
  • content:执行结果的描述文本。这段文字会被 AI 模型读取,并作为生成自然语言反馈的参考。

💡 为什么需要 tool_call_id 因为一次对话中可能有多个 Tool Calls(比如同时开灯和调节亮度),AI 需要通过 ID 来确认每个 tool 消息对应的是哪个工具调用。

重点解析:role: "assistant" 中的 tool_calls

    {
      "role": "assistant",
      "tool_calls": [
        {
          "id": "call_a4b2372b98b1492681255a",
          "type": "function",
          "function": {
            "name": "controlLEDBrightness",
            "arguments": "{\"level\": 5}"
          }
        }
      ]
    }

这条消息完全复制了 AI 第一次返回的 Tool Calls 内容。它的作用是告诉 AI:”这是你刚才要求我执行的操作”。这样 AI 就能理解上下文,知道”我要求调亮度到5,现在 ESP32 告诉我已经执行了”。

第四阶段:解析 AI 的自然语言反馈

AI 收到第二次请求后,会分析对话历史:

  1. 用户要求”LED亮度调节为最大”
  2. AI 之前要求调用 controlLEDBrightness(level=5)
  3. ESP32 报告”LED亮度已成功调节到最亮级别”

基于这些信息,AI 会生成一段自然语言回复,例如:

“已将LED亮度调节到最大值,现在灯光最亮哦!”

第二次响应 JSON 结构

为了便于您的理解,我们将简化后的第二次响应 JSON展示在下面。

{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "content": "已将LED亮度调节到最大值,现在灯光最亮哦!✨",
        "role": "assistant"
      }
    }
  ],
  "created": 1782587267,
  "id": "chatcmpl-4dc8fbf-1fb-9af9d-904f4dd-60286b17cd",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 16,
    "prompt_tokens": 90,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 106
  }
}

注意这里的 finish_reason 是 "stop" 而不是 "tool_calls",说明 AI 这次没有要求调用工具,而是直接给出了文本回复。ESP32 只需要提取 message.content 并显示出来即可。

本节示例程序

为了帮助您更好的理解,我们将通过以下示例程序向您讲解 Follow-up 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模型的Follow-up Tool Calls功能。
 * 程序首先通过Tool Calls控制LED,然后将执行结果再次发送给LLM,获取更自然的用户反馈。
 * 
 * Follow-up Tool Calls流程:
 * 1. 第一次请求:发送用户指令,获取Tool Calls响应
 * 2. 执行Tool Calls:控制LED的开关或亮度
 * 3. 第二次请求:将Tool Calls响应和执行结果发送给LLM
 * 4. 获取自然语言反馈:LLM根据执行结果生成自然语言回复
 * 
 * 本程序支持的指令类型:
 * - 打开/关闭LED(如"打开LED"、"关闭LED")
 * - 调节LED亮度(如"把LED调到亮度最大"、"将LED亮度调到中等")
 * 
 * 作者:Taichi-Maker
 * 作者官网:http://ai.taichi-maker.com
 * 创建日期:2026年06月27日
 * 版本:1.1.7
 * 
 * 硬件要求:
 * - 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配置
#define DATA_PIN 48
#define NUM_LEDS 1
CRGB leds[NUM_LEDS];

String userCommand = "LED亮度调节为最大";

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";

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": [
        {
          "id": "{{TOOL_CALL_ID}}",
          "type": "function",
          "function": {
            "name": "{{TOOL_CALL_NAME}}",
            "arguments": "{{TOOL_CALL_ARGUMENTS}}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "{{TOOL_CALL_ID}}",
      "content": "{{EXECUTION_RESULT}}"
    }
  ],
  "temperature": 0.1
})rawliteral";

WiFiClientSecure client;
HTTPClient https;

String firstToolCallResponse = "";
String firstToolCallId = "";
String firstToolCallName = "";
String firstToolCallArguments = "";
String toolExecutionResult = "";

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.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("========== Follow-up Tool Calls 示例开始 ==========");
  callAIPlatformFirst();
  callAIPlatformSecond();
  Serial.println("\n========== Follow-up Tool Calls 示例结束 ==========");
}

void loop() {
  delay(10);
}

// ==================== LED控制函数(FastLED版本) ====================

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;
}

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响应并执行操作
        parseToolCallResponse(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体
 * 
 * 这种方法使第一次请求的结构更加直观,便于学习者理解Tool Calls的实现
 * 
 * 返回值:
 * - 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. 解析第一次Tool Calls响应,提取关键信息
 * 3. 替换模板中的占位符为实际值
 * 4. 返回完整的第二次请求JSON体
 * 
 * 这种方法使第二次请求的结构更加直观,便于学习者理解Follow-up Tool Calls的实现
 * 
 * 返回值:
 * - String: 构造好的第二次请求JSON体
 */
String buildSecondPayload() {
  // 解析第一次Tool Calls响应
  DynamicJsonDocument firstDoc(4096);
  DeserializationError error = deserializeJson(firstDoc, firstToolCallResponse);
  
  if (error) {
    Serial.print("第一次响应JSON解析失败: ");
    Serial.println(error.c_str());
    return "";
  }
  
  // 从模板开始构建请求体
  String payload = ai_payload_second_template;
  
  // 准备替换的值
  String escapedArguments = firstToolCallArguments;
  // 转义参数字符串中的双引号,确保JSON格式正确
  escapedArguments.replace("\"", "\\\"");
  
  // 替换模板中的占位符
  payload.replace("{{USER_COMMAND}}", userCommand);
  payload.replace("{{TOOL_CALL_ID}}", firstToolCallId);
  payload.replace("{{TOOL_CALL_NAME}}", firstToolCallName);
  payload.replace("{{TOOL_CALL_ARGUMENTS}}", escapedArguments);
  payload.replace("{{EXECUTION_RESULT}}", toolExecutionResult);
  
  return payload;
}



/* 
 * -------------- 解析第一次Tool Call响应的函数--------------
 * 
 * 该函数完成以下步骤:
 * 1. 使用ArduinoJson库解析JSON响应
 * 2. 检查响应中是否包含tool_calls字段
 * 3. 提取函数名称和参数
 * 4. 调用统一的LED控制函数执行操作
 * 5. 存储Tool Calls信息用于第二次请求
 */
void parseToolCallResponse(String jsonResponse) {
  // 解析JSON响应
  DynamicJsonDocument doc(4096);
  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) {
      JsonObject toolCall = message["tool_calls"][0];
      JsonObject function = toolCall["function"];
      String functionName = function["name"].as<String>();
      
      // 存储Tool Calls信息
      firstToolCallId = toolCall["id"].as<String>();
      firstToolCallName = functionName;
      firstToolCallArguments = function["arguments"].as<String>();
      
      Serial.print("检测到工具调用: ");
      Serial.println(functionName);
      Serial.print("Tool Call ID: ");
      Serial.println(firstToolCallId);
      Serial.print("Tool Call参数: ");
      Serial.println(firstToolCallArguments);
      
      if (functionName == "controlLEDOnOFF") {
        // 解析LED开关函数参数
        DynamicJsonDocument argsDoc(512);
        DeserializationError argsError = deserializeJson(argsDoc, firstToolCallArguments);
        
        if (argsError) {
          Serial.print("参数解析失败: ");
          Serial.println(argsError.c_str());
          return;
        }
        
        String action = argsDoc["action"].as<String>();
        
        Serial.print(">>> 第二步:执行Tool Calls,控制LED开关...");
        Serial.print("执行动作: ");
        Serial.println(action);
        
        // 调用LED开关控制函数并保存执行结果
        toolExecutionResult = controlLEDOnOFF(action);
        
      } else if (functionName == "controlLEDBrightness") {
        // 解析LED亮度函数参数
        DynamicJsonDocument argsDoc(512);
        DeserializationError argsError = deserializeJson(argsDoc, firstToolCallArguments);
        
        if (argsError) {
          Serial.print("参数解析失败: ");
          Serial.println(argsError.c_str());
          return;
        }
        
        int level = argsDoc["level"].as<int>();
        
        Serial.print(">>> 第二步:执行Tool Calls,控制LED亮度...");
        Serial.print("执行亮度调节: ");
        Serial.println(level);
        
        // 调用LED亮度控制函数并保存执行结果
        toolExecutionResult = controlLEDBrightness(level);
        
      } else {
        Serial.print("未知函数: ");
        Serial.println(functionName);
      }
    } 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("无法解析第二次响应");
  }
}

/*
========================================================================
 【附录】本程序实际发送的HTTP请求内容(供参考)
========================================================================

第一次请求:

POST /compatible-mode/v1/chat/completions HTTP/1.1
Host: dashscope.aliyuncs.com
Content-Type: application/json
Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx
Content-Length: [自动计算]

{
  "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
}

第一次响应 
{
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "message": {
        "content": "",
        "role": "assistant",
        "tool_calls": [
          {
            "function": {
              "arguments": "{\"level\": 5}",
              "name": "controlLEDBrightness"
            },
            "id": "call_a4b2372b98b1492681255a",
            "index": 0,
            "type": "function"
          }
        ]
      }
    }
  ],
  "created": 1782587266,
  "id": "chatcmpl-e5b396d-99d7-4702-ac18-ed87f61aefb0f",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 21,
    "prompt_tokens": 275,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 296
  }
}
第二次请求(Follow-up Tool Calls):

POST /compatible-mode/v1/chat/completions HTTP/1.1
Host: dashscope.aliyuncs.com
Content-Type: application/json
Authorization: Bearer sk-xxxxxxxxxxxxxxxxxxxxxxxx
Content-Length: [自动计算]

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

第二次响应:

{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "content": "已将LED亮度调节到最大值,现在灯光最亮哦!✨",
        "role": "assistant"
      }
    }
  ],
  "created": 1782587267,
  "id": "chatcmpl-4dc8fbf-1fb-9af9d-904f4dd-60286b17cd",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 16,
    "prompt_tokens": 90,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 106
  }
}
 * 
 * 说明:
 * - 第一次请求用于获取Tool Calls响应
 * - 第二次请求包含完整的对话历史,包括Tool Calls和执行结果
 * - 第二次请求中的tool角色消息提供了Tool Calls的执行结果
 * - LLM根据执行结果生成自然语言反馈,提供更友好的用户体验
 * ========================================================================
 */

本程序是在上一节课程示例程序的基础上修改而来,新增了 Follow-up 相关的代码。下面逐段讲解新增和修改的部分。

1. 全局变量:存储第一次 Tool Calls 信息(第169-173行)

// 全局变量,用于存储第一次Tool Calls响应
String firstToolCallResponse = "";
String firstToolCallId = "";
String firstToolCallName = "";
String firstToolCallArguments = "";
// 全局变量,用于存储Tool Calls实际执行结果
String toolExecutionResult = "";

这五个全局变量用于在两次请求之间传递信息

变量名用途
firstToolCallResponse保存 AI 第一次返回的完整 JSON 响应
firstToolCallId保存 Tool Call 的唯一 ID
firstToolCallName保存调用的函数名称
firstToolCallArguments保存调用的参数 JSON 字符串
toolExecutionResult保存 LED 控制函数的执行结果描述

为什么用全局变量?因为 setup() 函数中会先后调用 callAIPlatformFirst() 和 callAIPlatformSecond(),需要一种方式让第二次请求访问第一次的结果。

2. 两个 JSON 请求模板(第78-164行)

与上一节使用一个 ai_payload 不同,本程序定义了两个模板:

const char* ai_payload_first_template = R"rawliteral({...})rawliteral";
const char* ai_payload_second_template = R"rawliteral({...})rawliteral";

第一次请求模板与上一节的 ai_payload 基本相同,只是将用户指令改为 {{USER_COMMAND}} 占位符,方便运行时替换。

第二次请求模板是全新的,它定义了 Follow-up 请求的 JSON 结构:

const char* ai_payload_second_template = R"rawliteral({
  "model": "qwen-flash",
  "messages": [
    {
      "role": "system",
      "content": "你是一个智能家居助手...请根据Tool Calls的执行结果,提供友好的自然语言反馈。"
    },
    {
      "role": "user",
      "content": "{{USER_COMMAND}}"
    },
    {
      "role": "assistant",
      "tool_calls": [
        {
          "id": "{{TOOL_CALL_ID}}",
          "type": "function",
          "function": {
            "name": "{{TOOL_CALL_NAME}}",
            "arguments": "{{TOOL_CALL_ARGUMENTS}}"
          }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "{{TOOL_CALL_ID}}",
      "content": "{{EXECUTION_RESULT}}"
    }
  ],
  "temperature": 0.1
})rawliteral";

模板中有五个占位符,将在运行时被替换为实际值:

占位符替换内容
{{USER_COMMAND}}用户原始指令
{{TOOL_CALL_ID}}AI 返回的 Tool Call ID
{{TOOL_CALL_NAME}}调用的函数名
{{TOOL_CALL_ARGUMENTS}}调用的参数(JSON 字符串)
{{EXECUTION_RESULT}}执行结果描述

需要特殊说明的是,我们在实际操作中,当需要建立类似以上提到的Json请求内容,会使用ArduinoJson库来实现,以保证格式准确。在本程序中,为了让学者可以更好的理解Json请求内容,因此我们将完整的Json内容罗列出来而没有使用ArduinoJson库来构建。

3. setup() 中的两次调用(第205-208行)

Serial.println("========== Follow-up Tool Calls 示例开始 ==========");

Serial.println("\n>>> 第一步:发送第一次请求,获取Tool Calls响应...");
callAIPlatformFirst();

Serial.println("\n>>> 第三步:发送第二次请求,获取自然语言反馈...");
callAIPlatformSecond();

Serial.println("\n========== Follow-up Tool Calls 示例结束 ==========");

setup() 函数中依次调用两个函数,完成完整的 Follow-up 流程。注意串口打印中的”第一步”和”第三步”,中间的”第二步”(执行 Tool Calls)是在 callAIPlatformFirst() 内部完成的。

4. 第一次请求函数:callAIPlatformFirst()(第274-334行)

这个函数与上一节的 callAIPlatform() 基本相同,但有以下关键变化:

变化一:使用 buildFirstPayload() 构造请求体

String firstPayload = buildFirstPayload();

而不是直接使用固定的 ai_payload。这样可以将用户指令动态插入模板。

变化二:保存响应并调用解析函数

firstToolCallResponse = resp;
parseToolCallResponse(resp);

将 AI 的完整响应保存到全局变量,然后调用解析函数。解析函数会提取 Tool Call 信息并执行 LED 控制。

5. 第二次请求函数:callAIPlatformSecond()(第346-403行)

这是全新增加的函数,专门用于发送 Follow-up 请求:

void callAIPlatformSecond() {
  // 构造第二次请求的JSON体
  String secondPayload = buildSecondPayload();
  
  // ...(HTTPS连接、发送POST请求、接收响应)
  
  // 解析并显示自然语言反馈
  parseSecondResponse(resp);
}

流程与第一次请求类似,但:

  • 使用 buildSecondPayload() 构造请求体
  • 响应解析使用 parseSecondResponse(),提取自然语言内容而不是 Tool Calls

6. 构造第一次请求体:buildFirstPayload()(第418-426行)

String buildFirstPayload() {
  // 从模板开始构建请求体
  String payload = ai_payload_first_template;
  
  // 替换模板中的占位符
  payload.replace("{{USER_COMMAND}}", userCommand);
  
  return payload;
}

非常简单:复制模板,替换用户指令占位符。

7. 构造第二次请求体:buildSecondPayload()(第442-469行)⭐

这是 Follow-up 流程中最核心的代码

String buildSecondPayload() {
  // 解析第一次Tool Calls响应
  DynamicJsonDocument firstDoc(4096);
  DeserializationError error = deserializeJson(firstDoc, firstToolCallResponse);

  if (error) {
    Serial.print("第一次响应JSON解析失败: ");
    Serial.println(error.c_str());
    return "";
  }

  // 从模板开始构建请求体
  String payload = ai_payload_second_template;

  // 准备替换的值
  String escapedArguments = firstToolCallArguments;
  // 转义参数字符串中的双引号,确保JSON格式正确
  escapedArguments.replace("\"", "\\\"");

  // 替换模板中的占位符
  payload.replace("{{USER_COMMAND}}", userCommand);
  payload.replace("{{TOOL_CALL_ID}}", firstToolCallId);
  payload.replace("{{TOOL_CALL_NAME}}", firstToolCallName);
  payload.replace("{{TOOL_CALL_ARGUMENTS}}", escapedArguments);
  payload.replace("{{EXECUTION_RESULT}}", toolExecutionResult);

  return payload;
}

关键步骤解析

步骤一:转义双引号

String escapedArguments = firstToolCallArguments;
escapedArguments.replace("\"", "\\\"");

firstToolCallArguments 的值是 {"level": 5},其中包含双引号。如果直接把这个字符串插入到 JSON 模板中,会破坏 JSON 格式:

"arguments": "{"level": 5}"   // ❌ 错误的JSON,内部双引号没有转义

通过将 " 替换为 \",得到 \{"level": 5\},这样插入模板后就是合法的 JSON:

"arguments": "{\"level\": 5}"   // ✅ 正确的JSON

步骤二:使用已保存的执行结果

payload.replace("{{EXECUTION_RESULT}}", toolExecutionResult);

toolExecutionResult 变量在 parseToolCallResponse() 中直接获取 LED 控制函数的返回值得到,包含了人类可读的执行结果描述,例如”LED亮度已成功调节到最亮级别”。

8. 解析第一次响应:parseToolCallResponse()(第483-570行)

这个函数与上一节的 parseToolCallsResponse() 类似,但增加了保存 Tool Call 信息的逻辑,并且直接获取 LED 控制函数的返回值保存到 toolExecutionResult

// 存储Tool Calls信息
firstToolCallId = toolCall["id"].as<String>();
firstToolCallName = functionName;
firstToolCallArguments = function["arguments"].as<String>();

在解析出函数名和参数后,立即保存到全局变量,供第二次请求使用。

关键变化:捕获 LED 控制函数的返回值

// 调用LED开关控制函数并保存执行结果
toolExecutionResult = controlLEDOnOFF(action);

// 调用LED亮度控制函数并保存执行结果
toolExecutionResult = controlLEDBrightness(level);

LED 控制函数(如 controlLEDOnOFF() 和 controlLEDBrightness())会返回执行结果描述(如”LED已成功打开”或”LED亮度已成功调节到最亮级别”),这些返回值直接被保存到 toolExecutionResult 变量中,供第二次请求使用。

9. 解析第二次响应:parseSecondResponse()(第580-606行)⭐

这是全新增加的函数,用于提取 AI 的自然语言反馈:

// 提取并显示自然语言反馈
  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 不同,这里只需要提取 message.content 字段,这就是 AI 生成的自然语言回复。

10. LED 控制函数的返回值

为了让 Follow-up 能够获取执行结果,两个 LED 控制函数都增加了 String 返回值:

String controlLEDOnOFF(String action) {
  String resultDescription = "";
  // ...执行操作...
  resultDescription = "LED已成功打开";
  return resultDescription;
}

String controlLEDBrightness(int level) {
  String resultDescription = "";
  // ...执行操作...
  resultDescription = "LED亮度已成功调节到最亮级别";
  return resultDescription;
}

在 parseToolCallResponse() 中,执行完函数后会将返回值保存到 toolExecutionResult 变量,供第二次请求使用。

AI在第二次接收到请求后,会根据toolExecutionResult的具体内容,得知ESP32具体执行指令的结果,并且会根据toolExecutionResult的具体内容配合请求中的其它信息生成自然语言信息,告知用户ESP32的执行结果。

这里需要说明的是,如果没有这第二次互动,那么ESP32自身只能按照我们的程序逻辑固定的生成程序运行结果信息,如“LED亮度已成功调节到最亮级别”或“LED亮度已成功调节到最低级别”。这是纯粹的机器式答复,不够智能化。

但是有了这第二次互动,那么ESP32就会在AI平台的帮助下,获得更加人性化的指令执行结果信息,向用户汇报结果。

完整交互流程示例

下面以用户指令 “LED亮度调节为最大” 为例,展示 ESP32、AI 平台和硬件之间的完整交互流程。(请注意,以下响应内容可能与实际响应内容存在差异,这里提供的信息仅供参考。)

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

{
  "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
}

第二步:AI 返回的第一次 Tool Calls 响应

{
  "choices": [
    {
      "finish_reason": "tool_calls",
      "index": 0,
      "message": {
        "content": "",
        "role": "assistant",
        "tool_calls": [
          {
            "function": {
              "arguments": "{\"level\": 5}",
              "name": "controlLEDBrightness"
            },
            "id": "call_a4b2372b98b1492681255a",
            "index": 0,
            "type": "function"
          }
        ]
      }
    }
  ],
  "created": 1782587266,
  "id": "chatcmpl-e5b396d-99d7-4702-ac18-ed87f61aefb0f",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 21,
    "prompt_tokens": 275,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 296
  }
}

第三步:ESP32 执行操作

  1. 解析出 controlLEDBrightness(level=5)
  2. 调用 myLumi.setBright(5),LED 变为最亮
  3. 保存 Tool Call ID、函数名、参数和执行结果

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

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

第五步:AI 返回第二次响应(含有自然语言反馈)

{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "content": "已将LED亮度调节到最大值,现在灯光最亮哦!✨",
        "role": "assistant"
      }
    }
  ],
  "created": 1782587267,
  "id": "chatcmpl-4dc8fbf-1fb-9af9d-904f4dd-60286b17cd",
  "model": "qwen-flash",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 16,
    "prompt_tokens": 90,
    "prompt_tokens_details": {
      "cached_tokens": 0
    },
    "total_tokens": 106
  }
}

第六步:ESP32 显示反馈

串口监视器输出:

>>> LLM自然语言反馈:
已将LED亮度调节到最大值,现在灯光最亮哦!✨

为什么第二次请求中 assistant 的 tool_calls 很重要?

有些初学者可能会有疑问:为什么第二次请求中,需要把 AI 第一次返回的 tool_calls 原样发回去?不能只发 role: "tool" 消息吗?

答案是:必须发送完整的对话历史,包括 assistant 的 tool_calls

原因如下:

  1. 对话连续性:AI 模型是无状态的,每次请求都是独立的。如果不告诉它”你之前要求我做了什么”,它就不知道上下文。
  2. ID 匹配role: "tool" 消息通过 tool_call_id 与 assistant 消息中的 tool_calls 进行匹配。AI 平台需要验证这个对应关系。
  3. 逻辑一致性:AI 需要知道”我要求调亮度到5″,才能理解”执行结果是调到最亮”的含义,从而生成合理的回复。

如果把第二次请求比作一场对话复盘,那么:

  • system 是会议规则
  • user 是用户提出的需求
  • assistant 是 AI 之前下达的指令
  • tool 是执行者(ESP32)汇报的执行结果

只有四者齐全,AI 才能做出恰当的总结和回应。

实践建议

1. 执行结果描述要清晰

role: "tool" 的 content 内容直接影响 AI 生成的反馈质量。建议:

  • 说明操作是否成功
  • 包含具体的参数值
  • 使用简洁明了的语言
较差的描述较好的描述
“OK”“LED已成功打开”
“done”“LED亮度已成功调节到最亮级别(级别5)”
“失败”“LED控制失败:无效的亮度级别,必须在1-5之间”

2. 处理执行失败的情况

如果 Tool Call 执行失败(比如参数无效、硬件故障),tool 消息应该如实报告:

{
  "role": "tool",
  "tool_call_id": "xxx",
  "content": "LED控制失败:无效的亮度级别8,必须在1-5之间"
}

AI 收到后可能会生成:

“抱歉,无法将LED调到级别8,亮度级别只能在1到5之间。”

3. 注意 tool_call_id 的准确性

tool 消息中的 tool_call_id 必须与 assistant 消息中的 tool_calls[].id 完全一致。如果 ID 不匹配,AI 平台可能会返回错误,或者 AI 模型无法正确理解上下文。

总结

本节课程讲解了 Follow-up Tool Calls 的完整实现流程:

  1. 第一次请求:发送用户指令和工具定义,获取 AI 的 Tool Calls 响应
  2. 执行并保存:解析 Tool Calls,执行硬件操作,保存 Tool Call ID、函数名、参数和结果
  3. 第二次请求:构造包含完整对话历史的 Follow-up 请求,关键是添加 role: "tool" 消息
  4. 获取反馈:解析 AI 返回的自然语言回复,呈现给用户

通过 Follow-up Tool Calls,我们让 AI 助手实现了**”既能做事,也能说话”**的完整交互体验。这在智能音箱、智能家居控制、机器人交互等场景中非常实用。

在下一节课程中,我们将进一步扩展 Tool Calls 的应用场景,探索更多有趣的 AIoT 交互方式。