数据截至 (上游 commit 5e1f1fb87d9a)
第 06 章 · 另外两个子系统:流程框架与数据检索层
本章讲什么: SK 里除了 agent(见 第 04 章、第 05 章),还有两块独立成体系、常被忽略的东西:一个把业务流程画成事件驱动图的 Process Framework,一个把向量库统一成一套接口的数据检索层。两块都能脱离 agent 单独用,原理也完全不同,所以合成一章讲。
6.0 为什么把这两块放在一起
它们的共同点只有一个:都不属于"模型对话"这条主线,却都是 SK 作为"企业级 SDK"而非"聊天玩具"的支撑件。
除此之外,两者 毫无关系,读的时候当成两篇独立文章:
| 子系统 | 它替你管什么 | 入口符号 | 能不能不用 Kernel |
|---|---|---|---|
| Process Framework | 多步骤业务流程的编排与恢复 | ProcessBuilder | 不能,步骤靠 Kernel 插件机制注册 |
| 数据检索层 | 向量库的 schema、读写、过滤、检索 | vectorstoremodel / VectorStore | 能,VectorStore 完全独立 |
两块唯一的交汇点在最后:数据层的检索结果可以用 create_search_function 包成一个 KernelFunction 挂回 Kernel(见 6.14),从而被自动函数调用循环(第 03 章)选中。
全部 Process / 数据代码都带
@experimental或@release_candidate标记(python/semantic_kernel/utils/feature_stage_decorator.py),接口随时可能变。
Part A · Process Framework:事件驱动的业务流程图
6.1 一句话:它是什么
把一段多步骤业务,写成"一堆只会收参数、发事件的步骤" + "一张事件该送给谁的边表",然后交给运行时批量跑。
它要解决的问题不是"让模型更聪明",而是:
- 一个业务流有 5 个环节,环节之间要传数据、要有分支、要能循环;
- 某个环节调 LLM、某个环节调数据库,你不想手写
await a(); await b(); if ...那一坨; - 流程跑到一半挂了,重启后想从存下来的状态接着跑;
- 同一张流程图,本地跑用协程,线上跑要换成分布式 actor。
一句话直觉: 像 Kubernetes 的声明式编排,但编排对象是你的业务函数——你只声明"谁的哪个事件流向谁的哪个参数",不写调度代码。
6.2 用起来什么样(最小真实例子)
下面这段是官方样例 python/samples/getting_started_with_processes/step01/step01_processes.py:166-199 的骨架,已删去无关行:
process = ProcessBuilder(name="ChatBot")
# 1. 注册步骤:传"类型"而不是实例,运行时才实例化
intro_step = process.add_step(IntroStep)
user_input_step = process.add_step(ScriptedInputStep)
response_step = process.add_step(ChatBotResponseStep)
# 2. 外部事件进入流程的入口
process.on_input_event(event_id=ChatBotEvents.StartProcess).send_event_to(target=intro_step)
# 3. 步骤之间连线:某个函数跑完 → 触发下一个步骤
intro_step.on_function_result(function_name="print_intro_message").send_event_to(target=user_input_step)
# 4. 带参数名的连线:事件里的数据填给目标函数的 user_message 参数
user_input_step.on_event(event_id=CommonEvents.UserInputReceived).send_event_to(
target=response_step, parameter_name="user_message"
)
kernel_process = process.build() # 编译成纯数据
await start(process=kernel_process, kernel=kernel, initial_event=...) # 跑
注意第 3、4 行的形态:没有一处写"接下来调用谁",全是"谁的什么事件,流到谁的什么参数"。
6.3 三层结构:构建期 → 编译产物 → 运行期
这是理解整个框架的骨架,先看图(从左到右是时间顺序):
构建期(你写的链式代码) 编译产物(纯数据,可 JSON) 运行期(真正跑)
┌────────────────────────┐ ┌────────────────────────┐ ┌────────────────────────┐
│ ProcessBuilder │ │ KernelProcess │ │ LocalProcess │
│ .add_step(类型) │build│ state: 名字/版本/id │ 装载│ 超步循环(默认 100轮) │
│ .on_input_event(...) │────>│ steps: [StepInfo] │────>│ LocalStep × N │
│ .send_event_to(...) │ │ edges: {事件id:[边]} │ │ 每步一个事件队列 │
└────────────────────────┘ └────────────────────────┘ └────────────────────────┘
攒一张边表 一张边表 + 一堆步骤状态 也可整体换成 Dapr actor
三层各由谁负责:
| 层 | 主类 | 文件 |
|---|---|---|
| 构建期 | ProcessBuilder / ProcessStepBuilder | python/semantic_kernel/processes/process_builder.py、process_step_builder.py |
| 编译产物 | KernelProcess / KernelProcessStepInfo / KernelProcessEdge | processes/kernel_process/ |
| 运行期(本地) | LocalProcess / LocalStep | processes/local_runtime/ |
| 运行期(分布式) | ProcessActor / StepActor | processes/dapr_runtime/actors/ |
关键设计:中间那层是纯数据。 ProcessBuilder.build()(process_builder.py:164-175)把 builder 全部塌缩成 KernelProcess(state, steps, edges, factories),里面没有任何可执行对象——步骤只以"类型 + 状态"的形式存在。正因为如此,同一份编译产物既能喂给本地协程运行时,也能喂给 Dapr actor 运 行时。
6.4 构建期:链式 DSL 如何变成一张边表
6.4.1 四个入口 API
链式调用的起点只有四个,区别在于事件 ID 怎么算:
| API | 定义位置 | 事件 ID 形态 | 用途 |
|---|---|---|---|
ProcessBuilder.on_input_event(id) | process_builder.py:132-140 | 原样(不加前缀) | 流程外部事件的入口 |
ProcessStepBuilder.on_event(id) | process_step_builder.py:84-91 | {步骤名}_{步骤uuid}.{id} | 接某步骤主动 emit 的事件 |
ProcessStepBuilder.on_function_result(fn) | process_step_builder.py:224-239 | on_event(f"{fn}.OnResult") | 接某函数的返回值 |
ProcessStepEdgeBuilder.stop_process() | process_step_edge_builder.py:57-65 | 固定登记为 "END" | 终止流程(有坑,见 6.16) |
on_event 的加前缀动作在 get_scoped_event_id(process_step_builder.py:141-143),前缀 event_namespace 是构造时算好的 f"{name}_{id}"(:59)。为什么要加命名空间? 因为同一个步骤类可以 add_step 多次,若不加前缀,两个实例发的 OnResult 会串线。
6.4.2 send_event_to 做了什么
链条的第二环 ProcessStepEdgeBuilder.send_event_to(process_step_edge_builder.py:28-55)只做三件事:
- 若传进来的是步骤(不是目标),包成
ProcessFunctionTargetBuilder; self.source.link_to(event_id, self)—— 把这条边挂进源步骤的edges字典(process_step_builder.py:255-264);- 返回一个新的 edge builder,所以同一个事件可以继续
.send_event_to(...)连多个目标(扇出)。
6.4.3 目标解析:函数名和参数名可以不写
ProcessFunctionTargetBuilder.__init__(process_function_target_builder.py:21-39)会调 resolve_function_target 帮你猜:
- 目标步骤只有一个 kernel function → 函数名可省;多于一个则抛
KernelException(process_step_builder.py:104-108); - 该函数除
KernelProcessStepContext外只剩一个参数 → 参数名可省(:120-135)。
这就是为什么 6.2 的例子里,连 intro_step 时什么都不用写,连 response_step 时才要写 parameter_name="user_message"。
6.4.4 编译:build()
ProcessBuilder.build()(process_builder.py:164-175)把每条 edge builder .build() 成 KernelProcessEdge(process_step_edge_builder.py:67-73),每个 step builder .build_step() 成 KernelProcessStepInfo(process_step_builder.py:156-222,顺带把 @kernel_process_step_metadata 的版本号写进状态)。
结果就是一张字典:事件 ID → 边列表,边里记着"目标步骤 id / 目标函数名 / 目标参数名"。运行期只查这张表。
6.5 步骤契约:一个步骤要长什么样
步骤基类薄得惊人——只有一个可选的 activate 钩子(processes/kernel_process/kernel_process_step.py:16-23):
class KernelProcessStep(ABC, KernelBaseModel, Generic[TState]):
state: TState | None = None
async def activate(self, state: "KernelProcessStepState[TState]"): ...
真正的契约是约定,共三条:
- 业务方法用
@kernel_function标注——步骤实例会被kernel.add_plugin注册成一个插件(local_step.py:203-209),所以步骤的函数就是普通 kernel function(第 01 章)。 - 要发事件就声明一个
KernelProcessStepContext参数,调context.emit_event(...)(kernel_process_step_context.py:22-42)。它接受KernelProcessEvent、字符串或 Enum,内部统一包成事件对象往消息通道扔。 - 要有状态就写成
KernelProcessStep[MyState],activate里接住框架给的状态对象。
事件有可见性之分(kernel_process_event.py:13-21):Internal 只在本流程内流转,Public 会被冒泡出流程边界(嵌套子流程时有用)。
6.6 运行期(核心):超步批处理循环
6.6.1 直觉先行
运行时不 是"A 跑完立刻跑 B"。它是一轮一轮的:
一轮里,先把上一轮攒下的所有事件一次性收齐、算出该投递的消息、然后并发执行;这一轮里新产生的事件一律不在本轮生效,要等下一轮。
这就是"超步"(superstep)——批量同步并行的经典做法。好处是并发天然无竞态,坏处是"同一轮内 A 写了 B 立刻读"这种期待会落空。
6.6.2 一轮的四个动作
LocalProcess.internal_execute(local_runtime/local_process.py:191-222)整个循环只有 30 行:
┌─────────── 一个超步(最多 max_supersteps=100 轮)───────────┐
外部│ ① enqueue_external_messages: 抽干外部事件队列 → 查边表 → 造消息 │
事件│ ② 逐个步骤 enqueue_step_messages: 抽干它的事件队列 → 造消息 │
──> │ ③ 把 message_channel 一次性取空;取不到 且 没有外部事件 → 结束 │
│ ④ asyncio.gather:所有消息并发投递给目标步骤 handle_message │
└───────────────────────────┬───────────────────────────────────┘
│ 步骤里 emit_event 只是 put 进自己队列
└──> 要到下一轮的 ② 才被看见 ← 这就是"栅栏"
对应源码位置:
| 动作 | 符号 | 位置 |
|---|---|---|
| 收外部事件 | enqueue_external_messages | local_process.py:237-245 |
| 收各步骤事件 | enqueue_step_messages | local_process.py:247-260 |
| 批量取消息 / 判停 | internal_execute 循环体 | local_process.py:203-209 |
| 并发执行 | asyncio.gather(*message_tasks) | local_process.py:218 |
max_supersteps 默认 100(local_process.py:47-49),start() 可以覆盖(local_kernel_process.py:17-52)。它是防死循环的闸门,不是"步骤最多跑 100 个"。