Почему большинство агентов на Swift — мертворождённые?
Попытки написать своего кодинг-агента на Swift обычно заканчиваются одним из двух исходов: либо вы упираетесь в стены управления контекстом, либо ваш агент впадает в бесконечный цикл, переписывая один и тот же файл. Я перебрал десяток реализаций — от простых скриптов до полноценных обвязок вроде тех, что используют в Claude Code на Mac M3. И сегодня разберу, как выглядит внутреннее устройство минимально жизнеспособного агента на Swift.
Большинство туториалов учат просто дёргать API LLM и выводить результат. Но проблема не в вызове — проблема в цикле агента и управлении контекстом. Агент, который не умеет поддерживать сессию дольше одного запроса, бесполезен. Тот, кто не контролирует длину истории, быстро вылетает за лимит токенов. Я покажу, как строить REPL-цикл и Agent Loop, управлять инструментами и не сойти с ума от утечки контекста.
REPL-цикл: сердце интерактивного агента
REPL (Read-Eval-Print Loop) — классическая архитектура для диалогового агента. Пользователь вводит запрос, агент читает, обрабатывает LLM, выполняет инструменты, выводит результат — и снова ждёт ввода. Звучит просто, но дьявол в деталях.
// Минимальный REPL-цикл агента
import Foundation
actor Agent {
let llm: LLMProvider
var tools: [String: Tool]
var history: [Message] = []
let maxTokens = 8_000
init(llm: LLMProvider, tools: [Tool]) {
self.llm = llm
self.tools = Dictionary(uniqueKeysWithValues: tools.map { ($0.name, $0) })
}
func start() async {
print("🤖 Agent ready. Type 'exit' to quit.")
while true {
print("> ", terminator: "")
guard let input = readLine(), input != "exit" else { break }
history.append(.user(input))
await process()
}
}
private func process() async {
// 1. Обрезаем контекст до maxTokens
trimHistory()
// 2. Запрашиваем LLM
let response = await llm.generate(history: history, tools: Array(tools.keys))
// 3. Парсим ответ — ожидаем JSON с action
guard let action = parseAction(from: response) else {
print("Failed to parse action")
return
}
// 4. Выполняем инструмент
if action.type == "final" {
print(action.output)
history.append(.assistant(action.output))
} else if let tool = tools[action.toolName] {
let result = await tool.call(with: action.arguments)
history.append(.observation(result))
// 5. Повторяем цикл — агент может делать несколько шагов
await process()
}
}
}Ключевая особенность: агент рекурсивно вызывает process(), пока не получит финальный ответ. Это и есть Agent Loop. Без такого цикла агент сможет выполнить только одно действие. В реальных задачах ему нужно прочитать файл, подумать, записать изменения, проверить — и только потом ответить.
⚠️ Важно: рекурсивный цикл надо ограничивать по глубине и времени, иначе уйдёте в бесконечность. Добавляйте счётчик итераций.
Инструменты: как агент взаимодействует с кодом
Агент без инструментов — это просто болтун. Чтобы он мог редактировать файлы, запускать тесты или выполнять команды shell, нужно определить протокол и реализовать конкретные инструменты.
protocol Tool {
var name: String { get }
var description: String { get }
var parameters: [String: ParameterInfo] { get }
func call(with arguments: [String: String]) async -> String
}
struct ReadFileTool: Tool {
let name = "read_file"
let description = "Read a file from the project"
let parameters = ["path": .string(description: "Absolute or relative path")]
func call(with arguments: [String: String]) async -> String {
guard let path = arguments["path"] else { return "Error: missing path" }
do {
return try String(contentsOfFile: path, encoding: .utf8)
} catch {
return "Error: \(error.localizedDescription)"
}
}
}Инструменты нужно регистрировать в агенте и передавать их описания LLM. Обычно в system prompt добавляют список инструментов с JSON-схемой. LLM выбирает, какой вызвать, и возвращает JSON с именем и аргументами. Этот парсинг — самое хрупкое место. Используйте Codable и жёсткую валидацию, иначе агент сломается на первой опечатке.
В статье QuillCode: разбор архитектуры своего кодинг-агента авторы как раз жалуются на то, что LLM часто генерирует некорректный JSON. Решение — использовать constrained decoding или хотя бы давать в prompt пример успешного разбора.
Управление контекстом: главный враг и союзник
Без контроля контекста агент сначала теряет из виду задачу, а потом просто перестаёт генерировать связные ответы. Лимиты моделей (Claude 3.5 Sonnet — 200K токенов, GPT-4 — 128K) кажутся огромными, но каждое наблюдение и результат работы инструмента занимают место. Через 5-10 шагов история может перевалить за 50K токенов.
Вот три техники, которые я использую в продакшене:
- Sliding window — храним только последние N сообщений (например, 20). Всё остальное отбрасываем или суммаризируем.
- Суммаризация — каждые K шагов просим LLM сжать историю в short-term memory. Эту технику детально разобрали в статье «Как не сжечь токены».
- Тримминг по токенам — перед отправкой запроса считаем токены и удаляем самые старые сообщения, пока не уложимся в лимит.
func trimHistory() {
var totalTokens = history.reduce(0) { $0 + $1.tokenCount }
while totalTokens > maxTokens, history.count > 1 {
let removed = history.removeFirst()
totalTokens -= removed.tokenCount
}
}❌ Ошибка: удалять только user-сообщения — ломает диалоговую структуру. Лучше удалять пары (user + assistant) или суммаризировать.
Более продвинутый подход — контекст-инжиниринг, о котором мы говорили в статье «Контекст-инжиниринг для coding-агентов». Там разобрано, как структурировать сессии, чтобы агент не «забывал» цель.
Типичные ошибки при сборке агента
| Ошибка | Последствие | Решение |
|---|---|---|
| Бесконечный цикл | Расход токенов, зависание | Max итераций (например, 20) |
| Некорректный JSON от LLM | Поломка пайплайна | Повторный запрос с исправлением |
| Переполнение контекста | Потеря понимания, вылет | Sliding window + суммаризация |
| Инъекции команд | Уязвимости безопасности | Строгий sandbox, белый список команд |
Особенно опасен agentic loop, когда агент сам решает, что делать дальше. В архитектуре мультиагентных систем используют специальный harness, который контролирует жизненный цикл. Для одноагентной версии достаточно простого счётчика.
Собираем всё вместе: минимальный рабочий агент
Ниже полный код агента, который читает файл, пишет в файл и выполняет shell-команды. В production стоит добавить асинхронную отмену, логирование и более умный парсинг. Но для старта — сойдёт.
@main
struct CodingAgentApp {
static func main() async {
let llm = OpenAIProvider(apiKey: ProcessInfo.processInfo.environment["OPENAI_API_KEY"]!)
let tools: [Tool] = [ReadFileTool(), WriteFileTool(), ShellTool()]
let agent = Agent(llm: llm, tools: tools)
await agent.start()
}
}Этот код запускает REPL-цикл. Агент может читать исходники, править их и запускать тесты. Разумеется, без контролируемого доступа к shell это опасно. Рекомендую запускать в Docker-контейнере или хотя бы ограничить права.
Важный момент: в Xcode 26.3 Apple уже встроила поддержку агентов (статья «Агентное программирование в Xcode 26.3»), но наша самописная обвязка даёт полный контроль — можно кастомизировать промпты, управлять контекстом и выбирать любую модель.
Неочевидный совет напоследок
Не пытайтесь сделать агента универсальным. Агент, который пытается делать всё — от рефакторинга Swift до деплоя в Kubernetes — будет жрать токены и тупить на каждом шагу. Заточите его под конкретную задачу: например, только для работы с SwiftUI-вьюхами или только для автоматического написания Unit-тестов. Тогда и контекст меньше, и поведение предсказуемее. Взгляните на подход GitHub Copilot Custom Agents — они как раз показывают, что узкая специализация побеждает универсальность.
И помните: агент — это не волшебная палочка. Это программа, которая принимает решения на основе LLM. А значит, ошибки неизбежны. Главное — сделать так, чтобы они не стоили вам бешеных счетов за API.