Перейти к содержимому

3.3 · Проект 1: tool calling + Telegram

Неделя 3 · Основы LLM · ⏱ ~8 ч (2 дня) · Проект

Здесь всё соединяется: владелец магазина пишет вопрос → модель выбирает функции → код их выполняет → бот отвечает. Именно эта склейка — цикл инструментов, история, ошибки, доступ, дата в промпте — отличает демо в ноутбуке от продукта. На собеседовании нас попросят нарисовать этот цикл и спросят, что будет, если модель зациклится, функция упадёт или бот напишет кто-то чужой.

⚠️ Только чтение. Никаких функций, которые меняют заказы, оформляют возвраты или выдают промокоды, и никаких запросов с ключами к платёжному провайдеру или службе доставки — только снимок базы.

Цикл tool_use → tool_result мы уже писали в гл. 2.6, обёртку call_llm() с ретраями и подсчётом стоимости — в гл. 2.7. Здесь — как встроить их в живой бот.

aiogram handler (bot/main.py) ← Telegram: доступ, "печатает…", отправка
│ question, history
▼
agent.answer() (bot/agent.py) ← цикл: call_llm ↔ run_tool, max 5 шагов
│ │
▼ ▼
llm_utils.call_llm() tools.run_tool(name, input) ← валидация Pydantic,
(AIL-11: retries, │ ошибки → is_error
timeout, usage) ▼
tools.get_sales(...) → SQLite (mode=ro, снимок)

Главное правило: agent.answer() ничего не знает о Telegram. Это функция «вопрос + история → ответ + метаданные». Тогда её можно прогнать из скрипта по списку вопросов (это наш eval), из консоли, а позже — из веб-интерфейса. Хендлер aiogram — тонкий: проверил доступ, достал историю, вызвал answer(), отправил текст.

Ниже цикл в формате Anthropic, как в главе 2.6 · Tool calling: модель вызывает 3 функции. Рабочая версия бота — на OpenAI-формате для Ollama и DeepSeek, она в разделе «DeepSeek и Ollama» ниже; логика та же, отличаются имена полей.

# bot/agent.py (illustrative, Anthropic format - kept for comparison)
MAX_STEPS = 5
async def answer(question: str, history: list[dict]) -> AgentResult:
messages = [*history, {"role": "user", "content": question}]
calls: list[str] = []
for _ in range(MAX_STEPS):
resp = await asyncio.to_thread(
call_llm,
system=build_system_prompt(),
messages=messages,
tools=tool_definitions(),
)
if resp.stop_reason != "tool_use":
text = "".join(b.text for b in resp.content if b.type == "text")
return AgentResult(text=text, tool_calls=calls)
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content: # may hold several tool_use blocks
if block.type == "tool_use":
calls.append(block.name)
results.append(run_tool(block.id, block.name, block.input))
messages.append({"role": "user", "content": results})
return AgentResult(text="Не получилось ответить за 5 шагов — стоит переформулировать вопрос.",
tool_calls=calls)

Детали, которые легко упустить:

  • Все tool_result одного шага — в одном user-сообщении, по одному на каждый tool_use с совпадающим tool_use_id. Модель может попросить две функции сразу («какие заказы сейчас в работе и какая выручка за неделю»).
  • Ответ ассистента с tool_use добавляется в историю целиком (resp.content), включая текст перед вызовом.
  • Лимит шагов обязателен: без него модель, получающая ошибку, может вызывать одну и ту же функцию бесконечно, и каждый шаг стоит денег.
  • asyncio.to_thread нужен, если наш call_llm() синхронный: синхронный HTTP-запрос внутри async-хендлера заблокирует event loop, и бот перестанет отвечать на всё остальное на 5–20 секунд. Альтернатива — асинхронный клиент AsyncAnthropic и async def call_llm. Аналогия из других языков: синхронно читать большой файл (например, fs.readFileSync в Node.js) прямо в обработчике HTTP-запроса.

В цикле выше каждый запрошенный моделью вызов проходит через run_tool. Вход от модели — недоверенный, а run_tool — единственная точка, где имя и аргументы превращаются в вызов кода:

