* Голема в городе лепят из глины, а работать его заставляет свиток в голове. Что написано в свитке, голем сам не читает: он просто делает. У хорошего хозяина голема есть привычка, которой нет у плохого: он проверяет работу, а не слова голема о работе.
Утро после того дня, когда Магистраль отключили. В подвале Академии пахнет печью и мокрой глиной. Пондер сидит на ящике с перфокартами, Липвиг ходит вокруг голема и прикидывает, пролезет ли тот в дверь справочной Почтамта. Людей у окошка не хватает: горожане с утра спрашивают, открыта ли их башня и где их письмо, и очередь стоит до угла.
Липвиг пишет на бумажке первый вопрос из очереди: «Открыта ли T-3?» Дорфл читает, думает и отвечает.
Башни T-9 на линии нет. Липвиг поворачивается к Пондеру, но голем говорит раньше.
В уроке 112 линия научилась держать город без хозяина. Каждая её часть отвечала по договору: башня принимает письмо и отвечает числом, движок помнит перегоны. Голем устроен иначе. Он отвечает словами, и что в этих словах будет, не знает никто, включая его самого. Иногда ответ верный. Иногда в нём есть башня, которой нет.
Это и есть языковая модель: сервис, который отвечает правдоподобно, но не обязательно правильно. С такими сервисами ты уже встречался. Книга башен по HTTP в уроке 90 тоже могла ответить мусором, и мы не верили ей на слово: разбирали ответ схемой. С моделью то же самое, только мусора больше и он вежливее. Сегодня три шага: спросить модель через сервис, потребовать ответ по схеме и подменить модель болванкой в тестах.
Модель это сервис
В Effect модель живёт в модуле effect/ai и выглядит как обычный сервис LanguageModel. Спросить его словами можно функцией LanguageModel.generateText:
import { Effect } from 'effect';
import { LanguageModel } from 'effect/ai';
const ask = LanguageModel.generateText({ prompt: 'Открыта ли башня T-3?' }).pipe(
Effect.map((response) => response.text),
);
// Effect<string, AiError, LanguageModel>
Посмотри на тип. Успех это строка, ответ модели. Ошибка это AiError, одна на все беды модели, к ней вернёмся ниже. А в третьем параметре стоит LanguageModel: эффекту нужен сервис модели, и пока его не дали, запустить эффект нельзя. Это тот же приём, что с сервисами в уроке 47: код говорит, что ему нужно, а какая модель отвечает, решает слой.
У ответа есть не только text. response.finishReason говорит, почему модель замолчала ('stop', кончились токены, позвала инструмент), response.usage считает потраченные токены. Инструменты подождут до следующего урока.
Промпт: свиток и вопрос
Строка в prompt это самый короткий вариант. Модели можно передать и разговор, массив сообщений с ролями:
const GOLEM_RULES =
'Ты голем справочной Почтамта. Отвечай только о башнях линии. Не знаешь, так и скажи.';
const ask = (question: string) =>
LanguageModel.generateText({
prompt: [
{ role: 'system', content: GOLEM_RULES },
{ role: 'user', content: question },
],
});
systemэто свиток в голове голема: правила, которые модель получает раньше вопроса. Горожанин их не видит и не пишет.userэто сам вопрос из окошка.assistantэто прошлые ответы модели. Их кладут в разговор, когда продолжают диалог.
Собирать разговор по частям умеет модуль Prompt (Prompt.make, Prompt.concat, Prompt.setSystem), но для одного вопроса хватает массива.
Свиток не волшебный. «Отвечай только о башнях» модель выполняет чаще, чем не выполняет, и только. Гарантий свиток не даёт, поэтому дальше нам нужна проверка, которая от слов модели не зависит.
Ответ по форме
Окошку справочной не нужен абзац прозы. Ему нужно три поля: какая башня, открыта ли и уверен ли голем. Это схема, и Effect умеет требовать у модели ответ по схеме:
import { Effect, Schema } from 'effect';
import { LanguageModel } from 'effect/ai';
const Answer = Schema.Struct({
tower: Schema.String,
open: Schema.Boolean,
sure: Schema.Boolean,
});
const answer = (question: string) =>
LanguageModel.generateObject({
prompt: [
{ role: 'system', content: GOLEM_RULES },
{ role: 'user', content: question },
],
schema: Answer,
}).pipe(Effect.map((response) => response.value));
// Effect<{ tower: string; open: boolean; sure: boolean }, AiError, LanguageModel>
generateObject делает две вещи. Сначала говорит модели, какой формы ждёт ответ. Потом разбирает то, что модель прислала, схемой Answer. Разобранный объект лежит в response.value, и его тип выведен из схемы. Ответ «T-3 вроде бы закрыта» или JSON без поля sure до value не доходит: схема его не пропустит, и эффект упадёт с AiError.
Поле sure здесь не украшение. Схема проверяет форму, но не правду: { tower: 'T-9', open: true, sure: true } форму проходит. Против выдумки работают две вещи. Первая: свиток разрешает голему сказать «не знаю», а sure: false даёт ему форму для этого; в разминке свиток так и говорит: «Не знаешь, ставь sure: false». Вторая: всё, что модель называет фактом, сверяют с источником правды, с книгой башен. Второе пойдёт в ход в следующем уроке, когда у голема появятся руки.
Где тут дыра
type Answer = { tower: string; open: boolean };
const answer = (question: string) =>
LanguageModel.generateText({ prompt: `${GOLEM_RULES}\nОтветь JSON.\n${question}` }).pipe(
Effect.map((response) => JSON.parse(response.text) as Answer),
);
На его примере всё работает: модель вернула {"tower":"T-3","open":true}, окошко показало «T-3 открыта». Соседний пример: модель отвечает {"tower":"T-9","open":true}. Башни нет, но форма верная, и окошко честно пишет «T-9 открыта». Горожанин идёт через полгорода к башне, которой нет. Третий пример хуже: модель начинает ответ словами «Конечно! Вот JSON:», и JSON.parse бросает исключение посреди эффекта. Это дефект, а не ошибка в типе, и снаружи его никто не ждёт.
as Answer здесь врёт так же, как врал каст в уроке 03. generateObject со схемой ловит и мусор, и пропущенные поля и превращает их в ошибку с именем: окошко перестаёт падать и показывать то, что не прошло проверку.
Беды модели
Всё, что может пойти не так у модели, приходит одной ошибкой AiError. Что случилось, говорит её поле reason, у каждой причины свой _tag:
RateLimitError: провайдер просит подождать, слишком много запросов.QuotaExhaustedError: деньги на счету у провайдера кончились.AuthenticationError: ключ неверный или его нет.NetworkError: запрос не дошёл.StructuredOutputError: ответ не разобрался схемой, у него естьresponseTextс тем, что прислала модель.UnknownErrorи ещё больше десятка причин: от запрещённого содержимого и сбоя у провайдера до ошибок инструментов.
Горожанину всё равно, сеть упала или квота кончилась: ему нужен честный ответ «не знаю». Поэтому почти все причины голем обрабатывает одинаково, и только ответ не по форме отдельно.
class GolemUnsure extends Schema.TaggedError<GolemUnsure>()('GolemUnsure', {
question: Schema.String,
reason: Schema.String,
}) {}
const honest = (question: string) =>
answer(question).pipe(
Effect.catchTag('AiError', (error) =>
Effect.fail(
new GolemUnsure({
question,
reason: error.reason._tag === 'StructuredOutputError' ? 'ответ не по форме' : 'голем молчит',
}),
),
),
);
// Effect<{ tower; open; sure }, GolemUnsure, LanguageModel>
AiError из канала ошибок ушёл, вместо него своя ошибка предметной области, как в уроке 39. Остальной код окошка про провайдера и его причины не знает.
Повторять ли запрос, тоже подсказывает ошибка. У AiError есть isRetryable: для RateLimitError и обрыва сети он true, для QuotaExhaustedError и AuthenticationError false, сколько ни повторяй, денег и ключа не прибавится. StructuredOutputError тоже повторяемая: модель отвечает каждый раз по-разному, и со второго раза форма может сойтись. Повтор собирается из урока 73:
answer(question).pipe(Effect.retry({ times: 2, while: (error) => error.isRetryable }));
Провайдер это слой
Кто отвечает за LanguageModel в работе, решает слой. Провайдеры лежат в отдельных пакетах, @effect/ai-anthropic и @effect/ai-openai (версии 4.1.2). В браузере урока их нет, поэтому код ниже только для чтения:
import { Config, Layer } from 'effect';
import { AnthropicClient, AnthropicLanguageModel } from '@effect/ai-anthropic';
import { FetchHttpClient } from 'effect/http';
const Client = AnthropicClient.layerConfig({ apiKey: Config.Redacted('ANTHROPIC_API_KEY') }).pipe(
Layer.provide(FetchHttpClient.layer),
);
const Golem = AnthropicLanguageModel.layer({ model: 'claude-sonnet-5-5' }).pipe(Layer.provide(Client));
// Layer<LanguageModel, ConfigError>
honest('Открыта ли T-3?').pipe(Effect.provide(Golem));
FetchHttpClient.layer ходит в сеть, AnthropicClient знает адрес и ключ, AnthropicLanguageModel.layer выбирает модель и даёт сервис LanguageModel. Другой провайдер это два других слоя, OpenAiClient и OpenAiLanguageModel; honest не меняется ни на строку.
Ключ провайдера это деньги. Он приходит через Config.Redacted из урока 51: значение читается из окружения и лежит в Redacted, которое в логе и в отчёте об ошибке печатается как <redacted>. Ключ строкой в коде уезжает в git при первом же коммите, и счёт за чужие запросы приходит тебе.
Болванка в тестах
Настоящая модель в тестах мешает трижды: она платная, медленная и каждый раз отвечает по-разному. Тест, который сегодня зелёный, а завтра красный, ничего не проверяет. Поэтому в тестах модель подменяют болванкой, как подменяли сервисы в уроке 53.
Собрать свой LanguageModel можно функцией LanguageModel.make. Ей нужны два метода, generateText и streamText, и каждый отвечает частями ответа:
import { Effect, Layer, Stream } from 'effect';
import { LanguageModel } from 'effect/ai';
const scripted = (reply: string) =>
Layer.effect(
LanguageModel.LanguageModel,
LanguageModel.make({
generateText: () =>
Effect.succeed([
{ type: 'text', text: reply },
{ type: 'finish', reason: 'stop', usage: { inputTokens: {}, outputTokens: {} } },
]),
streamText: () => Stream.empty,
}),
);
const sure = scripted(JSON.stringify({ tower: 'T-3', open: false, sure: true }));
const prose = scripted('T-3 вроде бы закрыта');
await Effect.runPromise(honest('T-3?').pipe(Effect.provide(sure))); // { tower: 'T-3', open: false, sure: true }
await Effect.runPromise(Effect.flip(honest('T-3?')).pipe(Effect.provide(prose))); // GolemUnsure «ответ не по форме»
Болванка отвечает тем, что ей записали, и тест проверяет то, за что отвечаешь ты: как код просит модель, разбирает ответ и обходится с бедой. Ответ не по форме, неуверенный ответ, AiError вместо ответа: любой сценарий пишется одной строкой, а настоящую модель так по заказу не сломаешь.
Под капотом
Нестабильно. Весь модуль
effect/ai(LanguageModel,Prompt,AiError,Response) и пакеты провайдеров в 4.1 помечены@stability unstable, и между бетами 4.0 и 4.1 этот API уже менялся. Урок написан наeffect@4.1.0и провайдерах4.1.2.
Разминка
Посади голема в окошко: ответ только по схеме и только уверенный, всё остальное честный GolemUnsure с причиной. Тесты подставляют болванку голема и проверяют, что он получил свиток и вопрос, что неуверенный ответ и ответ не по форме не уходят горожанину и что AiError наружу не течёт.
Практика
Глубже в архиве
Как модель режет текст на токены, откуда берутся выдумки и почему длина контекста конечна, разобрано в уроке про LLM и coding-агентов раздела про Python-бэкенд.
Дальше
К обеду Дорфл стоит в окошке справочной. Горожанин спрашивает про T-3 и получает короткое «закрыта». Про T-9 голем теперь говорит «не знаю».
Руки голема ждут в следующем уроке.