Локальний AI-агент з Ollama: вузькоспеціалізовані інструменти, перевірка та обмеження кількості кроків
Локальний AI-агент у 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, спеціалізованих тестів та простого механізму відновлення.