Раздел 46 · Effect: Тихая Академия

Свиток в голове: модель как недоверенный сервис

middle~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

* Голема в городе лепят из глины, а работать его заставляет свиток в голове. Что написано в свитке, голем сам не читает: он просто делает. У хорошего хозяина голема есть привычка, которой нет у плохого: он проверяет работу, а не слова голема о работе.

Утро после того дня, когда Магистраль отключили. В подвале Академии пахнет печью и мокрой глиной. Пондер сидит на ящике с перфокартами, Липвиг ходит вокруг голема и прикидывает, пролезет ли тот в дверь справочной Почтамта. Людей у окошка не хватает: горожане с утра спрашивают, открыта ли их башня и где их письмо, и очередь стоит до угла.

Липвиг пишет на бумажке первый вопрос из очереди: «Открыта ли 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 голем теперь говорит «не знаю».

Руки голема ждут в следующем уроке.