Spiga

程序员的AI体验(八):Semantic Kernel 架构

2025-05-31 10:54:33

一、Semantic Kernel概述

1. 与LangChain对比

  • 设计理念:LangChain以社区驱动,快速迭代,适合原型验证;Semantic Kernel则以企业级应用为目标,注重稳定性与可维护性。
  • 核心概念:提供LangChain与Semantic Kernel的核心概念映射表,帮助开发者快速理解两者差异。
  • 适用场景:LangChain适合快速开发和试验,Semantic Kernel更适合企业级应用,强调与现有系统的深度融合。
LangChain概念 Semantic Kernel对应概念 备注说明
Agent Agent(Planner) / Kernel Agent类似于一个高级的Agent,能根据用户目标自动生成执行计划(Plan)。而Kernel(内核)则扮演了AgentExecutor角色,是负责实际执行这个计划的核心引擎。
Tool Plugin(插件) / KernelFunction(内核函数) Plugin是SK中与tool对等的概念,代表一个能力的集合。SK的一个重要设计是它明确地将能力分成两种
Chain 手动调用链 LangChain的Chain是一种预先定义好的、线性的调用序列。在SK中,我们可以通过编写代码来手动实现类似的函数调用链,这种确定性的编排方式是理解后续自动化规划器工作原理的基础。
Memory Memory(记忆) 这个概念在两个框架中基本一致,都用于在多次交互中保持状态和上下午,从而实现连贯的对话或复杂的任务处理。
PromptTemplate 提示模版 SK将提示模板的工程化提到了一个新的高度。与LangChain中通常的代码里定义的字符串模板不同,SK推荐将提示模版做为独立的文件进行管理,并配有专门的配置文件,使其成为一个可复用、可配置的软件工件。

2. 架构概览

  • 内核:内核是Semantic Kernel的核心,负责接收请求、编排插件、管理服务和执行任务。
  • 插件:插件是功能扩展的核心机制,包含语义函数和原生函数,实现AI能力与传统代码的融合。
  • 记忆:记忆模块使AI应用能够保持上下文,支持连贯对话和复杂任务处理。

3. 搭建Semantic Kernel应用

  1. 创建一个.NET项目,如控制台应用程序,并添加Semantic Kernel的NuGet包。

  2. 使用依赖注入模式添加日志服务等,使应用高度可配置和可扩展。

        <ItemGroup>
          <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.8" />
          <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.8" />
            <PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.8" />
            <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.8" />
          <PackageReference Include="Microsoft.SemanticKernel" Version="1.61.0" />
        </ItemGroup>
    
        <ItemGroup>
            <Content Include="Plugins\**\*">
                <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
            </Content>
        </ItemGroup>
    
  3. 通过配置连接器,将内核连接到大语言模型服务,如通义千问。

    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    using Microsoft.Extensions.Logging;
    using Microsoft.SemanticKernel;
    using Microsoft.SemanticKernel.ChatCompletion;
    
    // 1. 构建配置
    var configuration = new ConfigurationBuilder()
        .AddUserSecrets<Program>() // 从用户机密中加载配置
        .Build();
    
    // 2. 从配置中获取连接信息
    var apiKey = configuration["Qianwen:ApiKey"]!;
    var modelId = "qwen-plus";
    var endpoint = "https://dashscope.aliyuncs.com/compatible-mode/v1";
    
    // 3. 创建一个内核构造器
    var builder = Kernel.CreateBuilder();
    
    // 4. 向构造器中添加服务,例如日志服务
    builder.Services.AddLogging(c => c.AddConsole().SetMinimumLevel(LogLevel.Information)); 
    builder.Plugins.AddFromType<LightsPlugin>("lights");
    
    // 5. 添加 AI 服务连接器
    builder.AddOpenAIChatCompletion(
        modelId: modelId,
        apiKey: apiKey,
        endpoint: new Uri(endpoint)
    );
    
    // 6. 使用配置好的构造器来构建内核实例
    var kernel = builder.Build();
    
    Console.WriteLine("内核已创建并成功连接到通义千问服务!");
    
    // 我们可以通过内核的服务容器来验证服务是否已成功注册
    // var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
    // Console.WriteLine($"获取到的聊天补全服务类型: {chatCompletionService.GetType()}");
    

