AI Awareness for Backend Engineers: LLMs and Tool Calling
A backend-focused primer on LLMs, tokens, prompt engineering, hallucinations, function calling, and safely integrating an LLM API into a service.
AI Awareness for Backend Engineers
You don't need to train a model or understand transformer attention math to ship an AI feature responsibly. What you need — and what most backend teams are missing — is the same rigor you already apply to any external dependency: know its failure modes, bound its cost, validate its output, and never trust it with more authority than it's earned. This guide gives you exactly enough LLM literacy to design, review, and operate an AI-adjacent feature in a backend service. It is deliberately not a full LLM engineering course — for that depth (RAG architectures, fine-tuning, evaluation harnesses, agent frameworks), this site's dedicated Gen AI track goes much further.
1. What an LLM Actually Is, at the Level You Need
A large language model is a function: given a sequence of text, it predicts a probability distribution over the next token, samples one, and repeats. That's it. Everything else — chat behavior, "reasoning," tool use — is built on top of that one primitive, shaped by how the model was trained (pretraining on massive text corpora, then fine-tuned on instruction-following and human-preference data).
The practical consequence of "next-token prediction": an LLM has no concept of "I don't know." It generates the statistically plausible continuation of the text so far, whether or not that continuation is factually true. This single fact explains most of what you need to design around — hallucination, why grounding matters, and why validation is not optional.
The API shape you'll actually integrate against
Every major LLM provider (Anthropic, OpenAI, and others) exposes essentially the same shape: you send a list of role-tagged messages, you get back generated text (optionally streamed token-by-token) plus usage metadata.
{
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"system": "You are a support-ticket summarizer. Output valid JSON only.",
"messages": [
{ "role": "user", "content": "Summarize: Customer reports checkout button unresponsive on mobile Safari after promo code applied." }
]
}2. Tokens and Context Windows
Models don't operate on characters or words — they operate on tokens, sub-word chunks produced by a tokenizer. This matters for three very concrete backend reasons: cost, latency, and hard input limits.
| Concept | What it means | Backend implication |
|---|---|---|
| Token | A sub-word unit (roughly 4 characters / 0.75 words in English) | Pricing and limits are measured in tokens, not characters |
| Context window | Maximum tokens (input + output combined) a model can process in one request | A large document or long chat history can silently exceed the limit |
| Input tokens | Everything you send: system prompt, history, user message, tool definitions | Every retry, every few-shot example, every tool schema costs tokens |
| Output tokens | Everything the model generates | Usually priced higher per-token than input; max_tokens caps this |
// Rough token estimation for cost/limit checks before calling the API —
// use the provider's official tokenizer for anything precision-critical
int estimatedTokens = text.length() / 4; // ballpark only, not exactA context window overflow doesn't fail gracefully by default in every integration path. Depending on the client and provider, sending more tokens than the model supports either truncates silently or returns a hard error. If your feature concatenates unbounded user content (a long support thread, a large document) into a prompt, enforce your own length limit and truncate/summarize deliberately — don't let the provider's limit be your first line of defense.
3. Prompt Engineering Fundamentals
A prompt is the only interface you have to steer model behavior — there's no config file, no method override. Treat it like an API contract you're writing against an unpredictable collaborator.
The three roles
| Role | Purpose | Backend pattern |
|---|---|---|
| System | Sets persistent behavior, constraints, and output format for the whole conversation | Defined once per feature, not per request — e.g., "You are a JSON-only classifier" |
| User | The actual request/content to act on | Built from request payload, user input, or retrieved context |
| Assistant | The model's prior responses (in multi-turn conversations) | Included when maintaining conversational state |
Practical techniques that consistently work
BAD (vague, underspecified):
"Summarize this support ticket."
BETTER (explicit format, constraints, and failure behavior):
"Summarize the support ticket below in exactly 2 sentences.
Extract the customer's core issue and any error messages verbatim.
If no clear issue is stated, respond with exactly: {"issue": "unclear"}.
Respond only with valid JSON matching this schema: {"issue": string, "severity": "low"|"medium"|"high"}.
Ticket: <ticket text>"| Technique | What it does | When to use |
|---|---|---|
| Be explicit about output format | Reduces parsing failures downstream | Any time you programmatically consume the response (almost always, in a backend service) |
| Few-shot examples | Show 2-3 input/output pairs in the prompt | Ambiguous classification/extraction tasks |
| Constrain the failure mode | Tell the model exactly what to output when it's unsure | Prevents free-text hedging that breaks a JSON parser |
| Separate instructions from data | Use clear delimiters (<ticket>...</ticket>, XML tags, or a dedicated system prompt) | Prevents user-supplied content from being interpreted as instructions |
| Ask for structured output / tool use | Use the provider's native structured-output or tool-calling feature instead of "please output JSON" | Whenever the API supports it — far more reliable than prompt-only formatting requests |
Prefer the provider's native structured-output or tool-calling mode over asking nicely for JSON in the prompt. Native structured output is validated against a schema server-side and is dramatically more reliable than hoping the model doesn't wrap its JSON in a markdown code fence or add a conversational preamble.
4. Why Hallucinations Happen, and How to Mitigate Them
A hallucination is the model generating fluent, confident, and factually wrong output — a fabricated API method, a citation to a paper that doesn't exist, a summary that includes a detail never present in the source. This isn't a bug in a particular model; it's an inherent property of next-token generation without a built-in fact-checking mechanism.
Mitigation strategies, from cheapest to most involved
| Strategy | How it helps | Effort |
|---|---|---|
| Grounding — supply the actual source data in the prompt (retrieved docs, DB records) rather than relying on the model's training knowledge | The model summarizes/extracts from provided text instead of recalling from memory | Low-medium (requires a retrieval step) |
| Ask the model to cite/quote its source | Forces traceability; a claim without a matching quote is a red flag | Low |
| Constrain the task to extraction/classification over generation | Extracting from provided text hallucinates far less than open-ended generation | Low (design choice) |
| Lower temperature for factual tasks | Reduces creative sampling variance for tasks that need consistency, not creativity | Trivial (a request parameter) |
| Validate output against ground truth | Programmatically check IDs, dates, amounts against your database before acting on them | Medium |
| Human-in-the-loop for high-stakes actions | A person confirms before the model's output triggers a refund, an email send, or a data mutation | Medium-high, but often mandatory |
Never let raw LLM output directly trigger an irreversible action — a payment, an email to a customer, a database write, an account deletion — without validation or human confirmation in between. Treat the model's output the same way you'd treat unvalidated input from an untrusted client: parse it, validate it against your actual business rules and data, and only then act.
5. Function / Tool Calling
Tool calling (also called function calling) is how you let a model take actions or fetch live data instead of only generating text. You describe available functions with a schema; the model decides when to invoke one and with what arguments; your code executes the actual function — the model never runs anything itself.
Defining a tool
{
"name": "get_order_status",
"description": "Look up the current status and ETA for a customer order by its order ID.",
"input_schema": {
"type": "object",
"properties": {
"orderId": {
"type": "string",
"description": "The order ID, e.g. '4521'."
}
},
"required": ["orderId"]
}
}The model decides when to call a tool and what arguments to pass — but it never executes anything. Your backend code executes the function and is fully responsible for authorization, input validation, and rate limiting on that call, exactly as if the arguments came from any other untrusted client. A model can hallucinate an order ID that doesn't belong to the requesting user; your getOrderStatus implementation must still enforce that the caller is authorized to see that order.
| Design principle | Why |
|---|---|
| Give tools narrow, specific scopes | getOrderStatus(orderId) is safer and more predictable than a generic runQuery(sql) tool |
| Never expose a raw SQL/shell-execution tool to a model | This is effectively giving an unpredictable, prompt-injectable caller direct database/system access |
| Validate and authorize every tool argument server-side | The model's arguments are untrusted input, full stop |
| Return structured, minimal data from tools | Keeps token usage and cost down, and reduces surface area for the model to misinterpret |
6. Wiring an LLM API into a Spring Boot Service
Once you get past the prompt, an LLM API call is an external HTTP dependency — treat it with the same production discipline as any third-party API call: timeouts, retries, circuit breaking, and observability.
@Configuration
public class LlmClientConfig {
@Bean
RestClient llmRestClient(@Value("${llm.api.base-url}") String baseUrl,
@Value("${llm.api.key}") String apiKey) {
return RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("x-api-key", apiKey)
.defaultHeader("anthropic-version", "2023-06-01")
.requestFactory(clientHttpRequestFactory())
.build();
}
private ClientHttpRequestFactory clientHttpRequestFactory() {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(3_000); // fail fast on connection issues
factory.setReadTimeout(20_000); // generation can legitimately take several seconds
return factory;
}
}@Service
public class TicketSummarizerService {
private final RestClient llmRestClient;
private final MeterRegistry meterRegistry;
public TicketSummarizerService(RestClient llmRestClient, MeterRegistry meterRegistry) {
this.llmRestClient = llmRestClient;
this.meterRegistry = meterRegistry;
}
@CircuitBreaker(name = "llmApi", fallbackMethod = "fallbackSummary")
@Retry(name = "llmApi")
@RateLimiter(name = "llmApi")
public TicketSummary summarize(String ticketText) {
// 1. Bound the input — never send unbounded user content
String truncated = truncateToTokenBudget(ticketText, 4_000);
long start = System.nanoTime();
LlmResponse response = llmRestClient.post()
.uri("/v1/messages")
.body(buildRequest(truncated))
.retrieve()
.body(LlmResponse.class);
meterRegistry.timer("llm.request.latency").record(Duration.ofNanos(System.nanoTime() - start));
meterRegistry.counter("llm.tokens.used",
"type", "total").increment(response.usage().totalTokens());
// 2. Validate before trusting the output
TicketSummary summary = parseAndValidate(response.text());
return summary;
}
private TicketSummary fallbackSummary(String ticketText, Throwable t) {
// Degrade gracefully — never let an AI feature take down the whole request
return TicketSummary.unavailable();
}
}# application.yml — Resilience4j config for the LLM dependency
resilience4j:
circuitbreaker:
instances:
llmApi:
sliding-window-size: 20
failure-rate-threshold: 50
wait-duration-in-open-state: 30s
retry:
instances:
llmApi:
max-attempts: 2
wait-duration: 500ms
retry-exceptions:
- java.io.IOException
- org.springframework.web.client.HttpServerErrorException
ratelimiter:
instances:
llmApi:
limit-for-period: 50
limit-refresh-period: 1m
timeout-duration: 0sOnly retry idempotent, safe-to-repeat LLM calls, and never retry on a 4xx. A transient network error or 5xx is retry-safe. A 429 (rate limited) needs backoff, not an immediate retry. And retrying blindly on every failure multiplies your token spend on every transient blip — cap retry attempts low (1-2) and use a circuit breaker so a struggling provider doesn't cascade into your own service's latency budget.
Cost control checklist
| Control | Implementation |
|---|---|
Cap max_tokens on every request | Bounds worst-case output cost and latency per call |
| Bound input size before sending | Truncate/summarize unbounded user content; don't let a 50-page document silently blow the budget |
| Cache repeated prompts/results | Identical classification/summarization requests shouldn't re-call the API |
| Track token usage as a first-class metric | Emit llm.tokens.used per feature so cost is visible in the same dashboards as latency |
| Rate-limit per user/tenant | Prevents a single caller (or a runaway loop) from consuming your entire budget |
| Set a hard monthly spend alert with the provider | A safety net for bugs that slip past the above |
PII and data handling
Treat every LLM API call as sending data to a third party, because it is one — even if it's the same company. Before sending request content:
- Strip or mask PII you don't need the model to see (SSNs, full card numbers, raw passwords).
- Check your provider's data retention and training-use policy — many enterprise API tiers explicitly do not train on your data, but confirm this for your specific contract/tier rather than assuming it.
- Log prompts and responses carefully: application logs are often broader-access than production databases, and logging full user PII into a shared log aggregator can itself be a compliance violation.
- For regulated data (health, financial), confirm the provider and your usage meet the applicable compliance framework (HIPAA, PCI-DSS, etc.) before sending real data — not after.
7. Observability for AI Features
An LLM call is a dependency like any other, but its failure modes are different enough to need their own signals.
| Signal | Why it's distinct from a normal API dependency |
|---|---|
| Token usage per request/feature | Directly maps to cost — no equivalent in a typical REST dependency |
| Latency distribution, not just p50 | Generation latency scales with output length; p99 can be many multiples of p50 |
| Output validation failure rate | How often does the model's output fail your schema/business validation? Rising trend = prompt regression or model drift |
| Fallback/circuit-breaker trip rate | How often are users getting the degraded, non-AI experience? |
| Refusal rate | How often does the model decline to answer? Can indicate a prompt that's inadvertently triggering safety behavior |
Log enough of the prompt/response pair (with PII redacted) to debug a bad output after the fact — but sample it, don't log every full request/response body verbatim at scale. You need the ability to reproduce and diagnose a bad output; you don't need to store gigabytes of raw transcripts.
Key takeaways
- An LLM predicts the next token by statistical plausibility, not by fact-checking — this single property explains hallucination and why validation is mandatory, not optional.
- Tokens, not characters, are the unit of cost and context-window limits — bound your input size explicitly rather than trusting the provider's limit as your only safeguard.
- Prefer native structured-output/tool-calling modes over "please respond in JSON" prompt instructions — they're validated and far more reliable.
- Grounding (supplying real source data in the prompt) is the highest-leverage, lowest-effort mitigation for hallucination.
- In tool calling, the model only decides what to call and with what arguments — your backend code executes it and must independently validate and authorize every argument.
- Never let LLM output directly trigger an irreversible action (payment, email, deletion) without validation or human confirmation.
- Wire an LLM API exactly like any other external dependency: explicit timeouts, capped retries, a circuit breaker, and cost/latency metrics — plus token-usage tracking, which has no equivalent in typical REST integrations.
- Treat every LLM API call as third-party data transmission — mask PII, understand the provider's retention/training policy, and don't over-log full transcripts.
Interview Questions
- In your own words, what does an LLM actually compute when generating a response?
- Why do LLMs hallucinate, and why is this not simply a "bug that will be fixed" in the next model version?
- What is grounding, and why does it reduce hallucination more reliably than prompt instructions alone?
- What's the difference between a token and a word, and why does that distinction matter for cost and context-window limits?
- How does function/tool calling actually work — what does the model do, and what does your backend code do?
- Why should a tool exposed to an LLM never be a raw SQL-execution or shell-execution function?
- If a model calls a tool with an order ID that doesn't belong to the requesting user, whose responsibility is it to catch that, and why?
- How would you design retry behavior for a call to an LLM API? What should and shouldn't be retried?
- What cost controls would you put in place before shipping an LLM-backed feature to production?
- Why is
max_tokensan important parameter to set explicitly on every request? - What PII-handling considerations apply specifically to sending data to an LLM API, beyond a normal third-party API call?
- Why might you prefer a provider's native structured-output feature over asking the model to "output JSON" in a plain-text prompt?
- Describe how you would build a circuit breaker and fallback path around an LLM dependency in a Spring Boot service.
- What metrics would you add to a dashboard specifically for an AI-backed feature, beyond standard latency/error-rate?