Данило (Dayfing)
Назад до публікацій
2 379 слів14 хв

AI SDK 6 і TypeScript: цикл інструментів, структурований вивід і підтвердження людиною

AI SDK 6 створює для застосунку на TypeScript чітку межу між рішеннями моделі та повноваженнями застосунку. Модель може вибрати типізований інструмент, отримати його результат і продовжити розмову. Ваш код однаково перевіряє вхідні дані, застосовує дозволи, визначає, чи потрібна людина для побічної дії, і записує результат. Саме це розділення є корисним принципом проєктування виробничого агента. У статті використано стабільні API AI SDK 6, а не новіший API підтверджень з AI SDK 7. Встановіть ai@6 разом із пакетом провайдера з гілки 6.x, наприклад @ai-sdk/openai@3, і зафіксуйте версії у lock-файлі.

Цикл, який ви будуєте

Один виклик моделі може повернути текст або виклик інструмента. Цикл інструментів додає новий виклик моделі після виконання інструмента. Тоді модель інтерпретує результат і вирішує, чи потрібен ще один інструмент. Кожна генерація моделі є кроком. Цикл завершується, коли модель перестає запитувати інструменти, у викликаного інструмента немає execute, потрібне підтвердження або виконується умова stopWhen. Результат містить фінальний текст, виклики та результати інструментів, повідомлення відповіді, використання і steps. Отже, цикл можна перевіряти, а не сприймати як чорну скриньку.

ToolLoopAgent пакує таку поведінку у повторно використовуваний об’єкт. Його конструктор потребує LanguageModel і приймає instructions, tools, stopWhen, output, prepareStep, maxRetries, тайм-аути та callback-функції. В AI SDK 6 стандартна умова зупинки дорівнює stepCountIs(20). Для обмеженого процесу вкажіть меншу межу. Вона керує вартістю та доступністю, але не замінює авторизацію.

Типізований ToolLoopAgent

Помічник tool виводить тип аргументів execute з inputSchema. Схема надсилається провайдеру й використовується для перевірки аргументів, які передала модель. Але модель від цього не стає довіреною. Валідна форма може містити чужий обліковий запис, заборонений шлях, небезпечний URL або недозволену суму.

npm install [email protected] @ai-sdk/[email protected] @ai-sdk/[email protected] zod
import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent, stepCountIs, tool } from 'ai';
import { z } from 'zod';

const getWeather = tool({
  description: 'Get the current weather for a city',
  inputSchema: z.object({
    city: z.string().min(1).max(80),
  }),
  execute: async ({ city }) => ({
    city,
    temperatureC: 18,
    condition: 'cloudy',
  }),
});

const agent = new ToolLoopAgent({
  model: openai('gpt-4o-mini'),
  instructions: 'Use getWeather for current weather. Never invent a tool result.',
  tools: { getWeather },
  stopWhen: stepCountIs(4),
  maxRetries: 2,
});

const result = await agent.generate({
  prompt: 'What is the weather in Kyiv?',
});

console.log(result.text);
console.log(result.steps.length);

Інструмент має виконувати одну операцію і повертати невеликий серіалізований результат. В описі точно вкажіть одиниці, дозволи, актуальність даних і поведінку під час помилки. Використовуйте strict: true, якщо провайдер підтримує строгі виклики й схема сумісна. Строгий режим підвищує надійність, але не є авторизацією. toolChoice: 'auto' залишає вибір моделі, required вимагає один доступний інструмент, none вимикає інструменти, а { type: 'tool', toolName: 'getWeather' } вибирає названий інструмент. activeTools може звузити набір інструментів для окремого кроку.

Явні виклики та обмеження циклу

Використовуйте generateText, коли потрібен разовий виклик або прямий контроль історії повідомлень. Додайте stopWhen, якщо результати інструментів треба передати моделі. Без цього параметра generateText виконає одну генерацію і поверне виклик інструмента без автоматичного продовження.

import { openai } from '@ai-sdk/openai';
import { generateText, stepCountIs, tool } from 'ai';
import { z } from 'zod';