4. 插件与函数

  • 插件的作用:插件为语言模型提供了与外部世界交互的能力,是AI应用的关键组成部分。
  • 插件与函数的关系:插件是函数的逻辑分组,函数是插件的基本单位,二者共同构成Semantic Kernel的能力体系。
  • 语义函数与原生函数:语义函数由语言模型驱动,原生函数由C#代码实现,二者相辅相成,满足不同场景需求。
特性 语义函数(Semantic Function) 原生函数(Native Function)
核心本质 参数化的提示词模板 确定性的原生 C# 代码方法
主要优势 强大的创造力、模糊性处理、自然语音理解与生成能力 逻辑的确定性、高计算性能、可访问本地或私有资源、与现在系统深度集成
适用场景 文本总结、创意写作、情感分析、内容改写、语言翻译等需要LLM推理能力的场景 数学计算、文件读写、API调用、数据库查询、硬件控制等需要精确逻辑和确定性结果的场景
创建方法 通过 skprompt.txt 和 config.json文件对,或通过代码中的字符串模版创建 在 C# 类中定义公共方法,并使用[Kernelfunction]和[Description]特性进行标记
执行逻辑 由外部的大语言模型服务解释提示词并生成结果 在本地的.NET运行时中直接、高效地执行 C# 代码
对LLM的依赖 高度依赖。LLM是起执行的主体 仅在“自动函数调用”场景下依赖LLM进行“选择和规划”,函数本身的执行过程完全不依赖LLM

5. Semantic Kernel优势

  • 企业级特性:Semantic Kernel具备企业级的可靠性、可维护性和与现有系统的无缝融合能力。
  • 功能强大:通过插件和函数的组合,Semantic Kernel能够实现复杂的AI驱动业务流程。
  • 开发友好:Semantic Kernel与.NET生态深度集成,降低了学习和开发成本。

二、语义函数开发

定义方式

  • 行内定义:在代码中直接定义语义函数,适合快速原型验证和简单任务。
  • 标准化构建:使用文件和目录结构定义语义函数,实现提示词与代码的解耦,提升可维护性。
  • 文件结构:介绍语义函数的标准文件结构,包括 skprompt.txt 和 config.json 文件的作用和内容。

语义函数示例

假设我们要开发一个旅行规划师语义函数

  1. 创建目录结构
|-- Plugins   					// 插件文件夹
	|-- TravelPlugin			// 插件
		|-- Suggestion			// 函数
			|-- config.json
			|-- skprompt.txt
Program.cs						
  1. skprompt.txt
你是一位经验丰富的旅行规划师。
你的任务是根据用户提供的兴趣点和预算,推荐一个合适的旅游目的地,并简要说明理由。
输出格式必须是 JSON。

[示例输入]
兴趣: 历史, 美食
预算: 中等

[示例输出] { "destination": "意大利罗马", "reason": "罗马拥有丰富的历史遗迹,如斗兽场和古罗马广场,同时也是享誉世界的美食之都,预算中等的游客可以在这里获得极佳的旅行体验。" }
[用户输入]
兴趣: {{$interest}}
预算: {{$budget}}
  1. config.json
{
  "type": "chat-completion",
  "description": "根据用户的兴趣和预算推荐旅游目的地。",
  "input_variables": [
    {
      "name": "interest",
      "description": "用户的旅行兴趣点,例如:海滩、登山、历史、美食等。",
      "required": true
    },
    {
      "name": "budget",
      "description": "用户的旅行预算,可选值为:低、中、高。",
      "required": true,
      "defaultValue": "中"
    }
  ],
  "execution_settings": {
    "default": {
      "temperature": 0.5,
      "max_tokens": 200,
      "top_p": 0.0,
      "presence_penalty": 0.0,
      "frequency_penalty": 0.0
    }
  }
}
  1. 调用语义函数
// 1. 加载插件
// 获取插件目录的路径
var pluginsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Plugins");

// 从目录中加载插件及其中的所有函数
// 第一个参数是插件的根目录,第二个参数是插件的名称
var travelPlugin = kernel.ImportPluginFromPromptDirectory(Path.Combine(pluginsDirectory, "TravelPlugin"));

// 3. 准备调用参数
var arguments = new KernelArguments
{
    { "interest", "海滩, 潜水" },
    { "budget", "高" }
};

// 4. 调用函数
//    InvokeAsync 的第一个参数是插件名称或插件函数,第二个参数是参数
var result = await kernel.InvokeAsync(travelPlugin["Suggestion"], arguments);

// 5. 打印结果
Console.WriteLine("AI 旅行建议:");
Console.WriteLine(result);

