У жніўні я апублікаваў fault-mcp, сервер MCP, які наўмысна ламае ўласныя інструменты. У яго 6 тыпаў збояў. error і empty паведамляюць пра сябе самі, а latency аддае правільны адказ са спазненнем. Астатнія 3 вяртаюць нешта няправільнае і маўчаць, corrupt у тым ліку. Гэта не першы такі інструмент. mcp-chaos старэйшы прыкладна на 2 месяцы, у яго таксама ёсць seed, а яго README прайгравае сваё дэма з seed 42.
Сёння я ўжо праверыў адзін сказ з README fault-mcp на сапраўдных кліентах і ён пацвердзіўся толькі для часткі з іх. Таму я ўзяў абзац, у якім быў упэўнены больш за ўсё, той, што называецца Reproducibility:
The decision for a given call depends only on
(seed, tool name, call number). Call 7 rolls the same numbers whether or not calls 1 through 6 ever happened, so a bad run replays from the seed alone with no session to re-drive. Change the seed and you get a different run; keep it and you get the same one, on any machine.
Калі коратка, рашэнне па выкліку залежыць толькі ад seed, імя інструмента і нумара выкліку. Выклік 7 атрымлівае тыя ж лікі, нават калі выклікаў з 1 па 6 не было. Няўдалы прагон паўтараецца па адным seed на любой машыне.
Гэты абзац і код пад ім пісаў я. Тэсты правяраюць дэтэрмінізм у адным працэсе: той жа seed і той жа нумар выкліку даюць тое ж рашэнне, а выклікі да яго ні на што не ўплываюць. CI ганяе іх на Linux, Windows і macOS, але ніводзін тэст не замацоўвае канкрэтнае значэнне, так што кожная машына згодная толькі сама з сабой. Я ні разу не паўтараў цэлы прагон на іншай версіі Node і ні разу не правіў профіль, каб параўнаць вынік. “На любой машыне” было сказана пра хэш-функцыю. Больш нідзе я гэта не запускаў.
Умовы
fault-mcp 0.1.0 з npm, убудаваны профіль chaos (усе 6 тыпаў збояў, правілы з імавернасцямі ад 5 да 15%) і seed 1337. Скрыпт робіць фіксаваны спіс выклікаў да 3 прыкладных інструментаў. Ён друкуе журнал без палёў часу ts і durationMs, а потым яго SHA-256. У рэжыме бібліятэкі мой скрыпт падмяняе setTimeout, таму збой latency не чакае і скрыпт запісвае затрымку, якая выпала. Кліент па stdio гэта @modelcontextprotocol/sdk 1.32.1. Для агентнай часткі я ўзяў Claude Code 2.1.292 з claude-opus-5, толькі з гэтым адным серверам MCP, і запускаў яго з пустой тэчкі.
Што пацвердзілася
3000 выклікаў на Node 18.17.0, 20.18.1, 22.23.1 і 24.11.0 далі адзін хэш. Тыя ж 3000 выклікаў на другім хосце з Node 22.23.1 далі той жа хэш. Абодва хосты x86_64 з адной і той жа мадэллю працэсара, так што “любая машына” ўсё яшчэ шырэйшая за тое, што я запускаў. Потым 150 выклікаў праз апублікаваны CLI па stdio, з сапраўднымі паўзамі. Гэта заняло хвіліну і журнал супаў з журналам бібліятэкі ў 150 радках са 150.
Частка пра нумар выкліку таксама пацвердзілася. Я перанёс усе выклікі search у пачатак, а астатнія інструменты паставіў пасля іх у адваротным парадку. Тып збою супаў на 300 з 300 пар (інструмент, нумар выкліку), а вынікі search супалі ў 100 выпадках са 100. Калі я ўручную выставіў лічыльнік на 69, выклік 70 інструмента search даў той жа error, што і ў поўным прагоне. Выклікі з 1 па 69 сапраўды могуць і не адбывацца. Тут дзве схемы seed і разыходзяцца. У mcp-chaos клас FaultPlan стварае адзін random.Random(seed) на ўвесь план і кожны выклік па чарзе цягне з яго лікі. Выклік там залежыць ад выклікаў да яго, любога інструмента.
Агент 5 разоў зрабіў тыя ж 7 выклікаў
Я ўзяў профіль flaky з seed 1337. Промпт прасіў балансы рахункаў з ACC-1001 па ACC-1006 і дазваляў адзін паўтор, калі выклік упадзе. Ва ўсіх 5 прагонах агент змясціў 6 выклікаў у адно паведамленне, атрымаў ETIMEDOUT на ACC-1002 і паўтарыў яго. Калі не лічыць часу, журналы супалі. У кожным адказе стаялі тыя ж 6 лікаў, 2 з іх няправільныя. Выклікі 1 і 4 былі сапсаваныя. ACC-1001 вярнуўся як 76 462,65 PLN замест 86 996,23 USD, а ACC-1004 як 82 303,32 PLN замест 58 115,28 PLN. Агент перадаў абодва лікі далей ва ўсіх 5 прагонах. Ягоныя заўвагі тычыліся дат as_of, а ў 4 прагонах з 5 яшчэ і мінорных адзінак. Ніводная з іх не ставіла пад сумнеў гэтыя 2 лікі.
Потым я развярнуў парадак рахункаў у промпце і запусціў двойчы.
Расклад застаўся на месцы, па нумарах выклікаў. Выклік 1 па-ранейшаму быў сапсаваны, а выклік 2 па-ранейшаму не дачакаўся адказу, але цяпер выклік 1 прыпаў на ACC-1006, а выклік 4 на ACC-1003. Значыць, seed фіксуе нумар выкліку, які зламаецца. Які рахунак апынецца пад гэтым нумарам, вырашае агент. Калі ён зменіць парадак, той жа seed зламае іншыя рахункі. Па той жа нумарацыі лішні паўтор павінен зрабіць тое ж самае, хоць лішняга паўтору ў маіх прагонах не было. У гэтых 7 прагонах Claude Code дастаўляў паралельныя выклікі ў парадку паведамлення. Нічога, што б гэта абяцала, я не бачу.
Дзе я памыліўся
У маім селектары ёсць каментар:
Probability 1 still draws, so that adding a certain rule in front of an uncertain one does not silently reshuffle the ones behind it.
Ён кажа, што правіла з імавернасцю 1 усё роўна цягне лік, каб устаўка такога правіла перад правілам з меншай імавернасцю не перамешвала моўчкі тыя, што стаяць за ім. Я разумеў яго як “профіль можна спакойна правіць”. Спакойна правіць яго можна толькі адным спосабам. У кожнага выкліку адзін паток выпадковых лікаў і правілы бяруць з яго лікі зверху ўніз. Калі змяніць імавернасць, кожнае правіла захоўвае свой нумар у патоку. Вынік мяняецца толькі ў тых выкліках, дзе кідок трапіў паміж старым і новым значэннем. Калі ўставіць правіла наверх, кожнае правіла ніжэй атрымлівае наступны лік патоку, гэта значыць іншы лік. Выключэнне гэта правіла з onCalls. На астатніх выкліках яно прапускаецца без кідка.
Абгрунтаванне ў каментары таксама няправільнае. Правіла з імавернасцю 1 спрацоўвае заўсёды і адразу вяртае вынік, таму правілы за ім у гэтым выкліку наогул не правяраюцца. Яго кідок мяняе толькі змесціва яго ўласнага збою.
Я вымераў гэта на 30 000 выкліках, па 10 000 на інструмент, пры фіксаваным seed. Устаўленае правіла гэта error з p 0,02, так што само па сабе яно спрацоўвае прыкладна на 2% выклікаў. Устаўленае першым, яно змяніла вынік у 48,52% усіх выклікаў. Новы seed як кантроль змяніў 53,17%. Тое ж правіла, дададзенае ў канец, змяніла 1,21%.
Так што паведамлення пра памылку “ламаецца на seed 1337” недастаткова. Да яго трэба прыкладаць профіль, правіла ў правіла. Нават праўка тэксту паведамлення ў першым правіле змяніла 7,86% вынікаў. Устаўка правіла наверх змяніла амаль столькі ж, колькі новы seed. У маім README пра гэта няма ні слова.
Па дарозе выявілася яшчэ адна дробязь. --seed і FAULTMCP_SEED праходзяць праз parseInt, так што 0x10 моўчкі становіцца seed 0, а 1337abc становіцца 1337. Стартавы радок у stderr паказвае seed, які сапраўды выкарыстаны, але памылкі не ўзнікае.
Чаго я не правяраў
Я правяраў толькі x86_64 і толькі Linux. Не правяраў інструменты, праксіраваныя праз --upstream, дзе сапраўдны сервер можа адказваць па-рознаму ад прагону да прагону і сапсаваны адказ мяняецца разам з ім. Не правяраў іншых агентаў акрамя Claude Code і іншых мадэляў акрамя claude-opus-5. 5 аднолькавых прагонаў агента на адным промпце нічога не кажуць пра доўгую сесію, дзе ў мадэлі больш шанцаў выбраць іншы шлях. А яшчэ ў журнале няма поля для затрымкі, якая выпала збою latency. Яе паказвае толькі durationMs па гадзінніку, у маіх прагонах з дакладнасцю да 3 мс. Правіла ў журнале нясе значэнне па змаўчанні ms: 2000, якое падобна да затрымкі, але гэта не затрымка.
Абзацу Reproducibility патрэбны профіль побач з seed і слова пра парадак выклікаў. Я яго пакуль не змяніў.