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

Руки голема: инструменты и цикл агента

middle-senior~25 мин

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

В уроке 113 голем научился отвечать по схеме: башня, открыта ли, уверен ли. Схема поймала выдуманную T-9 и мусор вместо JSON. Но одну беду схема не ловит: ответ может быть по форме и при этом не про дело. «Письмо отправлено» это правильная строка. Письма от неё не появляется.

Модель умеет только говорить. Открыть книгу башен, принять письмо, записать его в журнал умеет твой код. Значит, разговор надо устроить так: модель просит сделать дело, код решает, можно ли, делает и возвращает ей результат, а модель отвечает горожанину уже по результату. Просьба модели называется вызовом инструмента, а код, который гоняет разговор, пока модель просит, называется агентом.

Инструмент это договор

Руки голема начинаются с описания. Модели не дают функцию: ей дают имя, назначение и форму входа и выхода.

import { Effect, Schema } from 'effect';
import { Tool, Toolkit } from 'effect/ai';

const LookupTower = Tool.make('lookupTower', {
  description: 'Открыта ли башня линии',
  parameters: Schema.Struct({ code: Schema.String }),
  success: Schema.Struct({ code: Schema.String, open: Schema.Boolean }),
  failure: Schema.Struct({ reason: Schema.String }),
  failureMode: 'return',
});

Tool.make получает имя и четыре части договора. description читает модель, когда решает, какую руку звать: напиши его так, как объяснил бы новичку у окошка. parameters это схема просьбы. Модель присылает параметры JSON-ом, и Effect декодирует их этой схемой до того, как дойдёт до твоего кода. Это та же мысль, что в уроке 113: просьба модели такое же письмо с улицы, как её ответ. success описывает, что рука возвращает, а failure, чем она может не справиться.

failureMode решает, куда пойдёт провал. По умолчанию 'error': ошибка руки уходит в канал ошибок всего вызова модели, и разговор кончается бедой. С 'return' провал становится результатом: модель получит { reason: 'нет башни T-9' } и сможет честно сказать это горожанину. Для голема у окошка это почти всегда правильный выбор: «такой башни нет» это ответ, а не авария.

Набор рук и сами руки

Инструменты собирают в набор, и уже к набору пишут обработчики:

const book: Record<string, boolean> = { 'T-1': true, 'T-2': true, 'T-3': false };

const TowerDesk = Toolkit.make(LookupTower);

const TowerDeskLayer = TowerDesk.toLayer({
  lookupTower: ({ code }) =>
    code in book
      ? Effect.succeed({ code, open: book[code] === true })
      : Effect.fail({ reason: `нет башни ${code}` }),
});

Toolkit.make принимает инструменты, toLayer принимает объект «имя инструмента → обработчик» и отдаёт слой. Тип обработчика выводится из договора: code уже string, вернуть надо { code, open } или упасть { reason }. Опечатаешься в имени поля, и компилятор скажет об этом раньше голема.

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

Обработчикам часто нужно состояние. toLayer принимает и эффект, который строит обработчики, и тогда состояние рождается вместе со слоем:

import { Ref } from 'effect';

const CountedDeskLayer = TowerDesk.toLayer(
  Effect.gen(function* () {
    const asked = yield* Ref.make<ReadonlyArray<string>>([]);
    return TowerDesk.of({
      lookupTower: ({ code }) =>
        Effect.flatMap(
          Ref.update(asked, (codes) => [...codes, code]),
          () =>
            code in book
              ? Effect.succeed({ code, open: book[code] === true })
              : Effect.fail({ reason: `нет башни ${code}` }),
        ),
    });
  }),
);

TowerDesk.of только проверяет тип объекта и возвращает его как есть. Ref из урока 65 живёт столько, сколько слой: один журнал на собранный слой, а не на каждый вызов. В разминке так же устроен счётчик писем.

Один ход

Голем, у которого есть руки, говорит в чате. Отдельный вызов LanguageModel.generateText из урока 113 ничего не помнит: спросил, получил ответ, забыл. Chat держит историю.

import { Chat } from 'effect/ai';

const firstTurn = Effect.gen(function* () {
  const chat = yield* Chat.empty;
  const response = yield* chat.generateText({ prompt: 'Открыта ли T-9?', toolkit: TowerDesk });
  console.log(response.finishReason); // 'tool-calls'
  console.log(response.toolCalls.map((call) => call.name)); // ['lookupTower']
  console.log(response.toolResults.map((result) => result.result)); // [{ reason: 'нет башни T-9' }]
});

Chat.empty создаёт пустой разговор. chat.generateText делает за один вызов четыре вещи: кладёт промпт в историю, спрашивает модель, сам исполняет инструменты, которые она попросила, и кладёт в историю и просьбы, и результаты. Эффект требует модель и обработчики набора, так что запускаешь его с Effect.provide(TowerDeskLayer) и слоем модели.

Посмотри на finishReason. Модель не ответила словами, она остановилась со словами «мне нужна рука»: 'tool-calls'. Текста в этом ответе может не быть совсем. Горожанину отвечать пока нечего, но результат руки уже лежит в истории. Нужен следующий ход.

Цикл агента