三、原生函数开发

场景与优势

  • 精确计算:原生函数适合处理精确的数学和逻辑运算,提供高性能和确定性结果。
  • 访问资源:原生函数可以访问本地文件系统、数据库和私有资源,实现与现有系统的集成。
  • 性能优化:在需要高性能和低成本的操作中,原生函数是更好的选择。

定义与注册

  • 创建插件类:创建一个C#类来容纳原生函数,组织相关功能。
  • 标记函数:使用 [KernelFunction] 和 [Description] 特性标记和描述原生函数及其参数。
  • 注册到内核:将原生函数注册到内核,使其成为可调用的资源。
// 单独的MathPlugin.cs文件

public class MathPlugin
{
    [KernelFunction("add")] // 在内核中注册为 "add"
    [Description("将两个数字相加并返回结果。")]
    public double Add( 
        [Description("要加的第一个数字。")] double number1,
        [Description("要加的第二个数字。")] double number2)
    {
        return number1 + number2;
    }
}

复杂数据类型

  • 数据模型:定义复杂的数据模型,如 LightModel,用于封装多个属性。
  • 自动处理:Semantic Kernel自动处理复杂数据类型的序列化和反序列化,简化开发流程。
  • 调用示例:展示如何调用处理复杂数据类型的原生函数,实现与AI模型的协同工作。
// LightsPlugin.cs

/// <summary>
/// 代表一个智能灯光的数据模型。
/// </summary>
public class LightModel
{
    [JsonPropertyName("id")]
    [Description("灯光的唯一标识符。")]
    public int Id { get; set; }

    [JsonPropertyName("name")]
    [Description("灯光的名称,例如 '客厅灯' 或 '卧室灯'。")]
    public string Name { get; set; } = "";
    
    [JsonPropertyName("location")]
    [Description("灯光的位置,例如 '客厅' 或 '卧室'。")]
    public string Location { get; set; } = "";

    [JsonPropertyName("is_on")]
    [Description("表示灯光是否开启的状态,true代表开启,false代表关闭。")]
    public bool IsOn { get; set; }
}

/// <summary>
/// 一个用于控制和查询智能灯光状态的插件。
/// </summary>
public class LightsPlugin(ILogger<LightsPlugin> logger)
{
    // 模拟一个灯光状态的数据库
    private readonly List<LightModel> _lights =
    [
        new LightModel { Id = 1, Name = "客厅灯", Location = "客厅", IsOn = false },
        new LightModel { Id = 2, Name = "卧室灯", Location = "卧室", IsOn = true }
    ];
    
    [KernelFunction("get_lights")]
    [Description("获取指定位置的灯光列表,包含ID、名称、状态等")]
    public List<LightModel> GetLights(
        [Description("要查询的位置。")] string location)
    {
        logger.LogInformation("正在执行 get_lights 函数,查询位置: {location}", location);
        return _lights.Where(light => light.Location == location).ToList();
    }
    
    [KernelFunction("get_light_state")]
    [Description("获取指定ID灯光的ID与状态。")]
    public LightModel? GetLightState(
        [Description("要查询状态的灯光的ID。")] int id)
    {
        logger.LogInformation("正在执行 get_light_state 函数,查询 ID: {id}", id);
        return _lights.FirstOrDefault(light => light.Id == id);
    }

    [KernelFunction("change_light_state")]
    [Description("改变指定ID灯光的状态(打开或关闭),灯光ID必须通过 get_lights 获取。")]
    public LightModel? ChangeLightState(
        [Description("要改变状态的灯光的ID。")] int id,
        [Description("灯光的目标状态,true代表打开,false代表关闭。")] bool isOn)
    {
        logger.LogInformation("正在执行 change_light_state 函数,ID: {id}, 目标状态: {isOn}", id, isOn);
        var light = _lights.FirstOrDefault(l => l.Id == id);
        if (light == null) return null;
        light.IsOn = isOn;
        return light;
    }
}

手动调用原生函数

  • 手动调用通过 kernel.InvokeAsync() 实现,需指定插件名、函数名和参数。
  • KernelArguments 作为参数传递容器,类似字典结构,键为函数参数名。
  • 适用于业务流程固定、单元测试和简单任务,开发者可完全控制执行流程。
// 注入LightsPlugin插件
builder.Plugins.AddFromType<LightsPlugin>("lights");    

