Prompt 构造与 LLM 生成:模型嘴边那句话是怎么拼出来的
Day05/06 解决了"怎么调模型";今天解决"喂给模型的话从哪来"。主角是 core/prompt/(把配置变成 PromptMessage 列表)和 core/llm_generator/(Dify 自己怎么用 LLM 干杂活)。弄清三件事:①"简单模式"(一段系统提示词)和"高级模式"(手工编排多条消息)两条流水线怎么分工;②系统提示词、变量、上下文、历史对话、用户问题,是怎么按顺序拼成消息列表、又怎么在 token 预算内塞历史的;③Dify 自动起对话标题、生成 JSON Schema 这些"AI 辅助"功能,本质上就是它自己当用户去调 invoke_llm。
PromptTransform 像餐厅后厨的"备菜台"——顾客点单(用户问题)、菜谱(系统提示词模板)、食材(变量/上下文)、常客口味记录(历史对话),全在这里按固定顺序装盘,最后端出一盘标准化的"菜"(PromptMessage 列表)交给厨师(模型)。Simple 是套餐(照菜谱一键出菜),Advanced 是自助摆盘(你决定每道菜的位置)。类比二:token 预算像行李箱限重——系统提示词和用户问题是"必带物品"先占位,剩下的重量才用来塞"历史对话"这类可选行李;塞不下的旧对话就被丢掉。_calculate_rest_token 就是那台称。痛点:给模型的那段话,不是一个字符串
invoke_llm(prompt_messages, ...) 收的是一个 PromptMessage 列表,不是一段文本。可用户在 Dify 界面里只填了一个"系统提示词"(比如"你是客服,用中文回答")加几个变量。中间的落差是:怎么把"提示词模板 + 变量替换 + 检索到的上下文 + 前几轮对话 + 这轮用户问题",按对话模型 / 补全模型各自的格式,拼成一个规范的消息列表?而且历史对话可能很长,得在模型的 token 上限内裁剪。这些若散落在各个 App Runner 里,会重复且易错。PromptTransform(api/core/prompt/prompt_transform.py:11)作基类,派生两个变换器:SimplePromptTransform(面向"傻瓜模式",用内置菜谱)和 AdvancedPromptTransform(面向"高级模式",用户手工编排每条消息)。它们的输出统一是 list[PromptMessage],正好对接 Day05 的 invoke_llm。Day04 的 Runner 里那句 "organize_prompt_messages" 干的就是调它们。两条流水线:Simple 与 Advanced 怎么分工
| 维度 | SimplePromptTransform | AdvancedPromptTransform |
|---|---|---|
| 面向 | 基础编排(一段系统提示词 + 变量) | 高级编排(用户手排多条消息) |
| 输入模板 | 内置 JSON 菜谱(prompt_templates/*.json) | 用户填的 ChatModelMessage 列表 或 补全模板 |
| 入口 | get_prompt simple_prompt_transform.py:48 | get_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
_get_chat_... 和 _get_completion_...。今天我们主要看对话分支(现在几乎都是对话模型)。SimplePromptTransform:对话分支走一遍
看对话分支 _get_chat_model_prompt_messages(api/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_template,simple_prompt_transform.py:148 读 prompt_templates/common_chat.json 之类)里的 {{变量}} 用 inputs 替换,再把检索到的 context 拼进去,得到最终的系统提示词文本。SystemPromptMessage 放最前系统消息(角色设定)永远第一条。这是对话模型的惯例:先立人设,再谈历史,最后接当前问题。_append_chat_histories基类方法,把 memory(历史对话)转成一串 user/assistant 消息插进来——但会先算 token 预算再裁(L04 细看)。_get_last_user_message这轮用户问题放最后一条 user 消息,files(图片等)也在这里附上,变成多模态消息。历史对话与 token 预算:装不下就丢旧的
历史对话的塞入在基类 PromptTransform 里。_append_chat_histories(api/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_token(api/core/prompt/prompt_transform.py:58)的思路:拿"模型的上下文窗口上限"减去"已经拼进去的系统消息占用",剩下的才留给历史。
AdvancedPromptTransform:把编排权交给用户
高级模式下,用户在界面上自己排"第一条 system 说什么、第二条 user 说什么……"。AdvancedPromptTransform.get_prompt(api/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)——编排方式变了,但"历史裁剪"这套不变,所以放基类共享。PromptTransform,两边白拿。差异化的放子类,通用的放基类——又一次经典分层。LLMGenerator:Dify 自己也是 LLM 的用户
core/llm_generator/llm_generator.py 是个有意思的地方:Dify 用 LLM 来给自己干杂活——自动起对话标题、猜后续问题、生成 JSON Schema。看自动起标题 generate_conversation_name(api/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_output(api/core/llm_generator/llm_generator.py:663)也是同一套路。measure_time + TraceTask调用被计时并上报到链路追踪(GENERATE_NAME_TRACE),方便观测"起标题花了多久、用了多少 token"。generate_conversation_name 和 generate_structured_output 都先 json.loads 再 json_repair.loads,最后还判断类型对不对。你自己写"让 LLM 出结构化数据"时,照抄这套三层防御。串起来 + 今日小结
"你是{{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.loads→json_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
core/workflow/workflow_entry.py 是入口,看它怎么把一张"节点图"交给图执行引擎(GraphEngine)跑起来;graph_topology.py 看图的拓扑(节点/边/谁在谁上游)怎么解析。先建立工作流的整体世界观,Day09/10 再钻节点和引擎。