跳到主要内容

数据截至 (上游 commit 5e1f1fb87d9a)

提示词与内容模型:模板渲染怎么变成一段对话

30 秒导读: 你写一段带 {{$name}} 的文本,SK 要把它变成一串带 role 的对话消息发给模型。 这中间不是一步,是两步:先渲染成一段纯文本,再把这段纯文本当 XML 解析回消息列表。 本章讲清这条管线的每一环、它的内容模型(消息里装什么)、流式片段怎么合并、历史怎么裁剪。

上一章 01-kernel-and-functions.md 讲了「一个 KernelFunction 怎么被调用」。 本章讲的是提示词函数的内部:它调用模型之前,那段文本经历了什么。 工具调用循环和过滤器在 03-function-calling-and-filters.md


1. 这一章讲什么(零基础也能懂)

一句话定义: SK 的提示词子系统 = 一套「模板语言 + 渲染引擎 + 消息内容模型」,负责把 「一段带变量的文本」变成「模型 API 能接受的结构化对话」。

它要解决的问题。 大模型的 chat 接口吃的不是一坨字符串,而是一个列表: [{role: system, ...}, {role: user, ...}]。但人写提示词时最顺手的还是写一段文本。 中间这层落差,就是本章的全部内容。

用起来什么样。 一个真实的 prompt 文件长这样(prompt_template_samples/FunPlugin/Joke/skprompt.txt):

WRITE EXACTLY ONE JOKE or HUMOROUS STORY ABOUT THE TOPIC BELOW
...
Incorporate the style suggestion, if provided: {{$style}}
+++++

{{$input}}
+++++

带 role 的版本长这样(samples/concepts/resources/sample_plugins/parrot.yaml):

name: Parrot
template_format: semantic-kernel
template: |
<message role="user"> Repeat the user message {{$user_message}} in the voice of a pirate and then end with {{$count}} parrot sounds.</message>

注意第二个例子里的 <message role="user"> —— 它不是 SK 模板语法,而是渲染完成之后 才被当 XML 读出来的标记。这就是本章最重要的那句直觉。

一句话心智模型: 把渲染结果那段文本当成中间协议,像 HTTP 里的 wire format —— 上游(三种模板引擎)都往它上面吐,下游(ChatHistory)只认它,谁也不用认识谁。


2. 顶层全景:一条模板到对话的四段管线

先看整条路。从左到右,每一步的产物类型都写在下面:

①渲染 ②再解析 ③调模型 ④回填
模板文本 ────────> 一段纯文本 ────────> ChatHistory ────> ChatMessageContent
(template) (rendered_prompt) (消息列表) (可能带 items)
│ │ │ │
KernelPromptTemplate 这段文本被当 XML from_rendered_prompt 内容模型
Handlebars/Jinja2 里面可以有 负责切 role KernelContent 家族
三选一 <message role=..>

怎么读这张图: ①和②之间是完全解耦的 —— ① 的三种实现互不知道对方,② 也不知道 文本是谁渲染出来的。它们只靠「那段文本长什么样」达成约定。

四段各自的落点:

阶段干什么入口符号文件
① 渲染变量替换 + 模板内函数调用PromptTemplateBase.renderpython/semantic_kernel/prompt_template/prompt_template_base.py:21-24
② 再解析把文本当 XML 切成 system/user/assistantChatHistory.from_rendered_promptpython/semantic_kernel/contents/chat_history.py:330-377
③ 调模型把 ChatHistory 交给 chat 服务KernelFunctionFromPrompt._invoke_internalpython/semantic_kernel/functions/kernel_function_from_prompt.py:170-197
④ 内容模型消息里装的多态内容KernelContentpython/semantic_kernel/contents/kernel_content.py:13-41

这条串起来的代码只有几行,值得直接看。_invoke_internal 拿到渲染结果后立刻二次解析 (kernel_function_from_prompt.py:178):

chat_history = ChatHistory.from_rendered_prompt(prompt_render_result.rendered_prompt)

流式版本在 :251 做一模一样的事。而如果选中的服务是文本补全而非 chat,那段文本 就不走 XML 解析,直接 unescape 后当 prompt 用(:202)—— 同一段文本,两种消费方式。


3. 三种模板格式并存,由一个字段选

要解决的小问题: SK 有自己的模板语法,但用户可能更熟 Handlebars 或 Jinja2。 与其二选一,SK 让三者共存。