Console.WriteLine("准备手动调用 get_light_state 函数...");
var arguments = new KernelArguments { { "id", 1 } };
var result = await kernel.InvokeAsync("lights", "get_light_state", arguments);

var lightState = result.GetValue<LightModel>();

if (lightState != null)
{
    Console.WriteLine($"查询成功!灯光ID: {lightState.Id}, 名称: {lightState.Name}, 状态: {(lightState.IsOn ? "开" : "关")}");
}
else
{
    Console.WriteLine("查询失败,未找到指定ID的灯光。");
}

示例代码展示了如何手动调用 get_light_state 函数,查询灯光状态。若查询成功,输出灯光的ID、名称和状态;若失败,提示未找到指定ID的灯光。通过手动调用,开发者能清晰地看到数据在函数间的流动过程,便于调试和维护。

四、插件的高级用法

函数链

  • 定义与特点
    • 函数链将多个函数按顺序串联,前一个函数的输出作为后一个函数的输入。
    • 这是一种确定性编排方式,执行路径由开发者预先编码,具有可预测性和稳定性。
    • 适用于需要多个步骤完成的任务,如订单处理流程。
  • 实现方法:Semantic Kernel提供了简洁语法支持手动编排,可将语义函数和原生函数组合构建混合工作流。
  • 示例:创建客服回复助手,通过情感分析和回复生成两个函数完成任务。
  1. 创建情感分析语义函数。
// CustomerSupportPlugin/AnalyzeSentiment/config.json

{
  "schema": 1,
  "type": "completion",
  "description": "分析一段文本的情感(正面、负面或中性)。",
  "execution_settings": {
    "default": {
      "max_tokens": 10,
      "temperature": 0.0	// 这个值是0,返回确定的结果
    }
  },
  "input_variables": [
    {
      "name": "input",
      "description": "需要被分析情感的文本。",
      "required": true
    }
  ]
}
// CustomerSupportPlugin/AnalyzeSentiment/skprompt.txt

分析以下文本的情感。你只能回答 "正面"、"负面" 或 "中性" 这三个词中的一个。

文本:{{$input}}
  1. 创建回复生成原生函数
// CustomerSupportPlugin.cs

public class CustomerSupportPlugin
{
    [KernelFunction]
    public string GenerateResponseForSentiment(string sentiment)
    {
        return sentiment.Trim().ToLower() switch
        {
            "正面" => "非常感谢您的积极反馈!我们很高兴您有一次愉快的体验。",
            "负面" => "非常抱歉给您带来了不好的体验。我们的团队将会尽快与您联系以解决问题。",
            "中性" => "感谢您的反馈。我们将利用这些信息来改进我们的服务。",
            _ => $"无法识别的情感:'{sentiment}'。我们将记录此问题以供审查。"
        };
    }
}
  1. 调用函数链
// Program.cs

// 加载语义函数插件
var pluginsDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Plugins");
var semanticPlugin = builder.Plugins.AddFromPromptDirectory(Path.Combine(pluginsDirectory, "CustomerSupportPlugin"));

// 加载原生函数插件
builder.Plugins.AddFromType<CustomerSupportPlugin>("NativeCustomerSupport");

var userInput = "请把客厅灯打开,然后告诉我卧室灯现在的状态。";

// --- 开始链式调用 ---
// 第 1 步:调用情感分析函数
Console.WriteLine("--> 步骤 1: 正在分析情感...");
// 另一种调用方式,先获取指定内核函数,再调用该函数
var analyzeSentiment = kernel.Plugins["CustomerSupportPlugin"]["AnalyzeSentiment"];
var sentimentResult = await kernel.InvokeAsync(
    analyzeSentiment,
    new KernelArguments { { "input", userInput } }
);
var sentiment = sentimentResult.GetValue<string>()!;
Console.WriteLine($"情感分析结果: {sentiment}\n");

// 第 2 步:将上一步的结果作为输入,调用回复生成函数
Console.WriteLine("--> 步骤 2: 正在生成回复...");
var generateResponseForSentiment = kernel.Plugins["NativeCustomerSupport"]["GenerateResponseForSentiment"];
var finalResult = await kernel.InvokeAsync(
    generateResponseForSentiment,
    new KernelArguments { { "sentiment", sentiment } }
);

// 输出最终结果
Console.WriteLine("\n----- 最终生成的回复 -----\n");
Console.WriteLine(finalResult.GetValue<string>());
Console.WriteLine("\n--------------------------\n");

