构建基础设施为了显示基于调用链的跟踪信息我们在本地安装了Jaeger。为了收集和展示性能指标我们使用了Prometheus和Grafana。我们采用最简单的方式通过在本地创建相映的Docker容器来搭建这些服务。如果希望在Windows上执行相应的命令将换行符从\改为^即可。Jageerdocker run -d --name jaeger-p 16686:16686-p 4317:4317jaegertracing/all-in-one:latestPrometheusdocker run -d --name prometheus-p 9090:9090-v /c/prometheus/prometheus.yml:/etc/prometheus/prometheus.ymlprom/prometheus:latest其中c:\prometheus\prometheus.yml的内容如下global:scrape_interval: 5sscrape_configs:job_name: ‘prometheus’static_configs:targets: [‘localhost:9090’]job_name: ‘csharp_console_app’static_configs:targets: [‘192.168.1.166:9464’]192.168.1.166是本地的IP地址9464是接下来创建的应用暴露的端口用于输出性能指标信息。Grafanadocker run -d --name grafana-p 3000:3000grafana/grafana:latest然后添加针对Prometheushttp://192.168.1.166:9090的连接。我针对OpenTelemetryChatClient输出的指标创建了一个简单Dashboard可以通过这里下载并导入。构建一个简单的Agent应用我们创建一个简单的Console应用并添加针对OpenTelemetry.NET相关的NuGet包OpenTelemetryOpenTelemetry.Exporter.ConsoleOpenTelemetry.Exporter.OpenTelemetryProtocolOpenTelemetry.Exporter.Prometheus.HttpListenerOpenTelemetry.Extensions.Hosting如下所示的是完整的演示程序。最外层的两个using块分别创建了TracerProvider和MeterProvider前者用于链路跟踪后者用于性能指标的收集两者设置了相同的服务名称AIApp和版本1.0.0。对于Trace我们添加了Console和OTLP两种Exporter后者将数据发送到Jaeger。对于Metrics我们添加了Console和PrometheusHttpListener两种Exporter后者在http://192.168.1.166:9464/暴露性能指标供Prometheus收集。using Azure;using dotenv.net;using Microsoft.Agents.AI;using Microsoft.Extensions.AI;using OpenAI;using OpenTelemetry;using OpenTelemetry.Metrics;using OpenTelemetry.Resources;using OpenTelemetry.Trace;using System.Diagnostics;DotEnv.Load();var model Environment.GetEnvironmentVariable(“MODEL”)!;var apiKey Environment.GetEnvironmentVariable(“API_KEY”)!;var endpoint Environment.GetEnvironmentVariable(“OPENAI_URL”)!;var serviceName “AIApp”;var servceVersion “1.0.0”;using (Sdk.CreateTracerProviderBuilder().SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName, serviceVersion: servceVersion)).AddSource(serviceName).AddConsoleExporter().AddOtlpExporter(options {options.Endpoint new Uri(“http://localhost:4317”);options.Protocol OpenTelemetry.Exporter.OtlpExportProtocol.Grpc;}).Build())using (Sdk.CreateMeterProviderBuilder().SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(serviceName, serviceVersion: servceVersion)).AddMeter(serviceName).AddConsoleExporter().AddPrometheusHttpListener(options {options.UriPrefixes [“http://192.168.1.166:9464/”];}).Build()){var chatClient new OpenAIClient(credential: new AzureKeyCredential(apiKey),options: new OpenAIClientOptions { Endpoint new Uri(endpoint) }).GetChatClient(model: model).AsIChatClient().AsBuilder().UseOpenTelemetry(sourceName: serviceName).Build();string[] queries [ What is the capital of France?, Who won the FIFA World Cup in 2018?, What is the largest mammal on Earth? ]; var random new Random(); var source new ActivitySource(serviceName); for (int i 0; i 30; i) { using (source.StartActivity(Agent-Server, kind: ActivityKind.Server, parentContext: default)) { await Task.Delay(random.Next(100, 1000)); using (source.StartActivity(Foo)) { await Task.Delay(random.Next(100, 1000)); using (source.StartActivity(Bar)) { await Task.Delay(random.Next(100, 1000)); await chatClient.GetResponseAsync(queries[random.Next(queries.Length)]); } } } await Task.Delay(random.Next(3000, 5000)); } Console.ReadLine();}在using块中我们创建了用来调用LLM的IChatClient对象。具体来说我们首先创建了一个OpenAIClient并通过GetChatClient方法获取了一个针对聊天模型的客户端。然后我们将其转换为IChatClient并使用AsBuilder方法创建了一个可配置的构建器。在构建器上我们调用UseOpenTelemetry方法指定了与TracerProvider和MeterProvider相同的sourceNameAIApp以启用链路跟踪和性能指标的收集。最后我们调用Build方法构建了最终的IChatClient对象。为了模拟一段持续的调用我们在一个循环中随机选择了三个问题并调用了GetResponseAsync方法。为了模拟一段完整的调用链我们利用创建的ActivitySource将服务名称作为sourceName手动创建了三个不同层级的Activity分别命名为Agent-Server、Foo和Bar它们表示LLM调用外层的操作。结果展示运行程序之后我们可以在控制台上看到链路跟踪和性能指标的输出。同时在Jaeger的UI界面http://localhost:16686/上我们可以看到针对Agent-Server操作的调用链信息如下图所示Alternative Text打开Grafana的Dashboard我们可以看到针对LLM调用的性能指标其中包括请求和响应Token的消耗、调用LLM的延时、成功调用的比例和错误分布等。Alternative TextOpenTelemetryChatClient和我们演示的程序一样OpenTelemetryChatClient也是使用ActivitySource创建的Activity来表示针对LLM的调用。创建这个ActivitySource指定的名称来源于OpenTelemetryChatClient构造函数中的sourceName参数在OpenTelemetry的语境中将它视为服务名称。如果没有显示指定sourceNameOpenTelemetryChatClient会使用默认的名称Experimental.Microsoft.Extensions.AI。public sealed partial class OpenTelemetryChatClient : DelegatingChatClient{public OpenTelemetryChatClient(IChatClient innerClient,ILogger? logger null,string? sourceName null);public JsonSerializerOptions JsonSerializerOptions { get; set; } public bool EnableSensitiveData { get; set; } TelemetryHelpers.EnableSensitiveDataDefault; public override async TaskChatResponse GetResponseAsync( IEnumerableChatMessage messages, ChatOptions? options null, CancellationToken cancellationToken default); public override async IAsyncEnumerableChatResponseUpdate GetStreamingResponseAsync( IEnumerableChatMessage messages, ChatOptions? options null, CancellationToken cancellationToken default);}EnableSensitiveData属性用于控制是否允许在Trace数据中包含一些敏感数据这个属性的默认值来源于针对环境变量OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT的设置。如果两者均为设置默认不捕获敏感数据。如果这个属性被设置为true调用LLM的请求和响应消息会被序列化并作为操作的标签进行输出JsonSerializerOptions属性就是用来控制这个序列化过程的行为的。在重写的GetResponseAsync和GetStreamingResponseAsync方法中OpenTelemetryChatClient会创建一个新的Activity来表示针对IChatClient的调用。如果能够从ChatOptions中提取名称的名称对应ModelId属性此操作被命名为“chat {model-name}”否则被命名为“chat”。创建的Activity会被设置一系列丰富的标签来描述此次调用。对于我们前面的演示程序OpenTelemetryChatClient创建的跟踪操作包含的标签体现在如下这张针对Jaeger的截图上。Alternative Text对.NET的诊断跟踪具有了解的开发者应该知道性能指标是通过Meter计算和输出的。所以OpenTelemetryChatClient在构造函数中创建了一个Meter对象此对象依然使用sourceName作为它的名称。它会创建如下的性能指标gen_ai.client.operation.duration整个AI请求从发送到接收完成的总持续时间计量单位为秒。它记录了端到端End-to-End的延迟包括了网络传输、模型在云端的排队等待、模型推理计算以及数据返回的完整时间用于评估系统的整体性能。如果这个值突然变高说明用户等待回答的时间变长了。通常结合分位数如P95, P99来监控长尾延迟gen_ai.client.token.usage单次AI请求所消耗的Token数量。该指标通常包含多个维度Attributes如gen_ai.token.type会细分为input提示词Token和output模型生成的Token。通过统计这个指标你可以精准计算出应用消耗了多少费用或者分析是否存在异常的“长文本”请求耗尽了配额gen_ai.client.operation.time_to_first_chunk在使用流式传输时从发出请求到收到第一个数据块Chunk所消耗的时间。计量单位为秒 。这就是业界常说的TTFT (Time to First Token)它反映了模型的启动速度和网络建连的延迟而与模型最终生成多少个字无关。这是一个体现用户体验UX的核心指标。在聊天界面中只要第一个字蹦出来用户就会觉得系统在工作。如果TTFT过高用户会感到明显的卡顿和焦虑gen_ai.client.operation.time_per_output_chunk在使用流式传输时模型生成和传输每一个后续数据块Chunk的平均间隔时间计量单位为秒。它反映了模型的推理吞吐量类似常说的Tokens Per Second。数值越低意味着流式打字机的输出速度越快、越流畅。这个指标用于监控流式输出的流畅度。如果这个值偏高用户会看到打字机效果非常慢甚至出现一卡一卡抖动的现象。通常与模型的并发负载、云端实例的算力有关我制作Grafana Dashboard比较简单只用到了前面两个指标。至于后面两个流式传输相关的指标有兴趣的读者可以自己动手添加一下。UseOpenTelemetryOpenTelemetryChatClient的注册可以如下这个针对ChatClientBuilder的扩展方法UseOpenTelemetry来完成。它接受三个参数loggerFactory用于创建日志记录器sourceName用于指定ActivitySource和Meter的名称configure是一个可选的委托用于进一步配置OpenTelemetryChatClient实例。public static class OpenTelemetryChatClientBuilderExtensions{public static ChatClientBuilder UseOpenTelemetry(this ChatClientBuilder builder,ILoggerFactory? loggerFactory null,string? sourceName null,Action? configure null);}