Skip to content

14 min read

A shared core with per-model adapters

On this page

Copy-then-edit is the right first move and the wrong second one. The first time a port needs a per-model difference, duplicating the prompt file is faster than designing an abstraction for a difference you have seen once, and the duplication is honest about what you know. The cost arrives on the nth subsequent change: every edit to shared content now has to be applied three times by someone who remembers that there are three, and correctness depends on memory rather than on structure.

Nothing in the toolchain flags this. Three files that are 90% similar are three files, and version control has no opinion about whether an edit to one should have been an edit to all three. The failure is silent, it is proportional to how long the three files have existed, and it lands on whichever model is least used — which is usually the failover path, the one nobody looks at until it is carrying traffic.

The reason no tool flags it deserves to be exact, because "the toolchain should catch this" is the first thing anybody says. git diff compares a file against its own history. It holds no representation of any relationship between system_a.md and system_c.md, so "should this edit also have gone there" is not a question it declines to answer — it is a question that cannot be posed in its vocabulary. Nothing else in an ordinary repository carries that relationship either. The only place it exists is in the head of somebody who knows there are three files, and a fact that lives in a head takes holidays.

Which model the damage lands on follows from the same absence. Once the tooling has no detector, reading the output is the only detector left, so the forked file that stays wrong longest is the one whose output nobody reads. That is the least-used model. On most teams it is the failover path, which means the drift surfaces at the moment failover starts carrying traffic — the moment when nobody has attention to spare for it.

The tempting response is process: a checklist, a line in the pull-request template, a convention that shared edits go to all three. It is tempting for two good reasons. It works, for a while. And every failure it eventually produces looks like an individual lapse rather than a structural one, because somebody did forget. What it cannot change is that correctness still depends on a person holding a fact the system does not carry, so every such scheme has a half-life set by team turnover, and adding a fourth model shortens it.

The fork is the defect

This is the same shape as a translated string table that got copy-pasted per locale, where the English copy is fixed and the German one keeps the typo for a year. The fix in that domain is not to write better process. It is to make the shared thing exist once and the per-locale part exist as a small delta, so that changing the shared thing is one edit by construction.

That comparison is an analogy, and where it stops is where the fix comes from. A string table has a key for every string, so a German file missing checkout.confirm is a fact a script establishes by comparing key sets, and every localisation toolchain does exactly that. A prompt file has no keys. It is prose, and prose carries no identity that survives being paraphrased, so there is nothing to compare and nothing that can be reported missing. Much of the structure below is an argument for giving prompt content keys, and the rendering test at the end of this chapter is the key comparison that localisation has had all along.

For prompts the split runs along a specific line: content that is a fact about your task, your policy or your domain belongs to the core, and content that is a fact about how a particular model is addressed belongs to an adapter.

Prompt elementCore or adapterWhy
Task definition, scope, out-of-scopeCoreSame product on every model
Policy rules (refunds, escalation, prohibitions)CoreChanging them on one model and not another is the bug
Domain vocabulary and definitionsCore"Active subscription" means one thing in your business
Output contract (fields, ranges, units)CoreDownstream code is shared
Failure handling ("if the order is not found, say so")CoreProduct behaviour
Markup and delimiter styleAdapterThe authority differences from earlier in this topic
Turn placement and rule restatementAdapterThe chat-template differences from earlier in this topic
Format-enforcement mechanism (schema, JSON mode, prefill)AdapterCapability of the provider
Tool schema dialect and strictness flagsAdapterShape differs per API
Control parameters (temperature, effort, token caps)AdapterNot comparable across providers
Reasoning-procedure blockAdapterStripped on reasoning models, kept elsewhere
Wording patches for a measured divergenceAdapterEach one traceable to a register entry
Flowchart: core.md task · policy · vocabulary contract · failure handling → render(model); adapters/a.yaml 18 lines → render(model); adapters/b.yaml 22 lines → render(model); adapters/c.yaml 14 lines → render(model); render(model) → request for A; render(model) → request for B; render(model) → request for C.

Unpicking three files that have already forked is a day of unglamorous work with a definite procedure. Diff each pair and classify every differing line into one of three buckets: a deliberate model difference with a measurement behind it, a deliberate difference nobody can now justify, and drift. The first bucket becomes adapter entries. The third is resolved by choosing the best version of the line and putting it in the core, which is usually obvious once the three variants are side by side, since drift is normally one good edit that failed to propagate. The middle bucket is the one that takes the time, because it contains lines somebody added for a reason they did not write down, and the honest treatment is to drop them into the core, re-run the frozen sets on all three models, and put back only what the numbers ask for.

