Семантичне логування агентів

Семантичні логи структурують події агентів для аналізу.
На цій сторінці
  1. Ідея за 30 секунд
  2. Основна проблема
  3. Як це працює
  4. Мінімальний словник подій
  5. Коли використовувати
  6. Приклад реалізації
  7. Типові помилки
  8. Події названі по-різному в різних сервісах
  9. Вільний текст замість нормалізованих полів
  10. Немає event_version
  11. Логуються raw prompts або raw args без редагування
  12. Самоперевірка
  13. FAQ
  14. Пов'язані сторінки

Ідея за 30 секунд

Семантичне логування (semantic logging) для агентів означає, що події мають не лише JSON-формат, а й стабільний зміст.

Тобто однакові кроки у різних run логуються однаково: той самий event, ті самі ключові поля, ті самі статуси.

Це робить логи придатними для пошуку, алертів, аналітики та debugging у production.

Основна проблема

Багато команд уже пишуть структуровані логи, але цього часто недостатньо.

У різних сервісах та версіях агента одна і та сама подія може мати різні назви й поля: tool_called, call_tool, tool.invoke. У результаті логи наче є, але порівнювати run між собою складно.

Семантичне логування — це рішення на рівні дизайну подій, а не лише технічний шар поверх логів.

У production це зазвичай виглядає так:

  • запит у лог-систему повертає занадто багато шуму;
  • алерти поводяться нестабільно через різні назви подій;
  • під час інциденту доводиться вручну зводити події з кількох форматів.

Тому для агентних систем важливі спільний словник подій і стабільна схема полів.

Як це працює

Семантичне логування спирається на три речі:

  • узгоджений словник подій (event taxonomy);
  • стабільні поля для кожної події;
  • нормалізовані значення (status, error_class, stop_reason).

event taxonomy — це контракт між runtime, логами, дашбордами й алертами. Порушення цього контракту ламає observability. status зазвичай має обмежений словник значень (наприклад: ok, error, timeout, cancelled; у цьому прикладі використовується спрощений варіант: ok / error).

Семантичне логування не замінює трейсинг, а доповнює його. Воно робить події не тільки видимими, а й порівнюваними між сервісами та релізами. Логування відповідає на «що сталося», трейсинг — «як це сталося», а semantic logging — «що це означає».

Мінімальний словник подій

ПодіяСемантичний змістКлючові поля
run_startedновий run почавсяrun_id, trace_id, request_id, task_hash
agent_stepагент перейшов до наступного крокуstep_index, step_type, actor
tool_callпочаток виклику інструментаtool_name, args_hash
tool_resultрезультат виклику інструментаtool_name, latency_ms, status, error_class
llm_resultрезультат кроку моделіmodel, token_usage, latency_ms, status
policy_decisionрішення policy/guardrailsrule_id, decision, reason_code
run_finishedrun завершивсяstop_reason, total_steps, total_latency_ms

policy_decision дозволяє бачити не лише помилки, а й причини блокування та guardrail-рішень.

event_version дозволяє змінювати схему подій без поломки існуючих дашбордів і алертів.

Коли використовувати

Повне семантичне логування не завжди потрібне.

Для простого single-shot сценарію без tools і без циклу виконання часто вистачає базових логів.

Але семантичне логування стає критичним, коли:

  • у системі кілька агентів або сервісів;
  • потрібно стабільно будувати алерти та дашборди;
  • важливо порівнювати поведінку між релізами;
  • інциденти треба розбирати швидко, без ручного мапінгу подій.

Приклад реалізації

Нижче — спрощений приклад семантичного логування у runtime. Ідея проста: ми логуємо тільки події з узгодженого словника і нормалізуємо значення полів.

PYTHON
import hashlib
import json
import logging
import time
import uuid
from enum import StrEnum

logger = logging.getLogger("agent")


class EventName(StrEnum):
    RUN_STARTED = "run_started"
    AGENT_STEP = "agent_step"
    TOOL_CALL = "tool_call"
    TOOL_RESULT = "tool_result"
    LLM_RESULT = "llm_result"
    POLICY_DECISION = "policy_decision"
    RUN_FINISHED = "run_finished"