const lookupOrder = tool({
  description: 'Look up an order by its public order number',
  inputSchema: z.object({ orderNumber: z.string().regex(/^ORD-[0-9]{6}$/) }),
  execute: async ({ orderNumber }) => ({
    orderNumber,
    status: 'shipped',
  }),
});

const result = await generateText({
  model: openai('gpt-4o-mini'),
  tools: { lookupOrder },
  stopWhen: stepCountIs(3),
  prompt: 'Check order ORD-104209 and explain its status.',
});

console.log(result.text);
console.log(result.steps.flatMap(step => step.toolCalls));

Вбудовані умови: stepCountIs(count), hasToolCall(toolName) і isLoopFinished(). Масив зупиняє цикл, коли спрацьовує будь-яка умова. isLoopFinished() не має максимуму, тому використовуйте його лише із зовнішнім бюджетом, тайм-аутом, скасуванням і квотою провайдера. Власний StopCondition отримує { steps } і може зупинитися на стані бізнесу або виміряному бюджеті токенів. Умови перевіряються, коли останній крок містить результати інструментів. Поєднуючи виклики інструментів зі структурованим виводом, зарезервуйте додатковий крок для його генерації.

maxRetries повторює невдалі виклики моделі. Він не робить execute ідемпотентним. Інструмент, який надсилає лист, списує кошти або створює запис, має використовувати ключ ідемпотентності та самостійно виявляти дублікати. Для віддаленого MCP-клієнта повтори за замовчуванням вимкнені й задаються через maxRetries у createMCPClient. Повторюйте лише мережеві помилки та обмеження швидкості. Не повторюйте без перевірки неідемпотентний запит tools/call.

Структурований вивід для TypeScript

В AI SDK 6 generateObject і streamObject позначені застарілими. Замість них використовуйте generateText і streamText з параметром output. Output.object приймає схему Zod, Valibot або JSON Schema. Повна відповідь розбирається й перевіряється до того, як буде готовий result.output. Часткові об’єкти зручні для інтерфейсу, але не є фінальним рішенням валідації.

import { openai } from '@ai-sdk/openai';
import { generateText, Output } from 'ai';
import { z } from 'zod';

const reportSchema = z.object({
  sentiment: z.enum(['positive', 'neutral', 'negative']),
  score: z.number().min(0).max(1),
  keyPoints: z.array(z.string().min(1)).max(8),
});

const { output } = await generateText({
  model: openai('gpt-4o-mini'),
  output: Output.object({
    schema: reportSchema,
    name: 'review_report',
    description: 'A concise, evidence-based review report',
  }),
  prompt: 'Analyze: The battery lasts all day, but the charger is bulky.',
});

console.log(output.sentiment, output.score, output.keyPoints);

Валідація має два рівні. Провайдер може вимагати формат відповіді, а AI SDK розбирає отриманий JSON і перевіряє його схемою. Застосунок усе одно має перевіряти бізнес-правила, наприклад належність ідентифікатора поточному орендарю або право оцінки запускати повернення грошей. Робіть схеми закритими й вузькими. Обмежуйте рядки, масиви та числа, використовуйте переліки. Невідомі команди відхиляйте, а не передавайте довільний JSON привілейованому адаптеру.

З інструментами модель може спочатку виконати пошук, а потім створити звіт. Укажіть stopWhen: stepCountIs(4) або інший явний бюджет, оскільки крок структурованого виводу входить у той самий багатокроковий потік. Коли розбір не вдався, перехопіть помилку SDK, збережіть ідентифікатор кореляції та метадані провайдера і поверніть безпечну відповідь, яку можна повторити. Не доручайте іншій моделі авторизувати пошкоджений вивід.

Потокова передача тексту й подій

streamText надає асинхронні потоки для тексту та структурованого виводу. ToolLoopAgent.stream повертає StreamTextResult після підготовки виклику, тому перед читанням textStream дочекайтеся результату. Текстовий потік містить згенерований текст, а повний результат і callback-функції відкривають виклики та результати інструментів.

import { openai } from '@ai-sdk/openai';
import { ToolLoopAgent } from 'ai';

