Локальный ИИ-агент с Ollama: узкоспециализированные инструменты, проверка и ограничения по количеству шагов
Местный ИИ-агент в Ollama, который обеспечивает узкий список разрешенных инструментов, ограниченный цикл выполнения, структурированный вывод с использованием Pydantic, явное управление состоянием и набор тестовых случаев сбоев.
Источники: I Built a Local AI Agent With Ollama — and the Hard Part Wasn’t the Model
Первая версия инструмента для работы с местными счетами кажется впечатляющей примерно пять минут. Задача проста: помочь владельцу небольшого бизнеса взыскать просроченные счета. Инструмент может находить клиентов, выявлять непогашенные счета, определять степень просрочки и составлять напоминания. В тестовом запросе просится найти просроченные счета компании Acme Plumbing и подготовить дружелюбное напоминание. Инструмент сразу же отвечает, предоставляя счет, остаток долга и готовое письмо.
Проблема в том, что часть информации вымышлена. Клиент существует, но номер счета — нет. Сумма кажется правдоподобной, но ошибочна. Инструмент создал вид успешного результата, не выполнив реальной работы.
Эта глава преподносит самый важный урок о создании агентов: кажущаяся умность не является признаком надежности. Агент считается надежным, когда каждое значимое утверждение имеет под собой контролируемое действие или достоверный источник. Далее описывается процесс создания агента для обработки счетов на основе Ollama и Python с использованием специальных инструментов, механизмов проверки и компактного цикла работы агента. Инференс выполняется на локальном компьютере, но изложенные принципы применимы практически к любой платформе для создания агентов.
Почему Ollama
Локальная настройка позволяет избежать отправки данных клиентов к сторонним поставщикам моделей во время разработки и обеспечивает возможность замены моделей без необходимости пересоздания приложения. Ollama предоставляет модели локально через простой API для чатов. Также поддерживается вызов внешних инструментов, что позволяет модели запрашивать необходимые функции вместо того, чтобы выдавать неверные ответы. Настройка занимает совсем немного времени:
ollama pull qwen3
pip install ollama
Обычный запрос на чат из Python выглядит так:
from ollama import chat
response = chat(
model="qwen3",
messages=[
{"role": "user", "content": "Explain what an overdue invoice is."}
],
)print(response.message.content)
Это даёт чат-бота, но ещё не агента.
Чат-бот отвечает, агент действует пошагово
Термин «агент» используется довольно широко. Работоспособное определение — это модель, работающая в цикле и имеющая доступ к инструментам, состоянию и правилам, которые определяют, что она может сделать дальше. Для задачи с счётом-фактурой цикл выглядит следующим образом:
- Пользователь делает запрос.
- Модель определяет, требуется ли инструмент.
- Приложение проверяет запрос на использование инструмента.
- Приложение запускает инструмент.
- Результат добавляется к разговору.
- Модель запрашивает другой инструмент или даёт окончательный ответ.
Модель никогда не взаимодействует с базой данных. Она может лишь попросить приложение вызвать инструмент, определённый разработчиком. Этот предел важен, поскольку языковые модели хорошо справляются с интерпретацией намерений и генерацией текста, тогда как код приложений лучше подходит для работы с разрешениями, запросами, вычислениями и бизнес-правилами.
Сначала узкие инструменты
Распространённой первой ошибкой является наличие одного мощного инструмента, например manage_invoices, который может выполнять поиск, обновление, создание проекта и отправку. Это затрудняет отладку и крайне упрощает злоупотребления. Лучше использовать небольшие инструменты, каждый из которых выполняет одну задачу:
find_customerпреобразует имя клиента в реальный идентификатор клиента.get_overdue_invoicesвозвращает просроченные счёты для данного идентификатора.draft_reminderгенерирует текст, но ничего не отправляет.send_reminderвыполняет внешнюю операцию и требует одобрения.
Упрощённый локальный слой данных предоставляет необходимые факты:
CUSTOMERS = {
"acme plumbing": {
"customer_id": "cus_1042",
"name": "Acme Plumbing",
"email": "billing@example.com",
}
}
INVOICES = [
{
"invoice_id": "INV-2048",
"customer_id": "cus_1042",
"amount": 1850.00,
"days_overdue": 18,
}
]
def find_customer(name: str) -> dict:
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
Посмотрите, чего не делают эти функции. Они никогда не делают предположений. Отсутствие клиента приводит к явной ошибке, а отсутствие соответствующих счетов-фактур — к пустому списку.
Реальные инструменты, а не вымышленный доступ
Ollama принимает функции на Python в качестве инструментов, и имена, параметры и документация помогают модели выбрать подходящую функцию.
import json
from ollama import chat
def find_customer(name: str) -> dict:
"""Find a customer by business name and return its verified record."""
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
"""Return overdue invoices for a verified customer ID."""
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
TOOLS = {
"find_customer": find_customer,
"get_overdue_invoices": get_overdue_invoices,
}
Словарь реестра — это не просто удобство, а скорее список разрешённых действий. Даже если модель запрашивает функцию с именем delete_customer, приложение отклоняет запрос, поскольку такого инструмента нет в списке TOOLS.
Цикл агента
Именно цикл превращает вызов инструментов в действия агента. Он начинается с системного запроса:
SYSTEM_PROMPT = """
You are an invoice assistant.
Rules:
- Never invent a customer, invoice, email address, balance, or date.
- Use find_customer before requesting invoices.
- Only use customer IDs returned by tools.
- If a tool returns an error or no records, explain that clearly.
- You may draft communication, but you cannot send it.
"""
def run_agent(user_request: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_request},
] for _ in range(6):
response = chat(
model="qwen3",
messages=messages,
tools=list(TOOLS.values()),
) messages.append(response.message) if not response.message.tool_calls:
return response.message.content for call in response.message.tool_calls:
name = call.function.name
arguments = call.function.arguments if name not in TOOLS:
result = {"error": "tool_not_allowed"}
else:
try:
result = TOOLS[name](**arguments)
except (TypeError, ValueError) as error:
result = {
"error": "invalid_tool_arguments",
"detail": str(error),
} messages.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(result),
}
) return "I stopped because the task exceeded the maximum number of steps."
Лимит в шесть итераций установлен намеренно, поскольку агент никогда не должен бесконечно перерабатывать одну и ту же проблему. Цикл также рассматривает результаты работы инструментов как свежие доказательства. Модель сначала разрешает имя «Acme Plumbing», получает обратно cus_1042, и только после этого запрашивает счета для этого проверенного идентификатора.
Лучший промпт не стал решением
Галлюцинации побуждают разработчика добавить ещё одно предложение в системный промпт, например просьбу быть точным и никогда ничего не выдумывать. Такая инструкция помогает, но не является эффективным контролем. Надежное решение пришло благодаря реструктуризации:
- База данных остается источником истины.
- Модель может запрашивать данные и не может создать запись, которая считалась бы действительной.
- Имена инструментов находятся в списке разрешенных, а аргументы проверяются.
- У цикла есть жесткий лимит количества шагов.
- Чтение и запись данных разделены.
Подсказки влияют на поведение, а код обеспечивает их соблюдение.
Структурированный вывод на границе
Текст свободного формата подходит для дружелюбных писем, но представляет риск, когда другая часть приложения обрабатывает полученный ответ. Если агент должен вернуть экран с отзывом клиента, идентификаторами счетов-фактур, общей суммой и черновиком письма, модель Pydantic может определить такой формат данных:
from pydantic import BaseModel, Field
class ReminderReview(BaseModel):
customer_name: str
invoice_ids: list[str]
total_due: float = Field(ge=0)
draft_subject: str
draft_body: str
requires_approval: bool = True
Ollama принимает схему JSON через параметр format:
review_response = chat(
model="qwen3",
messages=messages,
format=ReminderReview.model_json_schema(),
)
review = ReminderReview.model_validate_json(
review_response.message.content
)
Это гарантирует, что ответ будет иметь ожидаемую структуру. Однако это не доказывает, что значения верны, поэтому invoice_ids и total_due всё равно сравниваются с реальными результатами инструмента перед появлением кнопки одобрения. Проверка схемы определяет, соответствует ли что-либо правильной структуре. Проверка бизнес-логики определяет, допускается ли это и является ли информация верной. Для работы в производственных условиях необходимы обе проверки.
Состояние — это не память
Длительные разговоры выявляют ещё одну проблему: модель забывает, какой клиент был выбран, или смешивает старый счёт с новым запросом. Причина в том, что весь текст чата рассматривается как состояние приложения. Разговор является полезным контекстом, но критически важные данные должны храниться в чётко определённой структуре:
task_state = {
"customer_id": "cus_1042",
"verified_invoice_ids": ["INV-2048"],
"approved_actions": [],
}
Модель может обобщать события, однако приложение обладает проверенными идентификаторами и разрешениями. Если диалог сокращается для соответствия размеру окна контекста, эти факты сохраняются.
Длина контекста также влияет на производительность, поскольку больший объем контекста требует больше памяти и занимает больше времени на обработку. Вместо того чтобы бесконечно передавать каждое сообщение и строку базы данных, передавайте минимальный набор информации, необходимый для текущего решения.
Локальная обработка не означает безопасности
Запуск модели через Ollama позволяет контролировать процесс инференса локально, но не обеспечивает безопасность остальной части приложения. Агент может всё равно утечь данные в логи, обратиться к небезопасному внутреннему концу, выполнить злонамеренные инструкции, скрытые в документе, или действовать с чрезмерными привилегиями. Применяются обычные принципы безопасности бэкенда:
- Предоставляйте каждому инструменту только те привилегии, которые ему нужны.
- Не включайте конфиденциальную информацию в промпты и исходный код.
- Рассматривайте полученный текст как ненадежные данные, никогда не как инструкции системы.
- Записывайте каждый вызов инструмента вместе с его результатом, временем задержки и окончательным решением.
- Удаляйте конфиденциальные поля из результатов до того, как они попадут в контекст модели.
- Требуйте одобрения для внешних или необратимых действий.
- Чтобы повторные попытки не приводили к дублированию, делайте важные записи идемпотентными.
Модель никогда не должна быть границей безопасности.
Тестирование
Демонстрации успешного сценария скрывают большинство сбоев агента, поэтому стоит создать небольшой набор для оценки. В него входят случаи с известным клиентом, у которого один счет опоздал, а у другого — нет; имя, не совпадающее ни с кем; два похожих названия компаний; попытка отправки без утверждения; инструкция, предназначенная для игнорирования правил; некорректно сформированный аргумент инструмента; а также инструмент, теряющий тайм-аут. Для каждого случая необходимо записать ожидаемые инструменты, допустимые действия и итоговый результат, а также перезапускать весь набор каждый раз, когда меняются описание модели, системного промпта или инструментов.
Самым полезным показателем является не то, насколько хорошо звучит окончательный ответ, а то, следовал ли агент правильной последовательности проверенных шагов.
Рабочая версия
После переработки исходный запрос оставляет след примерно в таком виде:
User request
→ find_customer(name="Acme Plumbing")
→ verified customer_id: cus_1042
→ get_overdue_invoices(customer_id="cus_1042")
→ verified invoice: INV-2048, $1,850, 18 days overdue
→ generate draft
→ wait for human approval
Это менее «волшебно», чем первая демонстрация, но гораздо полезнее. Вымышленный клиент заставляет агента остановиться и запросить уточнения. Никакие просроченные счета не отображаются как таковые. Запрос на использование неизвестного инструмента отклоняется приложением, а цикл, работающий слишком долго, безопасно останавливается. Всё это не выглядит впечатляюще, но именно оно способствует формированию доверия.
Заключительный урок
Ollama облегчает запуск мощных локальных моделей, но модель — это лишь одна из составляющих. Настоящим агентом является система вокруг неё: циклы, контракты инструментов, данные, являющиеся источником истины, слой валидации, машина состояний, границы разрешений и этап утверждения.
Для новичка-агента лучше обойтись без десяти инструментов и полного доступа к приложению. Начните с одной реальной проблемы и двух ограниченных инструментов с только чтительным доступом, записывайте каждое решение, добавляйте примеры сбоев, и предоставляйте права на запись только тогда, когда процесс чтения станет абсолютно надежным. Важный сдвиг заключается в замене вопроса о том, как сделать модель более автономной, на вопрос о том, как сделать каждый шаг наблюдаемым, проверяемым и безопасным. Автономность хороша для демонстраций, тогда как контролируемая автономность делает продукт полезным.
Связанные материалы
- Цикл работы локального агента для программирования: Aider, Ollama, Git worktrees и простой механизм отката — ограниченный рабочий процесс для локального агента, позволяющий сохранять каждую изменение в режиме отслеживания и восстанавливать их, с использованием Aider, Ollama, Git worktree, специализированных тестов и простого способа восстановления.