正如LoggingAIAgentLoggingChatClient虽然分别隶属于Agent中间件和ChatClient中间件,它们它们的设计和实现形式几乎一样,而OpenTelemetryAgent不只是与OpenTelemetryChatClient类似,而是分明就是利用后者实现的。我们在“OpenTelemetryChatClient-实现链路跟踪和性能监控”中已经介绍了OpenTelemetryChatClient的实现细节,下面我们安全按照类似的结构来介绍OpenTelemetryAgent。作为一个AIAgent中间件,OpenTelemetryAgent利用重写的RunCoreAsyncRunCoreStreamingAsync方法,为Agent的调用添加了对于的链路和性能计数的输出。结合OpenTelemetry框架,开发者可以轻松地将这些数据发送到各种后端系统,如PrometheusJaeger等,以实现对LLM调用的深入分析和监控。本篇文件通过一个简单的例子在本地搭建一个这样的监控环境,展示针对Agent调用的链路跟踪和性能指标。

1. 构建基础设施

为了显示基于调用链的跟踪信息,我们在本地安装了Jaeger。为了收集和展示性能指标,我们使用了PrometheusGrafana。我们采用最简单的方式,通过在本地创建相映的Docker容器来搭建这些服务。如果希望在Windows上执行相应的命令,将换行符从\改为 ^即可。

Jageer:

docker run -d --name jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  jaegertracing/all-in-one:latest

Prometheus:

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是接下来创建的应用暴露的端口,用于输出性能指标信息。之所以没有使用localhost,是因为Prometheus运行在Docker容器中,无法访问宿主机的localhost。

Grafana:

docker run -d --name grafana \
  -p 3000:3000 \
  grafana/grafana:latest

然后添加针对Prometheus对应地址http://192.168.1.166:9090的连接。我针对OpenTelemetryAgent输出的指标创建了一个简单Dashboard,可以通过这里下载并导入。

2. 构建一个简单的Agent应用

我们创建一个简单的Console应用,并添加针对OpenTelemetry.NET相关的NuGet包:

  • OpenTelemetry
  • OpenTelemetry.Exporter.Console
  • OpenTelemetry.Exporter.OpenTelemetryProtocol
  • OpenTelemetry.Exporter.Prometheus.HttpListener
  • OpenTelemetry.Extensions.Hosting

如下所示的是完整的演示程序。最外层的两个using块分别创建了TracerProviderMeterProvider,前者用于链路跟踪,后者用于性能指标的收集,两者设置了相同的服务名称(AIApp)和版本(1.0.0)。对于Trace,我们添加了ConsoleOTLP两种Exporter,后者将数据发送到Jaeger。对于Metrics,我们添加了ConsolePrometheusHttpListener两种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 agent = new OpenAIClient(
            credential: new AzureKeyCredential(apiKey),
            options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
        .GetChatClient(model: model)
        .AsIChatClient()
        .AsAIAgent(name:"MyAgent")
        .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 agent.RunAsync(queries[random.Next(queries.Length)]);
                    }
                }
            }
            await Task.Delay(random.Next(3000, 5000));
        }
    Console.ReadLine();
}

using块中,我们首先创建了一个OpenAIClient对象,然后基于它创建了对应的AIAgent对象。在调用扩展方法AsBuilder创建出用于构建AIAgent管道的AIAgentBuilder后,我们它的扩展方法UseOpenTelemetry完成了针对OpenTelemetryAgent中间件的注册。并指定了与TracerProviderMeterProvider相同的sourceName(AIApp),以启用链路跟踪和性能指标的收集。最后,我们调用Build方法构建了最终的AIAgent对象。

为了模拟一段持续的调用,我们在一个循环中随机选择了三个问题,并调用了RunAsync方法。为了模拟一段完整的调用链,我们利用创建的ActivitySource(将服务名称作为sourceName)手动创建了三个不同层级的Activity,分别命名为Agent-ServerFooBar,它们表示LLM调用外层的操作。

3. 结果展示

运行程序之后,我们可以在控制台上看到链路跟踪和性能指标的输出。同时,在Jaeger的UI界面http://localhost:16686/上,我们可以看到针对Agent-Server操作的调用链信息。从如下面片所示的调用链信息中,可以看出注册的OpenTelemetryAgent会为每个Agent的调用生成两个Span,这是因为它默认会将OpenTelemetryChatClient注册到ChatClient管道上。所以这两个Span分别反映的是分别针对AgentIChatClient对象的调用。

Alternative Text

