Normal path: execute → tool → observe.
Проблема (з реального продакшену)
Твій tool повертає JSON.
Поки не перестає.
Може проксі підсунув HTML. Може відповідь обрізалась. Може версія tool’а змінилась і поле перейменували.
Модель бачить “щось” і намагається продовжити. Так ти отримуєш:
- неправильні рішення
- неправильні writes
- і найгірший баг: “не впало, просто зробило не те”
Корупція відповіді tool’а — продакшен-фейл, бо tool output = реальність агента. Якщо реальність корумпована — рішення корумповані.
Чому це ламається в продакшені
1) Tool output сприймають як trusted і well-formed
Більшість команд валідовує inputs і ігнорує outputs. Для агентів це навпаки:
- outputs — це те, на чому агент приймає рішення
- outputs — найпростіший шлях для тихої корупції
2) Partial responses — реальність
Таймаути не завжди падають “чисто”. Інколи ти отримуєш:
- половину JSON
- 200 зі “стороннім” error payload
- порожній body із success статусом
3) Schema drift відбувається постійно
Внутрішні API змінюються. Вендори змінюються. Твої tools змінюються.
Якщо агент не валідовує outputs — ти дізнаєшся з юзерських факапів.
4) Модель намагається “згладити” корупцію
Людина бачить невалідний JSON і зупиняється. Модель бачить “майже” і заповнює прогалини галюцинаціями.
Для тексту це інколи ок. Для tool-mediated дій — це баг.
Приклад реалізації (реальний код)
Безпечний патерн:
- ліміти розміру
- content-type
- schema + invariants
- fail closed (або degrade) при mismatch
import json
from typing import Any
class ToolOutputInvalid(RuntimeError):
pass
def parse_json_strict(raw: str, *, max_chars: int = 200_000) -> Any:
if len(raw) > max_chars:
raise ToolOutputInvalid("tool output too large")
try:
return json.loads(raw)
except Exception as e:
raise ToolOutputInvalid(f"invalid JSON: {type(e).__name__}")
def validate_user_profile(obj: Any) -> dict[str, Any]:
if not isinstance(obj, dict):
raise ToolOutputInvalid("expected object")
if "user_id" not in obj or not isinstance(obj["user_id"], str):
raise ToolOutputInvalid("missing user_id")
if "plan" in obj and obj["plan"] not in {"free", "pro", "enterprise"}:
raise ToolOutputInvalid("invalid plan enum")
return obj
def get_user_profile(user_id: str) -> dict[str, Any]:
raw = http_get(f"https://api.internal/users/{user_id}") # (pseudo)
obj = parse_json_strict(raw)
return validate_user_profile(obj)export class ToolOutputInvalid extends Error {}
export function parseJsonStrict(raw, { maxChars = 200_000 } = {}) {
if (raw.length > maxChars) throw new ToolOutputInvalid("tool output too large");
try {
return JSON.parse(raw);
} catch (e) {
throw new ToolOutputInvalid("invalid JSON: " + (e && e.name ? e.name : "Error"));
}
}
export function validateUserProfile(obj) {
if (!obj || typeof obj !== "object") throw new ToolOutputInvalid("expected object");
if (typeof obj.user_id !== "string") throw new ToolOutputInvalid("missing user_id");
if ("plan" in obj && !["free", "pro", "enterprise"].includes(obj.plan)) {
throw new ToolOutputInvalid("invalid plan enum");
}
return obj;
}Це навмисно строго. Якщо tool output корумпований, правильна відповідь зазвичай:
- stop
- partial results
- і логування класу фейлу
Не: “вгадай, що мав на увазі tool”.
Реальний інцидент (з цифрами)
У нас був агент, який оновлював CRM нотатки на основі user profile + останніх подій.
Tool user.profile почав інколи повертати HTML error pages зі статусом 200 (proxy misconfig).
Модель “прочитала” HTML і витягла “plan = enterprise” з випадкового банера.
Impact:
- 23 CRM записи отримали неправильний plan
- sales команда витратила ~3 години на “enterprise leads”, яких не було
- довелось запускати cleanup job і відновлювати з логів (які були неповні)
Fix:
- content-type checks + strict JSON parse
- schema validation + enum checks
- fail closed + safe-mode (skip write, якщо profile invalid)
- метрики:
tool_output_invalidrate
Tools брешуть нудно. Агент має вміти сказати “ні”.
Компроміси
- Строга валідація підвищує hard failures під час drift (добре — ти це бачиш).
- Fail closed знижує success rate на короткий час, зате не дає тихої корупції.
- Підтримка schemas — робота. Silent data corruption — гірша робота.
Коли НЕ варто
- Якщо tool strongly typed end-to-end і ти повністю його контролюєш — можна валідовувати менше (але size limits лишай).
- Якщо tool повертає free-form text за дизайном — обгорни extractor’ом у структурований output.
- Якщо ти не можеш зупинятись — тобі потрібні fallback tools, а не “слабша” валідація.
Чекліст (можна копіювати)
- [ ] Enforce max response size
- [ ] Check content-type (JSON vs HTML)
- [ ] Strict parse (без “best effort”)
- [ ] Schema validation + enum constraints
- [ ] Invariant checks (ids, counts, ranges)
- [ ] Fail closed або degrade (ніяких writes при invalid output)
- [ ] Метрики/алерти на invalid output rate
- [ ] Лог args hash + tool version + error class
Безпечний дефолтний конфіг (JSON/YAML)
validation:
tool_output:
fail_closed: true
max_chars: 200000
require_content_type: "application/json"
enforce_enums: true
safe_mode:
on_invalid_output: "skip_writes"
metrics:
track: ["tool_output_invalid_rate"]
FAQ (3–5)
Використовується в патернах
Пов’язані відмови
Q: Валідація output’ів зайва, якщо tool внутрішній?
A: Ні. Внутрішні tools теж дрейфують і ламаються. “Внутрішній” означає лише, що це твоя проблема.
Q: Найгірший кейс, якщо пропустити це?
A: Silent corruption: неправильні writes, які виглядають як успіх. Ти дізнаєшся про це через дні від людей.
Q: Потрібна повна JSON schema бібліотека?
A: Не одразу. Почни з strict parse + ключових invariants. Додай schemas там, де великий blast radius.
Q: Як це пов’язано з prompt injection?
A: Корумповані outputs часто містять untrusted текст. Валідуй і сприймай tool output як data, не інструкції.
Пов’язані сторінки (3–6 лінків)
- Foundations: Як агенти використовують tools · Production-ready агент
- Failure: Prompt injection · Галюциновані джерела
- Governance: Tool permissions (allowlists)
- Production stack: Production agent stack