Day 07 / 共 20 天 · 阶段2 模型运行时

Prompt 构造与 LLM 生成:模型嘴边那句话是怎么拼出来的

Day05/06 解决了"怎么调模型";今天解决"喂给模型的话从哪来"。主角是 core/prompt/(把配置变成 PromptMessage 列表)和 core/llm_generator/(Dify 自己怎么用 LLM 干杂活)。弄清三件事:①"简单模式"(一段系统提示词)和"高级模式"(手工编排多条消息)两条流水线怎么分工;②系统提示词、变量、上下文、历史对话、用户问题,是怎么按顺序拼成消息列表、又怎么在 token 预算内塞历史的;③Dify 自动起对话标题、生成 JSON Schema 这些"AI 辅助"功能,本质上就是它自己当用户去调 invoke_llm

📍 你在 20 天里的位置(阶段2:模型运行时 · D05-07)
D05 模型管理 D06 Provider/实体 D07 Prompt/生成 D08 工作流总览 D09 节点体系 D10 图执行引擎 S4 RAG S6 收官
💡 先用两个类比兜住今天 类比一:PromptTransform餐厅后厨的"备菜台"——顾客点单(用户问题)、菜谱(系统提示词模板)、食材(变量/上下文)、常客口味记录(历史对话),全在这里按固定顺序装盘,最后端出一盘标准化的"菜"(PromptMessage 列表)交给厨师(模型)。Simple 是套餐(照菜谱一键出菜),Advanced 是自助摆盘(你决定每道菜的位置)。类比二:token 预算行李箱限重——系统提示词和用户问题是"必带物品"先占位,剩下的重量才用来塞"历史对话"这类可选行李;塞不下的旧对话就被丢掉。_calculate_rest_token 就是那台称。
L01

痛点:给模型的那段话,不是一个字符串

🤔 痛点Day05 的 invoke_llm(prompt_messages, ...) 收的是一个 PromptMessage 列表,不是一段文本。可用户在 Dify 界面里只填了一个"系统提示词"(比如"你是客服,用中文回答")加几个变量。中间的落差是:怎么把"提示词模板 + 变量替换 + 检索到的上下文 + 前几轮对话 + 这轮用户问题",按对话模型 / 补全模型各自的格式,拼成一个规范的消息列表?而且历史对话可能很长,得在模型的 token 上限内裁剪。这些若散落在各个 App Runner 里,会重复且易错。
💡 本质:把"配置 → 消息列表"抽成一个可复用的变换器Dify 用 PromptTransformapi/core/prompt/prompt_transform.py:11)作基类,派生两个变换器:SimplePromptTransform(面向"傻瓜模式",用内置菜谱)和 AdvancedPromptTransform(面向"高级模式",用户手工编排每条消息)。它们的输出统一是 list[PromptMessage],正好对接 Day05 的 invoke_llm。Day04 的 Runner 里那句 "organize_prompt_messages" 干的就是调它们。
L02

两条流水线:Simple 与 Advanced 怎么分工

