
Apple выпустила Foundation Models SDK for Python ещё в феврале 2026. Эта Python-библиотека предоставляет прямой доступ к локальной модели Apple Intelligence: модель грузится в процесс, KV-кеш лежит в памяти, инференс идёт на Apple Silicon. Библиотека получила 1,2 тыс. звёзд на GitHub, последнюю версию 0.2.0 с поддержкой изображений представили на WWDC26.
Есть несколько обёрток для Foundation Models с поддержкой интерфейса OpenAI, но нет нативного провайдера для агентов, написанных на Python, таких как Hermes, в котором локальная модель работала бы как основной инференс агента со всеми его особенностями: рейтлимитером, откатом к другим моделям и аудитом. Также известно прямое использование Foundation Models в агенте iClaw на Swift, но эти возможности пока недоступны более универсальным агентам на Python.
Почему бы не попробовать добавить к Hermes адаптер на Foundation Models SDK for Python и получить среднее время реакции на короткие запросы 2,8 секунды, а на бенчмарке из 50 вопросов из MMLU, BoolQ, GSM8K, BIG-Bench и HellaSwag выбить 46 правильных ответов?
Под катом — архитектура, код и бенчмарки нативного провайдера для автономного агента Hermes.
Код проекта: github.com/iplabme/hermes-on-python-apple-fm-sdk
Hermes Agent: github.com/NousResearch/hermes-agent — open-source агент Nous Research, распространяемый по MIT License.
Для работы нашей конфигурации требуется Mac на Apple Silicon (M1 и новее), macOS 26, Python 3.10 и включённая Apple Intelligence в настройках ОС, иначе наш адаптер автоматически будет переключать инференс на облачного провайдера. Язык устройства должен быть из списка поддерживаемых Apple Intelligence (en-US, en-GB и др.), но в этом списке пока нет русского языка, поэтому запросы на нём будут автоматически перенаправляться на облачную модель.
Теперь, выяснив все предварительные условия, напишем нативный Python-провайдер для Hermes Agent поверх Apple Foundation Models SDK, весь инференс которого проходит внутри процесса Hermes с единой системой журналирования, классификации ошибок, аудита вызовов инструментов и контролируемой замены провайдера.
Архитектурное решение: нативный плагин против HTTP-моста
Есть два возможных пути реализации:
HTTP-мост. Отдельный Python-процесс с OpenAI-совместимым API. Hermes подключают к нему через provider: custom и base_url. Так часто подключают локальные модели на vLLM или Ollama. Это самый простой способ: ноль изменений в Hermes, быстрый запуск.
Нативный плагин. Провайдер регистрируют в реестре Hermes через ProviderProfile(apimode="applefm") и register_provider(), файлы добавляют в исходники самого Hermes.
В чём разница двух подходов:
Критерий |
Нативный плагин |
HTTP-мост |
|---|---|---|
Наблюдаемость и ошибки |
Исключения Apple SDK транслируются напрямую в список Hermes FailoverReason; единый журнал, сквозная трассировка по |
HTTP-статусы теряют семантику ошибки; два процесса - два лога, корреляция вручную. |
Отказоустойчивость |
Один контур: ошибка → ErrorAdapter → retry или fallback на резервного провайдера. |
Два контура: HTTP-клиент и SDK-сервер обрабатывают ошибки независимо; fallback цепочка разорвана. |
Безопасность инструментов |
Модель в адресном пространстве Hermes; approval, sandbox и audit остаются в одном процессе. |
Данные идут через loopback-сокет; при ошибке конфигурации сервер может слушать внешний интерфейс. |
И вот причина, почему был выбран второй вариант: Hermes — это Python-проект с плагин-архитектурой, providers/init.py содержит register_provider(), плагины живут в plugins/model-providers с plugin.yaml и пакетом адаптеров, транспорт регистрируется через register_transport(), поэтому нативный плагин даёт качественно другой уровень управляемости. Существующие плагины Hermes OpenRouter (plugins/model-providers/openrouter) Bedrock и Anthropic реализованы таким же способом.
Модули, которые нужно добавить в Hermes для создания плагина:
Файл |
Путь в коде Hermes |
Назначение |
|---|---|---|
Плагин провайдера |
plugins/model-providers/apple-fm |
Профиль, plugin.yaml, пакет адаптеров |
Транспорт |
agent/transports/apple_fm.py |
Реализация контракта ProviderTransport |
Тесты |
tests/apple_fm |
45 модульных и интеграционных тестов |
Для поддержки нового apimode="applefm" потребовалось затронуть ядро Hermes, в нём изменили несколько файлов для адаптации работы с новым провайдером.
Устройство адаптера: классы, контракты и ключевые алгоритмы
Общая схема
Взаимодействие с плагином выглядит так: Hermes видит универсальный транспортный контракт, адаптер транслирует его в вызовы Apple SDK. Но, как уже упоминалось выше, оказалось, что плагинная архитектура Hermes пока не проработана, и необходима модификация кода основных классов Hermes. Далее подробно рассмотрим код нашего решения.

