agent_scratchpad 到底该放哪?一个隐蔽的顺序坑
一、先上结论
如果你在做 Tool Calling Agent,prompt 模板里消息顺序必须是这样:
text
[System]
[chat_history] ← 旧对话(多轮记忆)
[Human] 当前问题
[agent_scratchpad] ← 当前轮的工具往返
一句话口诀:
chat_history在 Human 前,agent_scratchpad在 Human 后。两者永远被 Human 分开。
记住这条,这篇文章你就不用看了。没记住,往下看我怎么踩的坑。
二、我踩的坑
2.1 现象:模型反复调用同一个工具
我在做一个 Tool Calling Agent,用的 glm-4-flash,工具是计算器、时间查询这些。用户问:
text
告诉我 1*1+10 等于多少,再告诉我当前系统时间
结果模型的表现是:
text
第1轮 content='' tool_calls=[calculator_tool(express='1*1+10')]
执行工具 → '结果:11'
第2轮 content='' tool_calls=[calculator_tool(express='1*1+10')] ← 又调一次!
执行工具 → '结果:11'
第3轮 content='' tool_calls=[calculator_tool(express='1*1+10')] ← 又又调一次!
... 直到 MAX_ROUND 兜底终止
模型像个复读机,永远在调第一个工具,永远不往下走。
更诡异的是:每次调用的 tool_call_id 都是新的。
text
第1轮 id = 'call_-7223951751387276138'
第2轮 id = 'call_-7223942474257923707'
第3轮 id = 'call_...'
我一开始以为是 tool_call_id 回填错了,检查了好几遍------没错,每次都用的当前轮的 tool_call['id']。
那为什么模型还要重新调?
2.2 排查过程:我几乎把所有可能都试了一遍
这一段的排查经历,现在回想起来,堪称"把每个角落都翻了一遍"。
第一轮怀疑:tool_call_id 回填错了
我最开始怀疑的是回填 ToolMessage 时 ID 传错了。
因为在写循环的时候,我一不留神把 tool_call['id'] 取错了层级------res.tool_calls 里的 id 和 additional_kwargs.tool_calls 里的 id 长得像但取值路径不同,我一开始取到了旧的那一层。
现象也对得上:如果 ID 对不上,模型就会认为"我上一次的调用没人响应",于是重发。
我改了 ID 来源,改成从 res.tool_calls[0]['id'] 里直接取。
跑了一次------还是不对。 模型还是复读。
第二轮怀疑:AIMessage 没 append,或者 ToolMessage 没 append
我又检查了一遍循环:
python
scratchpad.append(AIMessage(content=res.content, tool_calls=res.tool_calls))
for tool_call in res.tool_calls:
result = execute_action(tool_call)
scratchpad.append(ToolMessage(content=result, tool_call_id=tool_call["id"]))
AIMessage 加了,ToolMessage 也加了,顺序也对------先 AIMessage,再 ToolMessage,完全符合协议。
看起来一切处理好了。结果还是不对。
第三轮怀疑:是不是 glm-4-flash 本身的问题
我开始怀疑模型:glm-4-flash 是轻量模型,是不是对多轮 tool calling 支持不好?
不过我手头只有这一个模型,没去换别的。但我隐约觉得不太像模型问题 ------因为表现太"规律"了:不是偶尔错,而是每轮都在复读 ,而且每次调用参数都一样。模型如果有问题,通常不会这么整齐。
所以我先把这条放一边,继续往下查。
第四轮:跟能跑通的例子对比
我手头还有另一个项目,它的手动 Tool Calling 循环能跑通。
我把两边的代码并排贴出来对比。
循环逻辑一模一样:
- 都是
scratchpad = [] - 都是
chain.invoke({"user_input": ..., "agent_scratchpad": ...}) - 都是
append(AIMessage)+append(ToolMessage) - 都是
tool_call_id从当前tool_call['id']取
唯一的差别,在 prompt 模板里。
PYTHON
# 我的(跑不通)
ChatPromptTemplate.from_messages([
("system", system_tpl),
MessagesPlaceholder(variable_name="chat_history"),
("human", "{user_input}"), # ★ 在最后
])
# 能跑通的例子
ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("user", "{user_input}"), # ★ 在中间
MessagesPlaceholder("agent_scratchpad"), # ★ 在最后
])
就差一个位置。我盯着这两段看了好久,觉得不太可能------prompt 模板顺序而已,能影响这么大?
但既然别的都一样,那问题大概率就在这。
第五轮:把实际发给模型的 messages 打印出来
我加了一行调试,把每轮 chain.invoke 之前的 messages 打印出来。看到第2轮的 messages 我傻了:
text
第2轮实际发给模型的 messages:
[System] 你是一个工具调用助手...
[AI] tool_calls=[calculator_tool(express='1*1+10')]
[Tool] '数学运算执行成功,结果:11' id=call_AAA
[Human] 告诉我 1*1+10 等于多少,再告诉我当前系统时间 ← ★ 又冒出来一条!
看这第2轮的消息:[System] → [AI] → [Tool] → [Human]。
Human 跑到最后了。
正确的顺序应该是 [System] → [Human] → [AI] → [Tool]------用户问题在前面,工具往返在后面。
现在反过来了:模型看到的顺序是"工具往返在前,用户问题在后",于是理解成"用户刚发了一条新消息"。
于是模型很"合理"地又调了一次计算器。
2.3 根因:{user_input} 和 agent_scratchpad 的相对位置不同
我的(跑不通):
python
ChatPromptTemplate.from_messages([
("system", system_tpl),
MessagesPlaceholder(variable_name="chat_history"),
("human", "{user_input}"), # ★ 在最后
])
能跑通的例子(第二个项目):
python
ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("user", "{user_input}"), # ★ 在中间
MessagesPlaceholder("agent_scratchpad"), # ★ 在最后
])
就这么简单的一个位置差异!
把 ("human", "{user_input}") 从最后 挪到scratchpad 之前,问题当场消失。
2.4 修正后的 prompt 模板
python
ChatPromptTemplate.from_messages([
("system", system_tpl),
("user", "{user_input}"), # ★ 当前问题居中
MessagesPlaceholder("agent_scratchpad"), # ★ 工具往返在后
])
prompt 模板里那个占位符,名字叫
chat_history,但里面塞的其实是agent_scratchpad(工具往返记录)------只是当初懒得改名字。
2.5 问题链条总结
为什么位置错了会导致复读?
("human", "{user_input}")在 prompt 模板最后 → 每轮 invoke 都会拼一条 Human 到消息末尾- 消息顺序变成
[System, AI, Tool, Human]→ Human 排在工具往返后面 - 模型看到"用户问题排在工具往返后面" → 理解成"用户刚发了新消息"
- → 重新调工具 → 死循环
Human 重复出现 + 排在最后,是双重错误。
人类容易觉得"消息模板顺序无所谓" ,但模型对消息顺序极其敏感。顺序错了,行为就全变了。
三、为什么这个坑这么隐蔽
3.1 "消息映射 dict"的顺序 ≠ "消息顺序"
我看的很多 Agent 笔记,长这样:
python
agent = (
{
"input": lambda x: x["input"],
"agent_scratchpad": lambda x: format_to_openai_tool_messages(x["intermediate_steps"]),
"chat_history": lambda x: x["chat_history"], # ★ 注意位置
}
| prompt
| llm_with_tools
| OpenAIToolsAgentOutputParser()
)
看到 chat_history 在 agent_scratchpad 后面,我下意识以为"消息顺序也是这样"。
这是个巨大的误解。
上面那个 dict 只是"变量来源映射"------告诉 LangChain:
input从哪来agent_scratchpad从哪来chat_history从哪来
dict 里的 key 顺序,完全不影响最终发给模型的消息顺序。
决定消息顺序的只有一个地方:ChatPromptTemplate.from_messages([...]) 的列表顺序。
3.2 大部分笔记只给 dict,不给 prompt 模板
我回看自己收藏的笔记,发现一个规律:
- 只有 dict +
| prompt一句话的章节:容易误导,因为看不到 prompt 模板,读者会脑补顺序 - 给出完整
from_messages([...])的章节:往往是对的,因为作者必须面对顺序问题
而讲 agent_scratchpad 的位置时,几乎没有笔记明说"它要放在 Human 后面"------所有作者都默认你知道。
这就是"沉默的知识":所有人都在用,但没人在讲。
3.3 单轮能跑,多轮才崩------更隐蔽
如果只做单轮 Tool Calling(没有 chat_history),模板是这样:
python
ChatPromptTemplate.from_messages([
("system", ...),
("user", "{input}"),
MessagesPlaceholder("agent_scratchpad"),
])
此时 agent_scratchpad 放哪都对(因为只有一条 Human,没有对比)。
一旦加了 chat_history 做多轮,顺序错了立刻暴露。 很多人单轮测通,一加多轮就挂,根本不知道问题出在哪。
3.4 排查时容易被"显性错误"带偏
我这次排查走了很多弯路:
- 第一层怀疑 :
tool_call_id取错层级------确实有这个问题,改对了 - 第二层怀疑 :
AIMessage/ToolMessage没 append------检查了,都加了 - 第三层怀疑:模型问题------手头没有其他模型,无法验证
当我把"看起来会出错的点"全部排查完,以为"一切都处理好了"的时候,还是不对。
这时候最容易放弃,或者怪模型。
真正帮我定位的,是"跟能跑通的例子并排对比"------把所有代码摆一起,一眼就看出唯一差异是 prompt 模板里的顺序。
这个经验极其宝贵:
当你觉得"逻辑都对了但就是不工作"的时候,别硬猜,找一个能跑通的参照物,逐行对比。差异就是答案。
3.5 chat_history 这个名字有误导性
MessagesPlaceholder("chat_history") ------ 名字叫"历史对话",听起来就该放在开头。这没错。
但我的项目里,这个占位符里塞的其实是 agent_scratchpad(工具往返)!
我用错了名字,导致自己以为"历史就该放前面",然后 prompt 模板写成:
python
[
("system", ...),
MessagesPlaceholder("chat_history"), # 名字像"历史",但塞的是工具往返
("human", "{user_input}"),
]
名不副实,位置全错。
四、为什么顺序是这样
4.1 两类"历史"必须分清
Agent 的 messages 里其实有两种完全不同的"历史" ,它们的位置相反:
| 占位符 | 语义 | 内容 | 位置 |
|---|---|---|---|
chat_history |
旧对话 | 上一轮的 Human/AI 对 | Human 前 |
agent_scratchpad |
工具往返 | 当前轮的 AI(tool_calls)/Tool 对 | Human 后 |
关键:类型 A 是"过去发生的事",类型 B 是"正在发生的事"。
"过去"必然在"当前问题"之前,"正在发生"必然在"当前问题"之后。
4.2 用时间线理解
text
时间轴 ──────────────────────────────────────►
[System] ← 全局设定
[Human] 上一轮问题 ┐
[AI] 上一轮回答 ┘ chat_history ← 类型A:过去
[Human] 当前问题 ← 当前时刻
[AI] tool_calls ┐
[Tool] result ┘ agent_scratchpad ← 类型B:正在发生
[AI] tool_calls ┐
[Tool] result ┘
[AI] 最终答案
"当前 Human 问题"是一条分界线:
- 之前的都是"旧对话"
- 之后的都是"当前轮的处理过程"
4.3 LangChain 的两个占位符
LangChain 对这两个类型有明确的占位符约定:
python
MessagesPlaceholder("chat_history") # 类型A:旧对话
MessagesPlaceholder("agent_scratchpad") # 类型B:当前轮工具往返
名字不同,位置也必须不同。
正确的完整模板:
python
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
MessagesPlaceholder("chat_history", optional=True), # ★ 在 Human 前
("user", "{input}"),
MessagesPlaceholder("agent_scratchpad"), # ★ 在 Human 后
])
五、正确写法(完整可跑)
5.1 单轮 Tool Calling(无多轮记忆)
python
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
("user", "{input}"), # ★ 当前问题
MessagesPlaceholder("agent_scratchpad"), # ★ 工具往返
])
# 循环
scratchpad = []
for i in range(MAX_ROUND):
res = chain.invoke({
"input": user_input,
"agent_scratchpad": scratchpad,
})
scratchpad.append(AIMessage(content=res.content, tool_calls=res.tool_calls))
if not res.tool_calls:
return res.content
for tc in res.tool_calls:
result = execute_action(tc)
scratchpad.append(ToolMessage(content=str(result), tool_call_id=tc["id"]))
5.2 多轮 Tool Calling(带 chat_history)
python
prompt = ChatPromptTemplate.from_messages([
("system", SYSTEM_PROMPT),
MessagesPlaceholder("chat_history", optional=True), # ★ 在 Human 前
("user", "{input}"),
MessagesPlaceholder("agent_scratchpad"), # ★ 在 Human 后
])
5.3 如果用 AgentExecutor(推荐)
python
from langchain.agents import create_tool_calling_agent, AgentExecutor
agent = create_tool_calling_agent(llm_with_tools, TOOLS, prompt)
executor = AgentExecutor(agent=agent, tools=TOOLS, verbose=True)
result = executor.invoke({
"input": "告诉我 1*1+10 等于多少",
"chat_history": [],
})
六、自检清单
每次写 Tool Calling Agent 的 prompt 时,对照这几条:
-
chat_history在("user", "{input}")之前 -
agent_scratchpad在("user", "{input}")之后 - 没有把消息映射 dict 里的 key 顺序当成消息顺序
- 跑过一次多轮对话(不只是单轮)
-
agent_scratchpad里塞的内容确实是"工具往返"(AIMessage + ToolMessage),不是"旧对话" - 没有每轮都往 messages 里塞一条新的
HumanMessage(user_input)
全部 ✅,你的 Agent 就是对的。
七、一句话总结
chat_history在 Human 前,agent_scratchpad在 Human 后。两者位置相反,永远被 Human 分开。
消息映射 dict 的顺序不影响消息顺序,只看
from_messages([...])。单轮不暴露,多轮才崩------一定要真跑一遍多轮。
当逻辑都对了却还是不工作时,找一个能跑通的例子逐行对比,差异就是答案。
附:我是怎么从"能跑"到"真懂"的
这次踩坑的经历,其实是一次完整的认知升级:
- 一开始:抄笔记,抄了个错的 prompt 模板,碰巧单轮能跑
- 加多轮:Agent 开始复读,一直调同一个工具
- 以为是 ID 问题 :检查
tool_call_id,发现层级取错,改对了 - 以为修好了:结果还是不对,继续复读
- 怀疑模型:换了三个 GLM 模型,都不行
- 怀疑 append 逻辑:检查 AIMessage/ToolMessage,都对
- 打印 messages:看到 Human 出现了两次,且在最后
- 对比能跑通的例子:并排看代码,发现唯一差异是 prompt 模板里的顺序
- 改顺序 :把
("human", "{user_input}")从最后挪到前面,当场跑通 - 理解语义:原来有两种"历史",位置相反
这个坑的本质不是"LangChain 的语法",而是"消息时序"的语义理解。
任何用 message list 拼对话的系统(OpenAI 原生 API、LlamaIndex、自研框架)都有同样的坑,核心原则都是这一条:
"用户当前输入"是一条分界线:旧对话在它前面,当前任务的过程在它后面。
把这个原则记牢,任何框架你都能一眼看穿顺序对错。
最后吐槽一句:就这么一个位置差异,浪费了我三天。啊啊啊。