示例中通过分步调用实现链式调用,清晰展示数据在函数间的流动,虽然代码稍显冗长,但可控性强。

函数的自动调用

  • 工作原理
    • 能力注册与意图理解:
      • 将所有可用插件及其描述注册到内核中,相当于告诉调度员手头的工具。
      • 用户发出自然语言指令后,内核将指令和函数描述发送给语言模型。
      • 语言模型分析用户意图,并查阅工具清单,决定调用哪些函数、以何种顺序和参数完成请求。
    • 执行与响应
      • Semantic Kernel解析语言模型返回的结构化响应,并执行其中指定的函数调用。
      • 执行结果可返回给语言模型,生成自然语言答复给用户。
      • 自动函数调用使开发者无需编写繁琐的判断语句解析用户意图,提高了开发效率。
  • 示例与流程
示例代码展示了如何通过自动函数调用实现用户指令的处理。
用户输入“请把客厅灯打开,然后告诉我卧室灯现在的状态”,内核自动调用相关函数完成操作,并生成最终回复。
自动调用内核函数的过程包括自动发送工具信息、模型决策与回复、Kernel自动执行、发送结果、生成最终回复等步骤。

流程梳理:
初始化:获取聊天补全服务,设置执行配置,准备用户输入。
发起调用:调用获取对话消息方法,传入对话历史、执行设置和内核实例。
第一轮交互:内核将对话历史和函数定义发送给模型。
模型决策与回复:模型分析输入,返回工具调用请求。
Kernel自动执行:内核拦截请求,查找并执行函数。
第二轮交互:内核将函数调用结果发送给模型。
生成最终回复:模型根据所有信息生成自然语言回复。
// 获取聊天完成服务
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();

// 配置执行设置以启用自动函数调用
var settings = new OpenAIPromptExecutionSettings
{
    ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
};

// 模拟用户输入
var userInput = "请把客厅灯打开,然后告诉我卧室灯现在的状态。";
Console.WriteLine($"用户输入: {userInput}");

// 创建聊天历史
var history = new ChatHistory();
history.AddUserMessage(userInput);

// 调用聊天服务,内核将自动处理函数调用
var result = await chatCompletionService.GetChatMessageContentAsync(
    history,
    executionSettings: settings,
    kernel: kernel);

// 打印最终的 AI 回复
Console.WriteLine($"AI 回复: {result.Content}");

插件的最佳实践

  • 清晰命名:函数和参数的命名应使用完整、无歧义的单词,避免含糊缩写。好的命名能清晰表达用途,便于理解和使用。
  • 精确描述:描述是写给LLM的“API文档”,需简洁且精确地描述函数功能、前提条件和效果。好的描述能让LLM正确选择函数,避免调用失败或产生意外行为。
  • 原子性原则
    • 尽量让每个函数只做好一件事,避免包含过多逻辑分支和功能。
    • 对于复杂任务,应拆分为多个简单、原子性的函数,通过函数链或自动规划组合,降低复杂度,提高可复用性。
  • 参数简化
    • 减少参数数量,降低LLM正确推理参数值的难度。
    • 优先使用基元类型作为参数,必要时可将多个相关参数封装成复杂对象。
    • 利用枚举类型,将开放式问题变为选择题,提升模型调用准确性。

五、Semantic Kernel Agent框架

1. 框架核心架构

  • 内核功能:内核是Semantic Kernel的核心,是一个功能强大的依赖注入容器。它统一管理运行AI应用所需的所有服务和插件,为智能体提供所需功能。
  • 智能体特性:
    • 智能体是旨在自主或半自主地执行任务的软件实体,接收输入,处理信息,采取行动达成目标。
    • 与LangChain中智能体不同,SK智能体更强调模块化和协作性,可构建分布式智能系统。
  • 核心抽象类
    • 智能体:智能体是所有智能体类型的核心抽象基类,定义了智能体的基本行为和接口。
    • 智能体线程:一个智能体可同时参与多个独立对话,每个对话由一个智能体线程表示,负责维护对话状态和上下文。
    • 聊天记录:对话中的具体消息内容由聊天记录对象承载,每条消息是一个聊天消息内容对象。

2. 创建第一个智能体应用

  1. 创建内核
var configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>() 
    .Build();

var apiKey = configuration["Qianwen:ApiKey"]!;
const string modelId = "qwen-plus";
const string endpoint = "https://dashscope.aliyuncs.com/compatible-mode/v1";

