Инженерия хардвера: среда выполнения, превращающая LLM в агента
Что на самом деле позволяет работать агенту для программирования: цикл, механизм вызова инструментов, способный выдерживать ошибочные запросы, а также функции разрешения/запроса/отклонения прав доступа, показанные на примере примерно 600 строк простого кода на Python.
Источники: Harness Engineering: Build the Runtime That Turns an LLM Into an Agent (Part 1/3)
Когда что-то идет не так с агентом для кодирования, обычно вину возлагают на модель. На самом деле проблема чаще всего кроется в фреймворке, в программе, которая его окружает.
Каждый, кто использовал API для дополнения текста в чате, знает про простую часть работы: отправка сообщений, получение ответа, возможно, вызов функций. Сложная часть — это путь от этого до агента, способного находиться в репозитории, редактировать файлы, запускать тесты и восстанавливаться после повреждения чего-либо. Фреймворк преодолевает этот путь. Это среда выполнения вокруг модели: инструменты, разрешения, изоляция, обработка контекста, управление памятью и восстановление от ошибок. Модель делает только одно — принимает текст и возвращает текст. Фреймворк — это программа, которая повторяет этот цикл, запускает инструменты, обеспечивает безопасность и взаимодействует с вами в терминале. Если модель — это двигатель, то фреймворк — это автомобиль.
Сам по себе модель языка не может открыть ваши файлы, запустить pytest, отредактировать код, вспомнить разговор прошлого вторника или помешать себе ввести команду rm -rf /. Всё, что она может делать, — это генерировать следующее сообщение. Всё остальное требует дополнительных механизмов:
- Цикл: постоянно вызывать модель до тех пор, пока она не перестанет запрашивать инструменты.
- Инструменты: средства для выполнения действий (оболочка, файлы, редактирование).
- Разрешения: запрашивать разрешение перед выполнением рискованных действий и блокировать наиболее опасные из них.
- Песочница: препятствовать выполнению опасных действий на уровне операционной системы.
- Контекст: ограничивать объем разговора так, чтобы его можно было отправить.
- Память: хранение навыков, списка дел, файла сессии, который можно возобновить.
- Подагенты: исследование кодовой базы без перегрузки основного разговора.
В этой статье рассматриваются первые три аспекта, которые превращают чат-бота в агента. Остальные элементы — сандбоксинг, управление контекстом, память и субагенты — представляют собой дополнительные слои на той же основе.
Чат-бот против агента
Чат-ассистент выполняет один цикл: входит вопрос, выдает ответ. Инструменты вроде Claude Code или режим агента в Cursor работают иначе. Если сказать «переработай этот модуль», они читают файлы, запускают тесты, редактируют код, снова запускают тесты и исправляют возникшие ошибки. Причиной является не более умная модель, а программа, окружающая её, которая умеет выполнять циклы, вызывать инструменты и сохранять работу при сбоях.
Люди начали называть это инженерией креплений — довольно пафосное название для простой идеи: Claude Code, Cursor и Codex являются лучшими инструментами именно потому, что у них лучшие крепления, а не только лучшие модели.
Пример помогает лучше, чем определения. Вы вводите: «Исправьте несработавший тест в test_parser.py.» Далее следует следующее:
Turn 1: LLM reads the test file → bash("cat test_parser.py")
Turn 2: LLM runs the test to see error → bash("pytest test_parser.py")
Turn 3: LLM reads the source file → read_file("parser.py")
Turn 4: LLM edits the bug → str_replace("parser.py", old, new)
Turn 5: LLM re-runs the test → bash("pytest test_parser.py")
Turn 6: Tests pass → LLM says "Done! Fixed the off-by-one error in line 42."
Шесть раундов и шесть решений, и никто ничего не нажал после первого запроса. Это значит, что ремень безопасности выполняет свою функцию.
Лексика
Несколько терминов встречаются повторно.
- LLM: функция, преобразующая список сообщений в ещё одно сообщение. У неё нет памяти о предыдущих вызовах API. Если вы хотите, чтобы казалось, что она помнит, нужно снова отправить историю.
- Токен: единица текста, которую считает модель; примерно три четверти английского слова. Фраза «Hello world» состоит примерно из двух токенов, а 100-строчный файл на Python — примерно из 500 токенов.
- Окно контекста: количество токенов, которые модель может видеть одновременно. Значения около 128 тыс. для моделей класса GPT-4 и примерно 200 тыс. для Claude кажутся огромными, пока агент 40 раз не вставит результат выполнения команды
grep -rв чат. - Вызов инструмента: модель не запускает ваш код. Она формирует запрос вида
{"name": "bash", "arguments": {"command": "ls"}}, после чего ваш Python-скрипт выполняет командуlsи возвращает результат. - Без состояния: каждый вызов
call_llm(messages)является автономным, а списокmessagesслужит памятью.
Не требуется опыт в машинном обучении. Достаточно базовых знаний Python (функции, циклы, словари, try/except), небольшого опыта работы с API в стиле OpenAI и наиболее распространённых команд терминала (ls, grep, cat).
Эталонная реализация
Пример агента состоит примерно из 600 строк кода на Python без использования каких-либо фреймворков; в нем используются четыре библиотеки: openai, rich, prompt-toolkit и pyyaml. Приведённые ниже фрагменты — это реальный код, а не псевдокод, и к концу вы сможете узнать те же элементы в приложениях Claude Code или Cursor. Чтобы следовать за примером, скопируйте проект и установите его в режиме редактирования:
git clone https://github.com/shrinidhi-mahishi/coding-harness.git
cd coding-harness
pip install -e .
Может использоваться любой конечный пункт, совместимый с OpenAI: OpenRouter, локальный сервер или сам OpenAI. Укажите настройки в файле ~/.agents/env:
BASE_URL=https://openrouter.ai/api/v1
API_KEY=sk-or-...
MODEL=deepseek/deepseek-v4-flash
Настоящие переменные окружения переопределяют содержимое этого файла. Команда coding-harness, указанная в pyproject.toml как точка входа в coding_harness.agent:main, запускает терминальную сессию. Вы вводите команды, и внутренний цикл продолжает работу до тех пор, пока у агента не останется запросов к инструментам.
Вся конструкция состоит из семи компонентов: цикл агента («сердцебиение» системы), инструменты («руки»), разрешения (кто может что делать), песочница (безопасность, обеспечиваемая ядром), инжиниринг контекста (чистое окно для работы), навыки, задачи и сессии (память), а также субагенты (использование контекста во временных целях). Здесь рассматриваются только первые три компонента.
Важные файлы:
coding_harness/agent.py # two nested loops
coding_harness/llm.py # one API call + the system prompt
coding_harness/tools.py # tool functions + defensive execute()
coding_harness/permissions.py # allow / ask / deny
coding_harness/config.py # BASE_URL, API_KEY, MODEL
Не существует ни базы данных, ни веб-сервера, ни библиотеки оркестрации. «Фреймворк» состоит из двух циклов while True и словаря с функциями инструментов. При первом ознакомлении просмотрите файлы config.py, llm.py, agent.py, tools.py и permissions.py в указанном порядке.
1. Цикл агента
Claude Code, Cursor и Codex используют один и тот же цикл:
- Отправлять диалог модели.
- Если модель запрашивает инструмент, запустить его.
- Добавить результат в диалог и вернуться к шагу 1.
- Когда запросов к инструментам больше нет, работа завершена, и отображается ответ.
Чат-бот выполняет один раунд обмена сообщениями, тогда как агент продолжает работу до завершения задачи. В этом и заключается основное отличие; большая часть остальной инженерной реализации предназначена для предотвращения завершения цикла преждевременно.
Один момент сбивает с толку новичков: модели не имеют состояния. Они не запоминают предыдущий ход, поэтому каждый запрос API содержит всю переписку. Сообщения выглядят так:
messages = [
{"role": "system", "content": "You are a coding agent..."},
{"role": "user", "content": "Fix the failing test"},
{"role": "assistant", "content": "I'll start by reading the test file."},
{"role": "tool", "content": "... contents of test file ..."},
]
Существует четыре роли. Роль системы хранит постоянные инструкции, пользователь — это вы, ассистент — это модель, а инструмент передает результаты, полученные от нее. Модель принимает список и генерирует следующий элемент; этот список является ее единственной «памятью». Скрытого второго хранилища не существует, поэтому если список неверен, агент тоже действует неверно.
В процессе задействованы два цикла. Внутренний цикл охватывает один шаг пользователя и столько же шагов модели, сколько потребуется. Внешний цикл просто ожидает ввода.
Это упрощенная версия внутреннего цикла. Реальный файл также удаляет старые результаты работы инструментов, добавляет новое напоминание в конец каждого запроса, удаляет временные файлы и может сжимать историю операций.
while True:
message, usage = call_llm(messages)
messages.append(message.model_dump(exclude_none=True))
if message.content:
ui.agent(message.content)
# No tool calls? The agent is done thinking.
if not message.tool_calls:
break
# Execute each tool and feed the result back
for tool_call in message.tool_calls:
args, result = execute(tool_call)
ui.tool(tool_call.function.name, args, result)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
Код находится в файле agent.py. Функция call_llm(messages) отправляет полную историю запросов, и модель возвращает текст, результаты вызовов инструментов или и то, и другое. Функция model_dump() просто преобразует объект API в словарь, который можно сохранить. Если вызовов инструментов нет, цикл прерывается. В противном случае они выполняются, их результаты отображаются, добавляются к истории, после чего цикл повторяется, чтобы модель могла увидеть произошедшее и решить, следует ли продолжать.
Внешний цикл ещё короче:
while True:
user_input = ui.ask()
if not user_input:
break
messages.append({"role": "user", "content": user_input})
# ... inner agent loop runs here ...
Ваш текст добавляется в список, внутренний цикл выполняется до завершения работы агента, после чего программа снова ожидает. Здесь нет машин состояний — только два вложенных оператора while True, таких же, как у Claude Code и Cursor. Модель говорит или запрашивает инструменты, результаты работы инструментов возвращаются в messages, и цикл повторяется.
2. Инструменты: руки для модели
Без инструментов цикл представляет собой просто чат-бота с дополнительными шагами. Инструменты позволяют модели выполнять задачи: читать файлы, запускать команды, писать код, искать в репозитории.
Представьте, что вы просите талантливого человека исправить баг, в то время как он находится в запертой комнате без компьютера. Он может думать, но не может ничего трогать. Инструменты — это дверь.
Во многих учебниках упускается один важный момент: сами модели никогда не выполняют инструменты. Модель генерирует запрос вроде «запустить эту команду», а сам Python выполняет работу и возвращает результат. У модели нет оболочки — только форма для ввода данных.
Инструмент состоит из четырех элементов: имени (bash, read_file), описания, указывающего модели, когда его использовать, JSON Schema с перечнем обязательных полей и типов аргументов, а также функции на Python, которая выполняет работу.
bash является как самым мощным инструментом, так и самым вызывающим опасения. Вот его пример из файла tools.py:
def bash(command: str) -> str:
"""Run a shell command and return its combined stdout and stderr."""
try:
result = sandbox.run(command)
except subprocess.TimeoutExpired as expired:
return (
f"Timed out after {expired.timeout}s and was killed. "
"Narrow it down - search inside the working directory rather than /."
)
return (result.stdout + result.stderr) or "(no output)"
Модель видит следующую схему:
{
"type": "function",
"function": {
"name": "bash",
"description": "Run a shell command and return its combined stdout and stderr.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to run",
}
},
"required": ["command"],
},
},
}
Когда модель нуждается в списке содержимого каталога, она генерирует {"name": "bash", "arguments": {"command": "ls -la src/"}}. Код выполняет этот код, возвращает результат, и модель выбирает следующий шаг.
У агента семь инструментов, каждый из которых представляет собой функцию вместе с схемой:
bashвыполняет команду оболочки внутри песочницы.read_fileчитает файл.write_fileсоздаёт или перезаписывает файл.str_replaceвыполняет точечную замену одной строки на другую.read_skillзагружает руководствоSKILL.md.write_todosзаменяет весь список задач.taskзапускает подагента и возвращает только его окончательный ответ.
Функция bash представляет собой простой оберточный код: она запускает sandbox.run с таймаутом в 60 секунд и возвращает вывод в стандартный и ошибочный потоки, либо сообщение об истечении времени с рекомендацией искать внутри каталога проекта, а не по всей директории /.
Рассматривайте вызовы инструментов как ненадежный ввод
Во многих постах типа «создайте агента за 20 строк» этот момент игнорируется. Вызов инструмента — это текст, написанный моделью, поэтому модель угроз соответствует веб-форме. Имя может отсутствовать, аргументы — не быть корректным JSON, а типы — неверными. Любой из этих факторов может привести к прерыванию сессии, если код не подготовлен должным образом.
Модели могут выдавать ложную информацию: они выдумывают имена функций и генерируют некорректный JSON. Сбой заставляет пользователя начинать всё заново. Если вы выявите проблему и вернёте читаемое сообщение об ошибке, модель часто попытается выполнить задачу правильно, и у неё это удивительно хорошо получается, при условии, что вы передадите ей саму ошибку, а не стек-трейс.
execute() — защитный диспетчер, также находящийся в файле tools.py, работает в следующем порядке:
- Анализирует JSON-данные; если они повреждены, возвращается ошибка с указанием того, что аргументы не являются валидным JSON.
- Поиск по имени инструмента; если он неизвестен, выводится список реально существующих инструментов.
- Обращение к функции
permissions.check()для получения решения о запрете, запросе или разрешении. - Вызов соответствующей функции на Python.
- Ловля исключений типа
TypeErrorи других ошибок; возвращается строка «Error: ...», но никогда не возвращается стек вызовов.
Короче говоря, он направляет вызов инструмента к функции, не приводя к сбою сессии.
def execute(tool_call):
name = tool_call.function.name
# The model might send broken JSON
try:
args = json.loads(tool_call.function.arguments)
except json.JSONDecodeError as broken:
return {}, f"Error: arguments were not valid JSON ({broken})."
# The model might hallucinate a tool name
if name not in TOOLS:
return args, f"Error: no tool named '{name}'. Available: {', '.join(TOOLS)}."
# Run through the permission layer, then call the function
try:
action, reason = check(name, args)
if action == "deny":
return args, f"Blocked by policy: {reason}"
if action == "ask" and not ui.approve(reason):
return args, "The user denied this tool call."
return args, TOOLS[name](**args)
except TypeError as mismatch:
return args, f"Error: wrong arguments for {name} ({mismatch})."
except Exception as failure:
return args, f"Error: {name} failed - {type(failure).__name__}: {failure}"
Предположим, что модель создаёт инструмент под названием search_code. JSON-данные в порядке, но название отсутствует в списке TOOLS, поэтому диспетчер сообщает, что такого инструмента нет, и перечисляет доступные: bash, read_file, write_file, str_replace, read_skill, write_todos и task. В следующем ходе модель использует bash с командой grep -r "auth" .. Ничего не сломалось, и это именно то, что нужно: неправильный вызов инструмента никогда не должен приводить к сбою сессии. Ошибки — это просто дополнительные данные ввода.
3. Разрешения: кто может что делать
При наличии bash агент становится полезным, но именно он позволяет выполнять команды вроде rm -rf / или отправлять конфиденциальную информацию куда-либо. Если агент может выполнять любые команды, он может удалять файлы, загружать мусор или связываться с внешними системами. Если вы будете подтверждать каждую команду, весь день уйдёт на одобрение простой команды ls. Необходим третий путь.
Ответ — три уровня, как светофор:
| Уровень | Значение | Примеры |
|---|---|---|
| allow | работает бесшумно | ls, grep, cat, git status, pytest |
| ask | ожидает вашего решения | mkdir, python script.py, npm install |
| deny | блокируется даже при согласии | rm, sudo, curl, wget, git push |
Правила представляют собой шаблоны glob, где * соответствует чему угодно, и побеждает последнее подходящее правило. Из файла permissions.py:
BASH_RULES = {
"*": "ask", # default: ask for everything
# read-only: let them through
"ls*": "allow",
"cat *": "allow",
"grep *": "allow",
"git status*": "allow",
"git diff*": "allow",
"pytest*": "allow",
# risky: never, even if the user says yes
"rm *": "deny",
"sudo *": "deny",
"curl *": "deny",
"git push*": "deny",
}
Значение * устанавливает стандартный режим запроса разрешения. Команды, доступные только для чтения, разрешаются, затем опасные команды блокируются полностью, а все остальные, такие как python script.py или npm install, остаются в режиме запроса разрешения.
Модель может использовать хитрые приемы. Команда вроде grep foo | rm -rf / начинается безвредно, но заканчивается катастрофически. Поэтому проверщик разделяет команду по символам |, ; и &&, учитывая кавычки, и применяет самое строгое решение на основе каждой части.
def decide(command):
"""Rate every part of a compound command; the strictest verdict wins."""
verdicts = []
for part in split_command(command):
action = "ask"
for pattern, rule in BASH_RULES.items():
if fnmatch(part, pattern):
action = rule
verdicts.append(action)
for strictest in ("deny", "ask"):
if strictest in verdicts:
return strictest
return "allow"
На практике команда grep foo | rm -rf / отклоняется, поскольку запрещена команда rm. Команда ls && mkdir test запрашивает разрешение, так как команда mkdir не включена в список разрешённых действий. Команда cat file.py | grep TODO разрешена, поскольку обе части являются только для чтения.
Разрешения и сандбоксы выполняют разные функции. Разрешения касаются пользовательского опыта: стоит ли прерывать пользователя ради этого действия? Они не являются средством безопасности, поскольку умная команда может выглядеть как ls, но на самом деле быть чем-то другим. Сандбокс является средством безопасности, поскольку он определяет, допускается ли действие вообще с технической точки зрения. Настоящему агенту нужны и те, и другие.
Что это даёт нам
Циклы, защитные инструменты и разрешения уже позволяют создать рабочего кодинг-агента. Он может читать репозиторий, запускать тесты, редактировать файлы, запрашивать разрешение перед выполнением опасных действий и восстанавливаться после ошибок в вызовах инструментов.
Это ещё не безопасно, поскольку у решительно настроенной модели всё ещё может возникнуть угроза. Это также неэффективно, так как окно контекста быстро заполняется. Кроме того, у неё нет памяти, поэтому каждая сессия начинается с нуля. Естественными следующими шагами являются использование песочницы на уровне операционной системы, которая прерывает разрушительные действия на уровне ядра, а также многоуровневое управление контекстом для поддержания длительных сессий. Это те же самые примитивы, что используются в производственных агентах, только без всей этой тайны.