Vol. 20 · Application Engineering
The Python Utility Stack for LLM Pipelines
LLM applications live not only on models. Around them there is almost always templating, structure validation, string sanitization, JSON parsing, regex, file paths, batching, caching, async I/O and little standard library functions that save hours. This volume is not about “AI”, but about service Python, which makes the LLM pipeline stable and readable.
What Is This Layer About?
01FramingLLM application = model + service Python around it
In practice, most of the code around LLM does not generate tokens, but prepares the data for the model call and parses the result afterwards. This is where prompt templates, schema validation, string cleanup, JSON parsing, file/path logic, retries, batching and tracing live.
textTYPICAL NON-MODEL LAYER
input text
↓
normalize / strip / parse
↓
render prompt template
↓
call model
↓
parse JSON / validate schema
↓
log/retry/cache/save
Often it is this layer that determines the reliability of the system more than the choice of model02FramingFour Repetitive Tasks Around LLM
Almost all “helper libraries” around LLM solve one of four problems: text rendering, structure checking, data transformation and orchestration runtime. If you keep this in mind, it is easier to choose a tool without the cult of libraries.
text1.render:
collect string/prompt/message
2.validate:
make sure the structure is correct
3. transform:
clean, parse, serialize, cut
4. orchestrate:
batch, cache, run async, retrace03FramingTaking the library too early is also a mistake
Not every task requires Jinja or Pydantic. Sometimes `f-string`, `dict`, `json.loads` and a couple of string methods are enough. A library is justified when it provides repeatability, safety, or readability better than bare Python.
good rule
- one small prompt → f-string
- a lot of templates and branches → Jinja
- external JSON / API contract → Pydantic
- drag the library for one line `.strip()`
04FramingMental model: entrances are dirty, exits are fragile
LLM pipelines almost always stand between a non-ideal input and a non-ideal output. The user text is dirty, the retrieval context is noisy, the model output is unstable. Therefore, service Python is needed as a layer of normalization and insurance on both sides.
textdirty input
→ make predictable
→ give to the model
→ get fragile output
→ make it safe and structuredTemplating: From f-String to Jinja
05Templatingf-string, `.format()`, Jinja are three different levels
For short prompts, `f-string` is usually sufficient. If reuse and a little more explicit naming is needed, `.format()` is sometimes suitable. When loops, conditions, blocks and external patterns appear on disk, the Jinja zone begins.
| Tool | Good for | Bad for |
|---|---|---|
| f-string | short inline template | complex conditions and loops |
| `.format()` | named placeholders | large prompt trees |
| Jinja | many templates, loops, if, reuse | overkill for one line |
06TemplatingJinja starts with `Environment`
In Jinja, the central object is `Environment`. It stores loader, filters, globals, undefined behavior and template cache. For applications, this means that it is better to initialize the template engine once at the start of the service, rather than creating a new `Template(...)` for each request.
sqlfrom jinja2 import Environment, PackageLoader, StrictUndefined
env = Environment(
loader=PackageLoader("app"),
undefined=StrictUndefined,
)
template = env.get_template("answer_prompt.j2")
prompt = template.render(query=query, context=context)07Templating`StrictUndefined` is more useful for prompts than it seems
By default, the template may silently swallow the missing variable and render empty. For HTML this is already unpleasant, for prompt it is even worse: you will silently lose part of the instruction or context. Therefore, for LLM templates it is often wise to use `StrictUndefined` and fail early.
typical use
- catches missing `context`
- catches typos in keys
- better fail fast than silently wrong prompt
08TemplatingJinja is especially good when the prompt has loops and conditions
As soon as the prompt says “insert all retrieved chunks”, “if there is history - show it”, “if debug mode - add trace”, string concatenation begins to fall apart. Jinja makes these branches explicit and readable.
text{% if history %}
Conversation history:
{% for msg in history %}
- {{ msg }}
{% endfor %}
{% endif %}
Question:
{{ query }}
{% for item in context_items %}
[{{ item.label }}]
{{ item.text }}
{% endfor %}09TemplatingWhen Jinja is not needed
If the template is small, lives close to the code, and has no logic, `f-string` is usually simpler. Jinja makes sense when templates become a separate resource, require reuse, or are used by different teams and modes.
pythonif prompt_fits_on_screen and has_no_loops:
use f-string
else:
use JinjaSchema, Typing and Pydantic
10SchemaTyping, dataclasses and Pydantic are not the same thing
`typing` describes the expected form of the data. `dataclass` conveniently creates lightweight Python objects. `Pydantic` adds runtime validation, coercion, serialization and JSON schema generation. In LLM pipelines, most often `typing` specifies a contract, and `Pydantic` makes it executable on dirty external input/output.
| Layer | What it does it give? | When to take |
|---|---|---|
| `typing` | annotations and contracts | always |
| `dataclass` | light domain objects | internal structures without dirty input |
| `Pydantic` | validation + parsing + dump | API, model output, config, external data |
11Schema`BaseModel` - the main entry point to Pydantic
The main value of `BaseModel` is that it takes imperfect data and tries to force it into the expected form. This is especially useful for model output, webhook payloads, retrieval traces and structured response contracts.
pythonclass Answer(BaseModel):
answer: str
citations: list[str]
confidence: float
obj = Answer.model_validate(payload)12SchemaThree very useful methods: `model_validate`, `model_validate_json`, `model_dump`
In LLM pipelines, these three methods cover the lion's share of needs. `model_validate()` - when you already have a dict. `model_validate_json()` - when model output came as a JSON string. `model_dump()` - when you need to send the structure further to the log, API or prompt.
textAnswer.model_validate({"answer": "...", "citations": []})
Answer.model_validate_json(raw_json_string)
answer_obj.model_dump()13SchemaValidators are needed where “just types” are no longer sufficient
In real code, it is often not enough to say “the field must be a string.” You also need to check that `citations` are not empty, that `score` is in the range, that the two fields are consistent, that the Markdown is cleaned, and that the JSON does not contain forbidden keys. This is where `field_validator` and `model_validator` come in.
pythonclass Answer(BaseModel):
answer: str
citations: list[str]
@field_validator("answer")
@classmethod
def clean_answer(cls, value: str) -> str:
return value.strip()
@model_validator(mode="after")
def ensure_citations(self):
if not self.citations:
raise ValueError("citations required")
return self14SchemaWhen is `dataclass` better than Pydantic
If the data is already trusted and you just want a clean lightweight object for internal calculations, `dataclass` is often better. It's simpler, lighter, and faster for domain objects that don't require runtime validation on every input.
good signal in favor of dataclass
- the object is created only from already validated data
- need plain Python carrier object
- I don’t want to drag validation into a hot internal loop
15Schema`typing.Protocol`, `Literal`, `TypedDict` are often underestimated
Even without Pydantic, typing helps LLM code a lot. `Protocol` allows you to describe adapter interfaces, `Literal` fixes valid modes, `TypedDict` is convenient for intermediate JSON-like structures where you don’t want full-fledged objects yet.
pythonclass Embedder(Protocol):
def embed_query(self, text: str) -> list[float]: ...
Mode = Literal["chat", "rag", "judge"]Text Munging: Small Inline Functions with Big Effect
16Text`strip`, `split`, `splitlines`, `join` - four basic workhorses
A lot of LLM code comes down to neatly assembling and disassembling strings. `strip()` removes excess noise at the edges, `split()` cuts along delimiters, `splitlines()` is useful for line-based parsing responses, `join()` puts text back together without ugly concatenation.
pythontext = raw_text.strip()
lines = text.splitlines()
lines = [line.strip() for line in lines if line.strip()]
normalized = "\n".join(lines)17Text`partition()` is often more convenient than `split(..., 1)`
When you need to split a string once into a before/delimiter/after, `partition()` gives a stable triple result and doesn't make you wonder if the delimiter was found. For LLM pipelines, this is useful when parsing headers, source labels, Markdown front matter, and prefixes like `Answer:`.
pythonprefix, sep, rest = raw.partition("Answer:")
if sep:
answer = rest.strip()
else:
answer = raw.strip()18Text`removeprefix` and `removesuffix` are safer than `replace`
If you need to remove a specific prefix or suffix, it is better to use special methods rather than `replace()`. Otherwise, you can unexpectedly delete the desired fragment somewhere inside the line. This is especially noticeable when working with marks like `json\n...`, `assistant:` and markdown fences.
textraw = raw.removeprefix("```json").removesuffix("```")
raw = raw.strip()19Text`textwrap.dedent()` saves multi-line prompts
When prompt is written in triple quotes inside Python code, there is almost always extra indentation. `textwrap.dedent()` makes the template visually beautiful in code and at the same time removes the overall leading whitespace before sending it to the model.
textprompt = dedent("""
You are a helpful assistant.
Return valid JSON.
Question: {query}
""").strip()20Text`re` is needed not for “understanding the text”, but for targeted cleaning and extraction
Regular expressions in LLM code are useful for limited, formal things: extracting fenced JSON, replacing extra spaces, finding citation labels, normalizing bullet markers. They are bad for complex semantic parsing, but great for dirty mechanics.
textjson_block = re.search(r"```json\s*(.*?)\s*```", raw, re.S)
citations = re.findall(r"\[SOURCE\s+\d+\]", raw)
clean = re.sub(r"\n{3,}", "\n\n", raw)important rule
- regex for format
- regex for complex JSON structure
- write raw strings: `r\"...\"`
21Text`replace()` is good, as long as replacement is really stupid
`replace()` is great for banal normalization: replace `\r\n` with `\n`, remove non-breaking spaces, bring Unicode quotes to the same style. But as soon as the replacement has context and conditions, it is better to move on to regex or explicit parsing.
texttext = text.replace("\r\n", "\n")
text = text.replace("\u00a0", " ")
text = text.replace("—", "-")22TextList comprehensions + `join()` are often better than complex loops
Many small preparatory steps can be expressed in one readable line: clear the list of chunks, discard empty ones, add labels and assemble the final context block. This makes prompt-building shorter and more transparent.
pythoncontext_block = "\n\n".join(
f"[SOURCE {i}] {item.text.strip()}"
for i, item in enumerate(context_items, start=1)
if item.text.strip()
)JSON, Files, Paths And Storage Media
23Data`json.loads` / `json.dumps` - almost mandatory layer around structured output
Even if the model promises to “return JSON,” the code still needs an explicit parse step. `json.loads()` converts string into Python structures, `json.dumps()` - vice versa. It's useful to remember that parsing untrusted JSON can be expensive, meaning input size and parsing errors need to be controlled.
pythontry:
data = json.loads(raw_model_output)
except json.JSONDecodeError:
return fallback_or_retry()
validated = ResponseModel.model_validate(data)24Data`pathlib.Path` makes file code cleaner
For prompt files, evaluation datasets, cached traces, and local documents, `Path` is usually more convenient than manually working with path strings. Plus it is easier to combine with glob search and cross-platform logic.
sqlfrom pathlib import Path
prompt_dir = Path("prompts")
template_path = prompt_dir / "judge.j2"
text = template_path.read_text(encoding="utf-8")25DataCSV is still useful for eval sets and batch inference
There is a surprising amount of CSV around LLM pipelines: offline evaluation, manual markup, log export, FAQ import, list of golden queries. The standard `csv` module can already do quite a lot and does not require dragging pandas if the task is small.
textwith open("eval.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader)26DataSerialization is part of the contract, not a technical detail
As soon as trace, chunks, answers or prompts are written to a file or log, you already live in a world of format conventions. Therefore, it is useful to decide in advance whether you have line-delimited JSON, a regular JSON array, CSV or plain text logs, rather than writing “whatever happens.”
simple landmark
- JSONL for traces/events
- CSV for manual tables and eval
- plain text for prompt snapshots
- format should be fixed early
27Data`dict.get()` and `setdefault()` are often a rescue from brittle code
Not every intermediate structure in a pipeline requires a full model. When you're working with half-baked dicts from retrieval payloads, API responses, or trace blocks, `get()` and `setdefault()` make the code smoother and reduce the noise from checking for keys.
textsource = payload.get("source_url")
meta = payload.setdefault("metadata", {})
meta.setdefault("tenant_id", tenant_id)Pipeline Utilities: Collections, Itertools, Cache, Async
28Pipeline`defaultdict` and `Counter` - small but very useful
LLM pipelines constantly group something and count: hits by `doc_id`, errors by type, citation coverage, usage stats, retry reasons. For this, `defaultdict` and `Counter` often produce more readable code than manual `if key not in dict` and increments.
pythonby_doc = defaultdict(list)
for hit in hits:
by_doc[hit.doc_id].append(hit)
error_counts = Counter(trace.reason for trace in traces)29Pipeline`itertools.batched`, `chain`, `islice` help with batch flow
When you need to process documents into embedding batches, lazily merge multiple data streams, or take only the first N elements without extra copies, `itertools` come in very handy. In LLM code, this is especially common in ingestion and bulk evaluation.
pythonfor batch in batched(chunks, 32):
vectors = embedder.embed_documents([c.text for c in batch])
all_queries = chain(user_queries, golden_queries)
preview = list(islice(all_queries, 10))30Pipeline`functools.cache` / `lru_cache` are useful for deterministic helpers
A cache based on pure helper functions sometimes gives unexpected benefits: tokenization of repeating pieces, reading templates from disk, normalizing directories, calculating small derived settings. But the cache is poorly suited for functions with side effects, async generators, and things that depend on rapidly changing external state.
python@lru_cache(maxsize=128)
def load_prompt(name: str) -> str:
return (Path("prompts") / name).read_text(encoding="utf-8")31Pipeline`functools.partial` works well for preset configurations
When the same helper is called with almost the same arguments, `partial()` helps to capture some of the parameters and make the code shorter. This can be convenient for render helpers, logger wrappers and small adapters over SDK calls.
textrender_judge = partial(render_prompt, mode="judge")
render_rag = partial(render_prompt, mode="rag")32Pipeline`asyncio` is needed where the pipeline runs into I/O
Many LLM systems are network-based in nature: external LLM API, vector DB, reranker API, object storage, telemetry backend. Therefore, `asyncio` and `async/await` are often useful not for “AI magic”, but for normal control of parallel I/O. Especially in FastAPI and batch-evaluation pipelines.
typescriptresults = await asyncio.gather(
retrieve(query1),
retrieve(query2),
retrieve(query3),
)
usually useful in conjunction with semaphore/rate limiting33PipelineBatching is not only about models, but also about Python code
If you have 1000 documents for embeddings or 500 eval queries, a naive piece-by-piece loop will often be too slow and expensive. A small utility-layer should be able to accurately transfer data to batches, log the batch size and not lose connection between inputs and outputs.
pythonfor batch in batched(items, BATCH_SIZE):
outputs = call_api(batch)
for item, output in zip(batch, outputs):
save_result(item, output)Practical Playbook And Antipatterns
34OpsDefault mini-stack for most LLM projects
Most application pipelines do not need zoo from 20 libraries. Quite often, one reasonable set is enough: `Jinja` for complex templates, `Pydantic` for contracts, `json`, `re`, `pathlib`, `textwrap`, `collections`, `itertools` and `asyncio` as needed.
minimum set
- Jinja
- Pydantic
- json / re / pathlib / textwrap
- collections / itertools / functools
- add the rest for pain, not for fashion
35OpsFrequent anti-patterns
Many problems are not in the absence of a library, but in its incorrect use. Unseparated prompt lines, silently missing variables in the template, manual parsing of JSON via `split("{")`, regex instead of normal validation and giant utils.py - all this is then costly.
red flags
- prompt via chaotic string concatenation
- without `StrictUndefined` for complex templates
- without `json.loads`/schema validation on model output
- regex as the main parser for complex structures
36BridgeQuick tool selection
If in doubt, it is useful to run the problem through a short decision tree. Usually this is enough to choose a tool without unnecessary complication.
textMINI TREE
Do you just need to insert 2-3 variables?
→ f-string
Need loops/if/templates on disk?
→ Jinja
Need to accept or return external data?
→ Pydantic
Need to quickly clean up a line?
→ str methods/textwrap/re
Need to batch/cache/group?
→ itertools/functools/collections37BridgeThe main idea of the volume
Around LLM pipelines, it is not the most “AI magic” code that wins, but the most predictable service layer. A good utility stack makes prompts explicit, structures validated, strings clean, data serializable, and runtime observable. And this often provides more benefits than another model change.
short reminder
- rendering must be explicit
- structures need to be validated
- lines need to be cleaned boringly and systematically
- the simpler the utility layer, the more stable the entire pipeline
No dead end
Keep moving through the map.
Continue in sequence, switch to a related guide, or return to the seven-track learning map.