Skip to main content

03 · 模板与多轮历史:ChatPromptTemplate 到底有没有用

开头的现象

小林跟同事老王争论了一个下午。老王说:“ChatPromptTemplate 是 LangChain 的核心,没有它你什么都干不了。” 小林说:“我直接 f-string 拼字符串也能用,模板不就是个花架子吗?” 两人谁也说服不了谁。 小林回到家,决定用代码证明自己是对的。他写了一个纯 f-string 版本和一个模板版本,跑同一个对话——两个版本都能跑通。 小林得意地想:“看吧,模板就是多余!” 然后老王发来一条消息:“那你试试:你的客服系统要服务 3 家公司,每家的规则不一样,用户每次进来看到的 system 提示要带各自的规则。你 f-string 怎么搞?” 小林盯着这条消息,忽然意识到自己可能想得太简单了。

第一幕:小林发现”模板管结构,不管内容”

小林先做了一件事:把同一个模板调用两次,每次只换 question,看看输出有什么变化:
小林盯着两次输出,忽然懂了老王的用意:模板把”不变的结构”(system 放第一、human 放最后、规则写进 system)和”会变的内容”(company、rule、question)分开了。 f-string 也能拼,但拼的是”一段字符串”;模板拼的是”一条消息列表”——而chat 模型(智谱/DeepSeek/OpenAI)要的就是消息列表,不是字符串 他验证了一下 f-string 的致命伤:
“f-string 能拼文本,但拼不出’角色’。” 小林这才意识到,他之前”f-string 够用”是因为他的例子太简单(只发一句话);一旦要”系统人设 + 历史 + 当前问题”这种多角色消息,f-string 就得自己手动构造消息对象——而模板把这件事标准化了。
想验证”模板管结构”吗?跑一段代码:同一个 ChatPromptTemplate 两次 invoke,只换 question——system 人设不动,human 跟着变。再看 type(x):模板输出的是 ChatPromptValue.to_messages() 转成消息列表。

第二幕:MessagesPlaceholder——多轮历史为什么必须用它

小林又遇到一个问题:多轮对话时,历史消息是”不定条数的”——第 1 轮 0 条历史,第 5 轮 8 条历史。他在模板里放了一个叫 MessagesPlaceholder 的”插槽”:
小林数了数:history 塞了 3 条,模板就展开成 3 条。他试了塞 0 条、5 条,都能展开。他又试了把列表塞给普通变量 {question}——结果输出变成了字符串 "['a', 'b']"根本不展开成消息 他恍然大悟:ChatPromptTemplate 是”容器”(声明消息列表长什么样),MessagesPlaceholder 是”插槽”(声明”这里有一坨条数不定的消息”)。 普通变量 {question} 只能填一个字符串、展开一条;多轮历史这种”条数每轮都变”的东西,必须用 MessagesPlaceholder。 他把这两个概念比作表单:ChatPromptTemplate 是一张空表单,MessagesPlaceholder 是表单里”可贴任意张纸条”的区域。
想验证”插槽”吗?跑一段:MessagesPlaceholder("history") 塞 0 条 → 总消息 2 条;塞 3 条 → 5 条;塞 5 条 → 7 条。再把列表塞给普通 {question}——它不展开,直接变成字符串字面量。

第三幕:create_agent 内部到底用不用模板?——源码打脸

小林想起第 11 篇他解剖过 create_agent。他突然冒出个念头:“create_agent 里也有 system_prompt,它内部肯定用了 ChatPromptTemplate + MessagesPlaceholder 吧?” 他打开 langchain/agents/factory.py 的源码,一行行找。结果他发现了一个让他愣住的事实——create_agent 里根本没有 ChatPromptTemplate,也没有 MessagesPlaceholder!
小林盯着 messages = [request.system_message, *messages] 这一行,嘴张了半天:“create_agent 的 system 注入,就是一行列表拼接!” 他这才明白为什么 create_agent 不用模板:
  • create_agent 的 system_prompt 是创建时写死的——不需要 {variable} 槽位,因为根本不变。
  • create_agent 的历史由 LangGraph 的 checkpointer 管理——不需要 MessagesPlaceholder 展开,状态直接传。
  • create_agent 的消息流全走 LangGraph 状态——模板在这里是多余的。