Регистрация провайдера: AppleFMProfile
Точка входа плагина. Hermes вызывает fetch_models() при выборе провайдера, и для Apple FM список моделей статический — сеть не нужна:
class AppleFMProfile(ProviderProfile): def fetch_models(self, *, api_key=None, timeout=8.0) -> list[str] | None: return ["apple/on-device"] apple_fm = AppleFMProfile( name="apple-fm", api_mode="apple_fm", auth_type="local", env_vars=(), supports_health_check=True, fallback_models=("apple/on-device",), ) register_provider(apple_fm)
Поля apimode="applefm" и auth_type="local" сообщают ядру Hermes: это внутренний провайдер, не требующий HTTP-эндпоинта и ключа API. Hermes пропускает шаг аутентификации и направляет запросы напрямую в AppleFMTransport.
Транспорт: AppleFMTransport
Класс реализует контракт ProviderTransport — тот же, что используют Anthropic, OpenRouter и все остальные провайдеры Hermes. Четыре метода контракта:
class AppleFMTransport(ProviderTransport): api_mode = "apple_fm" def convert_messages(self, messages, **kwargs) -> AppleFMPrompt: ... def convert_tools(self, tools) -> tuple[ToolSpec, ...]: ... def build_kwargs(self, model, messages, tools=None, **params) -> dict: ... def normalize_response(self, response, **kwargs) -> ChatResult: ...
Ключевая логика сосредоточена в build_kwargs(). Этот метод принимает решение о маршруте запроса:
def build_kwargs(self, model, messages, tools=None, **params): prompt = self.convert_messages(messages, stateful=params.get("stateful", True)) tool_specs = self.convert_tools(tools or []) response_format = params.get("response_format") tool_choice = params.get("tool_choice") json_schema = None if ( tool_specs and tool_choice != "none" and params.get("tool_mode") == "structured_decision" ): json_schema = self.tool_adapter.to_tool_decision_schema(list(tool_specs)) elif response_format: json_schema = self.schema_adapter.from_response_format(response_format) return { "model": model, "request": AppleFMRequest( conversation_id=params["conversation_id"], prompt=prompt.prompt, instructions=prompt.instructions, json_schema=json_schema, tools=tool_specs, stream=bool(params.get("stream", False)), ), "schema_warnings": ..., "ignored_options": self.options_adapter.ignored_fields(params), }
Среда исполнения: AppleFMRuntime
Центральный оркестратор. Один раз создаёт SystemLanguageModel (singleton с двойной блокировкой), управляет сессиями, применяет бюджет контекста и разбирает ответы модели:
class AppleFMRuntime: def __init__(self, config: AppleFMConfig, availability: AppleFMAvailability, session_store: AppleFMSessionStore, error_adapter: ErrorAdapter): ... def health(self) -> AppleFMStatus: ... def get_session(self, request: AppleFMRequest) -> fm.LanguageModelSession: ... async def respond(self, request: AppleFMRequest) -> AppleFMResponse: ... async def stream(self, request: AppleFMRequest) -> AsyncIterator[AppleFMStreamEvent]: ... def close_session(self, conversation_id: str) -> None: ...
Три архитектурно значимых механизма внутри AppleFMRuntime:
-
Бюджет контекста. Модель Apple on-device имеет ограниченное контекстное окно. budgetrequest() обрезает системные инструкции и промпт до настроенных пределов (по умолчанию 1200 и 12 000 символов), заменяя отсечённую часть компактной строкой:
def _budget_request(self, request: AppleFMRequest) -> AppleFMRequest: instructions = request.instructions or "" if len(instructions) > self.config.max_instructions_chars: instructions = self._truncate_text( self.config.compact_instructions, self.config.max_instructions_chars ) prompt = request.prompt if isinstance(prompt, str) and len(prompt) > self.config.max_prompt_chars: prompt = prompt[:self.config.max_prompt_chars] + "\n\n[... truncated ...]" return replace(request, instructions=instructions, prompt=prompt)
-
Разбор ответа. Apple SDK может вернуть plain-строку, объект с .text или словарь. toresponse() обрабатывает все варианты и извлекает вызовы инструментов из structured decision:
def _to_response(self, generated) -> AppleFMResponse: if isinstance(generated, str): return AppleFMResponse(text=generated, finish_reason="stop") structured = self._try_extract_dict(generated) if isinstance(structured, dict): if structured.get("action") == "use_tool": tool_calls = self._tool_adapter.decode_tool_arguments(structured) return AppleFMResponse(text="", tool_calls=tool_calls, ...) if structured.get("action") == "answer": return AppleFMResponse(text=structured["answer"], ...) return AppleFMResponse(text=str(generated), ...)
Потоковый вывод. stream() проверяет флаг forcedisablestreaming и при необходимости откатывается на respond(). Сессия аннулируется при отмене стрима.
Адаптер сообщений: HermesMessageAdapter
Преобразует список сообщений Hermes в формат Apple FM — единый метод assemble() с двумя режимами:
@dataclass class AppleFMPrompt: instructions: str prompt: str | list # строка или [str, ImageAttachment, ...] stateful: bool class HermesMessageAdapter: def assemble(self, messages: list[dict], *, stateful: bool) -> AppleFMPrompt: ...
Stateful-режим (stateful=True): извлекает system/developer-сообщения как инструкции, берёт последнее пользовательское сообщение как промпт. Контекст диалога хранится внутри LanguageModelSession.
Stateless-режим (stateful=False): сериализует всю историю в одну строку, оборачивает выводы инструментов в маркеры [untrusted tool output] ... [/untrusted tool output].
Обработка изображений: адаптер обнаруживает части сообщений image_url и input_image, декодирует base64 (включая RFC 2397 data URI), сохраняет во временный файл, создаёт fm.ImageAttachment(path, label=...) и регистрирует очистку временных файлов при завершении процесса.
Адаптер схем: SchemaAdapter
Самая нетривиальная часть проекта. Apple FM не принимает стандартную OpenAI JSON Schema. Объектные схемы должны соответствовать диалекту Apple: каждое объектное поле обязано иметь title и x-order (массив имён свойств в порядке следования), additionalProperties должно быть задано явно (по умолчанию false), объект без непустого properties отвергается при запуске.
class SchemaAdapter: def to_json_schema(self, hermes_schema: dict) -> dict: ... def from_response_format(self, response_format: dict | None) -> dict | None: ... def _to_apple_schema(self, value, path, title): if is_object and "properties" in value: ordered_names = list(value["properties"].keys()) result.setdefault("type", "object") result.setdefault("additionalProperties", False) result["title"] = title result["x-order"] = ordered_names result["properties"] = { name: self._to_apple_schema(prop, f"{path}.{name}") for name, prop in value["properties"].items() } return result
Без этой трансформации любая попытка structured output завершается GenerationError(status: 255).
Адаптер ошибок: ErrorAdapter
Переводит исключения Apple SDK в таксономию Hermes. Ключевая идея — двойная классификация: каждому типу ошибки Apple FM назначается и код ошибки, и FailoverReason, управляющий поведением ядра Hermes (повтор, сжатие контекста, переключение на резервного провайдера):
class ErrorAdapter: REASON_BY_NAME = { "ExceededContextWindowSizeError": "context_overflow", "RateLimitedError": "rate_limit", "AssetsUnavailableError": "server_error", "UnsupportedLanguageOrLocaleError": "provider_policy_blocked", # ... } def map(self, exc: Exception) -> AppleFMAdapterError: error_name = type(exc).__name__ reason = self.REASON_BY_NAME.get(error_name) # ... classified = ClassifiedError( code=code, reason=reason, retryable=retryable, should_fallback=(reason in ("server_error", "rate_limit", "provider_policy_blocked")), ) return AppleFMAdapterError(str(exc), original=exc, classified=classified)
Важно: GuardrailViolationError не вызывает fallback, он транслируется в format_error с правом на повтор, но не на переключение провайдера. Нарушение guardrail — это отказ по содержанию, а не по доступности. А UnsupportedLanguageOrLocaleError, наоборот, транслируется в providerpolicyblocked с should_fallback=True — мгновенное переключение на облачную модель без повторных попыток.
Нормализатор потока: StreamNormalizer
Apple SDK при потоковом выводе (stream_response()) отдаёт накопительные снимки всего текста, а не приращения. Однако Hermes ожидает в ответах именно дельты. Поэтому StreamNormalizer реализован таким образом, что хранит предыдущий снимок и на каждом шаге вычисляет разность:
class StreamNormalizer: def __init__(self, request_id: str, session_id: str): self.delta_index = 0 self._snapshot = "" def to_event(self, chunk) -> AppleFMStreamEvent: self.delta_index += 1 text = self._chunk_text(chunk) delta = text[len(self._snapshot):] if text.startswith(self._snapshot) else text self._snapshot = text return AppleFMStreamEvent("text", delta, dict(delta_index=self.delta_index))
Быстрое префиксное сравнение text.startswith(self._snapshot) покрывает типичный случай; при несовпадении (первый чанк, сбой SDK) отдаётся полный снимок.
Хранилище сессий: AppleFMSessionStore
Хранилище сессий представляет собой кеш в оперативной памяти с временем жизни. Ключ сессии — составной: (conversationid, instructionshash, tools_hash). При изменении любого из компонентов старая сессия автоматически аннулируется и создаётся новая:
class AppleFMSessionStore: def get_or_create(self, *, conversation_id, instructions_hash, tools_hash, factory) -> fm.LanguageModelSession: self.cleanup_expired() # ленивая очистка по TTL key = (conversation_id, instructions_hash, tools_hash) entry = self._entries.get(key) if entry is None: self.invalidate(conversation_id) # сброс старых сессий entry = SessionEntry(factory(), time.monotonic()) self._entries[key] = entry entry.last_used_at = time.monotonic() return entry.session
Хеши инструкций и инструментов вычисляются через SHA-256 в AppleFMRuntime.hashtext() и hashtools() — это гарантирует, что при смене системного промпта или состава инструментов сессия будет пересоздана, а не переиспользована с устаревшим контекстом.
Система мониторинга
Диагностика одной командой
Проверка Apple FM интегрирована в hermes doctor:
$ hermes doctor Apple Foundation Models (available, warmup_skipped=True)
Проверяется: версия macOS, архитектура процессора, Xcode, наличие applefmsdk, статус SystemLanguageModel().is_available(), языковые настройки системы и флаг Apple Intelligence. Разогрев модели (пробная генерация) по умолчанию пропущен для скорости, но включается флагом applefm.doctorwarmup: true.
Классификация ошибок и отказоустойчивость
Каждая ошибка Apple SDK транслируется в список FailoverReason — ядро Hermes принимает решение на основе этой классификации:
Ошибка Apple SDK |
FailoverReason |
Поведение |
|---|---|---|
ExceededContextWindowSizeError |
context_overflow |
Сжатие контекста, повтор; затем fallback. |
UnsupportedLanguageOrLocaleError |
providerpolicyblocked |
Мгновенный fallback. |
AssetsUnavailableError |
server_error |
Fallback. |
GuardrailViolationError |
format_error |
Повтор до трёх раз, затем отказ. |
InvalidGenerationSchemaError |
format_error |
Ошибка немедленно (не маскируется). |
Журналирование
Транспорт Apple FM пишет в общий журнал Hermes в едином формате со всеми остальными провайдерами:
apple_fm.request— каждый запрос сrequest_id,conversation_id, флагомstream.apple_fm.response— каждый ответ сfinish_reasonиelapsed.apple_fm.stream— каждый потоковый чанк сdelta_index.apple_fm.request.error— каждая ошибка сattemptиerror_class.
События Apple FM видны в том же потоке, что Anthropic, OpenRouter и DeepSeek.
Бенчмарк адаптера
Возьмём 50 вопросов из публичных наборов MMLU, BoolQ, GSM8K, BIG-Bench и HellaSwag и напишем свой бенчмарк для нашего адапртера. Он запускается через hermes chat --provider apple-fm -q "..." и замеряет полное сквозное время.
Результат: 46 из 50:
Категория |
Результат |
|---|---|
MMLU (фактические знания, множественный выбор) |
10/10 — 100% |
BIG-Bench (следование инструкциям) |
10/10 — 100% |
HellaSwag EN (здравый смысл, английский) |
5/5 — 100% |
HellaSwag RU (русский → fallback на DeepSeek) |
5/5 — 100% |
GSM8K (арифметика, начальная школа) |
9/10 — 90% |
BoolQ (факты да/нет) |
7/10 — 70% |
Всего |
46/50 — 92% |
Задержки: медиана 2,8 сек. Измерено на десяти коротких запросах:
Метрика |
Время |
|---|---|
Медиана |
2,8 сек. |
90-й процентиль |
3,2 сек. |
Среднее |
3,42 сек. |
Приватность
При работе Apple FM запросы не покидают машину. Tcpdump во время инференса не показывает обращений к портам OpenAI, Anthropic или DeepSeek. Переключение на облачного провайдера происходит только при явном отказе Apple FM и фиксируется в журнале:
Fallback activated: apple/on-device -> deepseek-v4-pro
Безопасный вызов инструментов: structured decision
Проблема нативного Tool API
Apple FM SDK предоставляет класс fm.Tool — можно унаследоваться и реализовать async def call(self, args) -> str. SDK исполняет call() внутри процесса генерации.
Проверено экспериментом: в тестовом приложении с журналированием модель попросили просто поздороваться, и она всё равно вызвала Tool.call(), несмотря на отсутствие необходимости. Это означает, что любое побочное действие (запуск команды, запись в файл, сетевой вызов) произойдёт без подтверждения пользователя, без изоляции и аудита.
Поэтому в адаптере нативные fm.Tool намеренно не используем. Вместо этого ToolAdapter формирует JSON-схему, заставляющую модель выбрать одно из двух действий:
{"action": "answer", "answer": "текст ответа"} {"action": "use_tool", "tool_name": "...", "arguments": "{...}"}
Модель возвращает структурированное решение, но не исполняет его, AppleFMRuntime.toresponse() декодирует ответ в ToolCall(id, name, arguments), а Hermes agent loop решает: показать запрос на подтверждение, отклонить или исполнить в изолированной среде с аудитом.
Пользователь: прочитай README.md и скажи, о чём проект Apple FM → {"action": "use_tool", "tool_name": "file_read", "arguments":"{\"path\": \"README.md\"}"} Hermes: [Требуется подтверждение: прочитать файл README.md] [y/N] Пользователь: y Hermes исполняет file_read () → возвращает содержимое Hermes → Apple FM: <tool_result>содержимое файла</tool_result> Apple FM → {"action": "answer", "answer": "Проект — о локальном инференсе Apple FM в Hermes Agent"}
Как видно из примера, локальная модель не может выполнить побочное действие без явного подтверждения. Контроль, изоляция и аудит остаются на стороне Hermes.
Автоматическая замена провайдера при сбоях
При получении ошибок от Apple SDK в нашем решении провайдер может быть автоматически заменён на облачного (можно, конечно, это отключить в настройках). Вот список некоторых правил замены, которые настроены на основе кодов ошибок от Apple SDK:
Ошибка |
Действие |
|---|---|
UnsupportedLanguageOrLocaleError |
Мгновенный fallback, без повторов. |
ExceededContextWindowSizeError |
Сжатие контекста → повтор; затем fallback. |
AssetsUnavailableError |
Fallback. |
GuardrailViolationError |
Повтор до трёх раз, затем отказ. |
InvalidGenerationSchemaError |
Ошибка немедленно — программист ошибся в схеме. |
Как запускать
Конфигурация:
# ~/.hermes/config.yaml model: provider: apple-fm default: apple/on-device apple_fm: use_case: GENERAL guardrails: default session_mode: stateful request_timeout_seconds: 120 max_instructions_chars: 1200 max_prompt_chars: 12000 compact_instructions: compact
Запуск:
hermes gateway restart hermes doctor # Apple Foundation Models (available, warmup_skipped=True)
Выводы
Итак, что работает сейчас: диалоги с пользователем, structured output через json_schema, потоковый вывод, вызов инструментов через structured decision (модель выбирает инструмент, Hermes исполняет с подтверждением), автоматический откат на облачного провайдера при отказах Apple FM — русский язык, переполнение контекстного окна, недоступность Apple Intelligence. Нужно скачать код из репозитория, положить по указанному в README.md пути в плагины Hermes, применить патч к основным файлам Hermes (он также добавит и конфигурацию), и можно запускать!
Ссылки
Проект: github.com/iplabme/hermes-on-python-apple-fm-sdk — спецификация, адаптер, тесты, benchmark.
Hermes Agent (MIT License): github.com/NousResearch/hermes-agent
Apple FM SDK: github.com/apple/python-apple-fm-sdk