В распределенных AI-системах 2026 года классического мониторинга (CPU, RAM, HTTP 200/500) недостаточно. Поведение больших языковых моделей недетерминировано: запрос может завершиться с кодом 200 OK, но содержать галлюцинацию, поврежденный JSON, отказ следовать системному промпту (Refusal) или превысить бюджет токенов в 10 раз из-за зацикливания агента в вызове инструментов (Tool Calling loop).
Наблюдаемость (Observability) современных AI-контуров строится на трех столпах:
span_id и анонимизация персональных данных (PII Redaction).Трассировка мультиагентного сценария представляется как направленный ациклический граф (DAG) спанов, где контекст распространяется через W3C TraceContext заголовки (traceparent, tracestate).
[ Client Request ] -> trace_id: 00-4bf92f35...-01
|
v
[ Root Span: Agent Workflow (Langfuse/OTel) ]
|-- [ Span 1: Context Retrieval (Vector DB / pgvector) ] -> 45ms
|-- [ Span 2: Generation (Router LLM: Claude 3.5 Haiku) ] -> TTFT: 180ms, Total: 320ms
| |-- Event: gen_ai.choice (Tool Call: "search_database")
|-- [ Span 3: Tool Execution (MCP Server Query) ] -> 110ms
`-- [ Span 4: Generation (Synthesizer LLM: Claude 3.5 Sonnet) ] -> TTFT: 240ms, Total: 850ms
|-- Attributes: gen_ai.usage.prompt_tokens = 1420
|-- Attributes: gen_ai.usage.completion_tokens = 380
`-- Attributes: gen_ai.response.model = "claude-3-5-sonnet-20241022"user_id, org_id и workspace_id к спанам OTel для биллинга в B2B SaaS.Для реализации сквозной наблюдаемости создается OTel-инструментированный контур с интеграцией SDK Langfuse, экспортером телеметрии и перехватом метрик TTFT/TPS при потоковой передаче (Streaming).
import { trace, context, SpanStatusCode, Span } from "@opentelemetry/api";
import { Langfuse } from "langfuse";
export const langfuse = new Langfuse({
publicKey: process.env.LANGFUSE_PUBLIC_KEY ?? "",
secretKey: process.env.LANGFUSE_SECRET_KEY ?? "",
baseUrl: process.env.LANGFUSE_BASEURL ?? "https://cloud.langfuse.com",
});
const tracer = trace.getTracer("ai-agent-service", "2.0.0");
interface TracedGenerationParams {
userId: string;
sessionId: string;
model: string;
systemPrompt: string;
userPrompt: string;
temperature?: number;
}
interface GenerationMetrics {
ttftMs: number;
totalDurationMs: number;
tps: number;
promptTokens: number;
completionTokens: number;
}
/**
* Санитизация чувствительных данных в промптах согласно политикам безопасности
*/
function sanitizePII(text: string): string {
return text
.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, "[REDACTED_EMAIL]")
.replace(/Bearer\s+[A-Za-z0-9-_.]+/g, "Bearer [REDACTED_TOKEN]");
}
/**
* Выполнение LLM-генерации с записью спанов OTel и фиксацией в Langfuse
*/
export async function executeTracedLLMCall(
params: TracedGenerationParams,
callFn: (prompt: string, onFirstToken: () => void) => Promise<{ text: string; promptTokens: number; completionTokens: number }>
): Promise<{ text: string; metrics: GenerationMetrics }> {
return tracer.startActiveSpan("gen_ai.chat", async (span: Span) => {
const startTime = performance.now();
let firstTokenTime: number | null = null;
// Инициализация OpenTelemetry GenAI семантических атрибутов
span.setAttribute("gen_ai.system", "anthropic");
span.setAttribute("gen_ai.request.model", params.model);
span.setAttribute("gen_ai.request.temperature", params.temperature ?? 0.7);
span.setAttribute("gen_ai.operation.name", "chat");
span.setAttribute("enduser.id", params.userId);
span.setAttribute("session.id", params.sessionId);
// Langfuse Trace & Generation Span
const traceRef = langfuse.trace({
name: "agent-inference-step",
userId: params.userId,
sessionId: params.sessionId,
metadata: { environment: process.env.NODE_ENV },
});
const generation = traceRef.generation({
name: "llm-response",
model: params.model,
modelParameters: { temperature: params.temperature ?? 0.7 },
input: [
{ role: "system", content: sanitizePII(params.systemPrompt) },
{ role: "user", content: sanitizePII(params.userPrompt) },
],
});
try {
const result = await callFn(params.userPrompt, () => {
if (firstTokenTime === null) {
firstTokenTime = performance.now();
}
});
const endTime = performance.now();
const totalDurationMs = endTime - startTime;
const ttftMs = (firstTokenTime ?? endTime) - startTime;
const generationDurationSec = Math.max((endTime - (firstTokenTime ?? endTime)) / 1000, 0.001);
const tps = result.completionTokens / generationDurationSec;
// Запись результатов в OTel спан
span.setAttribute("gen_ai.usage.prompt_tokens", result.promptTokens);
span.setAttribute("gen_ai.usage.completion_tokens", result.completionTokens);
span.setAttribute("gen_ai.metrics.ttft_ms", ttftMs);
span.setAttribute("gen_ai.metrics.tps", tps);
span.setStatus({ code: SpanStatusCode.OK });
// Закрытие спана в Langfuse
generation.end({
output: sanitizePII(result.text),
usage: {
promptTokens: result.promptTokens,
completionTokens: result.completionTokens,
totalTokens: result.promptTokens + result.completionTokens,
},
});
return {
text: result.text,
metrics: {
ttftMs,
totalDurationMs,
tps,
promptTokens: result.promptTokens,
completionTokens: result.completionTokens,
},
};
} catch (error) {
span.recordException(error as Error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : "Unknown LLM execution failure",
});
generation.end({
level: "ERROR",
statusMessage: error instanceof Error ? error.message : "LLM Call Error",
});
throw error;
} finally {
span.end();
await langfuse.flushAsync();
}
});
}Для интеграции с Helicone на уровне прокси-шлюза достаточно передавать специализированные заголовки в HTTP-запросах, что исключает накладные расходы на стороне рантайма приложения:
export function getHeliconeHeaders(params: {
userId: string;
sessionId: string;
rateLimitPolicy?: string;
cacheEnabled?: boolean;
}): Record<string, string> {
const headers: Record<string, string> = {
"Helicone-Auth": `Bearer ${process.env.HELICONE_API_KEY}`,
"Helicone-User-Id": params.userId,
"Helicone-Session-Id": params.sessionId,
"Helicone-Session-Path": "/api/agent/execute",
"Helicone-Property-Environment": process.env.NODE_ENV ?? "development",
};
if (params.cacheEnabled) {
// Включение серверного кэширования на узле Helicone (TTL: 1 час)
headers["Helicone-Cache-Enabled"] = "true";
headers["Cache-Control"] = "max-age=3600";
}
if (params.rateLimitPolicy) {
// Ограничение частоты запросов на уровне шлюза: 100 запросов в минуту
headers["Helicone-RateLimit-Policy"] = params.rateLimitPolicy;
}
return headers;
}Запись необработанных заголовков Authorization, паролей пользователей или номеров документов в метаданные OTel или Langfuse. Данные логируются на сторонних серверах и нарушают стандарты GDPR/SOC2. Все входные и выходные строки должны проходить через этап препроцессинга с регулярными выражениями или специализированным NER-токенизатором.
Вызов блокирующего метода экспорта спанов (например, синхронный HTTP POST на каждый чанк генерации) внутри обработчика SSE. Это обрушивает TPS генерации и искусственно завышает задержку. Трейсы и события должны агрегироваться в буфер в памяти и отправляться батчами асинхронно через фоновый воркер.
Запуск фонового инструмента или обращения к очереди задач без проброса заголовка traceparent. В результате корневой трейс пользователя прерывается, а генерация ответа регистрируется как изолированный, не связанный сиротский спан (Orphan Span).
Оценка стоимости и расхода токенов через формулу length / 4. Для разных семейств моделей (Claude, OpenAI, Llama 3, DeepSeek) словари токенизаторов существенно отличаются (cl100k_base vs o200k_base). Это приводит к погрешностям биллинга до 35-40%. Используйте только метаданные usage из ответа API модели.
Генерация trace_id и span_id, связывание с родительским контекстом и установка базовых OTel-атрибутов
Маскирование чувствительных данных (PII, API keys) перед фиксацией промпта в логах
Отправка запроса в API шлюз и фиксация момента прихода первого токена
Завершение потока, извлечение usage-токенов из метаданных и расчет TPS
Асинхронная выгрузка структурированного спана в коллектор Langfuse / OpenTelemetry