Каждый LLM-фреймворк описывает инструмент по-своему
- пятница, 2 октября 2026 г. в 00:00:13
Чтобы дать LLM доступ к своему коду, пишут инструменты (tools). Инструмент — это функция и всё, что модели нужно знать, чтобы её вызвать: имя, описание и схема аргументов.
Потом те же инструменты приходится писать ещё раз. Первая версия сделана на tool() из AI SDK. В проекте появляется MCP-сервер, и инструменты переписывают под registerTool. Соседняя команда использует Genkit, и те же инструменты пишут в третий раз, через defineTool. Функции остаются прежними, меняется только обёртка.
К тому же каждая обёртка привязана к своему фреймворку. tool() берётся из пакета ai, defineTool — метод экземпляра Genkit, registerTool — метод McpServer из MCP SDK. Библиотеке, которая хочет поставлять инструменты, приходится выбрать один фреймворк, и его устанавливает каждый, кто её использует.
Без фреймворка инструмент — это функция, которая описывает сама себя: код, имя, описание и схемы входа и выхода. Модели этого достаточно, чтобы решить, когда и как вызвать инструмент. Этого же хватит, чтобы сгенерировать документацию, построить форму или добавить команду в CLI. Если оформить инструмент обычным объектом с такими полями, он останется частью вашего кода. Чтобы перенести его в другой фреймворк, нужен только небольшой адаптер — переписывать ничего не придётся.
Самое сложное в этом объекте — схемы, и они уже стандартизированы.
Standard Schema — 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 нет рантайм-кода, и библиотека может скопировать интерфейс к себе, а не зависеть от пакета.
Валидация — только половина того, что инструменту нужно от схем. Вторая половина — JSON Schema: прежде чем вызвать инструмент, модель должна получить JSON Schema его аргументов.
Standard JSON Schema — вторая спецификация тех же авторов. Она добавляет в то же свойство ~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, и тогда она тоже реализует обе.
Когда схемы сами валидируют данные и выдают 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, если обёртка меняет результат, например чтобы возвращать ошибки как данные. По умолчанию это 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 |
| ключ в объекте |
|
|
|
Mastra |
|
|
|
|
|
Genkit |
|
|
|
| 2-й аргумент |
LangChain |
|
|
| нет | 1-й аргумент |
MCP SDK |
| 1-й аргумент |
|
| 3-й аргумент |
| нет, это тип |
|
|
|
|
Сильнее различается то, что фреймворки принимают в качестве схемы:
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.
Любая интеграция делает две вещи. Сначала собирает описание инструмента для провайдера из name, description и JSON Schema. Потом, когда модель вызывает инструмент, запускает execute и отправляет результат обратно. От провайдера к провайдеру меняются только названия полей и диалект JSON Schema:
Потребитель | Поле схемы |
| Как возвращается результат |
|---|---|---|---|
OpenAI Responses API |
|
| элемент |
Anthropic |
|
| блок |
Gemini |
|
| часть |
MCP |
|
|
|
AI SDK |
| не нужен | 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.
Очевидное возражение — XKCD 927: пока другие проекты не создают и не читают такие объекты, это просто ещё один конкурирующий формат. Standard Schema показала, что маленький интерфейс без рантайма может широко распространиться, но за ней с самого начала стояли авторы Zod, Valibot и ArkType. У этого предложения один мейнтейнер и такой поддержки нет.