DEV Community

Samcorp
Samcorp

Posted on

What Broke When We Moved From GPT to Self-Hosted

Moving from a hosted GPT API to a self-hosted model sounded straightforward.

Moved From GPT to Self-Hosted

The application already had one clean interface:

Prompt
  ↓
LLM API
  ↓
Response
Enter fullscreen mode Exit fullscreen mode

So the migration plan looked simple:

Replace API endpoint
        ↓
Change model name
        ↓
Deploy
Enter fullscreen mode Exit fullscreen mode

The endpoint worked.

The model answered.

And then almost everything around it started behaving differently.

The lesson from our self hosted LLM migration was simple:

Replacing the model was easy. Replacing the behavior we had built around the model was not.

Note: This is a representative engineering post-mortem. The failures and architecture below illustrate common migration problems rather than a specific customer deployment.


Why We Wanted to Self-Host

The reasons were reasonable.

We wanted more control over:

  • Model selection
  • Infrastructure
  • Deployment location
  • Data handling
  • Inference configuration
  • Capacity planning
  • Model upgrades

We also wanted the ability to optimize around our own workload instead of treating inference as an external black box.

So we introduced a self-hosted inference server behind the existing application.

Conceptually, the architecture changed from:

Before

Application
    ↓
Hosted GPT API
Enter fullscreen mode Exit fullscreen mode

to:

After

Application
    ↓
LLM Gateway
    ↓
Self-Hosted Model Server
    ↓
GPU Infrastructure
Enter fullscreen mode Exit fullscreen mode

The API looked familiar.

That created false confidence.


Break #1: API Compatibility Was Not Behavior Compatibility

One of our first assumptions was:

If the server supports an OpenAI-compatible API, our application should behave the same.

At the HTTP level, that was mostly true.

For example, servers such as vLLM expose OpenAI-compatible Chat Completions, Responses, embeddings, and other endpoints.

But receiving a familiar response structure such as:

{
  "role": "assistant",
  "content": "..."
}
Enter fullscreen mode Exit fullscreen mode

does not mean two models will interpret the same conversation identically.

We saw changes in:

  • Instruction following
  • Response length
  • Formatting
  • Refusal behavior
  • JSON reliability
  • Tool selection
  • Edge-case reasoning

The integration compiled.

The product behavior changed.

That was our first major mistake:

We tested protocol compatibility before behavioral compatibility.


Break #2: Our Prompts Were More Model-Specific Than We Thought

We had system prompts that had been tuned over months.

For example:

Return a concise answer.
Do not include commentary.
Use the supplied context only.
Return JSON matching the requested format.
Enter fullscreen mode Exit fullscreen mode

They worked consistently enough with the previous model that we started treating them as application logic.

Then we moved models.

Some responses became longer.

Some ignored preferred formatting.

Some interpreted instructions differently.

Others needed clearer examples.

The prompts were not really portable.

They were part of the model integration.


Then We Discovered Chat Templates

With many self-hosted chat models, the messages sent by the application eventually need to become a token sequence such as:

<system>
You are...
<user>
...
<assistant>
Enter fullscreen mode Exit fullscreen mode

But different models may expect different special tokens and formatting.

Hugging Face explicitly recommends using the chat template associated with the model because formatting that does not match training can hurt performance.

That meant our architecture was actually:

Application messages
        ↓
Chat template
        ↓
Tokenizer
        ↓
Model
Enter fullscreen mode Exit fullscreen mode

not simply:

Application messages
        ↓
Model
Enter fullscreen mode Exit fullscreen mode

A wrong template could produce valid inference with worse behavior.

That is a particularly dangerous failure because nothing crashes.

The model just becomes less reliable.


Break #3: Structured Output Became Our Problem

Several application workflows expected objects like:

{
  "category": "billing",
  "priority": "high",
  "requires_human": true
}
Enter fullscreen mode Exit fullscreen mode

With the hosted implementation, we relied heavily on schema-constrained responses.

OpenAI supports Structured Outputs and strict function schemas designed to make responses conform to supplied schemas.

Our first self-hosted implementation mostly relied on prompting:

Respond with valid JSON only.
Enter fullscreen mode Exit fullscreen mode

It worked.

Until it did not.

We received responses such as:

Sure! Here's the JSON:
Enter fullscreen mode Exit fullscreen mode

followed by JSON.

Or:

{
  "priority": "HIGH"
}
Enter fullscreen mode Exit fullscreen mode

when the schema expected:

high
Enter fullscreen mode Exit fullscreen mode

Occasionally, we also received incomplete objects.

The migration exposed an architectural mistake:

Parsing model output is not validation.


We Added Schema Enforcement

Our gateway eventually owned structured-output validation.

Conceptually:

Model output
    ↓
Schema validation
    ↓
Valid?
 ┌──┴──┐
Yes    No
 ↓      ↓
Return Retry / Fail safely
Enter fullscreen mode Exit fullscreen mode

Modern inference servers can also support constrained or structured generation.

vLLM, for example, supports JSON Schema, regex, grammar, and other structured-output constraints.

That helped enormously.