维度SimplePromptTransformAdvancedPromptTransform
面向基础编排(一段系统提示词 + 变量)高级编排(用户手排多条消息)
输入模板内置 JSON 菜谱(prompt_templates/*.json用户填的 ChatModelMessage 列表 或 补全模板
入口get_prompt simple_prompt_transform.py:48get_prompt advanced_prompt_transform.py:37
共同基类PromptTransform(管历史对话 + token 预算),prompt_transform.py:11

两者都会先分"模型是对话式(chat)还是补全式(completion)"再走不同分支。Simple 的入口就是个二分派(api/core/prompt/simple_prompt_transform.py:48):

# api/core/prompt/simple_prompt_transform.py:48
def get_prompt(self, app_mode, prompt_template_entity, inputs, query, files,
               context, memory, model_config, ...):
    inputs = {key: str(value) for key, value in inputs.items()}   # 变量统一转字符串
    model_mode = ModelMode(model_config.mode)
    if model_mode == ModelMode.CHAT:                               # ① 对话模型 → 消息列表
        prompt_messages, stops = self._get_chat_model_prompt_messages(
            app_mode=app_mode, pre_prompt=prompt_template_entity.simple_prompt_template or "",
            inputs=inputs, query=query, files=files, context=context,
            memory=memory, model_config=model_config, ...)
    else:                                                          # ② 补全模型 → 单段长文本
        prompt_messages, stops = self._get_completion_model_prompt_messages(...)
    return prompt_messages, stops
大白话对话模型(gpt-4o 这类)吃的是"一条条消息"(system/user/assistant 轮流);老式补全模型(text-davinci 这类)吃的是"一大段拼好的文本"。同一份配置,得按模型口味装两种盘——这就是为什么每个 transform 里都成对出现 _get_chat_..._get_completion_...。今天我们主要看对话分支(现在几乎都是对话模型)。
L03

SimplePromptTransform:对话分支走一遍

看对话分支 _get_chat_model_prompt_messagesapi/core/prompt/simple_prompt_transform.py:188)——注意消息的拼装顺序

# api/core/prompt/simple_prompt_transform.py:188
def _get_chat_model_prompt_messages(self, app_mode, pre_prompt, inputs, query,
                                    context, files, memory, model_config, ...):
    prompt_messages: list[PromptMessage] = []
    # ① 先把"系统提示词模板 + 变量 + 上下文"渲染成一段 system 文本
    prompt, _ = self._get_prompt_str_and_rules(
        app_mode=app_mode, model_config=model_config,
        pre_prompt=pre_prompt, inputs=inputs, query=None, context=context)
    if prompt and query:
        prompt_messages.append(SystemPromptMessage(content=prompt))   # ② 系统消息放最前

    if memory:                                                        # ③ 再塞历史对话
        prompt_messages = self._append_chat_histories(
            memory=memory,
            memory_config=MemoryConfig(window=MemoryConfig.WindowConfig(enabled=False)),
            prompt_messages=prompt_messages, model_config=model_config)

    if query:                                                         # ④ 最后放这轮用户问题
        prompt_messages.append(self._get_last_user_message(query, files, ...))
    else:
        prompt_messages.append(self._get_last_user_message(prompt, files, ...))
    return prompt_messages, None
_get_prompt_str_and_rules把内置菜谱(get_prompt_templatesimple_prompt_transform.py:148prompt_templates/common_chat.json 之类)里的 {{变量}}inputs 替换,再把检索到的 context 拼进去,得到最终的系统提示词文本。
SystemPromptMessage 放最前系统消息(角色设定)永远第一条。这是对话模型的惯例:先立人设,再谈历史,最后接当前问题。
_append_chat_histories基类方法,把 memory(历史对话)转成一串 user/assistant 消息插进来——但会先算 token 预算再裁(L04 细看)。
_get_last_user_message这轮用户问题放最后一条 user 消息,files(图片等)也在这里附上,变成多模态消息。
💡 顺序即语义为什么必须是"系统 → 历史 → 当前问题"?因为模型是按顺序读消息、越靠后权重越高的。人设先定调,历史给上下文,当前问题压轴——顺序错了(比如问题放中间),模型可能答偏。这套顺序不是随意的,是所有对话应用的通用骨架。
L04

历史对话与 token 预算:装不下就丢旧的

历史对话的塞入在基类 PromptTransform 里。_append_chat_historiesapi/core/prompt/prompt_transform.py:39)先算"还剩多少 token 给历史":

# api/core/prompt/prompt_transform.py:39
def _append_chat_histories(self, memory, memory_config, prompt_messages, model_config):
    rest_tokens = self._calculate_rest_token(prompt_messages, model_config)  # ① 算剩余预算
    histories = self._get_history_messages_list_from_memory(                 # ② 在预算内取历史
        memory, memory_config, rest_tokens)
    prompt_messages.extend(histories)
    return prompt_messages

预算怎么算?看 _calculate_rest_tokenapi/core/prompt/prompt_transform.py:58)的思路:拿"模型的上下文窗口上限"减去"已经拼进去的系统消息占用",剩下的才留给历史。

token 预算 = 行李箱限重 模型上下文窗口上限(例:128k tokens) 系统提示词 当前问题 ← 剩余预算 → 塞历史对话 丢弃 rest_tokens = 上限 − 系统提示词 − 当前问题(预留) 历史只能在剩余预算里塞,超出部分(最旧的)被丢掉
图注:必带物品先占位,历史是可选行李,超重就从最旧的开始丢。
💡 设计取舍:为什么丢旧的、不丢新的?对话里越近的上下文越相关(用户刚说的话比十轮前更重要)。所以裁剪策略是"保新弃旧"。代价是长对话会"失忆"——这正是为什么后面 RAG / 记忆总结这类技术要登场(把旧对话压缩成摘要再塞)。这里你看到的是最朴素的"滑动窗口"版本:够用、简单、可预测。
L05

AdvancedPromptTransform:把编排权交给用户

高级模式下,用户在界面上自己排"第一条 system 说什么、第二条 user 说什么……"。AdvancedPromptTransform.get_promptapi/core/prompt/advanced_prompt_transform.py:37)用 match 按模板类型分派:

# api/core/prompt/advanced_prompt_transform.py:37
def get_prompt(self, *, prompt_template, inputs, query, files,
               memory_config, memory, model_config=None, model_instance=None, ...):
    prompt_messages = []
    match prompt_template:
        case CompletionModelPromptTemplate():                    # ① 补全模型:一段模板
            prompt_messages = self._get_completion_model_prompt_messages(
                prompt_template=prompt_template, inputs=inputs, query=query, ...)
        case list() if all(isinstance(item, ChatModelMessage) for item in prompt_template):
            prompt_messages = self._get_chat_model_prompt_messages(  # ② 对话模型:用户排的多条消息
                prompt_template=prompt_template, inputs=inputs, query=query, ...)
    return prompt_messages
prompt_template 是列表与 Simple 最大的不同:这里的模板本身就是"用户排好的一串 ChatModelMessage"(每条带 role + 内容),Transform 只负责把每条里的 {{变量}} 替换掉、在合适位置插历史和当前问题。
match/case 分派Python 3.10 的模式匹配:模板是"补全字符串模板"还是"对话消息列表",走两条不同的组装逻辑。比 Simple 里的 if-else 更清爽。
历史与预算复用基类Advanced 同样调基类的 _append_chat_histories / token 预算逻辑(L04)——编排方式变了,但"历史裁剪"这套不变,所以放基类共享。
💡 同一个基类,两种自由度Simple 和 Advanced 的关系,就像"套餐"和"自助餐":套餐由后厨决定顺序(内置菜谱),自助餐由你摆盘(手排消息)。但"称重限行李"(token 预算)、"翻常客记录"(历史对话)这些通用能力放在基类 PromptTransform,两边白拿。差异化的放子类,通用的放基类——又一次经典分层。
L06

LLMGenerator:Dify 自己也是 LLM 的用户

core/llm_generator/llm_generator.py 是个有意思的地方:Dify 用 LLM 来给自己干杂活——自动起对话标题、猜后续问题、生成 JSON Schema。看自动起标题 generate_conversation_nameapi/core/llm_generator/llm_generator.py:143):

# api/core/llm_generator/llm_generator.py:143
def generate_conversation_name(cls, tenant_id, query, conversation_id=None, app_id=None):
    prompt = CONVERSATION_TITLE_PROMPT            # ① 内置的"给我起个标题"提示词
    if len(query) > 2000:                         #    太长就掐头去尾
        query = query[:300] + "...[TRUNCATED]..." + query[-300:]
    prompt += query.replace("\n", " ") + "\n"

    model_manager = ModelManager.for_tenant(tenant_id=tenant_id)   # ② Day05 的入口!
    model_instance = model_manager.get_default_model_instance(     #    拿默认 LLM
        tenant_id=tenant_id, model_type=ModelType.LLM)
    prompts = [UserPromptMessage(content=prompt)]

    with measure_time() as timer:
        response = model_instance.invoke_llm(                      # ③ ★它自己当用户调 invoke_llm
            prompt_messages=list(prompts),
            model_parameters={"max_tokens": 500, "temperature": 1}, stream=False)
    answer = response.message.get_text_content()
    try:
        result_dict = json.loads(answer)                           # ④ 解析模型返回的 JSON
    except json.JSONDecodeError:
        result_dict = json_repair.loads(answer)                    #    坏 JSON 用 json_repair 兜底
    ...
    return name
ModelManager.for_tenant看到没——它走的正是 Day05 的入口!Dify 内部功能和用户的对话应用,共用同一套模型调用链。没有"内部专用通道",一切都是 invoke_llm
stream=False起标题不需要流式(不用一个字一个字蹦给用户看),直接要完整结果,所以 stream=False——对比 Day04/05 对话默认 stream=True
json_repair 兜底让模型输出 JSON 是"软约束",模型偶尔会吐出不合法 JSON。先 json.loads,失败再用 json_repair 修复——这是所有"让 LLM 出结构化数据"场景的通用防御。generate_structured_outputapi/core/llm_generator/llm_generator.py:663)也是同一套路。
measure_time + TraceTask调用被计时并上报到链路追踪(GENERATE_NAME_TRACE),方便观测"起标题花了多久、用了多少 token"。
⚠️ 坑:让 LLM 出 JSON 一定要兜底永远不要假设模型返回的就是合法 JSON——它可能多一句"好的,这是你要的:",或者少个引号。generate_conversation_namegenerate_structured_output 都先 json.loadsjson_repair.loads,最后还判断类型对不对。你自己写"让 LLM 出结构化数据"时,照抄这套三层防御。
L07

串起来 + 今日小结

📝 真实值:一句"退货怎么办"变成消息列表 用户在客服 App(简单模式)配了系统提示词 "你是{{brand}}的客服,用中文简洁回答",变量 brand=小蓝,本轮问 "退货怎么办",前面聊过一轮(问过"发货了吗")。→ SimplePromptTransform.get_prompt 走 CHAT 分支 → _get_prompt_str_and_rules 把变量替换成 "你是小蓝的客服,用中文简洁回答" → 拼成 SystemPromptMessage 放第一条 → _append_chat_histories 算完 token 预算,把上一轮 [User:"发货了吗" / Assistant:"已发货"] 塞进来 → 最后 UserPromptMessage("退货怎么办") 压轴。→ 最终 invoke_llm([System, User, Assistant, User])界面上填的一句话 + 一个变量,就这样变成了 4 条规范消息喂给模型。

👶 小白:PromptTransform 和 LLMGenerator 都跟 prompt 有关,到底啥区别?

👨‍🏫 老师:方向相反。PromptTransform 是"为用户的对话应用准备 prompt"——把用户配置的模板变成消息列表,然后交给用户的模型调用。LLMGenerator 是"Dify 自己要用 LLM 时准备 prompt"——它是 invoke_llm 的一个内部调用方,用内置提示词让模型帮平台干活(起标题、猜问题、生 Schema)。一个服务于"你的 App",一个服务于"Dify 这个产品自己"。但它们最后都汇入 Day05 的 invoke_llm 这一个出海口。

🧠 今天你应该能回答

  • invoke_llm 收的是列表不是字符串,谁负责拼?(PromptTransform 两个子类)
  • Simple 和 Advanced 的区别?(内置菜谱 vs 用户手排消息)
  • 对话消息的拼装顺序?(系统 → 历史 → 当前问题)
  • 历史对话塞不下怎么办?(算 rest_tokens 预算,保新弃旧)
  • Dify 自动起标题怎么实现的?(LLMGenerator 自己当用户调 invoke_llm
  • 让 LLM 出 JSON 怎么防坏数据?(json.loadsjson_repair → 类型校验)

✋ 10 分钟动手

cd /Users/bitmart/work/codes/github/AI_WORK/dify

# 1. 简单模式流水线
sed -n '48,92p'   api/core/prompt/simple_prompt_transform.py     # get_prompt 二分派
sed -n '188,234p' api/core/prompt/simple_prompt_transform.py     # 对话分支拼装顺序

# 2. 历史 + token 预算(基类)
sed -n '39,90p'   api/core/prompt/prompt_transform.py            # _append_chat_histories / _calculate_rest_token

# 3. 高级模式
sed -n '37,82p'   api/core/prompt/advanced_prompt_transform.py   # get_prompt match/case

# 4. Dify 自己用 LLM
sed -n '143,203p' api/core/llm_generator/llm_generator.py        # generate_conversation_name
grep -n "def generate_\|for_tenant\|invoke_llm" api/core/llm_generator/llm_generator.py
明日预告 · Day 08:阶段2(模型运行时)收工!明天进阶段3——工作流。core/workflow/workflow_entry.py 是入口,看它怎么把一张"节点图"交给图执行引擎(GraphEngine)跑起来;graph_topology.py 看图的拓扑(节点/边/谁在谁上游)怎么解析。先建立工作流的整体世界观,Day09/10 再钻节点和引擎。
← Day 06 Provider/实体 Day 08 · Workflow 总览 →