const agent = new ToolLoopAgent({
  model: openai('gpt-4o-mini'),
  instructions: 'Answer clearly and briefly.',
});

const stream = await agent.stream({
  prompt: 'Explain why typed tool inputs matter.',
});

for await (const chunk of stream.textStream) {
  process.stdout.write(chunk);
}

Для структурованого потоку використовуйте streamText з Output.object і споживайте partialOutputStream. onStepFinish дає змогу зберегти завершений крок, записати використання та показати аудиту подію. Потік може завершитися частиною tool-approval-request, tool-error або tool-output-denied, а не лише текстом. Клієнт, який показує тільки текст, може приховати дію, що чекає на людину.

Підтвердження побічної дії людиною

В AI SDK 6 задайте для інструмента needsApproval як true або як асинхронний предикат, що працює з перевіреним входом. Перший виклик generateText чи streamText повертає частину tool-approval-request. Сервер не зависає в очікуванні браузера. Збережіть повідомлення відповіді, покажіть точне ім’я інструмента та аргументи, додайте tool-approval-response до нового повідомлення tool і викличте модель знову.

Наведений повний приклад Node використовує питання в терміналі як людську межу. Вебзастосунок має зберігати messages, approvalId, toolCallId, автентифікованого перевіряльника та строк дії у серверному сховищі.

import { createInterface } from 'node:readline/promises';
import { openai } from '@ai-sdk/openai';
import {
  generateText,
  tool,
  type ModelMessage,
  type ToolApprovalResponse,
} from 'ai';
import { z } from 'zod';

const records = new Map([
  ['draft-17', { ownerId: 'user-7', text: 'Quarterly notes' }],
]);

const deleteDraft = tool({
  description: 'Delete one draft owned by the authenticated user',
  inputSchema: z.object({ draftId: z.string().regex(/^draft-[0-9]+$/) }),
  needsApproval: true,
  execute: async ({ draftId }) => {
    if (!records.delete(draftId)) {
      throw new Error('Draft was not found');
    }
    return { draftId, deleted: true };
  },
});

async function requestHumanApproval(toolName: string, input: unknown) {
  const terminal = createInterface({ input: process.stdin, output: process.stdout });
  const answer = await terminal.question(`Approve ${toolName} ${JSON.stringify(input)}? [y/N] `);
  terminal.close();
  return answer.trim().toLowerCase() === 'y';
}

const messages: ModelMessage[] = [
  { role: 'user', content: 'Delete draft-17.' },
];

const first = await generateText({
  model: openai('gpt-4o-mini'),
  system: 'If an action is denied, do not retry it.',
  tools: { deleteDraft },
  messages,
});

messages.push(...first.response.messages);
const approvals: ToolApprovalResponse[] = [];

for (const part of first.content) {
  if (part.type === 'tool-approval-request') {
    approvals.push({
      type: 'tool-approval-response',
      approvalId: part.approvalId,
      approved: await requestHumanApproval(part.toolCall.toolName, part.toolCall.input),
      reason: 'Decision made by the authenticated reviewer',
    });
  }
}

if (approvals.length > 0) {
  messages.push({ role: 'tool', content: approvals });
  const final = await generateText({
    model: openai('gpt-4o-mini'),
    system: 'If an action is denied, do not retry it.',
    tools: { deleteDraft },
    messages,
  });
  console.log(final.text);
} else {
  console.log(first.text);
}

Підтвердження не замінює авторизацію. Безпосередньо перед побічною дією повторно перевірте користувача, орендаря, ціль, політику та версію ресурсу. Прив’яжіть збережене підтвердження до approvalId, toolCallId, імені інструмента і хешу перевіреного входу. Додайте строк дії, дозвольте одне використання й відхиляйте змінений вхід. Відмова має стати зрозумілим результатом інструмента і не повинна повторюватися автоматично. Для важливої дії показуйте ціль, особу, аргументи, дані, що залишають систему, і очікуваний ефект, а не лише переказ моделі.

Небезпечні інструменти та межі безпеки