选择器就是 PromptTemplateConfig.template_format 一个字段 (python/semantic_kernel/prompt_template/prompt_template_config.py:41),取值被 Literal 锁死在三个 (python/semantic_kernel/prompt_template/const.py:9)。

映射表是一个字面 dict(python/semantic_kernel/functions/kernel_function.py:55-59,TEMPLATE_FORMAT_MAP):

template_format实现类底层引擎模板里调函数写成
semantic-kernel(默认)KernelPromptTemplateSK 自研两级 tokenizer{{plugin.func $arg}}
handlebarsHandlebarsPromptTemplatepybars.Compiler{{plugin-func arg=x}}
jinja2Jinja2PromptTemplateImmutableSandboxedEnvironment{{ plugin_func(arg=x) }}

三者的共同基类 PromptTemplateBase 只规定一个抽象方法 render,外加共享的转义逻辑 (prompt_template_base.py:15-24)。构造时各自校验 format 对不对,对不上直接 ValueError (例如 kernel_prompt_template.py:32-38)。

外挂引擎怎么调到 KernelFunction。 Handlebars 和 Jinja2 本身不认识 SK 的插件,所以 SK 在渲染前 把 kernel 里所有函数包成 helper 注入进去:

  • Handlebars:handlebars_prompt_template.py:88-99,helper 名就是 fully_qualified_name(带连字符)。
  • Jinja2:jinja2_prompt_template.py:91-102,连字符被换成下划线(:93),因为 Python 标识符不允许 -

包装函数本体是 create_template_helper_from_function (python/semantic_kernel/prompt_template/utils/template_function_helpers.py:26-67)。 它有同步/异步两个版本;同步版为了在模板里 await 不了而 asyncio.run,还得先打 nest_asyncio.apply() 补丁(:81-82)—— 这是个真实存在的妥协,Jinja2 走异步版就干净得多。

两个引擎还各带一套内建 helper,其中 message / messages 专门负责把 ChatMessageContentChatHistory 吐成 XML(utils/handlebars_system_helpers.py:29-55utils/jinja2_system_helpers.py:27-29)—— 又一次印证:所有引擎最终都往同一种 XML 文本上收敛


4. SK 自有模板语言:两级词法分析

这节讲 semantic-kernel 格式自己的实现,是本章最有工程味的一段。

4.1 为什么切两次

小问题: {{ time.now $tz format='24h' }} 里既有「块边界」又有「块内部结构」。 一个正则想同时管两件事会很脆(引号里出现 }} 怎么办)。

思路: 分层。外层只关心 {{}} 在哪断开,内层只关心一个块里有几个 token。

"Hi {{$name}}, it is {{time.now $tz format='24h'}}!"

├─── TemplateTokenizer ── 只找 {{ }} 边界,引号内的括号不算数
│ └──> [TextBlock "Hi "] [VarBlock $name] [TextBlock ", it is "]
│ [CodeBlock ...] [TextBlock "!"]

└─── CodeTokenizer ── 只切 CodeBlock 内部,按空格分 token
└──> [FunctionIdBlock time.now] [VarBlock $tz] [NamedArgBlock format='24h']

两层的文法都以 BNF 注释形式写在源码里 (python/semantic_kernel/template_engine/template_tokenizer.py:16-23python/semantic_kernel/template_engine/code_tokenizer.py:17-24,另有汇总版 python/semantic_kernel/template_engine/README.md)。

外层的关键状态机TemplateTokenizer.tokenize (template_tokenizer.py:27-107):它逐字符扫,用 inside_text_value 标志记住「现在在引号里」, 在引号里就不认 }}(:73-92);反斜杠转义则直接跳过下一个字符(:76-82)。

内层CodeTokenizer.tokenize(code_tokenizer.py:28-157):第一个字符决定 token 类型 ——$ 开头是变量、引号开头是字面值、其余是函数 id(:61-71);之后遇空白就收一个 token (:101-120)。它对语法很挑:token 之间没空格直接抛 CodeBlockSyntaxError ("Tokens must be separated by one space least",:127)。

4.2 六种 Block

所有块共享基类 Block(只有 content 一个字段 + 自动 strip, python/semantic_kernel/template_engine/blocks/block.py:14-24),类型枚举见 blocks/block_types.py:6-15

