循序渐进 · AIP 教学 · 其他应用(五)

构建支持语音的 OSDK 应用

这是一篇动手教程:用 OSDK 把实时语音能力接进你自己的应用。注意 —— 录音与知情同意由你负责。

全部目录 AIP 首页 ← 上一篇 构建支持语音的 OSDK 应用 下一篇 →
本文来源 · Source 内容整理自 Palantir Foundry 官方文档:
https://www.palantir.com/docs/foundry/realtime-audio/build-a-voice-enabled-osdk-application/
原始标题:Realtime audio • Build a voice-enabled OSDK application • Palantir · 所属:其他 AIP 应用

先记住这几条

① 用 OSDK 集成
在自有应用里接入语音能力。
② 端到端流程要自己串
音频采集、实时传输、结果处理。
③ 合规责任在开发者
录音与知情同意必须自行处理。
0

写在前面

录音与知情同意由你负责

在部署任何录制或转录人类语音的应用之前,请确保已通知参与者,并在你所在司法管辖区要求的情况下备好同意流程。无论参与者是应用用户、通话中的第三方,还是任何其他语音被采集的人,这可能都适用。请参阅录音、转录与知情同意

本教程将带你构建一个 OSDK 应用,它采集麦克风音频、将其流式传输到实时模型,并播放模型的语音回复。该应用以 Foundry 用户身份进行认证,因此每次交互都基于该用户的权限以及其可访问的 Ontology。关于 Foundry 上实时音频的高层概览,请参阅实时音频

1

你将构建什么

要点:先明确目标产物。

到本教程结束时,你将拥有一个在浏览器中运行的 TypeScript 应用,它可以:

  1. 通过标准的 OSDK OAuth 流程对用户进行认证。
  2. 直接从浏览器打开一条到 Foundry 代理端点的 WebSocket 连接,该端点会转发到实时模型。
  3. 为智能体提供一个从 Ontology 读取数据的工具,并以用户的权限运行。
  4. 将麦克风音频流式传输到模型,并播放模型的语音回复。
2

前置条件

要点:动手前的准备。

在开始之前,请确保你具备以下条件:

  • 一个通过 @osdk/create-app 搭建好的 OSDK 应用。生成的应用包含本教程所依赖的 OAuth 脚手架。关于 OSDK 本身的完整参考,请参阅 Ontology SDK
  • 在 Developer Console 中注册的一个不受限自定义应用。OSDK 前端针对该应用进行认证。需要“不受限”是为了让访问令牌能够包含步骤 1中使用的 language-model-service:use-model scope。
  • 在你的 enrollment 中启用的一个实时模型。列表请参阅可用的音频模型。本教程使用 gpt-realtime-2
  • 在你的应用中安装的 @openai/agents 包:
npm install @openai/agents
3

第 1 步:获取 OAuth 访问令牌

要点:鉴权准备。

@osdk/create-app 生成的 OSDK 应用在 src/client.ts 中包含一个 OAuth client。该 client 使用来自 @osdk/oauthcreatePublicOauthClient 创建,并暴露一个用于获取当前访问令牌的方法。clientIdfoundryUrlredirectUrl 的值在运行时从 @osdk/create-app 根据你的 .env 配置注入到 index.html 中的 osdk-* meta 标签读取。

为 OAuth client 配置 language-model-service:use-model scope,使生成的访问令牌被允许连接到实时端点。没有这个 scope,实时端点会在认证期间拒绝该 WebSocket 连接。该 scope 仅在不受限自定义应用上可用——在继续之前请确认你的应用是不受限的。

下面的代码片段展示了添加实时音频 scope 之后 src/client.ts 的完整形态。该文件的大部分由 @osdk/create-app 生成;本教程唯一需要的改动是在 scopes 数组中包含 language-model-service:use-model。这些导出在教程的其他地方会被使用:auth 用于下方获取令牌,foundryUrl 用于在步骤 2中构建 WebSocket URL,client 用于步骤 3中的 OSDK 查询。

import { createClient, type Client } from "@osdk/client";
import { createPublicOauthClient, type PublicOauthClient } from "@osdk/oauth";