打开Grafana的Dashboardhttp://localhost:3000/,我们可以看到针对LLM调用的性能指标,其中包括请求和响应Token的消耗、调用LLM的延时、成功调用的比例和错误分布等。

Alternative Text

4. OpenTelemetryAgent

OpenTelemetryAgent这个Agent中间件其实并没有做任何实质性的工作,所有的工作都是通过它内部创建的一个OpenTelemetryChatClient实例来完成的。我们知道OpenTelemetryChatClient是一个ChatClient中间件,它只能用于包装指定的IChatClient对象。但是ChatClient管道位于整个Agent管道的最后一部分,而Agent中间件却位于管道最前端,不论是类型和所处的位置都不支持从Agent中间件直接调用ChatClient中间件。

OpenTelemetryAgent采用了一个非常巧妙的方式来解决这个问题。它将自己伪装成一个IChatClient对象,并利用OpenTelemetryChatClient来装饰它,这样上述的两个问题都得到了完美的解决。这个将OpenTelemetryAgent装换成IChatClient的类型就是如下这个ForwardingChatClient类。作为它的配置选项类型,ForwardedOptions类继承自ChatOptions,并添加了AgentRunOptionsAgentSessionCurrentActivity三个属性,分别用于传递Agent运行选项、Agent会话信息和当前的跟踪操作信息。

public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
{
    private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient
    {
        public async Task<ChatResponse> GetResponseAsync(
            IEnumerable<ChatMessage> messages, 
            ChatOptions? options = null, 
            CancellationToken cancellationToken = default)
        {
            ForwardedOptions? fo = options as ForwardedOptions;
            parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
            var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false);
            return response.AsChatResponse();
        }
        ...
    }

    private sealed class ForwardedOptions : ChatOptions
	{
		public AgentRunOptions? Options { get; }
		public AgentSession? Session { get; }
		public Activity? CurrentActivity { get; }
		public ForwardedOptions(AgentRunOptions? options, AgentSession? session, Activity? currentActivity)
			: base((options as ChatClientAgentRunOptions)?.ChatOptions)
		{
			Options = options;
			Session = session;
			CurrentActivity = currentActivity;
		}
	}
}

我们利用提供的OpenTelemetryAgent来创建ForwardingChatClient。在它重写的GetResponseAsync方法中,它会将通过ForwardedOptions提供的Activity对象提取出来,并作为参数调用OpenTelemetryAgent如下这个UpdateCurrentActivity方法来更新当前的调用链信息。随后它会内层Agent的RunAsync方法来执行Agent的核心逻辑,并将结果转换成ChatResponse对象返回。重写的RunCoreStreamingAsync方法具有类似的实现逻辑。

public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
{
    private void UpdateCurrentActivity(Activity? previousActivity)
	{
		Activity current = Activity.Current;
		if (current != null && current != previousActivity)
		{
			current.DisplayName = (string.IsNullOrWhiteSpace(Name) ? ("invoke_agent " + base.Id) : $"{"invoke_agent"} {Name}({base.Id})");
			current.SetTag("gen_ai.operation.name", "invoke_agent");
			if (!string.IsNullOrWhiteSpace(_providerName))
			{
				current.SetTag("gen_ai.provider.name", _providerName);
			}
			current.SetTag("gen_ai.agent.id", base.Id);
			string name = Name;
			if (name != null && !string.IsNullOrWhiteSpace(name))
			{
				current.SetTag("gen_ai.agent.name", Name);
			}
			string description = Description;
			if (description != null && !string.IsNullOrWhiteSpace(description))
			{
				current.SetTag("gen_ai.agent.description", description);
			}
		}
	}

UpdateCurrentActivity会为Activity设置如下的标签:

  • gen_ai.operation.name:表示当前操作的名称,固定为invoke_agent;
  • gen_ai.provider.name:表示当前Agent使用的LLM提供商的名称,如果有的话;
  • gen_ai.agent.id:表示当前Agent的唯一标识符;
  • gen_ai.agent.name:表示当前Agent的名称,如果有的话;
  • gen_ai.agent.description:表示当前Agent的描述信息,如果有的话。

和我们演示的程序一样,OpenTelemetryAgent也是使用ActivitySource创建的Activity来表示针对LLM的调用。创建这个ActivitySource指定的名称来源于OpenTelemetryAgent构造函数中的sourceName参数,在OpenTelemetry的语境中将它视为服务名称。如果没有显示指定sourceNameOpenTelemetryAgent会使用默认的名称Experimental.Microsoft.Extensions.AI

OpenTelemetryAgent的构造函数中,它会创建ForwardingChatClient对象,并装饰上一个OpenTelemetryChatClient对象中间件。UpdateCurrentActivity中表示LLM提供者名称的标签gen_ai.provider.name的值来源于内层的Agent对象。重写的RunCoreAsync方法会根据传入的AgentRunOptionsAgentSession和当前的Activity对象创建一个ForwardedOptions对象,并将它作为参数调用OpenTelemetryChatClientGetResponseAsync方法来。由于OpenTelemetryChatClient装饰的Agent管道,所以返回的ChatResponse的原始表示形式(RawRepresentation属性)就是最终返回的AgentResponse对象。重写的RunCoreStreamingAsync方法具有类似的实现逻辑。

public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
{
    public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null)
		: base(innerAgent)
	{
		_providerName = innerAgent.GetService<AIAgentMetadata>()?.ProviderName;
		_otelClient = new OpenTelemetryChatClient(new ForwardingChatClient(this), null, string.IsNullOrEmpty(sourceName) 
            ? "Experimental.Microsoft.Agents.AI" 
            : sourceName);
	}

	protected override async Task<AgentResponse> RunCoreAsync(
        IEnumerable<ChatMessage> messages, 
        AgentSession? session = null, 
        AgentRunOptions? options = null, 
        CancellationToken cancellationToken = default(CancellationToken))
	{
		var co = new ForwardedOptions(options, session, Activity.Current);
		var response = await _otelClient.GetResponseAsync(messages, co, cancellationToken)
            .ConfigureAwait(continueOnCapturedContext: false);
		return (response.RawRepresentation as AgentResponse) ?? new AgentResponse(response);
	}
}

