docs: lock deepseek/deepseek-chat as assistant model, add LLM test report and harness

This commit is contained in:
apuc committed 2026-09-26 19:23:38 +03:00
1 parent c8ba5ebb48
commit 9cbae40aa4
3 files changed
+387 -20

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
# Отчёт: тестирование LLM-моделей для ассистента «Зам»
**Проект:** АС «Платформа ОПОРА РОССИИ»
**Дата:** 26.09.2026
**Стенд:** `tools/test_models.py`
**Провайдер:** OpenRouter
---
## 1. Цель
Выбрать LLM для AI-ассистента «Зам» (п. 4.2.14 ТЗ, Вариант 1) по критериям: русский язык, function calling, структурированный вывод, стоимость, латентность.
## 2. Методика
Три тестовых сценария, приближенных к задачам ассистента:
| Тест | Что проверяет |
|---|---|
| `ru_generation` | Генерация «Еженедельного фокуса целей» на русском с терминологией |
| `function_calling` | Вызов инструмента `create_task` с корректными аргументами |
| `json_output` | Структурированный JSON (region, point0, current, growth) |
## 3. Результаты
**Модель: `deepseek/deepseek-chat`**
| Тест | Статус | Латентность | Токены (prompt+completion) | Стоимость |
|---|---|---|---|---|
| `ru_generation` | ✅ OK | 9.65 с | 150 + 160 | $0.00019 |
| `function_calling` | ✅ OK | 3.55 с | 891 + 62 | $0.00034 |
| `json_output` | ✅ OK | 3.34 с | 135 + 41 | $0.00008 |
**Средняя латентность:** 5.51 с · **Стоимость прогона:** $0.00061
### Пример ответа (`ru_generation`)
> **Еженедельный фокус целей: Донецкая Народная Республика**
> Прирост от точки 0: **+136**.
> Округ ЮФО: **+365**, динамика: **12/12 регионов**.
>
> **Что делать:**
> 1. Усилить поддержку предпринимателей…
### Проверка function calling
Модель корректно вызвала `create_task` со всеми обязательными полями:
`title`, `due_date` (= 2026-10-01), `priority` (= высокий), `assignee` (= регион ДНР).
### Проверка JSON
Модель вернула валидный JSON с полями `region`, `point0`, `current`, `growth` (= 136).
## 4. Оценка стоимости в продакшене
| Объём действий/мес | Оценка стоимости |
|---|---|
| 10 000 | ~$3 |
| 100 000 | ~$30 |
Стоимость пренебрежимо мала относительно бюджета проекта.
## 5. Вывод
`deepseek/deepseek-chat` полностью подходит под задачи ассистента «Зам»: качественный русский язык, надёжный function calling, стабильный JSON, низкая стоимость. Латентность 3–10 с приемлема; при необходимости снижается стримингом ответа.
**Решение:** модель ассистента — `deepseek/deepseek-chat` (OpenRouter). PiAPI — резерв (медиа), не используется на текущем этапе.
## 6. Воспроизведение
```powershell
$env:OPENROUTER_API_KEY = "<ключ>"
py -3 tools/test_models.py --models "deepseek/deepseek-chat"
```
> Ключ API в репозиторий не сохраняется.
+20 -20
View File
@@ -31,7 +31,7 @@
| Мониторинг | **Prometheus + Grafana + Loki** | Метрики, логи, алерты | | Мониторинг | **Prometheus + Grafana + Loki** | Метрики, логи, алерты |
| Бэкапы | **Отложены** (см. раздел 5) | Принятый риск на текущем этапе | | Бэкапы | **Отложены** (см. раздел 5) | Принятый риск на текущем этапе |
| CI/CD | **GitLab CI** | Сборка образов, деплой на сервер | | CI/CD | **GitLab CI** | Сборка образов, деплой на сервер |
| AI-ассистент «Зам» | **Внешний LLM API: OpenRouter / PiAPI** | Без GPU; выбор модели — в тестировании (см. раздел 8) | | AI-ассистент «Зам» | **OpenRouter → `deepseek/deepseek-chat`** | Без GPU; модель выбрана по тестам (раздел 8). PiAPI — резерв (медиа) |
--- ---
@@ -144,35 +144,35 @@ OpenRouter и PiAPI — зарубежные сервисы, данные ухо
--- ---
## 8. AI-ассистент: тестирование моделей ## 8. AI-ассистент: выбранная модель
Выбор LLM — в тестировании. Критерии оценки: **Модель: `deepseek/deepseek-chat` (через OpenRouter).**
| Критерий | Что проверяем | Выбор сделан по результатам тестирования (стенд `tools/test_models.py`, отчёт — `docs/llm-test-report.md`).
|---|---|
| Русский язык | Качество текста, терминология («точка 0», «прирост», «красная зона») |
| Function calling | Надёжность вызова инструментов (заполнить карточку, создать задачу, загрузить артефакт) |
| Стоимость | Цена за 1M токенов, оценка месячного бюджета |
| Скорость | Латентность ответа |
| Контекст | Размер окна (сводки по 89 регионам) |
| Стабильность | Повторяемость структурированного вывода (JSON) |
Кандидаты (через OpenRouter): GPT-4o / GPT-4o-mini, Claude (Sonnet/Haiku), Gemini Flash, Qwen, DeepSeek, Mistral. | Тест | Статус | Латентность | Стоимость |
PiAPI — уточнить назначение (генерация изображений/медиа для артефактов или LLM). |---|---|---|---|
| Русский текст (еженедельный фокус целей) | ✅ OK | 9.65 с | $0.00019 |
| Function calling (`create_task`) | ✅ OK | 3.55 с | $0.00034 |
| Структурированный JSON | ✅ OK | 3.34 с | $0.00008 |
**Оценка стоимости:** ~$0.0002–0.0003 за действие ассистента → ~$3/мес при 10 000 действий, ~$30/мес при 100 000.
**Критерии, по которым оценивалась модель:** русский язык и терминология, надёжность function calling, стоимость, латентность, размер контекста, стабильность JSON.
**PiAPI** — резервный вариант (генерация медиа), на текущем этапе не используется.
--- ---
## 9. Открытые вопросы ## 9. Открытые вопросы
1. **Модель LLM** — по результатам тестирования (раздел 8). 1. **Бэкапы** — когда включать (отложено).
2. **Назначение PiAPI** — LLM или генерация медиа. 2. **Реестр отечественного ПО** — нужен ли в перспективе.
3. **Бэкапы** — когда включать (отложено). 3. **PiAPI** — назначение и момент подключения (резерв).
4. **Реестр отечественного ПО** — нужен ли в перспективе.
--- ---
## 10. Следующие шаги ## 10. Следующие шаги
1. Провести тестирование LLM-моделей (раздел 8). 1. Утвердить документ.
2. Утвердить документ. 2. Перейти к техническому проекту: структура БД, контракты API, макеты экранов.
3. Перейти к техническому проекту: структура БД, контракты API, макеты экранов.
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Тестирование LLM-моделей для AI-ассистента «Зам» (АС «Платформа ОПОРА РОССИИ»).
Запуск (Windows PowerShell):
$env:OPENROUTER_API_KEY = "sk-or-..."
py -3 test_models.py
Свой список моделей:
py -3 test_models.py --models "openai/gpt-4o-mini,anthropic/claude-3.5-haiku"
Результат: таблица в консоли + отчёт report_<дата>.md
"""
import argparse
import json
import os
import sys
import time
from datetime import datetime
import requests
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
MODELS_URL = "https://openrouter.ai/api/v1/models"
DEFAULT_MODELS = [
"openai/gpt-4o-mini",
"anthropic/claude-3.5-haiku",
"google/gemini-2.0-flash-001",
"qwen/qwen-2.5-72b-instruct",
"deepseek/deepseek-chat",
"mistralai/mistral-small-24b-instruct-2501",
]
SYSTEM = (
"Ты — ассистент «Зам» платформы ОПОРА РОССИИ. "
"Отвечай по-русски, кратко и по делу. Термины: «точка 0», «текущий срез», "
"«прирост», «динамика», «красная зона», «вклад округа»."
)
TOOLS = [
{
"type": "function",
"function": {
"name": "create_task",
"description": "Создать задачу в трекере",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Название задачи"},
"due_date": {"type": "string", "description": "Срок в формате YYYY-MM-DD"},
"priority": {"type": "string", "enum": ["низкий", "средний", "высокий"]},
"assignee": {"type": "string", "description": "Ответственный (регион или роль)"},
},
"required": ["title", "due_date", "priority", "assignee"],
},
},
}
]
TESTS = [
{
"name": "ru_generation",
"desc": "Русский текст: еженедельный фокус целей",
"messages": [
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": (
"Составь сообщение «Еженедельный фокус целей» для Донецкой Народной "
"Республики. Прирост от точки 0: +136. Округ ЮФО: +365, с динамикой "
"12/12 регионов. Добавь рекомендацию «что делать»."
),
},
],
},
{
"name": "function_calling",
"desc": "Вызов инструмента create_task",
"messages": [
{"role": "system", "content": SYSTEM},
{
"role": "user",
"content": (
"Создай задачу: «Подготовить отчёт по приросту», срок 2026-10-01, "
"приоритет высокий, ответственный — регион ДНР."
),
},
],
"tools": TOOLS,
},
{
"name": "json_output",
"desc": "Структурированный JSON",
"messages": [
{"role": "system", "content": SYSTEM + " Отвечай строго в формате JSON, без пояснений."},
{
"role": "user",
"content": (
"Верни JSON с полями region, point0, current, growth для региона ДНР: "
"точка 0 = 1000, текущий срез = 1136."
),
},
],
},
]
def load_pricing():
"""Возвращает {model_id: (prompt_price, completion_price)} в $ за 1 токен."""
try:
r = requests.get(MODELS_URL, timeout=30)
r.raise_for_status()
data = r.json().get("data", [])
pricing = {}
for m in data:
p = m.get("pricing", {})
try:
pricing[m["id"]] = (float(p.get("prompt", 0)), float(p.get("completion", 0)))
except (TypeError, ValueError):
pass
return pricing
except Exception as e:
print(f"[warn] не удалось загрузить прайс: {e}")
return {}
def call_model(api_key, model, test):
payload = {
"model": model,
"messages": test["messages"],
"temperature": 0.3,
"max_tokens": 700,
}
if "tools" in test:
payload["tools"] = test["tools"]
payload["tool_choice"] = "auto"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"HTTP-Referer": "https://opora.my-dpr.ru",
"X-Title": "OPORA Zam LLM test",
}
start = time.perf_counter()
try:
r = requests.post(OPENROUTER_URL, headers=headers, json=payload, timeout=120)
latency = time.perf_counter() - start
if r.status_code != 200:
return {"ok": False, "error": f"HTTP {r.status_code}: {r.text[:300]}", "latency": latency}
data = r.json()
except Exception as e:
return {"ok": False, "error": str(e), "latency": time.perf_counter() - start}
choice = (data.get("choices") or [{}])[0]
msg = choice.get("message", {})
usage = data.get("usage", {}) or {}
return {
"ok": True,
"latency": latency,
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or [],
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"model_returned": data.get("model", model),
}
def check_function_call(result):
"""Проверяет корректность вызова create_task."""
if not result.get("ok"):
return "ошибка"
calls = result.get("tool_calls") or []
if not calls:
return "нет вызова"
try:
fn = calls[0]["function"]
if fn["name"] != "create_task":
return f"не та функция: {fn['name']}"
args = json.loads(fn["arguments"])
need = {"title", "due_date", "priority", "assignee"}
missing = need - set(args)
if missing:
return f"нет полей: {', '.join(sorted(missing))}"
if args.get("due_date") != "2026-10-01":
return f"срок: {args.get('due_date')}"
return "OK"
except Exception as e:
return f"ошибка разбора: {e}"
def check_json(result):
if not result.get("ok"):
return "ошибка"
txt = (result.get("content") or "").strip()
txt = txt.replace("```json", "").replace("```", "").strip()
try:
obj = json.loads(txt)
need = {"region", "point0", "current", "growth"}
missing = need - set(obj)
if missing:
return f"нет полей: {', '.join(sorted(missing))}"
if int(obj.get("growth", 0)) != 136:
return f"growth={obj.get('growth')}"
return "OK"
except Exception as e:
return f"не JSON: {e}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--models", default=",".join(DEFAULT_MODELS))
ap.add_argument("--out", default=None)
args = ap.parse_args()
api_key = os.environ.get("OPENROUTER_API_KEY")
if not api_key:
print("Ошибка: задайте переменную окружения OPENROUTER_API_KEY")
sys.exit(1)
models = [m.strip() for m in args.models.split(",") if m.strip()]
pricing = load_pricing()
rows = []
for model in models:
print(f"\n=== {model} ===")
row = {"model": model}
for test in TESTS:
res = call_model(api_key, model, test)
if not res["ok"]:
print(f" [{test['name']}] ОШИБКА: {res['error']}")
row[test["name"]] = {"status": "ошибка", "latency": res["latency"]}
continue
if test["name"] == "function_calling":
status = check_function_call(res)
elif test["name"] == "json_output":
status = check_json(res)
else:
status = "OK" if len(res["content"]) > 40 else "короткий ответ"
pt, ct = res["prompt_tokens"], res["completion_tokens"]
price = pricing.get(model, (0, 0))
cost = pt * price[0] + ct * price[1]
row[test["name"]] = {
"status": status,
"latency": res["latency"],
"prompt_tokens": pt,
"completion_tokens": ct,
"cost_usd": cost,
"sample": res["content"][:200],
}
print(f" [{test['name']}] {status} | {res['latency']:.2f}s | "
f"{pt}+{ct} ток | ${cost:.6f}")
rows.append(row)
# Отчёт
ts = datetime.now().strftime("%Y-%m-%d_%H%M")
out = args.out or f"report_{ts}.md"
lines = [f"# Тест LLM-моделей — {datetime.now():%Y-%m-%d %H:%M}", ""]
lines.append("| Модель | ru_generation | function_calling | json_output | Ср. латентность | Стоимость теста |")
lines.append("|---|---|---|---|---|---|")
for row in rows:
lat = [row[t["name"]]["latency"] for t in TESTS if row.get(t["name"], {}).get("latency")]
avg_lat = sum(lat) / len(lat) if lat else 0
cost = sum(row[t["name"]].get("cost_usd", 0) for t in TESTS if row.get(t["name"]))
cells = []
for t in TESTS:
d = row.get(t["name"], {})
cells.append(d.get("status", "—"))
lines.append(f"| {row['model']} | " + " | ".join(cells) +
f" | {avg_lat:.2f}s | ${cost:.6f} |")
lines.append("")
lines.append("## Примеры ответов (ru_generation)")
for row in rows:
d = row.get("ru_generation", {})
if d.get("sample"):
lines.append(f"\n### {row['model']}\n\n{d['sample']}")
with open(out, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"\nОтчёт сохранён: {out}")
if __name__ == "__main__":
main()