[MAF预定义Agent中间件-04]OpenTelemetryAgent:基于Agent的调用链跟踪和性能监控
正如LoggingAIAgent和LoggingChatClient虽然分别隶属于Agent中间件和ChatClient中间件,它们它们的设计和实现形式几乎一样,而OpenTelemetryAgent不只是与OpenTelemetryChatClient类似,而是分明就是利用后者实现的。我们在“OpenTelemetryChatClient-实现链路跟踪和性能监控”中已经介绍了OpenTelemetryChatClient的实现细节,下面我们安全按照类似的结构来介绍OpenTelemetryAgent。作为一个AIAgent中间件,OpenTelemetryAgent利用重写的RunCoreAsync和RunCoreStreamingAsync方法,为Agent的调用添加了对于的链路和性能计数的输出。结合OpenTelemetry框架,开发者可以轻松地将这些数据发送到各种后端系统,如Prometheus、Jaeger等,以实现对LLM调用的深入分析和监控。本篇文件通过一个简单的例子在本地搭建一个这样的监控环境,展示针对Agent调用的链路跟踪和性能指标。
1. 构建基础设施
为了显示基于调用链的跟踪信息,我们在本地安装了Jaeger。为了收集和展示性能指标,我们使用了Prometheus和Grafana。我们采用最简单的方式,通过在本地创建相映的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块分别创建了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 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中间件的注册。并指定了与TracerProvider和MeterProvider相同的sourceName(AIApp),以启用链路跟踪和性能指标的收集。最后,我们调用Build方法构建了最终的AIAgent对象。
为了模拟一段持续的调用,我们在一个循环中随机选择了三个问题,并调用了RunAsync方法。为了模拟一段完整的调用链,我们利用创建的ActivitySource(将服务名称作为sourceName)手动创建了三个不同层级的Activity,分别命名为Agent-Server、Foo和Bar,它们表示LLM调用外层的操作。
3. 结果展示
运行程序之后,我们可以在控制台上看到链路跟踪和性能指标的输出。同时,在Jaeger的UI界面http://localhost:16686/上,我们可以看到针对Agent-Server操作的调用链信息。从如下面片所示的调用链信息中,可以看出注册的OpenTelemetryAgent会为每个Agent的调用生成两个Span,这是因为它默认会将OpenTelemetryChatClient注册到ChatClient管道上。所以这两个Span分别反映的是分别针对Agent和IChatClient对象的调用。
打开Grafana的Dashboardhttp://localhost:3000/,我们可以看到针对LLM调用的性能指标,其中包括请求和响应Token的消耗、调用LLM的延时、成功调用的比例和错误分布等。
4. OpenTelemetryAgent
OpenTelemetryAgent这个Agent中间件其实并没有做任何实质性的工作,所有的工作都是通过它内部创建的一个OpenTelemetryChatClient实例来完成的。我们知道OpenTelemetryChatClient是一个ChatClient中间件,它只能用于包装指定的IChatClient对象。但是ChatClient管道位于整个Agent管道的最后一部分,而Agent中间件却位于管道最前端,不论是类型和所处的位置都不支持从Agent中间件直接调用ChatClient中间件。
OpenTelemetryAgent采用了一个非常巧妙的方式来解决这个问题。它将自己伪装成一个IChatClient对象,并利用OpenTelemetryChatClient来装饰它,这样上述的两个问题都得到了完美的解决。这个将OpenTelemetryAgent装换成IChatClient的类型就是如下这个ForwardingChatClient类。作为它的配置选项类型,ForwardedOptions类继承自ChatOptions,并添加了AgentRunOptions、AgentSession和CurrentActivity三个属性,分别用于传递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的语境中将它视为服务名称。如果没有显示指定sourceName,OpenTelemetryAgent会使用默认的名称Experimental.Microsoft.Extensions.AI。
在OpenTelemetryAgent的构造函数中,它会创建ForwardingChatClient对象,并装饰上一个OpenTelemetryChatClient对象中间件。UpdateCurrentActivity中表示LLM提供者名称的标签gen_ai.provider.name的值来源于内层的Agent对象。重写的RunCoreAsync方法会根据传入的AgentRunOptions、AgentSession和当前的Activity对象创建一个ForwardedOptions对象,并将它作为参数调用OpenTelemetryChatClient的GetResponseAsync方法来。由于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的截图上。
对.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了),否则就直接跳过了; - 如果注册的
OpenTelemetryAgent的autoWireChatClient参数设置为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用于指定ActivitySource和Meter的名称,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;
});
}
}
更多推荐


所有评论(0)