# Prompt assembly as engineering: layering, contamination, and trimming

> A prompt is not a string; it is a build artifact with a layered structure, real incidents, and a need for regression protection. Drawing on the real source and tests of a production writing agent, this article dissects layered assembly, a documented incident in which a refactor dropped the quality anchors and generation depth collapsed, few-shot contamination guards, multi-turn history folding, and a hot-swappable overlay layer guarded by golden tests.

- Canonical (HTML): https://kaguc.com/blog/prompt-assembly/
- Date: 2026-07-29


*Also in [中文](/blog/prompt-assembly-zh/). Part 2 of the Engineering LLM Applications series, grounded — like the rest of the series — in the real source code and tests of a production-grade AI writing agent.*

## The problem: when the prompt grows too long for anyone to dare touch

When your first LLM feature ships, the prompt is usually one f-string: a role blurb, format requirements, a few examples, the user input, concatenated and sent. That stage is fine — the problems arrive three months later:

1. **Fix one place, break another.** The same prompt text is reused by several call paths; wording tuned for scenario A quietly degrades scenario B's output, and no test turns red.
2. **Example content leaks into output.** To teach the model *how to write*, you paste in a model essay; one day a user finds that essay's numbers and citations inside their own report.
3. **Multi-turn gets more expensive every turn.** Each turn carries the full history, and the history contains entire documents the model previously produced; token spend climbs with turn count, most of it redundant.
4. **Changing a sentence requires a release.** The prompt is hard-coded in source; tweaking one line of wording in the field means shipping a new package.

These four failure classes share one root cause: the prompt is treated as *a piece of text* rather than a **build artifact**. A build artifact implies three things: a layered structure (each layer with its own change frequency and ownership), incidents (which need incident records), and regression protection (changes are guarded by tests). The rest of this article dissects that practice through `prompts.py` and its tests, from this series' evidence-base project (an AI writing agent producing LaTeX lab reports).

## Mechanism 1: layered assembly — each layer has its own change frequency

The system prompt is a concatenation of five layers (`build_system`), not one string:

| Layer | Content | Change frequency | Hot-swappable |
|---|---|---|---|
| Role boundary | Identity + hard prohibitions (no AI-speak, no fabricated data or citations) | Very low | Via overlay |
| Genre spec | Two section skeletons: lab report / course notes | Low | Via overlay |
| Quality anchors | Writing methodology + golden sample (permanent domain assets) | Low | Via overlay |
| Writing-judgment skill | Distilled "why we write it this way" | High | Distillable, replaceable |
| Output-format hard rules | LaTeX template skeleton + 6 compile-error pitfalls | Tracks the template | Via overlay |

Three design points:

**Move knowledge assets out of code.** The methodology (~4KB of Markdown) and the golden sample (a 2.2KB LaTeX technique excerpt) live under the `knowledge/` directory as file assets; code only assembles. Together with the bib file and the material pack they form the agent's domain assets — reviewable and replaceable independently of the code.

**Write format rules with their error messages attached.** The output-format layer does not just say "do this"; it lists six numbered pitfalls, each with the real compile error that violation triggers — e.g. "before calling `\makeEngPage{}` you must `\def` the English metadata … missing any one of them raises *Undefined control sequence*", and "bold vectors only with `\symbf{}`; `\mathbf` triggers *Extended mathchar*". These were baked into the prompt after real failures — the compiler's error-driven experience, front-loaded.

**Trim by call site.** Section-level small edits (scoped editing, see [The deterministic boundary](/blog/deterministic-boundary/)) assemble with `scoped=True`: the quality anchors are dropped — methodology plus golden sample come to roughly 3.2k characters, about 2k tokens, and a one-section fragment edit has no use for a full model essay about someone else's experiment. The role boundary, the distilled judgment layer, and the format rules stay, because the fragment must still be stylistically correct and compilable.

```mermaid
flowchart TD
    A[Assemble system] --> B[Role boundary]
    B --> C[Genre spec: report or notes]
    C --> D{Scoped small edit?}
    D -->|yes| E[Skip quality anchors, keep distilled judgment layer]
    D -->|no, full generation| F{Distilled skill present?}
    F -->|yes| G[Fixed quality anchors, dedup, then judgment layer]
    F -->|no| H[Built-in judgment layer = the anchors]
    E --> I[Output-format hard rules]
    G --> I
    H --> I
```