One case resists the split and is worth naming before it turns into an argument. Occasionally a policy rule genuinely needs different wording per family — not a different rule, the same rule phrased so that one family obeys it. The temptation is to put both phrasings in the adapters, which forks the policy again with extra steps. The workable answer is to keep the canonical statement in the core with an identifier, and let the adapter carry a rendering of that identifier rather than a rewrite of it, so a policy change still edits one string and the per-family phrasing is visibly derived from it.

What the adapter looks like when it is doing its job

An adapter is a small declarative file, and the discipline that keeps it useful is a size budget — roughly 25 lines, short enough that a reviewer reads the whole thing rather than skimming a diff of it.

The budget is about what gets read, not about what fits. A reviewer handed three changed lines in a file they have never opened reviews three lines. A reviewer handed a file short enough to read whole reviews the file, and notices the line that should have come out two versions ago as well as the ones going in. That second mode only exists below some length. Which length matters less than treating an adapter that has outgrown it as a signal about the model rather than as a file in need of tidying.

yaml
# prompts/support/adapters/model_c.yaml
model: "family-c-v2"
markup: markdown          # core headings render as "## " rather than <section> tags
system_turn: supported

params:
  temperature: 0
  max_tokens: 900

format_enforcement:
  mechanism: json_mode    # no strict schema on this provider
  prefill: null
  stop: ["\n\n"]          # cuts a trailing fence or commentary

restate_rules_at_input_end: [refund_age_limit, never_quote_prices]
# ref DIV-014: violation rate 4.0% from system turn alone, 0.33% when restated.
# Measured on evalsets/support-600.json@8ab31f0c, 5 repeats, 2024-10-28.

reasoning_block: include  # model C answers immediately; the procedure block still pays

Every non-obvious line carries the divergence it exists for and the measurement that justified it. That comment is the difference between an adapter and a pile of accumulated superstition: in six months, when model C ships a new version, DIV-014 is a specific experiment somebody can re-run, and if the violation rate is now 0.2% from the system turn alone, the restate_rules_at_input_end line comes out and the request gets 25 tokens shorter per turn.

The rendering side stays deliberately dull, because a template engine with logic in it becomes a second place where model-specific behaviour hides.

python
def render(core: Core, adapter: Adapter, conversation: list[Message]) -> Request:
    body = core.to_markup(adapter.markup)
    msgs = ([{"role": "system", "content": body}] if adapter.system_turn == "supported"
            else [{"role": "user", "content": body + "\n\n" + conversation[0].content}]
                 + conversation[1:])
    if adapter.restate_rules_at_input_end:
        msgs[-1] = append_reminders(msgs[-1], core.rules(adapter.restate_rules_at_input_end))
    return Request(
        model=adapter.model,
        messages=msgs,
        tools=[t.render(adapter.tool_dialect) for t in core.tools],
        **adapter.params,
        **adapter.format_enforcement.as_request_fields(),
    )

Note that restate_rules_at_input_end names rules by identifier and pulls their text from the core. The adapter decides where a rule appears; it never contains a copy of the rule. That is the property that makes a policy edit reaching two models out of three impossible, since there is one string for the 90-day limit and moving it around cannot fork it.

The mechanism is identity, not discipline. restate_rules_at_input_end holds identifiers, and an identifier is a reference. A reference cannot disagree with the thing it refers to, because it holds no copy with which to disagree. Change the 90-day limit in the core and every adapter naming that rule renders the new text on its next call, having taken no action and required nobody to remember anything. Set that against an adapter holding the rule's words: two strings meant to be the same thing are two strings, and two strings drift. Everything in this chapter is a version of moving from the second arrangement to the first.

Examples are the awkward case, and they split down the middle

The element that resists the core-or-adapter question is the worked example, because it is two things at once. What the example demonstrates — a hard refund case, a ticket that should escalate, the exact boundary between an administrative and a clinical question — is knowledge about your product and belongs to the core. How it is laid out, how many are shown, and whether the assistant side is rendered as a tool call or as a text block are facts about the family reading it, which the authority table from earlier in this topic already established as adapter territory.

Storing examples as prose in either file forces a choice that is wrong half the time. Storing them as structured data lets both halves live where they belong:

yaml
# prompts/support/examples.yaml   (core)
- id: EX-004
  user: "I bought this 6 months ago and it stopped working, can I get my money back?"
  reasoning_hint: "Beyond the 90-day window; route to a human rather than deciding."
  action: {tool: escalate_refund, args: {reason: "outside_window", order_age_days: 183}}
  reply: "I've passed this to a specialist who can look at options beyond the standard window."

