Каждый, кто выпускал продукт на основе агентов, знает это чувство: быстро сделать работающую демонстрацию легко, но превратить её в то, чему пользователь доверяет каждый день, — что целый день работает на его компьютере и не падает, — трудно. И трудность не в подключении модели. Она во всём слое вокруг модели.
У этого слоя есть несколько названий; я буду использовать Agent Harness. Он находится между «большой моделью» и «функциями продукта» и является настоящей средой выполнения: превращает один пользовательский запрос в череду обращений к модели, вставляет между ними вызовы инструментов, возвращает результаты, сжимает контекст до его переполнения, повторяет запросы при сетевых сбоях и восстанавливает диалог даже после падения процесса. Модель думает; harness превращает эти размышления в надёжную последовательность действий.
Orkas — настольное приложение с агентами, работающее на компьютере пользователя, и его harness целиком находится на клиенте. В статье разбирается устройство этого слоя: деление на уровни, цикл выполнения, абстракции инструментов и моделей, работа с памятью и сеансами. Детали кода очищены и обобщены, но инженерная структура реальна.
Слои
Если разложить агентный продукт по слоям, снизу вверх получится примерно следующее:
┌─────────────────────────────────────────────────────────────────┐
│ Функции продукта (чат / навыки / коннекторы / синхронизация) │
├─────────────────────────────────────────────────────────────────┤
│ Обвязка агента (цикл выполнения / инструменты / сессия) │
├─────────────────────────────────────────────────────────────────┤
│ Абстракция провайдеров (единый интерфейс для разных LLM) │
├─────────────────────────────────────────────────────────────────┤
│ Инфраструктура (типы / ошибки / журналы / настройки) │
└─────────────────────────────────────────────────────────────────┘Здесь заложен один принципиальный выбор: весь инференс моделей происходит на клиенте. Настольное приложение — не тонкий клиент: оно содержит сам harness и напрямую вызывает модель. Сервер отвечает только за аккаунты, синхронизацию нескольких устройств и оплату; он вообще не запускает агента. Это решение определило почти всё остальное: сеансы сохраняются на локальном диске, инструменты работают непосредственно в рабочем каталоге пользователя, а чувствительные данные никогда не покидают компьютер.
Сам harness состоит из нескольких частей: цикла выполнения (runner), сеанса, инструментов, слоя Provider и памяти. Разберём их по очереди.
Цикл выполнения: потоковый генератор
Сердце harness — runner. Одной фразой его работа описывается так: снова и снова обращаться к модели, пока она не скажет «я закончила».
Он реализован как асинхронный генератор, и этот выбор важен. Один запуск агента — гораздо больше, чем «отправить запрос и дождаться результата». Между этими моментами происходит многое: модель выдаёт токены, хочет вызвать инструмент, инструмент завершает работу, контекст разрастается до порога сжатия, сеть даёт сбой, и мы повторяем запрос. При использовании обратных вызовов или обычных Promise эти промежуточные состояния трудно аккуратно передать вызывающему коду. В генераторе все они становятся потоком событий, выдаваемых через yield:
type AgentRunEvent =
| { type: "text_delta"; text: string } // model emitting tokens
| { type: "tool_start"; name: string; input: unknown } // a tool starts executing
| { type: "tool_end"; name: string; result: string } // a tool finished
| { type: "compaction"; tokensBefore: number; tokensAfter: number } // context compacted
| { type: "retry"; attempt: number; reason: string } // error, retrying
| { type: "done"; result: AgentRunResult } // terminalИнтерфейс подписывается на этот поток событий и в реальном времени отображает вывод модели и выполнение инструментов. Непотоковая точка входа внутри сводится к «прочитать поток до конца и взять итоговое событие done» — обе точки входа используют одну реализацию, поэтому нет второго пути выполнения кода, который мог бы рассинхронизироваться.
Что происходит внутри хода
Если развернуть один ход, он выглядит примерно так:
- Добавить сообщение пользователя (возможно, с изображениями) в историю сеанса.
- Собрать системный промпт, включив доступные сейчас инструменты, индекс навыков и так далее.
- Разобрать строку модели и определить конкретный Provider и ID модели.
- Преобразовать все инструменты в определения, понятные модели, и отправить их вместе с историей.
- Обрабатывать поток ответа модели, передавая через
yieldтекст токен за токеном и одновременно собирая все вызовы инструментов, которые делает модель. - Когда поток завершится, проверить причину остановки модели:
- Если это
tool_use, модель хочет вызвать инструмент — выполнить инструменты, затем вернуться к шагу 5 и снова обратиться к модели. - В противном случае ход завершён — собрать результат, выполнить
yield doneи вернуться.
Здесь нужно соблюдать один инвариант: за каждым вызовом инструмента моделью в истории должен сразу следовать соответствующий результат инструмента. API модели строго требует такого парного соответствия: нарушите его, и следующий запрос завершится ошибкой или просто зависнет. Мы вернёмся к этому при обсуждении самовосстановления сеансов.
Как маршрутизируется вызов инструмента
Модель не выполняет инструменты сама; она лишь говорит: «Я хочу вызвать read_file с такими аргументами». Когда runner получает это намерение:
for (const call of toolUseBlocks) {
yield { type: "tool_start", name: call.name, input: call.input };
const tool = this.tools.get(call.name);
const ctx = { workingDir, signal, state: { sandboxEnv } };
const result = await tool.execute(call.input, ctx);
// append the result to the session as a tool-result message
session.addToolResult(call.id, result);
yield { type: "tool_end", name: call.name, result: result.content };
}Инструменты выполняются последовательно, результаты записываются в историю в порядке, заданном моделью, затем к модели снова обращаются уже с этими результатами. Увидев их, модель может вызвать ещё один инструмент или дать окончательный ответ. Именно этот цикл «запрос → вызов → ответ → новый запрос» позволяет агенту выполнять многоэтапные задачи.
Одна деталь заслуживает отдельного упоминания: некоторые инструменты возвращают изображения — скриншоты, сгенерированные картинки. Но многие модели не принимают изображения в канале результатов инструментов. Orkas решает это, вынося изображение в отдельное сообщение пользователя, расположенное после результата инструмента: сначала модель читает «инструмент вернул такой текст», а на следующем ходу видит соответствующее изображение. Небольшой компромисс, позволяющий обойти различия возможностей провайдеров.
Что делать, когда контекст близок к переполнению
Чаще всего длительные задачи упираются в окно контекста. Orkas не ждёт его заполнения — он задаёт порог 60%: после каждого раунда инструментов оценивает, какую часть окна занимают текущие токены, и при превышении 60% заранее запускает сжатие.
При сжатии модель получает просьбу подвести итог предыдущей беседы, затем старые сообщения заменяются этой сводкой, а сохраняется только самый свежий конец истории. Звучит просто, но есть ловушка: после замены сохранённая часть не должна начинаться с «осиротевшего результата инструмента» — нельзя оставлять «результат без соответствующего вызова», иначе инвариант парности снова нарушится. Поэтому логика сжатия следит за тем, чтобы разрез проходил по корректной границе.
Здесь стоит разобрать более интересный выбор: почему грубый подход «сжать весь блок при 60%», а не что-то более точное — оценивать каждое сообщение и обрезать по важности, структурированно извлекать данные из результатов инструментов, поддерживать многоуровневое дерево памяти? В статьях эти подходы выглядят отлично, но мы сознательно не пошли этим путём по трём причинам.
Во-первых, кэширование. Кэш промпта модели работает по префиксу: пока начало истории не меняется, этот фрагмент берётся из кэша, экономя деньги и время ответа. Тонкое сжатие постоянно переписывает середину истории, снова и снова разрушая закэшированный префикс: каждая правка заставляет заново предварительно обработать большой объём. Стратегия «не трогать, затем один раз сжать при достижении порога» сохраняет префикс стабильным в подавляющем большинстве ходов; его инвалидирует лишь само сжатие. Для кэша это гораздо благоприятнее.
Во-вторых, сложность. Тот самый инвариант «каждый вызов инструмента должен иметь пару», на котором мы настаиваем: чем мельче вы обрезаете историю, тем выше вероятность нарушить его в каком-то угловом случае. При обобщённой сводке достаточно защитить одну корректную точку разреза; мест, где можно ошибиться, на порядок меньше. На один класс граничных случаев меньше — на один класс инцидентов в эксплуатации меньше.
В-третьих, польза от улучшения моделей. За последние пару лет окна контекста стабильно росли, а модели всё лучше справлялись с длинным контекстом. Вкладываться сегодня в сложный алгоритм сжатия — по сути бороться с уменьшающейся проблемой: велика вероятность, что вы закончите настройку как раз тогда, когда следующее поколение удвоит окно, и ваша сложность станет чистым бременем. Напротив, передача суммаризации самой модели автоматически даёт выигрыш по мере её улучшения: чем лучше она выделяет важное, тем качественнее сводка, а мы не меняем ни строки. Сложность, которую модель может взять на себя, не стоит брать на себя вам.
В оценке токенов скрывается одна легко упускаемая проблема: китайский язык. Если оценивать его по английской интуиции — примерно один токен на несколько символов, — получится сильное занижение. Orkas отдельно учитывает вес символов CJK; иначе порог для полностью китайского диалога определяется неверно и сжатие не срабатывает вовремя.
Ошибки и повторные попытки
При работе на компьютере пользователя с зависимостью от внешнего API модели ошибки — норма, а не исключение. Runner делит их на несколько классов и обрабатывает каждый по-своему:
- Допускающие повтор: ограничения частоты запросов, тайм-ауты, разрывы соединения, 5xx. Экспоненциальная задержка со случайным разбросом, не более 30 секунд; если это ограничение частоты и сервер прислал
retry-after, учитывать его. - Не допускающие повтор: например, ошибки авторизации — повторы не помогут, поэтому сразу завершать с ошибкой.
- Особые: переполнение контекста. Сначала попробовать сжатие, затем повторить запрос один раз и выдать ошибку, только если это не помогло.
Есть ещё один класс: «ошибка самого инструмента». Она не срывает весь ход: сбой инструмента сам по себе является информацией для модели, которая, увидев «эта команда завершилась ошибкой», вполне может попробовать другой подход. Harness отличает эти временные ошибки инструментов от настоящих отказов: он не прерывает процесс и не теряет их — они отражаются в итоговой статистике. (Позже эти данные используются механизмом саморазвития, которому посвящена следующая статья.)
Внешний сигнал отмены (AbortSignal) проверяется во всех ключевых точках. Пользователь нажимает «стоп», и текущий ход немедленно останавливается — новые повторные попытки не запускаются.
Абстракция инструментов: достаточно простая для расширения
Интерфейс инструмента намеренно минимален:
interface AgentTool {
readonly name: string;
readonly description: string; // shown to the model
readonly inputSchema: Record<string, unknown>; // JSON Schema to constrain inputs
execute(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}Инструмент — это просто «имя + описание для модели + входная схема + функция выполнения». Все встроенные инструменты — чтение файла, запись файла, запуск команды оболочки, веб-поиск и загрузка веб-страниц — реализуют этот интерфейс. Настольный слой добавляет поверх набор инструментов с локальной спецификой (поиск по базе знаний, генерация изображений, вызов внешних коннекторов), но интерфейс остаётся тем же.
Преимущество минимального интерфейса в том, что для runner неважно происхождение инструмента: встроенный, пользовательский или загруженный из навыка — всё это объекты одного типа, зарегистрированные в одной структуре Map<string, AgentTool> и преобразуемые на каждом ходу в определения, понятные модели.
Инструменты с побочными эффектами, например команды оболочки, проходят через изолированный исполнитель: тайм-ауты, ограничения длины вывода, список запрещённых команд и переменные окружения, передаваемые отдельно, а не через изменение глобального окружения процесса. Последнее распространилось бы на множество дочерних процессов и в многопроцессной архитектуре вроде Electron легко может нарушить запуск.
Слой Provider: сведение множества моделей к одному интерфейсу
Предпочтения пользователей в моделях сильно различаются, и продукт не может жёстко привязываться к одному поставщику. Под harness в Orkas расположен слой абстракции Provider, объединяющий модели разных поставщиков за единым интерфейсом:
interface LLMProvider {
readonly id: string;
complete(params: CompletionParams): Promise<CompletionResult>;
stream(params: CompletionParams): AsyncIterable<StreamEvent>;
validateAuth(): Promise<boolean>;
}Runner выше обращается только к этому интерфейсу и не знает, какой поставщик за ним стоит. Реестр маршрутизирует запросы по строке модели: явная форма provider/model разбирается напрямую; имя модели без уточнения определяется по префиксу. Авторизация (API-ключ или токен OAuth) тоже управляется здесь, а истёкший токен OAuth обновляется автоматически.
При сведении множества моделей настоящая головная боль — не генерация текста, а места, где семантика поставщиков расходится. Два примера, с которыми мы столкнулись.
Первый — сохранение блоков размышлений между поставщиками. Модели рассуждений выдают фрагмент «размышлений»; некоторые поставщики шифруют его и требуют возвращать без изменений, другие представляют его другим набором полей. Если пользователь посреди диалога переключается с поставщика A на B, подпись этого фрагмента размышлений в истории больше не совпадает. Решение — помечать каждое сообщение в истории указанием модели, которая его создала, чтобы слой преобразования мог решить, сохранять ли его дословно: та же модель — сохранить; другая — понизить представление по правилам.
Второй — кэш промпта. Между ходами одного сеанса префикс многократно повторяется, и его кэширование заметно снижает стоимость и задержку. Реализация передаёт ID сеанса как ключ кэша поставщикам, которые это поддерживают, учитывая ограничения каждого по длине ключа (например, обрезая или хэшируя слишком длинный).
Всё это рутинная работа, но именно этот слой рутины позволяет runner выше считать, что «существует только один вид модели».
Память: два механизма, каждый для своей задачи
«Память» в Orkas — на самом деле два параллельных механизма, решающих совершенно разные задачи. Первый — база знаний с поиском для объёмных материалов, к которым «обращаются при необходимости». Второй — память между сеансами для небольшого набора ключевых фактов, которые «нужно всегда держать в уме». Многие продукты смешивают эти два механизма; их разделение делает всё гораздо понятнее.
База знаний: гибридный поиск
Первый механизм предназначен для объёмного, но лишь иногда нужного контента: документов пользователя, прошлых заметок, предметных знаний. Это локальная база знаний с векторным поиском и двумя бэкендами: облегчённым, полностью в памяти (для тестов и временного использования), и сохраняемым в локальной базе данных (для эксплуатации, с полнотекстовым индексом и векторами).
Данные поступают по такому пути:
документы → фрагменты по границам строк (с перекрытием) → двойная индексация
├─ полнотекстовый индекс (ключевые слова, без затрат на эмбеддинги)
└─ векторный индекс (если настроена модель эмбеддингов)Фрагменты разделяются по границам строк с небольшим перекрытием, чтобы не разрезать целостный смысл посередине. Поиск — гибридный: векторный проход (семантическая близость) и проход по ключевым словам (буквальные совпадения), после чего два набора результатов объединяются через RRF (Reciprocal Rank Fusion):
score = Σ 1 / (k + rank_i)Чем выше результат в одном проходе, тем больше его вклад; сумма по обоим проходам позволяет учитывать семантическую релевантность и не терять точные буквальные совпадения. Веса векторного поиска и ключевых слов настраиваются; по умолчанию предпочтение отдаётся семантике. После объединения дубликаты удаляются по паре «(документ, начальная строка)», оставляя лучший результат для каждого места, затем всё ниже порога отсекается и возвращаются лучшие K результатов.
Почему не полагаться только на векторы? Потому что векторный поиск часто проваливается на именах собственных, символах кода и точных строках — запросах, где семантика ничем не выделяется, но буквальное совпадение крайне важно. А поиск только по ключевым словам не улавливает «тот же смысл, другая формулировка». Использование обоих — очень практичный компромисс между качеством поиска и затратами.
Память между сеансами: помнить о пользователе
База знаний решает проблему «слишком много материала, чтобы всё удержать». Но есть другой класс сведений: их мало, однако их нужно помнить постоянно — кто этот пользователь, что он предпочитает, о чём договорились в прошлый раз. Они не должны зависеть от того, «повезёт ли найти и вспомнить»; они должны присутствовать на каждом ходу.
Для этого Orkas создаёт отдельный слой памяти между сеансами, разделённый по содержанию на две части:
- Профиль пользователя: стабильные факты о человеке — роль, предпочтения, стиль общения, технологический стек.
- Заметки о фактах: долговременные факты о работе — решения, этапы, соглашения проекта.
Обе части малы: каждая жёстко ограничена несколькими тысячами символов, что заставляет сохранять только действительно полезное в долгосрочной перспективе. Они не проходят через поиск, а напрямую включаются в системный промпт в начале каждого хода: агент просто «знает» эти вещи, не вспоминая о необходимости их искать. Это прямо противоположно подходу базы знаний: база знаний — «загрузить только при необходимости, затем убрать», память между сеансами — «всегда присутствует, всегда видна».
Запись проходит через специальный инструмент памяти, который модель вызывает, когда по ходу беседы решает: «это стоит запомнить надолго». Он поддерживает добавление, замену подстроки и удаление. Что сохранять, а что нет, явно указано в описании инструмента: высший приоритет у исправлений и предпочтений пользователя; долговременные решения и соглашения сохраняются; временное состояние текущей задачи, разовая отладочная информация и всё, что легко выяснить заново, — нет. Память предназначена для «устойчивых фактов о пользователе и проекте», а не для «того, до чего я дошёл в этот раз».
Есть легко упускаемая, но весьма важная деталь: перед каждой записью выполняется проверка безопасности. Это содержимое дословно попадает в системный промпт и надолго сохраняется между сеансами — фактически становясь постоянной поверхностью для инъекций. Поэтому перед записью на диск каждая заметка памяти проверяется на подозрительные шаблоны: типичные формулировки инъекций в промпт («игнорируй все предыдущие инструкции» и подобные), команды для вывода ключей наружу, скрытые в тексте невидимые символы Unicode. При совпадении запись сразу отклоняется. В сочетании с удалением дубликатов и обрезкой сверх лимита это сохраняет полезность слоя памяти, не превращая его в источник риска.
Вместе эти механизмы покрывают оба полюса: «огромное, но нужное изредка» и «малое, но нужное постоянно». Первым занимается база знаний, вторым — память между сеансами. Добавьте к этому понимание агентом самого себя (тема следующей статьи), и агент Orkas начинает работу сразу с тремя видами памяти: о материалах, о пользователе и о себе.
Сеансы: рассчитаны на сбои и восстановление
Сеанс управляет историей сообщений. Базовая версия — просто массив сообщений в памяти с обрезкой и сжатием истории. Но всё, что работает на компьютере пользователя, должно предполагать, что его могут завершить в любой момент: пользователь закроет приложение, система перезагрузится, сторожевой таймер убьёт процесс. Поэтому в эксплуатации используется сохраняемый сеанс: локальный файл JSONL, одно сообщение на строку.
Есть две стратегии записи: новое сообщение добавляется атомарно; всё, что переписывает файл целиком (сжатие, очистка), использует схему «записать временный файл + атомарно переименовать». Так даже при отключении питания посреди записи не останется половины повреждённой записи.
Самая интересная часть — восстановление осиротевших вызовов инструментов. Вернёмся к инварианту парности: модель делает вызов инструмента, harness выполняет его, результат записывается обратно. Прервите любой из этих трёх шагов — и на диске останется «сирота», вызов без результата. Если в следующий раз загрузить этот сеанс и отправить модели как есть, API отклонит его или зависнет.
Логика восстановления запускается при каждой загрузке сеанса с диска и является идемпотентной:
- Просмотреть все сообщения помощника и собрать ID сделанных ими вызовов инструментов.
- Найти далее соответствующие результаты инструментов.
- Для каждого вызова без соответствующего результата создать результат с пометкой «прервано».
- Попутно выровнять порядок результатов по порядку объявления вызовов и удалить осиротевшие результаты без соответствующего вызова.
После этого прохода сеанс гарантированно удовлетворяет требованию API о парности и безопасен для отправки. Механизм выглядит обыденно, но именно он служит страховкой от того, чтобы диалог пользователя навсегда заблокировался из-за одного сбоя.
Несколько решений, ценность которых стала ясна позже
Если собрать всё вместе, некоторые решения задним числом выглядят особенно ценными.
Генераторы как основной интерфейс. Потоковый и непотоковый режимы используют одну реализацию, промежуточное состояние появляется естественным образом, а интерфейс может отображать столько деталей, сколько нужно. Это избавило от целого класса ошибок согласованности, которые породил бы подход «сначала непотоковая реализация, потом прикрутить потоковую».
Сжимать при 60%, а не при полном окне. Это оставляет запас для самого сжатия (которому тоже нужен вызов модели) и позволяет избежать спешки в последний момент.
Инвариант парности проходит через всё. От точки разреза при сжатии до записи на диск и восстановления при загрузке — везде, где затрагивается сеанс, соблюдается одно правило. Благодаря единому правилу нигде не приходится изобретать собственную логику исправлений.
Рутинная работа сосредоточена в слое Provider. Все неудобства различий поставщиков — блоки размышлений, ключи кэша, различия возможностей — обрабатываются в одном слое, сохраняя runner выше чистым. Если когда-нибудь добавить нового поставщика моделей, изменения почти не выйдут за пределы этого слоя.
В завершение
В harness Orkas нет поразительного алгоритма. Его ценность в том, что задача «обеспечить надёжную работу агента в реальной среде» разделена на набор модулей с чёткими границами, каждый из которых отвечает за свою часть: runner — за цикл и повторы, инструменты — за возможности, слой Provider — за унификацию множества моделей, память — за поиск, сеанс — за сохранение и восстановление. По отдельности они несложны; только вместе они поддерживают то, чем люди пользуются каждый день.
Если вынести что-то главное: сделайте цикл выполнения потоковым генератором — и работать с промежуточным состоянием станет гораздо легче; задав ключевой инвариант (например, «вызовы инструментов должны иметь пару»), соблюдайте его при сжатии, записи на диск и загрузке, без исключений в отдельных уголках; сосредоточьте рутину различий поставщиков в одном слое и вынесите её из бизнес-логики; и, самое прямое, считайте, что процесс убьют в худший возможный момент, и заранее напишите восстановление для этого случая.
В следующей статье разбирается более интересная часть Orkas: как этот агент учится на собственном использовании, превращает опыт в повторно используемые навыки и постепенно становится полезнее.