Semantic Kernel 对接 Grafana/Jaeger + 微软OpenTelemetry -- 实现LLM 调用监控

声明:本篇博客并没有使用到OpenTelemetry及相关知识,这里只是记录如果需要对接Grafana/Jaeger ,就必须使用OpenTelemetry

✅ 对比:LoggingFilter vs OpenTelemetry

┌─────────────────────────────────────────────────────────┐
│          LoggingFilter 统计 vs OpenTelemetry            │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  LoggingFilter 静态字典(你现在的代码)                 │
│  ├─ 自己写的简易统计                                    │
│  ├─ 用 static Dictionary 累积数据                       │
│  ├─ 控制台输出汇总                                      │
│  ├─ ✅ 简单、够用、好理解                              │
│  └─ ❌ 只能在本程序用,无法对接外部系统                │
│                                                         │
│  OpenTelemetry                                          │
│  ├─ 行业标准化的可观测性框架                            │
│  ├─ 自动采集 Trace/Metrics/Logs                        │
│  ├─ 可以对接 Jaeger/Prometheus/Grafana 等              │
│  ├─ ✅ 标准化、生态丰富、生产级                        │
│  └─ ❌ 配置复杂、学习成本高                            │
│                                                         │
│  关联:几乎没有!                                       │
│  两者是独立的方案,可以只用其中一个,也可以都用         │
│                                                         │
└─────────────────────────────────────────────────────────┘

📊 实际关系图

┌─────────────────────────────────────────────────────────┐
│              两种独立的监控方案                          │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  方案 A:LoggingFilter 静态字典 ✅ (你已实现)           │
│  ┌─────────────────────────────────────────────────┐   │
│  │  Semantic Kernel                                │   │
│  │       ↓ (FunctionInvocationFilter)             │   │
│  │  LoggingFilter                                  │   │
│  │       ↓ (手动累加)                              │   │
│  │  static Dictionary _metrics                     │   │
│  │       ↓ (Console.WriteLine)                     │   │
│  │  控制台输出                                     │   │
│  └─────────────────────────────────────────────────┘   │
│                                                         │
│  方案 B:OpenTelemetry ⏭️ (可以缓一缓)                 │
│  ┌─────────────────────────────────────────────────┐   │
│  │  Semantic Kernel                                │   │
│  │       ↓ (AddSource)                            │   │
│  │  OpenTelemetry TracerProvider                   │   │
│  │       ↓ (自动采集)                              │   │
│  │  OTLP Exporter                                  │   │
│  │       ↓                                         │   │
│  │  Jaeger / Prometheus / Grafana                  │   │
│  └─────────────────────────────────────────────────┘   │
│                                                         │
│  两者没有依赖关系!可以只用 A,也可以只用 B,或都用    │
│                                                         │
└─────────────────────────────────────────────────────────┘

💡 为什么我之前把它们放在一起讲?

我的错! 我之前的学习路线把它们都放在"日志与监控"章节,容易让人以为有关联。

实际上

  • LoggingFilter 静态字典 = 自己造的简易轮子 ✅
  • OpenTelemetry = 行业标准轮子 ⏭️

你现在的 LoggingFilter 已经够用了! 不需要 OpenTelemetry 也能实现:

  • ✅ 记录每次调用
  • ✅ 统计成功率
  • ✅ 统计平均耗时
  • ✅ 统计 Token 用量
  • ✅ 定期汇总输出
  • ✅ 慢函数告警
  • ✅ Token 超额告警

🎯 真实区别

特性LoggingFilter 静态字典OpenTelemetry
实现难度 ⭐ (简单) ⭐⭐⭐⭐ (复杂)
代码量 50 行 200 行+
依赖包 5-6 个 NuGet 包
输出目标 控制台 Jaeger/Prometheus 等
标准化 ❌ 自定义格式 ✅ 行业标准
学习曲线 平缓 陡峭
学习阶段必要性 ✅ 够用了 ⏭️ 生产环境再说

✅ 我的建议

┌─────────────────────────────────────────────────────────┐
│                    现阶段建议                            │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ✅ 继续用 LoggingFilter 静态字典                       │
│  ├─ 简单、够用、好理解                                  │
│  ├─ 你想加什么检测就加什么                              │
│  └─ 学习 static 变量、锁、字典等基础知识               │
│                                                         │
│  ⏭️ OpenTelemetry 可以暂时放下                         │
│  ├─ 现阶段用不到                                        │
│  ├─ 等以后做生产项目再学                                │
│  └─ 或者等公司要求对接监控系统再学                      │
│                                                         │
│  📚 下一步学更有价值的:                                │
│  ├─ Planner 任务规划器                                  │
│  ├─ Qdrant 向量检索 (RAG)                               │
│  └─ 结构化输出                                          │
│                                                         │
└─────────────────────────────────────────────────────────┘