Block长什么样渲染成什么文件
TextBlock模板里的普通文字原样blocks/text_block.py:18(render:57)
VarBlock$namestr(arguments[name]),缺失则空串+告警blocks/var_block.py:25(render:68-84)
ValBlock'字面值' / "字面值"去掉引号的内容blocks/val_block.py:24(render:72-74)
FunctionIdBlockplugin.funcfunc自身文本(实际由 CodeBlock 消费)blocks/function_id_block.py:24
NamedArgBlockarg=$var / arg='v'参数值blocks/named_arg_block.py:26(render:90-98)
CodeBlock上面几种的组合异步函数调用结果blocks/code_block.py:27

每种块的合法形状都由一条正则定死,方便你核对:VAR_BLOCK_REGEX(var_block.py:20)、 VAL_BLOCK_REGEX(val_block.py:19)、FUNCTION_ID_BLOCK_REGEX(function_id_block.py:19)、 NAMED_ARG_REGEX(named_arg_block.py:21)。

一个降级细节: 如果 {{ }} 里只有一个变量或字面值,_extract_blocks 直接返回那个块本身、 不包一层 CodeBlock(template_tokenizer.py:149-154)。这不只是省一层对象 —— 下一节会看到 它决定了要不要二次 HTML 转义。

4.3 渲染:同步块直接渲,代码块异步渲

KernelPromptTemplate.render 只是转发(kernel_prompt_template.py:79-94),真身是 render_blocks(:96-130)。它靠两个 runtime_checkable Protocol 分流:

  • TextRenderer(有同步 render,template_engine/protocols/text_renderer.py:12)→ 直接调,不再转义
  • CodeRenderer(有异步 render_code,protocols/code_renderer.py:12)→ await,默认转义输出

关键那几行(kernel_prompt_template.py:117-128):

if isinstance(block, TextRenderer):
rendered_blocks.append(block.render(kernel, arguments))
continue
if isinstance(block, CodeRenderer):
...
rendered_blocks.append(rendered if allow_unsafe_function_output else escape(rendered))

最后 "".join(...) 出一段纯文本。整个渲染过程不产生任何结构,只产生字符串。

quick_render(:132-151)是个静态快捷方式:遇到任何 CodeBlock 直接拒绝,只支持文本+变量, 用于不需要 kernel 的场合。

4.4 模板里直接调别的 KernelFunction

这是 SK 模板语言最独特的能力:{{time.date}} 会真的去执行插件函数。

路径: CodeBlock.render_code(code_block.py:105-115)看第一个 token 是不是 FunctionIdBlock,是就走 _render_function_call(:117-137):

  1. kernel.get_function(plugin_name, function_name) 取函数,取不到抛 CodeBlockRenderException(:122-126);
  2. copy(arguments) 拿一份参数副本,避免污染外层(:128);
  3. _enrich_function_arguments 把模板里写的实参塞进去(:139-173);
  4. await function.invoke(...),结果 str() 后返回(:132-137)。

参数绑定规则有点讲究(_enrich_function_arguments:150-171):第一个位置参数按函数元数据的 第一个形参名绑定,其余必须是具名参数按名绑定。而且 VarBlock 走的是 get_value (var_block.py:86-99)拿原始值而不是 render 拿字符串 —— 这样传 int 进去还是 int, 不会被字符串化。

参数形状的合法性在构造时就检查完了,不等到渲染:CodeBlock.check_tokens (code_block.py:67-103)规定第一个 token 不能是具名参数、第二个必须是值/变量/具名参数、 第三个起必须全是具名参数。语法错误在加载模板时就炸,不会拖到运行时。

看一眼真实用法(python/samples/concepts/prompt_templates/template_language.py):

function_definition = """
Today is: {{time.date}}
Current time is: {{time.time}}
...
"""

4.5 变量的自动登记

模板里出现过的变量会自动补进 input_variables,不用手写声明。这发生在 KernelPromptTemplate.model_post_init(kernel_prompt_template.py:40-64):遍历所有块, 把 VarBlockCodeBlock 里的 VarBlock、以及 NamedArgBlock.variable 的名字都收集起来, 已声明的(按小写比较)跳过。

这件事的下游意义:get_kernel_parameter_metadata(prompt_template_config.py:79-90) 把这些变量变成函数的形参元数据 —— 也就是说,模板里写了 {{$topic}},这个提示词函数就自动 有了一个叫 topic 的参数


5. 安全阀:默认转义,显式开洞

这节讲一个容易踩的坑,也是理解「为什么渲染结果里能有 <message>」的钥匙。

问题: 模板作者写的 <message role="user"> 应该生效;但用户传进来的变量值里如果也有 <message>,那就是提示词注入。两者都是同一段文本里的尖括号,怎么区分?

SK 的答案:按来源区分