def run_tool(tool_use_id: str, name: str, raw_input: dict) -> dict:
entry = TOOLS.get(name)
if entry is None:
return _error(tool_use_id, f"Unknown tool {name}")
args_model, fn, _ = entry
try:
args = args_model.model_validate(raw_input)
with closing(open_readonly(SHOP_DB)) as conn: # fresh read-only connection, closed after the call
result = fn(conn, **args.model_dump())
return {"type": "tool_result", "tool_use_id": tool_use_id,
"content": result.model_dump_json()}
except ValidationError as e:
return _error(tool_use_id, f"Invalid arguments: {e.errors()}")
except Exception:
log.exception("tool %s failed", name)
return _error(tool_use_id, "Internal error while reading data")
def _error(tool_use_id, msg):
return {"type": "tool_result", "tool_use_id": tool_use_id,
"content": msg, "is_error": True}
  • Реестр TOOLS — белый список. Модель не может вызвать то, чего в нём нет.
  • Ошибка функции возвращается модели как is_error, а не пользователю как traceback. Модель часто сама исправит аргументы (period="2 weeks" → "week") или честно скажет, что данных нет.
  • Внутренние детали (пути, SQL, стек) в content не отдаём — они уйдут в контекст и могут оказаться в ответе.
  • Зачем closing (из contextlib): open_readonly из гл. 3.1 возвращает обычный sqlite3.Connection, а with conn: в Python управляет транзакцией и не закрывает соединение. closing(...) гарантирует conn.close() даже при исключении.
  • get_product_interest из гл. 3.2 получает ещё и соединение со снимком analytics.db: run_tool открывает его так же, через closing(open_readonly(ANALYTICS_DB)), и передаёт вторым аргументом.
  • Соединение SQLite по умолчанию нельзя использовать из другого потока (check_same_thread=True). Открывать соединение на каждый вызов — дешево и снимает проблему.

Цикл и граница доверия готовы. Остаётся объяснить модели, кто она, какое сейчас время и каких правил держаться, — это системный промпт, который build_system_prompt() собирает перед каждым запросом:

Роль: аналитик интернет-магазина «Полка», отвечает владельцу. Ответы по-русски, коротко.
Сейчас: {now:%Y-%m-%d %H:%M} ({tz}), день недели: {weekday}.
Правила:
- Цифры брать только из результатов инструментов. Не выдумывать и не округлять
сильнее, чем пришло.
- Прибыль посчитана по ценам, себестоимости, скидкам и комиссиям оплаты;
фактические расходы на доставку не учтены — говорить об этом в ответах про прибыль.
- Статистика просмотров и добавлений в корзину хранится 30 дней.
- Бот только читает данные. Он не может менять заказы, оформлять возвраты
или выдавать промокоды. На такие просьбы — ответ, что бот только для чтения.
- Если вопрос неоднозначен (подходят несколько товаров) — переспросить.
- Формат: обычный текст без Markdown-таблиц, суммы с 2 знаками после запятой.

Почему дата обязательна: модель не знает «сегодня». Без даты «за неделю» она либо выберет параметр наугад, либо, если в инструментах есть явные даты, посчитает от даты из своего обучения. С period: Literal["day","week","month"] это менее критично (окно считает код), но вопросы «что было в понедельник» или «с начала месяца» без даты в промпте не решаются: модель не поймёт, какое окно выбрать и как объяснить владельцу, за какие дни цифры. Поэтому дата, время и часовой пояс владельца — в каждом системном промпте.

Строка с датой меняется каждую минуту, а prompt caching (гл. 2.7) кэширует префикс. Если понадобится кэшировать длинный промпт — дату ставим в конец системного промпта или в user-сообщение, а неизменную часть — в начало.

Один вопрос — один прогон цикла. Но владелец пишет и уточнения вроде «а за месяц?», которые без предыдущего вопроса бессмысленны. Модель не помнит прошлые запросы (гл. 1.6) — историю передаём сами. Для бота:

from collections import defaultdict, deque
HISTORY_TURNS = 6 # user+assistant pairs
_history: dict[int, deque] = defaultdict(lambda: deque(maxlen=HISTORY_TURNS * 2))
  • Храним только текст вопроса и финальный текст ответа, не промежуточные tool_use/tool_result. Это компактно, и обрезка по maxlen никогда не разорвёт пару tool_use→tool_result (API отклонит историю, где tool_result без своего tool_use).
  • История должна начинаться с user. deque пар user/assistant это гарантирует, если добавлять их парой и только после успешного ответа.
  • Плата: на follow-up «а по кружкам?» модель вызовет инструмент заново — цифр прошлого шага она не видит. Для этого бота это нормально и даже хорошо: цифры всегда свежие из функции.
  • Команда /reset очищает историю. История в памяти процесса и пропадает при перезапуске — для бота одного владельца это нормально.

answer() и история не знают о Telegram. Последний слой — тонкий хендлер aiogram, который пускает только владельца, показывает «печатает…», вызывает answer() и отправляет ответ:

# bot/main.py (illustrative, aiogram 3)
router = Router()
router.message.filter(F.chat.type == "private",
F.from_user.id == settings.owner_telegram_id)
@router.message(CommandStart())
async def start(message: Message):
await message.answer("Вопросы про магазин: выручка, заказы в работе, интерес к товарам.")
@router.message(Command("reset"))
async def reset(message: Message):
_history.pop(message.chat.id, None)
await message.answer("История очищена.")
@router.message(F.text)
async def question(message: Message, bot: Bot):
await bot.send_chat_action(message.chat.id, ChatAction.TYPING)
try:
result = await answer(message.text, list(_history[message.chat.id]))
except Exception:
log.exception("answer failed")
await message.answer("Не получилось ответить: сервис модели недоступен. "
"Можно повторить через минуту.")
return
_history[message.chat.id].extend([
{"role": "user", "content": message.text},
{"role": "assistant", "content": result.text},
])
await message.answer(result.text[:4000])
async def main():
bot = Bot(token=settings.telegram_bot_token)
dp = Dispatcher()
dp.include_router(router)
await dp.start_polling(bot)
  • Фильтр на роутере применяется ко всем его хендлерам. Чужие сообщения в него не попадают — бот молчит. Молчать лучше, чем отвечать «доступ запрещён»: так мы не подтверждаем, что бот что-то умеет.
  • Telegram ID не секрет, но в код его не зашиваем — в .env, рядом с токеном. Свой ID можно узнать, залогировав message.from_user.id один раз.
  • Отдельный бот. Новый токен у @BotFather; токен другого бота (например, бота уведомлений магазина) не используем. Один токен опрашивает (polling) только один процесс — иначе ошибка Conflict: terminated by other getUpdates request.
  • Polling, не webhook: бот работает на нашей машине, публичный URL не нужен.
  • Лимит сообщения Telegram — 4096 символов: обрезаем или режем на части.
  • Parse mode не включаем: Markdown от модели с непарной _ или * приводит к ошибке отправки. Простой текст надёжнее.

Ошибки встречаются на каждом слое: в функции, в API модели, в самом цикле. Сведём в одну таблицу, где каждая ловится и что в итоге видит владелец:

Что упало Что делает код Что видит пользователь
Функция: неверные аргументы is_error модели модель исправилась и ответила
Функция: исключение лог + is_error модели «данные сейчас недоступны» (сказала модель)
API: 429/5xx/529 ретраи в call_llm() (гл. 2.7) ничего, если ретрай помог
API: все ретраи исчерпаны, таймаут исключение → хендлер «сервис модели недоступен, можно повторить позже»
Лимит шагов фолбэк-текст «стоит переформулировать вопрос»
Ответ пустой (только tool_use без текста) проверка if not text «не получилось сформулировать ответ»

Пользователь никогда не видит traceback, а в логе всегда есть traceback.

Бот собран; осталось проверить его на questions.md из гл. 3.1. Критерий готовности «12 из 15» проверяется скриптом, а не на глаз в Telegram. scripts/run_questions.py прогоняет answer() по каждому вопросу из questions.md (без истории), печатает вызванные инструменты и ответ. Мы сравниваем с ожидаемым (ручной SQL из гл. 3.1) и ставим ✓/✗. Это прототип evals, которым посвящена неделя 6: скрипт и результаты прогонов сохраняем (результаты на снимке живой базы — только локально, в logs/, не в git).

Напоминание из главы 3.1: снимок «живой» базы (data/shop.db, в нём персональные данные покупателей) видит только локальная модель Ollama; DeepSeek (серверы в Китае), а для сравнения и Claude/OpenAI, работают только с демо-базой data/demo_shop.db. Данные выдуманы, но обращаемся с ними как с настоящими. assert_data_policy() вызывается при старте бота и run_questions.py.

Один код для Ollama и DeepSeek — меняются только base_url, ключ и модель из .env:

# bot/agent.py (illustrative, OpenAI Chat Completions format: Ollama and DeepSeek)
import json
from openai import AsyncOpenAI
provider, base_url, api_key = llm_settings() # from bot/config.py
client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=120, max_retries=2)
# DeepSeek: thinking mode is on by default; the bot runs it off (see below)
EXTRA = {"extra_body": {"thinking": {"type": "disabled"}}} if provider == "deepseek" else {}
async def answer(question: str, history: list[dict]) -> AgentResult:
messages = [{"role": "system", "content": build_system_prompt()},
*history, {"role": "user", "content": question}]
calls: list[str] = []
for _ in range(MAX_STEPS):
resp = await client.chat.completions.create(
model=settings.llm_model, messages=messages,
tools=tool_definitions("openai"), **EXTRA)
msg = resp.choices[0].message
if not msg.tool_calls: # final answer
return AgentResult(text=msg.content or "", tool_calls=calls)
messages.append(msg) # the whole assistant message, with tool_calls
for tc in msg.tool_calls: # may be several in one step
calls.append(tc.function.name)
messages.append(run_tool_openai(tc))
return AgentResult(text="Не получилось ответить за 5 шагов — стоит переформулировать вопрос.",
tool_calls=calls)
def run_tool_openai(tc) -> dict:
try:
raw = json.loads(tc.function.arguments or "{}") # arguments arrive as a JSON string
except json.JSONDecodeError:
content, is_error = "Arguments are not valid JSON", True
else:
content, is_error = execute_tool(tc.function.name, raw) # provider-neutral core
# no is_error flag in this format: mark the error in the text itself
return {"role": "tool", "tool_call_id": tc.id,
"content": f"ERROR: {content}" if is_error else content}

Для краткости клиент AsyncOpenAI здесь вызывается напрямую, с ретраями самого SDK (max_retries=2), а settings — объект с переменными окружения из bot/config.py. В проекте это место для call_llm() из гл. 2.7: свой цикл ретраев, логирование токенов и стоимости (они понадобятся в гл. 3.4). Если call_llm() синхронный, его вызываем через asyncio.to_thread, как в теории.

Что изменилось по сравнению с Anthropic-версией:

  • Системный промпт — первое сообщение с role: "system", а не отдельный параметр.
  • Признак вызова — непустой message.tool_calls. Проверяем его, а не finish_reason == "tool_calls": так надёжнее на разных серверах.
  • Аргументы приходят JSON-строкой, а не словарём. Маленькая локальная модель может прислать невалидный JSON — это такая же ошибка аргументов, её возвращаем модели.
  • Каждый результат — отдельное сообщение role: "tool" с tool_call_id (у Anthropic — все tool_result в одном user-сообщении).
  • Флага is_error нет — пишем ERROR: ... в content.
  • run_tool из теории полезно разрезать: ядро execute_tool(name, raw) -> (content, is_error) (белый список, Pydantic, read-only соединение) не знает о провайдере, а два тонких адаптера упаковывают результат в tool_result (Anthropic) или role: "tool" (OpenAI-формат). Тесты из практики пишем на ядро.
  • Асинхронный клиент AsyncOpenAI — и asyncio.to_thread больше не нужен.

По документации thinking mode (ноябрь 2026): режим размышлений у deepseek-flash и deepseek-v4-pro включён по умолчанию, и если в запросе есть tools, поле reasoning_content нужно возвращать API во всех следующих запросах — иначе ошибка 400. А наш бот хранит в истории только текст вопросов и ответов. Плюс в thinking-режиме tool_choice="required" и выбор конкретной функции тоже дают 400, а temperature молча игнорируется. Для бота с 4 простыми функциями размышления не нужны, поэтому extra_body={"thinking": {"type": "disabled"}}. Чтобы сравнить с thinking, нужно хранить в истории полные сообщения ассистента (messages.append(msg) целиком, как в цикле выше), а не только текст. Как именно API реагирует на историю, где старые ходы урезаны до текста, документация прямо не описывает — проверим сами на демо-базе.

  • Окно. По умолчанию 4k токенов на машинах с < 24 ГБ видеопамяти (Context length). Для бота ставим 8–16k: ползунок в настройках приложения Ollama или OLLAMA_CONTEXT_LENGTH=16384 для сервера. Проверка — ollama ps: колонка CONTEXT показывает окно, PROCESSOR — помещается ли модель в GPU (нужно 100% GPU; если часть на CPU — окно или модель слишком большие для нашей памяти).
  • tool_choice в /v1 не поддерживается (OpenAI compatibility) — заставить модель вызвать функцию нельзя. Обходной путь: если в ответе есть цифры, а tool_calls пуст, один раз повторяем запрос с добавленным user-сообщением «Use the tools to get the numbers, do not answer from memory» и помечаем случай в логе.
  • Первый запрос медленный: модель грузится в память. Потом она живёт keep_alive (по умолчанию 5 минут) и выгружается. Для бота одного владельца это нормально; keep_alive бесконечным не ставим на машине, где работает что-то ещё важное.

Какие модели Ollama справляются с tool calling для этого бота

Заголовок раздела «Какие модели Ollama справляются с tool calling для этого бота»

