Spiga

程序员的AI体验(九):Semantic Kernel 流程框架

2025-06-07 15:58:26

一、流程框架设计理念

  • 探索性AI与企业级应用差异:探索性AI高度自主,适合创意生成;企业级应用需确定性、可重复性、可审计性。
  • 流程框架关键转变:从“让AI决定做什么”转变为“让AI高效执行预定义流程”,开发者通过声明式语法控制业务流程。
  • 应用场景与优势:插件数量多时,单纯依靠大语言模型编排函数调用不可靠,流程框架可解决此挑战。
  • 架构基础:事件驱动架构
    • 优势
      • 高度解耦与模块化:每个步骤独立开发、测试和替换,提升系统可维护性。
      • 异步与并发:步骤发布事件后无需等待响应,实现高性能并行处理。
      • 灵活性与可扩展性:修改事件路由规则或增加新步骤即可适应业务变更。
    • 云原生环境支持
      • 支持本地与分布式部署:流程框架支持本地开发环境及Dapr、Orleans等分布式云原生环境。
      • 无缝迁移与扩展:本地定义的流程可无缝迁移到生产环境,无需修改核心业务逻辑。
  • 核心概念详解
    • 流程
      • 定义与构成:流程是顶层容器,由步骤和事件路由规则构成,代表端到端业务目标。
      • 生命周期与状态:每个流程实例有独立生命周期和状态。
    • 步骤
      • 基本执行单元:步骤是流程的原子性活动,封装具体业务逻辑,遵循单一职责原则。
      • 内核函数:步骤类中用[KernelFunction]标记的方法,可被流程框架调用。
    • 事件
      • 信息传递与触发载体:事件由特定类表示,包含Id和Data属性,用于步骤间传递信息。
      • 事件路由:事件路由基于Id,决定事件的流向。
    • 上下文
      • 运行时关键组件:上下文对象提供运行时信息和提交事件的方法,实现复杂流程控制逻辑。
  • SK的流程框架还在预发布版本,最终版代码可能会有偏差,但不影响我们学习。

二、构建第一个线性流程

1. 环境准备与项目设置

  • 创建.NET控制台应用程序项目,安装Semantic Kernel流程框架核心依赖包。
  • Process.Core是核心库
  • Process.LocalRuntime提供本地运行组件。
    <PropertyGroup>
        <NoWarn>SKEXP0080</NoWarn>	<!-- 关闭警告 -->
    </PropertyGroup>
	<ItemGroup>
        <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.62.0" />
        <PackageReference Include="Microsoft.SemanticKernel.Process.Core" Version="1.62.0-alpha" />
        <PackageReference Include="Microsoft.SemanticKernel.Process.LocalRuntime" Version="1.62.0-alpha" />
    </ItemGroup>

2. 定义流程步骤

  • 信息收集步骤:收集产品信息,返回信息作为事件载荷传递给下一步。
// Steps/GatherProductInfoStep.cs

// 步骤1:信息收集步骤,用于收集产品信息
// 请注意,每个步骤类都需要继承自 KernelProcessStep
public class GatherProductInfoStep : KernelProcessStep
{
    // 使用 [KernelFunction] 特性来标记这个方法是一个可被流程调用的内核函数
    [KernelFunction]
    // 定义一个内核函数,它接收产品名称作为输入,并返回收集到的产品信息字符串
    public string GatherProductInformation(string productName)
    {
        // 在控制台打印日志,方便我们追踪流程的执行情况
        Console.Write($"- 正在为产品 '{productName}' 收集信息...");
        // 为了简化演示,这里我们返回一个硬编码的字符串作为产品信息
        // 在实际应用中,这里可能会是调用API、查询数据库或执行网络爬虫等复杂操作
        var productInfo = $"产品 '{productName}' 是一款AI开发平台,能够帮助开发者高效构建智能应用。";
        Console.WriteLine($"信息收集完成。");
        // 返回收集到的信息,这个返回值将作为事件的载荷,自动传递给下一个步骤
        return productInfo;
    }
}
  • 文档生成步骤:根据产品信息生成文档内容。
// Steps/GenerateDocumentationStep.cs

