В классических текстовых интерфейсах вызов функций (Function Calling / Tool Use) представляет собой блокирующий, строго последовательный конвейер: пользователь отправляет запрос, модель приостанавливает генерацию текста, возвращает JSON-манифест вызова функции, бэкенд выполняет код, отправляет результат обратно модели, после чего модель возобновляет формирование текстового ответа. Этот синхронный цикл (Round-Trip Time) занимает от 1.5 до 3 секунд.
В голосовых интерфейсах реального времени (Speech-to-Speech) задержка такого масштаба разрушает иллюзию живого диалога. Пользователь ожидает, что система реагирует на слова мгновенно: когда он произносит команду, интерфейс браузера обязан трансформироваться в процессе или непосредственно в момент произнесения подтверждающей фразы агентом, а не после долгой тишины.
Классический последовательный цикл (Блокирующий: 2000 - 3500 мс):
[User Text] ---> [LLM Tool Call] ---> [API Execute] ---> [LLM Final Text] ---> [UI Render]
Real-time Speech & UI Multitasking (Параллельный: < 150 мс):
[User Voice] ====> [Realtime Model]
|
+------------------+------------------+
| (Аудио-поток по RTP) | (Control Event по DataChannel)
v v
[Speaker Plays Audio]: [Client Tool Dispatcher]:
"Фильтрую каталог по размеру..." Zustand Store -> DOM Re-render
(Синтез речи идет параллельно) (Витрина обновлена за 50 мс)Архитектура Real-time Function Calling строится на разделении каналов (Channel Multiplexing) в рамках единой WebRTC или WebSocket сессии:
response.function_call_arguments.delta и done).При проектировании голосового ассистента критически важно строго разграничить два типа инструментов:
router.push), автозаполнения форм, прокрутки экрана (window.scrollTo), управления видеоплеером и визуальной подсветки интерактивных элементов DOM.Клиентский контур управления интерфейсом реализуется как событийно-ориентированный диспетчер инструментов (Client-Side Tool Dispatcher), связанный с глобальным хранилищем состояния приложения.
Client-Side Tool Dispatcher Out-of-Band UI Control response.function_call_arguments.done conversation.item.create (function_call_output) Race Condition GuardПОТОК ДАННЫХ REAL-TIME FUNCTION CALLING В БРАУЗЕРЕ
+-------------------------------------------------------------------------------+
| OpenAI Realtime API / WebRTC Gateway |
+-------------------------------------------------------------------------------+
| |
| (1) Audio Stream (RTP) | (2) JSON: function_call_arguments.done
| "Открываю форму заявки..." | { name: "navigate", args: { path: "/apply" } }
v v
+-----------------------+ +----------------------------------------+
| Web Audio / Speaker | | WebRTC DataChannel Listener |
| Воспроизведение звука | +----------------------------------------+
+-----------------------+ |
v
+----------------------------------------+
| Zod Schema Validator & Tool Registry |
+----------------------------------------+
|
v
+----------------------------------------+
| Zustand Store / Next.js App Router |
| - router.push('/apply') |
| - setHighlightedField('email') |
+----------------------------------------+
|
v
+----------------------------------------+
| DOM Updates: страница сменилась, |
| поле подсвечено синим фокусом |
+----------------------------------------+
|
v (3) JSON: function_call_output
+----------------------------------------+
| RTCDataChannel.send() |
| conversation.item.create & response |
+----------------------------------------+setDashboardDateRange и zoomChartWidget, мгновенно перерисовывая графики.filterProductCatalog, сужая список товаров на экране в реальном времени.fillFormField({ field: 'diagnosis', value: '...' }), перемещает визуальный курсор и валидирует значения по Zod-схеме, подсвечивая заполненные блоки зеленым цветом.Реализация состоит из трех компонентов:
session.update.response.function_call_arguments.done, выполнение функции и передача подтверждения function_call_output.import { z } from "zod";
// Схемы аргументов для клиентских инструментов
export const NavigateSchema = z.object({
route: z.enum(["/dashboard", "/catalog", "/checkout", "/settings"]),
});
export const FilterCatalogSchema = z.object({
category: z.string().optional(),
maxPrice: z.number().positive().optional(),
inStockOnly: z.boolean().default(true),
searchQuery: z.string().optional(),
});
export const HighlightElementSchema = z.object({
elementId: z.string(),
pulseCount: z.number().int().min(1).max(5).default(2),
});
// Манифест инструментов для OpenAI Realtime API (session.update)
export const CLIENT_TOOLS_MANIFEST = [
{
type: "function",
name: "navigateToRoute",
description: "Перенаправляет пользователя на указанную страницу веб-приложения.",
parameters: {
type: "object",
properties: {
route: {
type: "string",
enum: ["/dashboard", "/catalog", "/checkout", "/settings"],
description: "Целевой маршрут приложения",
},
},
required: ["route"],
},
},
{
type: "function",
name: "filterCatalog",
description: "Фильтрует витрину товаров по категории, цене и наличию.",
parameters: {
type: "object",
properties: {
category: { type: "string", description: "Категория товара" },
maxPrice: { type: "number", description: "Максимальная цена в USD" },
inStockOnly: { type: "boolean", description: "Только товары в наличии" },
searchQuery: { type: "string", description: "Поисковый запрос" },
},
},
},
{
type: "function",
name: "highlightElement",
description: "Визуально подсвечивает элемент интерфейса для привлечения внимания пользователя.",
parameters: {
type: "object",
properties: {
elementId: { type: "string", description: "DOM ID целевого элемента" },
pulseCount: { type: "number", description: "Количество циклов пульсации" },
},
required: ["elementId"],
},
},
] as const;import { create } from "zustand";
interface FilterState {
category?: string;
maxPrice?: number;
inStockOnly: boolean;
searchQuery?: string;
}
interface UIStoreState {
filters: FilterState;
highlightedElementId: string | null;
activeRoute: string;
setFilters: (filters: Partial<FilterState>) => void;
setHighlightedElement: (id: string | null) => void;
setActiveRoute: (route: string) => void;
}
export const useUIStore = create<UIStoreState>((set) => ({
filters: { inStockOnly: true },
highlightedElementId: null,
activeRoute: "/catalog",
setFilters: (newFilters) =>
set((state) => ({
filters: { ...state.filters, ...newFilters },
})),
setHighlightedElement: (id) =>
set({ highlightedElementId: id }),
setActiveRoute: (route) =>
set({ activeRoute: route }),
}));import { useUIStore } from "@/lib/store/useUIStore";
import {
NavigateSchema,
FilterCatalogSchema,
HighlightElementSchema,
} from "./tools-manifest";
export interface ToolExecutionResult {
success: boolean;
message?: string;
data?: unknown;
}
export class ClientToolDispatcher {
/**
* Выполняет зарегистрированный инструмент на стороне браузера
*/
public static async execute(
name: string,
rawArgs: string
): Promise<ToolExecutionResult> {
try {
const parsedJson = JSON.parse(rawArgs);
switch (name) {
case "navigateToRoute": {
const validated = NavigateSchema.parse(parsedJson);
useUIStore.getState().setActiveRoute(validated.route);
// В реальном приложении: window.history.pushState или router.push
return { success: true, message: `Навигация на ${validated.route} выполнена` };
}
case "filterCatalog": {
const validated = FilterCatalogSchema.parse(parsedJson);
useUIStore.getState().setFilters(validated);
return {
success: true,
data: { appliedFilters: validated },
message: "Фильтры каталога успешно применены",
};
}
case "highlightElement": {
const validated = HighlightElementSchema.parse(parsedJson);
const target = document.getElementById(validated.elementId);
if (!target) {
return {
success: false,
message: `Элемент с ID '${validated.elementId}' не найден в DOM`,
};
}
useUIStore.getState().setHighlightedElement(validated.elementId);
target.scrollIntoView({ behavior: "smooth", block: "center" });
// Сбрасываем подсветку через заданный интервал
setTimeout(() => {
useUIStore.getState().setHighlightedElement(null);
}, validated.pulseCount * 1000);
return { success: true, message: `Элемент ${validated.elementId} подсвечен` };
}
default:
return {
success: false,
message: `Неизвестный клиентский инструмент: ${name}`,
};
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : "Ошибка валидации параметров";
console.error(`[ToolDispatcher] Сбой выполнения ${name}:`, errMsg);
return { success: false, message: errMsg };
}
}
}"use client";
import { useEffect } from "react";
import { ClientToolDispatcher } from "@/lib/realtime/tool-dispatcher";
import { CLIENT_TOOLS_MANIFEST } from "@/lib/realtime/tools-manifest";
interface RealtimeEventData {
type: string;
call_id?: string;
name?: string;
arguments?: string;
[key: string]: unknown;
}
export function useRealtimeToolExecutor(
dataChannel: RTCDataChannel | null,
sendEvent: (event: Record<string, unknown>) => void
) {
useEffect(() => {
if (!dataChannel) return;
// 1. При открытии канала регистрируем инструменты в сессии модели
const handleOpen = () => {
sendEvent({
type: "session.update",
session: {
tools: CLIENT_TOOLS_MANIFEST,
tool_choice: "auto",
},
});
};
// 2. Слушаем события вызова функций от модели
const handleMessage = async (event: MessageEvent) => {
try {
const payload: RealtimeEventData = JSON.parse(event.data);
// Перехватываем завершение генерации вызова функции
if (payload.type === "response.function_call_arguments.done") {
const callId = payload.call_id;
const toolName = payload.name;
const toolArgs = payload.arguments;
if (!callId || !toolName || !toolArgs) {
console.warn("[RealtimeTool] Неполные данные вызова инструмента", payload);
return;
}
// Выполняем мутацию интерфейса
const result = await ClientToolDispatcher.execute(toolName, toolArgs);
// Отправляем результат выполнения функции обратно модели
sendEvent({
type: "conversation.item.create",
item: {
type: "function_call_output",
call_id: callId,
output: JSON.stringify(result),
},
});
// Запрашиваем у модели продолжение аудио-ответа с учетом результата
sendEvent({
type: "response.create",
});
}
} catch (err: unknown) {
console.error("[RealtimeTool] Ошибка обработки входящего события:", err);
}
};
if (dataChannel.readyState === "open") {
handleOpen();
} else {
dataChannel.addEventListener("open", handleOpen);
}
dataChannel.addEventListener("message", handleMessage);
return () => {
dataChannel.removeEventListener("open", handleOpen);
dataChannel.removeEventListener("message", handleMessage);
};
}, [dataChannel, sendEvent]);
}Если клиентский инструмент выполняет сложный синхронный расчет или вызывает синхронную перерисовку тяжелого дерева DOM (Layout Thrashing) в момент, когда Web Audio API воспроизводит входящий аудиопоток, аудио-буфер опустошается (Buffer Underrun). Пользователь слышит щелчки, артефакты или заикание речи агента. Все тяжелые вычисления выносите в Web Workers или планируйте через requestAnimationFrame.
Частая ошибка новичков: отправить событие conversation.item.create с типом function_call_output, но забыть вызвать response.create. В результате результат функции записывается в историю сессии, но модель переходит в состояние бесконечного ожидания и умолкает, не подтвердив выполнение команды голосом.
Событие response.function_call_arguments.delta передает сырые фрагменты JSON-строки по мере их генерации. Попытка вызвать JSON.parse() на промежуточном delta-чанке вызовет синтаксическую ошибку SyntaxError: Unexpected end of JSON input. Парсинг и исполнение следует запускать только по событию response.function_call_arguments.done.
Если пользователь подряд говорит "Открой профиль... нет, вернись в корзину", модель может последовательно инициировать два вызова функций с разницей в 200 мс. Без отмены предыдущих асинхронных операций (например, сетевых запросов загрузки профиля) корзина может быть перезаписана запоздавшим ответом первого вызова.
Браузер отправляет session.update со списком Zod-валидированных Client Tools через RTCDataChannel при старте сессии.
Модель распознает намерение пользователя, начинает синтез аудио-ответа в RTP и параллельно формирует tool_call в DataChannel.
Браузер ловит событие response.function_call_arguments.done, валидирует схему и мутирует стейт Zustand / DOM.
Результат выполнения операции немедленно отправляется обратно событием conversation.item.create (function_call_output).
Событие response.create побуждает модель продолжить речь, органично упомянув результат действия ('Я обновил витрину, найдено 8 моделей').