Цифры ниже — не прогон на конкретной машине, а публичный бенчмарк плюс ограничения памяти. Решает наш прогон 15 вопросов. Бенчмарк — Berkeley Function Calling Leaderboard (BFCL), обновление от 2026-04-12. Колонки: общий балл (в нём много многоходовых агентных сценариев, которые нашему боту не нужны), Live (реальные пользовательские функции) и Non-Live (простые, множественные и параллельные вызовы) — две последние ближе всего к нашему боту.

Тег Ollama Скачать На машине с 8 ГБ рядом с другими программами BFCL: overall / live / non-live Вывод
qwen3:4b-instruct-2507-q4_K_M 2.5 ГБ да 35.68% / 76.39% / 87.88% (Qwen3-4B-Instruct-2507, FC) основной кандидат
qwen3:8b (q4_K_M) 5.2 ГБ впритык: только с закрытым браузером и без тяжёлых программ 42.57% / 80.53% / 87.58% (Qwen3-8B, FC) сильнее на многоходовых; пробуем, если 4B не дотянет
llama3.1:8b 4.9 ГБ впритык 25.83% / 70.76% / 84.00% (Llama-3.1-8B-Instruct, Prompt) заметно слабее Qwen того же размера
qwen3:1.7b 1.4 ГБ да 28.41% / 74.61% / 82.92% (Qwen3-1.7B, FC) нижняя граница для сравнения
gpt-oss:20b 14 ГБ нет (по странице модели нужно от 16 ГБ) в выгрузке BFCL не нашлось для машин с 16+ ГБ

Выводы для этого бота:

  • 4B и 8B почти не различаются на одиночных и параллельных вызовах (Non-Live ~88%), разница — на длинных диалогах. У нас 1–2 вызова на вопрос, поэтому 4B — разумный старт, а 8B на 8 ГБ памяти — дорогое удовольствие (на 16 ГБ 7–8B уже помещаются спокойно).
  • Тег qwen3:4b-instruct-… без размышлений, отвечает быстрее. Обычный qwen3:8b — гибрид с режимом thinking; в нативном API его выключают think: false, для /v1 это стоит проверить в документации модели.
  • Типичные ошибки маленьких моделей, которые увидим в прогоне: аргумент вне enum (period="7d"), ответ без вызова инструмента с выдуманными цифрами, повторный вызов той же функции с теми же аргументами, ответ не по-русски. Лекарства — ошибка валидации в ответ модели, MAX_STEPS, повтор с подсказкой (выше), правило языка в системном промпте и детектор повторного вызова (одинаковые имя+аргументы дважды подряд → прервать цикл).
  • Эталон сверху — deepseek-flash на демо-базе: если и он не набирает 12/15, проблема в функциях или вопросах, а не в размере модели.

Сравнение: что этот бот использует у провайдеров