内容来源默认处理依据
模板里手写的文字(TextBlock)原样输出kernel_prompt_template.py:118-120 不转义
传入的参数值(VarBlock)进入渲染前就 HTML 转义prompt_template_base.py:26-48 _get_trusted_arguments
模板内函数调用的返回值渲染后转义kernel_prompt_template.py:127

所以 <&lt; 的动作发生在参数进门那一刻,而不是最后统一处理。三级开关都能关掉它: 整个模板对象的 allow_dangerously_set_content、config 上的同名字段、以及单个变量上的 InputVariable.allow_dangerously_set_content(prompt_template/input_variable.py:26)。

坑在这里: 非字符串的复杂对象根本不让传_get_encoded_value_or_default (prompt_template_base.py:62-94)只放行「白名单安全类型」——int / float / bool / bytes / datetime / timedelta / UUID / Enum / None(_is_safe_type,:96-126),其余一律抛 NotImplementedError。这意味着想把一个 ChatHistory 对象塞进 {{$chat_history}}, 必须显式开 allow_dangerously_set_content。测试直接锁死了这个行为 (python/tests/unit/prompt_template/test_handlebars_prompt_template.py:467-470, 以及 tests/unit/contents/test_chat_history.py:377-380 里所有传 history 的用例都开了这个开关)。

转义完的文本进到第 6 节会被 unescape 回来 —— 转义只是为了熬过 XML 解析那一关, 不改变最终发给模型的字面内容。


6. 再解析:一段文本怎么变回 system/user/assistant

入口: ChatHistory.from_rendered_prompt(python/semantic_kernel/contents/chat_history.py:330-377)。 它是整条管线的枢纽,只有 47 行,建议直接读。

第一步是个小花招: 把整段文本包一层假根标签再交给 XML 解析器(:344):

xml_prompt = XML(text=f"<{prompt_tag}>{prompt}</{prompt_tag}>")

用的是 defusedxml(:10),防 XML 炸弹。解析失败就整段当一条 user 消息(:345-347)—— 所以不带任何标签的普通提示词也能正常工作,不需要用户懂 XML。

解析成功后按下面这张图走:

┌─ 根标签前的散文本 ──────────> SYSTEM 消息 (:348-349)

<root>...</root>┼─ <message ...> ──────> ChatMessageContent.from_element (:351-352)

├─ <chat_history> ──────> 展开,逐条 from_element (:353-355)

└─ 其它未知标签(<p> 等)──────> 序列化回文本,并回上一条消息 (:356-372)

每个已知标签的 tail 文本(标签之后、下个标签之前)──> 新的 USER 消息 (:373-374)
收尾:如果全程只解析出一条 SYSTEM 消息 ──────────> 把它降级成 USER (:375-376)

三个值得单独说的设计:

(a) 开头的散文本当 system。 模板写 你是助手。<message role="user">...,前半句自动变 system 提示词。这就是为什么 SK 的提示词可以「不写 role 也能有 role」。

(b) 未知标签原样并回上一条,而不是丢掉。 这是为了让提示词里能安全出现 HTML (:356-372)。实现上先摘掉 tailtostring、再把 tail 单独接回同一条消息 (:360-371),避免 <p> 之后的文字被误判成新的一条 user 消息。测试锁了这个行为: tests/unit/contents/test_chat_history.py:616-635(<p>/<div> 都要留在同一条消息里)。

(c) 单条 system 降级成 user。 只有一条消息且是 system 时,role 被改成 user(:375-376)—— 因为很多模型不接受「只有 system、没有 user」的请求。

反方向是对称的:ChatHistory.to_prompt / __str__ 把消息列表吐成 <chat_history> XML (:312-317:280-285),ChatMessageContent.to_prompt 吐单条(chat_message_content.py:294-301)。 所以「history 塞回模板 → 渲染 → 再解析出来」是个闭环,test_template_two_histories (tests/unit/contents/test_chat_history.py:421-448)验证的正是这个往返。


7. 内容模型:一条消息里到底装了什么

7.1 三层结构

KernelContent (抽象基类:inner_content / ai_model_id / metadata)

├── ChatMessageContent 一条消息 = role + items[]
│ │
│ └── items: TextContent | FunctionCallContent | FunctionResultContent
│ | ImageContent | AudioContent | ReasoningContent
│ | AnnotationContent | ...

└── 上面这些 item 类型自己也都是 KernelContent