Следующий ход ничего нового не спрашивает: у модели уже есть вопрос и результат руки. Поэтому промпт пустой, а ходы идут, пока модель просит:

const ask = (question: string) =>
  Effect.gen(function* () {
    const chat = yield* Chat.empty;
    let response = yield* chat.generateText({ prompt: question, toolkit: TowerDesk });
    while (response.finishReason === 'tool-calls') {
      response = yield* chat.generateText({ prompt: [], toolkit: TowerDesk });
    }
    return response.text;
  });

Это и есть агент: цикл на десять строк. Модель просит, код исполняет, модель видит результат и либо просит ещё, либо отвечает. На вопрос «открыта ли T-9» получится два хода: просьба lookupTower и ответ словами «такой башни нет».

В этом цикле дыра размером с ночь. Ничто не мешает модели просить руку снова и снова: она путается, зовёт lookupTower для той же башни, опять и опять. Каждый ход это запрос к провайдеру и деньги, а while не устаёт. Агенту нужен предел ходов: считаешь ходы, и если на последнем разрешённом модель всё ещё просит руки, разговор кончается типизированной ошибкой, а не счётом за ночь. В разминке это GolemLoop { rounds }, и askGolem возвращает вместе с ответом число ходов: по нему видно, сколько стоил разговор.

Под капотом

Запасной голем

Голем иногда молчит: провайдер упал, кончилась квота, свиток не читается. В уроке 73 от такого спасал Effect.retry. Но повтор зовёт ту же модель, а та может молчать час. Лучше после пары попыток спросить другую.

import { ExecutionPlan, type Layer } from 'effect';
import type { LanguageModel } from 'effect/ai';

declare const mainGolem: Layer.Layer<LanguageModel.LanguageModel>;
declare const spareGolem: Layer.Layer<LanguageModel.LanguageModel>;

const plan = ExecutionPlan.make({ provide: mainGolem, attempts: 3 }, { provide: spareGolem });

const answer = ask('Открыта ли T-2?').pipe(
  Effect.withExecutionPlan(plan),
  Effect.provide(TowerDeskLayer),
);

ExecutionPlan это список шагов. У шага есть provide, слой, который он подставляет, и attempts, сколько раз пробовать. Ещё бывают schedule, пауза между попытками, как в уроке 72, и while, условие «ошибка такая, что пробовать на этом шаге ещё есть смысл». Effect.withExecutionPlan запускает эффект на первом шаге, а если тот провалился, на следующем. Требование модели из типа ask закрывает сам план: каждый шаг подставляет свой слой, и после withExecutionPlan модель в требованиях уже не значится.

Одна тонкость стоит отдельной строки. План перезапускает весь эффект. Если основной голем успел позвать deliverLetter, а потом замолчал, следующая попытка начнёт разговор сначала и отправит письмо ещё раз. Повторяет и сам основной шаг, и запасной: с attempts: 3 и запасным шагом это до четырёх одинаковых писем. Руки с последствиями должны переживать повтор: ключ письма и проверка дубля из урока 110 нужны и здесь.

Где тут дыра

const deliver = ({ to }: { to: string }) =>
  Effect.sync(() => {
    if (book[to] !== true) throw new Error(`башня ${to} закрыта`);
    return { letter: 1 };
  });

Голем не разберётся: до него ничего не дойдёт. Исключение внутри Effect.sync это дефект, а не ошибка в типе. Дефект не превращается в результат руки даже с failureMode: 'return': он поднимается выше и роняет весь разговор. Горожанин у окошка видит пустоту, а в логе стоит «башня T-3 закрыта», о которой модель так и не узнала.

Почини договором. Опиши провал схемой failure, верни его Effect.fail({ reason: 'башня закрыта' }) и поставь failureMode: 'return'. Тогда закрытая башня станет обычным результатом, и голем скажет горожанину правду: «T-3 закрыта, письмо не принято». Дефект остаётся для того, чего правда не должно случаться, как в уроке 40.

Нестабильно. Всё в effect/ai (Tool, Toolkit, Chat, LanguageModel) и ExecutionPlan в 4.1 помечены @stability unstable; между бетами 4.0 и 4.1 этот API уже менялся. Документация на сайте Effect пока описывает версию 3: там те же Tool.make и Toolkit.make, но из пакета @effect/ai. Урок написан на effect@4.1.0.

Разминка

Дай голему руки: книгу башен и окошко писем со счётчиком, цикл агента на одном чате с пределом ходов и запасного голема на плане. Тесты играют за модель сценарием: просьба руки, ответ словами или молчание.

Практика

Глубже в архиве

Тот же цикл без библиотеки, на сотню строк Python, с бюджетом шагов и вопросом к человеку, собран в уроке про мини-агента, а что такое агент и из чего состоит разговор с моделью, разобрано в уроке про LLM-агентов раздела про Python-бэкенд.

Дальше

К вечеру голем у окошка смотрит в книгу башен, прежде чем ответить, и принимает письма с номером. На «отправь письмо на T-3» он отвечает: «T-3 закрыта. Письмо не принято». Это правда. Липвиг доволен, но у окошка уже стоит Чонси.

Общий язык для всех помощников ждёт в следующем уроке.