Работа с ИИ-агентом: сервер MCP и песочница
Всё, что было в предыдущих главах, инструмент умеет и в командной строке, и по запросу от программы. Вторая дверь называется сервером MCP — это обычный способ, которым к ИИ-агенту подключают внешние инструменты.
Зачем агенту трасса
Модель, читающая исходник, рассуждает о том, что может случиться: по тексту видны все ветви сразу и не видно ни одной пройденной. Трасса говорит, что случилось: какие ветви исполнялись, какие доводы встретились, какой вызов бросил исключение, какой вошёл и не вернулся.
Этого в исходнике нет ни в каком виде. Поэтому связка «агент плюс трасса» отличается от «агент плюс исходник» не аккуратностью, а тем, какие вопросы вообще разрешимы.
Насколько это помогает, в числах
Двенадцать программ на шести языках, написанных как чужой код: ветвление
зависит от данных, часть функций на запуске не вызывается вовсе, где-то
возникает исключение. К каждой пять вопросов о том, что случилось на конкретном
запуске: сколько раз вызвана функция, что она вернула, с чем её позвали,
вызывалась ли она вообще, кто бросил исключение. Шестьдесят вопросов, каждый
задан дважды — без трассы (исходник, команда запуска и то, что программа
напечатала) и с трассой (то же плюс debug.info того же запуска).
Контрольная группа получает всё, чем можно вывести ответ самому: полный исходник, доводы и вывод. Иначе сравнение было бы подстроенным. Ответы считает отдельная программа, которой видны только номер вопроса и текст ответа.
Опыт прогнали на четырёх отвечающих. Меняется ровно одно — кто отвечает.
| кто отвечал | ответов | верных без трассы | верных с трассой | разница | промежуток |
|---|---|---|---|---|---|
qwen3.5:4b |
600 | 44,0 % | 78,3 % | +34,3 | 19,7 … 47,7 |
qwen2.5:14b-instruct |
600 | 61,0 % | 84,7 % | +23,7 | 12,3 … 35,0 |
qwen3:32b |
600 | 66,7 % | 90,3 % | +23,6 | 10,3 … 36,3 |
| агент Claude Opus 5 | 120 | 95,0 % | 98,3 % | +3,3 | 0,0 … 8,3 |
Промежуток — тот разброс, в который попадает разница, если 10 000 раз пересобрать дюжину программ случайной выборкой с возвратом. Условие объявлено до первого прогона: разница считается находкой, только если ноль в промежуток не попадает.
Три модели под ollama выигрывают от трассы, и тем сильнее, чем модель слабее. На сильной модели прибавки не видно вовсе. У агента промежуток начинается с нуля — порог не пройден.
Для опыта на сильной модели каждый вопрос отдавался отдельному подчинённому
агенту: он не знал ни про опыт, ни про то, что групп две, ни про то, в какой он
группе, и видел только текст задания. Из 120 ответов неверных оказалось четыре,
и все четыре — это «нет» вместо false, то есть верное значение в неверном
виде. На этих двенадцати программах сильная модель знает ответ и без
трассы.
Отсюда не следует, что агенту трасса не нужна. Следует другое: эти двенадцать программ ему слишком лёгкие. Полсотни строк, которые целиком помещаются в голову, — не то место, где помощь видна. Сказать по этому опыту, что трасса поможет агенту на настоящем коде, нельзя; сказать, что не поможет, — тоже.
Та же дюжина на четырёх размерах одной модели
Разница между 4b, 14b и 32b выше снята на моделях разных семейств, и по ней о
размере судить нельзя. Поэтому те же двенадцать программ и те же шестьдесят
вопросов прогнали отдельно по лестнице одного семейства — qwen2.5-instruct от
3b до 32b, по 600 ответов на размер. Набор выложен целиком, вместе с правилами
разбора: ouroboros-trace-help.
| размер | верных без трассы | верных с трассой | прибавка | промежуток |
|---|---|---|---|---|
qwen2.5:3b-instruct |
31,3 % | 53,3 % | +22,0 | 14,3 … 30,3 |
qwen2.5:7b-instruct |
50,0 % | 63,7 % | +13,7 | 5,7 … 22,3 |
qwen2.5:14b-instruct |
61,7 % | 83,7 % | +22,0 | 11,7 … 32,7 |
qwen2.5:32b-instruct |
67,7 % | 94,0 % | +26,3 | 16,0 … 37,7 |
Ноль не попал ни в один из четырёх промежутков: прибавка есть на каждом размере. Растёт ли она с размером — сказать нельзя: разница прибавок между 32b и 3b равна +4,3 пункта с промежутком −5,7 … +14,7, и ноль внутри. Зато видно, чего не случилось: наверху лестницы прибавка не пропала, и без трассы самая большая из проверенных моделей отвечает верно лишь в 67,7 % случаев.
Отрицательный результат: у самой маленькой модели трасса убрала не ошибки, а отказы
Нижняя ступень лестницы ведёт себя не как остальные, и это стоит знать заранее. Уверенно неверных ответов трасса у 3b не убрала: 39,3 % без неё и 42,7 % с ней, промежуток −8,0 … +16,0 накрывает ноль. У остальных размеров такие ответы трасса режет, и заметно: у 7b на 12,0 пункта, у 14b на 22,0, у 32b на 26,3.
Куда тогда делась прибавка у 3b? В отказы. Ответ «не знаю» засчитывается отдельно от неверного, и доля таких ответов упала с 29,4 % до 4,0 %. Маленькая модель, получив трассу, перестала говорить «не знаю» и начала отвечать уверенно — где верно, а где и нет. Верных стало больше, но уверенно неверных не стало меньше.
Для работы с агентом вывод простой: трасса даёт материал, а не осторожность. Слабому отвечающему она прибавляет смелости раньше, чем правоты, и проверять за ним придётся ровно столько же.
Где прибавки нет. Там, где она есть, она распределена неровно, и это важнее
среднего. Числа qwen2.5:14b-instruct:
| о чём вопрос | ответов на группу | без трассы | с трассой |
|---|---|---|---|
| что вернул такой-то вызов | 140 | 46,4 % | 87,1 % |
| с чем позвали функцию | 10 | 50,0 % | 100,0 % |
| сколько раз вызвана функция | 90 | 58,9 % | 68,9 % |
| вызывалась ли функция вообще | 55 | 100 % | 100 % |
Последняя строка — честный ноль, и он повторился на трёх отвечающих из четырёх. Понять, что функция ни разу не вызвана, модель умеет и по исходнику: она видит условие, при котором в неё не заходят. Вся прибавка приходится на вопросы о значениях, которые из исходника надо досчитывать в уме. Это и есть то, чем трасса отличается от чтения кода: она не делает модель умнее, она снимает необходимость считать.
Платится за это длиной. Трасса двенадцати программ — 48 777 знаков против 13 070 знаков исходника, в 3,73 раза больше; на одну программу от 2,45 до 6,16. Запрос к модели вырастает с 1 623 знаков до 5 904 — втрое с половиной.
Отдельный прогон проверяет, что будет, когда трасса не влезает вовсе. Четыре из тех же двенадцати программ запускаются на входе в сотни раз большем: трасса выходит 1 621 522 знака против 4 670 знаков исходника — в 347 раз больше, — и в запрос кладётся обрезок в 8 000 знаков, начало и конец, середина вырезана. На 360 ответах верных 13,9 % без трассы против 37,2 % с обрезком. Но разбивка по тому, где лежит ответ, показывает, за счёт чего:
| где лежит ответ | ответов на группу | без трассы | с обрезком |
|---|---|---|---|
| нужный вызов уцелел | 55 | 9,1 % | 65,5 % |
| нужный вызов попал в вырезанную середину | 65 | 0,0 % | 10,8 % |
| «сколько раз вызвана» — нужна вся трасса | 40 | 0,0 % | 10,0 % |
Обрезанная трасса отвечает про то, что в неё попало, и молчит про то, что выброшено. Практический вывод для работы с агентом: отдавать трассу целиком стоит, пока она длиннее исходника в разы. Когда в сотни раз — нужен отбор нужных вызовов, а не обрезка по краям — та самая глава о выборе того, что записывать.
Опыт переснимается одной командой в дереве инструмента:
scripts/measure/trace-help/run.sh
Чего опыт не говорит: двенадцать сочинённых программ по полсотни строк — не рабочий код; все три модели под ollama из одного семейства, и лестница размеров — тоже одно семейство; 32 миллиарда весов в четырёхбитном виде передовой моделью не являются, и про заметно большую или обученную рассуждать модель здесь нет ничего; сильная модель одна и с одним повтором; отбор вызовов вместо обрезки не мерился вовсе.
Запустить сервер
uv run ouroboros-mcp
Команда не печатает ничего и не возвращает управление: сервер разговаривает через свой ввод-вывод и ждёт запросов. Запускать его руками обычно не нужно — это делает агент по записи в своей настройке:
{ "mcpServers": { "ouroboros": {
"type": "stdio", "command": "uv",
"args": ["run", "--directory", "<путь>/ouroboros", "ouroboros-mcp"] } } }
| поле | что значит |
|---|---|
mcpServers |
список внешних инструментов, которые агент вправе звать |
ouroboros |
имя, под которым инструмент будет виден агенту |
"type": "stdio" |
разговор идёт через ввод-вывод запущенного процесса |
"command": "uv" |
чем запускать |
--directory <путь>/ouroboros |
где лежит исходник сервера |
ouroboros-mcp |
что именно запускается |
Куда положить эту запись, каждый клиент называет в своей документации: место у всех своё.
Что агент получает
Сервер отдаёт семнадцать операций:
wrap_code_snippet wrap_file wrap_functions
read_trace trace_stats
create_project write_file read_file list_files
execute finish
lint_file symbol_search document_symbols
references call_hierarchy describe_symbol
Разбиваются они на четыре группы:
- обмазка — три первых: в памяти, файл целиком, названные функции;
- чтение трассы — те же две команды, что в командной строке;
- песочница — завести, записать, прочитать, перечислить, запустить, закрыть;
- разбор C и C++ — проверка кода
clang-tidyи поиск по символам черезclangd: где определено, кто зовёт, кого зовёт. Последние нужны, чтобы выбрать, какие функции обмазывать, до того как обмазывать.
Порядок работы записан прямо в сервере
При подключении сервер сообщает агенту, как им пользоваться. Дословно оттуда:
The loop is instrument -> run -> observe
То есть: обмазать → запустить → посмотреть. Дальше в том же тексте перечислено,
какие операции что делают на каждом шаге, и отдельно — какие из них меняют
файлы на диске: wrap_file и wrap_functions переписывают целевой файл,
write_file и finish меняют дерево песочницы, execute запускает
произвольную команду. Операции чтения не пишут ничего.
Это не украшение. Агент, который собирается позвать wrap_file, из этого текста
узнаёт, что файл будет переписан до вызова, а не после.
Песочница: draft и clean
Обмазанный код — это код с посторонними строками внутри. Он полезен ровно на время разбора и не должен уехать туда, откуда его можно случайно выпустить. Требование «не забудь потом убрать вставки» ненадёжно, когда его исполняет человек, и бессмысленно, когда его исполняет модель.
Поэтому у инструмента есть отдельное рабочее место из двух каталогов —
черновика draft, где идёт работа, и чистовика clean, куда её отдают наружу.
Пройдём его целиком.
Завести
ouroboros create ./demo
{"ok": true, "base": "/home/user/shop/demo", "draft": "/home/user/shop/demo/draft", "clean": "/home/user/shop/demo/clean"}
Появился каталог draft — обычное хранилище git со своей историей. В него
сразу положены помощник ouroboros_runtime.py и .gitignore, а история
начинается первой записью.
Записать файл
ouroboros write ./demo discount.py < discount.py
{"ok": true, "rel_path": "discount.py", "functions_wrapped": 3, "wrapped": true}
Ключевое слово здесь — до. Файл обмазывается перед сохранением, а не после: операция берёт ваш исходник, вставляет записи и кладёт на диск уже обмазанное. На каждую операцию делается одна запись в историю:
git -C ./demo/draft log --format='%h %s'
ce767f9 ouroboros: write discount.py (+3 wrapped)
9191765 ouroboros: init draft
Любой шаг видно, любой шаг можно отмотать. И файл, который не разобрался, сюда не попадает вовсе — про это была вторая глава.
Запустить
ouroboros execute ./demo -- python3 discount.py
9999 False 9999.0
10000 False 9000.0
500 True 425.0
Команда запускается внутри draft, а переменная OUROBOROS_DEBUG_INFO уже
указывает на его трассу — задавать её руками не нужно. Вывод программы приходит
как есть, код возврата сохраняется.
Заодно в трассу дописывается служебная запись о самом запуске:
{"p":"exec","cmd":["python3","discount.py"],"rc":1,"out":"9999 False 9999.0\n10000 False 9000.0\n500 True 425.0\n","err":"Traceback (most recent call last):\n …\nTypeError: '>=' not supported between instances of 'str' and 'int'\n"}
Так debug.info остаётся единственным местом, где написано и «что происходило
внутри», и «что вообще запускали и чем кончилось».
Закрыть
ouroboros finish ./demo
{"ok": true, "clean": "/home/user/shop/demo/clean", "synced": [".gitignore", "discount.py", "ouroboros_runtime.py"], "skipped": [], "instrumentation_removed": false, "note": "The copy is instrumented, exactly like the draft: this step publishes the draft, it does not un-instrument it. Left behind: .git, debug.info, tool caches, and anything that looks built (compiled binaries, object files, crash dumps) — each one listed in `skipped` with the reason. If something you wanted is in that list, copy it across yourself."}
Содержимое черновика переносится в clean — без истории git и без debug.info.
Обмазанный код при этом остаётся обмазанным, и ответ говорит это прямо:
"instrumentation_removed": false. Помощник ouroboros_runtime.py едет вместе
с кодом, иначе перенесённое не запустится.
Это стоит понимать точно, потому что название обманывает: clean — это чистая
копия черновика, а не исходный код без вставок. Снять обмазку инструмент не
умеет; для этого пользуются системой контроля версий.
Что не поехало, тоже названо. В draft к этому времени лежат .git,
debug.info и __pycache__ — ни одного из них в clean нет. А всё, что
похоже на собранное — двоичные файлы, объектники, дампы, — попадает в список
skipped с причиной по каждому файлу:
"skipped": [{"path": "hello", "reason": "looks built (compiled-format signature, or a NUL byte in the first 8 KiB) and has no source extension"}]
Отличить собранное от файла, который программа должна была сделать,
инструмент не может, поэтому и не молчит: список skipped стоит читать глазами,
а нужное забирать руками.
Формулировка задания, которую можно взять как есть
Разберись, почему падает
apply_discount. Возьми Уроборос: обмажь файл, прогони на настоящих данных, прочитай трассу.В отчёте назови: сколько вызовов разобрано, сколько бросило исключение и с какими доводами, пуст ли список незавершённых вызовов и сколько строк оказалось битыми. Приведи запись падавшего вызова целиком.
Выводы о том, как должно быть, не пиши: трасса этого не знает. Если увидишь в поведении странность — назови её вопросом ко мне, а не утверждением.
Три абзаца делают три разные вещи. Первый ставит задачу. Второй требует чисел, по которым результат можно проверить, не повторяя работу. Третий закрывает единственный способ испортить всё бесповоротно — выдать наблюдение за суждение.
Что проверять за агентом
Числа, а не пересказ. «Нашёл ошибку в обработке строк» — это пересказ.
calls_parsed, matched, in_flight, malformed и запись падавшего вызова —
это проверяемо.
Пустой ли in_flight и нулевой ли malformed. Непустой первый означает, что
часть вызовов не вернулась, и разбор по завершённым вызовам этого не покажет.
Ненулевой второй означает, что часть трассы не прочиталась.
Не выдал ли агент наблюдение за требование. Запись из трассы говорит «на этом входе вышло вот это». Она не говорит «так правильно». Это самая частая ошибка при работе с инструментом, и последняя глава объясняет, почему её нельзя уступить ни агенту, ни человеку.
Перед этим — восемь языков и то, как одна и та же запись получается восемью разными способами.