KernelContent(python/semantic_kernel/contents/kernel_content.py:13-41)只强制四个抽象方法: __str__to_elementfrom_elementto_dict所有内容类型必须能双向 XML、单向 dict —— XML 面向第 6 节那条管线,dict 面向模型 API 的 JSON。

注意 inner_contentField(exclude=True)(:18):原始 SDK 响应对象不参与序列化, 想留就自己存。

7.2 items 表

ChatMessageContent.items 是一个带判别器的联合类型(chat_message_content.py:56-69), 判别字段是 content_type(contents/const.py:18),所以 pydantic 能从 JSON 精确还原子类。

item 类型XML tagto_dict() 形状文件
TextContenttext{"type":"text","text":...}contents/text_content.py:57-59
FunctionCallContentfunction_call{"id":..,"type":"function","function":{...}}contents/function_call_content.py:222-225
FunctionResultContentfunction_result{"tool_call_id":..,"content":..}contents/function_result_content.py:173-178
ImageContentimage{"type":"image_url","image_url":{...}}contents/image_content.py:103-105
AudioContentaudio{"type":"audio_url","audio_url":{...}}contents/audio_content.py:86-88
ReasoningContentreasoning{"type":"reasoning","text":..}contents/reasoning_content.py:57-59
AnnotationContentannotation压成一条 {"type":"text",...}contents/annotation_content.py:87-93

tag 常量集中在 contents/const.py:5-18,tag→类的反查表是 TAG_CONTENT_MAP (chat_message_content.py:44-54)—— 第 6 节 from_element 就是靠它认标签的。

7.3 content 是个便利视图,不是字段

看着像字段,其实是 property:读的时候返回第一个 TextContent 的文本 (chat_message_content.py:199-205),写的时候改第一个 TextContent、没有就追加一个(:207-229)。 构造函数也做了同样的糖:传 content="..." 会被包成一个 TextContent 放进 items (:175-186)。

所以 str(msg) 只给你文本部分,函数调用、图片这些不会出现在里面。这是很容易踩的一脚。

7.4 双向序列化的两个不对称处

to_element(:235-254)只把 role / name / encoding / finish_reason / ai_model_id 写成属性, 且只写显式设置过的字段(model_fields_set);items 逐个 to_element 插进去。

反过来 from_element(:256-292)有两处收敛:

  • 未知子标签当文本:不在 TAG_CONTENT_MAP 里的标签会被原样序列化成 TextContent(:273-276)—— 和第 6 节的处理一致,主打一个「不丢内容」。
  • 全是文本就合并:若 items 全是 TextContent,直接拼成一个 content 字符串(:279-282)。 意味着一条消息里的多个 <text> 会在往返之后合并成一个

to_dict(:303-321)则是面向 OpenAI 风格 API 的转换:assistant + 有函数调用 → 出 tool_calls 数组;role 是 tool → 补 tool_call_id。这是内容模型和具体厂商协议之间唯一的耦合点。


8. 流式内容:用加号把碎片拼回整体

8.1 直觉

流式响应是一串碎片,每片都是一个 StreamingChatMessageContent。SK 的做法很直接: 给它定义 __add__,然后 reduce 一路加过去

# 示意,非源码
full = chunk1 + chunk2 + chunk3 # 每个 chunk 都是 StreamingChatMessageContent
# 等价于:reduce(lambda x, y: x + y, chunks)

8.2 相加的规矩

StreamingChatMessageContent.__add__(python/semantic_kernel/contents/streaming_chat_message_content.py:171-206) 先做四道校验,任何一道不过就抛 ContentAdditionException:

必须相同的字段为什么
choice_index不同候选回复不能混:186-187
ai_model_id不同模型的输出不能拼:188-189
encoding编码不一致拼出来是乱码:190-191
role两边都有 role 时才比:192-193

choice_index 这个字段来自 StreamingContentMixin (contents/streaming_content_mixin.py:19-32)—— 它是所有流式内容的共同标识, "n 个候选并行流式返回"时全靠它把碎片分堆。

8.3 items 怎么合并

_merge_items_lists(streaming_content_mixin.py:34-59)的逻辑是同类型相加、加不动就追加: 对每个新 item,在已有列表里找同类型且有 __add__ 的,试着加;抛 ValueError / ContentAdditionException 就记 debug 日志继续找;都没成功就当新 item 挂上去。