Заголовок раздела «Сравнение: что этот бот использует у провайдеров»
Возможность Claude OpenAI DeepSeek Ollama
Признак вызова stop_reason == "tool_use", блоки tool_use message.tool_calls как OpenAI как OpenAI в /v1; в нативном /api/chat — message.tool_calls
Аргументы словарь input JSON-строка JSON-строка /v1: JSON-строка; нативно — объект
Куда результат tool_result в user-сообщении, tool_use_id role: "tool", tool_call_id то же /v1: как OpenAI; нативно — role: "tool", tool_name
Флаг ошибки is_error: true нет, текстом нет, текстом нет, текстом
Несколько вызовов за шаг да да tool_calls — массив; отдельно параллельность в доке не описана да (пример в доке)
tool_choice есть auto/required/none/функция то же, но required и выбор функции → 400 в thinking-режиме в /v1 не поддерживается
Thinking + tools см. главу 2.6 — reasoning_content возвращать во всех следующих запросах, иначе 400 у гибридных моделей параметр think
Ошибки, которые ретраить 429, 5xx, 529 (глава 2.7) 429, 5xx 429, 500, 503; 402 (нет баланса) и 401 — не ретраить Connection refused — сервер Ollama не запущен; ретраи не помогут
  • Основной путь: Ollama (qwen3:4b-instruct-2507-q4_K_M или то, что победит в нашем сравнении) — сначала на демо-базе, потом на снимке живой базы. Только это сочетание попадает в итоговый критерий «12 из 15 на снимке».
  • DeepSeek — на демо-базе: эталон качества и проверка, что бот провайдеро-независим.
  • Claude / OpenAI — по желанию, на демо-базе, для сравнения форматов (Anthropic-версия цикла из теории).
  1. Перечитываем Anthropic: Tool use → разделы про обработку результатов инструментов, is_error, параллельные вызовы, tool_choice (≈30 мин). Сверяем свой цикл из гл. 2.6 с документацией: формат tool_result, порядок блоков.
  2. aiogram → Dispatcher (start_polling), Router (include_router, фильтры на уровне роутера), Filters (magic filter F, Command, CommandStart) (≈45 мин). Обратим внимание, как фильтры применяются к роутеру целиком.
  3. Там же → раздел Middlewares (≈20 мин): доступ можно сделать и middleware; сравниваем с фильтром на роутере и выбираем один способ.
  4. Telegram Bot API → sendMessage (лимит текста, parse_mode), sendChatAction (≈15 мин).
  5. asyncio: Coroutines and Tasks → asyncio.to_thread (≈10 мин). Берём: как не блокировать event loop синхронным кодом.
  6. Anthropic: Errors → коды ошибок (≈10 мин) — освежить, что ретраить (гл. 2.7).
  7. (По желанию) Anthropic: Building effective agents → начало, про workflows vs agents (≈20 мин). Наш бот — самый простой агент: один цикл, узкие инструменты.
  8. Ollama: Tool calling → разделы про параллельные вызовы и multi-turn цикл (≈15 мин). Сравниваем их цикл while True с нашим MAX_STEPS.
  9. Ollama: Context length и Ollama FAQ → окно по умолчанию, ollama ps, keep_alive (≈10 мин).
  10. DeepSeek: Tool Calls и Thinking Mode → как вернуть результат (role: "tool", tool_call_id), как выключить thinking, правило про reasoning_content (≈20 мин).
  11. DeepSeek: Error Codes и Rate Limit → что ретраить, а что нет (402, 401); keep-alive пустыми строками при нагрузке (≈10 мин).
  12. BFCL → находим в таблице Qwen3-4B/8B, Llama-3.1-8B; смотрим, что значат категории Live, Non-Live, Multi Turn (≈15 мин). Берём: общий балл ≠ пригодность для нашей задачи.
  • Модель может «ответить» без вызова инструмента, выдумав цифры. Правило в промпте «цифры только из инструментов» плюс проверка в run_questions.py: если в ответе есть число, а tool_calls пустой — подозрительно.
  • Неверный выбор периода. «За прошлую неделю» ≠ «за последние 7 дней». Если в коде только скользящие окна — промпт должен велеть модели сказать, какое окно она взяла.
  • Синхронный вызов в async-хендлере — бот «зависает» для всех апдейтов. Симптом: второй вопрос обрабатывается только после первого.
  • История с обрезанной парой tool_use/tool_result → ошибка 400 от API на каждом следующем сообщении. Решение — хранить только текст.
  • Бот в группе. Если владельца добавят в группу с ботом, фильтр по from_user.id пропустит его сообщения и ответ увидят все. F.chat.type == "private" закрывает это.
  • Логи — это реальные данные. Вопросы и ответы с выручкой и заказами магазина пишутся в logs/, которые в .gitignore.
  • Prompt injection через данные здесь маловероятна (данные — из собственной базы), но правило то же: результат инструмента — это данные, а не инструкции. Привычка пригодится в RAG (недели 4–5) и у агентов (неделя 9).
  • Ни одного нового инструмента «на всякий случай». Список инструментов в этом проекте — 4 функции из гл. 3.2. Если в голове возникает «а пусть бот ещё сможет оформить возврат» — нет.
  • Стоимость. Каждый шаг цикла повторно отправляет весь контекст: системный промпт + схемы 4 инструментов + история + результаты. Ответ в 3 шага стоит примерно втрое больше одного. В гл. 3.4 мы это измерим. На Ollama денег это не стоит, но стоит секунд: каждый шаг — новая обработка всего промпта на нашей машине.
  • Сравнение моделей на снимке живой базы. Соблазн «прогоню DeepSeek на настоящих вопросах, для честности» — нет. Сравнение — на демо-базе; на снимке — только Ollama.
  • Локальная модель и другие программы на одной машине. Прогон 15 вопросов на 8B-модели может уйти в swap и замедлить всё остальное, что работает рядом. Прогоняем, когда машина не занята важной работой.
  • DeepSeek в thinking-режиме + история только из текста → риск 400 на втором сообщении. Отсюда thinking: disabled.
  • Бот-«эхо» с доступом. bot/main.py с фильтром на роутере по OWNER_TELEGRAM_ID и типу чата. Проверяем: наш аккаунт получает эхо, второй аккаунт (или знакомый) — тишину. Токен и ID — из .env через python-dotenv.
  • bot/agent.py. answer(question, history) -> AgentResult(text, tool_calls, usage...) на OpenAI-формате по скелету из раздела «DeepSeek и Ollama» (AsyncOpenAI, провайдер из .env), MAX_STEPS = 5, build_system_prompt() с датой, временем и часовым поясом из TIMEZONE (zoneinfo.ZoneInfo). Для DeepSeek — thinking: disabled.
  • execute_tool + адаптеры. Ядро execute_tool(name, raw) -> (content, is_error) с реестром и валидацией Pydantic; адаптер run_tool_openai (основной) и, для сравнения, run_tool в формате Anthropic с is_error. Тесты pytest на ядро: неизвестный инструмент, невалидные аргументы, невалидный JSON в arguments, исключение внутри функции — во всех случаях ошибка без traceback в content.
  • Окно Ollama. Поднимаем контекст до 8–16k (ползунок в приложении или OLLAMA_CONTEXT_LENGTH), убеждаемся в ollama ps, что модель 100% GPU.
  • Консольный режим. python -m bot.agent "какая выручка за неделю?" — отладка без Telegram. Печатаем шаги: какой инструмент, с какими аргументами, сколько символов результата.
  • Подключить агента к хендлеру, «печатает…», обрезка до 4000 символов, /start, /reset.
  • История: последние 6 пар в deque. Проверяем follow-up: «какая выручка за неделю?» → «а за месяц?» → «а какая категория продавалась лучше всего?».
  • Ошибки. Проверяем руками четыре сценария: (1) переименовать файл снимка базы → бот отвечает человеческой фразой, в логе traceback; (2) закрыть приложение Ollama → «сервис модели недоступен»; (3) с DeepSeek на демо-базе — заведомо неверный ключ (401) → то же сообщение, без ретраев; (4) написать «оформи возврат по заказу 1042» → вежливый отказ, ни одного вызова инструмента записи (их нет).
  • scripts/run_questions.py. Аргументы --provider, --model, --db; первым делом assert_data_policy. Вывод: # | вопрос | инструменты | ответ | время. Отмечаем ✓/✗ по ожидаемым ответам. Результат сохраняем в logs/eval-<model>-YYYY-MM-DD.md (в git не попадает).
  • Выбор локальной модели (демо-база). Прогоняем 15 вопросов на deepseek-flash (эталон), qwen3:4b-instruct-2507-q4_K_M и, если память позволяет, qwen3:8b. Таблица в logs/model-compare.md: модель | ✓ из 15 | неверный инструмент/аргументы | цифры без вызова | медиана времени. Выбираем модель для снимка живой базы, записываем тег в .env.
  • Доводим до 12/15 на снимке с Ollama. Типичные исправления: описание инструмента, формулировка правила в системном промпте, параметр периода, резолвер товара, повтор с подсказкой при ответе без инструментов. После каждого изменения — полный прогон, а не один вопрос.
  • (Для сравнения, по желанию, демо-база) Тот же прогон через Anthropic-цикл из теории. Записываем, что пришлось поменять в коде, кроме клиента.
  • Бот отвечает только владельцу и только в личке; чужие сообщения игнорируются.
  • Модель вызывает инструменты из реестра, ответы содержат цифры только из результатов функций.
  • История последних N сообщений работает, /reset её очищает.
  • Падение функции, модели или таймаут дают человеческое сообщение, traceback только в логе.
  • run_questions.py показывает не меньше 12 из 15 правильных ответов на снимке базы data/shop.db с локальной моделью Ollama; вопросы «вне возможностей» получают отказ, а не выдумку.
  • Есть logs/model-compare.md: DeepSeek и 1–2 локальные модели на демо-базе, выбранный тег записан в .env.
  • Облачный провайдер не запускается на снимке живой базы — assert_data_policy стоит в боте и в run_questions.py.

