AI服务的可观测性与运维
当 AI 服务从开发环境走向生产,可观测性(Observability)成为运维的基石。传统的监控(CPU、内存、请求量)已不足以应对 AI 系统的复杂性,我们需要深入追踪 每个 AI 交互的细节 :Token 消耗、模型延迟、函数调用路径、成本分布等。同时,AI 模型的持续迭代要求我们具备 A/B 测试 和 灰度发布 能力,以安全地引入新模型或提示词。
将构建一套完整的 AI 可观测性体系,涵盖 OpenTelemetry 埋点、实验框架以及成本分析工具。
1 OpenTelemetry 在 AI 链路中的埋点(Token 消耗、延迟)
1.1 为什么需要 OpenTelemetry?
OpenTelemetry(OTel)是云原生可观测性的行业标准,提供统一的 API 和 SDK 来收集 追踪(Traces) 、指标(Metrics) 和 日志(Logs)。对于 AI 应用,我们需要将 LLM 调用、向量检索、插件执行等环节作为 span 串联起来,形成完整的调用链,从而定位性能瓶颈和异常。
10.1.2 在 .NET 中集成 OpenTelemetry
首先安装必要的 NuGet 包:
bash
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Exporter.Console
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol # 导出到 OTLP 兼容后端
在 Program.cs 中配置:
csharp
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using OpenTelemetry.Resources;
var serviceName = "EnterpriseAI";
var serviceVersion = "1.0.0";
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddSource(serviceName)
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddSource("Microsoft.SemanticKernel") // 自动捕获 SK 的 spans
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService(serviceName, serviceVersion: serviceVersion))
.AddOtlpExporter()) // 发送到 Jaeger、Aspire Dashboard 等
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter(serviceName)
.AddPrometheusExporter()); // 可选:暴露 Prometheus 端点
1.3 为 AI 组件添加自定义 Span
我们需要在关键位置创建自定义 Span,记录模型调用、Token 用量等关键信息。
创建 ActivitySource:
csharp
public static class AIDiagnostics
{
public static readonly ActivitySource ActivitySource = new("EnterpriseAI.AI", "1.0.0");
}
在 Semantic Kernel 调用中添加 Span:
由于 Semantic Kernel 内部已支持 OpenTelemetry(通过 HttpClient 的自动追踪),但我们可以包装 Invoke 方法以添加更细粒度的信息。
csharp
public class ObservableKernel
{
private readonly Kernel _kernel;
public async Task<string> InvokePromptWithTelemetryAsync(string prompt, KernelArguments? arguments = null)
{
using var activity = AIDiagnostics.ActivitySource.StartActivity("AI Invoke", ActivityKind.Server);
activity?.SetTag("prompt", prompt);
activity?.SetTag("arguments", arguments?.ToString());
var stopwatch = Stopwatch.StartNew();
try
{
var result = await _kernel.InvokePromptAsync(prompt, arguments);
stopwatch.Stop();
activity?.SetTag("duration_ms", stopwatch.ElapsedMilliseconds);
activity?.SetTag("output_length", result.Length);
// 记录 Token 用量(需从响应中提取)
// 注意:OpenAI SDK 返回的 Completions 对象包含 Usage 信息
// 这里仅示例,实际需要从 Kernel 返回的结果中解析
return result;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}
对于 ONNX Runtime 推理,也可以添加类似的 Span。
在插件方法中添加 Span:
csharp
public class WeatherPlugin
{
[KernelFunction]
public async Task<string> GetWeatherAsync(string city)
{
using var activity = AIDiagnostics.ActivitySource.StartActivity("WeatherPlugin.GetWeather");
activity?.SetTag("city", city);
try
{
var weather = await _weatherService.GetAsync(city);
activity?.SetTag("result", weather);
return weather;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
}
}
1.4 记录 Token 消耗与成本
Token 消耗是 LLM 调用的核心指标。Azure OpenAI 和 OpenAI 的响应中都包含 Usage 对象。在 Semantic Kernel 中,可以通过配置 OpenAIPromptExecutionSettings 并捕获响应来提取。
自定义 DelegatingHandler 捕获响应:
csharp
public class TokenUsageHandler : DelegatingHandler
{
public static readonly Meter Meter = new("EnterpriseAI.AI");
public static readonly Counter<int> InputTokenCounter = Meter.CreateCounter<int>("ai.input_tokens");
public static readonly Counter<int> OutputTokenCounter = Meter.CreateCounter<int>("ai.output_tokens");
public static readonly Histogram<double> RequestDuration = Meter.CreateHistogram<double>("ai.request_duration");
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var response = await base.SendAsync(request, cancellationToken);
stopwatch.Stop();
// 尝试解析响应中的 usage
var content = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode && TryExtractUsage(content, out var inputTokens, out var outputTokens))
{
InputTokenCounter.Add(inputTokens);
OutputTokenCounter.Add(outputTokens);
RequestDuration.Record(stopwatch.Elapsed.TotalSeconds);
}
// 重新包装内容流,因为已经读取了一次
response.Content = new StringContent(content);
return response;
}
private bool TryExtractUsage(string json, out int inputTokens, out int outputTokens)
{
// 解析 OpenAI/Azure OpenAI 响应格式
// 示例:{"usage":{"prompt_tokens":10,"completion_tokens":20,"total_tokens":30}}
inputTokens = outputTokens = 0;
try
{
var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("usage", out var usage))
{
inputTokens = usage.GetProperty("prompt_tokens").GetInt32();
outputTokens = usage.GetProperty("completion_tokens").GetInt32();
return true;
}
}
catch { }
return false;
}
}
在注册 HttpClient 时添加此 Handler:
csharp
builder.Services.AddHttpClient("AzureOpenAI")
.AddHttpMessageHandler<TokenUsageHandler>();
1.5 集成可观测性后端
- 本地开发:使用 .NET Aspire Dashboard 或 Jaeger 查看 traces。
- 生产环境:导出到 Azure Application Insights、AWS X-Ray、Datadog 或 Prometheus + Grafana。
导出到 Application Insights(Azure Monitor):
bash
dotnet add package OpenTelemetry.Exporter.AzureMonitor
配置:
csharp
builder.Services.AddOpenTelemetry().WithTracing(tracing => tracing
.AddAzureMonitorTraceExporter(options =>
{
options.ConnectionString = "<Your Application Insights Connection String>";
}));
2 模型的 A/B 测试与灰度发布策略
2.1 为什么需要 A/B 测试?
模型迭代速度快,新模型可能在准确率、延迟、成本上有差异。直接全量替换风险极高。通过 A/B 测试,我们可以:
- 比较不同模型版本的效果(如 GPT-3.5 vs GPT-4)
- 验证新提示词模板的效果
- 评估不同部署后端(如本地 vs 云端)的性能
2.2 构建实验框架
设计一个简单的实验框架,支持按用户、租户或随机流量分配变体。
定义实验配置:
csharp
public class ExperimentConfig
{
public string Name { get; set; } = "model_ab_test";
public List<Variant> Variants { get; set; } = new();
public string DefaultVariant { get; set; }
}
public class Variant
{
public string Name { get; set; }
public int Weight { get; set; } // 流量权重百分比
public string ModelName { get; set; }
public string PromptTemplate { get; set; }
public Dictionary<string, object> Parameters { get; set; }
}
配置存储在 appsettings.json:
json
{
"Experiments": {
"model_ab_test": {
"Variants": [
{ "Name": "gpt35", "Weight": 50, "ModelName": "gpt-3.5-turbo", "PromptTemplate": "default" },
{ "Name": "gpt4", "Weight": 50, "ModelName": "gpt-4o", "PromptTemplate": "default" }
],
"DefaultVariant": "gpt35"
}
}
}
实验分配器:
csharp
public interface IExperimentAssigner
{
Variant GetVariant(string experimentName, string userId);
}
public class RandomExperimentAssigner : IExperimentAssigner
{
private readonly IConfiguration _config;
private readonly Random _random = new();
public Variant GetVariant(string experimentName, string userId)
{
var expConfig = _config.GetSection($"Experiments:{experimentName}").Get<ExperimentConfig>();
if (expConfig == null) return null;
// 可以基于 userId 实现确定性哈希,保证同一用户始终看到同一变体
var hash = (userId?.GetHashCode() ?? 0) % 100;
int cumulative = 0;
foreach (var variant in expConfig.Variants)
{
cumulative += variant.Weight;
if (hash < cumulative)
return variant;
}
return expConfig.Variants.FirstOrDefault(v => v.Name == expConfig.DefaultVariant);
}
}
在服务中使用:
csharp
public class ChatService
{
private readonly IExperimentAssigner _assigner;
private readonly KernelFactory _kernelFactory;
public async Task<string> ChatAsync(string userMessage, string userId)
{
var variant = _assigner.GetVariant("model_ab_test", userId);
var kernel = _kernelFactory.Create(variant);
var result = await kernel.InvokePromptAsync(variant.PromptTemplate, new() { ["input"] = userMessage });
// 记录实验数据(见下节)
return result;
}
}
2.3 灰度发布策略
灰度发布是逐步将流量切换到新版本的过程。与 A/B 测试不同,灰度通常有一个明确的目标(全量切换)。我们可以利用相同的实验框架,通过调整权重实现:
- 初始:新模型权重 0%,旧模型 100%。
- 灰度 1%:新模型 1%,旧模型 99%。
- 灰度 10%:新模型 10%,旧模型 90%。
- ...直至 100%。
自动化灰度:结合监控指标(错误率、延迟),当新版本指标异常时自动回滚。
2.4 实验数据收集与评估
A/B 测试需要收集关键指标,如:
- 业务指标:用户满意度(点赞/点踩)、任务完成率
- 技术指标:延迟、Token 消耗、错误率
- 成本指标:每次请求的成本
将实验变体信息添加到 OpenTelemetry span 的标签中:
csharp
using var activity = AIDiagnostics.ActivitySource.StartActivity("Chat");
activity?.SetTag("experiment", "model_ab_test");
activity?.SetTag("variant", variant.Name);
在后端(如 Application Insights)中,可以通过这些标签进行分组分析,比较不同变体的表现。
3 成本控制:Token 经济学与缓存策略
3.1 Token 成本构成
LLM 的成本与 Token 数量直接相关:
- 输入 Token:提示词、历史对话、检索的上下文
- 输出 Token:模型生成的回答
3.2 缓存策略
1. 精确缓存(Exact Match Cache)
对于完全相同的用户问题,直接返回缓存的结果。适用于 FAQ 类场景。
csharp
public class ExactMatchCache
{
private readonly IMemoryCache _cache;
private readonly TimeSpan _ttl;
public async Task<string> GetOrAddAsync(string userInput, Func<Task<string>> factory)
{
return await _cache.GetOrCreateAsync(userInput, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = _ttl;
return await factory();
});
}
}
2. 语义缓存(Semantic Cache)
对于语义相似但表述不同的问题,可以基于向量相似度判断是否命中缓存。将问题向量化,计算与缓存项的相似度,超过阈值则返回。
csharp
public class SemanticCache
{
private readonly IVectorDatabase _vectorDb;
private readonly ITextEmbeddingGenerationService _embeddingService;
public async Task<string?> GetAsync(string userInput, float similarityThreshold = 0.95f)
{
var embedding = await _embeddingService.GenerateEmbeddingAsync(userInput);
var results = await _vectorDb.SearchAsync(embedding.ToArray(), topK: 1);
if (results.Any() && results[0].Score >= similarityThreshold)
{
return results[0].Metadata["response"];
}
return null;
}
public async Task SetAsync(string userInput, string response)
{
var embedding = await _embeddingService.GenerateEmbeddingAsync(userInput);
await _vectorDb.AddAsync(Guid.NewGuid().ToString(), response, embedding.ToArray(),
metadata: new Dictionary<string, object> { ["response"] = response });
}
}
3. 提示词压缩
对于包含大量上下文的 RAG 应用,可以对检索到的内容进行摘要或提取关键句子,减少输入 Token。
csharp
public async Task<string> SummarizeContextAsync(string context, int maxTokens)
{
var prompt = $"Summarize the following context in less than {maxTokens} tokens:\n{context}";
var summary = await _kernel.InvokePromptAsync(prompt);
return summary;
}
3.3 模型选择策略
根据任务复杂度动态选择模型:
- 简单任务(如情感分析、分类):使用轻量级模型(GPT-3.5-turbo 或本地小模型)
- 复杂任务(如代码生成、逻辑推理):使用 GPT-4o
- 可批处理的任务:使用异步批处理模式,降低单次调用成本
3.4 成本监控与告警
在 OpenTelemetry 指标中增加成本指标:
csharp
public static class CostMetrics
{
public static readonly Histogram<double> RequestCost = Meter.CreateHistogram<double>("ai.request_cost", "USD");
}
// 在 TokenUsageHandler 中计算成本并记录
var cost = (inputTokens * 0.000005) + (outputTokens * 0.000015); // 示例单价
CostMetrics.RequestCost.Record(cost, new TagList { { "model", modelName } });
在 Prometheus/Grafana 中建立成本仪表板,并设置预算告警(如日成本超过 $100 时触发)。
3.5 限流与配额管理
防止单个用户或租户滥用导致成本激增:
csharp
public class RateLimiter
{
private readonly IMemoryCache _cache;
public bool IsAllowed(string userId, int maxTokensPerDay)
{
var key = $"user_tokens_{userId}";
var todayTokens = _cache.Get<int>(key);
if (todayTokens > maxTokensPerDay) return false;
// 在请求后增加 Token 计数
return true;
}
public void RecordTokens(string userId, int tokens)
{
var key = $"user_tokens_{userId}";
_cache.Set(key, _cache.Get<int>(key) + tokens, TimeSpan.FromDays(1));
}
}
总结
- OpenTelemetry 埋点:为 AI 链路添加分布式追踪和指标,记录 Token 消耗和延迟。
- A/B 测试与灰度发布:通过实验框架安全地迭代模型和提示词,降低变更风险。
- 成本控制:通过缓存、模型选择、限流等策略,在保证体验的前提下优化成本。