于是每种 item 自己决定怎么合:

  • StreamingTextContent.__add__ 文本直接拼(contents/streaming_text_content.py:31-55)。
  • FunctionCallContent.__add__ 合并参数(contents/function_call_content.py:108-132): 两边都是 dict 就 merge、都是字符串就拼接、一边 dict 一边字符串直接抛 (combine_arguments,:134-149)。字符串拼接这条正是流式工具调用的关键 —— 模型把 JSON 参数切成好几片吐出来,靠它拼回完整 JSON,最后由 parse_arguments(:151-169)解析。

inner_content 则统一被收成 list(_merge_inner_contents,streaming_content_mixin.py:61-83), 原始响应片段一个不丢。

8.4 reduce 用在哪

最重要的一处在自动函数调用循环里 (python/semantic_kernel/connectors/ai/chat_completion_client_base.py:279):

full_completion: StreamingChatMessageContent = reduce(lambda x, y: x + y, all_messages)

上下文很说明问题:循环先把这一轮所有片段收进 all_messages(:261-271),发现里面有 FunctionCallContent 就必须先合成完整消息,才能拿到完整的函数名和参数去执行(:280-281)。 换句话说,流式场景下工具调用能成立,全靠这个 __add__。这个循环本身在 03-function-calling-and-filters.md 展开。

同样的 reduce 还出现在遥测装饰器(python/semantic_kernel/utils/telemetry/model_diagnostics/decorators.py:194) 和 Bedrock agent(python/semantic_kernel/agents/bedrock/bedrock_agent.py:543)里。


9. 历史裁剪:上下文放不下的时候

ChatHistory 会越长越长。contents/history_reducer/ 提供两种收缩策略,它们都继承 ChatHistory 本身(chat_history_reducer.py:21-38),所以任何吃 ChatHistory 的地方都能直接换上去。

ChatHistoryTruncationReducerChatHistorySummarizationReducer
做法掐掉旧消息旧消息喂给模型总结成一条
需要 LLM是(service: ChatCompletionClientBase)
关键字段target_count / threshold_count另加 use_single_summary / fail_on_error
文件chat_history_truncation_reducer.py:39-85chat_history_summarization_reducer.py:85-179

共享的三条纪律(两个 reducer 的 reduce() 都遵守):

  1. 没超阈值就不动。 len(history) <= target_count + threshold_count 直接返回 None (truncation:42-44 / summarization:88-89)。threshold_count 是迟滞带,防止每加一条就抖动一次。
  2. 不拆散函数调用和它的结果。 切点由 locate_safe_reduction_index (contents/history_reducer/chat_history_reducer_utils.py:64-140)算:它从朴素切点往前退, 遇到 call/result 对就继续退(:117-129),还会在阈值窗口里优先找一条 user 消息当切点 (:135-138)—— 让保留下来的部分从一个完整回合开始。
  3. system 消息永远保命。 找到第一条 system/developer 消息,若它会被切掉就在结果前面补回来 (truncation:51-55:79-82;summarization:98-106:165-169)。用的是 is 身份比较 而不是 ==,避免内容相同的两条被误判(注释写在 truncation:80)。

总结版还多两步:SUMMARY_METADATA_KEY(chat_history_reducer_utils.py:15,值 "__summary__") 给生成的摘要打标(summarization:156),下次靠 locate_summarization_boundary(utils:49) 认出「哪些是上轮的摘要」;摘要 prompt 本身是硬编码的 DEFAULT_SUMMARIZATION_PROMPT(summarization:35-49),里面明确禁止模型"批评、纠正、推测"。

auto_reduce=True 时,add_message_async 每加一条就触发一次 reduce (chat_history_reducer.py:40-63)。注意同步的 add_message 不会触发 —— 想自动裁剪必须走 async 那条。


10. 提示词的持久化形态:yaml 与目录

提示词最终要能从磁盘加载。SK 支持两种布局,入口都在 KernelFunctionFromPrompt 上。

布局长什么样入口备注
单 YAML 文件一个 .yaml,字段直接对应 PromptTemplateConfigfrom_yaml(kernel_function_from_prompt.py:334-357)函数名取 yaml 里的 name
目录<函数名>/skprompt.txt + <函数名>/config.jsonfrom_directory(:359-416)函数名取目录名(:397)

两个文件名是常量 PROMPT_FILE_NAME / CONFIG_FILE_NAME(:44-45),缺任何一个都会给出 分别的错误信息(:381-395)——「prompt 在但 config 不在」和反过来是两条不同的提示。

from_yaml 的实现很薄:yaml.safe_load → 直接喂给 PromptTemplateConfig(**data) → 构造函数 (:338-357)。也就是说 yaml 的字段名 = PromptTemplateConfig 的字段名,想知道能写什么, 去看 prompt_template_config.py:38-44 就行。

