3.3 · Проект 1: tool calling + Telegram
Неделя 3 · Основы LLM · ⏱ ~8 ч (2 дня) · Проект
Зачем это AI-инженеру
Заголовок раздела «Зачем это AI-инженеру»Здесь всё соединяется: владелец магазина пишет вопрос → модель выбирает функции → код их выполняет → бот отвечает. Именно эта склейка — цикл инструментов, история, ошибки, доступ, дата в промпте — отличает демо в ноутбуке от продукта. На собеседовании нас попросят нарисовать этот цикл и спросят, что будет, если модель зациклится, функция упадёт или бот напишет кто-то чужой.
⚠️ Только чтение. Никаких функций, которые меняют заказы, оформляют возвраты или выдают промокоды, и никаких запросов с ключами к платёжному провайдеру или службе доставки — только снимок базы.
Цикл tool_use → tool_result мы уже писали в гл. 2.6, обёртку call_llm() с ретраями и подсчётом стоимости — в гл. 2.7. Здесь — как встроить их в живой бот.
1. Слои приложения
Заголовок раздела «1. Слои приложения»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(), отправил текст.
2. Цикл агента
Заголовок раздела «2. Цикл агента»Ниже цикл в формате 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-запроса.
3. run_tool: граница доверия
Заголовок раздела «3. run_tool: граница доверия»В цикле выше каждый запрошенный моделью вызов проходит через 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). Открывать соединение на каждый вызов — дешево и снимает проблему.
4. Системный промпт
Заголовок раздела «4. Системный промпт»Цикл и граница доверия готовы. Остаётся объяснить модели, кто она, какое сейчас время и каких правил держаться, — это системный промпт, который build_system_prompt() собирает перед каждым запросом:
Роль: аналитик интернет-магазина «Полка», отвечает владельцу. Ответы по-русски, коротко.Сейчас: {now:%Y-%m-%d %H:%M} ({tz}), день недели: {weekday}.
Правила:- Цифры брать только из результатов инструментов. Не выдумывать и не округлять сильнее, чем пришло.- Прибыль посчитана по ценам, себестоимости, скидкам и комиссиям оплаты; фактические расходы на доставку не учтены — говорить об этом в ответах про прибыль.- Статистика просмотров и добавлений в корзину хранится 30 дней.- Бот только читает данные. Он не может менять заказы, оформлять возвраты или выдавать промокоды. На такие просьбы — ответ, что бот только для чтения.- Если вопрос неоднозначен (подходят несколько товаров) — переспросить.- Формат: обычный текст без Markdown-таблиц, суммы с 2 знаками после запятой.Почему дата обязательна: модель не знает «сегодня». Без даты «за неделю» она либо выберет параметр наугад, либо, если в инструментах есть явные даты, посчитает от даты из своего обучения. С period: Literal["day","week","month"] это менее критично (окно считает код), но вопросы «что было в понедельник» или «с начала месяца» без даты в промпте не решаются: модель не поймёт, какое окно выбрать и как объяснить владельцу, за какие дни цифры. Поэтому дата, время и часовой пояс владельца — в каждом системном промпте.
Строка с датой меняется каждую минуту, а prompt caching (гл. 2.7) кэширует префикс. Если понадобится кэшировать длинный промпт — дату ставим в конец системного промпта или в user-сообщение, а неизменную часть — в начало.
5. История диалога
Заголовок раздела «5. История диалога»Один вопрос — один прогон цикла. Но владелец пишет и уточнения вроде «а за месяц?», которые без предыдущего вопроса бессмысленны. Модель не помнит прошлые запросы (гл. 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очищает историю. История в памяти процесса и пропадает при перезапуске — для бота одного владельца это нормально.
6. aiogram: доступ, «печатает…», отправка
Заголовок раздела «6. aiogram: доступ, «печатает…», отправка»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 от модели с непарной
_или*приводит к ошибке отправки. Простой текст надёжнее.
7. Ошибки: что видит пользователь
Заголовок раздела «7. Ошибки: что видит пользователь»Ошибки встречаются на каждом слое: в функции, в API модели, в самом цикле. Сведём в одну таблицу, где каждая ловится и что в итоге видит владелец:
| Что упало | Что делает код | Что видит пользователь |
|---|---|---|
| Функция: неверные аргументы | is_error модели |
модель исправилась и ответила |
| Функция: исключение | лог + is_error модели |
«данные сейчас недоступны» (сказала модель) |
| API: 429/5xx/529 | ретраи в call_llm() (гл. 2.7) |
ничего, если ретрай помог |
| API: все ретраи исчерпаны, таймаут | исключение → хендлер | «сервис модели недоступен, можно повторить позже» |
| Лимит шагов | фолбэк-текст | «стоит переформулировать вопрос» |
| Ответ пустой (только tool_use без текста) | проверка if not text |
«не получилось сформулировать ответ» |
Пользователь никогда не видит traceback, а в логе всегда есть traceback.
8. Проверка по списку вопросов
Заголовок раздела «8. Проверка по списку вопросов»Бот собран; осталось проверить его на questions.md из гл. 3.1. Критерий готовности «12 из 15» проверяется скриптом, а не на глаз в Telegram. scripts/run_questions.py прогоняет answer() по каждому вопросу из questions.md (без истории), печатает вызванные инструменты и ответ. Мы сравниваем с ожидаемым (ручной SQL из гл. 3.1) и ставим ✓/✗. Это прототип evals, которым посвящена неделя 6: скрипт и результаты прогонов сохраняем (результаты на снимке живой базы — только локально, в logs/, не в git).
DeepSeek и Ollama: как это сделать у нас
Заголовок раздела «DeepSeek и Ollama: как это сделать у нас»Напоминание из главы 3.1: снимок «живой» базы (data/shop.db, в нём персональные данные покупателей) видит только локальная модель Ollama; DeepSeek (серверы в Китае), а для сравнения и Claude/OpenAI, работают только с демо-базой data/demo_shop.db. Данные выдуманы, но обращаемся с ними как с настоящими. assert_data_policy() вызывается при старте бота и run_questions.py.
Цикл на OpenAI-формате
Заголовок раздела «Цикл на OpenAI-формате»Один код для Ollama и DeepSeek — меняются только base_url, ключ и модель из .env:
# bot/agent.py (illustrative, OpenAI Chat Completions format: Ollama and DeepSeek)import jsonfrom openai import AsyncOpenAI
provider, base_url, api_key = llm_settings() # from bot/config.pyclient = 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больше не нужен.
DeepSeek: почему бот работает без thinking
Заголовок раздела «DeepSeek: почему бот работает без thinking»По документации 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 реагирует на историю, где старые ходы урезаны до текста, документация прямо не описывает — проверим сами на демо-базе.
Ollama: окно контекста и отсутствие tool_choice
Заголовок раздела «Ollama: окно контекста и отсутствие tool_choice»- Окно. По умолчанию 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-версия цикла из теории).
Маршрут изучения
Заголовок раздела «Маршрут изучения»- Перечитываем Anthropic: Tool use → разделы про обработку результатов инструментов,
is_error, параллельные вызовы,tool_choice(≈30 мин). Сверяем свой цикл из гл. 2.6 с документацией: форматtool_result, порядок блоков. - aiogram → Dispatcher (start_polling), Router (
include_router, фильтры на уровне роутера), Filters (magic filterF,Command,CommandStart) (≈45 мин). Обратим внимание, как фильтры применяются к роутеру целиком. - Там же → раздел Middlewares (≈20 мин): доступ можно сделать и middleware; сравниваем с фильтром на роутере и выбираем один способ.
- Telegram Bot API →
sendMessage(лимит текста,parse_mode),sendChatAction(≈15 мин). - asyncio: Coroutines and Tasks →
asyncio.to_thread(≈10 мин). Берём: как не блокировать event loop синхронным кодом. - Anthropic: Errors → коды ошибок (≈10 мин) — освежить, что ретраить (гл. 2.7).
- (По желанию) Anthropic: Building effective agents → начало, про workflows vs agents (≈20 мин). Наш бот — самый простой агент: один цикл, узкие инструменты.
- Ollama: Tool calling → разделы про параллельные вызовы и multi-turn цикл (≈15 мин). Сравниваем их цикл
while Trueс нашимMAX_STEPS. - Ollama: Context length и Ollama FAQ → окно по умолчанию,
ollama ps,keep_alive(≈10 мин). - DeepSeek: Tool Calls и Thinking Mode → как вернуть результат (
role: "tool",tool_call_id), как выключить thinking, правило проreasoning_content(≈20 мин). - DeepSeek: Error Codes и Rate Limit → что ретраить, а что нет (402, 401); keep-alive пустыми строками при нагрузке (≈10 мин).
- 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 в одном ответе. Как правильно отправить результаты?
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].messagefor 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 и доводим репозиторий до публичного вида на демо-базе.