// 步骤 1: 创建内核
var builder = Kernel.CreateBuilder();

builder.AddOpenAIChatCompletion(
    modelId: modelId,
    apiKey: apiKey,
    endpoint: new Uri(endpoint)
);

var kernel = builder.Build();
  1. 定义智能体的指令,实例化聊天补全智能体,设置内核、名称和指令。

添加Microsoft.SemanticKernel.Agents.Core包

// 步骤 2: 定义智能体的指令
// 指令定义了智能体的角色、能力和行为准则。
const string instructions = 
    """
    你是一个乐于助人的 AI 助手,名叫“SK 专家”。
    你的任务是回答有关 C# 和.NET 的技术问题。
    你的回答应该清晰、简洁,并尽可能提供代码示例。
    如果一个问题与 C# 或.NET 无关,请礼貌地拒绝回答。
    """;
    
// 步骤 3: 创建聊天完成智能体
var agent = new ChatCompletionAgent()
{
    Kernel = kernel,
    Name = "SK 专家",
    Instructions = instructions
};
  1. 管理对话状态

智能体线程:使用ChatHistoryAgentThread创建智能体线程,管理对话状态。

// 步骤 4: 创建一个 AgentThread 来开启并管理一个新的对话
var agentThread = new ChatHistoryAgentThread();
Console.WriteLine("你好,我是 SK 专家。你可以问我任何关于 C# 和.NET 的问题。输入 '退出' 来结束对话。");
  1. 调用智能体

将用户消息封装成消息对象,调用智能体的InvokeStreamingAsync方法,处理返回的响应流。

智能体生成响应时,可逐个接收内容片段,改善用户体验。

while (true)
{
    Console.Write("用户 > ");
    var userInput = Console.ReadLine();

    if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("退出"))
    {
        Console.WriteLine("再见!");
        break;
    }

    // 将用户输入封装成消息对象
    var userMessage = new ChatMessageContent(AuthorRole.User, userInput);

    // 步骤 5: 调用智能体并处理流式响应
    Console.Write($"{agent.Name} > ");
    // 使用 await foreach 异步迭代智能体的响应流 
    await foreach (var response in agent.InvokeStreamingAsync(userMessage, agentThread))
    {
        // 将收到的每个内容片段打印到控制台
        Console.Write(response.Message.Content);
    }
    Console.WriteLine();
}

3. 智能体自动调用插件

  1. 模拟一个天气插件
public class WeatherPlugin
{
    [KernelFunction]
    [Description("获取指定城市当前的天气信息")]
    public string GetWeather(
        [Description("要查询的城市名称")]string city )
    {
        // 这是一个模拟实现。在真实应用中,这里会调用一个真实的天气 API。
        switch (city.ToLower())
        {
            case "北京":
                return "晴,25°C";
            case "上海":
                return "多云,28°C";
            case "西雅图":
                return "小雨,15°C";
            default:
                return $"抱歉,我不知道城市“{city}”的天气。";
        }
    }
}
  1. 注册插件
builder.Plugins.AddFromType<WeatherPlugin>();
  1. 启用自动调用:在创建智能体时,设置FunctionChoiceBehavior为Auto,让模型自主决定是否调用函数。
const string instructions = "你是一个乐于助人的 AI 助手,名叫“小唆助手”。尽你所能回答用户的所有问题。";
var agent = new ChatCompletionAgent()
{
    Kernel = kernel,
    Name = "小唆助手",
    Instructions = instructions,
    Arguments = new KernelArguments(new OpenAIPromptExecutionSettings
    {
        FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
    })
};

// 创建一个 AgentThread 来开启并管理一个新的对话
var agentThread = new ChatHistoryAgentThread();
Console.WriteLine("你好,我是小唆助手。你可以问我任何问题。输入 '退出' 来结束对话。");

while (true)
{
    Console.Write("用户 > ");
    var userInput = Console.ReadLine();

    if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("退出"))
    {
        Console.WriteLine("再见!");
        break;
    }

    // 将用户输入封装成消息对象
    var userMessage = new ChatMessageContent(AuthorRole.User, userInput);

    // 调用智能体并处理流式响应
    Console.Write("小唆助手> ");
    // 使用 await foreach 异步迭代智能体的响应流 
    await foreach (var response in agent.InvokeStreamingAsync(userMessage, agentThread))
    {
        // 将收到的每个内容片段打印到控制台
        Console.Write(response.Message.Content);
    }
    Console.WriteLine();
}
  1. 测试工具调用
