03 · 模板与多轮历史:ChatPromptTemplate 到底有没有用
开头的现象
小林跟同事老王争论了一个下午。老王说:“ChatPromptTemplate 是 LangChain 的核心,没有它你什么都干不了。” 小林说:“我直接 f-string 拼字符串也能用,模板不就是个花架子吗?” 两人谁也说服不了谁。 小林回到家,决定用代码证明自己是对的。他写了一个纯 f-string 版本和一个模板版本,跑同一个对话——两个版本都能跑通。 小林得意地想:“看吧,模板就是多余!” 然后老王发来一条消息:“那你试试:你的客服系统要服务 3 家公司,每家的规则不一样,用户每次进来看到的 system 提示要带各自的规则。你 f-string 怎么搞?” 小林盯着这条消息,忽然意识到自己可能想得太简单了。第一幕:小林发现”模板管结构,不管内容”
小林先做了一件事:把同一个模板调用两次,每次只换question,看看输出有什么变化:
想验证”模板管结构”吗?跑一段代码:同一个ChatPromptTemplate两次invoke,只换question——system 人设不动,human 跟着变。再看type(x):模板输出的是ChatPromptValue,.to_messages()转成消息列表。
第二幕:MessagesPlaceholder——多轮历史为什么必须用它
小林又遇到一个问题:多轮对话时,历史消息是”不定条数的”——第 1 轮 0 条历史,第 5 轮 8 条历史。他在模板里放了一个叫MessagesPlaceholder 的”插槽”:
{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 状态——模板在这里是多余的。
想验证”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 原对象,不要重建
小林写多轮对话时,又踩了一个坑,这次他记了一辈子。他一开始”贴心”地做了这么一件事: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 直接断。
复现信号:什么时候你会想起这一章
- 自己搭链,消息里有”每次会变的内容”(检索资料、用户配置)——用 ChatPromptTemplate +
{变量}。 - 多轮历史条数不定——用 MessagesPlaceholder(“history”),别塞普通变量。
- 用 create_agent 想让 system 带变量——没门,它不做模板渲染;要用动态人设,自己在消息里拼。
- 想把模型返回存历史——直接
history.append(resp),提取文本重建 = 撕方向盘。