构建基础设施
为了显示基于调用链的跟踪信息,我们在本地安装了Jaeger。为了收集和展示性能指标,我们使用了Prometheus和Grafana。我们采用最简单的方式,通过在本地创建相映的Docker容器来搭建这些服务。如果希望在Windows上执行相应的命令,将换行符从\改为^即可。
Jageer:
docker run -d --name jaeger \ -p 16686:16686 \ -p 4317:4317 \ jaegertracing/all-in-one:latestPrometheus:
docker run -d --name prometheus \ -p 9090:9090 \ -v /c/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml \ prom/prometheus:latest其中c:\prometheus\prometheus.yml的内容如下:
global: scrape_interval: 5s scrape_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是接下来创建的应用暴露的端口,用于输出性能指标信息。
Grafana:
docker run -d --name grafana \ -p 3000:3000 \ grafana/grafana:latest然后添加针对Prometheushttp://192.168.1.166:9090的连接。我针对OpenTelemetryChatClient输出的指标创建了一个简单Dashboard,可以通过这里下载并导入。
2. 构建一个简单的Agent应用
我们创建一个简单的Console应用,并添加针对OpenTelemetry.NET相关的NuGet包:
- OpenTelemetry
- OpenTelemetry.Exporter.Console
- OpenTelemetry.Exporter.OpenTelemetryProtocol
- OpenTelemetry.Exporter.Prometheus.HttpListener
- OpenTelemetry.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相同的sourceName(AIApp),以启用链路跟踪和性能指标的收集。最后,我们调用Build方法构建了最终的IChatClient对象。
为了模拟一段持续的调用,我们在一个循环中随机选择了三个问题,并调用了GetResponseAsync方法。为了模拟一段完整的调用链,我们利用创建的ActivitySource(将服务名称作为sourceName)手动创建了三个不同层级的Activity,分别命名为Agent-Server、Foo和Bar,它们表示LLM调用外层的操作。
3. 结果展示
运行程序之后,我们可以在控制台上看到链路跟踪和性能指标的输出。同时,在Jaeger的UI界面http://localhost:16686/上,我们可以看到针对Agent-Server操作的调用链信息,如下图所示:
打开Grafana的Dashboard,我们可以看到针对LLM调用的性能指标,其中包括请求和响应Token的消耗、调用LLM的延时、成功调用的比例和错误分布等。
4. OpenTelemetryChatClient
和我们演示的程序一样,OpenTelemetryChatClient也是使用ActivitySource创建的Activity来表示针对LLM的调用。创建这个ActivitySource指定的名称来源于OpenTelemetryChatClient构造函数中的sourceName参数,在OpenTelemetry的语境中将它视为服务名称。如果没有显示指定sourceName,OpenTelemetryChatClient会使用默认的名称"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 Task<ChatResponse> GetResponseAsync( IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default); public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync( IEnumerable<ChatMessage> 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的截图上。