Вопрос 1

Модель вернула два блока tool_use в одном ответе. Как правильно отправить результаты?

  • a) два отдельных user-сообщения
  • b) одно user-сообщение с двумя tool_result
  • c) только первый результат
  • d) assistant-сообщение с результатами.

b. Все результаты одного шага — в одном user-сообщении, каждый со своим tool_use_id.

Вопрос 2

Зачем MAX_STEPS в цикле агента?

Чтобы модель не зациклилась на ошибках или повторных вызовах: каждый шаг стоит денег и времени. После лимита — фолбэк-ответ.

Вопрос 3

Что не так в этом хендлере?

@router.message(F.text)
async def question(message: Message):
resp = anthropic_client.messages.create(...) # sync client
await message.answer(resp.content[0].text)

Синхронный клиент блокирует event loop — бот не обрабатывает другие апдейты во время запроса; нет обработки ошибок; content[0] может быть tool_use, а не текстом; нет инструментов и цикла.

Вопрос 4

Почему лучше хранить в истории только текст вопросов и ответов, а не блоки tool_use/tool_result?

Компактнее, и обрезка истории не разорвёт пару tool_use→tool_result, из-за которой API вернёт 400. Цифры на follow-up модель получит заново из функции.

Вопрос 5

Функция упала с исключением. Что получает модель и что — пользователь?

