Суть патерна (коротко)
Routing Agent — це патерн, де агент не виконує задачу напряму, а обирає найкращого спеціалізованого виконавця під конкретний тип запиту.
LLM приймає route-рішення, а виконання робить лише execution layer через policy boundary.
Що демонструє цей приклад
- окремий етап
Routeперед виконанням - policy boundary між routing decision (LLM) і workers (execution layer)
- strict validation для route-action (
kind,target,args, allowed keys) - allowlist (deny by default) для маршрутизації
- fallback через
needs_rerouteз обмеженим числом спроб - бюджети run:
max_route_attempts,max_delegations,max_seconds - явні
stop_reasonдля дебагу, алертів і продакшен-спостереження raw_routeу відповіді, якщо LLM повернув некоректний route JSON
Архітектура
- LLM отримує goal і повертає route-intent у JSON (
kind="route",target,args). - Policy boundary валідовує route як недовірений input (включно з обовʼязковим
args.ticket). - RouteGateway делегує задачу обраному worker (
allowlist, budgets, loop detection). - Observation додається в
historyі стає evidence для наступної route-спроби (якщо потрібен reroute). - Якщо попередня спроба мала
needs_reroute, policy не дозволяє повторити той самий target. - Коли worker повертає
done, окремийFinalizeLLM-крок формує фінальну відповідь без виклику workers.
LLM повертає intent (route JSON), який розглядається як недовірений input: policy boundary спершу валідовує його, а потім (якщо дозволено) викликає workers.
Allowlist застосовується двічі: у route validation (invalid_route:route_not_allowed:*) і у виконанні (route_denied:*).
Так Routing залишається керованим: агент обирає виконавця, а виконання проходить через контрольований шар.
Структура проєкту
examples/
└── agent-patterns/
└── routing-agent/
└── python/
├── main.py # Route -> Delegate -> (optional reroute) -> Finalize
├── llm.py # router + final synthesis
├── gateway.py # policy boundary: route validation + delegation control
├── tools.py # deterministic specialists (billing/technical/sales)
└── requirements.txt
Як запустити
git clone https://github.com/AgentPatterns-tech/agentpatterns.git
cd agentpatterns
cd examples/agent-patterns/routing-agent/python
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
Потрібен Python 3.11+.
Варіант через export:
export OPENAI_API_KEY="sk-..."
# optional:
# export OPENAI_MODEL="gpt-4.1-mini"
# export OPENAI_TIMEOUT_SECONDS="60"
python main.py
Варіант через .env (опційно)
cat > .env <<'EOF_ENV'
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1-mini
OPENAI_TIMEOUT_SECONDS=60
EOF_ENV
set -a
source .env
set +a
python main.py
Це shell-варіант (macOS/Linux). На Windows простіше використовувати set змінних або, за бажанням, python-dotenv, щоб підвантажувати .env автоматично.
Задача
Уяви, що користувач пише в сапорт:
"З мене зняли оплату за підписку 10 днів тому. Чи можу я повернути гроші?"
Агент не має вирішувати це сам. Він має:
- зрозуміти тип запиту (
billing/technical/sales) - обрати потрібного спеціаліста
- передати задачу worker-у
- якщо треба, змінити маршрут (
needs_reroute) - дати фінальну відповідь тільки після результату від worker-а
Рішення
Тут агент сам нічого не “вирішує по суті”. Він тільки обирає, кому передати звернення.
- модель каже: до кого направити запит
- система перевіряє, що такий маршрут дозволений
- спеціаліст (worker) виконує роботу
- якщо маршрут не підійшов, агент обирає іншого
- коли є готовий результат, агент формує фінальну відповідь
- Не ReAct: бо тут не потрібно робити багато кроків/інструментів, потрібен один правильний вибір виконавця.
- Не Orchestrator: бо тут немає паралельних підзадач, є один доменний маршрут для делегування.
Код
tools.py — спеціалізовані workers
from __future__ import annotations
from typing import Any
USERS = {
42: {"id": 42, "name": "Anna", "country": "US", "tier": "pro"},
7: {"id": 7, "name": "Max", "country": "US", "tier": "free"},
}
BILLING = {
42: {
"currency": "USD",
"plan": "pro_monthly",
"price_usd": 49.0,
"days_since_first_payment": 10,
},
7: {
"currency": "USD",
"plan": "free",
"price_usd": 0.0,
"days_since_first_payment": 120,
},
}
def _extract_user_id(ticket: str) -> int:
if "user_id=7" in ticket:
return 7
return 42
def _contains_any(text: str, keywords: list[str]) -> bool:
lowered = text.lower()
return any(keyword in lowered for keyword in keywords)
def billing_specialist(ticket: str) -> dict[str, Any]:
if not _contains_any(ticket, ["refund", "charge", "billing", "invoice"]):
return {
"status": "needs_reroute",
"reason": "ticket_not_billing",
"domain": "billing",
}
user_id = _extract_user_id(ticket)
user = USERS.get(user_id)
billing = BILLING.get(user_id)
if not user or not billing:
return {"status": "done", "domain": "billing", "error": "user_not_found"}
is_refundable = (
billing["plan"] == "pro_monthly" and billing["days_since_first_payment"] <= 14
)
refund_amount = billing["price_usd"] if is_refundable else 0.0
return {
"status": "done",
"domain": "billing",
"result": {
"user_name": user["name"],
"plan": billing["plan"],
"currency": billing["currency"],
"refund_eligible": is_refundable,
"refund_amount_usd": refund_amount,
"reason": "Pro monthly subscriptions are refundable within 14 days.",
},
}
def technical_specialist(ticket: str) -> dict[str, Any]:
if not _contains_any(ticket, ["error", "bug", "incident", "api", "latency"]):
return {
"status": "needs_reroute",
"reason": "ticket_not_technical",
"domain": "technical",
}
return {
"status": "done",
"domain": "technical",
"result": {
"incident_id": "INC-4021",
"service": "public-api",
"state": "mitigated",
"next_update_in_minutes": 30,
},
}
def sales_specialist(ticket: str) -> dict[str, Any]:
if not _contains_any(ticket, ["price", "pricing", "quote", "plan", "discount"]):
return {
"status": "needs_reroute",
"reason": "ticket_not_sales",
"domain": "sales",
}
return {
"status": "done",
"domain": "sales",
"result": {
"recommended_plan": "team_plus",
"currency": "USD",
"monthly_price_usd": 199.0,
"reason": "Best fit for teams that need priority support and usage controls.",
},
}
Що тут найважливіше (простими словами)
- Workers є детермінованим execution-layer і не містять LLM-логіки.
- Router вирішує, кого викликати, але не виконує доменну бізнес-логіку сам.
needs_rerouteдає безпечний сигнал на повторну маршрутизацію замість «вигаданого» результату.
gateway.py — policy boundary (найважливіший шар)
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass
from typing import Any, Callable
class StopRun(Exception):
def __init__(self, reason: str):
super().__init__(reason)
self.reason = reason
@dataclass(frozen=True)
class Budget:
max_route_attempts: int = 3
max_delegations: int = 3
max_seconds: int = 60
def _stable_json(value: Any) -> str:
if value is None or isinstance(value, (bool, int, float, str)):
return json.dumps(value, ensure_ascii=True, sort_keys=True)
if isinstance(value, list):
return "[" + ",".join(_stable_json(item) for item in value) + "]"
if isinstance(value, dict):
parts = []
for key in sorted(value):
parts.append(
json.dumps(str(key), ensure_ascii=True) + ":" + _stable_json(value[key])
)
return "{" + ",".join(parts) + "}"
return json.dumps(str(value), ensure_ascii=True)
def _normalize_for_hash(value: Any) -> Any:
if isinstance(value, str):
return " ".join(value.strip().split())
if isinstance(value, list):
return [_normalize_for_hash(item) for item in value]
if isinstance(value, dict):
return {str(key): _normalize_for_hash(value[key]) for key in sorted(value)}
return value
def _normalize_ticket(value: str) -> str:
return " ".join(value.strip().split())
def args_hash(args: dict[str, Any]) -> str:
normalized = _normalize_for_hash(args or {})
raw = _stable_json(normalized)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
def validate_route_action(
action: Any,
*,
allowed_routes: set[str],
previous_target: str | None = None,
previous_status: str | None = None,
) -> dict[str, Any]:
if not isinstance(action, dict):
raise StopRun("invalid_route:not_object")
kind = action.get("kind")
if kind == "invalid":
raise StopRun("invalid_route:non_json")
if kind != "route":
raise StopRun("invalid_route:bad_kind")
allowed_keys = {"kind", "target", "args"}
if set(action.keys()) - allowed_keys:
raise StopRun("invalid_route:extra_keys")
target = action.get("target")
if not isinstance(target, str) or not target.strip():
raise StopRun("invalid_route:missing_target")
target = target.strip()
if target not in allowed_routes:
raise StopRun(f"invalid_route:route_not_allowed:{target}")
args = action.get("args", {})
if args is None:
args = {}
if not isinstance(args, dict):
raise StopRun("invalid_route:bad_args")
ticket = args.get("ticket")
if not isinstance(ticket, str) or not ticket.strip():
raise StopRun("invalid_route:missing_ticket")
ticket = _normalize_ticket(ticket)
normalized_args = {**args, "ticket": ticket}
if previous_status == "needs_reroute" and target == previous_target:
raise StopRun("invalid_route:repeat_target_after_reroute")
return {"kind": "route", "target": target, "args": normalized_args}
class RouteGateway:
def __init__(
self,
*,
allow: set[str],
registry: dict[str, Callable[..., dict[str, Any]]],
budget: Budget,
):
self.allow = set(allow)
self.registry = registry
self.budget = budget
self.delegations = 0
self.seen_routes: set[str] = set()
def call(self, target: str, args: dict[str, Any]) -> dict[str, Any]:
self.delegations += 1
if self.delegations > self.budget.max_delegations:
raise StopRun("max_delegations")
if target not in self.allow:
raise StopRun(f"route_denied:{target}")
worker = self.registry.get(target)
if worker is None:
raise StopRun(f"route_missing:{target}")
signature = f"{target}:{args_hash(args)}"
if signature in self.seen_routes:
raise StopRun("loop_detected")
self.seen_routes.add(signature)
try:
return worker(**args)
except TypeError as exc:
raise StopRun(f"route_bad_args:{target}") from exc
except Exception as exc:
raise StopRun(f"route_error:{target}") from exc
Що тут найважливіше (простими словами)
validate_route_action(...)— governance/control layer для route-рішення від LLM.- Route обробляється як недовірений input і проходить strict validation (обовʼязковий
ticket, нормалізаціяticket, policy guard після reroute). RouteGateway.call(...)— межаagent ≠ executor: router вирішує маршрут, gateway безпечно делегує worker.loop_detectedловить exact-repeat (target + args_hash), аargs_hashнормалізує пробіли в string-аргументах.
llm.py — routing decision + final synthesis
LLM бачить лише каталог доступних routes; якщо route не в allowlist, policy boundary зупинить run.
from __future__ import annotations
import json
import os
from typing import Any
from openai import APIConnectionError, APITimeoutError, OpenAI
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
LLM_TIMEOUT_SECONDS = float(os.getenv("OPENAI_TIMEOUT_SECONDS", "60"))
class LLMTimeout(Exception):
pass
class LLMEmpty(Exception):
pass
ROUTER_SYSTEM_PROMPT = """
You are a routing decision engine.
Return only one JSON object in this exact shape:
{"kind":"route","target":"<route_name>","args":{"ticket":"..."}}
Rules:
- Choose exactly one target from available_routes.
- Never choose targets from forbidden_targets.
- Keep args minimal and valid for that target.
- If previous attempts failed with needs_reroute, choose a different target.
- Respect routing budgets and avoid unnecessary retries.
- Do not answer the user directly.
- Never output markdown or extra keys.
""".strip()
FINAL_SYSTEM_PROMPT = """
You are a support response assistant.
Write a short final answer in English for a US customer.
Use only evidence from delegated specialist observation.
Include: selected specialist, final decision, and one reason.
For billing refunds, include amount in USD when available.
""".strip()
ROUTE_CATALOG = [
{
"name": "billing_specialist",
"description": "Handle refunds, charges, invoices, and billing policy",
"args": {"ticket": "string"},
},
{
"name": "technical_specialist",
"description": "Handle errors, incidents, API issues, and outages",
"args": {"ticket": "string"},
},
{
"name": "sales_specialist",
"description": "Handle pricing, plan recommendations, and quotes",
"args": {"ticket": "string"},
},
]
def _get_client() -> OpenAI:
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise EnvironmentError(
"OPENAI_API_KEY is not set. Run: export OPENAI_API_KEY='sk-...'"
)
return OpenAI(api_key=api_key)
def _build_state_summary(history: list[dict[str, Any]]) -> dict[str, Any]:
routes_used = [
step.get("route", {}).get("target")
for step in history
if isinstance(step, dict)
and isinstance(step.get("route"), dict)
and step.get("route", {}).get("kind") == "route"
]
routes_used_unique = list(dict.fromkeys(route for route in routes_used if route))
last_route_target = routes_used[-1] if routes_used else None
last_observation = history[-1].get("observation") if history else None
last_observation_status = (
last_observation.get("status") if isinstance(last_observation, dict) else None
)
return {
"attempts_completed": len(history),
"routes_used_unique": routes_used_unique,
"last_route_target": last_route_target,
"last_observation_status": last_observation_status,
"last_observation": last_observation,
}
def decide_route(
goal: str,
history: list[dict[str, Any]],
*,
max_route_attempts: int,
remaining_attempts: int,
forbidden_targets: list[str],
) -> dict[str, Any]:
recent_history = history[-3:]
payload = {
"goal": goal,
"budgets": {
"max_route_attempts": max_route_attempts,
"remaining_attempts": remaining_attempts,
},
"forbidden_targets": forbidden_targets,
"state_summary": _build_state_summary(history),
"recent_history": recent_history,
"available_routes": ROUTE_CATALOG,
}
client = _get_client()
try:
completion = client.chat.completions.create(
model=MODEL,
temperature=0,
timeout=LLM_TIMEOUT_SECONDS,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": ROUTER_SYSTEM_PROMPT},
{"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
],
)
except (APITimeoutError, APIConnectionError) as exc:
raise LLMTimeout("llm_timeout") from exc
text = completion.choices[0].message.content or "{}"
try:
return json.loads(text)
except json.JSONDecodeError:
return {"kind": "invalid", "raw": text}
def compose_final_answer(
goal: str, selected_route: str, history: list[dict[str, Any]]
) -> str:
payload = {
"goal": goal,
"selected_route": selected_route,
"history": history,
}
client = _get_client()
try:
completion = client.chat.completions.create(
model=MODEL,
temperature=0,
timeout=LLM_TIMEOUT_SECONDS,
messages=[
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
{"role": "user", "content": json.dumps(payload, ensure_ascii=True)},
],
)
except (APITimeoutError, APIConnectionError) as exc:
raise LLMTimeout("llm_timeout") from exc
text = completion.choices[0].message.content or ""
text = text.strip()
if not text:
raise LLMEmpty("llm_empty")
return text
Що тут найважливіше (простими словами)
decide_route(...)— decision-stage для вибору виконавця.- Для продакшен-звички в prompt ідуть
state_summary + recent_history + budgets, а не весь сирий лог. forbidden_targetsдає LLM явну заборону на повтор того самого target післяneeds_reroute.state_summaryстабілізує routing черезroutes_used_unique,last_route_target,last_observation_status.timeout=LLM_TIMEOUT_SECONDSіLLMTimeoutдають керовану зупинку при проблемах мережі/моделі.- Порожня фінальна відповідь не маскується fallback-текстом: повертається явний
llm_empty.
main.py — Route -> Delegate -> Finalize
from __future__ import annotations
import json
import time
from typing import Any
from gateway import Budget, RouteGateway, StopRun, args_hash, validate_route_action
from llm import LLMEmpty, LLMTimeout, compose_final_answer, decide_route
from tools import billing_specialist, sales_specialist, technical_specialist
GOAL = (
"User Anna (user_id=42) asks: Can I get a refund for my pro_monthly subscription "
"charged 10 days ago? Route to the correct specialist and provide a short final answer."
)
BUDGET = Budget(max_route_attempts=3, max_delegations=3, max_seconds=60)
ROUTE_REGISTRY = {
"billing_specialist": billing_specialist,
"technical_specialist": technical_specialist,
"sales_specialist": sales_specialist,
}
ALLOWED_ROUTE_TARGETS_POLICY = {
"billing_specialist",
"technical_specialist",
"sales_specialist",
}
ALLOWED_ROUTE_TARGETS_EXECUTION = {
"billing_specialist",
"technical_specialist",
"sales_specialist",
}
def run_routing(goal: str) -> dict[str, Any]:
started = time.monotonic()
trace: list[dict[str, Any]] = []
history: list[dict[str, Any]] = []
gateway = RouteGateway(
allow=ALLOWED_ROUTE_TARGETS_EXECUTION,
registry=ROUTE_REGISTRY,
budget=BUDGET,
)
for attempt in range(1, BUDGET.max_route_attempts + 1):
elapsed = time.monotonic() - started
if elapsed > BUDGET.max_seconds:
return {
"status": "stopped",
"stop_reason": "max_seconds",
"trace": trace,
"history": history,
}
previous_step = history[-1] if history else None
previous_observation = (
previous_step.get("observation")
if isinstance(previous_step, dict)
else None
)
previous_route = previous_step.get("route") if isinstance(previous_step, dict) else None
previous_status = (
previous_observation.get("status")
if isinstance(previous_observation, dict)
else None
)
previous_target = (
previous_route.get("target")
if isinstance(previous_route, dict)
else None
)
forbidden_targets = (
[previous_target]
if previous_status == "needs_reroute" and isinstance(previous_target, str)
else []
)
try:
raw_route = decide_route(
goal=goal,
history=history,
max_route_attempts=BUDGET.max_route_attempts,
remaining_attempts=(BUDGET.max_route_attempts - attempt + 1),
forbidden_targets=forbidden_targets,
)
except LLMTimeout:
return {
"status": "stopped",
"stop_reason": "llm_timeout",
"phase": "route",
"trace": trace,
"history": history,
}
try:
route_action = validate_route_action(
raw_route,
allowed_routes=ALLOWED_ROUTE_TARGETS_POLICY,
previous_target=previous_target,
previous_status=previous_status,
)
except StopRun as exc:
return {
"status": "stopped",
"stop_reason": exc.reason,
"phase": "route",
"raw_route": raw_route,
"trace": trace,
"history": history,
}
target = route_action["target"]
route_args = route_action["args"]
try:
observation = gateway.call(target, route_args)
trace.append(
{
"attempt": attempt,
"target": target,
"args_hash": args_hash(route_args),
"ok": True,
}
)
except StopRun as exc:
trace.append(
{
"attempt": attempt,
"target": target,
"args_hash": args_hash(route_args),
"ok": False,
"stop_reason": exc.reason,
}
)
return {
"status": "stopped",
"stop_reason": exc.reason,
"phase": "delegate",
"route": route_action,
"trace": trace,
"history": history,
}
history.append(
{
"attempt": attempt,
"route": route_action,
"observation": observation,
}
)
observation_status = observation.get("status")
if trace:
trace[-1]["observation_status"] = observation_status
if isinstance(observation, dict) and observation.get("domain"):
trace[-1]["domain"] = observation.get("domain")
if observation_status == "needs_reroute":
continue
if observation_status != "done":
return {
"status": "stopped",
"stop_reason": "route_bad_observation",
"phase": "delegate",
"route": route_action,
"expected_statuses": ["needs_reroute", "done"],
"received_status": observation_status,
"bad_observation": observation,
"trace": trace,
"history": history,
}
try:
answer = compose_final_answer(
goal=goal,
selected_route=target,
history=history,
)
except LLMTimeout:
return {
"status": "stopped",
"stop_reason": "llm_timeout",
"phase": "finalize",
"route": route_action,
"trace": trace,
"history": history,
}
except LLMEmpty:
return {
"status": "stopped",
"stop_reason": "llm_empty",
"phase": "finalize",
"route": route_action,
"trace": trace,
"history": history,
}
return {
"status": "ok",
"stop_reason": "success",
"selected_route": target,
"answer": answer,
"trace": trace,
"history": history,
}
return {
"status": "stopped",
"stop_reason": "max_route_attempts",
"trace": trace,
"history": history,
}
def main() -> None:
result = run_routing(GOAL)
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()
Що тут найважливіше (простими словами)
run_routing(...)керує повним життєвим цикломRoute -> Delegate -> Finalize.- Router (LLM) не виконує роботу — execution робить тільки worker через
RouteGateway. - Якщо route некоректний, повертається
raw_routeдля дебагу. - Якщо reroute потрібен, policy не дозволяє повторити той самий target (
invalid_route:repeat_target_after_reroute). - Для дебагу в stop-відповідях повертається
phase(route/delegate/finalize). historyпрозоро фіксує route-рішення та observation кожної спроби.
requirements.txt
openai==2.21.0
Приклад виводу
Маршрут і порядок route-спроб можуть відрізнятись між запусками, але policy-гейти і stop reasons залишаються стабільними.
{
"status": "ok",
"stop_reason": "success",
"selected_route": "billing_specialist",
"answer": "The billing specialist reviewed your request and confirmed that your pro_monthly subscription charged 10 days ago is eligible for a refund. You will receive a refund of $49.00 because pro monthly subscriptions are refundable within 14 days.",
"trace": [
{
"attempt": 1,
"target": "billing_specialist",
"args_hash": "5e89...",
"ok": true,
"observation_status": "done",
"domain": "billing"
}
],
"history": [{...}]
}
Це скорочений приклад: у реальному запуску trace може містити кілька route-спроб.
history — це журнал виконання: для кожної attempt є route і observation.
args_hash — це hash аргументів після нормалізації string-значень (trim + collapse spaces), тому loop detection стійкіше ловить семантично однакові повтори.
Типові stop_reason
success— route обрано, worker відпрацював, фінальна відповідь згенерованаinvalid_route:*— route JSON від LLM не пройшов policy validationinvalid_route:non_json— LLM не повернув валідний JSON routeinvalid_route:missing_ticket— route args не містить обовʼязковийticketinvalid_route:route_not_allowed:<target>— route поза allowlist policyinvalid_route:repeat_target_after_reroute— післяneeds_rerouteзнову обрано той самий targetmax_route_attempts— перевищено ліміт reroute-спробmax_delegations— вичерпано ліміт delegation-викликівmax_seconds— перевищено time budget runllm_timeout— LLM не відповів у межахOPENAI_TIMEOUT_SECONDSllm_empty— LLM повернув порожню фінальну відповідь на етапіfinalizeroute_denied:<target>— target заблокований execution allowlistroute_missing:<target>— target відсутній уROUTE_REGISTRYroute_bad_args:<target>— route містить некоректні аргументиroute_bad_observation— worker повернув observation поза контрактом (у результаті єexpected_statuses,received_status,bad_observation)loop_detected— exact repeat (target + args_hash)
У зупинених runs додатково повертається phase, щоб швидко бачити, де саме спрацювала зупинка.
Що тут НЕ показано
- Немає auth/PII та продакшен-контролів доступу до персональних даних.
- Немає retry/backoff політик для LLM і execution-layer.
- Немає бюджетів по токенах/вартості (cost guardrails).
- Workers тут детерміновані mocks для навчання, а не реальні зовнішні системи.
Що спробувати далі
- Прибери
billing_specialistзALLOWED_ROUTE_TARGETS_POLICYі перевірinvalid_route:route_not_allowed:*. - Прибери
billing_specialistтільки зALLOWED_ROUTE_TARGETS_EXECUTIONі перевірroute_denied:*. - Додай неіснуючий target у route JSON і перевір
route_missing:*. - Зміни
GOALна технічний інцидент і перевір маршрутизацію вtechnical_specialist. - Спробуй route без
ticketуargsі перевірinvalid_route:missing_ticket.
Повний код на GitHub
У репозиторії лежить повна runnable-версія цього прикладу: route decision, policy boundary, delegation, reroute fallback і stop reasons.
Переглянути повний код на GitHub ↗