function getMetaTagContent(tagName: string): string {
  const elements = document.querySelectorAll(`meta[name="${tagName}"]`);
  const element = elements.item(elements.length - 1);
  const value = element ? element.getAttribute("content") : null;
  if (value == null || value === "") {
    throw new Error(`Meta tag ${tagName} not found or empty`);
  }
  return value;
}

export const foundryUrl = getMetaTagContent("osdk-foundryUrl");
const clientId = getMetaTagContent("osdk-clientId");
const redirectUrl = getMetaTagContent("osdk-redirectUrl");
const ontologyRid = getMetaTagContent("osdk-ontologyRid");

const scopes = [
  "language-model-service:use-model",
  // Other scopes your application requires, for example:
  // "api:read-data",
  // "api:use-ontologies-read",
];

export const auth: PublicOauthClient = createPublicOauthClient(
  clientId,
  foundryUrl,
  redirectUrl,
  { scopes }
);

export const client: Client = createClient(foundryUrl, ontologyRid, auth);
export default client;

要在运行时获取访问令牌,请调用 auth.getTokenOrUndefined()。如果用户尚未登录,请先调用 auth.signIn() 然后重试:

import { auth } from "../client";

let token = await auth.getTokenOrUndefined();
if (!token) {
  await auth.signIn();
  token = await auth.getTokenOrUndefined();
}
if (!token) {
  throw new Error("Unable to obtain access token. Please try again.");
}
4

第 2 步:连接实时端点

要点:建立长连接。

实时端点在以下 URL 接受 WebSocket 连接,其中 <your-foundry-domain> 是你 Foundry 环境的主机名(与 src/client.ts 中用作 foundryUrl 的值相同),而 model 查询参数用于选择要使用的实时模型:

wss://<your-foundry-domain>/language-model-service/ws/v1/open-ai/realtime?model=gpt-realtime-2

这是一个 Foundry 代理端点,类似于 REST LLM 供应商兼容 API,但通过 WebSocket 暴露。该代理会将会话转发到底层供应商(根据你的 enrollment,为 OpenAI Direct 或 Azure OpenAI),并保留供应商的原生实时协议。因此,你可以不加修改地使用 OpenAI 实时 SDK(@openai/agents/realtime)。

访问令牌作为 WebSocket 子协议值传递,因为浏览器无法在 WebSocket 连接上设置自定义的 Authorization 头。请将该子协议值格式化为 Bearer-<access-token>

将子协议值视为凭据

访问令牌作为 WebSocket 子协议值的一部分,在浏览器的开发者工具网络面板中可见。请将其视为凭据:不要记录它,不要在面向用户的字符串中暴露它,也不要将其传输到除你的 Foundry 端点之外的任何系统。访问令牌是短时效的;WebSocket 会话使用其建立时的令牌,因此长时间运行的会话可能需要在令牌过期时重新连接。

使用 OpenAI 实时 SDK 打开连接,并提供一个自定义的 createWebSocket 函数,用该子协议值构造浏览器的 WebSocket。完整的连接流程封装在一个名为 startVoiceSessionasync 函数中,它在会话就绪后返回。该函数由三个小型具名组成部分构成,下面分别定义,并在本节末尾进行组合。将结果保存为例如 src/voice/session.ts

首先从导入和常量开始:

import {
  RealtimeAgent,
  RealtimeSession,
  OpenAIRealtimeWebSocket,
} from "@openai/agents/realtime";
import { foundryUrl } from "../client";

const PROXY_URL =
  `wss://${new URL(foundryUrl).host}/language-model-service/ws/v1/open-ai/realtime` +
  `?model=gpt-realtime-2`;
const CONNECT_TIMEOUT_MS = 10_000;

定义 createTransport 来构造传输层。createWebSocket 回调用 bearer token 子协议构造浏览器的 WebSocket,并附加记录诊断信息的 error 和 close 处理器。useInsecureApiKey: true 标志会禁用 SDK 中仅针对浏览器的一项防护,该防护禁止使用非临时(non-ephemeral)的 OpenAI key。它在这里不适用,因为认证由 WebSocket 子协议处理,而不是由 OpenAI key 处理。