对于我们前面的演示程序,OpenTelemetryAgent创建的跟踪操作包含的标签体现在如下这张针对Jaeger的截图上。

Alternative Text

对.NET的诊断跟踪具有了解的开发者应该知道,性能指标是通过Meter计算和输出的。所以OpenTelemetryAgent在构造函数中创建了一个Meter对象,此对象依然使用sourceName作为它的名称。它会创建如下的性能指标

  • gen_ai.client.operation.duration:整个AI请求从发送到接收完成的总持续时间,计量单位为秒。它记录了端到端(End-to-End)的延迟,包括了网络传输、模型在云端的排队等待、模型推理(计算)以及数据返回的完整时间,用于评估系统的整体性能。如果这个值突然变高,说明用户等待回答的时间变长了。通常结合分位数(如P95, P99)来监控长尾延迟;
  • gen_ai.client.token.usage:单次AI请求所消耗的Token数量。该指标通常包含多个维度,如gen_ai.token.type,会细分为input(提示词Token)和output(模型生成的Token)。通过统计这个指标,你可以精准计算出应用消耗了多少费用,或者分析是否存在异常的“长文本”请求耗尽了配额;
  • gen_ai.client.operation.time_to_first_chunk:在使用流式传输时,从发出请求到收到第一个数据块所消耗的时间。计量单位为秒 。这就是业界常说的TTFT (Time to First Token),它反映了模型的启动速度网络建连的延迟,而与模型最终生成多少个字无关。这是一个体现用户体验(UX)的核心指标。在聊天界面中,只要第一个字蹦出来,用户就会觉得系统在工作。如果TTFT过高,用户会感到明显的卡顿和焦虑;
  • gen_ai.client.operation.time_per_output_chunk:在使用流式传输时,模型生成和传输每一个后续数据块的平均间隔时间,计量单位为秒。它反映了模型的推理吞吐量(类似常说的Tokens Per Second)。数值越低,意味着流式打字机的输出速度越快、越流畅。这个指标用于监控流式输出的流畅度。如果这个值偏高,用户会看到打字机效果非常慢,甚至出现一卡一卡(抖动)的现象。通常与模型的并发负载、云端实例的算力有关;

我制作Grafana Dashboard比较简单,只用到了前面两个指标。至于后面两个流式传输相关的指标,有兴趣的读者可以自己动手添加一下。

5. autoWireChatClient与工具调用的跟踪

OpenTelemetryAgent的构造函数中有一个autoWireChatClient参数,默认值为true。它的作用是控制是否自动将OpenTelemetryChatClient注册到ChatClient管道上。由于该参数的默认值为true,所以我们在会在前面演示的实例中从Jaeger中看到两个Span,分别反映了针对Agent和IChatClient对象的调用。但是这里引入了一个问题:这个主动注册的OpenTelemetryChatClient对象会影响针对工具调用的跟踪。

