> ## Documentation Index
> Fetch the complete documentation index at: https://www.yuan111.asia/doc/llms.txt
> Use this file to discover all available pages before exploring further.

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

> ChatPromptTemplate 管结构不管内容：模板、MessagesPlaceholder 与多轮历史的正确用法。

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

## 开头的现象

小林跟同事老王争论了一个下午。老王说："ChatPromptTemplate 是 LangChain 的核心，没有它你什么都干不了。" 小林说："我直接 f-string 拼字符串也能用，模板不就是个花架子吗？" 两人谁也说服不了谁。

小林回到家，决定用代码证明自己是对的。他写了一个纯 f-string 版本和一个模板版本，跑同一个对话——**两个版本都能跑通。** 小林得意地想："看吧，模板就是多余！"

然后老王发来一条消息："那你试试：你的客服系统要服务 3 家公司，每家的规则不一样，用户每次进来看到的 system 提示要带各自的规则。你 f-string 怎么搞？"

小林盯着这条消息，忽然意识到自己可能想得太简单了。

## 第一幕：小林发现"模板管结构，不管内容"

小林先做了一件事：把同一个模板调用两次，每次只换 `question`，看看输出有什么变化：

```python theme={null}
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "你是{company}的智能助手，只能依据【规则】回答。\n【规则】\n{rule}"),
    ("human", "{question}"),
])

# 第一次调用
msgs1 = prompt.invoke({
    "company": "小米之家", "question": "能退货吗？",
    "rule": "退货：7 天内无理由",
}).to_messages()
# → [system] 你是小米之家的智能助手...【规则】退货：7 天内无理由
# → [human]  能退货吗？

# 第二次调用：只换 question 和 rule
msgs2 = prompt.invoke({
    "company": "华为商城", "question": "能退货吗？",
    "rule": "退货：15 天内无理由",
}).to_messages()
# → [system] 你是华为商城的智能助手...【规则】退货：15 天内无理由
# → [human]  能退货吗？
```

小林盯着两次输出，忽然懂了老王的用意：**模板把"不变的结构"（system 放第一、human 放最后、规则写进 system）和"会变的内容"（company、rule、question）分开了。** f-string 也能拼，但拼的是"一段字符串"；模板拼的是"一条消息列表"——而**chat 模型（智谱/DeepSeek/OpenAI）要的就是消息列表，不是字符串**。

他验证了一下 f-string 的致命伤：

```python theme={null}
# f-string 拼出来的是一段字符串
s = f"你是{company}的助手...{question}"
# 模型收到的是：一个没有 role 区分的字符串！
# ChatAnthropic.invoke() 需要 [SystemMessage, HumanMessage] 这种带角色的消息列表

# 模板拼出来的是一条消息列表
msgs = prompt.invoke({...}).to_messages()
# [SystemMessage(...), HumanMessage(...)]  ← 这才是模型要的
```

**"f-string 能拼文本，但拼不出'角色'。"** 小林这才意识到，他之前"f-string 够用"是因为他的例子太简单（只发一句话）；一旦要"系统人设 + 历史 + 当前问题"这种多角色消息，f-string 就得自己手动构造消息对象——而模板把这件事标准化了。

> 想验证"模板管结构"吗？跑一段代码：同一个 `ChatPromptTemplate` 两次 `invoke`，只换 `question`——system 人设不动，human 跟着变。再看 `type(x)`：模板输出的是 `ChatPromptValue`，`.to_messages()` 转成消息列表。

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

小林又遇到一个问题：多轮对话时，历史消息是"不定条数的"——第 1 轮 0 条历史，第 5 轮 8 条历史。他在模板里放了一个叫 `MessagesPlaceholder` 的"插槽"：

```python theme={null}
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

chat_prompt = ChatPromptTemplate.from_messages([
    ("system", "你是{company}的客服。"),
    MessagesPlaceholder("history"),     # ← 动态插槽：塞多少条历史都行
    ("human", "{question}"),
])

msgs = chat_prompt.invoke({
    "company": "小林公司",
    "history": [
        ("user", "我叫小林"),
        ("assistant", "你好小林！"),
        ("user", "我是后端工程师"),
    ],
    "question": "我叫什么？",
}).to_messages()
# → [system] 你是小林公司的客服。
# → [human]  我叫小林          ← history 展开成 3 条
# → [ai]     你好小林！
# → [human]  我是后端工程师
# → [human]  我叫什么？        ← question 还是 1 条
```