// 步骤2: 文档生成,用于根据产品信息生成文档
public class GenerateDocumentationStep : KernelProcessStep
{
    [KernelFunction] // 同样标记为内核函数
    public string GenerateDocument(string productInfo)
    {
        // 在控制台打印日志
        Console.Write("- 根据提供的信息生成文档...");
        // 这里我们简单地通过字符串拼接来模拟文档生成过程
        var generatedDocument = $"敏感词 # 用户手册\n\n## 简介\n{productInfo}\n\n## 功能特性\n- 特性A\n- 特性B\n- 特性C";
        Console.WriteLine("文档生成成功。");
        // 返回最终生成的文档内容
        return generatedDocument;
    }
}
  • 文档发布步骤:接收文档内容并模拟发布操作。
// Steps/PublishDocumentationStep.cs

// 步骤3:文档发布,用于将生成的文档进行发布
public class PublishDocumentationStep : KernelProcessStep
{
    // 定义一个内核函数,接收文档内容,并模拟发布操作
    // 注意这个函数没有返回值,因为它是一个流程的终点
    [KernelFunction]
    public void PublishDocument(string document)
    {
        Console.WriteLine($"- 正在发布文档...");
        // 为了直观展示,我们直接将文档内容打印到控制台
        Console.WriteLine("-------------------- 文档内容 --------------------");
        Console.WriteLine(document);
        Console.WriteLine("-------------------------------------------------");
        Console.WriteLine("- 文档发布成功!");
    }
}

3. 编排工作流

  • 流程构建器:使用ProcessBuilder定义事件流转路径,构建完整工作流。

  • 事件流转逻辑:定义入口点、步骤间流转逻辑,形成线性执行链条。

// Program.cs

// 首先,创建一个流程构建器的实例,并为我们的流程起一个名字,例如 "DocumentationGenerationProcess"
var processBuilder = new ProcessBuilder("DocumentationGenerationProcess");

// 接下来,使用 AddStepFromType<T> 方法将我们刚才定义好的三个步骤类添加到流程中
// 这个方法会让流程框架来负责步骤实例的创建和生命周期管理
// 它会返回一个 ProcessStepBuilder 对象,我们稍后会用它来定义事件路由
var gatherProductInfoStep = processBuilder.AddStepFromType<GatherProductInfoStep>();
var generateDocumentationStep = processBuilder.AddStepFromType<GenerateDocumentationStep>();
var publishDocumentationStep = processBuilder.AddStepFromType<PublishDocumentationStep>();

// 现在,开始定义事件的编排逻辑,也就是我们工作流的执行顺序
// 1. 定义整个流程的入口点
processBuilder
    // OnInputEvent("Start") 表示这个流程会监听一个来自外部、ID为 "Start" 的输入事件
    // 我们可以把它理解为整个流程的启动信号
    .OnInputEvent("Start")
    // 当 "Start" 事件发生时,将该事件(及其携带的数据)发送到 "信息收集" 步骤进行处理
    .SendEventTo(new ProcessFunctionTargetBuilder(gatherProductInfoStep));

// 2. 定义第一个步骤完成后的流转逻辑
gatherProductInfoStep
    // OnFunctionResult() 是一个非常方便且常用的方法
    // 它表示当 "信息收集" 步骤中的任何一个内核函数成功执行并返回结果后,就会自动触发一个内部事件
    // 这个事件的 Data 属性会被自动填充为该函数的返回值
    .OnFunctionResult()
    // 将这个由函数结果触发的事件,发送到 "文档生成" 步骤
    .SendEventTo(new ProcessFunctionTargetBuilder(generateDocumentationStep));

// 3. 定义第二个步骤完成后的流转逻辑
generateDocumentationStep
    // 同样地,当 "文档生成" 步骤的内核函数执行完毕并返回结果后
    .OnFunctionResult()
    // 将其结果事件传递给最终的 "文档发布" 步骤
    .SendEventTo(new ProcessFunctionTargetBuilder(publishDocumentationStep));

4. 启动与执行流程

  • 创建Kernel实例:即使不调用大语言模型,流程框架运行也需要Kernel实例。
  • 构建与启动流程:调用Build方法生成可执行流程对象,通过StartAsync方法启动流程。