Модель — tool_result с is_error: true и коротким сообщением без деталей; пользователь — человеческий ответ модели («данные сейчас недоступны»); traceback — только в логе.

Вопрос 6

Почему в системный промпт нужно добавлять текущую дату, если period считается в коде?

Модель не знает «сегодня»: без даты она не решит, что такое «понедельник», «с начала месяца», и может неверно выбрать период или интерпретировать результат.

Вопрос 7

Как ограничить бота только владельцем в aiogram 3 и почему бот должен молчать для чужих?

Фильтр на роутере: router.message.filter(F.chat.type == "private", F.from_user.id == OWNER_ID) (или middleware). Молчание не раскрывает, что бот что-то умеет, и не тратит токены на чужие запросы.

Вопрос 8

Что не так в этом системном промпте?

Роль: помощник владельца магазина. Помогает с заказами: может их анализировать,
менять, оформлять возвраты и выдавать промокоды по просьбе пользователя.

Обещает возможность менять заказы, оформлять возвраты и выдавать промокоды — это противоречит архитектуре проекта (только чтение) и подталкивает модель обещать действия; нет даты, правил про цифры из инструментов и ограничений данных.

Вопрос 9

Бот работает на двух машинах с одним токеном. Что произойдёт?

Telegram отдаст апдейты только одному процессу, второй получит ошибку Conflict: terminated by other getUpdates request; ответы будут непредсказуемо приходить с одной из машин.

Вопрос 10

Почему answer() не должна импортировать ничего из aiogram?

Чтобы её можно было вызывать из скрипта оценки, консоли и тестов без Telegram; хендлер остаётся тонким транспортным слоем.

Вопрос 11

Модель ответила «выручка за неделю 48 320.00», а tool_calls пустой. Что это значит и как это ловить?

Модель выдумала цифру (галлюцинация). Ловить в run_questions.py: число в ответе при пустом tool_calls — флаг; лечить правилом в промпте и, при необходимости, tool_choice (там, где он есть).

Вопрос 12

Что не так в этом коде для DeepSeek/Ollama?

msg = resp.choices[0].message
for tc in msg.tool_calls:
result = execute_tool(tc.function.name, tc.function.arguments)
messages.append({"role": "user", "content": result})

arguments — JSON-строка, её надо json.loads (и обработать невалидный JSON); ассистентское сообщение с tool_calls не добавлено в историю; результат должен идти как role: "tool" с tool_call_id, а не user; нет проверки, что tool_calls не None.

Вопрос 13

Бот на DeepSeek без extra_body отвечает на первый вопрос, а на втором (с историей) получает 400. Почему и как исправить?

У DeepSeek thinking включён по умолчанию, и с tools поле reasoning_content нужно возвращать в следующих запросах, а история хранит только текст. Исправление для бота — extra_body={"thinking": {"type": "disabled"}} (или хранить полные сообщения ассистента).

Вопрос 14

Почему в таблице BFCL общий балл Qwen3-4B заметно ниже, чем Qwen3-8B, но для этого бота 4B — разумный старт?

Общий балл BFCL тянут вниз многоходовые агентные сценарии; на одиночных и параллельных вызовах (Non-Live) 4B и 8B почти равны (~88%). У бота 1–2 вызова на вопрос, а 4B в 2 раза меньше по памяти — важно на 8 ГБ. Окончательно решает прогон своих 15 вопросов.

Вопрос 15

На Ollama нельзя tool_choice="required". Как тогда бороться с ответами без вызова инструмента?

Правило в системном промпте; детектор «цифры без вызова» и один повтор с подсказкой «Use the tools…»; узкие понятные описания инструментов; если не помогает — модель покрупнее.

В гл. 3.4 «Проект 1: логирование стоимости, README, GitHub» начинаем логировать токены, стоимость и время каждого запроса, добавляем /stats и доводим репозиторий до публичного вида на демо-базе.