小林数了数：**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！**

```python theme={null}
# langchain/agents/factory.py 里 create_agent 的核心（源码逐行核对）

# ① system_prompt 转成 SystemMessage
system_message: SystemMessage | None = None
if system_prompt is not None:
    if isinstance(system_prompt, SystemMessage):
        system_message = system_prompt
    else:
        system_message = SystemMessage(content=system_prompt)   # str → SystemMessage

# ② 模型节点调用前：直接列表拼接！没有模板！
messages = request.messages
if request.system_message:
    messages = [request.system_message, *messages]   # ← 一行搞定，不用模板
output = await model_.ainvoke(messages)
```

小林盯着 `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）**

```python theme={null}
from langchain_core.prompts import ChatPromptTemplate

ChatPromptTemplate.from_messages(
    messages: Sequence[MessageLikeRepresentation],   # ("role", "文本") 或 Message 对象 或 MessagesPlaceholder
    template_format="f-string",                      # f-string / jinja2
) -> ChatPromptTemplate

chat_prompt.invoke(
    input: dict[str, Any],      # 填变量：{"history": [...], "question": "..."}
    config: RunnableConfig | None = None,
) -> PromptValue                # 再 .to_messages() → list[BaseMessage]
```

**② `MessagesPlaceholder`——动态插槽（"不定条数消息"的占位符）**

```python theme={null}
MessagesPlaceholder(
    variable_name="history",    # 运行时填这个键
    optional=False,             # True = 没传也不报错
    n_messages=None,            # 限制最多塞几条（None = 不限）
)
placeholder.format_messages(**kwargs) -> list[BaseMessage]   # 展开成消息列表
```

**③ `PromptTemplate`——单条字符串模板（非 chat 用）**

```python theme={null}
PromptTemplate.from_template(
    template="{company}的助手",     # 字符串模板，{变量} 占位
    template_format="f-string",
    partial_variables=None,        # 预填部分变量
) -> PromptTemplate
```

**④ `MessageLikeRepresentation`——from\_messages 里每个元素的三种写法：**

```python theme={null}
# 写法 1：元组（最常见）
("system", "你是{company}的助手")
# 写法 2：消息对象
SystemMessage(content="...")
# 写法 3：插槽
MessagesPlaceholder("history")
```

> 想验证吗？`ChatPromptTemplate.from_messages([("human","{q}")]).invoke({"q":"hi"}).to_messages()`——返回 `[HumanMessage(content='hi')]`。

## 第四幕：多轮历史——为什么 append 原对象，不要重建

小林写多轮对话时，又踩了一个坑，这次他记了一辈子。他一开始"贴心"地做了这么一件事：

```python theme={null}
resp = model.invoke(history)
# 小林"净化"：只提取文本，造一个干净的新 AIMessage
new_msg = AIMessage(content=resp.content)   # ← 灾难现场
history.append(new_msg)
```

结果：模型第二轮开始失忆，而且 Agent 的工具循环直接断。他打印了 `new_msg`，发现：

```python theme={null}
print(new_msg.tool_calls)   # []  ← 模型这轮明明调了工具！
print(new_msg.usage_metadata)  # None  ← 丢了
print(new_msg.response_metadata)  # {}  ← 丢了
```

小林看着那个空 `tool_calls`，血往脑门上涌：**"我把模型返回的 AIMessage 提取成纯文本，结果把'方向盘'tool\_calls 撕了！agent 靠 tool\_calls 判断下一步，它看不到，直接以为模型说完了！"**

他翻到第 4 篇自己的笔记——那里写着"内存完整 + 发送裁剪"：**AIMessage 在内存里存全量（response\_metadata、usage\_metadata、tool\_calls 都留着，给程序看），发送时协议适配层 `_format_messages` 自动剥离（只发 role + content + tool\_calls）。** 他居然忘了！

他改回正确写法，并贴在自己的工位上：

```python theme={null}
history.append(resp)   # 直接 append 模型返回的 AIMessage 原对象，一个字不改！
```

> 想验证"重建丢方向盘"吗？跑一段：`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 篇：金鱼脑的真相](/doc/doc/narrative-course/04-记忆)。