// Program.cs
// 接上面代码

var kernel = builder.Build();

// 调用流程构建器的 Build() 方法,根据我们之前的定义,生成一个可执行的 KernelProcess 对象
var process = processBuilder.Build();

// 现在,定义一个初始事件,用于启动整个流程
var startEvent = new KernelProcessEvent
{
    // 这个事件的ID必须与我们在编排中定义的入口事件ID "Start" 完全匹配
    Id = "Start",
    // 事件的 Data 属性将作为第一个步骤的输入参数
    // 在这里,我们传入一个产品名称 "Semantic Kernel"
    Data = "Semantic Kernel"
};

// 调用流程的 StartAsync 方法,异步启动整个流程
// 我们需要传入 Kernel 实例和我们创建的初始事件
Console.WriteLine("流程即将启动...");
await process.StartAsync(kernel, startEvent);
Console.WriteLine("流程执行完毕...");

5. 在流程中集成AI服务

  1. 通过用户机密配置AI服务的ApiKey。
  2. 注册OpenAI聊天补全服务,使Kernel具备与远端AI模型通信能力。
  3. 定义AI驱动步骤:创建AIGenerateDocumentationStep,调用AI模型生成文档。
// Steps/AIGenerateDocumentationStep.cs

// 定义一个由AI驱动的、全新的文档生成步骤
public class AIGenerateDocumentationStep : KernelProcessStep
{
    // 依然是用 [KernelFunction] 标记内核函数
    [KernelFunction]
    // 请特别注意这个函数的签名:我们增加了一个 Kernel kernel 参数
    // 流程框架非常智能,它会自动将我们配置好的 Kernel 实例注入到这里
    public async Task<string> GenerateDocumentAsync(string productInfo, Kernel kernel)
    {
        Console.Write($"- 调用AI模型以生成文档...");

        // 定义一个高质量的提示(Prompt)模板
        const string prompt = """
                              你是一名专业的技术文档撰写人。
                              根据以下产品信息,为产品撰写一份简洁、专业的用户手册。

                              产品信息:
                              {{$productInfo}}

                              用户手册:
                              """;

        // 使用注入的 Kernel 对象来调用AI模型
        // InvokePromptAsync 方法会自动将 productInfo 变量填充到提示模板的占位符中,然后发送给AI
        var result = await kernel.InvokePromptAsync(
            prompt,
            new KernelArguments { { "productInfo", productInfo } }
        );

        // 从AI的返回结果中,安全地获取生成的文本内容
        var generatedDocument = result.GetValue<string>();
        Console.WriteLine($"AI文档生成成功。");

        // 将AI生成的结果作为函数的返回值,传递给流程的下一步
        return generatedDocument;
    }
}
  1. 替换模拟步骤:在流程编排中用AI步骤替换模拟步骤,无需改动事件路由逻辑。
var generateDocumentationStep = processBuilder.AddStepFromType<GenerateDocumentationStep>();
// 替换成
var generateDocumentationStep = processBuilder.AddStepFromType<AIGenerateDocumentationStep>();

三、高级流程编排功能

实现有状态的步骤

  • 有状态步骤需求:一些复杂场景中,步骤需维护内部状态并在多次调用间持续存在。
  • 状态管理机制:流程框架通过泛型步骤类和运行时自动管理状态的持久化与恢复。
// GenerateDocumentationState.cs

// 定义一个用于存储文档生成步骤状态的类
public class GenerateDocumentationState
{
    // 使用一个列表来记录所有生成过的文档内容的历史版本
    public List<string> DocumentHistory { get; set; } = new();

    // 同时,记录一下最后一次生成的文档是什么
    public string? LastGeneratedDocument { get; set; }
}

// Steps/StatefulGenerateDocumentationStep.cs
public class StatefulGenerateDocumentationStep : KernelProcessStep<GenerateDocumentationState>
{
    // 1. 在步骤内部定义一个私有字段,用于在运行时持有当前的状态
    private GenerateDocumentationState _state = new();

