Une architecture pour surmonter le démarrage à froid serverless et réduire les coûts des agents Vercel Eve
Lorsqu'on déploie un agent basé sur Vercel Eve en production, on se heurte immédiatement à l'absence d'état (statelessness) propre au serverless. Une fois la requête terminée, l'instance se ferme et l'état d'exécution est perdu. Pour autant, interroger une base de données comme PostgreSQL à chaque fois afin de restaurer la session entraîne un temps de latence de plus de 100 ms et une explosion des coûts de base de données.
Pour dépasser les limites du serverless, voici le récapitulatif de trois architectures utilisées en production réelle.
1. Réduire la latence de restauration de session sous les 50 ms avec Upstash Redis
Dans un environnement serverless, ouvrir une nouvelle connexion à la base de données à chaque fois est le moyen le plus rapide de gâcher à la fois les coûts d'infrastructure et le temps de réponse. Placer Upstash Redis, qui communique via une API REST HTTP, comme couche de cache de session permet de résoudre ce problème.
`
[User Request]
│
▼
┌──────────────┐ < 50ms (HTTP REST) ┌────────────────────────┐
│ Vercel Eve │ ────────────────────────> │ Upstash Redis │
│ Agent │ <──────────────────────── │ (Session State Storage)│
└──────────────┘ Session Context Restored└────────────────────────┘
│
│ Compress History (Sliding Window + Summary)
▼
┌──────────────┐
│ LLM Provider │
└──────────────┘
`
Les données de session sont récupérées en moins de 50 ms via l'API REST. Le fait de remplacer les requêtes directes à la BDR par du cache réduit considérablement la consommation de capacité de lecture (Read Capacity).
| Critère d'évaluation |
BDR traditionnelle (PostgreSQL) |
DynamoDB (On-Demand) |
Upstash Redis (HTTP REST) |
| Mode de connexion |
Socket TCP |
AWS SDK |
API HTTP/REST |
| Latence moyenne de lecture |
50ms - 200ms |
10ms - 20ms |
1ms - 5ms (Edge < 50ms) |
| Adaptabilité au Serverless |
Faible (Connection Exhaustion) |
Moyenne (latence de connexion présente) |
Très haute (Support de Scale-to-Zero) |
| Structure des coûts |
Facturation à l'heure d'instance provisionnée |
Facturation à la requête RCU/WCU |
Facturation à la commande ($0.20/100k) |
| Usage principal |
Transactions ACID, stockage source |
Stockage permanent et recherche |
Cache de session, Rate Limit, mémoire de l'agent |
Le code de stockage et de restauration de session reste simple.
`typescript
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
interface AgentSessionContext {
userId: string;
currentStep: string;
intermediateThoughts: Record<string, unknown>[];
lastActiveTimestamp: number;
}
export async function restoreSessionContext(sessionId: string): Promise<AgentSessionContext | null> {
const cacheKey = session:context:${sessionId};
const cachedContext = await redis.get(cacheKey);
return cachedContext ?? null;
}
export async function saveSessionContext(
sessionId: string,
context: AgentSessionContext,
ttlSeconds: number = 3600
): Promise {
const cacheKey = session:context:${sessionId};
await redis.set(cacheKey, JSON.stringify(context), { ex: ttlSeconds });
}
`
À mesure que la conversation s'allonge, les coûts en tokens augmentent régulièrement. On utilise une approche consistant à conserver uniquement les 6 derniers tours de parole sous leur forme originale, tout en résumant la conversation antérieure à l'aide d'un modèle léger placé en haut du prompt.
`typescript
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";
interface Message {
role: "user" | "assistant" | "system";
content: string;
}
export async function compressConversationHistory(
messages: Message[],
recentWindowSize: number = 6
): Promise<Message[]> {
if (messages.length <= recentWindowSize) return messages;
const systemMessage = messages.find((m) => m.role === "system");
const nonSystemMessages = messages.filter((m) => m.role !== "system");
const olderMessages = nonSystemMessages.slice(0, nonSystemMessages.length - recentWindowSize);
const recentMessages = nonSystemMessages.slice(nonSystemMessages.length - recentWindowSize);
const summaryResponse = await generateText({
model: openai("gpt-4o-mini"),
prompt: 다음 대화의 핵심 사실과 결정 사항만 200자 이내로 요약하세요:\n\n${JSON.stringify(olderMessages)},
});
const compressedHistory: Message[] = [];
if (systemMessage) compressedHistory.push(systemMessage);
compressedHistory.push({
role: "system",
content: [이전 대화 요약]: ${summaryResponse.text},
});
compressedHistory.push(...recentMessages);
return compressedHistory;
}
`
2. Pattern de protection contre les latences et défaillances des API externes
Si l'appel d'un outil externe rencontre une erreur 429 (Rate Limit) ou 5xx, c'est l'ensemble du raisonnement de l'agent qui s'effondre. Il faut mettre en place un backoff exponentiel intégrant du Full Jitter ainsi qu'un disjoncteur (circuit breaker).
La formule du backoff exponentiel évite les engorgements en combinant une augmentation proportionnelle du temps d'attente avec une valeur aléatoire.
Textdelay=minleft(Textmax,Textbaseimes2extattemptight)imesleft(0.5+extrandom(0,1.0)ight)`typescript
export interface RetryConfig {
maxRetries: number;
baseDelayMs: number;
maxDelayMs: number;
}
export async function executeWithExponentialBackoff(
fn: () => Promise,
config: RetryConfig = { maxRetries: 3, baseDelayMs: 200, maxDelayMs: 8000 }
): Promise {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (error: any) {
attempt++;
const statusCode = error?.status || error?.response?.status;
const isUnretryable = statusCode && statusCode >= 400 && statusCode < 500 && statusCode !== 429;
if (attempt > config.maxRetries || isUnretryable) throw error;
const calculatedDelay = Math.min(
config.maxDelayMs,
config.baseDelayMs * Math.pow(2, attempt)
);
const jitteredDelay = calculatedDelay * (0.5 + Math.random());
await new Promise((resolve) => setTimeout(resolve, jitteredDelay));
}
}
}
`
Si la panne se prolonge, le disjoncteur bloque immédiatement les requêtes (Fail-Fast) et déclenche la logique de fallback.
| État de réponse de l'API externe |
État du disjoncteur |
Mécanisme de fonctionnement |
Résultat de traitement de l'agent |
| HTTP 200 OK |
Closed |
Passage normal et incrémentation du compteur de succès |
Fournit normalement les données externes à l'agent |
| HTTP 429 / 503 |
Closed $ |
|
|
| ightarrow$ Open |
Exécution du backoff exponentiel, puis passage en Open si le seuil d'échec est atteint |
Ouverture du circuit après tentatives |
|
| État Circuit OPEN |
Open |
Blocage des requêtes réseau vers l'API externe (Fail-Fast) |
Utilisation d'un outil alternatif ou affichage d'un message de fallback |
| Après expiration du Cooldown |
Half-Open |
Vérification du rétablissement du service externe via une requête unique de probing |
Normalisation du circuit en cas de succès, re-blocage en cas d'échec |
`typescript
export class CircuitBreaker {
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
private failureCount = 0;
private lastStateChange = Date.now();
constructor(
private failureThreshold: number = 5,
private cooldownPeriodMs: number = 30000
) {}
async execute(requestFn: () => Promise, fallbackFn: () => Promise): Promise {
const now = Date.now();
if (this.state === 'OPEN') {
if (now - this.lastStateChange > this.cooldownPeriodMs) {
this.state = 'HALF_OPEN';
this.lastStateChange = now;
} else {
return await fallbackFn();
}
}
try {
const result = await requestFn();
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
this.failureCount = 0;
this.lastStateChange = now;
}
return result;
} catch (error) {
this.failureCount++;
if (this.failureCount >= this.failureThreshold || this.state === 'HALF_OPEN') {
this.state = 'OPEN';
this.lastStateChange = now;
}
return await fallbackFn();
}
}
}
`
3. Intégration de la validation humaine (Human-in-the-loop) asynchrone sans timeout
Les fonctions serverless ont une limite de temps d'exécution. Laisser la requête ouverte en attendant une validation de paiement ou de suppression de base de données provoque une erreur de timeout.
`
[Agent Action] ──> Eve Tool (needsApproval: true)
│
▼
[Checkpoint Saved & Instance Terminated]
│
├─> Slack Notification (Interactive Card)
│
[Human Approve] ───────>│ (Webhook POST Callback)
│
▼
[Resume Agent & Proceed Transaction]
`
On applique needsApproval: true à l'outil Eve pour interrompre l'exécution et ne sauvegarder qu'un checkpoint.
`typescript
import { defineTool } from "@vercel/eve";
import { z } from "zod";
export const deleteDatabaseTool = defineTool({
name: "delete_database",
description: "특정 테넌트의 영구 데이터베이스 레코드를 삭제합니다.",
needsApproval: true,
input: z.object({
tenantId: z.string(),
reason: z.string(),
}),
execute: async ({ tenantId }) => {
return await db.tenant.delete({ where: { id: tenantId } });
},
});
`
La validation humaine est reçue via un callback webhook afin de reprendre le processus.
`typescript
import { createWebhook } from "@vercel/workflows";
export async function handleApprovalWorkflow(event: { approvalId: string; payload: any }) {
const webhook = createWebhook();
await sendSlackApprovalCard({
approvalId: event.approvalId,
callbackUrl: webhook.url,
payload: event.payload,
});
try {
const { approved, userReason } = await webhook.timeout("12h");
if (!approved) {
await rollbackPreviousSteps(event.payload);
return { status: "REJECTED", reason: userReason };
}
return await proceedAction(event.payload);
} catch (error) {
await rollbackPreviousSteps(event.payload);
return { status: "TIMEOUT_CANCELLED" };
}
}
`
4. Validation de prompt au niveau CI/CD et routage canary
Les phénomènes d'hallucination qui surviennent après la modification d'un prompt sont difficiles à détecter par des tests meuels. Le pipeline est conçu de manière à ce que la PR ne puisse être fusionnée que si elle valide les indicateurs DeepEval.
| Indicateur d'évaluation |
Seuil d'acceptation |
Critère d'évaluation |
| Faithfulness |
ge0.85 |
Présence ou absence de déformation des faits par rapport au contexte fourni |
| Answer Relevancy |
ge0.75 |
Niveau d'adéquation avec l'objectif de la question de l'utilisateur |
| Hallucination Rate |
le0.10 |
Proportion d'hallucinations dans le jeu de test |
| Tool Calling Accuracy |
ge0.90 |
Sélection de l'outil conforme à la spécification OpenAPI et respect des types |
Pytest est exécuté dans GitHub Actions pour bloquer le build si le seuil n'est pas atteint.
`yaml
name: Eve Agent Prompt Evaluation Pipeline
on:
pull_request:
branches: [ main ]
jobs:
evaluate-agent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Evaluation Dependencies
run: |
pip install deepeval pytest
- name: Run DeepEval Regression Suite
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
pytest test_agent_evals.py --deepeval-metric-threshold=0.85
`
Lors du déploiement, Edge Config et un middleware sont associés pour n'appliquer le nouveau prompt qu'à 10 % du trafic dans un premier temps.
`typescript
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { get } from '@vercel/edge-config';
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
let variant = req.cookies.get('agent_canary_variant')?.value;
if (!variant) {
const canaryRate = (await get('canary_traffic_rate')) || 0.10;
variant = Math.random() < canaryRate ? 'canary' : 'control';
res.cookies.set('agent_canary_variant', variant, { path: '/', httpOnly: true });
}
res.headers.set('x-agent-prompt-version', variant === 'canary' ? 'v2-canary' : 'v1-stable');
return res;
}
export const config = {
matcher: '/api/agent/:path*',
};
`