💬 真心话

你问"有什么关联",说明你在独立思考,这是好事!

很多教程喜欢堆砌名词,好像学得越多越好。

但真正的高手知道什么不该学!

你现在的 LoggingFilter 已经实现了:

指标收集 ✅
汇总统计 ✅
告警检测 ✅
这就够了! 不需要为了"学新技术"而学 OpenTelemetry。

等哪天你的项目真的需要对接 Grafana/Jaeger 了,再学也不迟!

代码:

 
using ConsoleApp1;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
using OpenTelemetry.Trace;
using System.ComponentModel;
using System.Diagnostics;
using System.Timers;

namespace ConsoleApp5
{
    class Program
    {
        static string ChatPluginName = "ChatPlugin"; //聊天插件 

        static async Task Main(string[] args)
        {
            Console.WriteLine("╔════════════════════════════════════════╗");
            Console.WriteLine("║     LLM Gateway - 路由模块学习版      ║");
            Console.WriteLine("║     基于用户 Token 等级自动切换模型     ║");
            Console.WriteLine("╚════════════════════════════════════════╝\n");
            // 在 Program.cs 或 Startup.cs
            var tracerProvider = Sdk.CreateTracerProviderBuilder()
                .AddSource("Microsoft.SemanticKernel.*")  // ⭐ 监听 SK 的所有操作
                .AddConsoleExporter()                      // 输出到控制台
                .AddOtlpExporter(options =>                // 或发送到 Jaeger/Zipkin
                {
                    options.Endpoint = new Uri("http://localhost:4317");
                })
                .Build();
            var kernel = CreateGatewayKernel();

            // 注册插件-聊天插件

            kernel.Plugins.Add(KernelPluginFactory.CreateFromObject(new ChatPlugin(kernel), ChatPluginName));
  
            var Chat_funcName = "Chat";//插件函数
            var result1 = await kernel.InvokeAsync(ChatPluginName, Chat_funcName, new KernelArguments
            {
                ["message"] = "你好"
            });
            var result2 = await kernel.InvokeAsync(ChatPluginName, Chat_funcName, new KernelArguments
            {
                ["message"] = "请帮我模拟一个天气预报"
            });

            var result3 = await kernel.InvokeAsync(ChatPluginName, Chat_funcName, new KernelArguments
            {
                ["message"] = "简单介绍一个小动物"
            });

            Console.WriteLine($"✅ 结果:{result1}\n"); 
            Console.WriteLine($"✅ 结果:{result2}\n"); 
            Console.WriteLine($"✅ 结果:{result3}\n");


            Console.Read();


        }

        static Kernel CreateGatewayKernel()
        {
            var builder = Kernel.CreateBuilder();

            // ========== 1. 注册模型服务(3 个模型) ==========
            var dashScopeApiKey = ConstParm.apiKey;
            var dashScopeEndpoint = ConstParm.endpoint;

            builder.AddOpenAIChatCompletion(
                serviceId: "dashscope_qwen-turbo",  // Fast 级
                modelId: "qwen-turbo",
                apiKey: dashScopeApiKey,
                endpoint: new Uri(dashScopeEndpoint));

            builder.Services.AddLogging(b => b
             .AddConsole()
             .SetMinimumLevel(LogLevel.Error));

            var kernel = builder.Build();

            // ========== 4. 注册过滤器 ========== 
           
            var logger = kernel.Services.GetRequiredService<ILogger<LoggingFilter>>();
            kernel.FunctionInvocationFilters.Add(new LoggingFilter(logger));

            return kernel;
        
        }
    }


    /// <summary>
    /// 演示插件(通用场景)
    /// 用于测试 LLM Gateway 的路由、限流、缓存、成本统计等功能
    /// </summary>
    public class ChatPlugin
    {
        private readonly Kernel _kernel;

        public ChatPlugin(Kernel kernel)
        {
            _kernel = kernel;
        }