通过对话测试智能体自动调用插件的功能,如查询天气信息。

4. 通过模板构建Agent

使用模板化指令,可在调用时动态替换参数,使Agent更具灵活性和复用性。与硬编码指令相比,模板化指令能适应多变需求,实现Agent行为的动态化。

  • 指令模板化
// 硬编码指令,功能单一
var agent = new ChatCompletionAgent()
{
    Instructions = "请写一个关于狗的故事,故事长度为3句话。",
    //... 其他配置
};
// 比如把 狗、3 可以转成参数模版,这样我们可以动态的改成 猫 5句话等

// 使用模板化指令,灵活可复用
agent = new ChatCompletionAgent()
{
    Instructions = "请写一个关于{{$topic}}的故事,故事长度为{{$length}}句话。",
    Arguments = new KernelArguments()
    {
        { "topic", "猫" },
        { "length", "5" },
    }
    //... 其他配置
};
  • 声明式定义
    • 将Agent定义外部化到YAML文件中,实现更高层次的解耦和工程实践。
    • YAML文件包含Agent的名称、描述、指令模板、输入输出变量等信息,便于维护、版本控制和团队协作
  • 构建基于模板的Agent
  1. 编写YAML:GenerateStory.yaml
name: GenerateStory
description: 一个可以根据指定的主题和句子数量来生成一个简短故事的 Agent。
template_format: semantic-kernel
template: |
  你的任务是扮演一个富有创造力的故事大王。
  请根据下面的要求创作一个故事:
  - 故事主题:{{$topic}}
  - 故事长度:恰好 {{$length}} 句话。
  请直接开始讲述故事,不要有任何额外的开场白或解释。
input_variables:
  - name: topic
    description: 故事的主题,例如“一只勇敢的小猫”或“一次太空冒险”。
    is_required: true
  - name: length
    description: 故事需要包含的句子数量。
    is_required: true
    default: "3"
execution_settings:
  default:
    temperature: 0.8
  1. 加载与实例化

添加Microsoft.SemanticKernel.Yaml包

// 1. 读取 YAML 文件内容。
// 确保 GenerateStory.yaml 文件已复制到输出目录。
var yamlContent = await File.ReadAllTextAsync("./GenerateStory.yaml");

// 2. 将 YAML 字符串转换为 PromptTemplateConfig 对象。
// 这是连接 YAML 定义和 Semantic Kernel 对象的关键步骤。
var templateConfig = KernelFunctionYaml.ToPromptTemplateConfig(yamlContent);

// 3. 使用配置对象创建 ChatCompletionAgent 实例。
var agent = new ChatCompletionAgent(templateConfig, new KernelPromptTemplateFactory())
{
    // 4. 将配置好的内核实例赋给 Agent,使其具备与 LLM 通信的能力。
    Kernel = kernel,
            
    // 5. 为模板变量提供默认值。
    // 这里的 'topic' 会覆盖 YAML 中可能存在的默认值。
    // 'length' 使用 YAML 中定义的默认值 "3"。
    Arguments = new KernelArguments()
    {
        { "topic", "一只迷路的小狗" }
    }
};

Console.WriteLine($"Agent '{agent.Name}' 已创建。");
Console.WriteLine($"描述: {agent.Description}");
Console.WriteLine("---");
  1. 调用Agent

实例化Agent后,通过调用InvokeStreamingAsync方法与Agent交互,获取响应。

Agent内部会整合指令和参数,生成完整的提示发送给语言模型,模型生成响应后返回给用户。

var userMessage = new ChatMessageContent(AuthorRole.User, "开始");
await foreach (var response in agent.InvokeStreamingAsync(userMessage))
{
    // 将收到的每个内容片段打印到控制台
    Console.Write(response.Message.Content);
}

最佳实践

  • 统一文件组织:在项目中创建专门目录存放YAML文件,根据功能或业务领域创建子目录,保持结构清晰。
  • 清晰命名:制定一致且具有描述性的命名规范,便于从文件名快速识别Agent用途。
  • 原子化版本控制:修改Agent行为时,将YAML文件变更与使用该Agent的业务逻辑代码变更放在同一次Git提交中,保持同步。
  • 善用描述字段:在YAML的description字段中编写清晰准确的描述,帮助语言模型理解工具用途和参数,对启用函数调用的Agent至关重要。