# 019、Semantic Kernel 与微软生态:Planner、Plugin、Memory 深度解析
2026/5/8 1:56:47 网站建设 项目流程

从一次诡异的 Planner 死循环说起

上个月帮一个做工业质检的团队调 Semantic Kernel,他们的 Agent 在调用一个“检测结果汇总”的 Plugin 时,Planner 突然开始疯狂重试同一个步骤。日志里反复出现“Plan execution failed, retrying with adjusted context”,但每次重试的 Plan 一模一样,连参数都没变。我盯着日志看了半小时,最后发现是 Memory 里存了一条过期的工作流缓存,Planner 每次读取 Memory 时都拿到了同一个“最优解”,而这个最优解里调用的 Plugin 接口已经废弃了。

这个坑让我意识到,Semantic Kernel 的三大组件——Planner、Plugin、Memory——表面上是独立模块,实际运行时是深度耦合的。任何一个环节的配置失误,都会让整个 Agent 陷入诡异的“假死”状态。今天这篇笔记,我就从这三个组件的底层逻辑和实战踩坑点展开,不讲官方文档里那些漂亮的架构图,只讲你写代码时真正会撞上的墙。

Plugin:别把 Function 当 API 封装

很多人第一次接触 Semantic Kernel 的 Plugin,会下意识把它当成“给 LLM 调用的 API 封装”。这个理解没错,但太浅了。Plugin 的核心价值不在于“把函数暴露给 AI”,而在于“让 AI 理解这个函数什么时候该用、怎么用、用了会有什么副作用”。

我见过最典型的错误写法是这样的:

