使用流式 API 构建多模态点餐体验

本指南为工程师提供了使用 FoodOrderingService.BidiProcessOrder RPC 方法构建订餐体验的说明和最佳实践。 这种实时双向流式传输 API 是订餐 AI 代理的核心,可在移动应用、语音助理、汽车餐厅和自助服务终端等各种应用中实现动态对话式订餐。

BidiProcessOrder 概览

BidiProcessOrder 方法会在客户端应用与订餐 AI 代理之间建立持久的双向通信渠道。与标准的一元请求和响应 RPC 不同,这种流式传输方法允许:

  • 低延迟互动: 持续交换信息,而无需重复 HTTP 请求的开销。
  • 多模态输入: 处理音频流(用于语音订餐)、文本输入和客户端事件。
  • 实时响应: 代理可以在对话展开时发回音频、文本、订单更新和其他信号。

无法 使用 REST 调用 BidiProcessOrder。集成必须使用面向连接的协议:

  • gRPC(推荐): 为双向流式传输提供强大而高效的框架。
  • WebSocket: 适用于因编程语言或网络限制而不适合使用 gRPC 的客户端或环境。

如需详细了解类型定义,请参阅 BidiProcessOrder API 参考文档 。WebSocket 集成使用这些类型的 JSON 表示法 ,如 WebSocket 部分中所述。

前提条件

在与 BidiProcessOrder 集成之前,请执行以下操作:

  1. 启用 API: 确保在您的 Google Cloud 项目中 启用了订餐 AI 代理 API。bash gcloud services enable foodorderingaiagent.googleapis.com --project=PROJECT_ID

  2. 身份验证: 确定身份验证方法,并设置任何 必要的服务账号和 IAM 角色,如 身份验证中所述。

  3. 菜单提取: 必须提取有效的 菜单 并将其与 Store 相关联。如需了解详情,请参阅集成菜单 数据

身份验证

如需安全地连接到 BidiProcessOrder RPC,您的应用必须 使用 Google Cloud 服务账号进行身份验证。

1. 配置服务账号

  • 创建服务账号: 在您的 Google Cloud 项目中,创建一个服务 账号,供您的应用用于向订餐 AI 代理 API 进行身份验证。 请参阅创建和管理服务 账号
  • 授予 IAM 角色:向此服务帐号授予必要的 IAM 角色。调用 BidiProcessOrder 所需的主要角色是:

    • 订餐代理用户 (roles/foodorderingaiagent.agentUser):允许服务帐号连接到订餐服务并处理会话。

    您可以使用 Google Cloud 控制台或 gcloudbash gcloud projects add-iam-policy-binding PROJECT_ID \ --member="serviceAccount:SERVICE_ACCOUNT_EMAIL" \ --role="roles/foodorderingaiagent.agentUser"

2. 应用身份验证流程

确切的身份验证流程取决于您的应用架构,尤其是客户端应用(例如移动应用、自助服务终端软件)是直接连接还是通过您自己的后端连接。

常见场景:对面向消费者的客户端应用进行身份验证

这是移动应用或 Web 应用的典型模式:

  1. Client-to-YourAuth:: 最终用户客户端应用(移动应用、Web 应用)使用您现有的用户身份验证系统(可以是 Firebase Authentication、您自己的 OAuth 服务器等)进行身份验证。
  2. 令牌交换: 客户端应用在对用户进行身份验证后,会向您控制的安全后端服务(例如“API 令牌服务”)请求短期令牌。
  3. 生成访问令牌: 您的后端服务使用在第 1 步中配置的 服务账号主账号的凭据,为 https://www.googleapis.com/auth/cloud-platform范围生成 标准 OAuth 2.0 访问令牌。 Google Cloud 这可以使用 Google Cloud 身份验证客户端 库完成。

    • 安全: 用于生成这些令牌的服务账号密钥或凭据必须安全地存储在您的后端并由您的后端管理。切勿直接向最终用户客户端应用公开服务帐号私钥。 请参阅管理服务帐号的最佳实践。
  4. 令牌到客户端: 您的后端服务会将生成的 Google 访问令牌返回给客户端应用。

  5. API 调用: 客户端应用使用此 Google 访问令牌对其与 BidiProcessOrder RPC 的 gRPC 或 WebSocket 连接进行身份验证。

