Классическая интеграция больших языковых моделей через синхронный запрос POST /generate создает неприемлемую задержку отклика (Time to First Token) от 5 до 15 секунд при использовании рассуждающих моделей (Thinking Models). Архитектура автономного AI-ассистента на базе официального SDK нового поколения @google/genai переводит взаимодействие в реальное время: посимвольная трансляция через протокол Server-Sent Events (SSE), автоматическое исполнение локальных функций через Multi-turn Tool Calling и гарантия валидности данных с помощью Structured Outputs.
Создание производственных AI-ассистентов в веб-приложениях требует решения фундаментальных инженерных вызовов:
functionResponse обратно модели, агент не способен решать сквозные задачи.```json), добавляет пояснения или генерирует невалидные запятые. Использование нативной опции responseSchema сжимает пространство декодирования до строгого соответствия типам TypeScript.Официальный SDK @google/genai стандартизирует реализацию этих требований в едином элегантном API.
Агентный цикл со стримингом работает как двусторонний конвейер между браузером, Route Handler в Next.js и нейросетью Gemini.
[ Browser Client (fetch + ReadableStream) ]
│
│ 1. POST /api/chat { prompt, history }
▼
[ Next.js Route Handler (Edge or Node.js) ]
│
│ 2. ai.models.generateContentStream({
│ model: 'gemini-2.5-flash',
│ contents: messages,
│ config: { tools: [weatherTool, dbQueryTool] }
│ })
▼
[ Google Gemini 2.5 Inference Engine ]
│
├── Ветка А: Текстовый чанк ────> [SSE Stream] data: {"text":"..."}\n\n
│ │
│ ▼
│ [ UI: Печать символов ]
│
└── Ветка Б: Function Call ─────> { name: "get_user_orders", args: { id: "u1" } }
│
├── 3. Сервер исполняет локальную функцию
├── 4. Повторный вызов Gemini с functionResponse
▼
[ Финальный стриминг ответа ]Ключевые преимущества SDK нового поколения @google/genai (по сравнению с устаревшим @google/generative-ai):
const ai = new GoogleGenAI({ apiKey }).ai.models.generateContentStream.Создание синглтона GoogleGenAI с чтением GEMINI_API_KEY и описание схем функций (Function Declarations) для Tool Calling.
Создание эндпоинта /api/chat на базе ReadableStream с заголовками text/event-stream и буферизацией чанков.
Перехват вызовов functionCalls, маршрутизация в локальные сервисы и передача functionResponse обратно в модель.
Настройка структурированного JSON-вывода для задач классификации, извлечения сущностей и аналитики.
Чтение ReadableStream в браузере, анимированный вывод текста и карточки подтверждения опасных операций.
Пользователь отправляет запрос: 'Найди заказы клиента u_42 и отправь ему квитанцию'
Gemini 2.5 анализирует запрос и решает вызвать инструмент search_orders
Сервер запрашивает базу данных и возвращает JSON-результат в модель
Перед вызовом send_receipt агент отправляет в стрим запрос подтверждения пользователю
Пользователь нажимает 'Подтвердить отправку' в интерфейсе чата
Модель генерирует итоговый вежливый ответ посимвольно через SSE-поток
Команда модернизирует кодовую базу. Какое утверждение относительно нового пакета @google/genai является технически верным по сравнению со старым @google/generative-ai?
Каким образом Gemini гарантирует, что ответ при responseMimeType: 'application/json' и заданной responseSchema будет на 100% валидным JSON без галлюцинаций синтаксиса?
src/lib/gemini/client.ts#Централизованная инициализация @google/genai с типизацией и декларацией инструментов.
/**
* src/lib/gemini/client.ts
* Инициализация официального Google GenAI SDK (@google/genai) для моделей Gemini 2.5.
*/
import { GoogleGenAI, Type } from '@google/genai'
if (!process.env.GEMINI_API_KEY) {
console.warn('[Warning] Переменная GEMINI_API_KEY не задана в окружении!')
}
// Инициализация синглтона GoogleGenAI
export const ai = new GoogleGenAI({
apiKey: process.env.GEMINI_API_KEY || '',
})
// Константа модели по умолчанию
export const DEFAULT_MODEL = 'gemini-2.5-flash'
/**
* Декларации инструментов (Tools) для Gemini 2.5
*/
export const analyticsTools = [
{
functionDeclarations: [
{
name: 'search_articles',
description: 'Выполняет семантический поиск по опубликованным урокам и воркфлоу Fullstack Agentic Lab.',
parameters: {
type: Type.OBJECT,
properties: {
query: {
type: Type.STRING,
description: 'Ключевые слова для поиска (например: "Zustand", "Docker", "Gutenberg")',
},
track: {
type: Type.STRING,
description: 'Категория: wordpress, react, nextjs, ai-mcp',
},
},
required: ['query'],
},
},
{
name: 'request_action_confirmation',
description: 'Запрашивает подтверждение пользователя (Human-in-the-loop) перед выполнением необратимого действия.',
parameters: {
type: Type.OBJECT,
properties: {
actionName: {
type: Type.STRING,
description: 'Название запрашиваемого действия (например: "Очистка кэша Redis", "Сброс сессий")',
},
severity: {
type: Type.STRING,
description: 'Уровень риска: low, medium, critical',
},
},
required: ['actionName', 'severity'],
},
},
],
},
]src/app/api/chat/route.ts#Серверный эндпоинт со стримингом SSE, обработкой functionCalls и рекурсивной передачей functionResponse.
import { NextRequest, NextResponse } from 'next/server'
import { ai, DEFAULT_MODEL, analyticsTools } from '@/lib/gemini/client'
import { searchArticlesTool } from '@/lib/gemini/tools/analytics-tools'
export const dynamic = 'force-dynamic'
export async function POST(req: NextRequest) {
try {
const { messages } = await req.json()
if (!Array.isArray(messages) || messages.length === 0) {
return NextResponse.json({ error: 'Параметр messages должен быть непустым массивом' }, { status: 400 })
}
const encoder = new TextEncoder()
// Создаем поток ReadableStream для посимвольной отдачи через SSE
const stream = new ReadableStream({
async start(controller) {
// Хелпер отправки SSE-события
const sendEvent = (data: Record<string, any>) => {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`))
}
try {
// Запуск генерации контента со стримингом
let currentContents = [...messages]
let keepGenerating = true
let iterations = 0
while (keepGenerating && iterations < 5) {
iterations++
const responseStream = await ai.models.generateContentStream({
model: DEFAULT_MODEL,
contents: currentContents,
config: {
systemInstruction:
'Вы — ведущий инженер Fullstack Agentic Lab. Отвечайте точно, технически глубоко, на русском языке. При необходимости поиска используйте search_articles. Перед критическими действиями вызывайте request_action_confirmation.',
tools: analyticsTools,
temperature: 0.2,
},
})
let toolCallsDetected: any[] = []
for await (const chunk of responseStream) {
// 1. Если модель стримит обычный текст
if (chunk.text) {
sendEvent({ type: 'text', text: chunk.text })
}
// 2. Если модель сгенерировала вызов инструмента
if (chunk.functionCalls && chunk.functionCalls.length > 0) {
toolCallsDetected = [...toolCallsDetected, ...chunk.functionCalls]
}
}
// Обработка вызванных инструментов
if (toolCallsDetected.length > 0) {
for (const call of toolCallsDetected) {
sendEvent({
type: 'tool_call',
name: call.name,
args: call.args,
})
// Исполнение инструмента
if (call.name === 'search_articles') {
const result = await searchArticlesTool(call.args as any)
// Отправляем клиенту информацию о результате
sendEvent({ type: 'tool_result', name: call.name, result })
// Добавляем вызов инструмента и ответ инструмента в историю диалога
currentContents.push({
role: 'model',
parts: [{ functionCall: call }],
})
currentContents.push({
role: 'user',
parts: [
{
functionResponse: {
name: call.name,
response: { result },
},
},
],
})
} else if (call.name === 'request_action_confirmation') {
// Для Human-in-the-loop прерываем серверный цикл и ждем решения пользователя
sendEvent({
type: 'confirmation_required',
actionName: call.args.actionName,
severity: call.args.severity,
})
keepGenerating = false
break
}
}
} else {
// Вызовов инструментов нет, генерация завершена
keepGenerating = false
}
}
sendEvent({ type: 'done' })
controller.close()
} catch (streamError: any) {
console.error('[Gemini Stream Error]:', streamError)
sendEvent({ type: 'error', message: streamError.message || 'Ошибка потоковой генерации' })
controller.close()
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
})
} catch (error: any) {
return NextResponse.json({ error: error.message || 'Внутренняя ошибка API' }, { status: 500 })
}
}src/lib/gemini/tools/analytics-tools.ts#Локальная реализация инструментов поиска и вычислений с фильтрацией по трекам.
/**
* src/lib/gemini/tools/analytics-tools.ts
* Локальное исполнение инструментов для Gemini Agent.
*/
export interface SearchArticlesArgs {
query: string
track?: string
}
export interface ArticleSummary {
id: string
title: string
track: string
duration: number
}
const KNOWLEDGE_BASE: ArticleSummary[] = [
{
id: 'wf-react-zustand-state',
title: 'Архитектура глобального состояния на Zustand v5: типизированные слайсы и useShallow',
track: 'react',
duration: 35,
},
{
id: 'wf-react-compound-components',
title: 'Архитектура Compound Components: паттерн слота asChild и WAI-ARIA 1.3',
track: 'react',
duration: 35,
},
{
id: 'wf-nextjs-server-actions-crud',
title: 'Комплексный CRUD на Next.js Server Actions: Zod-валидация и useOptimistic',
track: 'nextjs',
duration: 35,
},
{
id: 'wf-ai-mcp-server-dev',
title: 'Разработка Model Context Protocol (MCP) сервера на TypeScript',
track: 'ai-mcp',
duration: 35,
},
]
export async function searchArticlesTool(args: SearchArticlesArgs): Promise<ArticleSummary[]> {
console.log(`[Tool Executing] search_articles: "${args.query}" (track: ${args.track || 'all'})`)
// Имитация задержки поиска
await new Promise((resolve) => setTimeout(resolve, 300))
const q = args.query.toLowerCase()
return KNOWLEDGE_BASE.filter((article) => {
const matchesQuery = article.title.toLowerCase().includes(q) || article.id.toLowerCase().includes(q)
const matchesTrack = args.track ? article.track === args.track : true
return matchesQuery && matchesTrack
})
}src/components/chat/streaming-chat-widget.tsx#Клиентский компонент React 19 с чтением ReadableStream, печатью текста и плашкой подтверждения действия.
'use client'
import * as React from 'react'
interface Message {
role: 'user' | 'model'
text: string
toolCalls?: string[]
}
export function StreamingChatWidget() {
const [messages, setMessages] = React.useState<Message[]>([
{
role: 'model',
text: 'Здравствуйте! Я AI-ассистент платформы Fullstack Agentic Lab на базе Gemini 2.5. Чем я могу помочь вам в проектировании систем?',
},
])
const [input, setInput] = React.useState('')
const [isLoading, setIsLoading] = React.useState(false)
const [pendingConfirmation, setPendingConfirmation] = React.useState<{ actionName: string; severity: string } | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!input.trim() || isLoading) return
const userMsg: Message = { role: 'user', text: input }
const updatedMessages = [...messages, userMsg]
setMessages(updatedMessages)
setInput('')
setIsLoading(true)
// Добавляем заготовку под потоковый ответ модели
const assistantIndex = updatedMessages.length
setMessages((prev) => [...prev, { role: 'model', text: '' }])
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: updatedMessages.map((m) => ({
role: m.role,
parts: [{ text: m.text }],
})),
}),
})
if (!response.body) throw new Error('ReadableStream не поддерживается ответом')
const reader = response.body.getReader()
const decoder = new TextDecoder()
let done = false
while (!done) {
const { value, done: streamDone } = await reader.read()
done = streamDone
if (value) {
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const event = JSON.parse(line.slice(6))
if (event.type === 'text') {
setMessages((prev) => {
const next = [...prev]
next[assistantIndex].text += event.text
return next
})
} else if (event.type === 'confirmation_required') {
setPendingConfirmation({
actionName: event.actionName,
severity: event.severity,
})
}
} catch {
// Игнорируем неполные пакеты
}
}
}
}
}
} catch (err: any) {
console.error('Ошибка стриминга:', err)
setMessages((prev) => {
const next = [...prev]
next[assistantIndex].text += `\n[Ошибка сети: ${err.message}]`
return next
})
} finally {
setIsLoading(false)
}
}
return (
<div className="w-full max-w-2xl mx-auto rounded-xl border border-border bg-surface flex flex-col h-[520px] shadow-sm">
{/* Шапка чата */}
<div className="px-4 py-3 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500 animate-pulse" />
<h3 className="font-bold text-sm text-text-primary">Gemini 2.5 Agent Stream</h3>
</div>
<span className="text-xs text-text-secondary">@google/genai SDK</span>
</div>
{/* Список сообщений */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((m, idx) => (
<div
key={idx}
className={`flex ${m.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-lg px-3.5 py-2 text-sm leading-relaxed ${
m.role === 'user'
? 'bg-primary text-white'
: 'bg-surface-hover text-text-primary border border-border'
}`}
>
<div className="whitespace-pre-wrap">{m.text}</div>
</div>
</div>
))}
{/* Карточка Human-in-the-loop подтверждения */}
{pendingConfirmation && (
<div className="p-3.5 rounded-lg border border-amber-500/40 bg-amber-500/10 space-y-2 animate-in zoom-in-95">
<div className="flex items-center gap-2 text-amber-500 font-semibold text-xs">
<span>⚠️ ТРЕБУЕТСЯ ПОДТВЕРЖДЕНИЕ ДЕЙСТВИЯ</span>
</div>
<p className="text-xs text-text-primary">
Ассистент запрашивает разрешение на операцию:{' '}
<code className="font-mono font-bold">{pendingConfirmation.actionName}</code> (Уровень риска: {pendingConfirmation.severity})
</p>
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={() => setPendingConfirmation(null)}
className="px-3 py-1 rounded text-xs font-semibold bg-primary text-white"
>
Подтвердить
</button>
<button
type="button"
onClick={() => setPendingConfirmation(null)}
className="px-3 py-1 rounded text-xs font-semibold bg-surface border border-border text-text-secondary"
>
Отклонить
</button>
</div>
</div>
)}
</div>
{/* Поле ввода */}
<form onSubmit={handleSubmit} className="p-3 border-t border-border flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Спросите об архитектуре или вызовите поиск..."
disabled={isLoading}
className="flex-1 px-3 py-2 text-sm bg-surface border border-border rounded-lg outline-none focus:border-primary text-text-primary"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="px-4 py-2 text-sm font-semibold rounded-lg bg-primary text-white hover:bg-primary/90 disabled:opacity-50 transition-opacity"
>
{isLoading ? '...' : 'Отправить'}
</button>
</form>
</div>
)
}functionCalls#Когда модель генерирует functionCalls, она ожидает получить результат работы инструмента в виде объекта functionResponse. Если сервер возвращает клиенту текст вызова и закрывает соединение, модель не сможет сформулировать финальный ответ на естественном языке.
@google/generative-ai#Старый пакет не поддерживает многие нововведения Gemini 2.5 (новые конфигурации рассуждений, расширенное управление схемами). Переходите на @google/genai с унифицированным синтаксисом new GoogleGenAI({ apiKey }).
При создании потока в Route Handler необходимо обязательно указывать заголовки:
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',Без no-transform промежуточные прокси-серверы и сжатие Gzip в CDN могут буферизовать поток целиком, лишая пользователя эффекта посимвольной печати.