[KernelFunction][Description("获取当前时间")]publicstringGetCurrentTime(){returnDateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");}

这段代码能跑,但实际效果很差。因为 LLM 在 Planner 里做决策时,它不光要知道“这个函数能获取时间”,还需要知道“这个时间是什么时区的”“精度到秒还是毫秒”“是否适合用于日志记录”。你给的信息越模糊,Planner 就越容易在错误的场景下调用这个 Plugin。

正确的做法是给足“语义上下文”:

[KernelFunction][Description("获取系统当前本地时间,适用于日志记录和任务调度")][return:Description("格式为 yyyy-MM-dd HH:mm:ss 的本地时间字符串,时区为系统默认时区")]publicstringGetCurrentTime(){// 这里踩过坑:之前直接返回 UTC 时间,导致 Planner 在计算任务截止时间时算错了 8 小时returnDateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");}

别小看这些 Description 和 return 的注释。Semantic Kernel 在序列化 Plugin 元数据时,会把这些信息原封不动地塞进给 LLM 的 Prompt 里。你写得越详细,LLM 的决策就越精准。

还有一个容易被忽略的点:Plugin 的参数类型。尽量用 string、int、bool 这些基础类型,别用复杂对象。因为 LLM 在生成 JSON 参数时,对嵌套对象的理解能力远不如对扁平结构的理解。如果你非要用自定义类,记得在类上加[Description][JsonPropertyDescription],否则 LLM 可能会把字段名理解错。

Planner:它不是万能的调度器

Planner 是 Semantic Kernel 里最容易被神化的组件。很多人以为 Planner 能像人类一样“理解任务、拆解步骤、自动编排”,实际上它只是一个“基于 LLM 的步骤生成器 + 执行引擎”。它的核心逻辑很简单:给你一个目标,你生成一个步骤列表,然后按顺序执行,失败了就重试或回滚。

但就是这个“简单逻辑”,在实际项目中坑最多。

第一个坑:Planner 的上下文窗口限制。默认的 SequentialPlanner 会把所有 Plugin 的元数据、历史对话、Memory 内容全部塞进一个 Prompt 里。如果你的 Plugin 数量超过 20 个,或者 Memory 里存了大量长文本,LLM 的上下文窗口很容易被撑爆。这时候 Planner 会开始“失忆”——它可能只看到了前 10 个 Plugin,然后生成一个残缺的 Plan。

解决方案是手动控制 Plugin 的可见范围:

varplanner=newSequentialPlanner(kernel,newPlannerOptions{// 别这样写:var planner = new SequentialPlanner(kernel); 默认会加载所有 PluginAvailableFunctions=kernel.Plugins.GetFunctionsMetadata().Where(f=>f.PluginName=="DataProcessing"||f.PluginName=="Logging").ToList()});

第二个坑:Planner 的“过度自信”。当 LLM 遇到一个它不确定的任务时,它倾向于“编造”一个步骤,而不是承认自己不会。比如你让 Planner 执行一个“计算圆周率到第 1000 位”的任务,它可能会生成一个“调用 CalculatePi 函数”的步骤,但实际上你的 Plugin 里根本没有这个函数。执行时就会报错,然后 Planner 重试,再报错,陷入死循环。

我现在的做法是:在 Plugin 里加一个“兜底函数”,专门处理 Planner 无法匹配的任务:

[KernelFunction][Description("当其他所有函数都无法满足用户需求时,调用此函数返回错误信息")]publicstringFallbackHandler([Description("用户原始请求")]stringuserRequest){// 这里踩过坑:之前直接返回"无法处理",导致 Planner 反复重试return$"无法处理请求:{userRequest}。请检查 Plugin 配置或提供更具体的指令。";}

然后在 Planner 的配置里,把这个 FallbackHandler 的优先级设到最低,确保它只在其他所有选项都失败时才被调用。

Memory:别把它当数据库用

Semantic Kernel 的 Memory 模块,官方文档里吹得天花乱坠——什么“长期记忆”“语义搜索”“自动摘要”。但实际用起来,它就是一个“带向量索引的键值存储”,只不过索引是基于语义相似度而不是精确匹配。

我见过最离谱的用法是:有人把整个项目的配置文件、数据库 Schema、甚至用户隐私数据都塞进 Memory 里,然后指望 Planner 能“智能地”从中提取需要的信息。结果就是 Memory 的向量索引被大量无关数据污染,Planner 每次查询都返回一堆噪音,最终生成的 Plan 完全跑偏。

Memory 的正确用法是“只存 Agent 需要记住的上下文”,而不是“存所有可能用到的数据”。比如:

  • 用户在当前对话中提到的偏好(“我喜欢用 JSON 格式输出”)
  • 之前任务执行的结果摘要(“上次处理了 100 条记录,耗时 2.3 秒”)
  • 需要跨对话保持的状态(“用户已授权访问数据库”)

这些信息的特点是:体积小、语义明确、对后续决策有直接影响。

还有一个容易被忽略的点:Memory 的过期策略。默认的 SemanticTextMemory 没有自动过期机制,你存进去的数据会永远留在那里。如果用户今天说“我喜欢红色”,明天说“我喜欢蓝色”,Memory 里就会同时存在两条矛盾的信息。Planner 在查询时,如果两条信息的相似度都很高,它可能会随机选择一条,导致行为不一致。

我现在的做法是:每次写入 Memory 时,先删除同类型的旧记录:

// 别这样写:await memory.SaveInformationAsync("UserPreferences", "color", "blue");// 这样会保留旧记录,导致冲突// 正确的做法:先删除再写入awaitmemory.RemoveAsync("UserPreferences","color");awaitmemory.SaveInformationAsync("UserPreferences","color","blue");

三者的协作:一个真实的调试案例

回到文章开头那个死循环问题。当时我排查的步骤是这样的:

  1. 先看 Planner 生成的 Plan 日志,发现每次重试的 Plan 完全一样,说明问题不在 Planner 的生成逻辑,而在“它为什么认为这个 Plan 是正确的”。

  2. 检查 Plugin 的元数据,发现那个废弃接口的 Description 里写着“推荐使用新接口 V2”,但 Planner 在读取 Memory 时,拿到了一条旧的工作流缓存,里面明确写着“处理检测结果时,调用旧接口”。

  3. 问题出在 Memory 的写入逻辑:之前的工作流缓存没有设置过期时间,也没有在接口更新时自动失效。Planner 每次查询 Memory,都优先返回了这条“高相似度”的旧缓存。

  4. 修复方案:在 Memory 的写入逻辑里,给每条缓存加一个“版本号”字段,并在查询时过滤掉过期版本。同时,在 Plugin 的元数据里,明确标注“此接口已废弃,请勿使用”,这样即使 Planner 误调用了,也能在第一步就报错退出,而不是陷入死循环。

这个案例说明:Planner、Plugin、Memory 三者不是独立的,它们通过“语义”这个纽带紧密耦合。任何一个环节的语义信息不准确,都会导致整个系统行为异常。

个人经验性建议

  1. Plugin 的 Description 要写“场景”而不是“功能”。比如“获取当前时间”不如“获取当前本地时间,用于日志记录和任务调度”。LLM 理解场景比理解功能更容易。

  2. Planner 的失败重试次数不要超过 3 次。超过 3 次还失败,说明 Plan 本身有问题,重试只会浪费 Token。不如直接返回错误,让上层逻辑处理。

  3. Memory 的向量维度不要超过 1536。OpenAI 的 text-embedding-ada-002 默认就是 1536 维,你用更高的维度不仅不会提升精度,反而会增加计算开销。

  4. 永远给 Planner 一个“我不知道”的选项。在 Plugin 里加一个 FallbackHandler,或者在 Planner 的 Prompt 里明确告诉 LLM:“如果你不确定如何完成任务,请直接说不知道,不要编造步骤。”

  5. 日志里记录每一步的“决策依据”。不要只记录“调用了哪个函数”,还要记录“为什么调用这个函数”。这样当 Agent 行为异常时,你能快速定位是 Planner 的决策问题,还是 Plugin 的执行问题。

  6. 别迷信“全自动”。Semantic Kernel 的 Planner 再智能,也只是个“半自动”工具。对于关键业务逻辑,我建议手动编写 Plan 模板,让 Planner 只在模板内做参数填充,而不是完全自由发挥。

最后说一句:Semantic Kernel 是个好框架,但它不是银弹。它的设计哲学是“给 LLM 足够的自由度”,但自由度越大,不可控因素就越多。你在用它的同时,必须时刻想着“如果 LLM 在这里犯错了,我的系统会怎样”。只有把容错机制做扎实了,Agent 才能真正落地到生产环境。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询