目录布局的真实样例在克隆根的 prompt_template_samples/,按 <Plugin>/<Function>/ 两级组织:

prompt_template_samples/
FunPlugin/
Joke/ skprompt.txt + config.json
Excuses/
Limerick/
SummarizePlugin/ ...

config.json 里主要放 descriptionexecution_settingsinput_variables (见 prompt_template_samples/FunPlugin/Joke/config.json),模板正文单独放在 skprompt.txt

加载后还有一步默认值回填: update_arguments_with_defaults (kernel_function_from_prompt.py:328-332)在渲染前把缺失参数补上 config 里的 default, 但会跳过 None / "" / False / 0 这几个"假空值"—— 也就是说 default 设成 0不生效的。


11. 巧妙之处(可以借鉴的)

① 用「一段文本」当子系统间的协议。 三种模板引擎、YAML/目录两种加载方式,最终都收敛到 同一种带 <message> 的文本上,再由唯一的 from_rendered_prompt 解析 (chat_history.py:330-377)。加第四种模板引擎不需要碰下游一行代码。

② 解析失败就降级成纯文本,不报错。 from_rendered_prompt:345-347 把 XML 解析异常 降为 logger.info 并整段当 user 消息。让「不懂 XML 的用户」和「要用 role 的用户」共用一条路径, 这个取舍很值。

③ 未知标签不丢,原样并回上一条。 chat_history.py:356-372chat_message_content.py:273-276 两处都这么干。代价是 role 归属可能不直观,收益是提示词里 写 HTML 不会静默丢内容。

④ 转义按来源分级,而不是一刀切。 模板作者的文字可信、参数值不可信、函数输出不可信 (prompt_template_base.py:26-48kernel_prompt_template.py:127),再给三级开关放行。 比"全转义"或"全不转义"都精确。

⑤ 复杂类型默认拒收。 _is_safe_type(prompt_template_base.py:96-126)用白名单而非黑名单, 把「不知道怎么安全字符串化的对象」挡在门外,逼你显式表态。

⑥ 合并逻辑放在数据类型上,而不是循环里。 每个 content 类型自己实现 __add__, 消费方只写 reduce(lambda x, y: x + y, chunks)(chat_completion_client_base.py:279)。 新增内容类型不用改任何一处流式循环。

⑦ 模板语法错误在加载期就炸。 CodeBlock.check_tokens(code_block.py:67-103)是 pydantic 的 field validator,构造模板对象时跑,不等到第一次渲染 —— 错的 prompt 部署不上去。


