Глава 7: Параллельное выполнение инструментов
Стоимость ожидания
Глава 6 проследила жизненный цикл единого вызова инструмента — от блока tool_use в ответе API до валидации входных данных, проверок прав, выполнения и форматирования результата. Эта схема обрабатывает один инструмент. Но модель редко запрашивает только один.
Типичное взаимодействие агента включает три-пять вызовов инструментов за ход. «Прочитай эти два файла, выполните grep по этому шаблону, затем отредактируй эту функцию». Модель выдаёт всё это в одном ответе. Если каждый инструмент занимает 200 миллисекунд, последовательный запуск стоит целой секунды. Если вызовы Read и Grep независимы — а они таковы — параллельный запуск сокращает это до 200 миллисекунд. Улучшение пять к одному — бесплатно.
Но не все инструменты независимы. Edit, который модифицирует config.ts, не может выполняться одновременно с другим Edit, меняющим тот же файл. Bash-команда, создающая директорию, должна завершиться до выполнения Bash-команды, записывающей файл в эту директорию. Конкурентность — это не глобальное свойство инструмента. Это свойство конкретного вызова инструмента с конкретными входными данными.
Это основная идея всей системы конкурентного исполнения: безопасность определяется для каждого вызова, а не для типа инструмента. Bash("ls -la") безопасно параллелить. Bash("rm -rf build/") — нет. Один и тот же инструмент при разных входных данных может иметь разную классификацию. Система должна проанализировать вход перед принятием решения.
Агент реализует два уровня оптимизации параллелизма. Первый — это оркестрация батчей: после полного получения ответа модели разбить вызовы инструментов на группы, выполняющиеся параллельно, и на одиночные последовательные вызовы, затем выполнить их соответствующим образом. Второй — спекулятивное выполнение: запускать инструменты ещё во время потоковой передачи ответа модели, собирая результаты до того, как ответ завершён. Вместе эти механизмы устраняют большую часть времени ожидания.
Алгоритм разбиения на группы
Точка входа — partitionToolCalls() в toolOrchestration.ts. Она принимает упорядоченный массив сообщений ToolUseBlock и выдаёт массив батчей, где каждый батч либо «все вызовы безопасны для параллельного выполнения», либо «один последовательный инструмент».
// Псевдокод — иллюстрация алгоритма разбиения
type Group = { parallel: boolean; calls: ToolCall[] }
function groupBySafety(calls: ToolCall[], registry: ToolRegistry): Group[] {
return calls.reduce((groups, call) => {
const def = registry.lookup(call.name)
const input = def?.schema.safeParse(call.input)
// Поведение "fail-closed": если парсинг неуспешен или возникло исключение → считать последовательным
const safe = input?.success
? tryCatch(() => def.isParallelSafe(input.data), false)
: false
// Сливаем подряд идущие безопасные вызовы в одну группу
if (safe && groups.at(-1)?.parallel) {
groups.at(-1)!.calls.push(call)
} else {
groups.push({ parallel: safe, calls: [call] })
}
return groups
}, [] as Group[])
}
Алгоритм проходит массив слева направо. Для каждого вызова инструмента:
- Находит определение инструмента по имени.
- Парсит вход используя Zod-схему инструмента через
safeParse(). Если парсинг не удаётся, инструмент консервативно классифицируется как не безопасный для конкуренции. - Вызывает
isConcurrencySafe(parsedInput)на определении инструмента. Здесь происходит классификация по конкретным входным данным. Инструмент Bash парсит строку команды, проверяет, все ли подкоманды — только для чтения (ls,grep,cat,git status) и возвращаетtrueтолько если вся составная команда является чисто чтением. Read всегда возвращаетtrue. Edit всегда возвращаетfalse. Вызов окружён try-catch — еслиisConcurrencySafeбросает (например, строка Bash не может быть распарсена библиотекой shell-quote), инструмент по умолчанию считается последовательным. - Сливает в батч или создаёт новый. Если текущий инструмент безопасен для параллели И предыдущий батч также безопасен — добавляем вызов к этому батчу. Иначе — создаём новый батч.
В результате получается последовательность батчей, чередующихся между параллельными группами и одиночными последовательными вызовами. Пройдемся по примеру:
Модель запросила: [Read, Read, Grep, Edit, Read]
Шаг 1: Read → безопасен для параллели → новый батч {safe, [Read]}
Шаг 2: Read → безопасен для параллели → добавить {safe, [Read, Read]}
Шаг 3: Grep → безопасен для параллели → добавить {safe, [Read, Read, Grep]}
Шаг 4: Edit → НЕ безопасен → новый батч {serial, [Edit]}
Шаг 5: Read → безопасен для параллели → новый батч {safe, [Read]}
Результат: 3 батча
Батч 1: [Read, Read, Grep] — выполняется параллельно
Батч 2: [Edit] — выполняется отдельно
Батч 3: [Read] — выполняется параллельно (только один инструмент)
Разбиение жадное и сохраняет порядок. Последовательные безопасные инструменты накапливаются в одном батче. Любой небезопасный инструмент разрывает прогон и начинает новый батч. Это означает, что порядок, в котором модель выдаёт вызовы инструментов, важен — если модель вставляет запись между двумя чтениями, вы получите три батча вместо двух. На практике модели склонны группировать чтения вместе — это частый случай, на который оптимизирован алгоритм.
Выполнение батчей
Генератор runTools() проходит по полученным батчам и отправляет каждый в соответствующий исполнитель.
Параллельные батчи
Для параллельного батча runToolsConcurrently() запускает все инструменты параллельно, используя утилиту all(), которая ограничивает число активных генераторов до предела:
// Псевдокод — иллюстрация шаблона параллельной отправки
async function* dispatchParallel(calls, context) {
yield* boundedAll(
calls.map(async function* (call) {
context.markInProgress(call.id)
yield* executeSingle(call, context)
context.markComplete(call.id)
}),
MAX_CONCURRENCY, // По умолчанию: 10
)
}
Предел параллелизма по умолчанию — 10, настраиваемый через CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY. Десяти обычно достаточно — редко встречается больше пяти или шести вызовов инструментов в одном ответе модели. Этот лимит скорее предохранитель для патологических случаев, чем типичное ограничение.
Утилита all() — это вариант Promise.all, ориентированный на генераторы, с ограничением параллельных задач. Она стартует до N генераторов одновременно, отдаёт результаты по мере завершения любого из них и запускает следующий ожидающий генератор всякий раз, когда один заканчивается. Механика похожа на пул задач с семафором, но адаптирована к асинхронным генераторам, которые могут отдавать промежуточные результаты.
Ключевой деталью является очередь модификаторов контекста (context modifier queuing). Некоторые инструменты производят модификаторы контекста — функции, которые трансформируют ToolUseContext для последующих инструментов. Когда инструменты выполняются параллельно, применять эти модификаторы немедленно нельзя, потому что другие инструменты в том же батче читают тот же контекст. Вместо этого модификаторы собираются в мапу по ключу tool use ID:
const queuedContextModifiers: Record<
string,
((context: ToolUseContext) => ToolUseContext)[]
> = {}
После завершения всего параллельного батча модификаторы применяются в порядке отправки инструментов (а не в порядке завершения), что сохраняет детерминированную эволюцию контекста:
for (const block of blocks) {
const modifiers = queuedContextModifiers[block.id]
if (!modifiers) continue
for (const modifier of modifiers) {
currentContext = modifier(currentContext)
}
}
На практике ни один из текущих инструментов, помеченных как безопасные для параллели, не производит модификаторов контекста — кодовая база это прямо отмечает. Но инфраструктура предусмотрена, потому что инструменты могут добавляться в MCP-серверах, и пользовательский read-only инструмент мог бы легитимно захотеть обновить контекст (например, пометить “прочитанные файлы”).
Последовательные батчи
Последовательное выполнение тривиально. Каждый инструмент выполняется, его модификаторы контекста применяются немедленно, и следующий инструмент видит обновлённый контекст:
for (const toolUse of toolUseMessages) {
for await (const update of runToolUse(toolUse, /* ... */)) {
if (update.contextModifier) {
currentContext = update.contextModifier.modifyContext(currentContext)
}
yield { message: update.message, newContext: currentContext }
}
}
Это ключевое отличие. Последовательные инструменты могут изменить мир для последующих инструментов. Edit модифицирует файл; следующий Read увидит изменённую версию. Bash-команда создаёт директорию; следующая Bash-команда пишет в неё. Модификаторы контекста формализуют эту зависимость: инструмент может сказать «среда выполнения изменилась, вот как».
Поточный (streaming) исполнитель инструментов
Оркестрация батчей устраняет ненужную сериализацию уже после получения ответа модели. Но есть большая возможность: ответ модели стримится. Типичный многоинструментный ответ может передаваться 2–3 секунды. Первый блок tool_use парсится уже через ~500 миллисекунд. Зачем ждать оставшиеся 2 секунды?
Класс StreamingToolExecutor реализует спекулятивное выполнение. Пока модель стримит ответ, каждый полностью распарсенный блок tool_use передаётся исполнителю мгновенно. Исполнитель запускает его сразу — пока модель генерирует следующий блок. К моменту завершения стрима несколько инструментов могут уже закончить.
Последовательный итог: 3.1с. Поточный итог: 2.6с — инструменты 1 и 2 завершились во время стрима, экономя ~16% времени.
Эти сбережения складываются. Когда модель запрашивает пять read-only инструментов и поток занимает 3 секунды, все пять инструментов могут стартовать и завершиться в течение этих 3 секунд. Этап опустошения после стрима не делает ничего. Пользователь видит результаты почти сразу после появления последнего символа ответа модели.
Жизненный цикл инструмента
Каждый инструмент, отслеживаемый исполнителем, проходит четыре состояния:
- queued: блок
tool_useраспарсен и зарегистрирован. Ожидает условий конкуренции для старта. - executing: выполняется
call()инструмента. Результаты накапливаются в буфере. - completed: выполнение завершено. Результаты готовы к выдаче.
- yielded: результаты были эмитированы. Терминальное состояние.
addTool(): очередь во время стрима
addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void
Вызывается парсером потокового ответа каждый раз, когда полностью получен очередной блок tool_use. Метод:
- Находит определение инструмента. Если не найдено, сразу создаёт запись
completedс сообщением об ошибке — нет смысла ставить в очередь несуществующий инструмент. - Парсит вход и определяет
isConcurrencySafeпо той же логике, что иpartitionToolCalls(). - Добавляет
TrackedToolсо статусом'queued'. - Вызывает
processQueue()— который может запустить инструмент немедленно.
Вызов processQueue() — fire-and-forget (void this.processQueue()). Исполнитель не ожидает его завершения. Это сделано намеренно: addTool() вызывается из обработчика событий потокового парсера, и блокировка там замедлила бы парсинг. Инструмент стартует в фоне, пока парсер продолжает потреблять поток.
processQueue(): проверка приёма в исполнение
Проверка допуска — одно предикатное условие:
// Псевдокод — иллюстрация правила взаимного исключения
canRun = noToolsRunning || (newToolIsSafe && allRunningAreSafe)
Инструмент может начать выполняться тогда и только тогда, когда:
- Ни один инструмент в данный момент не выполняется (очередь пуста), ИЛИ
- Новый инструмент безопасен для параллели И все текущие выполняющиеся инструменты также безопасны.
Это контракт взаимного исключения. Небезопасный для параллели инструмент требует исключительного доступа — ничего другого выполняться не должно. Параллельные инструменты могут делить «взлётную полосу» с другими параллельными, но если в выполняющемся наборе есть один небезопасный инструмент, он блокирует всех.
Метод processQueue() проходит по всем инструментам в порядке. Для каждого поставленного в очередь инструмента он проверяет canExecuteTool(). Если инструмент может выполняться, он запускается. Если небезопасный для параллели инструмент не может запуститься сейчас, цикл прерывается — дальнейшие инструменты не проверяются, потому что последовательные инструменты должны сохранять порядок. Если параллельный инструмент не может запуститься (блокируется выполняющимся небезопасным инструментом), цикл продолжает работу — но на практике это редко помогает, потому что параллельные инструменты после блокера обычно зависят от его результатов.
executeTool(): ключевой цикл исполнения
Здесь скрыта основная сложность. Метод управляет AbortController’ами, каскадом ошибок, отчётностью о прогрессе и модификаторами контекста.
Дочерние контроллеры прерывания. Каждый инструмент получает собственный AbortController, являющийся потомком общего sibling-level контроллера.
Иерархия трёх уровней: контроллер уровня запроса (owned by REPL, срабатывает на Ctrl+C пользователя) — родитель контроллера sibling (owned by streaming executor, срабатывает при ошибках Bash) — родитель каждого индивидуального контроллера инструмента. Прерывание sibling-контроллера убивает все запущенные инструменты. Прерывание индивидуального контроллера убивает только этот инструмент — но также всплывает к контроллеру запроса, если причина прерывания не является ошибкой sibling. Такое всплытие предотвращает молчаливое проигнорирование исполнителя, например, при отказе в правах, который должен завершить весь ход.
Это всплытие важно для отказа в правах. Когда пользователь отклоняет инструмент в диалоге разрешений, срабатывает abort контроллера инструмента. Этот сигнал должен дойти до цикла запроса, чтобы он мог завершить ход. Без этого цикл запроса продолжал бы работать, посылая устаревшее сообщение об отказе модели.
Каскад ошибок sibling. Когда инструмент выдаёт ошибку, исполнитель решает, отменять ли sibling-инструменты. Правило: каскадируют только ошибки Bash. Когда shell-команда падает, исполнитель фиксирует отказ, захватывает описание упавшего инструмента и abort’ит sibling-контроллер — что отменяет все остальные выполняющиеся инструменты в батче.
Обоснование прагматично. Bash-команды часто образуют неявные цепочки зависимостей: mkdir build && cp src/* build/ && tar -czf dist.tar.gz build/. Если mkdir не удался, запуск cp и tar бессмысленен. Немедленное прерывание sibling’ов экономит время и избегает запутанных сообщений об ошибках.
Ошибки Read и Grep, напротив, независимы. Если одно чтение файла падает потому что файл удалён, это не влияет на параллельный grep по другой директории. Отмена grep в таком случае — пустая трата усилий.
Каскад ошибок порождает синтетические сообщения об отмене для sibling’ов:
Cancelled: parallel tool call Bash(mkdir build) errored
Описание включает первые 40 символов команды или пути ошибки, давая модели достаточно контекста чтобы понять, что пошло не так.
Сообщения о прогрессе обрабатываются отдельно от результатов. Пока результаты буферизуются и выдаются в порядке, сообщения о прогрессе (статус вроде «Чтение файла…» или «Идёт поиск…») помещаются в pendingProgress и выдаются немедленно через getCompletedResults(). Колбэк разрешения пробуждает цикл getRemainingResults() когда прогресс появляется, предотвращая «зависание» UI при длительных операциях.
Переобработка очереди. После завершения каждого инструмента вызывается processQueue() снова:
void promise.finally(() => {
void this.processQueue()
})
Так последовательные инструменты, блокированные параллельным батчем, запускаются после его завершения. Когда последний параллельный инструмент завершится, проверка canExecuteTool() для следующего небезопасного инструмента пройдёт, и он начнёт выполняться.
Сбор результатов
Поточный исполнитель предоставляет два метода «сбора» результатов, предназначенных для двух фаз жизненного цикла ответа.
getCompletedResults() — сбор в середине стрима. Это синхронный генератор, вызываемый между кусками потокового ответа от API. Он проходит по массиву инструментов в порядке отправки и отдаёт результаты для инструментов, которые завершились:
getCompletedResults() синхронно проходит массив инструментов в порядке отправки. Для каждого инструмента сначала опустошает любые ожидающие сообщения о прогрессе. Если инструмент завершён, он отдаёт результаты и помечает его как yielded. Критическое правило: если последовательный (non-concurrent) инструмент всё ещё выполняется, цикл прерывается — ничего стоящего после него не может быть выдано, даже если последующие инструменты уже завершились. Результаты после последовательного инструмента могут зависеть от его модификаций контекста, поэтому они должны подождать. Для параллельных инструментов этого ограничения нет; цикл пропускает исполняющиеся параллельные инструменты и продолжает проверку следующих.
Это прерывание — механизм сохранения порядка. Если последовательный инструмент всё ещё выполняется, ничего позже него не выдается — даже если последующие инструменты уже завершились.
getRemainingResults() — окончательное опустошение после стрима. Вызывается после полного получения ответа модели. Этот асинхронный генератор выполняет цикл, пока все инструменты не будут выданы:
getRemainingResults() — этап опустошения после стрима. Он повторяет цикл до тех пор, пока все инструменты не будут выданы. На каждой итерации он обрабатывает очередь (запуская любые новые разблокированные инструменты), отдаёт завершённые результаты через getCompletedResults(), и затем — если инструменты всё ещё выполняются, но ничего нового не завершилось — использует Promise.race чтобы ждать того, что завершится раньше: любой выполняющийся инструмент или сигнал наличия прогресса. Это избегает активного опроса и одновременно пробуждается в момент события. Когда нет новых завершённых задач и ничего не может стартовать — исполнитель ждёт завершения любого выполняющегося инструмента (или появления прогресса). Это предотвращает busy-wait, но остаётся отзывчивым.
Сохранение порядка
Результаты выдаются в том порядке, в котором инструменты были получены, а не в порядке их завершения. Это осознанный дизайн.
Рассмотрим ответ модели [Read("a.ts"), Read("b.ts"), Read("c.ts")]. Все три стартуют параллельно. c.ts заканчивается первым, затем a.ts, затем b.ts. Если выдавать результаты в порядке завершения, история будет:
Результат инструмента: c.ts
Результат инструмента: a.ts
Результат инструмента: b.ts
Но модель ожидала их в порядке a-b-c. История беседы должна соответствовать ожиданию модели, иначе следующий ход будет запутан. Поэтому результаты выдаются в порядке прихода:
Результат инструмента: a.ts (завершён вторым, выдан первым)
Результат инструмента: b.ts (завершён третьим, выдан вторым)
Результат инструмента: c.ts (завершён первым, выдан третьим)
Цена незначительна: если инструмент 1 медленный, а инструменты 2–5 быстрые, быстрые результаты ждут в буфере, пока инструмент 1 не завершится. Но альтернатива — несогласованность разговора — намного дороже выигранной латентности.
discard(): аварийный выход для потокового исполнения
Если поток API завершился ошибкой на середине (сетевая ошибка, отключение сервера), система повторяет запрос новым вызовом API. Но потоковый исполнитель мог уже запустить инструменты из провалившейся попытки. Их результаты теперь сиротские — они соответствуют ответу, который так и не был полностью получен.
discard(): void {
this.discarded = true
}
Установка discarded = true приводит к тому, что:
getCompletedResults()немедленно возвращает без результатов.getRemainingResults()немедленно возвращает без результатов.- Любой инструмент, который стартует, проверяет
getAbortReason(), видитstreaming_fallbackи получает синтетическую ошибку вместо реального выполнения.
Отброшенный исполнитель просто отбрасывается. Для повторной попытки создаётся новый исполнитель.
Свойства конкурентности инструментов
Каждый встроенный инструмент объявляет свои характеристики конкурентности через метод isConcurrencySafe(). Классификация не произвольная — она отражает реальное влияние инструмента на общее состояние.
| Инструмент | Безопасен для параллели | Условие | Обоснование |
|---|---|---|---|
| Read | Всегда | — | Чистое чтение. Нет побочных эффектов. |
| Grep | Всегда | — | Чистое чтение. Обёртка над ripgrep. |
| Glob | Всегда | — | Чистое чтение. Перечисление файлов. |
| Fetch | Всегда | — | HTTP GET. Нет локальных побочных эффектов. |
| WebSearch | Всегда | — | Вызов API провайдера поиска. |
| Bash | Иногда | Только команды для чтения | isReadOnly() парсит команду и классифицирует подкоманды. ls, git status, cat, grep безопасны. rm, mkdir, mv — нет. |
| Edit | Никогда | — | Модифицирует файлы. Два параллельных редактирования одного файла могут привести к порче. |
| Write | Никогда | — | Создаёт или перезаписывает файлы. Риск порчи тот же. |
| NotebookEdit | Никогда | — | Изменяет .ipynb файлы. |
Классификация Bash требует пояснения. Она использует splitCommandWithOperators() для разложения составных команд (&&, ||, ;, |), затем классифицирует каждую подкоманду по известным наборам безопасных команд:
- Команды поиска:
grep,rg,find,fd,ag,ack - Команды чтения:
cat,head,tail,wc,jq,less,file,stat - Команды списка:
ls,tree,du,df - Нейтральные команды:
echo,printf(нет побочных эффектов, но это не “чтение”)
Составная команда считается read-only только если каждая не-нейтральная подкоманда принадлежит к множеству поиска, чтения или списка. ls -la && cat README.md — безопасна. ls -la && rm -rf build/ — нет: rm «загрязняет» всю команду.
Контракт поведения при прерывании
Пока инструменты выполняются, пользователь может отправить новое сообщение. Что должно произойти? Ответ зависит от инструмента.
Каждый инструмент объявляет interruptBehavior() метод, который возвращает либо 'cancel', либо 'block':
'cancel': немедленно остановить инструмент, отбросить частичные результаты и обработать новое сообщение пользователя. Применяется к инструментам, для которых прерывание безопасно (чтения, поиски).'block': позволить инструменту завершиться. Новое сообщение пользователя ждёт. Применяется к инструментам, где прерывание оставит систему в неконсистентном состоянии (запись в процессе, длительные bash-команды). Это поведение по умолчанию.
Поточный исполнитель отслеживает состояние прерываемости текущего набора инструментов:
Состояние прерываемости обновляется проверкой всех выполняющихся инструментов: набор прерываем только тогда, когда каждый выполняющийся инструмент поддерживает отмену. Если хоть один инструмент возвращает 'block', весь набор считается непрерываемым.
UI показывает индикатор «можно прервать» только если ВСЕ выполняющиеся инструменты поддерживают отмену. Если хотя бы один инструмент 'block', весь набор считается непрерываемым. Это консервативно, но корректно: нельзя прервать батч, в котором один инструмент всё равно будет продолжать выполняться.
Когда пользователь прерывает и все инструменты отменяемы, срабатывает abort контроллер с причиной 'interrupt'. Метод getAbortReason() исполнителя проверяет поведение по прерыванию для каждого инструмента отдельно — инструментам с 'cancel' создаётся синтетическая ошибка user_interrupted, а инструменты с 'block' (хотя они не должны присутствовать в полностью прерываемом наборе, но код учитывает крайний случай) продолжают работу.
Модификаторы контекста: контракт для последовательного исполнения
Модификаторы контекста имеют тип (context: ToolUseContext) => ToolUseContext. Они позволяют инструменту сказать «я изменил что-то в среде выполнения, и последующие инструменты должны об этом знать».
Контракт прост: модификаторы контекста применяются только для последовательных (non-concurrent) инструментов. Это прямо указано в исходниках:
// ПРИМЕЧАНИЕ: в данный момент мы не поддерживаем модификаторы контекста для
// параллельных инструментов. Никакие такие модификаторы сейчас не используются,
// но если мы захотим их поддержать, нужно добавить логику здесь.
if (!tool.isConcurrencySafe && contextModifiers.length > 0) {
for (const modifier of contextModifiers) {
this.toolUseContext = modifier(this.toolUseContext)
}
}
В пути оркестрации батчей (toolOrchestration.ts) модификаторы для параллельных батчей собираются и применяются после завершения батча в порядке отправки инструментов. Это означает, что параллельные инструменты внутри батча не видят изменений контекста друг друга, но батч, идущий после них, увидит все изменения.
Асимметрия намеренна. Если инструмент A изменяет контекст, а инструмент B читает этот контекст — между ними есть зависимость данных. Зависимости данных исключают параллельное выполнение. Если два инструмента безопасны для параллели, то по определению они не должны зависеть от изменений контекста друг друга. Система обеспечивает это, откладывая применение изменений.
Apply This (сводка)
Паттерны конкурентности в Агенте обобщаются на любую систему, оркестрирующую множество независимых операций. Ниже — пять практических, переносимых приёмов, которые можно применить в своих системах.
-
Разбивайте по безопасности, а не по типу. Метод
isConcurrencySafe(input)получает распарсенный вход, а не просто имя инструмента. Такая классификация на уровне вызова точнее, чем статическое «этот тип инструмента всегда безопасен». В своих системах проверяйте аргументы операции перед параллелизацией: чтение базы данных — безопасно, запись в ту же строку — нет. Тип операции сам по себе часто недостаточен. -
Запускайте спекулятивно во время I/O-ожиданий. Поточный исполнитель стартует инструменты в процессе получения ответа. Такая схема применима где угодно, где есть медленный производитель и быстрые потребители: HTTP/2 server push, параллелизм в компиляторе, спекулятивное выполнение ЦПУ. Главное требование — возможность идентифицировать независимую работу до получения полного набора инструкций.
-
Сохраняйте порядок отправки при выдаче результатов. Выдача в порядке завершения уменьшает задержку первого результата, но если потребитель (в данном случае языковая модель) ожидает результатов в определённом порядке, их перемешивание создаёт путаницу, которая дороже выигранной латентности. Буферизуйте завершённые результаты и отдавайте их в порядке запроса.
-
Спроектируйте явную политику каскадирования ошибок и иерархию отмен. Решите заранее, какие ошибки должны приводить к отмене sibling’ов (например, shell-ошибки) и какие — нет (локальные чтения). Постройте иерархию контроллеров прерывания (запрос → sibling → инструмент), чтобы иметь гибкость отменять разные области без разрушения всего процесса. Явная политика упрощает отладку и даёт предсказуемое поведение в боевых условиях.
-
Придерживайтесь принципа «минимальная допустимая доверенность». Когда парсинг входа или проверка безопасности неудачны, выбирайте более консервативное поведение (последовательное выполнение). Это уменьшает риск коррумпирования состояния и делает систему более предсказуемой при расширении списка инструментов или появлении пользовательских расширений.
Паттерн потокового исполнителя особенно полезен для агентных систем. Каждый раз, когда ваш цикл агента — «подумать → действовать» — генерирует множество независимых действий, можно перекрыть хвост фазы мышления с началом фазы действий. Экономия пропорциональна отношению времени размышления к времени выполнения. Для языковых моделей, где время генерации обычно доминирует, выигрыш велик.