Semantic Kernel Kernel Hooks(钩子)设计解读:Phase 1 决策记录与源码落地分析
【免费下载链接】semantic-kernelIntegrate cutting-edge LLM technology quickly and easily into your apps项目地址: https://gitcode.com/GitHub_Trending/se/semantic-kernel
本篇文章围绕 Semantic Kernel 仓库中
docs/decisions/0005-kernel-hooks-phase1.md这份 ADR(架构决策记录)展开,系统讲解"函数执行前/后拦截"这一核心机制的设计背景、候选方案权衡、最终决策(事件式注册),并结合当前仓库源码验证其落地形态与演进方向(过滤器 filters)。读完本文,你将理解 Semantic Kernel 中FunctionInvoking/FunctionInvoked事件的设计动机、CancelKernelEventArgs的语义,以及为何新代码推荐使用IFunctionInvocationFilter替代事件。
一、问题背景:为什么需要 Kernel Hooks
在 LLM 应用开发中,一个 Kernel 通常会编排多个函数的执行,例如先查询上下文、再渲染 Prompt、最后调用 LLM 并返回结果。开发者常常需要在函数执行之前和执行之后两个时间点介入,以实现:
- 前置拦截(Pre-Execution / Function Invoking):
- 获取当前
SKContext(即当前上下文变量); - 修改将要传给函数的输入参数;
- 中止/取消整个流水线(pipeline)的执行;
- 跳过某个函数的执行;
- 获取当前
- 后置处理(Post-Execution / Function Invoked):
- 获取 LLM 模型结果(Token 用量、停止序列等);
- 获取
SKContext与输出参数; - 修改输出参数内容(在返回值返回给调用方之前);
- 取消流水线执行;
- 重复执行函数。
这份 ADR 记录了 Semantic Kernel(当时 .NET 侧)为满足上述场景而做的第一阶段设计决策。决策驱动因素很明确:架构变更过程对社区透明、决策记录存放在仓库中便于各语言移植团队查阅,并且方案要"简单、可扩展、易于理解"。
值得注意的是,ADR 同时划定了 Phase 1 的范围边界——以下能力被明确放入Phase 2:
- 前置阶段:获取已渲染的 Prompt、获取当前使用的 settings(执行设置)、修改已渲染的 Prompt;
- 后置阶段:获取已渲染的 Prompt、获取当前使用的 settings。
也就是说,Phase 1 只解决"执行前改参数 / 执行后改结果"的问题,Prompt 内容级别的拦截留待下一阶段。
二、五个候选方案的系统性比较
决策记录完整列出了五种可选实现,并逐一给出了 Pros/Cons:
1. Callback Registration + Recursive(递归回调注册,作用于 Kernel/Plan/Function 三级)
在 plan(计划)和 function(函数)级别以配置方式指定将被触发的回调处理器。
- 优点:观察/修改数据的常见模式;注册回调时会返回注册对象,可用于将来取消函数执行;递归方式允许对同一事件注册多个回调,也允许在既有回调之上叠加新回调。
- 缺点:递归方式可能占用更多内存,且在函数或 plan 被释放之前回调可能无法被垃圾回收。
2. Single Callback(单一回调委托)
在 Kernel 级别配置;也可以放在函数构造函数或函数调用参数中。
- 优点:同样具备"通过委托签名中的参数观察/修改数据"的通用模式。
- 缺点:一个特定事件(Pre/Post/InExecution)只能注册一个观察方法;如果作为函数调用参数传入,函数签名需要额外增加三个参数。
3. Event Based Registration(基于事件的注册,仅 Kernel 级)——最终选定方案
在IKernel和ISKFunction上暴露事件,调用方可以订阅这些事件进行交互。
- 优点:同一事件可注册多个监听器;监听器可随时注册/注销;
EventArgs是观察和修改数据的通用模式。 - 缺点:事件处理器是
void返回类型,修改数据只能靠引用传递EventArgs;对异步模式/多线程的支撑程度不明确;不支持ISKFunction.InvokeAsync。
4. Middleware(中间件,仅 Kernel 级)
在 Kernel 级别配置,仅用于IKernel.RunAsync操作,模式类似 ASP.NET Core 中间件:用 context 和requestDelegate next控制前置/后置条件。
- 优点:处理前置/后置设置与过滤数据的常见模式。
- 缺点:函数可以在自己的实例上独立运行,中间件暗示了更高的复杂度,并且需要一个外部容器/管理器(Kernel)来拦截/观察函数调用。
5. ISKFunction Event Support Interfaces(ISKFunction 事件支持接口)
给ISKFunction增加可选的事件支撑接口,让函数实现者决定是否支持某个具体事件。ADR 中给出了原型代码:Kernel.RunAsync在执行函数前后分别触发FunctionInvoking与FunctionInvoked事件,SemanticFunction通过实现ISKFunctionEventSupport<TEventArgs>来准备事件参数(例如在触发前把已渲染的 Prompt 写入上下文字典)。
- 优点:
Kernel不感知SemanticFunction的具体实现细节;可为自定义ISKFunction扩展专属的EventArgs(包括语义函数的 Prompt);未来新事件可通过ISKFunctionEventSupport<NewEvent>扩展;接口可选,自定义函数可选择不实现。 - 缺点:自定义函数若要支持事件,必须承担实现
ISKFunctionEventSupport接口的责任;Kernel需要检查函数是否实现该接口,否则要抛出异常或忽略事件;原本只需实现InvokeAsync的函数实现者,现在需要把状态管理分散到多个位置。
三、决策结果:采用事件式注册(Event Based Registration)
ADR 最终选择方案 3:Event Base Registration(Kernel only),理由是最简单,并且能直接利用标准 .NET 事件实现的全部能力。
在决策记录中,针对社区常见疑问给出了明确的结论,这些结论也构成了该机制的语义契约:
| 问题 | 决策结论 |
|---|---|
| 后置处理器应在 LLM 结果返回后立即执行,还是函数整个执行结束前执行? | 当前实现中,后置处理器在函数执行完成之后执行 |
| 前置/后置处理器是否应支持多注册(pub/sub,可注册/注销)? | 标准 .NET 事件天然支持多注册与调用方管理的注销 |
| 在既有处理器之上再叠加处理器,应允许还是抛错? | 标准 .NET 事件行为:不抛错,且会依次执行所有已注册的处理器 |
| 在 Plan 上设置的处理器是否应级联到所有内部步骤并覆盖既有处理器? | 处理器会在每一步执行前后被触发,与 KernelRunAsync流水线的工作方式一致 |
| 前置处理器意图取消执行时,链上后续处理器是否还要被调用? | 标准 .NET 行为:会调用所有已注册的处理器;函数是否执行仅取决于所有处理器都执行完后Cancellation Request的最终状态 |
其中最后一条尤其关键:它明确了"取消"是一个协作式的最终状态判定——每个处理器都可以设置/改写取消标志,最终结果由事件触发方(Kernel)读取。这一语义在当前的CancelKernelEventArgs.Cancel属性注释中仍能找到一致描述(见 CancelKernelEventArgs.cs)。
四、源码落地:事件机制在仓库中的实际形态
虽然 ADR 写于 2023 年(status: accepted,date: 2023-05-29),但其决策在今天仓库的源码中依然可追溯。在 Kernel.cs 的 Obsolete 区域中,Kernel类暴露了四个事件:
public event EventHandler<FunctionInvokingEventArgs>? FunctionInvoking; public event EventHandler<FunctionInvokedEventArgs>? FunctionInvoked; public event EventHandler<PromptRenderingEventArgs>? PromptRendering; public event EventHandler<PromptRenderedEventArgs>? PromptRendered;其中FunctionInvoking/FunctionInvoked正是 ADR Phase 1 的核心产出,而PromptRendering/PromptRendered对应 ADR 中规划到 Phase 2 的"渲染后的 Prompt"能力——说明后续版本确实补齐了 Phase 2 的事件。
4.1 事件参数类型体系
当前事件参数类型已从 ADR 原型中的SKContext演进为更通用的KernelArguments:
- KernelEventArgs.cs:抽象基类,继承自
EventArgs,携带Function(关联的KernelFunction)、Arguments(调用参数)、Metadata(元数据字典); - CancelKernelEventArgs.cs:在基类之上增加
bool Cancel属性,语义为"事件触发方(Kernel)在考虑最终结果时读取该值",与 ADR 中"最终状态决定执行与否"的结论一致; - FunctionInvokingEventArgs.cs:
sealed密封类,在函数即将被调用前触发,可用于修改参数或设置Cancel; - FunctionInvokedEventArgs.cs:
sealed密封类,在函数调用完成之后触发,额外暴露Result(FunctionResult)与内部只读的ResultValue,并提供SetResultValue(object?)方法用于覆盖函数的返回值。
SetResultValue的实现验证了 ADR 中"修改输出参数内容(在返回输出之前)"的后置场景确实落地。而 FunctionInvokedEventArgsTests.cs 中的单元测试也印证了这一点:
ResultValuePropertyShouldBeInitializedByOriginalOne:构造FunctionInvokedEventArgs时ResultValue初始化为原始函数结果(测试中用整数 36 验证);ResultValuePropertyShouldBeUpdated:调用SetResultValue(72)后,ResultValue被更新为 72。
4.2 当前的弃用状态:事件 → 过滤器(Filters)演进
需要特别向读者说明的是:在当前仓库版本中,这套事件机制已被标记为[Obsolete],编译警告信息明确写道:
Events are deprecated in favor of filters. Example in dotnet/samples/GettingStarted/Step7_Observability.cs of Semantic Kernel repository.
也就是说,FunctionInvoking/FunctionInvoked事件在现行版本中保留但不再推荐使用,官方推荐的新机制是Kernel Filters(过滤器),对应接口 IFunctionInvocationFilter.cs:
public interface IFunctionInvocationFilter { Task OnFunctionInvocationAsync(FunctionInvocationContext context, Func<FunctionInvocationContext, Task> next); }OnFunctionInvocationAsync在函数调用前被调用,next委托指向过滤器管道中的下一个过滤器或函数本身——如果不调用next,后续过滤器与函数本体都不会执行。这正是 ADR 中"Middleware(方案 4)"思想在 Filter 形态下的回归:既保留了事件式注册"同一事件多处理器、按注册顺序依次执行"的灵活性,又通过next委托提供了显式的中断控制,同时规避了事件机制不支持异步、只能靠引用修改EventArgs的短板。
从演进脉络看,Phase 1 的"执行前改参数、执行后改结果"、"多处理器协作式取消"等核心能力,在过滤器机制中均有对应物:FunctionInvocationContext提供Arguments、Result等属性,处理器可以修改参数与结果,也可以通过不调用next短路整个调用链。
五、对开发者的实战指引
5.1 如果使用旧版本(事件机制)
在仍使用事件机制的 Semantic Kernel 版本中,可按下述方式订阅:
kernel.FunctionInvoking += (sender, e) => { // 修改输入参数 e.Arguments["prompt"] = "修改后的输入"; // 或取消执行:e.Cancel = true; }; kernel.FunctionInvoked += (sender, e) => { // 读取结果 Console.WriteLine(e.Result); // 覆盖结果:e.SetResultValue("新的返回值"); };注意:Cancel的协作式语义意味着多个处理器会全部执行,最终是否取消取决于最后一个设置的值。
5.2 新代码推荐使用过滤器
在当前仓库版本中,推荐通过实现IFunctionInvocationFilter并在 DI 容器中注册(AddFunctionInvocationFilter一类扩展方法)来拦截函数调用。可参考仓库中的 Step7_Observability.cs 示例,了解官方推荐的过滤器用法。
5.3 事件与过滤器的能力对照
| 能力维度 | 事件(Phase 1,已废弃) | 过滤器(现行推荐) |
|---|---|---|
| 前置修改参数 | ✅ 通过Arguments | ✅ 通过FunctionInvocationContext.Arguments |
| 取消执行 | ✅Cancel = true(协作式最终状态) | ✅ 不调用next短路 |
| 后置读取/修改结果 | ✅Result/SetResultValue | ✅ 通过context.Result |
| 多处理器 | ✅ 标准 .NET 事件多订阅 | ✅ 管道式多过滤器 |
| 异步支持 | ⚠️ 事件处理器 void,需靠引用传递 | ✅ 原生 async 管道 |
六、总结
0005-kernel-hooks-phase1.md这份 ADR 完整记录了 Semantic Kernel 函数钩子机制的设计取舍:从五个候选方案中选定"基于事件的注册"作为 Phase 1 方案,明确划定了 Phase 2 的范围(渲染 Prompt 与 settings 的访问/修改),并给出了多处理器取消、级联触发等关键语义的权威解答。这些设计决策在仓库源码中均有迹可循——FunctionInvoking/FunctionInvoked事件及其EventArgs体系是 Phase 1 的直接落地,PromptRendering/PromptRendered事件补齐了 Phase 2 能力,而当前版本中事件被标记为弃用、由IFunctionInvocationFilter过滤器接管,则是这一设计在新架构下的自然演进。对于想要理解 Semantic Kernel 拦截机制的开发者而言,这份 ADR 加上 Kernel.cs 与 Filters 目录 下的实现,构成了从"设计意图"到"代码现实"的完整学习路径。
【免费下载链接】semantic-kernelIntegrate cutting-edge LLM technology quickly and easily into your apps项目地址: https://gitcode.com/GitHub_Trending/se/semantic-kernel
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考