Skip to content
LLM 应用可观测性技术教程
概述
LLM 应用的可观测性,是指通过日志、指标和追踪等手段,还原一次请求从入口到模型调用、检索、工具调用、最终输出之间的完整过程。对于基于大语言模型开发的系统,除了常规的接口延迟、错误率、吞吐量,还需要记录模型名称、Token 用量、成本、Prompt 摘要等信息。
传统 APM 面向的是调用关系相对稳定的服务:一个请求会经过网关、业务服务、数据库、缓存,调用链中的每个节点都是可枚举的。LLM 应用在此基础上增加了一类外部依赖:模型 API。模型调用具有输出不确定、延迟波动大、按 Token 计费等特征。只监控 HTTP 状态码,无法区分“模型拒绝了请求”和“模型成功返回但输出不符合预期”。因此,模型调用需要被当作可观测性系统中的一等公民,与普通 RPC 调用区分开来。
内容组织顺序为:数据模型 → 采集 → 传输 → 存储 → 告警。覆盖内容包括:
- LLM 应用调用链的基本结构;
- 日志、指标、追踪三大支柱;
- OpenTelemetry GenAI 语义约定;
- 模型调用的 Token、成本、延迟、错误率指标;
- 自动埋点与手动埋点;
- OpenTelemetry Collector 与后端系统;
- 采样、脱敏、告警和看板;
- 工具生态与演进方向。
LLM 应用调用链的基本结构
一个 LLM 应用的最小形态是一个“消息进、消息出”的服务:
text
HTTP 入口 → 构造消息 → 模型调用 → 返回在此基础上,常见的扩展形态有两种。
RAG(检索增强生成)应用在模型调用之前增加一个检索阶段:
text
HTTP 入口 → 向量检索 → 增强提示词 → 模型调用 → 返回Agent 应用则在一次请求内多次调用模型和执行工具:
text
HTTP 入口 → 模型决策 → 工具调用 → 再次模型调用 → 最终回答这三种形态的共同点是:一次用户请求最终会转换为一次或多次外部模型调用。可观测性系统需要记录这些调用之间的先后关系和依赖关系。
RAG 调用链中,向量检索的结果会影响提示词的长度和内容;Agent 调用链中,工具调用的结果会成为下一次模型调用的上下文。因此,仅记录单次模型调用是不够的,需要把检索、工具调用和模型调用关联到同一条调用链中。
可观测性三大支柱:日志、指标与追踪
可观测性的三大支柱是日志(Logs)、指标(Metrics)和追踪(Traces)。三者记录的对象不同,组合起来才能形成完整视图。
日志是一条带时间戳的事件记录。在 LLM 应用中,关键日志事件包括:请求进入、检索完成、模型调用开始、模型调用结束、工具执行完成。日志中应包含模型名、消息摘要、Token 用量等字段。
指标是一段时间内的聚合数值。LLM 应用的基础指标包括:
- 请求量:按模型、接口、应用维度统计;
- 延迟:模型调用的 P50、P95、P99;
- Token 用量:Input Token、Output Token;
- 成本:由 Token 用量乘以模型单价换算;
- 错误率:按错误类型分类;
- 限流事件:HTTP 429、配额耗尽。
追踪记录一次请求从入口到所有依赖调用的调用树。追踪的基本单位是 Span,一个 Span 代表一次操作,多个 Span 通过 trace_id 组成 Trace。与日志和指标相比,追踪能够回答“这次请求到底慢在哪里”的问题。
三大支柱不是孤立的。日志中写入 trace_id,可以将日志挂到调用链上;指标样本通过 Exemplar 携带 trace_id,可以从指标告警跳转到具体请求。两种关联方式分别在后续小节展开。
追踪模型:Span、Trace 与 GenAI 语义约定
Span 与 Trace
Span 是追踪的基本单元,它描述一次带时间范围的操作。一个 Span 包含:
text
trace_id: 调用链的唯一标识
span_id: 当前 Span 的唯一标识
parent_span_id: 父 Span 的标识
name: 操作名称
kind: Span 类型,如 client、server、internal
start_time / end_time: 操作时间范围
attributes: 键值对属性
events: 时间点事件
status: 状态,OK / ERROR多个 Span 通过父子关系形成树形结构。一次请求的根 Span 表示整个操作,Retrieval、Tool Call、LLM Call 是它的子 Span。
在 LLM 应用中,模型调用 Span 通常使用 client 类型,因为它是在向外部模型服务发起请求;入口请求 Span 使用 server 类型;检索、工具执行等内部操作使用 internal 类型。
GenAI 语义约定
不同模型服务商的 API 结构不同,但语义相近:都有模型名、消息列表、Token 用量。OpenTelemetry 社区通过 GenAI Semantic Conventions 对这些语义进行标准化。
需要说明的是,GenAI 语义约定目前仍处于 development 阶段,尚未进入 stable。属性名会随版本演进。以 v1.38.0 的更新为例,旧的 gen_ai.prompt 和 gen_ai.completion 被标记为 deprecated,推荐改用结构化的消息属性 gen_ai.input.messages、gen_ai.output.messages 和 gen_ai.system_instructions[1]。
常用的 GenAI 属性包括:
gen_ai.system:模型服务商,如openai、anthropic;gen_ai.model:模型名,如gpt-4o-mini;gen_ai.input.messages:输入给模型的消息列表;gen_ai.output.messages:模型输出的消息对象;gen_ai.system_instructions:系统指令内容;gen_ai.usage.input_tokens:输入 Token 数;gen_ai.usage.output_tokens:输出 Token 数。
由于 Span 属性值不支持嵌套对象,代码中的消息列表需要序列化为 JSON 字符串后写入。
手动创建模型调用 Span
下面的代码使用 OpenTelemetry Node.js API 手动创建一个模型调用 Span:
js
import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api';
import OpenAI from 'openai';
const tracer = trace.getTracer('llm-tutorial');
const openai = new OpenAI();
async function callModel(model, messages) {
const span = tracer.startSpan('llm.generate', {
kind: SpanKind.CLIENT,
});
span.setAttribute('gen_ai.system', 'openai');
span.setAttribute('gen_ai.model', model);
span.setAttribute('gen_ai.input.messages', JSON.stringify(messages));
try {
const response = await openai.chat.completions.create({ model, messages });
const text = response.choices[0]?.message?.content ?? '';
span.setAttribute('gen_ai.output.messages', JSON.stringify({
role: 'assistant',
content: text,
}));
span.setAttribute('gen_ai.usage.input_tokens', response.usage?.prompt_tokens ?? 0);
span.setAttribute('gen_ai.usage.output_tokens', response.usage?.completion_tokens ?? 0);
span.setStatus({ code: SpanStatusCode.OK });
return text;
} catch (error) {
span.recordException(error);
span.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
span.end();
}
}这段代码在 Span 上记录模型名、输入消息、输出消息和 Token 用量。gen_ai.input.messages 和 gen_ai.output.messages 的值是 JSON 字符串,后端可以解析后用于检索或展示。
把完整消息写入 Span 属性会带来体积和隐私问题。实际采集时通常需要截断或脱敏,具体见“运行配置”一节。
日志与链路关联:结构化日志与上下文传播
要使日志能够关联到 Trace,日志中需要包含 trace_id 和 span_id。在 OpenTelemetry 中,可以通过当前 Context 获取这些值。
js
import { trace } from '@opentelemetry/api';
function getCurrentTraceIds() {
const span = trace.getSpan(context.active());
if (!span) {
return { trace_id: null, span_id: null };
}
const spanContext = span.spanContext();
return {
trace_id: spanContext.traceId,
span_id: spanContext.spanId,
};
}
logger.info('llm.request', {
...getCurrentTraceIds(),
model: 'gpt-4o-mini',
promptTokens: 128,
});配合结构化日志系统,按 trace_id 查询就能获得某次请求的全部日志。
上下文传播要解决的是:当请求跨服务传递时,如何把 trace_id 传递下去。在 HTTP 通信中,OpenTelemetry 会在客户端自动注入 W3C traceparent 头,在服务端自动提取。对于自定义协议或异步队列,需要手动传播。
在 LLM 调用场景中,应用调用 OpenAI API 时,OpenAI 服务端虽然会收到 traceparent 头,但其托管服务不会基于该头把内部处理 Span 注入到调用方的 Trace 中。OpenTelemetry 的 LLM instrumentation 只能把模型调用作为一个客户端 Span 记录,无法获取模型服务端的内部 Span。如果模型服务是自托管的,则需要模型服务端单独接入可观测性系统,再通过请求 ID 或自定义响应头与应用侧关联。
指标采集:Token 用量、成本、延迟与错误率
Token 用量
Token 用量是 LLM 应用最核心的指标之一。非流式响应中,response.usage 会直接返回 Token 计数:
js
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages,
});
console.log(response.usage);
// { prompt_tokens: 128, completion_tokens: 56, total_tokens: 184 }流式响应的情况不同。OpenAI 在流式响应中默认不返回 usage 对象,需要在请求中显式设置 stream_options.include_usage = true[3][4]。设置后,流式响应末尾会出现一个包含 usage 的特殊 chunk:
js
const stream = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages,
stream: true,
stream_options: { include_usage: true },
});
let promptTokens = 0;
let completionTokens = 0;
for await (const chunk of stream) {
if (chunk.usage) {
promptTokens = chunk.usage.prompt_tokens;
completionTokens = chunk.usage.completion_tokens;
}
}如果使用流式响应但未设置 include_usage,就只能用 tiktoken 等库自行估算。估算结果可能与实际计费不一致,因为它不包含服务端特殊分词规则和计费策略的影响,适合做粗粒度统计,不适合作为结算依据[4]。
社区也有建议让模型服务商在响应头中直接暴露 Token 统计字段,以降低客户端的统计成本,但这一行为尚未成为 API 标准[2]。
延迟
延迟分两种:
- 模型调用总延迟:从发起请求到收到完整响应的耗时;
- 流式场景的首 Token 延迟:从发起请求到收到第一个内容 chunk 的耗时。
总延迟可以直接由 Span 的 end_time - start_time 得到。首 Token 延迟需要在流式回调中记录第一个 chunk 的时间,再写入 Span Event 或属性。
成本
成本通常不直接来自模型 API,而是根据 Token 用量和模型单价换算。可以在代码中做换算,也可以在后端告警程序里做换算。inputPricePer1K 与 outputPricePer1K 为每千 Token 单价,由配置提供:
js
const cost = (
response.usage.prompt_tokens / 1000 * inputPricePer1K
+ response.usage.completion_tokens / 1000 * outputPricePer1K
);换算逻辑会随模型和计费方式变化,单价通常在配置中维护,而不是散落在代码中。
错误与限流
错误指标需要区分错误类型:
- HTTP 4xx:请求参数错误、认证失败、上下文过长;
- HTTP 429:限流、配额耗尽;
- HTTP 5xx:模型服务端故障。
429 是 LLM 应用最常见的稳定性风险之一。当 429 事件发生时,应用会重试,重试会进一步增大请求量,可能触发更严格的限流。因此,429 应单独作为一个指标统计,而不是混在普通错误中。
指标与 Trace 的关联:Exemplars
指标是聚合值,无法直接定位到具体请求。为了从指标跳到调用链,需要使用 Exemplar 机制。
OpenTelemetry Metrics SDK 在记录指标样本时,可以从当前 Span 上下文提取 trace_id,并将 trace_id 作为 Exemplar 附加到样本中。指标后端如果支持 Exemplar 存储,就可以在查询指标时看到该样本对应的 trace_id。例如,Prometheus 搭配 Grafana 时,可以在图表面板中显示 Exemplar 的 trace_id,点击后跳转到对应 Trace。
Exemplar 是一个可选能力,不是所有指标后端都支持。在使用 Prometheus 存储指标时,需要为 TSDB 配置 Exemplar 存储容量,否则聚合后的指标会丢失 trace_id 关联。在 OpenTelemetry 到 Prometheus 的链路中,默认的 Exemplar 过滤器通常会从当前 Context 中提取 trace_id,不需要在业务代码中手动传入。
埋点实现:自动埋点与手动埋点
自动埋点
当应用使用 LangChain、LlamaIndex 等框架时,可以借助现成的 Instrumentation 库自动埋点。OpenLLMetry 提供了 opentelemetry-instrumentation-langchain 包[6]:
js
import { LangchainInstrumentor } from 'opentelemetry-instrumentation-langchain';
LangchainInstrumentor().instrument();调用 instrument() 之后,LangChain 内部的模型调用、检索、工具调用会被自动记录为 OpenTelemetry Span,并通过 OTLP 导出。除了 LangChain,OpenLLMetry 还提供针对 Anthropic、LlamaIndex 等框架的 Instrumentor[6]。
导出前需要配置 OTLP 地址和资源属性,常见方式是通过环境变量:
bash
export OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer token"
export OTEL_RESOURCE_ATTRIBUTES="service.name=rag-server"自动埋点的优点是接入成本低,缺点是采集内容和 Span 结构由框架决定。如果需要在 Span 上增加业务属性,或者控制哪些信息不采集,仍需要手动埋点。
手动埋点:RAG 与 Agent 嵌套 Span
手动埋点适合自动埋点覆盖不到的场景。下面是一个同时包含检索、工具调用和模型调用的 Node.js 示例。startActiveSpan 创建的 Span 会自动成为当前 Context 的活动 Span,因此在异步函数内部创建的 Span 会自动成为它的子 Span。
为控制示例长度,下面的代码使用 truncateForTrace 和 truncateJsonForTrace 两个辅助函数对写入 Span 的文本做截断与脱敏,实现见本章最后一节。
js
import { trace, SpanStatusCode } from '@opentelemetry/api';
import OpenAI from 'openai';
const tracer = trace.getTracer('rag-agent-demo');
const openai = new OpenAI();
async function chatWithRetrieval(query) {
return tracer.startActiveSpan('chat.request', async (root) => {
root.setAttribute('app.query', truncateForTrace(query, 500));
try {
const chunks = await retrieveDocuments(query);
root.setAttribute('retrieve.result_count', chunks.length);
const answer = await callModel('gpt-4o-mini', [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `${query}\n\n参考片段:\n${chunks.join('\n')}` },
]);
root.setStatus({ code: SpanStatusCode.OK });
return answer;
} catch (error) {
root.setStatus({
code: SpanStatusCode.ERROR,
message: error instanceof Error ? error.message : String(error),
});
throw error;
} finally {
root.end();
}
});
}
async function retrieveDocuments(query) {
return tracer.startActiveSpan('retrieve.documents', async (span) => {
const embedding = await createEmbedding(query);
const chunks = await searchVectorStore(embedding);
span.setAttribute('retrieve.embedding_model', 'text-embedding-3-small');
span.setAttribute('retrieve.result_count', chunks.length);
span.setStatus({ code: SpanStatusCode.OK });
return chunks;
});
}
async function callTool(toolName, args) {
return tracer.startActiveSpan(`tool.${toolName}`, async (span) => {
span.setAttribute('tool.name', toolName);
span.setAttribute('tool.args', truncateJsonForTrace(args));
const result = await executeTool(toolName, args);
span.setAttribute('tool.result', truncateJsonForTrace(result));
span.setStatus({ code: SpanStatusCode.OK });
return result;
});
}
async function callModel(model, messages) {
return tracer.startActiveSpan('llm.generate', async (span) => {
span.setAttribute('gen_ai.system', 'openai');
span.setAttribute('gen_ai.model', model);
span.setAttribute('gen_ai.input.messages', truncateJsonForTrace(messages));
const response = await openai.chat.completions.create({ model, messages });
const text = response.choices[0]?.message?.content ?? '';
span.setAttribute('gen_ai.output.messages', truncateJsonForTrace([
{ role: 'assistant', content: text },
]));
span.setAttribute('gen_ai.usage.input_tokens', response.usage?.prompt_tokens ?? 0);
span.setAttribute('gen_ai.usage.output_tokens', response.usage?.completion_tokens ?? 0);
span.setStatus({ code: SpanStatusCode.OK });
return text;
});
}chatWithRetrieval 生成的 Trace 结构如下:
text
chat.request
├── retrieve.documents
│ └── embedding.create
└── llm.generate如果 Agent 场景中根 Span 命名为 agent.run,内部调用工具并多次调用模型,则结构为:
text
agent.run
├── tool.web_search
├── llm.generate
└── llm.generate这种嵌套关系能清楚回答两个问题:
- 慢在哪个环节:检索、工具调用还是模型调用;
- Token 和成本发生在哪一次模型调用。
SDK 初始化
手动埋点前需要初始化 OpenTelemetry SDK:
js
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
const sdk = new NodeSDK({
resource: new Resource({
'service.name': 'rag-demo',
}),
traceExporter: new OTLPTraceExporter({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
}),
});
sdk.start();如果只安装了基础 API 包而没有注册 SDK,tracer.startSpan 会返回一个 Noop Span,所有属性都不会被记录。排查埋点问题时,先检查 SDK 是否启动。
在埋点入口做基本清洗
如果不希望把完整 Prompt 写入 OTel Span,可以在埋点入口调用一个统一的清洗函数。下面是一个简单的字符串截断和 PII 替换函数:
js
import { createHash } from 'crypto';
function sanitizeForTrace(value, maxLength = 1000) {
let text = String(value);
text = text
.replace(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, '[EMAIL]')
.replace(/\b1[3-9]\d{9}\b/g, '[PHONE]');
return text.length > maxLength
? text.slice(0, maxLength) + '...'
: text;
}
function hashValue(value) {
return createHash('sha256').update(value).digest('hex').slice(0, 16);
}然后在埋点时:
js
span.setAttribute('gen_ai.input.messages', sanitizeForTrace(JSON.stringify(messages)));对于需要保留原始语义但不希望明文存取的字段,可以使用 hashValue 生成短哈希。这种替换是单向的,不能还原。如果后续需要基于原始 Prompt 做调试,采集层应保留可配置的完整采样模式,而不是默认全量收集。
可观测性管道:OpenTelemetry Collector 与后端系统
埋点产生的 Telemetry 数据需要经过采集、处理、存储和展示。典型的数据链路如下:
text
应用 SDK ──OTLP──> OpenTelemetry Collector ──> Trace 后端:Jaeger / Tempo
├──> Metrics 后端:Prometheus
└──> 日志后端:Loki / OpenSearchOpenTelemetry Collector 可以接收来自应用的 OTLP 数据,在管道中执行批处理、采样、脱敏、过滤,再分发给不同的后端。一个最小 Collector 配置:
yaml
receivers:
otlp:
protocols:
grpc:
http:
processors:
batch:
exporters:
otlp/jaeger:
endpoint: jaeger:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/jaeger]在较大规模的部署中,Collector 通常部署为独立服务,而不是与应用共用一个进程。这样做的原因是:
- 应用只负责发送数据,不关心后端的类型;
- 采样、脱敏、过滤策略可以在 Collector 中统一调整,无需修改应用代码;
- 后端存储扩容时,只需修改 Collector 配置。
在指标方面,Collector 可以将 OTLP 指标转换为 Prometheus 文本格式暴露给 Prometheus 抓取,也可以通过 remote write 发送到远端。日志方面,应用可以直接将结构化日志写入日志后端,也可以先发送到 Collector 再分发。选择哪种方式取决于日志系统的接入协议。
模型部署方式对监控范围的影响
模型服务是自托管还是使用托管 API,决定了可观测性的边界。
使用托管模型 API(如 OpenAI、Anthropic、Gemini)时,应用层可以看到:
- 模型名称、Token 用量;
- 应用侧观测到的请求延迟;
- HTTP 状态码和错误信息。
看不到:
- 模型服务端的排队时间;
- 推理节点的资源占用;
- 服务端 Token 化细节。
自托管模型服务(如 vLLM、TGI、SGLang)可以在模型服务层暴露额外指标,例如:
- 吞吐量(每秒请求数、每秒 Token 数);
- 队列长度;
- KV Cache 使用率;
- 首 Token 时间(TTFT)分布;
- GPU 利用率和显存占用。
这些指标通常通过 Prometheus 协议从模型服务暴露出来,属于模型服务层监控,与应用层的 OpenTelemetry Trace 是两套数据。应用层记录“这次请求调用了哪个模型、消耗了多少 Token”,模型服务层记录“这个模型实例当前负载如何”。要把两者关联起来,通常需要在应用侧记录模型服务的请求 ID 或实例 ID,再在模型服务日志中查询对应时间段的资源状态。
运行配置:采样、脱敏、告警与看板
采样策略
LLM 应用的 Trace 数据量通常远大于普通 Web 应用,因为一次请求可能携带完整 Prompt 和响应文本。全量保存成本很高,因此需要采样。
按采样位置可分为两类:
- Head Sampling:在 SDK 端决定是否记录 Trace。优点是实现简单,在数据产生的源头丢弃部分数据;缺点是决策时还不知道 Trace 最终是否出错,容易漏掉错误 Trace。
- Tail Sampling:在 Collector 端决策,等 Trace 完整到达后再决定保留还是丢弃。优点是可以根据最终状态做出决策,例如保留所有错误 Trace、保留耗时超过阈值的 Trace;缺点是需要缓冲完整 Trace,内存开销更大。
Tail Sampling 示例:
yaml
processors:
tail_sampling:
decision_wait: 10s
policies:
- name: keep-errors
type: status_code
status_code:
status_codes: ["ERROR"]
- name: keep-slow
type: latency
threshold_ms: 5000这个配置会保留所有包含 ERROR Span 的 Trace,以及总耗时超过 5 秒的 Trace。其余 Trace 按后端默认策略丢弃。
在 OpenTelemetry SDK 中,也可以通过采样器配置固定比例:
js
import { TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
new NodeSDK({
sampler: new TraceIdRatioBasedSampler(0.1),
});0.1 表示保留 10% 的 Trace。具体比例应根据请求量和存储成本调整。
数据脱敏与数据保留
LLM 应用的 Trace 中通常包含用户输入、检索片段、模型输出,这些数据可能包含个人身份信息(PII)。脱敏策略需要分层实施。
在代码埋点层,应避免写入以下内容:
- API Key、Authorization 头;
- 文档中不必要保留的长文本;
- 明显属于个人信息的字段,如邮箱、手机号。
在写入 Span 前,可以使用正则替换或哈希:
js
import { createHash } from 'crypto';
function hashValue(value) {
return createHash('sha256').update(value).digest('hex').slice(0, 16);
}
function sanitizeMessageContent(content) {
return content
.replace(/\b[\w.+-]+@[\w-]+\.[\w.]+\b/g, '[EMAIL]')
.replace(/\b1[3-9]\d{9}\b/g, '[PHONE]');
}对于需要保留原始数据用于问题排查的场景,可以只对长文本做截断,并保留少量关键结构字段。
在 Collector 层,可以使用 attributes 处理器或 redaction 处理器删除指定属性。例如:
yaml
processors:
attributes/redact:
actions:
- key: gen_ai.input.messages
action: delete需要根据使用的 Collector 发行版确认对应的处理器名称。一般建议在应用侧先做脱敏,Collector 侧的脱敏只作为兜底。
数据保留策略应区分数据类型:
- 完整 Trace(含 Prompt 摘要)保存时间最短;
- 指标聚合数据保存时间最长;
- 结构化日志介于两者之间。
如果涉及数据合规要求(例如用户有权要求删除个人数据),需要确认后端支持按用户标识或 Trace ID 删除数据。如果后端不支持单条删除,则应在采集阶段尽量降低个人数据的留存。
告警规则
告警指标的命名与 SDK 的导出配置有关。以下 PromQL 表达式假设应用导出了以 llm_client_ 为前缀的自定义指标,实际使用时需替换为当前系统采用的指标名。
模型调用错误率:
promql
sum by (model) (rate(llm_client_request_errors_total[5m]))
/
sum by (model) (rate(llm_client_requests_total[5m]))
> 0.02延迟 P95:
promql
histogram_quantile(0.95,
sum by (le, model) (rate(llm_client_request_duration_seconds_bucket[5m]))
)
> 5Token 用量突增:
promql
sum by (model) (rate(llm_client_token_usage_total[5m]))
> 2 * sum by (model) (rate(llm_client_token_usage_total[5m] offset 1h))429 限流事件增加:
promql
sum by (model) (increase(llm_client_rate_limits_total[5m]))
> 10错误率阈值、延迟阈值和限流阈值应根据具体模型和应用基线设置,以上表达式中的数值仅作为示例。对于模型调用错误率,建议按模型维度分别告警,因为不同模型的敏感度不同。
看板设计
看板的目标是把 Trace 中的 Span 属性聚合为可浏览的指标。常见面板包括:
- 总览:请求量、错误量、平均延迟;
- 模型维度:各模型的调用量、Token 用量、成本;
- 延迟分布:P50/P95/P99,按检索、工具调用、模型调用拆分;
- 错误分布:按状态码和错误类型统计;
- 限流:429 事件走势。
如果指标后端支持 Exemplar,可以在延迟或错误率面板中开启 Exemplar 显示。这样在一个时间点出现高延迟时,可以直接从指标图中的样本点跳到对应的 Trace。
工具生态与选型
LLM 可观测性工具分成两类:通用可观测性后端和 LLM 专用平台。
通用后端包括 Jaeger、Tempo、Prometheus、Grafana、Loki 等。这类工具不感知模型语义,但可以接收 OpenTelemetry 标准属性。应用埋点中记录的 gen_ai.* 属性会作为 Span 属性存储,可以按模型名、Token 用量过滤。
LLM 专用平台包括:
- Langfuse:开源,支持自托管,提供 Trace 查看、Prompt 管理、数据集和模型评估功能;
- LangSmith:LangChain 团队提供的托管平台,与 LangChain、LlamaIndex 等框架深度集成;
- Helicone:以代理网关模式接入,拦截模型 API 请求并记录 Token、成本、延迟;
- OpenLLMetry:Traceloop 开源项目,提供一组基于 OpenTelemetry 的 Instrumentor[6],本身不是后端,而是一种埋点工具集。OpenObserve 的 RAG demo 展示了基于 LangChain/LlamaIndex 的应用如何通过 OpenLLMetry 自动埋点并导出 Trace[5]。
选型时可以考虑以下问题:
- 是否要求数据保存在自建环境;
- 是否需要保存 Prompt 原文用于调试;
- 是否已经有 Prometheus 或 Grafana 等基础设施;
- 团队是否使用 LangChain / LlamaIndex 等框架;
- 是否需要模型评估、数据集管理等功能。
演进方向:Agentic tracing
Agent 应用会在一次请求内反复执行“思考 → 工具调用 → 结果观察 → 再思考”的循环。传统 Trace 将每次模型调用记录为独立 Span,可以表达调用次数和时序,但难以表达“Agent 当前处于决策循环的哪一步”。
OpenTelemetry GenAI 语义约定正在向 Agent 场景扩展。在这个方向稳定之前,实际项目可以先用自定义属性描述 Agent 结构。例如:
- 为 Agent 节点创建独立 Span,
name设为agent.run; - 工具调用 Span 使用
tool.${toolName}作为命名; - 在 Agent Span 上记录
agent.current_iteration、agent.max_iterations等属性。
当标准成熟后,只需要将自定义属性迁移到标准属性名,Span 结构本身可以保持。
总结
LLM 应用的可观测性涉及四个边界:应用层、模型服务层、可观测性后端、运行配置层。
应用层负责埋点,记录模型调用的 Span、Token 用量和业务上下文。模型服务层负责暴露模型运行指标,在自托管场景下尤其重要。可观测性后端负责存储和展示 Trace、Metrics、Logs。运行配置层负责采样、脱敏、告警和看板。
搭建一套 LLM 可观测性系统可以按以下步骤推进:
- 在应用中初始化 OpenTelemetry SDK;
- 使用自动埋点或手动埋点为 RAG、Agent 调用链创建 Span;
- 导出到 Collector;
- 在 Collector 中配置采样和脱敏;
- 将 Trace 导出到 Jaeger/Tempo,将指标导出到 Prometheus;
- 配置错误率、延迟、Token 用量和 429 告警。
参考链接
- [1] https://github.com/traceloop/openllmetry/issues/3515
- [2] https://community.openai.com/t/add-trailing-response-headers-for-token-cost-information/1332635
- [3] https://developers.openai.com/api/docs/guides/streaming-responses
- [4] https://community.openai.com/t/openai-api-get-usage-tokens-in-response-when-set-stream-true/141866
- [5] https://github.com/openobserve/langchain-llamaindex-tracing-demo
- [6] https://www.elastic.co/observability-labs/blog/elastic-opentelemetry-langchain-tracing
