SHADOW AI

The document and RAG architecture established in Chapter 41 provides the evidence layer of the AI system.
The next requirement is a reliable inference layer.
A production AI application should not allow every API route to directly call a model provider. That approach creates tight coupling, inconsistent prompts, duplicated error handling, uncontrolled costs, and weak security boundaries.
Instead, the application should introduce an AI Orchestration Layer.
The conceptual architecture is:
Application
│
▼
AI Orchestrator
│
├── Model Router
├── Prompt Manager
├── Context Manager
├── Token Manager
├── Safety Policy
├── Provider Adapter
├── Retry/Fallback Manager
├── Usage Meter
└── Observability
│
▼
Model Provider(s)
The purpose of this layer is to make model invocation controlled, observable, replaceable, and secure.
A weak architecture might look like:
API Route
↓
AI Provider
Then another feature might implement:
Another API Route
↓
Different AI Provider
Eventually the application contains dozens of independent model calls.
This creates problems:
duplicated authentication logic,
duplicated prompts,
inconsistent safety controls,
inconsistent timeouts,
inconsistent retries,
difficult provider migration,
difficult cost tracking,
difficult testing,
inconsistent logging.
The preferred architecture is:
Feature
↓
AI Orchestrator
↓
Provider Adapter
↓
Model
Every AI feature therefore passes through a common control layer.
The application should define a provider-neutral interface.
Conceptually:
AIProvider
├── generateText()
├── generateStructured()
├── generateEmbeddings()
└── healthCheck()
Individual providers implement the interface.
The application should not need to know the implementation details.
For example:
Provider Adapter
│
├── Cloud Model Adapter
├── Local Model Adapter
└── Backup Model Adapter
This makes the system portable.
Provider and model should be separate concepts.
For example:
Provider
└── Model
A provider may expose multiple models.
The application should therefore use an internal model identifier such as:
fast-general
quality-general
reasoning
vision
embedding
rather than hard-coding provider-specific names throughout the codebase.
The routing layer can translate internal identifiers into actual configured models.
The orchestrator should maintain metadata about model capabilities.
Example:
ModelProfile
├── modelId
├── provider
├── capability
├── contextLimit
├── supportsVision
├── supportsStructuredOutput
├── supportsTools
├── latencyClass
└── costClass
This allows the router to select an appropriate model based on task requirements.
For example:
Image Question
↓
Vision-capable model
while:
Simple Classification
↓
Fast low-cost model
and:
Complex Reasoning
↓
High-capability model
Model routing determines which model should process a request.
A basic routing function can consider:
Task
User Plan
Latency Requirement
Context Size
Required Capability
Safety Requirement
Availability
Cost Budget
Conceptually:
Request
│
▼
Task Classification
│
▼
Capability Check
│
▼
Policy Check
│
▼
Model Selection
│
▼
Inference
The router should not select a model merely because it is currently popular or inexpensive.
It should satisfy the application's explicit requirements.
A practical system can define task classes such as:
CHAT
RAG_QA
SUMMARIZATION
CLASSIFICATION
STRUCTURED_EXTRACTION
VISION_ANALYSIS
CODE_ASSISTANCE
EMBEDDING
AGENT_PLANNING
Each task can have a default model policy.
For example:
RAG_QA
→ quality-general
CLASSIFICATION
→ fast-general
EMBEDDING
→ embedding-model
VISION_ANALYSIS
→ vision-model
The mapping should remain configurable.
External AI services can fail.
Possible causes include:
temporary network errors,
provider outages,
rate limits,
capacity errors,
invalid requests,
timeout,
quota exhaustion.
A resilient architecture can use controlled fallback routing.
Example:
Primary Model
│
├── Success → Return
│
└── Retryable Failure
↓
Backup Model
│
├── Success → Return
└── Failure → Safe Error
Fallback should never bypass authorization or safety controls.
Every provider must operate under the same application policy.
Not every failure should be retried.
Retryable conditions may include certain:
transient network failures,
temporary service unavailability,
controlled rate-limit responses.
Non-retryable conditions may include:
invalid request,
invalid authentication configuration,
malformed structured output,
authorization failure,
policy rejection.
Retries should use bounded exponential backoff.
A system should also enforce a maximum retry count.
Otherwise, one failed request could generate unnecessary provider traffic and cost.
Every model request should have a timeout.
Conceptually:
Request
↓
Timeout Boundary
↓
Model Provider
Without a timeout, an application worker can remain occupied indefinitely.
The timeout should be appropriate for the operation.
For example:
Simple classification
→ short timeout
Long document summarization
→ longer timeout
The exact values should be configurable.
Prompts should not be scattered throughout API handlers.
Instead, create a prompt layer.
Conceptually:
PromptRegistry
├── ChatPrompt
├── RagPrompt
├── SummaryPrompt
├── ExtractionPrompt
└── AgentPrompt
Each prompt should have:
an identifier,
a version,
an intended task,
required variables,
expected output format.
Example:
rag.answer.v3
is more manageable than an anonymous string embedded inside an API route.
Prompts are part of application behavior.
Changing a prompt can change model output.
Therefore, prompt versions should be tracked.
Example:
rag.answer.v1
rag.answer.v2
rag.answer.v3
The system can then determine:
Which prompt produced this answer?
This is valuable for:
debugging,
evaluation,
regression testing,
research,
reproducibility.
A RAG request may contain:
System Policy
Task Instructions
User Question
Retrieved Evidence
Output Requirements
The orchestrator should construct these components explicitly.
Conceptually:
SYSTEM
↓
TASK
↓
USER INPUT
↓
RETRIEVED EVIDENCE
↓
OUTPUT CONTRACT
The exact implementation depends on the model interface.
The important architectural principle is that different content types retain their roles.
The system must distinguish between trusted instructions and untrusted content.
For example:
Trusted Application Policy
↓
Application Task
↓
User Request
↓
Retrieved Documents
↓
External Tool Output
Document content, search results, webpages, uploaded files, and tool outputs should not automatically gain authority simply because they appear inside the model context.
This is particularly important for agentic systems.
Although the user controls their own request, the application should not blindly interpolate arbitrary user input into privileged instructions.
User input may contain:
accidental formatting,
malformed data,
prompt-like language,
very large text,
unsupported instructions.
The application should therefore separate:
Application Instructions
from:
User Content
rather than concatenating everything into one indistinguishable string.
Whenever an AI operation has a machine-readable result, structured output should be preferred.
Instead of asking for:
"Return the answer somehow."
the application should define an expected structure.
For example:
{
"summary": "string",
"confidence": 0.0,
"sources": []
}
The application can then validate the result before using it.
This is especially important for:
database updates,
workflow decisions,
tool calls,
document extraction,
automated actions.
Model output must be considered untrusted until validated.
A structured response should pass through:
Model Output
↓
Parser
↓
Schema Validator
↓
Business Rules
↓
Application
If validation fails:
Validation Failure
↓
Repair / Retry
↓
Second Validation
↓
Safe Failure
The system should never assume that a model will always obey an output schema perfectly.
Token usage affects:
latency,
cost,
context capacity,
model reliability.
The orchestrator should calculate or estimate:
Input Tokens
Output Tokens
Total Tokens
for every inference operation where the provider exposes appropriate usage data.
A usage record can contain:
requestId
userId
projectId
model
inputTokens
outputTokens
totalTokens
duration
status
A request may contain:
Conversation History
+
User Question
+
Retrieved Documents
+
System Instructions
All of these consume context.
Therefore, the application should maintain a context budget.
Conceptually:
Maximum Context
├── System Policy
├── Conversation
├── Retrieved Evidence
└── Output Reserve
If the input exceeds the budget, the context manager should reduce it systematically.
Possible strategies include:
remove redundant history,
summarize older conversation,
retrieve fewer chunks,
compress evidence,
prioritize relevant sources.
Not every piece of information has equal importance.
A priority system can be:
Priority 1:
Security / system requirements
Priority 2:
Current user request
Priority 3:
Relevant retrieved evidence
Priority 4:
Recent conversation
Priority 5:
Older optional context
The exact ordering depends on application design, but it should be deterministic and testable.
Long conversations can become expensive.
Instead of sending the entire conversation on every request, the system can maintain:
Recent Messages
+
Conversation Summary
+
Relevant Retrieved History
For example:
Conversation
├── Summary
├── Recent 10 messages
└── Relevant historical messages
This reduces unnecessary context consumption.
Conversation context and document context should remain distinguishable.
Conceptually:
Conversation Context
+
Document Evidence
+
Current Request
The model can then understand which information came from where.
This also improves citation generation.
Every AI request should pass through a controlled lifecycle:
1. Receive Request
2. Authenticate User
3. Authorize Operation
4. Validate Input
5. Apply Rate Limits
6. Determine Task Type
7. Select Model
8. Retrieve Context
9. Build Prompt
10. Apply Token Budget
11. Call Provider
12. Validate Output
13. Record Usage
14. Record Audit Event
15. Return Response
This creates a consistent security boundary.
Internally, the system can normalize requests into a common structure:
AIRequest
├── requestId
├── userId
├── projectId
├── task
├── input
├── context
├── modelPolicy
├── outputSchema
└── metadata
This allows the orchestrator to process different AI features consistently.
Likewise:
AIResponse
├── requestId
├── content
├── structuredData
├── model
├── usage
├── citations
├── finishReason
└── metadata
This provides a stable contract for the frontend and backend.
AI operations should be connected to the usage system established earlier.
A request can record:
User
Project
Task
Model
Input Tokens
Output Tokens
Duration
Status
This enables:
quotas,
billing,
analytics,
abuse detection,
capacity planning.
Usage records should not contain unnecessary sensitive prompt content.
AI endpoints require stronger controls than ordinary read APIs because inference can be expensive.
Rate limits can operate at multiple levels:
IP
User
Project
API Key
Task
Model
For example:
User Limit
↓
Project Limit
↓
Model Limit
↓
Global Capacity Limit
The system should return a controlled response when a limit is exceeded.
A project may have a monthly AI budget.
The orchestrator can check:
Current Usage
+
Estimated Request Cost
≤
Allowed Budget
before starting expensive operations.
For exact cost accounting, the implementation should use provider-reported usage and configured pricing data where applicable.
Safety controls should not depend entirely on the model.
The application can have a policy layer that determines whether a requested operation is permitted.
Conceptually:
Request
↓
Application Policy
↓
Model Policy
↓
Provider
The policy layer can enforce application-specific restrictions such as:
user permissions,
project permissions,
feature availability,
content handling requirements,
tool permissions,
data access restrictions.
When the AI system eventually gains access to tools, model output must not automatically execute arbitrary actions.
Instead:
Model Request
↓
Tool Authorization
↓
Parameter Validation
↓
Approval Policy
↓
Tool Execution
For sensitive operations, human approval can be required.
This creates a crucial separation:
Model proposes
versus:
Application authorizes
A future agent system may perform multiple inference steps:
Goal
↓
Plan
↓
Retrieve
↓
Reason
↓
Tool Proposal
↓
Verification
↓
Action
Each step should be separately observable.
The model should not receive unrestricted authority over the entire application.
Instead, the orchestrator should provide bounded capabilities.
One major benefit of orchestration is model independence.
The application can evolve from:
One Model
to:
Multiple Models
without rewriting every feature.
The architecture becomes:
Feature
↓
Task
↓
Model Policy
↓
Router
↓
Selected Model
This is particularly valuable as model capabilities and pricing change over time.
The abstraction layer can support both remote and locally hosted models.
Conceptually:
AI Orchestrator
│
├── Remote Provider
│
└── Local Provider
This can support different deployment strategies.
For sensitive workloads, an organization may choose a locally hosted model.
For high-capability workloads, it may use a remote model.
The application architecture should remain consistent.
The router should maintain basic provider health information.
Possible states:
HEALTHY
DEGRADED
RATE_LIMITED
UNAVAILABLE
DISABLED
A provider that repeatedly fails can temporarily be removed from normal routing.
This prevents every request from repeatedly encountering the same failure.
A circuit breaker can protect the application from an unhealthy dependency.
Conceptually:
Normal
↓
Repeated Failures
↓
Open Circuit
↓
Stop Requests Temporarily
↓
Health Check
↓
Recovery
↓
Normal
This should be carefully implemented so that a provider outage does not cascade into application-wide resource exhaustion.
Some AI operations may be safely cacheable.
Examples could include deterministic operations where the same input and configuration produce reusable results.
A cache key might include:
task
model
promptVersion
inputHash
contextHash
configurationVersion
Sensitive data should not be placed into shared caches without appropriate isolation.
Caching should never bypass authorization.
Model outputs can vary.
For research and testing, the system should record relevant configuration.
For example:
model
promptVersion
temperature
maxOutputTokens
retrievalVersion
embeddingVersion
applicationVersion
Not every provider exposes identical controls, so the architecture should record what is actually available.
This makes experiments easier to reproduce.
The system should maintain an AI operation record.
A conceptual audit entry:
AI Audit Event
├── requestId
├── userId
├── projectId
├── task
├── model
├── promptVersion
├── retrievalVersion
├── status
├── duration
└── timestamp
The audit record should avoid storing full sensitive prompts or outputs unless explicitly required and appropriately protected.
AI failures should be categorized.
Example:
INPUT_VALIDATION_ERROR
AUTHORIZATION_ERROR
RATE_LIMIT_ERROR
PROVIDER_TIMEOUT
PROVIDER_UNAVAILABLE
MODEL_OUTPUT_INVALID
CONTEXT_LIMIT_ERROR
POLICY_REJECTION
INTERNAL_ERROR
This allows the frontend to provide meaningful responses without exposing internal implementation details.
The system should not expose:
provider credentials,
internal stack traces,
private configuration,
database details,
internal network information.
A user-facing response should be safe.
Internally:
Detailed diagnostic
Externally:
Controlled error
This follows the same security boundary established for the API layer.
The resulting backend architecture can now be:
src/
├── app/
│ └── api/
├── services/
│ ├── ai/
│ │ ├── orchestrator
│ │ ├── router
│ │ ├── prompts
│ │ ├── context
│ │ ├── token-manager
│ │ ├── safety
│ │ ├── usage
│ │ └── providers/
│ ├── documents/
│ ├── retrieval/
│ └── storage/
├── repositories/
└── lib/
This keeps model-specific implementation away from business-facing API routes.
A RAG question might follow:
User
↓
POST /api/ai/ask
↓
Authenticate
↓
Authorize Project
↓
Validate Question
↓
Check Usage Limit
↓
Retrieve Authorized Evidence
↓
Select RAG Model
↓
Build Versioned Prompt
↓
Apply Context Budget
↓
Call Provider
↓
Validate Response
↓
Attach Citations
↓
Record Usage
↓
Audit
↓
Return Answer
This flow integrates the architecture established in previous chapters.
The orchestration layer requires multiple levels of testing.
Test:
model routing,
token budgeting,
prompt construction,
output validation,
retry decisions.
Test:
provider adapters,
database usage recording,
retrieval integration,
authorization.
Simulate:
timeout,
provider unavailable,
malformed output,
rate limit,
context overflow.
Test:
unauthorized project access,
cross-user retrieval,
untrusted document instructions,
tool authorization,
sensitive error leakage.
A useful evaluation matrix is:
Task
Model
Prompt Version
Retrieval Version
Expected Quality
Actual Quality
Latency
Token Usage
Failure Rate
This enables systematic comparison.
For example:
RAG-QA
Model A
Prompt v3
Retriever v2
Accuracy: ...
Latency: ...
The exact metrics should be determined by the research objective.
The system can expose metrics such as:
AI Requests / Minute
Success Rate
Failure Rate
Average Latency
P95 Latency
Input Tokens
Output Tokens
Provider Errors
Fallback Rate
Retrieval Latency
Model Distribution
Sensitive user content should not be displayed in operational dashboards.
AI configuration should be environment-controlled.
Examples include:
AI_DEFAULT_MODEL
AI_TIMEOUT_MS
AI_MAX_RETRIES
AI_MAX_CONTEXT_TOKENS
AI_MAX_OUTPUT_TOKENS
AI_DEFAULT_TEMPERATURE
AI_PROVIDER_MODE
Secrets should remain outside source control.
Configuration should be validated when the application starts.
API keys, passwords, session tokens, and private credentials must never be intentionally inserted into model prompts.
Similarly, logs should avoid storing credentials or unnecessary sensitive content.
The AI layer should assume that model providers and logging systems are separate trust boundaries.
Before sending information to a model, the application should ask:
Does the model actually need this data?
If not, it should be omitted.
For example:
Full User Profile
may not be necessary for:
Simple Document Summarization
Reducing unnecessary context improves:
privacy,
latency,
cost,
security.
The orchestrator should become the central checkpoint for AI operations.
AI REQUEST
│
▼
┌──────────────┐
│ Orchestrator │
└──────────────┘
│ │ │
┌───────┘ │ └────────┐
▼ ▼ ▼
Authorization Routing Safety
│ │ │
└────────────┼─────────────┘
▼
Context
│
▼
Model
│
▼
Validation
│
▼
Application
This architecture makes AI behavior significantly easier to control.
Combining previous chapters produces the following architecture:
USER
│
▼
API
│
▼
Authentication/AuthZ
│
▼
AI Orchestrator
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
Model Router Context Manager Safety Policy
│ │ │
▼ ▼ │
Provider Adapter RAG Retrieval │
│ │ │
└──────────────┬───┴──────────────────┘
▼
Prompt
│
▼
Token Manager
│
▼
Selected Model
│
▼
Output Validation
│
┌─────────┴─────────┐
▼ ▼
Usage Audit
│ │
└─────────┬─────────┘
▼
Response
Features should call the orchestrator rather than providers directly.
Provider-specific details belong inside adapters.
Use task and capability policies.
Prompt changes should be traceable.
Never send unnecessary information to the model.
Model-generated data is not automatically trusted.
The model must never become an access-control mechanism.
Model proposals are not automatically authorized actions.
AI behavior must be measurable.
Provider outages, timeouts, rate limits, and malformed outputs are expected conditions.
The system now contains a dedicated AI inference architecture.
The major components are:
AI Orchestrator
Model Router
Provider Adapters
Prompt Registry
Context Manager
Token Manager
Safety Policy
Output Validator
Usage Meter
Audit Layer
Observability
The resulting lifecycle is:
Request → Authenticate → Authorize → Validate → Route → Retrieve → Build Context → Construct Prompt → Infer → Validate → Meter → Audit → Respond
This architecture prevents AI providers from becoming deeply embedded throughout the application.
It also establishes the foundation for the next stage of the project: agent architecture, tool calling, planning, execution boundaries, approvals, task state, and safe autonomous workflows.
export type GenerateInput = { messages: Array<{ role: "system" | "user" | "assistant"; content: string; }>; maxOutputTokens?: number; }; export type GenerateResult = { text: string; model: string; usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number; }; }; export interface AIProvider { generate(input: GenerateInput): Promise<GenerateResult>; }
type TaskType = | "CHAT" | "RAG_QA" | "SUMMARIZATION" | "CLASSIFICATION" | "STRUCTURED_EXTRACTION" | "VISION_ANALYSIS"; type ModelPolicy = { task: TaskType; preferredModel: string; fallbackModel?: string; }; const policies: ModelPolicy[] = [ { task: "CHAT", preferredModel: "fast-general", fallbackModel: "quality-general", }, { task: "RAG_QA", preferredModel: "quality-general", fallbackModel: "fast-general", }, { task: "CLASSIFICATION", preferredModel: "fast-general", }, ]; export function routeModel(task: TaskType): ModelPolicy { const policy = policies.find((item) => item.task === task); if (!policy) { throw new Error(`No model policy configured for task: ${task}`); } return policy; }
export async function runAIRequest(input: { userId: string; projectId: string; task: TaskType; messages: GenerateInput["messages"]; }) { // Authorization should happen before this function is allowed to run. const policy = routeModel(input.task); const provider = providerRegistry.get(policy.preferredModel); try { return await provider.generate({ messages: input.messages, maxOutputTokens: 2000, }); } catch (error) { if (!policy.fallbackModel) { throw error; } const fallback = providerRegistry.get(policy.fallbackModel); return fallback.generate({ messages: input.messages, maxOutputTokens: 2000, }); } }
Comments
Post a Comment