- BrainTools - https://www.braintools.ru -

Каждый LLM-фреймворк описывает инструмент по-своему

Чтобы дать LLM доступ к своему коду, пишут инструменты (tools). Инструмент — это функция и всё, что модели нужно знать, чтобы её вызвать: имя, описание и схема аргументов.

Потом те же инструменты приходится писать ещё раз. Первая версия сделана на tool() из AI SDK. В проекте появляется MCP-сервер, и инструменты переписывают под registerTool. Соседняя команда использует Genkit, и те же инструменты пишут в третий раз, через defineTool. Функции остаются прежними, меняется только обёртка.

К тому же каждая обёртка привязана к своему фреймворку. tool() берётся из пакета ai, defineTool — метод экземпляра Genkit, registerTool — метод McpServer из MCP SDK. Библиотеке, которая хочет поставлять инструменты, приходится выбрать один фреймворк, и его устанавливает каждый, кто её использует.

Без фреймворка инструмент — это функция, которая описывает сама себя: код, имя, описание и схемы входа и выхода. Модели этого достаточно, чтобы решить, когда и как вызвать инструмент. Этого же хватит, чтобы сгенерировать документацию, построить форму или добавить команду в CLI. Если оформить инструмент обычным объектом с такими полями, он останется частью вашего кода. Чтобы перенести его в другой фреймворк, нужен только небольшой адаптер — переписывать ничего не придётся.

Самое сложное в этом объекте — схемы, и они уже стандартизированы.

Что такое Standard Schema

Standard Schema [1] — TypeScript-интерфейс для библиотек валидации. Его разработали авторы Zod, Valibot и ArkType. Код, который принимает Standard Schema, работает со схемой из любой библиотеки с поддержкой спецификации, и адаптер под каждую библиотеку не нужен.

Весь интерфейс — одно свойство, ~standard. В сокращённом виде:

interface StandardSchemaV1<Input = unknown, Output = Input> {
  readonly '~standard': {
    readonly version: 1;
    readonly vendor: string;
    readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>;
    readonly types?: { readonly input: Input; readonly output: Output };
  };
}

// Result<Output> — это { value: Output } при успехе и { issues: Issue[] } при ошибке.

Код валидации одинаковый для любой библиотеки:

const result = await schema['~standard'].validate(data);
if (result.issues) throw new Error(result.issues.map((issue) => issue.message).join('; '));
const value = result.value; // тип берётся из output схемы

Спецификацию реализуют больше 30 библиотек, среди них Zod, Valibot, ArkType, yup и joi. Принимают её больше 60 проектов, в том числе tRPC, TanStack Form и Router, Hono, Elysia, oRPC и React Hook Form.

Спецификация состоит только из типов. В пакете @standard-schema/spec нет рантайм-кода, и библиотека может скопировать интерфейс к себе, а не зависеть от пакета.

Что такое Standard JSON Schema

Валидация — только половина того, что инструменту нужно от схем. Вторая половина — JSON Schema: прежде чем вызвать инструмент, модель должна получить JSON Schema его аргументов.

Standard JSON Schema [2] — вторая спецификация тех же авторов. Она добавляет в то же свойство ~standard конвертер в JSON Schema:

schema['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
schema['~standard'].jsonSchema.output({ target: 'openapi-3.0' });

target выбирает диалект JSON Schema, потому что разным потребителям нужны разные. OpenAI, Anthropic и MCP принимают JSON Schema draft 2020-12, а поле parameters у Gemini — формат OpenAPI 3.0.

input и output разделены, потому что схема может преобразовывать значения. Если схема принимает "42" и возвращает 42, у её входа одна JSON Schema, а у выхода — другая.

Спецификации независимы: объект может реализовать любую из них или обе. Схемы Zod 4.2+ и ArkType 2.1.28+ реализуют обе. В Valibot 1.2+ схему оборачивают в toStandardJsonSchema() из @valibot/to-json-schema, и тогда она тоже реализует обе.

Что такое Standard Tool

Когда схемы сами валидируют данные и выдают JSON Schema, от инструмента остаются имя, описание и функция. Для этой части стандарта нет, поэтому каждый фреймворк определяет для неё свой объект.

StandardToolV0 — предложение, каким должен быть этот объект:

import type { StandardSchemaV1, StandardJSONSchemaV1 } from '@standard-schema/spec';

interface StandardToolV0<
  Input = unknown, Output = unknown, FormattedOutput = Output, Context = unknown,
> {
  name: string;
  title?: string;
  description: string;
  inputSchema?: StandardSchemaV1<Input, unknown> & StandardJSONSchemaV1<Input, unknown>;
  outputSchema?: StandardSchemaV1<unknown, Output> & StandardJSONSchemaV1<unknown, Output>;
  meta?: Record<string, unknown>;
  execute(input: Input, context?: Context): FormattedOutput | Promise<FormattedOutput>;
}
  • name — идентификатор, по которому модель вызывает инструмент.

  • description объясняет модели, что делает инструмент и когда его вызывать.

  • title — необязательное название для людей. MCP-клиенты могут показывать его в списке инструментов.

  • inputSchema и outputSchema должны реализовывать обе спецификации, то есть и валидировать, и выдавать JSON Schema. Input — входной тип inputSchema, а Output — выходной тип outputSchema, поэтому подходят и схемы, которые преобразуют значения.

  • meta — статические данные об инструменте, например { destructive: true }. Их читают потребители, а execute их не получает.

  • execute выполняет инструмент. Необязательный второй аргумент, context, передаёт данные конкретного вызова, например локаль или токен авторизации. context не валидируется и не попадает в JSON Schema.

  • FormattedOutput — то, что возвращает execute, если обёртка меняет результат, например чтобы возвращать ошибки [3] как данные. По умолчанию это Output.

Как и обе спецификации, StandardToolV0 — это только тип. Подходит любой объект с этими полями:

import { z } from 'zod'; // или ArkType, или Valibot
import type { StandardToolV0 } from 'standard-tool';

export const getWeather: StandardToolV0<{ city: string }, { tempC: number }> = {
  name: 'get_weather',
  description: 'Current temperature for a city',
  inputSchema: z.object({ city: z.string() }),
  outputSchema: z.object({ tempC: z.number() }),
  execute: async ({ city }) => ({ tempC: await fetchTemperature(city) }),
};

Импорт здесь только для типов, и вместо него можно вставить интерфейс прямо в свой проект.

В пакете standard-tool есть и необязательная эталонная реализация, около 90 строк. standardTool() оборачивает определение так, что execute проверяет вход до вызова вашей функции и выход после, а при несовпадении бросает StandardToolValidationError. withFormattedOutput() перехватывает ошибки и возвращает их как данные, чтобы модель могла прочитать, что пошло не так.

Сравнение с фреймворками

Такой объект есть в каждом фреймворке. Отличаются в основном названия и позиции аргументов:

Пакет

Идентификатор

Схема входа

Схема выхода

Функция

AI SDK

ai

ключ в объекте tools

inputSchema

outputSchema

execute

Mastra

@mastra/core

id

inputSchema

outputSchema

execute

Genkit

genkit

name

inputSchema

outputSchema

2-й аргумент defineTool

LangChain

@langchain/core

name

schema

нет

1-й аргумент tool

MCP SDK

@modelcontextprotocol/sdk

1-й аргумент registerTool

inputSchema

outputSchema

3-й аргумент registerTool

StandardToolV0

нет, это тип

name

inputSchema

outputSchema

execute

Сильнее различается то, что фреймворки принимают в качестве схемы:

  • AI SDK: Standard Schema, Zod или JSON Schema

  • Mastra: Standard Schema вместе со Standard JSON Schema, Zod или JSON Schema

  • Genkit: Zod или JSON Schema

  • LangChain: Zod или JSON Schema

  • MCP SDK: только Zod

Проверено на версиях ai 7.0, @mastra/core 1.72, genkit 1.42, @langchain/core 1.2 и @modelcontextprotocol/sdk 1.31.

Объекты похожи, но не взаимозаменяемы, и каждому нужен пакет своего фреймворка. Как правило, чтобы перенести инструмент в другой фреймворк, обёртку приходится переписывать. Mastra — исключение, и то в одну сторону: её агенты принимают и инструменты из AI SDK. А чтобы использовать инструмент, написанный под другой фреймворк, нужно установить этот фреймворк.

Это важно даже при одном фреймворке. Объект инструмента рассчитан на свой фреймворк. Обычный объект можно ещё и вызвать из скрипта или теста, прочитать генератором документации или экспортировать из библиотеки, пользователи которой ваш фреймворк не ставят.

Как это использовать

У объекта не один читатель, и модель — только один из них.

Вызвать. execute — обычная функция:

const { tempC } = await getWeather.execute({ city: 'Paris' });

Отдать модели. name, description и JSON Schema, которую выдаёт inputSchema, превращаются в описание инструмента для провайдера. Когда модель вызывает инструмент, её аргументы уходят в execute. Как это выглядит у разных провайдеров — в следующем разделе.

Прочитать. Полей хватает для справочной документации, списка инструментов в промпте, формы по inputSchema или команды CLI:

function describeTools(tools: StandardToolV0[]) {
  return tools.map((tool) => ({
    name: tool.name,
    description: tool.description,
    input: tool.inputSchema?.['~standard'].jsonSchema.input({ target: 'draft-2020-12' }),
    output: tool.outputSchema?.['~standard'].jsonSchema.output({ target: 'draft-2020-12' }),
  }));
}

Экспортировать из библиотеки. Инструменты — обычные значения, и библиотека экспортирует их как любые другие:

export const getOrders: StandardToolV0<{ userId: string }, Order[]> = {
  name: 'get_orders',
  description: "List a user's orders",
  inputSchema: z.object({ userId: z.string() }),
  execute: ({ userId }) => api.get(`/orders/${userId}`),
};

Пользователи библиотеки могут запустить инструмент, задокументировать его или отдать модели, а сама библиотека не зависит ни от одного AI-фреймворка.

Взять готовые RPC-процедуры. У процедуры tRPC или oRPC уже есть схемы входа и выхода и обработчик. Если схемы реализуют Standard JSON Schema, они становятся схемами инструмента, а execute вызывает процедуру через серверный вызов фреймворка (в tRPC это createCaller). Пример с tRPC [4].

Подключение к моделям и фреймворкам

Любая интеграция делает две вещи. Сначала собирает описание инструмента для провайдера из name, description и JSON Schema. Потом, когда модель вызывает инструмент, запускает execute и отправляет результат обратно. От провайдера к провайдеру меняются только названия полей и диалект JSON Schema:

Потребитель

Поле схемы

target

Как возвращается результат

OpenAI Responses API

parameters

draft-2020-12

элемент function_call_output

Anthropic

input_schema

draft-2020-12

блок tool_result

Gemini

parameters

openapi-3.0

часть functionResponse

MCP

inputSchema в дескрипторе инструмента

draft-2020-12

{ content, structuredContent?, isError? }

AI SDK

inputSchema, принимает Standard Schema как есть

не нужен

SDK сам ведёт цикл

Обе части на примере Anthropic:

import type Anthropic from '@anthropic-ai/sdk';
import type { StandardToolV0 } from 'standard-tool';

export function toAnthropicTool(tool: StandardToolV0): Anthropic.Tool {
  const schema = tool.inputSchema?.['~standard'].jsonSchema.input({ target: 'draft-2020-12' });
  return {
    name: tool.name,
    description: tool.description,
    input_schema: (schema ?? { type: 'object', properties: {} }) as Anthropic.Tool.InputSchema,
  };
}

export async function runToolUse(
  tools: StandardToolV0[],
  block: Anthropic.ToolUseBlock,
): Promise<Anthropic.ToolResultBlockParam> {
  try {
    const tool = tools.find((t) => t.name === block.name);
    if (!tool) throw new Error(`Unknown tool: ${block.name}`);
    const result = await tool.execute(block.input);
    return { type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(result) };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    return { type: 'tool_result', tool_use_id: block.id, content: message, is_error: true };
  }
}

execute получает аргументы модели без проверки. Инструмент из standardTool() проверяет их по inputSchema, а в написанном вручную это нужно делать самому.

Для другого провайдера поменяйте поле и target по таблице. Адаптер пишется один раз, и новый провайдер не требует правок в инструментах.

Итог

Инструмент, оформленный как функция, которая описывает сама себя, остаётся частью вашего кода. Его можно вызвать, протестировать, задокументировать и отдать любой модели или фреймворку, и это будет всё тот же объект.

StandardToolV0 — это предложение: один TypeScript-интерфейс без рантайма. Форма V0 заморожена, так что правки, которые её меняют, пойдут в новый интерфейс — StandardToolV1. Спецификация, эталонная реализация и обоснование — на standard-tool.js.org [5].

Очевидное возражение — XKCD 927 [6]: пока другие проекты не создают и не читают такие объекты, это просто ещё один конкурирующий формат. Standard Schema показала, что маленький интерфейс без рантайма может широко распространиться, но за ней с самого начала стояли авторы Zod, Valibot и ArkType. У этого предложения один мейнтейнер и такой поддержки нет.

Автор: Finom

Источник [7]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/36304

URLs in this post:

[1] Standard Schema: https://standardschema.dev

[2] Standard JSON Schema: https://standardschema.dev/json-schema

[3] ошибки: http://www.braintools.ru/article/4192

[4] Пример с tRPC: https://standard-tool.js.org/why.html#from-procedures-you-already-have

[5] standard-tool.js.org: https://standard-tool.js.org

[6] XKCD 927: https://xkcd.com/927/

[7] Источник: https://habr.com/ru/articles/1088768/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1088768

www.BrainTools.ru

Rambler's Top100