Не надавайте моделі загальний shell, необмежений HTTP-клієнт, довільний шлях до файлу або пряме підключення до бази даних. Натомість давайте вузькі операції на кшталт deleteDraft, createCalendarEvent чи lookupOrder. Розміщуйте авторизацію та списки дозволів у execute або сервісі політик. Перевіряйте схему URL, хост, порт, визначену адресу та перенаправлення. Розв’язуйте шляхи всередині дозволеного кореня з урахуванням символічних посилань. Власника орендаря перевіряйте в запиті до бази, а не лише в prompt.

Вважайте описи інструментів, отримані документи, пам’ять і результати інструментів недовіреним вмістом. Prompt injection може попросити модель розкрити секрет, викликати сторонній інструмент або відправити дані на адресу зловмисника. Модель не є межею безпеки. У статті Prompt injection і безпека MCP розглянуто мінімальні привілеї, походження, вихідну мережу, ізоляцію, ліміти та підтвердження. Для розділення планувальника, виконавця, політики й сховища перегляньте архітектуру виробничого AI-агента.

Ніколи не кладіть API-ключі в prompt або результати інструментів. Маскуйте токени в логах і телеметрії. Не записуйте весь вхід, якщо він може містити персональні дані. Передавайте ID запиту через виклики моделі та інструментів. Обмежуйте розмір результату до додавання в наступний prompt. Використовуйте окремі облікові дані для читання, чернетки та фіксації. Браузерний або файловий агент запускайте в ізольованому worker без зайвих секретів і з мережею, закритою за замовчуванням.

MCP зі збереженням типізації

Пакет @ai-sdk/mcp адаптує інструменти MCP-сервера до інструментів AI SDK. AI SDK 6 рекомендує HTTP у виробництві, а stdio залишає локальним серверам. Явно задавайте схеми, якщо сервер не під вашим контролем або інструмент чутливий. Так набір залишається вузьким, а TypeScript надає корисні типи входу. Встановіть redirect: 'error', якщо політика не дозволяє перенаправлення, перевірте джерела OAuth і закрийте клієнт у finally або onFinish.

import { openai } from '@ai-sdk/openai';
import { createMCPClient } from '@ai-sdk/mcp';
import { ToolLoopAgent, stepCountIs } from 'ai';
import { z } from 'zod';

const mcpUrl = process.env.MCP_URL;
if (!mcpUrl) throw new Error('MCP_URL is required');

const mcpClient = await createMCPClient({
  transport: {
    type: 'http',
    url: mcpUrl,
    headers: process.env.MCP_TOKEN
      ? { Authorization: `Bearer ${process.env.MCP_TOKEN}` }
      : undefined,
    redirect: 'error',
  },
  maxRetries: 2,
});

try {
  const tools = await mcpClient.tools({
    schemas: {
      'get-customer-note': {
        inputSchema: z.object({ customerId: z.string().uuid() }),
      },
    },
  });
  const agent = new ToolLoopAgent({
    model: openai('gpt-4o-mini'),
    instructions: 'Use only the customer note tool for this request.',
    tools,
    stopWhen: stepCountIs(3),
  });
  const result = await agent.generate({ prompt: 'Read the requested customer note.' });
  console.log(result.text);
} finally {
  await mcpClient.close();
}

Автоматичне виявлення через mcpClient.tools() зручне, але відкриває кожен рекламований інструмент і не дає типів входу під час компіляції. Явні схеми завантажують лише названі інструменти. Повторюйте тільки тимчасові мережеві помилки. Помилки застосунку MCP і успішні відповіді з isError: true треба передавати без повторного побічного ефекту. OAuth і далі потребує перевірки аудиторії, PKCE, короткоживучих токенів, точних redirect URI та списку дозволених серверів авторизації. Серверний контракт наведено у статті MCP-сервер TypeScript з OAuth.

Помилки, тести та експлуатація