def stable_hash(value):
    # default=str дає базову сумісність
    # у критичних системах краще явна серіалізація (наприклад ISO 8601)
    payload = json.dumps(
        value,
        sort_keys=True,
        ensure_ascii=False,
        default=str,
    ).encode("utf-8")
    return hashlib.sha256(payload).hexdigest()


def normalize_status(ok):
    return "ok" if ok else "error"


def normalize_error(error):
    if error is None:
        return None
    return type(error).__name__


def log_semantic(event_name: EventName, **fields):
    logger.info(
        event_name.value,
        extra={
            "event": event_name.value,
            "event_version": 1,
            "timestamp_ms": int(time.time() * 1000),
            **fields,
        },
    )


def run_agent(agent, task, request_id=None):
    run_id = str(uuid.uuid4())
    trace_id = str(uuid.uuid4())
    started_at = time.time()
    step_index = 0
    stop_reason = "max_steps"
    run_status = "ok"

    log_semantic(
        EventName.RUN_STARTED,
        run_id=run_id,
        trace_id=trace_id,
        request_id=request_id,
        task_hash=stable_hash(task),
    )

    try:
        for step in agent.iter(task):  # step: reasoning або tool execution
            step_index += 1
            step_started_at = time.time()
            step_type = step.type
            tool_name = getattr(step, "tool_name", None)

            log_semantic(
                EventName.AGENT_STEP,
                run_id=run_id,
                trace_id=trace_id,
                step_index=step_index,
                step_type=step_type,
                actor=getattr(step, "actor", "agent_runtime"),
            )

            if step_type == "tool_call":
                args = getattr(step, "args", {})
                log_semantic(
                    EventName.TOOL_CALL,
                    run_id=run_id,
                    trace_id=trace_id,
                    step_index=step_index,
                    tool_name=tool_name,
                    args_hash=stable_hash(args),
                )

            try:
                result = step.execute()
                latency_ms = int((time.time() - step_started_at) * 1000)

                if step_type == "tool_call":
                    log_semantic(
                        EventName.TOOL_RESULT,
                        run_id=run_id,
                        trace_id=trace_id,
                        step_index=step_index,
                        tool_name=tool_name,
                        latency_ms=latency_ms,
                        status=normalize_status(True),
                        error_class=None,
                    )
                else:
                    log_semantic(
                        EventName.LLM_RESULT,
                        run_id=run_id,
                        trace_id=trace_id,
                        step_index=step_index,
                        model=getattr(step, "model", None),
                        token_usage=getattr(result, "token_usage", None),
                        latency_ms=latency_ms,
                        status=normalize_status(True),
                    )

                # policy_decision фіксується після виконання кроку
                # (коли відомий результат або помилка)
                if getattr(step, "policy_decision", None) is not None:
                    decision = step.policy_decision
                    log_semantic(
                        EventName.POLICY_DECISION,
                        run_id=run_id,
                        trace_id=trace_id,
                        step_index=step_index,
                        rule_id=decision.rule_id,
                        decision=decision.value,
                        reason_code=decision.reason_code,
                    )

            except Exception as error:
                latency_ms = int((time.time() - step_started_at) * 1000)
                run_status = "error"

                if step_type == "tool_call":
                    stop_reason = "tool_error"
                    log_semantic(
                        EventName.TOOL_RESULT,
                        run_id=run_id,
                        trace_id=trace_id,
                        step_index=step_index,
                        tool_name=tool_name,
                        latency_ms=latency_ms,
                        status=normalize_status(False),
                        error_class=normalize_error(error),
                    )
                else:
                    stop_reason = "step_error"
                    log_semantic(
                        EventName.LLM_RESULT,
                        run_id=run_id,
                        trace_id=trace_id,
                        step_index=step_index,
                        model=getattr(step, "model", None),
                        latency_ms=latency_ms,
                        status=normalize_status(False),
                        error_class=normalize_error(error),
                    )

                if getattr(step, "policy_decision", None) is not None:
                    decision = step.policy_decision
                    log_semantic(
                        EventName.POLICY_DECISION,
                        run_id=run_id,
                        trace_id=trace_id,
                        step_index=step_index,
                        rule_id=decision.rule_id,
                        decision=decision.value,
                        reason_code=decision.reason_code,
                    )

                raise

            if result.is_final:
                stop_reason = "completed"
                break

    finally:
        log_semantic(
            EventName.RUN_FINISHED,
            run_id=run_id,
            trace_id=trace_id,
            status=run_status,
            stop_reason=stop_reason,
            total_steps=step_index,
            total_latency_ms=int((time.time() - started_at) * 1000),
        )