12. 边界与局限(诚实的部分)

  • SK 自有模板语言没有控制流。 从 BNF(template_engine/README.md)可见,只有文本、变量、 值、函数调用四种东西 —— 没有 if、没有 loop。要循环渲染消息列表,只能换 Handlebars ({{#each history}})或 Jinja2。
  • 同步 helper 靠 nest_asyncio 打补丁。 template_function_helpers.py:81-82 在已有事件循环里 调 asyncio.run,需要打全局补丁才行。Handlebars 路径逃不掉这个;Jinja2 有原生异步版 (:116-141)。
  • {{$chat_history}} 这类复杂对象必须开危险开关。 见第 5 节,默认配置下会抛 NotImplementedError,不是 bug 是设计。
  • XML 是硬约定。 提示词里出现裸露的 <& 而又没被转义,会让整段降级成一条 user 消息 (静默,只有 info 级日志)。
  • str(ChatMessageContent) 只给文本。 content property 只找第一个 TextContent (chat_message_content.py:199-205),函数调用/图片不在里面。
  • 两个 history reducer 都标了 @experimental(chat_history_reducer.py:20 等),API 可能变。
  • auto_reduce 只对 async 加消息生效(chat_history_reducer.py:40-63)。
  • default 值为 0 不会被回填(kernel_function_from_prompt.py:331)。

13. 代码地图(导航索引)

路径相对克隆根。行号 as-of sourceCommit,符号名更抗漂移,优先用符号名 grep。

主题文件符号
模板基类 / 转义策略python/semantic_kernel/prompt_template/prompt_template_base.pyPromptTemplateBase_get_trusted_arguments_get_encoded_value_or_default_is_safe_type
模板配置 / 格式选择python/semantic_kernel/prompt_template/prompt_template_config.pyPromptTemplateConfig.template_formatget_kernel_parameter_metadatafrom_json
三种格式常量python/semantic_kernel/prompt_template/const.pyTEMPLATE_FORMAT_TYPESKERNEL_TEMPLATE_FORMAT_NAME
格式 → 实现类映射python/semantic_kernel/functions/kernel_function.pyTEMPLATE_FORMAT_MAP
SK 模板渲染主体python/semantic_kernel/prompt_template/kernel_prompt_template.pyKernelPromptTemplate.renderrender_blocksextract_blocksquick_render
Handlebars 实现python/semantic_kernel/prompt_template/handlebars_prompt_template.pyHandlebarsPromptTemplate.render
Jinja2 实现(沙箱)python/semantic_kernel/prompt_template/jinja2_prompt_template.pyJinja2PromptTemplate.renderImmutableSandboxedEnvironment
函数包成模板 helperpython/semantic_kernel/prompt_template/utils/template_function_helpers.pycreate_template_helper_from_function
内建 helper(含 message)python/semantic_kernel/prompt_template/utils/handlebars_system_helpers.pyHANDLEBAR_SYSTEM_HELPERS_message_messages
外层分词python/semantic_kernel/template_engine/template_tokenizer.pyTemplateTokenizer.tokenize_extract_blocks
内层分词python/semantic_kernel/template_engine/code_tokenizer.pyCodeTokenizer.tokenize
块基类 / 类型枚举python/semantic_kernel/template_engine/blocks/block.pyblock_types.pyBlockBlockTypes
模板内函数调用python/semantic_kernel/template_engine/blocks/code_block.pyCodeBlock.render_code_render_function_call_enrich_function_argumentscheck_tokens
变量块python/semantic_kernel/template_engine/blocks/var_block.pyVarBlock.renderVarBlock.get_valueVAR_BLOCK_REGEX
具名参数块python/semantic_kernel/template_engine/blocks/named_arg_block.pyNamedArgBlockNAMED_ARG_REGEX
渲染协议(分流依据)python/semantic_kernel/template_engine/protocols/TextRendererCodeRenderer
文本 → 对话(枢纽)python/semantic_kernel/contents/chat_history.pyChatHistory.from_rendered_promptto_prompt
一条消息python/semantic_kernel/contents/chat_message_content.pyChatMessageContentto_elementfrom_elementto_dictTAG_CONTENT_MAPCMC_ITEM_TYPES
内容基类python/semantic_kernel/contents/kernel_content.pyKernelContent
XML tag 常量python/semantic_kernel/contents/const.pyCHAT_MESSAGE_CONTENT_TAGContentTypesDISCRIMINATOR_FIELD
流式消息相加python/semantic_kernel/contents/streaming_chat_message_content.pyStreamingChatMessageContent.__add__
流式合并公共逻辑python/semantic_kernel/contents/streaming_content_mixin.pyStreamingContentMixin_merge_items_lists_merge_inner_contents
工具调用参数拼接python/semantic_kernel/contents/function_call_content.pyFunctionCallContent.__add__combine_argumentsparse_arguments
reduce 的使用现场python/semantic_kernel/connectors/ai/chat_completion_client_base.py第 279 行 reduce(lambda x, y: x + y, all_messages)
裁剪基类python/semantic_kernel/contents/history_reducer/chat_history_reducer.pyChatHistoryReduceradd_message_async
截断策略python/semantic_kernel/contents/history_reducer/chat_history_truncation_reducer.pyChatHistoryTruncationReducer.reduce
摘要策略python/semantic_kernel/contents/history_reducer/chat_history_summarization_reducer.pyChatHistorySummarizationReducer.reduceDEFAULT_SUMMARIZATION_PROMPT
安全切点算法python/semantic_kernel/contents/history_reducer/chat_history_reducer_utils.pylocate_safe_reduction_indexlocate_summarization_boundarySUMMARY_METADATA_KEY
提示词函数 / 持久化python/semantic_kernel/functions/kernel_function_from_prompt.pyfrom_yamlfrom_directory_render_prompt_invoke_internalPROMPT_FILE_NAME
目录布局样例prompt_template_samples/FunPlugin/Joke/skprompt.txtconfig.json
YAML 样例python/samples/concepts/resources/sample_plugins/parrot.yaml

继续读: 渲染出的 ChatHistory 交给模型之后会发生什么 —— 自动函数调用循环、 以及包住渲染这一步的 prompt-render 过滤器,见 03-function-calling-and-filters.md。 本章的内容模型在 agent 层怎么被线程复用,见 04-agents-and-threads.md