Без Ollama приложение больше не выглядит зависшим
Проверка работы с недоступной Ollama показала: если машина с ней просто выключена, пакеты уходят в никуда и каждый вызов честно ждёт свой таймаут. Замер: запись одной вещи — 92 секунды, разбор фразы — 154, правка — 30. Формально всё работало, но пользоваться этим невозможно. Предохранитель в ollama.py: - перед дорогим запросом быстрая проверка TCP-соединения (1.5 с вместо 120); - после неудачи 30 секунд к серверу вообще не обращаемся; - при успехе результат кешируется на 5 секунд, чтобы не проверять на каждом шаге; - смена адреса в настройках и проба адреса сбрасывают паузу, иначе новый сервер полминуты считался бы мёртвым. Замер после: те же операции отвечают мгновенно, первая проверка — 1.5 с. Проверено в двух режимах недоступности: закрытый порт и молчащий адрес. Тесты закрепляют поведение: запись, разбор фразы и поиск при мёртвом адресе обязаны укладываться в 3 секунды и работать по существу — вещь записывается без тегов, фраза разбирается регулярками, поиск идёт по словам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -185,8 +185,9 @@ ollama pull bge-m3
|
||||
|
||||
## Что если Ollama выключен
|
||||
|
||||
Если Ollama на отдельной машине, она бывает выключена. Приложение это переживает:
|
||||
наверху появляется предупреждение, а работает почти всё.
|
||||
**Приложение работает и без Ollama вообще** — можно поставить и пользоваться,
|
||||
не устанавливая её. Наверху появится предупреждение со ссылкой в настройки,
|
||||
а всё основное останется на месте.
|
||||
|
||||
| | Ollama есть | Ollama нет |
|
||||
|---|---|---|
|
||||
@@ -196,6 +197,14 @@ ollama pull bge-m3
|
||||
| Разбор свободных фраз | модель | упрощённо, регулярками |
|
||||
| Поиск по смыслу | да | нет |
|
||||
|
||||
**Ожидания это не добавляет.** Если машина с Ollama выключена, пакеты уходят
|
||||
в никуда, и без защиты каждый вызов честно ждал бы свой таймаут: замер показал
|
||||
92 секунды на запись одной вещи и 154 на разбор фразы — выглядит как зависание.
|
||||
Поэтому в `ollama.py` стоит предохранитель: перед дорогим запросом идёт быстрая
|
||||
проверка соединения (полторы секунды вместо двух минут), а после неудачи
|
||||
приложение полминуты вообще не тревожит сервер. Замер после: те же операции
|
||||
отвечают мгновенно. Смена адреса в настройках сбрасывает паузу сразу.
|
||||
|
||||
Модели: `qwen2.5:7b` для разбора фраз (в конфиге — `CHAT_MODEL`).
|
||||
Не `omnicoder-9b`: тот ризонер, пишет `<think>` прямо в ответ и медленнее.
|
||||
|
||||
|
||||
@@ -81,10 +81,12 @@ def api_config_probe():
|
||||
was = config.OLLAMA_HOST
|
||||
try:
|
||||
config.OLLAMA_HOST = host.rstrip("/")
|
||||
ollama.reset_breaker() # проверяем именно этот адрес, без памяти о прошлом
|
||||
alive = ollama.check()
|
||||
models = ollama.models() if alive else []
|
||||
finally:
|
||||
config.OLLAMA_HOST = was # проба не должна менять настройки
|
||||
ollama.reset_breaker()
|
||||
|
||||
if not alive:
|
||||
return jsonify({"ok": False, "models": [],
|
||||
@@ -107,6 +109,9 @@ def api_config_save():
|
||||
|
||||
if not changed:
|
||||
return jsonify({"ok": True, "reply": "Менять нечего."})
|
||||
# Адрес поменяли — прошлая неудача больше ничего не значит, иначе новый
|
||||
# сервер полминуты считался бы мёртвым.
|
||||
ollama.reset_breaker()
|
||||
alive = ollama.check()
|
||||
reply = "Сохранил в .env. " + ("Ollama отвечает." if alive else
|
||||
"Но Ollama по этому адресу молчит.")
|
||||
|
||||
+85
-3
@@ -5,7 +5,10 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import socket
|
||||
import logging
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
# Адрес и модели читаем через `config.X`, а не копией при импорте: их можно
|
||||
@@ -16,25 +19,95 @@ from .config import (CHAT_TIMEOUT, EMBED_TIMEOUT, MAX_OUTPUT_TOKENS,
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================
|
||||
# Предохранитель
|
||||
#
|
||||
# Если машина с Ollama просто выключена, пакеты уходят в никуда, и каждый
|
||||
# запрос честно ждёт свой таймаут. Замер: запись одной вещи занимала 92 секунды,
|
||||
# разбор фразы — 154. Пользователь решит, что приложение зависло.
|
||||
#
|
||||
# Поэтому: сначала быстрая проверка TCP (полтора секунды вместо двух минут),
|
||||
# а после неудачи — пауза, в течение которой к серверу вообще не ходим.
|
||||
# ============================================================
|
||||
CONNECT_TIMEOUT = 1.5 # столько ждём само соединение
|
||||
DOWN_COOLDOWN = 30.0 # столько не тревожим сервер после неудачи
|
||||
UP_CACHE = 5.0 # столько верим, что сервер жив, без повторной проверки
|
||||
|
||||
_down_until = 0.0
|
||||
_up_until = 0.0
|
||||
|
||||
|
||||
def _host_port():
|
||||
parsed = urllib.parse.urlparse(config.OLLAMA_HOST)
|
||||
return parsed.hostname or "localhost", parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
|
||||
|
||||
def available():
|
||||
"""Стоит ли вообще пытаться. Быстро и без долгих ожиданий."""
|
||||
global _down_until, _up_until
|
||||
now = time.time()
|
||||
if now < _up_until:
|
||||
return True
|
||||
if now < _down_until:
|
||||
return False
|
||||
try:
|
||||
host, port = _host_port()
|
||||
socket.create_connection((host, port), timeout=CONNECT_TIMEOUT).close()
|
||||
_up_until = now + UP_CACHE
|
||||
_down_until = 0.0
|
||||
return True
|
||||
except Exception:
|
||||
_down_until = now + DOWN_COOLDOWN
|
||||
_up_until = 0.0
|
||||
return False
|
||||
|
||||
|
||||
def _mark_down():
|
||||
"""Запрос не удался — держим паузу, чтобы не ждать таймаут на каждом шаге."""
|
||||
global _down_until, _up_until
|
||||
_down_until = time.time() + DOWN_COOLDOWN
|
||||
_up_until = 0.0
|
||||
|
||||
|
||||
def _mark_up():
|
||||
global _up_until
|
||||
_up_until = time.time() + UP_CACHE
|
||||
|
||||
|
||||
def reset_breaker():
|
||||
"""Сбросить паузу — например, когда адрес поменяли в настройках."""
|
||||
global _down_until, _up_until
|
||||
_down_until = 0.0
|
||||
_up_until = 0.0
|
||||
|
||||
|
||||
def check():
|
||||
"""Доступен ли сервер. Таймаут короткий: это дёргает баннер в UI."""
|
||||
"""Доступен ли сервер. Отвечает быстро: это дёргает баннер в UI."""
|
||||
if not available():
|
||||
return False
|
||||
try:
|
||||
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=3) as r:
|
||||
return r.status == 200
|
||||
if r.status == 200:
|
||||
_mark_up()
|
||||
return True
|
||||
except Exception:
|
||||
_mark_down()
|
||||
return False
|
||||
|
||||
|
||||
def models():
|
||||
"""Список установленных моделей (для диагностики)."""
|
||||
if not available():
|
||||
return []
|
||||
try:
|
||||
req = urllib.request.Request(config.OLLAMA_HOST + "/api/tags")
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
data = json.loads(r.read().decode("utf-8"))
|
||||
_mark_up()
|
||||
return [m.get("model", "") for m in data.get("models", [])]
|
||||
except Exception as e:
|
||||
_mark_down()
|
||||
logger.warning("Список моделей не получен: %s", e)
|
||||
return []
|
||||
|
||||
@@ -51,6 +124,8 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS):
|
||||
"stream": False,
|
||||
"options": {"num_predict": num_predict, "temperature": 0},
|
||||
}
|
||||
if not available():
|
||||
return None # сервер лежит: не ждём таймаут на каждой записи
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
config.OLLAMA_HOST + "/api/chat", data=data,
|
||||
@@ -59,8 +134,10 @@ def chat(model, messages, timeout=CHAT_TIMEOUT, num_predict=MAX_OUTPUT_TOKENS):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
resp = json.loads(r.read().decode("utf-8"))
|
||||
_mark_up()
|
||||
return (resp.get("message") or {}).get("content", "")
|
||||
except Exception as e:
|
||||
_mark_down()
|
||||
logger.warning("Запрос к модели %s не удался: %s", model, e)
|
||||
return None
|
||||
|
||||
@@ -74,6 +151,8 @@ def embed(text, is_query=False):
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if not available():
|
||||
return None
|
||||
prefix = EMBED_QUERY_PREFIX if is_query else EMBED_DOC_PREFIX
|
||||
data = json.dumps({"model": config.EMBED_MODEL, "prompt": prefix + text}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
@@ -82,7 +161,10 @@ def embed(text, is_query=False):
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=EMBED_TIMEOUT) as r:
|
||||
return json.loads(r.read().decode("utf-8")).get("embedding")
|
||||
vector = json.loads(r.read().decode("utf-8")).get("embedding")
|
||||
_mark_up()
|
||||
return vector
|
||||
except Exception as e:
|
||||
_mark_down()
|
||||
logger.warning("Эмбеддинг не получен: %s", e)
|
||||
return None
|
||||
|
||||
@@ -177,6 +177,47 @@ config.DB_FILE.write_text("{ битый json", encoding="utf-8")
|
||||
r.check("битая база не роняет", c.get("/api/tree").status_code == 200)
|
||||
r.check("пустой ввод не роняет", "reply" in j("POST", "/api/say", text=" "))
|
||||
|
||||
print("\n== Работа без Ollama: не ждём таймаутов ==")
|
||||
# Машина с Ollama бывает выключена, и тогда пакеты уходят в никуда. Без
|
||||
# предохранителя запись одной вещи ждала 92 секунды, разбор фразы — 154:
|
||||
# пользователь решит, что приложение зависло. 192.0.2.1 — из блока TEST-NET-1
|
||||
# (RFC 5737), он гарантированно не отвечает.
|
||||
import time as _time # noqa: E402
|
||||
_was_host = config.OLLAMA_HOST
|
||||
try:
|
||||
config.OLLAMA_HOST = "http://192.0.2.1:11434"
|
||||
ollama.reset_breaker()
|
||||
|
||||
t0 = _time.time()
|
||||
alive = ollama.check()
|
||||
first = _time.time() - t0
|
||||
r.check("недоступность видна быстро", not alive and first < 5, "%.1f с" % first)
|
||||
|
||||
t0 = _time.time()
|
||||
d = j("POST", "/api/put", name="вещь без ии")
|
||||
put_time = _time.time() - t0
|
||||
r.check("запись вещи не ждёт таймаут", d.get("ok") and put_time < 3,
|
||||
"%.1f с, ответ: %s" % (put_time, d))
|
||||
r.check("тегов нет, но вещь записана",
|
||||
d.get("item", {}).get("aliases") == [], d.get("item"))
|
||||
|
||||
t0 = _time.time()
|
||||
d = j("POST", "/api/say", text="положил кусачки в коробку D1")
|
||||
say_time = _time.time() - t0
|
||||
r.check("свободная фраза не ждёт таймаут", say_time < 3, "%.1f с" % say_time)
|
||||
r.check("фраза разобрана регулярками", d.get("kind") == "put", d)
|
||||
|
||||
t0 = _time.time()
|
||||
d = c.get("/api/find?q=вещь без ии").get_json()
|
||||
r.check("поиск работает без ИИ", d["items"] and _time.time() - t0 < 3, d)
|
||||
|
||||
r.check("смена адреса сбрасывает паузу",
|
||||
(ollama.reset_breaker() or True) and ollama._down_until == 0)
|
||||
finally:
|
||||
config.OLLAMA_HOST = _was_host
|
||||
ollama.reset_breaker()
|
||||
r.check("после возврата адреса связь есть", ollama.check() if HAS_AI else True)
|
||||
|
||||
print("\n== Батники: только ASCII ==")
|
||||
# cmd.exe читает .cmd в системной кодировке. Кириллица внутри — даже
|
||||
# в комментарии rem — превращается в мусор, и cmd пытается выполнить обрывки
|
||||
|
||||
Reference in New Issue
Block a user