У production такі події зазвичай відправляються в centralized logging систему (наприклад, ELK, Datadog або ClickHouse), де на їх основі будуються запити, дашборди й алерти.

Наприклад, один semantic event у JSON може виглядати так:

JSON
{
  "timestamp_ms": 1774106220000,
  "event": "policy_decision",
  "event_version": 1,
  "run_id": "run_9fd2",
  "trace_id": "tr_9fd2",
  "step_index": 3,
  "rule_id": "email_external_domain",
  "decision": "deny",
  "reason_code": "missing_user_confirmation"
}

Типові помилки

Навіть якщо структуровані логи вже є, семантичне логування часто ламається через типові помилки нижче.

Події названі по-різному в різних сервісах

Коли одна й та сама дія має різні event-назви, запити по логах стають нестабільними. У результаті складніше вчасно помітити збій інструмента або ранню фазу спаму інструментами.

Вільний текст замість нормалізованих полів

Поля на кшталт "error": "something failed" майже не придатні для аналітики. Краще мати окремі поля status, error_class, reason_code з фіксованими значеннями.

Немає event_version

Без версії події зміни схеми непомітно ламають дашборди, saved queries й алерти. Тому еволюцію схеми краще робити явно.

Логуються raw prompts або raw args без редагування

Це ризик для безпеки та відповідності вимогам. Безпечніше зберігати hash або анонімізовану версію полів.

Самоперевірка

Нижче — короткий checklist базового семантичного логування перед релізом.

Прогрес: 0/9

⚠ Бракує базової observability

Систему буде складно дебажити в production. Почніть з run_id, structured logs і tracing tool calls.

FAQ

Q: Чим semantic logging відрізняється від звичайного JSON-логування?
A: JSON-логування задає лише формат. Semantic logging задає зміст: стабільні назви подій, однакові поля та нормалізовані значення.

Q: Чи замінює semantic logging трейсинг?
A: Ні. Трейсинг показує шлях виконання, а semantic logging робить події цього шляху зрозумілими для пошуку, алертів і аналітики.

Q: Який мінімум semantic logging потрібен для першого production-релізу?
A: Базовий словник подій (run_started, tool_call, tool_result, run_finished), стабільні run_id/trace_id, status, error_class і stop_reason.

Q: Чи треба одразу переробляти всі старі логи?
A: Ні. Краще почати з нових подій і критичних run-шляхів, а потім поступово мігрувати старі формати.

Пов'язані сторінки

Далі за темою:

⏱️ 6 хв читанняОновлено 20 березня 2026 р.Складність: ★★★
Інтегровано: продакшен-контрольOnceOnly
Додай guardrails до агентів з tool-calling
Зашип цей патерн з governance:
  • Бюджетами (кроки / ліміти витрат)
  • Дозволами на інструменти (allowlist / blocklist)
  • Kill switch та аварійна зупинка
  • Ідемпотентність і dedupe
  • Audit logs та трасування
Інтегрована згадка: OnceOnly — контрольний шар для продакшен агент-систем.

Автор

Микола — інженер, який будує інфраструктуру для продакшн AI-агентів.

Фокус: патерни агентів, режими відмов, контроль рантайму та надійність систем.

🔗 GitHub: https://github.com/mykolademyanov


Редакційна примітка

Ця документація підготовлена з допомогою AI, із людською редакторською відповідальністю за точність, ясність і продакшн-релевантність.

Контент базується на реальних відмовах, постмортемах та операційних інцидентах у розгорнутих AI-агентних системах.