3. 使用令牌

  • gRPC: Google gRPC 客户端库通常会在提供服务帐号凭据时处理令牌刷新和在调用元数据中包含令牌。
  • WebSocket(非浏览器): 在 Authorization: Bearer TOKEN 标头中包含令牌。
  • WebSocket(浏览器): 如 WebSocket 部分中所述, 直接浏览器 WebSocket 连接无法使用授权标头。您需要使用 服务器端流式传输代理来对客户端与 的连接进行身份验证 Google Cloud。

连接到 API

您可以使用 gRPC 客户端库或 WebSocket 连接建立流。

gRPC

建议使用 gRPC。您将使用所选 语言(例如 Node.js)的客户端库,这些库基于 BidiProcessOrder API 参考文档

基本步骤包括:

  1. 为订餐 AI 代理 API 端点(例如 foodorderingaiagent.googleapis.com)创建 gRPC 渠道。
  2. 获取 FoodOrderingService 的客户端桩。
  3. 调用 BidiProcessOrder 方法,该方法会返回一个流对象,用于发送请求和接收响应。
  4. 根据您的用例实现业务逻辑,该逻辑会同时执行以下操作:
    • 发送来自最终用户的音频、文本和事件输入。
    • 处理来自代理的消息,包括音频、文本和事件。

Node.js


const {FoodOrderingServiceClient} = require('@google-cloud/foodorderingaiagent');

const client = new FoodOrderingServiceClient();

// The stream is initialized immediately. You can now write commands and attach listeners.

const stream = client.bidiProcessOrder();

WebSocket

对于 WebSocket 连接,网址路径为:

wss://foodorderingaiagent.googleapis.com/ws/google.cloud.foodorderingaiagent.v1beta.FoodOrderingService/BidiProcessOrder/locations/LOCATION

  • LOCATION:例如 us

所需标头

  • AuthorizationBearer TOKEN - 其中 TOKEN 是为 您的服务帐号获取的 OAuth 2.0 访问令牌。

消息格式

  • 客户端到服务器: 发送到 API 的消息(例如 ConfigAudioInputTextInputEventInput)必须是 BidiProcessOrderRequest 原型的 JSON 表示法,以 websocket.TextMessage 的形式发送。
  • 服务器到客户端: 从 API 收到的消息 (BidiProcessOrderResponse) 将以 websocket.BinaryMessage 的形式发送,但这些二进制消息的内容是 JSON 载荷。
  • 二进制数据: JSON 载荷中的二进制数据(例如 AudioInput 中的 customerAudioAgentAudio 中的 agentAudio)必须采用 base64 编码。

Node.js WebSocket 示例

以下示例展示了如何在 Node.js 中使用 ws 库通过 WebSockets 连接到 API 并与之互动:

const WebSocket = require('ws');

// Replace with your actual values
const location = 'LOCATION';
const projectId = 'PROJECT_ID';
const sessionId = 'SESSION_ID';
const brandId = 'BRAND_ID';
const storeId = 'STORE_ID';
const token = 'OAUTH_TOKEN';

const wsUrl = `wss://foodorderingaiagent.googleapis.com/ws/google.cloud.foodorderingaiagent.v1beta.FoodOrderingService/BidiProcessOrder/locations/${location}`;