But it also reinforced the larger lesson:

The hosted platform had been providing more than tokens. It had been providing behavioral infrastructure.


Break #4: Tool Calling Changed

Our application used tools for actions such as:

search_documents()
lookup_customer()
create_ticket()
get_order_status()
Enter fullscreen mode Exit fullscreen mode

Before migration, we had logic built around a fairly predictable tool-call structure.

After migration, three things changed.

Sometimes the new model:

  • Answered directly instead of calling the tool
  • Chose the wrong tool
  • Produced arguments that were technically valid but semantically poor

Tool calling is partly an inference-server feature, but it is also heavily model-dependent.

For example, vLLM requires model-specific tool parsers and configurations for automatic tool selection in many setups.

So this:

tools=[...]
Enter fullscreen mode Exit fullscreen mode

was not enough.

We had to test:

Should the model call a tool?

Which tool should it choose?

Are the arguments correct?

What happens after the tool output returns?

Can it handle multiple tool steps?
Enter fullscreen mode Exit fullscreen mode

Tool-use quality became its own evaluation suite.


Break #5: Latency Looked Great Until Concurrency Arrived

Our first benchmark was encouraging.

One request:

User
 ↓
Model
 ↓
Fast response
Enter fullscreen mode Exit fullscreen mode

Then production-like traffic arrived.

Now it looked like:

Request ─┐
Request ─┤
Request ─┤
Request ─┼──→ GPU
Request ─┤
Request ─┤
Request ─┘
Enter fullscreen mode Exit fullscreen mode

Suddenly we were debugging:

  • Queue time
  • Batching
  • Token throughput
  • Long prompts
  • Long generations
  • Memory pressure
  • Concurrent requests

Single-request latency had told us almost nothing about production capacity.

The important metrics became:

Time to First Token
Tokens per Second
Queue Time
Requests per Second
P95 Latency
P99 Latency
Enter fullscreen mode Exit fullscreen mode

A self-hosted model is not just a model.

It is a serving system.


Break #6: Context Length Became a Capacity Problem

Our application frequently supplied:

  • System instructions
  • Conversation history
  • Retrieved documents
  • Tool definitions
  • User input

A request could become large quickly.

Under a hosted API, we mainly thought about context as a model limit and usage cost.

Self-hosting added another concern:

Memory capacity.

Longer contexts meant more inference memory and lower effective concurrency.

So a request that worked perfectly alone could reduce throughput when many users arrived together.

We started treating context as an infrastructure resource.

Instead of automatically sending:

Entire conversation
+
10 retrieved documents
+
All tool definitions
Enter fullscreen mode Exit fullscreen mode

we became much stricter about:

  • Document selection
  • History trimming
  • Prompt size
  • Tool exposure
  • Output limits

Prompt optimization became capacity optimization.


Break #7: Retries Became More Dangerous

Our hosted integration had retry logic.

Something failed?

Retry.

That behavior became dangerous when infrastructure was already overloaded.

Consider:

Server overloaded
      ↓
Request times out
      ↓
Client retries
      ↓
More load
      ↓
More timeouts
      ↓
More retries
Enter fullscreen mode Exit fullscreen mode

Now we had created a retry storm.

We changed the gateway to use:

  • Bounded retries
  • Backoff
  • Timeouts
  • Concurrency limits
  • Overload handling

Inference failure had become distributed-systems failure.


Break #8: We Became the Operations Team

Before:

POST request
     ↓
Receive result
Enter fullscreen mode Exit fullscreen mode

After:

Model weights
GPU drivers
Inference server
Container images
Autoscaling
Networking
Load balancing
Monitoring
Logging
Model versions
Security
Capacity planning
Failure recovery
Enter fullscreen mode Exit fullscreen mode

None of those are reasons not to self-host.

They are simply costs that need to exist in the migration plan.

We had moved responsibility across a boundary.

The managed API provider was no longer operating inference for us.

Self-hosting also turns model deployment into a cloud infrastructure and scaling problem, because GPU capacity, monitoring, networking, and failure recovery now belong to your team.


The Most Important Fix: Put a Gateway in Front

Initially, parts of the application called the inference server directly.

That made every model difference leak into product code.

We replaced that design with an internal LLM gateway.

Application
     ↓
LLM Gateway
     ↓
 ┌───────────────┐
 │ Hosted Model  │
 │ Self-Hosted A │
 │ Self-Hosted B │
 └───────────────┘
Enter fullscreen mode Exit fullscreen mode

The gateway owned:

  • Request normalization
  • Prompt versions
  • Timeouts
  • Retries
  • Structured-output validation
  • Tool schemas
  • Logging
  • Metrics
  • Model routing

Application code stopped caring which inference backend produced the answer.

A stable gateway also makes enterprise AI development easier because applications can switch models, validate outputs, monitor behavior, and scale inference without coupling every workflow to one provider.

That made the migration much safer.


We Also Stopped Doing Big-Bang Model Swaps

Our first approach was basically:

GPT
 ↓
New model
Enter fullscreen mode Exit fullscreen mode

Later migrations used:

Current model
      ↓
Shadow traffic
      ↓
Evaluation
      ↓
Small percentage
      ↓
Compare
      ↓
Increase traffic
      ↓
Full migration
Enter fullscreen mode Exit fullscreen mode

The new model initially received traffic without controlling the user-visible response.

We compared:

  • Answer quality
  • JSON validity
  • Tool selection
  • Latency
  • Token usage
  • Failure rate

That surfaced problems before customers saw them.


Our Evaluation Suite Became the Real Migration Plan

The most useful artifact was not the deployment script.

It was our evaluation dataset.

We collected representative requests for:

Simple Q&A
RAG
Extraction
Classification
Tool calling
Long context
Ambiguous instructions
Bad input
Edge cases
Enter fullscreen mode Exit fullscreen mode

For every candidate model, we tested the same workload.

Conceptually:

Test Existing Model Self-Hosted
JSON schema compliance PASS PASS
Tool selection PASS FAIL
RAG answer quality PASS PASS
Long-context request PASS SLOW
Classification PASS PASS

Now model selection was based on product behavior.

Not leaderboard scores.


What Broke

Looking back, the failures fell into a few categories.

Prompt Behavior

Prompts that worked well with one model needed adjustment.

Formatting

Structured output required stronger enforcement.

Tool Use

Tool selection and arguments needed fresh evaluation.

Performance

Single-request benchmarks hid concurrency problems.

Context

Large prompts reduced serving capacity.

Reliability

Retries, overload, and failover became our responsibility.

Operations

Inference infrastructure became part of the product.


What Worked

Several changes made the migration much safer.

A Stable Internal API

Application code talked to our gateway, not directly to a specific model.

Model-Specific Prompting

We stopped pretending every model needed identical prompts.

Schema Validation

Structured outputs were validated rather than trusted.

Production-Like Load Tests

We tested concurrent traffic instead of one request at a time.

Shadow Evaluation

New models were compared before receiving full traffic.

Versioned Models and Prompts

Every production request could be traced to:

Model version
Prompt version
Gateway version
Inference configuration
Enter fullscreen mode Exit fullscreen mode

That made regressions much easier to investigate.


The Architecture We Ended Up With

The final system looked closer to this:

                   Application
                       │
                       ▼
                   LLM Gateway
                       │
       ┌───────────────┼───────────────┐
       ▼               ▼               ▼
 Prompt Manager    Validation     Observability
       │               │               │
       └───────────────┼───────────────┘
                       ▼
                 Model Router
                  ┌────┴────┐
                  ▼         ▼
              Hosted     Self-Hosted
               Model       Models
Enter fullscreen mode Exit fullscreen mode

The key abstraction was no longer:

callModel()
Enter fullscreen mode Exit fullscreen mode

It was:

executeLLMWorkflow()
Enter fullscreen mode Exit fullscreen mode

because production AI applications depend on much more than generation.


What We Would Do Differently

For the next self hosted LLM migration, we would use this order.

1. Build Evaluations First

Build the evaluation suite before choosing the replacement model.

2. Inventory Every Model-Dependent Feature

Include:

  • Structured output
  • Tools
  • RAG
  • Vision, if applicable
  • Context length
  • Streaming

3. Introduce an LLM Gateway

Put the gateway in place before changing providers or models.

4. Validate the Chat Template

Make sure the model receives messages in the format it was trained to understand.

5. Load-Test Realistic Prompts

Do not rely only on tiny benchmark requests.

6. Test Tool Behavior Separately

API support does not prove that the model uses tools correctly.

7. Run Shadow Traffic

Compare production workloads before switching user-visible responses.

8. Plan Operations Before Launch

GPU capacity, monitoring, failure handling, scaling, and upgrades belong in the original migration scope.


The Biggest Lesson

We originally thought we were replacing this:

GPT
Enter fullscreen mode Exit fullscreen mode

with this:

Self-hosted model
Enter fullscreen mode Exit fullscreen mode

What we were really replacing was:

Model
+
Serving infrastructure
+
Structured output behavior
+
Tool behavior
+
Scaling
+
Reliability
+
Operational responsibility
Enter fullscreen mode Exit fullscreen mode

That is a much larger migration.

And it explains why an application can successfully return its first self-hosted response while still being nowhere near production-ready.


Final Takeaway

A self hosted LLM migration should not begin with:

Which open model looks closest to our current GPT model?

Start with:

What behavior does our application depend on?

How will we measure that behavior?

Which parts are currently handled by the provider?

Which of those responsibilities are moving to us?
Enter fullscreen mode Exit fullscreen mode

Then migrate in layers:

Inventory dependencies
       ↓
Build evaluations
       ↓
Add gateway
       ↓
Deploy model
       ↓
Validate outputs
       ↓
Load test
       ↓
Shadow traffic
       ↓
Gradual rollout
       ↓
Monitor
Enter fullscreen mode Exit fullscreen mode

Self-hosting gave us more control.

But that control came with responsibility.

The real migration was not from one model to another.

It was from consuming inference as a service to operating inference as a production system.

Top comments (0)