function createTransport(token: string): OpenAIRealtimeWebSocket {
  return new OpenAIRealtimeWebSocket({
    url: PROXY_URL,
    useInsecureApiKey: true, // auth is via the subprotocol below, not OpenAI's apiKey
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    createWebSocket: async ({ url }: { url: string }): Promise<any> => {
      const ws = new WebSocket(url, [`Bearer-${token}`]);

      ws.addEventListener("error", () => {
        console.error("WebSocket connection failed (check network tab for details)");
      });

      ws.addEventListener("close", (ev) => {
        // 1000 = normal close, do not surface as an error.
        if (ev.code !== 1000) {
          console.error(`WebSocket closed: ${ev.code}${ev.reason ? ` (${ev.reason})` : ""}`);
        }
      });

      return ws;
    },
  });
}

定义 createSession 来构造会话,包含音频输入/输出格式、服务端语音活动检测(server-side voice activity detection),以及一个内联转录模型,以便在对话进行时对用户的语音进行转录:

function createSession(
  agent: RealtimeAgent,
  transport: OpenAIRealtimeWebSocket,
): RealtimeSession {
  return new RealtimeSession(agent, {
    transport,
    model: "gpt-realtime-2",
    tracingDisabled: true,
    config: {
      outputModalities: ["audio"],
      audio: {
        input: {
          format: "pcm16",
          transcription: { model: "whisper-1" },
          turnDetection: {
            type: "server_vad",
            threshold: 0.8,
            silenceDurationMs: 500,
            prefixPaddingMs: 300,
          },
        },
        output: { format: "pcm16" },
      },
    },
  });
}

定义 awaitSessionReady 来等待会话变得可用。调用 session.connect() 会在会话能够收发音频之前就返回,因此要等待 session.created,然后等待第一个 session.updated 事件。其后的 200 毫秒延迟给 SDK 留出稳定的时间;没有它,早期的音频分块可能会丢失。如果会话未在 CONNECT_TIMEOUT_MS 内达到该状态,promise 会以超时错误被拒绝。apiKey 字段未被使用,因为认证由 WebSocket 子协议处理;SDK 只是要求一个非空字符串:

function awaitSessionReady(session: RealtimeSession): Promise<RealtimeSession> {
  return new Promise<RealtimeSession>((resolve, reject) => {
    const timeout = setTimeout(() => {
      reject(new Error("Connection timeout"));
    }, CONNECT_TIMEOUT_MS);

    let created = false;
    let updatedCount = 0;

    session.on("transport_event", (ev) => {
      if (ev.type === "session.created") {
        created = true;
      }
      if (ev.type === "session.updated" && created) {
        updatedCount++;
        if (updatedCount === 1) {
          setTimeout(() => {
            clearTimeout(timeout);
            resolve(session);
          }, 200);
        }
      }
    });

    session.connect({ apiKey: "unused" });
  });
}

最后,将三个组成部分组合为 startVoiceSession

export async function startVoiceSession(
  agent: RealtimeAgent,
  token: string,
): Promise<RealtimeSession> {
  const transport = createTransport(token);
  const session = createSession(agent, transport);
  return awaitSessionReady(session);
}

一旦你从步骤 1 获得了访问令牌并有了智能体定义(见步骤 3),就调用 startVoiceSession(agent, token)。该函数会在会话准备好收发音频时返回。

5

第 3 步:给智能体一个读 Ontology 的工具

要点:让语音助手能查业务数据。

上面的示例搭建了一个通用助手。要让它有用,需要给智能体提供从 Ontology 读取和写入数据的工具。OpenAI 实时 SDK 支持工具调用:定义一个工具,将其注册到智能体上,当模型判断自己需要该信息时就会调用该工具的 execute 函数。

工具的 execute 函数在浏览器中运行,使用用户的 OAuth 令牌和 OSDK client。模型从不直接接触 Ontology——它只看到工具返回的内容。由你决定要暴露哪些数据、要应用哪些筛选条件,以及要写回什么。

下面的示例展示了一个工具桩(stub)。请将函数体替换为你针对自己 Ontology 的 OSDK 查询。关于 OSDK 查询语法和示例,请参阅 TypeScript OSDK

import { RealtimeAgent, tool } from "@openai/agents/realtime";
import { z } from "zod";
import { client } from "../client";