    // 2. 重写 ActivateAsync 方法,这是框架注入状态的入口
    public override ValueTask ActivateAsync(KernelProcessStepState<GenerateDocumentationState> state)
    {
        // 将框架从持久化存储中加载的状态,赋值给我们内部的私有字段
        this._state = state.State!; 
        return base.ActivateAsync(state);
    }

    // 3. 在内核函数中,我们就可以自由地读取和修改这个状态字段了
    [KernelFunction]
    public string GenerateDocument(string productInfo)
    {
        Console.WriteLine($": 正在根据提供的信息生成文档...");
        
        // 根据历史记录计算当前的版本号
        var version = this._state.DocumentHistory.Count + 1;
        var generatedDocument = $"# 产品手册 v{version}\n\n{productInfo}";

        // 操作状态:更新最后一次生成的文档,并将其添加到历史记录中
        this._state.LastGeneratedDocument = generatedDocument;
        this._state.DocumentHistory.Add(generatedDocument);

        Console.WriteLine($": 已生成第 {version} 版文档。历史版本数: {this._state.DocumentHistory.Count}。");
        
        // 正常返回生成的文档
        return generatedDocument;
    }
}

自定义事件与条件分支

  • 条件分支实现方式:步骤根据业务逻辑发布不同自定义事件,编排阶段定义不同事件的路由路径。
  • 审核流程示例:构建内容审核流程,根据审核结果发布不同事件,实现条件路由。
// Steps/ReviewStep.cs

// 审核步骤
public class ReviewStep : KernelProcessStep
{
    // 我们先用常量来定义两个自定义事件的ID,这是一个好习惯,可以避免拼写错误
    public const string ReviewApprovedEvent = "ReviewApproved";
    public const string ReviewRejectedEvent = "ReviewRejected";

    // 定义内核函数,它将实现审核逻辑,并根据结果发布不同的事件
    [KernelFunction]
    public async Task ReviewContent(
        string document,
        // 关键点:在函数的参数列表中,声明一个 KernelProcessStepContext 类型的参数
        // 流程框架的运行时会自动将这个上下文对象注入进来
        KernelProcessStepContext context)
    {
        Console.Write($"- 正在审核文档内容...");
        
        // 这是一个简化的审核逻辑,用于演示
        // 在真实场景中,这里可能是调用一个专业的内容安全API或一个复杂的规则引擎
        if (document.Contains("敏感词"))
        {
            Console.WriteLine($"- 发现敏感内容。发布 '{ReviewRejectedEvent}' 事件。");
            // 使用上下文对象的 EmitEventAsync 方法来发布一个我们自定义的“拒绝”事件
            await context.EmitEventAsync(new KernelProcessEvent
            { 
                Id = ReviewRejectedEvent, 
                Data = "文档包含违规内容,已被拒绝。" // 我们还可以在事件中附带一些说明数据
            });
        }
        else
        {
            Console.WriteLine($"- 内容合规。发布 '{ReviewApprovedEvent}' 事件。");
            // 否则,就发布一个“通过”事件
            await context.EmitEventAsync(new KernelProcessEvent
            { 
                Id = ReviewApprovedEvent,
                Data = document // 在这个事件中,我们将审核通过的原文内容继续传递下去
            });
        }
    }
}

// 通知步骤
// 定义一个简单的通知步骤,用于处理被拒绝的情况
public class NotificationStep : KernelProcessStep
{
    [KernelFunction]
    public void SendNotification(string message)
    {
        Console.WriteLine($"- 发送通知:{message}");
    }
}
  • 调用
// Program.cs

// 1. 注入2个新的步骤
var notificationStep = processBuilder.AddStepFromType<NotificationStep>();
var reviewStep = processBuilder.AddStepFromType<ReviewStep>();

// 2. 把文档发布后的步骤链接到审核
generateDocumentationStep
    // 同样地,当 "文档生成" 步骤的内核函数执行完毕并返回结果后
    .OnFunctionResult()
    // 将其结果事件传递给最终的 "文档发布" 步骤
    .SendEventTo(new ProcessFunctionTargetBuilder(publishDocumentationStep));

// 改成
generateDocumentationStep
    // 同样地,当 "文档生成" 步骤的内核函数执行完毕并返回结果后
    .OnFunctionResult()
    // 将其结果事件传递给最终的 "文档发布" 步骤
    .SendEventTo(new ProcessFunctionTargetBuilder(reviewStep));

