Інженерія керування: середовище виконання, яке перетворює 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 токенів.
- Вікно контексту: кількість токенів, які модель бачить одночасно. Значення близько 128K для моделей класу GPT-4 та приблизно 200K для 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 для параметрів із обов’язковими полями та типами, а також функції на 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 секунд та повертає stdout та stderr, або повідомлення про таймаут із рекомендацією шукати в каталозі проекту, а не по всій структурі /.
Розглядайте виклики інструментів як ненадійний вхідний даний
Багато постів типу „Створіть агента за 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, але насправді бути чимось іншим. Сендбокс є засобом безпеки, оскільки він визначає, чи дозволено взагалі виконати певну дію. Справжньому агенту потрібні обидва елементи.
Що це дає вам
Цикли, інструменти захисту та дозволи вже разом створюють функціонального кодувального агента. Він може читати репозиторії, запускати тести, редагувати файли, запитувати дозволу перед виконанням нерозумних дій та відновлюватися після некоректних викликів інструментів.
Це ще не є безпечним, оскільки рішуча модель все ще може завдати шкоди. Це також не є ефективним, адже вікно контексту швидко заповнюється. Крім того, у нього немає пам’яті, тож кожна сесія починається з нуля. Природними наступними кроками є використання пісочниці на рівні операційної системи, яка запобігає руйнівним діям у ядрі, а також багатошарове управління контекстом, щоб довгі сесії залишалися стабільними. Це ті самі примітиви, які використовують продакшн-агенти, тільки без всієї таємничості.