В уроке 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 закрыта. Письмо не принято». Это правда. Липвиг доволен, но у окошка уже стоит Чонси.
Общий язык для всех помощников ждёт в следующем уроке.