        [KernelFunction("Chat")]
        [Description("通用对话 - 日常聊天、问答、咨询")]
        public async Task<string> ChatMessage(
            [Description("用户消息")] string message,
            [Description("对话历史(可选)")] string? history = null)
        {
            var chatService = _kernel.GetRequiredService<IChatCompletionService>();

            var chatHistory = new ChatHistory();
            if (!string.IsNullOrEmpty(history))
            {
                chatHistory.AddUserMessage(history);
            }
            chatHistory.AddUserMessage(message);

            var response = await chatService.GetChatMessageContentAsync(
                chatHistory,
                cancellationToken: default);

            // ✅ 新增:提取 Token 用量并注入 Kernel.Data
            if (response.Metadata != null)
            {
                // 尝试从 Metadata 中获取 Usage
                if (response.Metadata.TryGetValue("Usage", out var usageObj) && usageObj != null)
                {
                    var usage = usageObj as OpenAI.Chat.ChatTokenUsage;
                    if (usage != null)
                    {
                        _kernel.Data["input_tokens"] = usage.InputTokenCount;
                        _kernel.Data["output_tokens"] = usage.OutputTokenCount;
                        _kernel.Data["total_tokens"] = usage.TotalTokenCount;
                    }
                }
                // 兼容其他格式(DashScope 可能用不同 key)
                else if (response.Metadata.TryGetValue("usage", out var usageObj2) && usageObj2 != null)
                {
                    var usage = usageObj2 as OpenAI.Chat.ChatTokenUsage;
                    if (usage != null)
                    {
                        _kernel.Data["input_tokens"] = usage.InputTokenCount;
                        _kernel.Data["output_tokens"] = usage.OutputTokenCount;
                        _kernel.Data["total_tokens"] = usage.TotalTokenCount;
                    }
                }
            }

            return response.Content ?? "无回复";
        }
    }



    public class LoggingFilter : IFunctionInvocationFilter
    {
        private readonly ILogger<LoggingFilter> _logger;
        private readonly Stopwatch _stopwatch;

        // ⭐ 新增:静态指标收集(用于汇总分析)
        private static readonly Dictionary<string, FunctionMetrics> _metrics = new();
        private static readonly object _lock = new();

        public LoggingFilter(ILogger<LoggingFilter> logger)
        {
            _logger = logger;
            _stopwatch = new Stopwatch();
        }

        public async Task OnFunctionInvocationAsync(
            FunctionInvocationContext context,
            Func<FunctionInvocationContext, Task> next)
        {
            var functionName = $"{context.Function.PluginName}.{context.Function.Name}";
            var arguments = string.Join(", ", context.Arguments.Select(kvp => $"{kvp.Key}={kvp.Value}"));
            var startTime = DateTime.Now;

            // 📝 开始执行函数
            _logger.LogInformation("📝 开始执行函数:{FunctionName}({Arguments})", functionName, arguments);
            PrintColoredConsole($"📝 开始执行函数:{functionName}({arguments})", ConsoleColor.Cyan);

            _stopwatch.Restart();

            try
            {
                // 执行函数
                await next(context);

                _stopwatch.Stop();
                var result = context.Result?.GetValue<object>()?.ToString() ?? "null";

                // ⭐ 获取 Token 消耗量
                var inputTokens = context.Kernel.Data.TryGetValue("input_tokens", out var inputObj) ? (int)inputObj : 0;
                var outputTokens = context.Kernel.Data.TryGetValue("output_tokens", out var outputObj) ? (int)outputObj : 0;
                var totalTokens = inputTokens + outputTokens;

            
                // ✅ 记录成功指标
                RecordMetrics(functionName, success: true, _stopwatch.ElapsedMilliseconds, totalTokens);

                // ✅ 函数执行成功 - 彩色输出
                _logger.LogInformation(
                    "✅ 函数执行成功:{FunctionName},耗时 {ElapsedMs}ms,Token 消耗:输入={InputTokens}, 输出={OutputTokens}, 总计={TotalTokens},结果:{Result}",
                    functionName,
                    _stopwatch.ElapsedMilliseconds,
                    inputTokens,
                    outputTokens,
                    totalTokens,
                    Truncate(result, 100));

                PrintColoredConsole(
                    $"✅ 函数执行成功:{functionName},耗时 {_stopwatch.ElapsedMilliseconds}ms,📊 Token 消耗:输入={inputTokens}, 输出={outputTokens}, 总计={totalTokens}",
                    ConsoleColor.Green);

                // ⭐ 新增检测 1:慢函数告警(> 3 秒)
                if (_stopwatch.ElapsedMilliseconds > 3000)
                {
                    _logger.LogWarning("⚠️ 慢函数告警:{FunctionName} 耗时 {ElapsedMs}ms", functionName, _stopwatch.ElapsedMilliseconds);
                    PrintColoredConsole($"⚠️ 慢函数告警:{functionName} 耗时 {_stopwatch.ElapsedMilliseconds}ms", ConsoleColor.Yellow);
                }

                // ⭐ 新增检测 2:Token 超额告警(单次 > 1 万)
                if (totalTokens > 10000)
                {
                    _logger.LogWarning("⚠️ Token 超额告警:{FunctionName} 消耗 {TotalTokens} Token", functionName, totalTokens);
                    PrintColoredConsole($"⚠️ Token 超额告警:{functionName} 消耗 {totalTokens} Token", ConsoleColor.Yellow);
                }
            }
            catch (Exception ex)
            {
                _stopwatch.Stop();

                // ⭐ 记录失败指标
                RecordMetrics(functionName, success: false, _stopwatch.ElapsedMilliseconds, 0);

                // ❌ 函数执行失败 - 彩色输出
                _logger.LogError(
                    ex,
                    "❌ 函数执行失败:{FunctionName},耗时 {ElapsedMs}ms,错误:{Error}",
                    functionName,
                    _stopwatch.ElapsedMilliseconds,
                    ex.Message);

                PrintColoredConsole($"❌ 函数执行失败:{functionName},耗时 {_stopwatch.ElapsedMilliseconds}ms,错误:{ex.Message}", ConsoleColor.Red);

                throw;
            }
            finally
            {
                // ⭐ 新增检测 4:每 10 次调用打印汇总统计
                PrintMetricsSummary();
            }
        }