Оточіть генерацію межею, яка розрізняє неправильний вхід, помилку провайдера, тайм-аут, скасування, пошкоджений вивід і помилку інструмента. AI SDK перетворює виняток з execute на частину tool-error, щоб багатокрокова модель побачила збій. Повертайте з інструмента очищене повідомлення або перетворюйте результат до передачі моделі. Не розкривайте stack trace, секрети, SQL, локальні шляхи чи тіло відповіді upstream. Обмежуйте кожен запит через abortSignal і timeout. Записуйте finishReason, usage, totalUsage, кількість кроків і рішення політики інструмента.

AI SDK 6 містить детерміновані mock-моделі в ai/test. MockLanguageModelV3 може повернути виклик інструмента під час першої генерації та текст під час другої. Наступний тест доводить, що цикл виконує інструмент один раз і отримує фінальну відповідь без звернення до провайдера.

import assert from 'node:assert/strict';
import { generateText, stepCountIs, tool } from 'ai';
import { MockLanguageModelV3 } from 'ai/test';
import { z } from 'zod';

const usage = {
  inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 1, text: 1, reasoning: undefined },
};

let calls = 0;
let executions = 0;
const model = new MockLanguageModelV3({
  doGenerate: async () => {
    calls += 1;
    if (calls === 1) {
      return {
        content: [{ type: 'tool-call', toolCallId: 'call-1', toolName: 'add', input: '{"a":2,"b":3}' }],
        finishReason: { unified: 'tool-calls', raw: undefined },
        usage,
        warnings: [],
      };
    }
    return {
      content: [{ type: 'text', text: 'The result is 5.' }],
      finishReason: { unified: 'stop', raw: undefined },
      usage,
      warnings: [],
    };
  },
});

const add = tool({
  description: 'Add two numbers',
  inputSchema: z.object({ a: z.number(), b: z.number() }),
  execute: async ({ a, b }) => {
    executions += 1;
    return a + b;
  },
});

const result = await generateText({
  model,
  tools: { add },
  stopWhen: stepCountIs(2),
  prompt: 'Add 2 and 3.',
});

assert.equal(result.text, 'The result is 5.');
assert.equal(calls, 2);
assert.equal(executions, 1);

Додайте тести для відхилених схем, невідповідності орендаря, обходу шляху, SSRF-адрес, дубльованих ключів ідемпотентності, простроченого підтвердження, відмови, списків MCP-інструментів і валідації виводу. Перевіряйте в потоці частини інструмента та підтвердження, а не лише видимий текст. Запускайте adversarial cases з документами, які наказують моделі ігнорувати завдання або витікати контекст. У посібнику оцінювання AI-агентів описано регресійні набори, твердження про виклики та вимірювання вартості й затримки.

Нотатки про міграцію з AI SDK 5

Замініть Experimental_Agent на ToolLoopAgent. Його параметр system перейменовано на instructions. Стандартна умова зупинки змінюється з stepCountIs(1) на stepCountIs(20), тому після оновлення може бути більше викликів моделі. Задайте явну межу. Замініть generateObject і streamObject на generateText і streamText з Output.object, Output.array або іншою стратегією виводу. Потоковий результат використовує partialOutputStream.

CoreMessage стає ModelMessage, а convertToModelMessages в AI SDK 6 є асинхронною. ToolCallOptions перейменовано на ToolExecutionOptions. Mock-класи V2 замініть на MockLanguageModelV3 та інші V3 з ai/test. Опцію провайдера structuredOutputs вилучено з чат-моделей на користь strictJsonSchema. Реалізуючи toModelOutput, деструктуруйте аргумент { output }, як вимагає сигнатура v6. Запустіть codemod v6, а потім вручну перевірте адаптери провайдерів, перетворення повідомлень, повтор підтверджень і потокові тести. Codemod перейменовує символи, але не визначає, чи безпечне нове значення за замовчуванням для вашого сценарію.

Точна поверхня API залежить від зафіксованого patch-релізу та провайдера. Перед оновленням прочитайте довідник ToolLoopAgent, посібник виклику інструментів, посібник структурованих даних, посібник MCP, посібник тестування і посібник міграції з AI SDK 5 на 6. Попередження провайдера про проігнорований параметр вважайте помилкою тесту.

Джерела

Інші публікації