## Mechanism 2: a real incident — the quality anchors got skipped

Layering is not about tidiness on paper; it exists because *not* layering caused a real incident. The incident record sits in the docstring of `build_system` itself (translated; the source is Chinese):

```python
"""⚠️ An early L1 refactor made the "distilled template" path (skill_prompt
non-empty) skip builtin_skill entirely → golden sample + methodology lost →
generation depth/style collapse (the root cause of "output is worse with
distilled templates"). Quality anchors are now extracted and always injected,
with dedup for old built-in/legacy templates that carry their own anchors."""
```

The post-mortem: in the early implementation the quality anchors were bundled inside the "built-in skill"; a refactor made the "use a distilled template" path skip the built-in skill entirely — golden sample and methodology vanished together, and generation depth and style collapsed. The symptom read as "distilled templates produce worse output" and was for a while investigated as a distillation-quality problem; the actual cause was a missing layer in assembly.

The fix hardened into two structural decisions. First, **decouple quality anchors from the skill layer**: the anchors are "permanent domain assets", independent of whether a distilled template is in use; a distilled template carries only the judgment layer (why to write it this way) and is not allowed to displace the depth/style anchors. Second, **dedup for legacy templates** — old built-in templates that already carry the anchors are not injected twice:

```python
if skill:
    anchors = _quality_anchors(kind)
    if anchors and "金样例" not in skill:   # skip if the skill already embeds the golden sample
        parts.append(anchors)
    parts.append(skill)
```

The transferable lesson: layered assembly must state explicitly **which layers are unconditionally present** — otherwise any "looks equivalent" refactor can silently remove a layer. A prompt missing a layer throws no error; it only degrades.

## Mechanism 3: few-shot contamination guards

The golden sample is a double-edged sword. It is a model-essay excerpt about YBCO — *someone else's experiment* — used to teach "how to write with depth, to spec, and compilably". The natural risk of few-shot is content contamination: the model copies the sample's subject, numbers, and citations into the user's report — fabricating data by way of the example. There are three guards, all at the wording level:

1. **An isolation label at injection time.** The assembly code wraps the golden sample in a declaration: "it is a model essay about **another experiment** — **never copy its subject / title / keywords / numbers / citations / section layout**; you must write strictly from this session's materials, about the experiment you actually did."
2. **The sample file carries its own immunization comments.** `fewshot_ybco.tex` is not a complete report but a 2.2KB *technique excerpt*: only an equation-bearing principle paragraph, a booktabs table, and similar craft demonstrations survive. Its header comment restates "the topic below is only an example … never copy this excerpt's title, data, citations, or sections", and even the table caption reads "example — a real report must contain this experiment's real data".
3. **The methodology layer adds an iron rule.** The methodology file's own hard-rules section independently repeats: "never copy the model essay's / golden sample's subject, numbers, or citations — that is someone else's experiment."

The triple redundancy is deliberate: wording-level protection is probabilistic, and a single declaration can be diluted in a long context. The final backstop is not in the prompt layer at all — a provenance check runs server-side against the *full* materials, deterministically. That belongs to the deterministic-boundary side of the system.

## Mechanism 4: multi-turn history folding and material trimming

In multi-turn revision, what the model needs is the **thread of per-turn feedback** (what the user objected to first, what changed next) — not the full text of every historical version. Yet the assistant's history messages are precisely entire LaTeX documents. The folding rule in `build_messages`:

```python
if role == "assistant" and "\\documentclass" in content:
    content = "[此前已生成的文档版本，此处省略源码]"   # "earlier document version, source elided"
elif len(content) > max_chars:
    content = content[:max_chars] + "…（略）"
```

Three points. Full documents are replaced by a one-line placeholder — the current document is already supplied separately in this turn's user content, so re-sending every historical version burns tokens for zero value. **Non-document assistant notes are kept** — e.g. an auto-exploration conclusion like "the data is in the xrd/ directory" is a fact later turns need. Everything else over 4,000 characters is truncated, and only the last 40 history messages are kept. Dedicated tests pin this behavior (document folding, note preservation, count capping).