const ws = new WebSocket(wsUrl, {
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

ws.on('open', () => {
  console.log('Connected to WebSocket');

  // 1. Send the required initial Config message
  const configRequest = {
    config: {
      session: `projects/${projectId}/locations/${location}/sessions/${sessionId}`,
      store: `projects/${projectId}/locations/${location}/brands/${brandId}/stores/${storeId}`
    }
  };

  // Client-to-server messages are sent as TextMessage
  ws.send(JSON.stringify(configRequest));
  console.log('Sent Config message');
});

ws.on('message', (data, isBinary) => {
  // The documentation specifies that server-to-client messages
  // are sent as BinaryMessage containing a JSON payload.
  if (isBinary) {
    try {
      const response = JSON.parse(data.toString('utf8'));
      console.log('Received response:', response);

      if (response.agentText) {
        console.log(`Agent: ${response.agentText.text}`);
      }

      if (response.agentAudio) {
        const audioBytes = Buffer.from(response.agentAudio.agentAudio, 'base64');
        console.log(`Received ${audioBytes.length} bytes of agent audio.`);
        // Play or process the audio bytes here
      }

      if (response.endSession) {
        console.log('Session ended by agent.');
        ws.close();
      }
    } catch (e) {
      console.error('Failed to parse JSON response:', e);
    }
  }
});

ws.on('close', () => {
  console.log('Connection closed');
});

会话生命周期

每次调用 BidiProcessOrder 都会启动一个会话。只要流处于打开状态,会话就会保持活跃状态。

1. 启动(配置消息)

  • 建立连接后,客户端发送的第一条消息 必须 是包含 Config 消息的 BidiProcessOrderRequest
  • Config 中的**必填字段**
    • session:客户端生成的唯一会话标识符。格式: projects/PROJECT/locations/LOCATION/sessions/SESSION_ID
    • storeStore 的资源名称。格式: projects/PROJECT/locations/LOCATION/brands/BRAND/stores/STORE.
      • 代理使用 store 加载相应的菜单和配置。
    • mode(对于 BidiProcessOrder 是可选的):默认为 HYBRID(语音和文本)。请注意,如果您使用的是一元 REST 或 gRPC ProcessOrder API 而不是 BidiProcessOrder,则必须将 mode 显式设置为 TEXT

Node.js

// Send the first message containing Config
stream.write({
  config: {
    session: client.sessionPath(projectId, location, sessionId),
    store: client.storePath(projectId, location, brandId, storeId),
  }
});

2. 发送输入

  • 在初始 Config 之后,客户端可以发送包含以下输入之一的 BidiProcessOrderRequest 消息流:
    • AudioInput:原始音频数据(通常为 16 位线性 PCM,采样率为 16000 Hz,无标头)。 用于语音互动。
    • TextInput:来自用户的文本消息。
    • EventInput:用于事件的信号,例如 DriveOffEvent(适用于车辆离开时的汽车餐厅用例)、CrewInterjectionEvent(适用于在对话过程中由人工接管订餐角色的任何情况)或 OrderStateUpdateEvent(如果订单在客户端进行了修改,例如使用触摸界面)。

Node.js

// Stream user inputs over the active connection
stream.write({textInput: {text: 'Hi, I\'d like to order a cheeseburger.'}});

3. 接收响应

  • 同时,代理会发回 BidiProcessOrderResponse 消息流。您的客户端必须准备好处理 oneof response 字段中的各种响应类型:
    • AgentAudio:要播放给用户的合成音频字节,用于语音互动。
    • AgentText:代理响应的文本版本。
    • SpeechRecognition:识别的用户语音的转录。
    • UpdatedOrderState: 每当代理更新客户的 Order 时,都包含该订单的完整当前状态。使用此字段更新应用的订单表示。这通常会导致更新用户界面或订单状态信息的记录系统(例如销售终端系统)。
    • InterruptionSignal:表示用户打断了代理的语音。客户端应立即停止播放任何传出的 AgentAudio
    • AgentEvent: 特殊事件,例如 RestartOrder, 需要客户端操作。
    • SuggestedOptions:提供用户接下来可能会选择的上下文相关选项,有助于在屏幕上显示。
    • EndSession:表示会话已由代理终止(例如订单完成、用户离开或代理升级)。

Node.js

// Attach event listeners to handle responses sequentially
stream.on('data', (response) => {
  if (response.agentAudio) {
    console.log(`Received ${response.agentAudio.agentAudio.length} bytes of agent audio.`);
  } else if (response.agentText) {
    console.log(`Agent: ${response.agentText.text}`);
  } else if (response.speechRecognition) {
    console.log(`Recognized User Speech: ${response.speechRecognition.transcript}`);
  } else if (response.updatedOrderState) {
    console.log('Order updated.');
  } else if (response.interruptionSignal) {
    console.log('User interrupted the agent. Stop playing audio!');
  } else if (response.endSession) {
    console.log(`Session ended. Type: ${response.endSession.type}, Reason: ${response.endSession.reason}`);
    stream.end();
  }
});

stream.on('error', (err) => {
  console.error('Stream error:', err);
});

4. 关闭流

  • 流可以由客户端或服务器关闭。通常,服务器使用 EndSession 消息表示对话结束。客户端应在收到此消息时关闭流。

处理特定消息类型

以下部分介绍了如何处理客户端在调用 BidiProcessOrder 时收到的特定响应类型。

AudioInput

  • 在音频块可用时,以块的形式流式传输音频。
  • 格式:16 位线性 PCM,采样率为 16000 Hz。
  • 音频块不包含通常以 WAV 文件为前缀的音频标头。
  • 对于启用了回声消除功能(Config 中的 enable_echo_cancellation)的汽车餐厅场景,请同时提供 customer_audiocrew_audio

UpdatedOrderState

  • 此消息每次发送时都会提供订单的完整状态。 将订单的任何本地缓存替换为收到的 Order 消息的内容。
  • 使用 Order 项和修饰符中的 custom_integration_attributesOrder 内容映射到应用记录系统中的等效实体。

InterruptionSignal

  • 收到后,立即停止播放任何 AgentAudio 并清除任何缓冲的代理音频。这样可确保在用户打断代理的语音时实现自然的对话流程。

EndSession

  • 检查 EndType(例如 DRIVE_OFFAGENT_ESCALATION)。
  • 您的应用应正常关闭连接并适当地转换用户(例如,在 AGENT_ESCALATION 的情况下通知人工主管,或转换为订单确认状态)。

最佳实践

  • 异步处理消息: 使用线程或非阻塞 I/O 并发发送请求和处理传入的响应,以最大限度地减少延迟时间。
  • 重新连接逻辑: 在出现网络问题时实现强大的重新连接逻辑,并记住发送具有相同会话 ID 的初始 Config 消息以尝试恢复。
  • 错误处理: 监控流是否存在错误。gRPC 和 WebSocket 库提供了检测流关闭或传输错误的机制。 记录这些事件并妥善处理。
  • 音频缓冲: 仔细管理音频缓冲区,并在必要时实现缓冲,以确保顺利播放 AgentAudio 并及时传送 AudioInput。在确定缓冲方案时,请仔细考虑延迟时间和播放质量之间的权衡取舍。
  • 会话 ID 管理: 确保每个不同的订单/对话的会话 ID 都是唯一的。
  • 资源管理: 在会话完成或发生不可恢复的错误时,关闭流并释放资源。
  • 超时: 虽然流本身可以是长期存在的(默认最长为 15 分钟),但如果需要,可以考虑针对特定状态设置应用级超时。

集成流程示例(概念)

  1. 客户端应用(例如移动应用)发起订单。
  2. 建立与 BidiProcessOrder 的 gRPC/WebSocket 连接。
  3. 发送带有 Config(会话 ID、商店 ID)的 BidiProcessOrderRequest
  4. 接收初始 AgentAudio(例如欢迎辞)并播放。
  5. 用户说话:捕获音频,在 AudioInput 消息中流式传输。
  6. 接收 SpeechRecognition(显示转录)、AgentAudio(播放响应)以及可能的 UpdatedOrderState(更新界面购物车)。
  7. 如果用户打断,则接收 InterruptionSignal,停止播放。
  8. 继续交换音频或文本输入和代理响应。
  9. 用户确认订单:代理发送最终 UpdatedOrderState
  10. 代理发送 EndSession:客户端关闭流,并使用来自最后一个 UpdatedOrderState 的数据在 POS 系统中完成订单。

端到端示例

虽然上述说明逐一介绍了流式传输概念,但下面展示了完整的端到端集成流程。

Node.js

在尝试此示例之前,请按照《订餐 AI 代理快速入门:使用客户端库》中的 Node.js 设置说明执行操作。

如需向订餐 AI 代理进行身份验证,请设置应用默认凭据。 如需了解详情,请参阅 为本地开发环境设置身份验证

const {FoodOrderingServiceClient} = require('@google-cloud/foodorderingaiagent');

async function bidiProcessOrderSample(projectId, location, brand, store, sessionId) {
  const client = new FoodOrderingServiceClient();

  // Create the resource names
  const sessionPath = client.sessionPath(projectId, location, sessionId);
  const storePath = client.storePath(projectId, location, brand, store);

  // Initialize the stream using gRPC. See the WebSocket section for the equivalent WebSocket implementation.
  const stream = client.bidiProcessOrder();

  // Attach event listeners to handle responses sequentially
  stream.on('data', (response) => {
    if (response.agentAudio) {
      console.log(`Received ${response.agentAudio.agentAudio.length} bytes of agent audio.`);
    } else if (response.agentText) {
      console.log(`Agent: ${response.agentText.text}`);
    } else if (response.speechRecognition) {
      console.log(`Recognized User Speech: ${response.speechRecognition.transcript}`);
    } else if (response.updatedOrderState) {
      console.log('Order updated.');
    } else if (response.interruptionSignal) {
      console.log('User interrupted the agent. Stop playing audio!');
    } else if (response.endSession) {
      console.log(`Session ended. Type: ${response.endSession.type}, Reason: ${response.endSession.reason}`);
      stream.end();
    }
  });

  stream.on('error', (err) => {
    console.error('Stream error:', err);
  });

  // 1. Send the first message containing Config
  stream.write({
    config: {
      session: sessionPath,
      store: storePath,
    }
  });

  // 2. Stream user inputs over the active connection
  stream.write({textInput: {text: 'Hi, I\'d like to order a cheeseburger.'}});
}