public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
{
    public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName = null)
        : this(innerAgent, sourceName, autoWireChatClient: true);
    public OpenTelemetryAgent(AIAgent innerAgent, string? sourceName, bool autoWireChatClient) : base(innerAgent);
}

我们通过一个例子来说明这个问题:针对Agent的调用会涉及一次针对GetWeather工具的调用,注册的OpenTelemetryAgent在创建的时候显式将autoWireChatClient设置为false,这样就不会将OpenTelemetryChatClient注册到ChatClient管道上了。

var agent = new OpenAIClient(
    credential: new AzureKeyCredential(apiKey),
    options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
.GetChatClient(model: model)
.AsIChatClient()
.AsAIAgent(name: "MyAgent", tools: [AIFunctionFactory.Create(GetWeather, nameof(GetWeather))])
.AsBuilder()
.Use(inner => new OpenTelemetryAgent(inner, serviceName, false))
.Build();

var random = new Random();
var source = new ActivitySource(serviceName);
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 agent.RunAsync($"根据苏州目前天气提供着装建议!");
        }
    }
}

[Description("根据指定位置获取天气信息")]
static string GetWeather(string location) => $"现在{location}的天气是:晴,温度25摄氏度";

此时我们可以从调用链中看到针对工具GetWeather的调用,而且这也是我们希望看到的结果。

![Alternative Text][1780284549976]

但是一旦我们在调用OpenTelemetryAgent的构造函数时将autoWireChatClient设置为true,那么我们在调用链中就看不到针对工具GetWeather的调用了,取而代之的是针对LLM调用。

![Alternative Text][1780284749362]

这肯定不是我们希望看到的结果,所以我觉得这是一个Bug。造成这个Bug的原因是:

  • 工具调用的跟踪实现在FunctionInvokingChatClient中,但是它在实施跟踪之前会检查当前的Activity是否是针对Agent调用的,如果是的话才会进行跟踪(它的本意是确定Agent调用是否开启了跟踪,但是Agent调用对应的已经不是当前Activity了),否则就直接跳过了;
  • 如果注册的OpenTelemetryAgentautoWireChatClient参数设置为true,那么它会将OpenTelemetryChatClient注册到ChatClient管道上,并且置于FunctionInvokingChatClient的前面,当的跟踪操作执行之后,当前Activity将变成针对IChatClient调用的了,这样FunctionInvokingChatClient就不会再进行工具调用的跟踪了。

知道了原因,我们自然也就有了解决方案:在注册OpenTelemetryAgent的时候将autoWireChatClient参数设置为false,同时显式注册一个OpenTelemetryChatClient到ChatClient管道上,这样就既能保证针对Agent调用的跟踪,也能保证针对工具调用的跟踪了。具体的实现如下所示:

    var agent = new OpenAIClient(
        credential: new AzureKeyCredential(apiKey),
        options: new OpenAIClientOptions { Endpoint = new Uri(endpoint) })
    .GetChatClient(model: model)
    .AsIChatClient()
    .AsBuilder()
    .UseOpenTelemetry(sourceName: serviceName)
    .Build()
    .AsAIAgent(name: "MyAgent", tools: [AIFunctionFactory.Create(GetWeather, nameof(GetWeather))])
    .AsBuilder()
    .Use(inner => new OpenTelemetryAgent(inner, serviceName, true))
    .Build();

这下我们可以看到完整的调用链了:

![Alternative Text][1780285298155]

6. UseOpenTelemetry

OpenTelemetryAgent的注册可以如下这个针对AIAgentBuilder的扩展方法UseOpenTelemetry来完成。它接受三个参数,loggerFactory用于创建日志记录器,sourceName用于指定ActivitySourceMeter的名称,configure是一个可选的委托,用于进一步配置OpenTelemetryAgent实例。这个扩展方法并没有提供设置置autoWireChatClient参数的选项,这也是需要改进的地方。

public static class OpenTelemetryAgentBuilderExtensions
{
	public static AIAgentBuilder UseOpenTelemetry(this AIAgentBuilder builder, string? sourceName = null, Action<OpenTelemetryAgent>? configure = null)
	{
		return Microsoft.Shared.Diagnostics.Throw.IfNull(builder, "builder").Use(delegate(AIAgent innerAgent, IServiceProvider services)
		{
			OpenTelemetryAgent openTelemetryAgent = new OpenTelemetryAgent(innerAgent, sourceName);
			configure?.Invoke(openTelemetryAgent);
			return openTelemetryAgent;
		});
	}
}
Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