Skip to content
LLM 应用工程化:配置管理、日志、监控与异常处理
概述
一次 LLM 调用比普通 HTTP 请求包含更多环节。调用时需要携带 Prompt、模型参数、密钥等配置,需要处理流式输出,还需要应对限流、超时、配额耗尽等异常。如果这些环节没有统一管理,应用会出现配置散落、日志难以检索、故障无法感知、成本不可控等问题。
下面以 Node.js/ES6 为例,说明 LLM 应用在配置管理、结构化日志、监控指标、链路追踪和异常处理方面的基本做法。这些能力可以独立使用,也可以组合到一个统一的客户端入口中。
基本概念:三类配置来源
LLM 应用的配置通常分为三类:
- 环境变量:存放 API Key、BaseURL 等与部署环境相关的敏感信息。
- 配置文件:存放模型名、采样参数、Prompt 模板等非敏感静态内容。
- 运行时配置:由用户请求或后端下发的动态参数。
三层配置的优先级从低到高。运行时配置的优先级最高,环境变量的优先级最低。不同来源的配置需要在客户端入口处合并,并转换为代码可直接使用的数据结构。
配置管理:环境变量、配置文件与运行时配置
Node.js 中可以使用 dotenv 加载 .env 文件。下面的例子展示统一读取并转换配置的过程:
js
import 'dotenv/config';
const config = {
apiKey: process.env.LLM_API_KEY,
baseURL: process.env.LLM_BASE_URL,
model: process.env.LLM_MODEL || 'gpt-4o-mini',
temperature: process.env.LLM_TEMPERATURE
? Number(process.env.LLM_TEMPERATURE)
: undefined,
maxTokens: process.env.LLM_MAX_TOKENS
? Number(process.env.LLM_MAX_TOKENS)
: undefined,
};import 'dotenv/config' 会在模块加载时读取 .env 文件,并将变量写入 process.env。环境变量都以字符串形式存在,数字型配置需要先用 JavaScript 的 Number 方法转换为数值类型。temperature 和 maxTokens 在没有对应环境变量时保持 undefined,此时底层 SDK 会使用模型默认参数。
配置文件可以使用 JSON 或 YAML。敏感信息应通过环境变量引用,不要直接写入文件。运行时配置可以在请求处理过程中覆盖默认值:
js
function buildRequestConfig(userConfig) {
return {
...config,
...userConfig,
};
}展开运算符将默认配置与用户传入的配置合并。userConfig 中的字段会覆盖同名默认字段,未传入的字段保持默认值不变。
Prompt 模板与模型参数配置化
Prompt 模板不应散落在业务代码中。可以将模板集中定义,并使用 ES6 模板字符串生成最终 Prompt:
js
const promptTemplates = {
summarize: (text, language = 'zh') =>
`请用${language}概括以下内容,不超过200字:\n\n${text}`,
translate: (text, targetLang) =>
`请将以下内容翻译成${targetLang}:\n${text}`,
};该对象中的每个函数都返回一个完整的 Prompt 字符串。text、language、targetLang 是模板参数,在调用时插入。
模型参数可随请求传入,但不同模型对参数的支持不同。例如,部分模型不支持 temperature 等采样参数,部分模型只支持 max_tokens。在客户端封装层统一处理各模型之间的差异,可以减少业务方的适配负担。
结构化日志:请求/响应字段设计
结构化日志使用 JSON 格式输出,便于日志系统解析和检索。一次 LLM 调用的日志条目可以规划以下字段:
requestId:关联整个请求链路model:使用的模型名称prompt/response:输入输出内容(记录前需要脱敏)usage:Token 用量latencyMs:调用耗时error:错误信息
一个基础的日志函数:
js
function logLLMRequest({ requestId, model, prompt, response, usage, latencyMs, error }) {
const entry = {
timestamp: new Date().toISOString(),
level: error ? 'error' : 'info',
type: 'llm.call',
requestId,
model,
prompt,
response,
usage,
latencyMs,
error: error ? error.message : undefined,
};
console.log(JSON.stringify(entry));
}该函数接受一次 LLM 调用的上下文,将其序列化为 JSON 并写入标准输出。正常调用记录为 info 级别,异常调用记录为 error 级别。
实际部署时,可以选用 pino、winston 等日志库,并将输出发送到集中日志系统。日志条目的核心结构仍与上述示例一致。
Token 用量、耗时与审计日志
Token 用量是衡量 LLM 应用成本和效率的关键数据。非流式模式下,响应体中的 usage 对象通常包含 prompt_tokens、completion_tokens、total_tokens:
js
const completion = await client.chat.completions.create({
model: 'gpt-4o-mini',
messages,
});
console.log(completion.usage);completion.usage 是服务端返回的 token 统计。prompt_tokens 表示输入消耗,completion_tokens 表示输出消耗,total_tokens 是两者之和。
流式模式下,usage 不一定会出现在每个数据块中,通常在最后一个数据块中返回。因此需要在流结束时检查并记录,而不是在流刚开始时读取。
审计日志用于记录“谁在何时调用了什么模型”。它比普通日志更强调关联性和完整性:
js
function logAudit({ userId, requestId, model, usage, latencyMs }) {
console.log(JSON.stringify({
type: 'llm.audit',
userId,
requestId,
model,
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens,
totalTokens: usage.total_tokens,
latencyMs,
}));
}字段中增加了 userId,用于定位调用方。审计日志应与普通调试日志分开存储或单独标记,避免在排查问题时互相干扰。
日志脱敏与敏感信息保护
日志中可能包含 API Key、用户个人信息、Prompt 中的敏感数据。在写入日志前应进行脱敏。脱敏策略一般覆盖两类场景:
- 密钥脱敏:只保留开头和结尾少量字符。
- 内容脱敏:对 Prompt 或 Response 中的邮箱、电话号码、身份证号等使用正则替换。
一个简单的脱敏函数:
js
function maskSecret(value) {
if (!value || value.length < 6) return '***';
return `${value.slice(0, 2)}***${value.slice(-2)}`;
}
function maskEmail(email) {
return email.replace(/^(.)(.*)(@.*)$/, '$1***$3');
}maskSecret 保留前两位和末尾两位,中间替换为星号。maskEmail 保留邮箱第一个字符和 @ 域名部分,其余内容替换为星号。
脱敏后的数据只用于观察,不应再用于真实请求。如果使用 pino 等日志库,可以直接配置 redact 选项,按字段名自动过滤敏感内容。
监控指标:延迟、Token、成本与错误率
监控指标用于量化 LLM 服务的健康状态。常用指标包括:
- 延迟:调用耗时的分位数(p50、p95、p99)
- Token 速率:每分钟消费的 Token 数
- 每次调用 Token 数:prompt 与 completion 分别统计
- 成本估算:根据 Token 数与单价计算
- 错误率:按状态码或错误类型分类
- 限流与重试次数:反映服务端压力与客户端重试行为
Node.js 中可以使用 prom-client 暴露指标:
js
import { Counter, Histogram } from 'prom-client';
const llmCalls = new Counter({
name: 'llm_calls_total',
help: 'Total number of LLM calls',
labelNames: ['model', 'status'],
});
const llmDuration = new Histogram({
name: 'llm_duration_seconds',
help: 'LLM call latency in seconds',
labelNames: ['model'],
});Counter 用于累计事件次数,Histogram 用于记录耗时分布。两者都使用 model 作为标签,方便按模型聚合。
在调用点记录指标:
js
async function trackedCall(model, fn) {
const start = process.hrtime.bigint();
try {
const result = await fn();
llmCalls.inc({ model, status: 'success' });
return result;
} catch (err) {
llmCalls.inc({ model, status: String(err.status || 'error') });
throw err;
} finally {
const durationMs = Number(process.hrtime.bigint() - start) / 1e6;
llmDuration.observe({ model }, durationMs / 1000);
}
}调用成功时增加 success 计数,失败时按错误状态码增加对应计数。无论成功或失败,finally 块都会将耗时写入直方图。
成本指标可以基于响应中的 usage 和已知单价计算,但单价会因模型、地域和时间变化。建议在配置层定义单价函数,避免在业务代码中硬编码。
告警规则与重试/限流指标
告警规则依赖上述指标。常见的规则包括:
- 错误率持续 5 分钟超过 1%
- p95 延迟超过 5 秒
- 限流错误(429)数量在短时间窗口内突增
- 配额耗尽错误出现
这些规则在监控平台配置,但指标采集端需要提供对应的数据。例如,可以为限流错误单独建立计数器:
js
const llmRateLimit = new Counter({
name: 'llm_rate_limit_total',
help: 'Number of rate limit errors',
labelNames: ['model', 'reason'],
});记录重试次数同样重要:
js
const llmRetries = new Counter({
name: 'llm_retries_total',
help: 'Number of LLM retries',
labelNames: ['model', 'reason'],
});这两个计数器帮助区分“服务端限制”和“客户端重试策略”对错误率的影响。
追踪与链路:OpenTelemetry GenAI 语义约定
OpenTelemetry 的追踪能力可以串联一次 LLM 调用涉及的内部流程。GenAI 语义约定定义了一组与 LLM 相关的 Span 属性,常见字段包括:
gen_ai.provider:供应商名称gen_ai.request.model:模型名gen_ai.request.temperature:采样参数gen_ai.usage.prompt_tokens、gen_ai.usage.completion_tokens
在 Node.js 中使用 @opentelemetry/api 手动创建 Span:
js
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('llm-client');
async function callWithTracing(params) {
return tracer.startActiveSpan('llm.chat', async (span) => {
span.setAttribute('gen_ai.request.model', params.model);
try {
const result = await invoke(params);
span.setAttribute('gen_ai.usage.total_tokens', result.usage.total_tokens);
span.setStatus({ code: 1 });
return result;
} catch (err) {
span.recordException(err);
span.setStatus({ code: 2 });
throw err;
} finally {
span.end();
}
});
}startActiveSpan 启动一个名为 llm.chat 的 Span。调用开始时写入模型名,成功时写入 Token 用量,异常时记录异常信息并标记错误状态。span.end() 会在 finally 中执行,保证 Span 一定被关闭。
GenAI 语义约定仍在演进,不同平台的字段名可能不同。建议在客户端封装层维护一个字段映射表,使核心代码不依赖具体约定。
异常处理:错误分类与可重试性
LLM API 的错误可以按 HTTP 状态码分类:
- 400、401、403、404:客户端错误,需要修正请求,重试本身无法解决。
- 429:限流或配额耗尽。限流可以重试,配额耗尽不能通过重试恢复。
- 500、503:服务端错误,可以重试。
Node.js SDK 通常会将底层 HTTP 错误封装为异常类。可以自行封装一个统一的错误映射函数:
js
class LLMError extends Error {
constructor(status, type, message, retryable) {
super(message);
this.status = status;
this.type = type;
this.retryable = retryable;
}
}
function normalizeError(err) {
const status = err.status;
const raw = err.error || err;
// 不同服务的错误体格式不同:OpenAI 是 { error: { message } },有些是 { detail }
const message = raw.message || raw.detail || err.message;
const isQuota = status === 429 && /quota|exceeded/i.test(message);
const retryable = status === 429 && !isQuota || status >= 500;
return new LLMError(status, isQuota ? 'insufficient_quota' : 'api_error', message, retryable);
}normalizeError 将不同来源的错误转换为统一的 LLMError。429 状态码会被单独判断:如果错误消息包含 quota 或 exceeded,说明是配额耗尽,retryable 为 false;否则视为普通限流,可以重试。500 及以上的状态码一律标记为可重试。
注意:429 不一定都是限流。配额耗尽时,重试不会恢复,只会继续增加请求消耗。
超时预算、重试退避与熔断
超时预算定义了一次调用允许的最大等待时间。LLM 服务可能因为网络或服务端负载而长时间不返回,因此必须设置超时。在 Node.js 中,可以使用 AbortSignal.timeout() 或 SDK 的 timeout 参数。
一个通用的超时包装:
js
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
),
]);
}Promise.race 让原始请求与一个定时器竞争。定时器先触发时,调用方会收到超时错误。该包装只控制调用方的等待时间,并不会取消底层 HTTP 请求。
重试应使用指数退避,并加入随机抖动,避免大量请求在失败后同时重试:
js
const MAX_RETRIES = 3;
const BASE_DELAY = 1000;
async function retry(fn, attempt = 0) {
try {
return await fn();
} catch (err) {
if (!err.retryable || attempt >= MAX_RETRIES) {
throw err;
}
const delay = BASE_DELAY * 2 ** attempt + Math.random() * 200;
await new Promise((resolve) => setTimeout(resolve, delay));
return retry(fn, attempt + 1);
}
}每次失败后等待 BASE_DELAY * 2 ** attempt 毫秒,并叠加一个随机值,降低重试请求同时到达的概率。仅 retryable 为 true 的错误才会触发重试,且最多重试 MAX_RETRIES 次。
上述 MAX_RETRIES 与 BASE_DELAY 仅用于演示,具体数值应根据服务等级协议(SLA)和业务容忍度确定。
熔断是比重试更上层的保护机制。当连续失败次数达到阈值时,短时间内不再发起实际请求,直接快速失败。可以用计数器实现,也可以借助现成的熔断库完成。
流式响应异常与 Fallback 降级
流式响应(SSE)中,错误可能发生在流中途,此时客户端可能已经收到部分内容。处理流程如下:
- 监听流对象的
error事件。 - 在
for await...of中使用 try/catch 捕获错误。 - 根据错误类型决定是否丢弃部分结果。
示例:
js
const stream = await client.chat.completions.create({ stream: true });
let content = '';
try {
for await (const chunk of stream) {
content += chunk.choices?.[0]?.delta?.content ?? '';
}
} catch (err) {
// 记录错误,决定是否使用部分内容
console.error('stream error', err);
}for await...of 逐块读取流式内容。若流在读取过程中出错,会进入 catch。此时 content 中已经包含出错前收到的内容,可以根据业务场景决定丢弃还是部分使用。
如果 finish_reason 为 length,说明输出被 max_tokens 截断。此时应记录日志,并在后续请求中适当调整 max_tokens。
Fallback 降级是指主模型不可用时切换到备用模型。切换前需要确认错误是否可重试,并注意不同模型的输出格式和上下文窗口可能不同:
js
async function chatWithFallback(messages, primary, fallback) {
try {
return await callModel(primary, messages);
} catch (err) {
if (err.retryable) {
// 可以按策略先重试,或直接切换
}
return callModel(fallback, messages);
}
}主模型调用失败后,再尝试备用模型。是否先重试取决于业务策略,但需要注意两个模型可能返回不同结构的内容,调用方需要兼容这种差异。
应用:LLMClient 统一入口
将配置、日志、指标、追踪和异常处理组合到一个 LLMClient 类中,可以统一暴露 chat 方法,业务方无需关心底层细节。
js
class LLMClient {
constructor({ config, logger, metrics, tracer }) {
this.config = config;
this.logger = logger;
this.metrics = metrics;
this.tracer = tracer;
}
async chat(messages, options = {}) {
const requestConfig = {
...this.config,
...options,
};
const start = Date.now();
const span = this.tracer?.startSpan('llm.chat');
span?.setAttribute('gen_ai.request.model', requestConfig.model);
const requestId = crypto.randomUUID();
this.logger?.info({ event: 'llm.start', requestId, model: requestConfig.model });
try {
const response = await this.invokeWithRetry(requestConfig, messages);
const durationMs = Date.now() - start;
this.logger?.info({
event: 'llm.success',
requestId,
model: requestConfig.model,
usage: response.usage,
latencyMs: durationMs,
});
this.metrics?.observeSuccess(requestConfig.model, durationMs);
span?.setAttribute('gen_ai.usage.total_tokens', response.usage?.total_tokens);
span?.setStatus({ code: 1 });
return response;
} catch (err) {
const durationMs = Date.now() - start;
const normalized = normalizeError(err);
this.logger?.error({
event: 'llm.error',
requestId,
model: requestConfig.model,
error: normalized.message,
latencyMs: durationMs,
});
this.metrics?.observeError(requestConfig.model, normalized.status);
span?.recordException(normalized);
span?.setStatus({ code: 2 });
throw normalized;
} finally {
span?.end();
}
}
}构造函数注入配置、日志、指标与追踪器。chat 方法负责组合配置、生成 requestId、记录开始与结束日志、更新指标和 Span,并将异常统一转换为 LLMError 后抛出。invokeWithRetry 是内部方法,组合超时、重试和流式处理。
业务代码只需要实例化客户端并传入消息数组:
js
const client = new LLMClient({ config, logger, metrics, tracer });
const response = await client.chat([
{ role: 'user', content: 'Hello' },
]);调用方不直接接触配置合并、日志格式、指标更新或错误标准化,只接收一个正常的响应对象或一个统一的错误对象。
综合示例
以下是一个使用 LLMClient 的最小调用示例:
js
import { LLMClient } from './llm-client.js';
const config = {
apiKey: process.env.LLM_API_KEY,
model: process.env.LLM_MODEL || 'gpt-4o-mini',
};
const client = new LLMClient({ config });
const messages = [
{ role: 'user', content: '用一句话解释 Node.js 事件循环' },
];
try {
const res = await client.chat(messages);
console.log(res.choices[0].message.content);
} catch (err) {
console.error(`调用失败: ${err.message} (可重试: ${err.retryable})`);
process.exitCode = 1;
}正常调用时,chat 返回模型生成的完整响应,代码打印 choices[0].message.content。调用失败时,打印错误信息和可重试标记,并将进程退出码设置为 1。
总结
完整的 LLM 应用工程化需要配置、日志、指标、追踪和异常处理协同工作。配置管理解决“参数从哪里来”,结构化日志解决“发生了什么”,指标解决“系统状态如何”,追踪解决“调用链路如何”,异常处理解决“故障如何恢复”。将这些能力整合到统一客户端中,可以让业务代码保持简洁,同时使系统行为可观测、可预测。