The adapter then decides how many of these to render and in what shape: three on the family where few-shot demonstrations carry most of the weight, one on the family where a densely written contract outranks them, as native tool_use blocks where the API has them and as the text protocol where it does not, with reasoning_hint included on the model that answers immediately and dropped on the reasoning model. Adding a new example is one entry that every model gets. Changing how examples are presented on one family is a line in one adapter, and it cannot silently change what the examples teach.

The same treatment works for anything else that is content in the core and presentation in the adapter, which in practice means tool descriptions, enum value lists and the output contract's field documentation. The test for whether something qualifies is whether an edit to it should reach all three models: if yes, the content is core and only its rendering is negotiable.

Two checks that keep the split honest

Structure decays unless something enforces it, and two cheap tests cover most of the decay.

The first is a lint rule that fails the build if a core file mentions a model, a provider or a vendor-specific mechanism. It is crude and it catches the common regression, which is someone adding "(on Claude, begin your reply with {)" to the core because it was the file they had open.

python
FORBIDDEN = re.compile(r"\b(gpt|claude|gemini|llama|openai|anthropic|prefill|json_mode)\b", re.I)

def test_core_has_no_model_specific_text():
    for path in Path("prompts").rglob("core*.md"):
        assert not FORBIDDEN.search(path.read_text()), f"model-specific text in {path}"

The second is a rendering test asserting that every rule in the core appears in every model's rendered prompt:

python
@pytest.mark.parametrize("adapter", load_adapters())
def test_every_core_rule_is_present(adapter):
    rendered = render(CORE, adapter, SAMPLE_CONVERSATION).messages_as_text()
    for rule in CORE.rules():
        assert rule.id in rendered_rule_ids(rendered), f"{rule.id} missing for {adapter.model}"

Those two tests are not two instances of one idea, and the difference is why one of them is worth much more. The lint rule catches text that is present and should not be: a mistake with an author, a diff and a reviewer, which stood a fair chance of being caught anyway. The rendering test catches text that is absent and should not be. An absence has no author and produces no diff, so there is no line for a reviewer to look at and no moment at which anybody is deciding anything. That is the entire failure class this chapter exists for, and it is the one a review process structurally cannot cover, because a change that was not made cannot be reviewed.

Ownership carries the rest. A core file that any team can edit is a policy document with no reviewer, so the core belongs to whoever owns the product behaviour — support operations for a support agent, finance for an extractor — while adapters belong to whoever runs the models, and a code-owners rule makes that structural rather than cultural. The review question differs by file, too: a core change is reviewed for whether it says the right thing about the business, and an adapter change is reviewed for whether the measurement in its comment supports the line it justifies.

Beyond those, a core change should trigger the frozen-set run on every adapter, not only the one being worked on. That costs three times as many eval calls as before, which is the standing price of running three models, and it turns a policy edit into a change with a known quality effect on all three rather than on the one the author had in mind.

The costs the split leaves in place

It does not reduce the number of eval runs, the number of divergences to track, or the per-model patches from earlier chapters. Those costs come from serving three models and are unaffected by how the text is stored; the $600-a-month maintenance line from the previous chapter is smaller under this structure but it is not zero, and pretending otherwise is how teams end up adding a fourth model without re-doing the arithmetic.

Adding that fourth model does at least have a shape. Write the adapter, run the frozen sets to get a baseline, work through the patch-and-re-score loop with anything that diverges, and record each patch in the adapter with its divergence id. The work is bounded because the core is not part of it, and the day it stops being bounded — when the fourth model needs the core reworded to function at all — is the signal that the abstraction has met something it cannot absorb.

It also does not make the models interchangeable. An adapter that has grown to 70 lines is not a badly written adapter; it is telling you that the difference it is absorbing is larger than markup and parameters, and forcing it into the same shape as the other two is starting to cost something. On the support agent, model C's adapter stayed at 14 lines while model B's grew to 40 as its tool-calling protocol turned out to be a text convention rather than a native API, and at that point the honest description was that B was doing a different thing rather than the same thing differently.

Length works as a diagnostic because of what an adapter is permitted to hold. Every line in one is a fact about how a model is addressed — markup, placement, parameters, enforcement mechanism — and that list is short, and roughly the same length whichever model it describes. So when a file of that kind doubles, the extra lines are not more addressing. They are content, arriving in the adapter because it was the only place it would fit, and content in an adapter is the fork coming back by another route. The line count is a proxy. What it measures is whether the core-or-adapter question still has an answer for this model.

That is the boundary question. A platform team that has just watched a three-week migration will propose extending the abstraction until no model-specific text exists anywhere, with adapters translating everything at call time, and the proposal is right about most of the surface and wrong at exactly the edge where B's adapter grew.

Model-specific prompting differences — read the current version at https://daviddominguez.dev/learn/en/model-specific-prompting-differences/a-shared-core-with-per-model-adapters

Back to top