结论:你自己搭链(RAG、对话)需要模板(因为 context/question 每次变);用 create_agent 不需要模板(它把该变量化的地方全内置了)。 老王和小林都没全对——模板不是”必需”,但在”自己搭链”的场景里,它是标准答案。
想验证”create_agent 不用模板”吗?跑 10_agent.py,然后给 create_agent 传一个带 {变量} 的 system_prompt,比如 "你是{公司}的助手"——你会发现 {公司} 原样发给模型(不会被替换)。create_agent 不做模板渲染,它只拼字符串。

🔧 技术细节:这一章涉及的关键类和签名

ChatPromptTemplate——消息模板容器(langchain_core.prompts)
MessagesPlaceholder——动态插槽(“不定条数消息”的占位符)
PromptTemplate——单条字符串模板(非 chat 用)
MessageLikeRepresentation——from_messages 里每个元素的三种写法:
想验证吗?ChatPromptTemplate.from_messages([("human","{q}")]).invoke({"q":"hi"}).to_messages()——返回 [HumanMessage(content='hi')]

第四幕:多轮历史——为什么 append 原对象,不要重建

小林写多轮对话时,又踩了一个坑,这次他记了一辈子。他一开始”贴心”地做了这么一件事:
结果:模型第二轮开始失忆,而且 Agent 的工具循环直接断。他打印了 new_msg,发现:
小林看着那个空 tool_calls,血往脑门上涌:“我把模型返回的 AIMessage 提取成纯文本,结果把’方向盘’tool_calls 撕了!agent 靠 tool_calls 判断下一步,它看不到,直接以为模型说完了!” 他翻到第 4 篇自己的笔记——那里写着”内存完整 + 发送裁剪”:AIMessage 在内存里存全量(response_metadata、usage_metadata、tool_calls 都留着,给程序看),发送时协议适配层 _format_messages 自动剥离(只发 role + content + tool_calls)。 他居然忘了! 他改回正确写法,并贴在自己的工位上:
想验证”重建丢方向盘”吗?跑一段:new = AIMessage(content=resp.content),打印 new.tool_calls——空数组。而 history.append(resp) 后,history[-1] is resp 为 True,tool_calls/usage 全保留。

结论(小林用一天换来的)

模板管”结构”不管”内容”:ChatPromptTemplate 是容器(声明消息列表),MessagesPlaceholder 是插槽(塞不定条数的历史),{变量} 是普通槽位(填一个字符串)。 自己搭链(RAG/对话)必须用它们,因为 context/question/history 每次变;create_agent 内部不用模板——它的 system 是写死的、历史由 LangGraph 管,源码里就一行 messages = [system_message, *messages]多轮历史永远直接 append 返回的 AIMessage 原对象——重建会丢 tool_calls,agent 直接断。

复现信号:什么时候你会想起这一章

  1. 自己搭链,消息里有”每次会变的内容”(检索资料、用户配置)——用 ChatPromptTemplate + {变量}
  2. 多轮历史条数不定——用 MessagesPlaceholder(“history”),别塞普通变量。
  3. 用 create_agent 想让 system 带变量——没门,它不做模板渲染;要用动态人设,自己在消息里拼。
  4. 想把模型返回存历史——直接 history.append(resp),提取文本重建 = 撕方向盘。
小林把”消息怎么组装”搞懂了。但老王又问了一句:“你说消息要滚雪球式回传——那模型到底有没有记忆?为什么我问第二轮它就忘了?” 小林正要解释,忽然意识到这个问题他自己也没彻底搞懂。他决定去做一个对照实验——翻到第 4 篇:金鱼脑的真相