4. Guardrails and Metrics
An assistant that talks to 2 million fans will be asked to ignore its rules, will be given CPFs and card numbers, and will sometimes invent an event. The safety mesh of the safety and evals class puts checks on both sides of the model, and metrics make the cost and the latency of every answer visible next to the rest of BoxOffice in Grafana (lab 8).
flowchart LR
q[question] --> in{"input guard"}
in -->|"too long / injection"| block[422 blocked]
in -->|"CPF, card, e-mail -> [CPF] [CARD] [EMAIL]"| agent[RAG + agent loop]
agent --> out{"output guard"}
out -->|"cites only what it received"| ok[answer, grounded=true]
out -->|"invented or missing citations"| flag["answer, grounded=false (+ metric)"] The checks
"""The safety mesh of the assistant: checks before the model (input) and after it (output)."""
import re
INJECTION = re.compile(
r"ignore (all |any )?(the )?(previous|prior|above) (instructions|rules)"
r"|disregard (the )?(system|previous) (prompt|instructions)"
r"|(reveal|print|show) (me )?(your|the) (system prompt|instructions)"
r"|you are now|developer mode|jailbreak",
re.IGNORECASE,
)
PII = [ # replaced BEFORE the text reaches the model, the logs or a third-party provider
(re.compile(r"\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b"), "[CPF]"),
(re.compile(r"\b(?:\d[ -]?){13,16}\b"), "[CARD]"),
(re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"), "[EMAIL]"),
]
CITATION = re.compile(r"\[(event:\d+)\]")
class Blocked(Exception):
def __init__(self, kind: str, message: str):
super().__init__(message)
self.kind = kind
def check_input(text: str, max_chars: int) -> str:
"""Reject what must never reach the model; redact what must not leave the system."""
if not text.strip():
raise Blocked("empty", "Please ask a question.")
if len(text) > max_chars:
raise Blocked("too_long", f"Please keep the question under {max_chars} characters.")
if INJECTION.search(text):
raise Blocked("prompt_injection", "I can only help with BoxOffice events, tickets and orders.")
for pattern, label in PII:
text = pattern.sub(label, text)
return text
DONT_KNOW = re.compile(r"(don't|do not) know|não sei|no information|não encontrei", re.IGNORECASE)
def check_output(answer: str, evidence: set[str]) -> tuple[str, bool]:
"""Grounded = cites at least one document it received and no document it did not.
Invented citations are replaced by "[unverified]"; "I don't know" needs no citation.
"""
cited = set(CITATION.findall(answer))
invented = cited - evidence
for doc_id in invented:
answer = answer.replace(f"[{doc_id}]", "[unverified]")
grounded = not invented and (bool(cited) or bool(DONT_KNOW.search(answer)))
return answer, grounded
| Check | Where | Catches | Does not catch |
|---|---|---|---|
| length limit | input | cost attacks, huge pasted documents | — |
| injection patterns | input | the obvious "ignore previous instructions" | paraphrases, other languages, injections inside documents or tool results |
| PII redaction | input | CPF, card numbers and e-mails reaching the model, the logs or a third-party provider | names, addresses, free-form personal data |
| citation check | output | citations of documents the model never received; answers with no citation at all | a wrong sentence next to a valid citation |
| read-only tools, identity from headers | tools (step 3) | a manipulated model buying, cancelling or reading others' orders | — |
Pattern matching is a first layer, cheap and explainable, never the only one: the strongest defense in this design is architectural — read-only tools and identity that the model cannot forge. Production systems add classifier models (for injection and harmful content), structured outputs, and human review of sampled conversations.
Try them:
You should see HTTP 422 with "blocked":"prompt_injection" — and no LLM call (instant). The redaction can be checked directly in Python:
cd services/assistant
uv run python -c "from app.guardrails import check_input; print(check_input('my CPF is 123.456.789-09 and card 4111 1111 1111 1111, mail ana@x.com', 2000))"
cd ../..
You should see my CPF is [CPF] and card [CARD], mail [EMAIL].
Metrics: tokens, cost, latency
"""LLM metrics with OpenTelemetry, pushed by OTLP to otel-lgtm (lab 8).
In Prometheus they appear as assistant_llm_tokens_total, assistant_llm_cost_usd_total,
assistant_ttft_seconds_bucket, assistant_duration_seconds_bucket, assistant_tool_calls_total,
assistant_guardrail_blocks_total and assistant_requests_total.
"""
import os
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View
from opentelemetry.sdk.resources import Resource
# LLM latencies are seconds, not milliseconds: the default buckets (0, 5, 10, 25 ...) would be useless
SECONDS = ExplicitBucketHistogramAggregation([0.1, 0.25, 0.5, 1, 2, 4, 8, 16, 32, 64])
readers = []
if os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT"): # no backend configured: metrics stay in memory
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
readers.append(PeriodicExportingMetricReader(OTLPMetricExporter()))
provider = MeterProvider(
resource=Resource.create(), # reads OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES
metric_readers=readers,
views=[View(instrument_name="assistant.ttft", aggregation=SECONDS),
View(instrument_name="assistant.duration", aggregation=SECONDS)],
)
metrics.set_meter_provider(provider)
meter = metrics.get_meter("boxoffice.assistant")
requests = meter.create_counter("assistant.requests", description="questions answered, by outcome")
tokens = meter.create_counter("assistant.llm.tokens", description="LLM tokens, by model and type")
cost = meter.create_counter("assistant.llm.cost_usd", description="estimated LLM cost in US dollars")
ttft = meter.create_histogram("assistant.ttft", unit="s", description="time to first answer token")
duration = meter.create_histogram("assistant.duration", unit="s", description="time to the complete answer")
tool_calls = meter.create_counter("assistant.tool.calls", description="tool calls, by tool and outcome")
guardrail_blocks = meter.create_counter("assistant.guardrail.blocks", description="inputs or outputs blocked")
# US dollars per million tokens (input, output). Local models cost electricity, not tokens.
PRICES = {
"anthropic/claude-opus-5-5": (4.00, 20.00),
"anthropic/claude-sonnet-5-5": (2.00, 10.00),
"anthropic/claude-haiku-4-5": (1.00, 5.00),
}
def price(model: str, prompt_tokens: int, completion_tokens: int) -> float:
per_in, per_out = PRICES.get(model, (0.0, 0.0))
return (prompt_tokens * per_in + completion_tokens * per_out) / 1_000_000
The assistant uses the OpenTelemetry SDK (Python has no Java-style agent here) and pushes OTLP to otel-lgtm, so its metrics live next to those of the Java services:
| Prometheus metric | Labels | Question it answers |
|---|---|---|
assistant_llm_tokens_total | model, type (prompt / completion) | how many tokens, and where they go |
assistant_llm_cost_usd_total | model | how much the assistant costs per hour / per answer |
assistant_ttft_seconds_bucket | model | how long the fan stares at an empty bubble |
assistant_duration_seconds_bucket | model | how long until the answer is complete |
assistant_tool_calls_total | tool, outcome | which tools are used, and how often they fail |
assistant_guardrail_blocks_total | kind | injection attempts, ungrounded answers |
assistant_requests_total | outcome | answered vs blocked |
Useful PromQL in Grafana → Explore:
sum(rate(assistant_llm_cost_usd_total[1h])) * 3600
histogram_quantile(0.95, sum by (le) (rate(assistant_ttft_seconds_bucket[5m])))
sum by (kind) (increase(assistant_guardrail_blocks_total[1h]))
sum(rate(assistant_llm_tokens_total{type="completion"}[5m])) / sum(rate(assistant_requests_total{outcome="answered"}[5m]))
(cost per hour; p95 time to first token; blocks per hour by kind; completion tokens per answer.) After a few questions you should see the series in Grafana with service_name="assistant". The prices are a table in the code (PRICES), not something the provider returns: keep it up to date — cost dashboards are only as right as their price list.