        // ⭐ 记录指标
        private void RecordMetrics(string functionName, bool success, long elapsedMs, int tokens)
        {
            lock (_lock)
            {
                if (!_metrics.ContainsKey(functionName))
                {
                    _metrics[functionName] = new FunctionMetrics();
                }

                var m = _metrics[functionName];
                m.TotalCalls++;
                m.SuccessCalls += success ? 1 : 0;
                m.TotalElapsedMs += elapsedMs;
                m.TotalTokens += tokens;
                m.LastUpdated = DateTime.Now;
            }
        }

        // ⭐ 打印汇总统计
        private void PrintMetricsSummary()
        {
            lock (_lock)
            {
                var totalCalls = _metrics.Values.Sum(m => m.TotalCalls);

                // 每 3 次调用打印一次
                if (totalCalls % 3 == 0 && totalCalls > 0)
                {
                    Console.WriteLine("\n═══════════════════════════════════════════════════════════");
                    Console.WriteLine("📊 函数调用统计(累计 " + totalCalls + " 次)");
                    Console.WriteLine("═══════════════════════════════════════════════════════════");

                    foreach (var kvp in _metrics)
                    {
                        var m = kvp.Value;
                        Console.WriteLine($"  {kvp.Key}:");
                        Console.WriteLine($"    调用={m.TotalCalls}, 成功率={m.SuccessRate:P1}, " +
                            $"平均耗时={m.AvgElapsedMs}ms, Token={m.TotalTokens}");
                    }
                    Console.WriteLine("═══════════════════════════════════════════════════════════\n");
                }
            }
        }

        // ⭐ 新增检测 5:辅助方法 - 彩色控制台输出
        private void PrintColoredConsole(string message, ConsoleColor color)
        {
            Console.ForegroundColor = color;
            Console.WriteLine(message);
            Console.ResetColor();
        }

        private string Truncate(string text, int maxLength)
        {
            if (string.IsNullOrEmpty(text) || text.Length <= maxLength)
                return text;
            return text[..maxLength] + "...";
        }
    }

    // ⭐ 指标数据结构
    public class FunctionMetrics
    {
        public int TotalCalls { get; set; }
        public int SuccessCalls { get; set; }
        public long TotalElapsedMs { get; set; }
        public int TotalTokens { get; set; }
        public DateTime LastUpdated { get; set; }

        public double SuccessRate => TotalCalls > 0 ? (double)SuccessCalls / TotalCalls : 0;
        public double AvgElapsedMs => TotalCalls > 0 ? (double)TotalElapsedMs / TotalCalls : 0;
    }
}
View Code

项目引用

<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <NoWarn>$(NoWarn);NU5104</NoWarn>
    </PropertyGroup>
 

    <ItemGroup> 
        <PackageReference Include="Microsoft.SemanticKernel" Version="1.78.0" /> 
        <PackageReference Include="OpenTelemetry" Version="1.17.0" />
        <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.17.0" />
        <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
        <PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
        <PackageReference Include="Sdcb.DashScope" Version="2.0.0" />
         
    </ItemGroup> 

</Project>

 

posted @ 2026-08-10 16:59  天才卧龙  阅读(6)  评论(0)    收藏  举报