// 3. 为审核步骤添加新流程
// 这里是条件路由的关键定义
// 我们开始监听来自 "reviewStep" 步骤发出的事件
reviewStep
    // OnEvent 方法允许我们精确地指定要监听的事件ID
    .OnEvent(ReviewStep.ReviewApprovedEvent)
    // 如果监听到 "审核通过" 事件,则将任务路由到 "发布" 步骤
    .SendEventTo(new ProcessFunctionTargetBuilder(publishDocumentationStep));

// 同时,我们为同一个审核步骤定义另一条路由规则
reviewStep
    // 监听 "审核拒绝" 事件
    .OnEvent(ReviewStep.ReviewRejectedEvent)
    // 如果监听到该事件,则将任务路由到 "通知" 步骤
    .SendEventTo(new ProcessFunctionTargetBuilder(notificationStep));
  • 测试
// 修改GenerateDocumentationStep内容,添加"敏感词"三个字,测试自动拒绝
var generatedDocument = $"敏感词 # 用户手册\n\n## 简介\n{productInfo}\n\n## 功能特性\n- 特性A\n- 特性B\n- 特性C";

人机协作模式

  • 人机协作实现方式:利用步骤内核函数参数机制和外部消息通道实现人机协作。

    • 一个步骤的内核函数只有在它所有参数都接收到数据之后,才会被框架调用。

      函数(参数1,参数2)

    • 让流程能够与外部世界双向通信

      • 流程向外部发送信号:代理步骤
      • 流程等待外部输入:空闲状态输入信息
      • 外部流程输入信息:满足参数需求,恢复执行
  • 审批流程示例:构建文档发布审批流程,实现流程暂停等待人工审批后再继续执行。

  1. 模拟一个外部通道
// Steps/InMemoryMessageChannel.cs

// 这是一个模拟外部消息系统的简单内存实现
public class InMemoryMessageChannel : IExternalKernelProcessMessageChannel
{
    // 用一个布尔值来记录是否收到了需要用户审核的请求
    public bool RequestUserReview;
    
    // 当流程中的 ProxyStep 调用 EmitExternalEvent 时,此方法会被触发
    public Task EmitExternalEventAsync(string eventName, KernelProcessProxyMessage message)
    {
        Console.WriteLine($"[外部通道]: 接收到流程发出的事件 '{eventName}',请求人工审核。");
        // 根据事件名称设置状态,以便外部程序查询
        RequestUserReview = eventName == "RequestUserReview";
        return Task.CompletedTask;
    }

    // 在这个简单的示例中,我们不需要复杂的初始化和反初始化逻辑
    public ValueTask Initialize() => ValueTask.CompletedTask;
    public ValueTask Uninitialize() => ValueTask.CompletedTask;
}
  1. 添加审核步骤
// Steps/ApprovalPublishStep.cs

// 定义一个需要人工审批的发布步骤
public class ApprovalPublishStep : KernelProcessStep
{
    [KernelFunction]
    // 关键改动:在参数列表中增加了一个 'userApproval' 参数
    // 这意味着,此函数现在需要两个输入:“document”和“userApproval”都满足后才能执行
    public void PublishDocument(string document, bool userApproval)
    {
        Console.WriteLine($"- 接收到审批结果:{(userApproval? "批准" : "拒绝")}");
        // 根据审批结果,决定是否执行真正的发布操作
        if (userApproval)
        {
            Console.WriteLine($"- 正在发布文档...");
            Console.WriteLine("-------------------- 文档内容 --------------------");
            Console.WriteLine(document);
            Console.WriteLine("-------------------------------------------------");
            Console.WriteLine($"- 文档发布成功!");
        }
        else
        {
            Console.WriteLine($"- 文档被拒绝,不执行发布。");
        }
    }
}
  1. 编排
// 1. 把发布步骤改成审核步骤
var publishDocumentationStep = processBuilder.AddStepFromType<PublishDocumentationStep>();
// 改成    
var publishDocumentationStep = processBuilder.AddStepFromType<ApprovalPublishStep>();
    