const lookupProduct = tool({
  name: "lookup_product",
  description: "Look up a product by name. Use this when the user asks about a specific product.",
  parameters: z.object({
    name: z.string().describe("The product name to look up"),
  }),
  execute: async ({ name }) => {
    // TODO: replace this stub with an OSDK query against your Ontology.
    // For example, if your Ontology has a Product object type:
    //
    //   const page = await client(Product)
    //     .where({ name: { $startsWith: name } })
    //     .fetchPage({ $pageSize: 5 });
    //   return JSON.stringify(page.data.map((p) => ({
    //     id: p.id, name: p.name, price: p.price, stock: p.stock,
    //   })));
    //
    return JSON.stringify({
      id: "stub-1",
      name,
      price: 0,
      stock: 0,
      note: "Replace the tool body with an OSDK query against your Ontology.",
    });
  },
});

const agent = new RealtimeAgent({
  name: "Assistant",
  instructions:
    "You help users find products. When the user asks about a product, call the lookup_product tool.",
  tools: [lookupProduct],
});

将这个智能体传入步骤 2 中的 startVoiceSession(agent, token)。当用户说话时,模型会决定是否调用该工具。工具的 execute 函数在浏览器中针对 OSDK client 运行,结果会被回传给模型以生成回复。

工具也可以写入 Ontology。要做到这一点,请在工具的 execute 函数中调用一个 action。Actions 会经过标准的 Foundry 权限和校验路径。

6

第 4 步:串流麦克风音频并播放回复

要点:打通双向音频。

给定 startVoiceSession 返回的 RealtimeSession,接下来连接麦克风采集和音频播放。使用浏览器的 MediaDevices API 采集麦克风音频,将其转换为 24 kHz 的 16 位 PCM,并在各分块到达时通过 session.sendAudio(chunk) 将每个分块转发给会话。应用会将从会话接收到的音频分块排队以进行播放。

麦克风采集和播放队列的实现不在本教程范围内。用于接收音频的会话级连接方式大致如下:

const session = await startVoiceSession(agent, token);

// Surface mid-session errors from the SDK. WebSocket-level transport errors
// (such as a failure to establish the connection) are logged to the console
// from the handlers in `createTransport`; if the connection never establishes,
// `startVoiceSession` rejects after `CONNECT_TIMEOUT_MS`.
session.on("error", (event) => {
  console.error("Realtime session error:", event.error);
});

// Play back audio from the model
session.on("audio", (event) => {
  playbackQueue.enqueue(event.data); // your playback implementation
});

// Clear playback when the model is interrupted (for example, the user starts speaking)
session.on("audio_interrupted", () => {
  playbackQueue.clear();
});

// Send mic audio to the model as 16-bit PCM chunks at 24 kHz arrive
startMicCapture((pcm16) => session.sendAudio(pcm16)); // your mic capture implementation

// Optional: prompt the agent to greet the user immediately rather than
// waiting for the user to speak first.
session.sendMessage("(Session started. Greet the user briefly.)");
7

下一步

要点:继续完善的方向。
  • Build out your application. 将会话接入你的前端,添加你的应用所需的工具,并在工具的 execute 函数中调用 OSDK 查询和 actions 以与 Ontology 集成。
  • Improve agent instructions. 关于为实时模型编写有效指令的指引,请参阅 OpenAI 实时提示词指南 ↗

延伸阅读 · 相关页面

按主题横向跳转,不必顺着目录一篇篇读。

本组其他页面 · 其他 AIP 应用

同一主题下的相邻内容。

常见问题速答 · FAQ

关于「构建支持语音的 OSDK 应用」,读者最常问的几个问题。

你将构建什么?
先明确目标产物。到本教程结束时,你将拥有一个在浏览器中运行的 TypeScript 应用,它可以。
前置条件是什么?
动手前的准备。在开始之前,请确保你具备以下条件。
第 1 步:获取 OAuth 访问令牌是什么?
鉴权准备。由 @osdk/create-app 生成的 OSDK 应用在 src/client.ts 中包含一个 OAuth client。该 client 使用来自 @osdk/oauth 的 createPublicOauthClient 创建,并暴露一个…
第 2 步:连接实时端点是什么?
建立长连接。实时端点在以下 URL 接受 WebSocket 连接,其中 <your-foundry-domain> 是你 Foundry 环境的主机名(与 src/client.ts 中用作 foundryUrl 的值相同),而 model 查询参数用于选择…