Materials get the same deterministic trimming (zero LLM calls, zero tokens): in revision turns, materials over 12k characters are filtered by lexical overlap with the current feedback; pure-number matrix lines (bare CSV) are always dropped — the figure pipeline has already digested the raw data, and prose writing has no use for row-level numbers. If filtering keeps fewer than two lines and under 40 characters, it falls back to the head of the material — filtering must never destroy all context. On the chat path, materials become a **per-file-quota** stable digest (each file guaranteed at least a 600-character allowance, so later files are not starved by earlier large ones), depending only on the material itself and therefore byte-identical across turns — usable as a stable first-message prefix that hits DeepSeek's automatic prefix caching.

## Mechanism 5: the overlay hot-swap layer and its regression gates

The last cost of hard-coded prompts is release coupling: changing one sentence in the field means shipping a new installer. The remedy is the agent-pack overlay:

```python
def _prompt_part(rel: str, default: str) -> str:
    return _overlay_read(rel) or default
```

Role, genre specs, format rules, methodology, and golden sample are all overridable: a same-named non-empty file in the overlay directory wins; otherwise the built-in default applies. `_overlay_read` **reads on every call** (lazy loading) — swap the pack and the next generation uses it, with no recompile and no restart; a failed read or an empty file returns the empty string and falls back to the built-in, so an empty pack can never wipe out the knowledge.

The danger of a hot-swap layer is that **behavior with no overlay must not change by a single byte**. Two groups of offline tests guard this:

- **Overlay behavior tests**: no overlay → bundled defaults; overlay takes precedence and is lazy (change the content from V1 to V2 and the next call sees V2); an empty file falls back to the built-in.
- **Prompt-shape assertions in the golden regression gate**: the scoped prompt must still contain the "rewrite only this block" instruction and the prohibition wording (with `\documentclass` and `\end{document}` appearing *inside the prohibition*); `build_user(gaps=None)` must be byte-identical to calling without `gaps` — proving a newly added parameter did not contaminate the existing path.

Both groups are plain `pytest`, fully offline: whoever edits the prompt back to "output the full document", or lets the default path drift, turns CI red immediately.

## Costs and gains

| Mechanism | Cost of not doing it | Effect in practice |
|---|---|---|
| Layering + scoped trimming | Full system prompt on every small edit | ~2k tokens of quality anchors saved per scoped edit; with scoped editing, output tokens drop an order of magnitude |
| Fixed anchor injection | Relying on each template to carry its own | Fixed the "distilled-template collapse" incident; dedup prevents double injection |
| Contamination labels × 3 | Bare few-shot, model-essay content leaking | Three wording-level guards + server-side provenance backstop |
| History folding + material trimming | Re-sending all history and materials every turn | Full documents fold to a one-line placeholder; 40-message / 4,000-character double cap; stable prefix hits caching |
| Overlay hot-swap | Every wording change is a release | Swap the pack, next generation applies it; no-overlay behavior byte-identical, guarded by golden gates |

## Where not to do this

1. **Do not layer during prototyping.** With one call path and a prompt still changing daily, one f-string *is* the correct form; layering and golden gates are for the stage where the prompt has stabilized, multiple call sites reuse it, and the field needs hot swaps. Premature abstraction pays every cost twice.
2. **Shape assertions do not test semantics.** The golden gate asserts keyword presence and byte identity; a rewording that preserves the keywords passes green while quality shifts. Semantic drift needs online evaluation sets — see part 8 of this series, [The testing pyramid for LLM applications](/blog/llm-testing-pyramid/).
3. **Contamination guards are probabilistic.** Isolation labels lower the copy probability; they do not zero it. High-risk domains (numbers, citations) need deterministic verification outside the prompt as the backstop — the defense cannot live in wording alone.
4. **Hot swapping cuts both ways.** The overlay lets a field pack bypass the bundled tests — golden gates guard the built-in defaults, not the pack's contents. Hot-swap capability must come with an acceptance process for the pack itself; otherwise it is an unguarded change channel.