// 2. 添加一个代理步骤
var proxyStep = processBuilder.AddProxyStep("proxyStep", ["RequestUserReview"]);

// 3. 在审核通过步骤添加一个动作
reviewStep
    // OnEvent 方法允许我们精确地指定要监听的事件ID
    .OnEvent(ReviewStep.ReviewApprovedEvent)
    // 动作1:通过 ProxyStep 向外部世界发出一个 "RequestUserReview" 事件,请求人工干预
    .EmitExternalEvent(proxyStep, "RequestUserReview")
    // 如果监听到 "审核通过" 事件,则将任务路由到 "发布" 步骤
    .SendEventTo(new ProcessFunctionTargetBuilder(publishDocumentationStep, parameterName: "document")); 

// 4. 为2个参数指定参数名
processBuilder
    .OnInputEvent("UserApprovalCompleted")
    // 将这个外部事件携带的数据,直接路由到“发布”步骤正在等待的“userApproval”参数上
    .SendEventTo(new ProcessFunctionTargetBuilder(publishDocumentationStep, parameterName:"userApproval"));

// 5. 创建一个全局外的部事件监听器
// 新增一个全局的外部事件监听器,用于接收人工审批的结果
processBuilder
    .OnInputEvent("UserApprovalCompleted")
    // 将这个外部事件携带的数据,直接路由到“发布”步骤正在等待的“userApproval”参数上
    .SendEventTo(new ProcessFunctionTargetBuilder(publishDocumentationStep, parameterName:"userApproval"));

// 6. 创建外部消息对象
var externalChannel = new InMemoryMessageChannel(); 

// 调用流程的 StartAsync 方法,异步启动整个流程
// 我们需要传入 Kernel 实例和我们创建的初始事件
Console.WriteLine("流程即将启动...");
await process.StartAsync(kernel, startEvent, externalChannel);
// 流程第一次执行完毕后,我们检查外部通道是否收到了需要审核的请求
if (externalChannel.RequestUserReview)
{
    Console.WriteLine("--- 流程已暂停,等待人工操作... ---\n");
    // 在这里,我们可以暂停程序,等待用户输入,或者通过API暴露给前端
    // 为了演示,我们直接模拟用户批准了该请求
    Console.WriteLine("--- 模拟用户批准 ---");
    var approvalEvent = new KernelProcessEvent { Id = "UserApprovalCompleted", Data = true };
    // 在同一个流程实例上,再次调用 StartAsync,并传入带有审批结果的恢复事件
    await process.StartAsync(kernel, approvalEvent);
    Console.WriteLine("--- 流程恢复执行并完毕 ---");
}

四、流程框架与智能体框架关系与区别

对比与区别

维度 流程框架(Process Framework) 智能通框架(Agent Framework)
控制流 确定性的、预先定义的、由开发者硬编码 动态的、自主决策的、由LLM在运行时生成
自主性 低。严格遵循预设的脚本和规则 高。能够根据目标自主规划步骤和选择工具
可预测性 高。执行路径完全可预测,便于调试和审计 中/低。执行路径可能因LLM的推理而变化
核心优势 可靠性、可审计性、可重复性、企业级控制 灵活性、适应性、复杂问题解决能力
典型场景 员工入职理财、订单处理流程、财务审批流、文档自动化发布管道 动态研究助手、自动化任务规划器、交互式客服机器人、多工具协同任务

混合模式

  • 混合模式优势:兼具智能体灵活性和流程可靠性,形成强大认知架构。
  • 工作模式:
    • 智能体接收任务并分解任务
    • 规划与决策
    • 任务派发:确定性、标准化子任务。发送一个初始事件,触发一个预定义的流程
    • 流程执行
    • 结果反馈

最佳实践

  • 保持步骤单一职责:设计良好步骤应只关注一件事,提高复用性。
  • 定义清晰事件契约:使用常量或枚举定义事件ID,考虑使用DTO类。
  • 合理设计状态对象:状态对象应轻量化,避免存储冗余数据。
  • 优先使用函数结果传递:简单线性流程优先使用OnFunctionResult()。
  • 隔离AI与业务逻辑:分离确定性业务逻辑与非确定性AI生成逻辑。