Agent相关 - agent_scratchpad 到底该放哪?一个隐蔽的顺序坑

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 问题链条总结

为什么位置错了会导致复读?

  1. ("human", "{user_input}") 在 prompt 模板最后 → 每轮 invoke 都会拼一条 Human 到消息末尾
  2. 消息顺序变成 [System, AI, Tool, Human] → Human 排在工具往返后面
  3. 模型看到"用户问题排在工具往返后面" → 理解成"用户刚发了新消息"
  4. → 重新调工具 → 死循环

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([...])。

单轮不暴露,多轮才崩------一定要真跑一遍多轮。

当逻辑都对了却还是不工作时,找一个能跑通的例子逐行对比,差异就是答案。


附:我是怎么从"能跑"到"真懂"的

这次踩坑的经历,其实是一次完整的认知升级:

  1. 一开始:抄笔记,抄了个错的 prompt 模板,碰巧单轮能跑
  2. 加多轮:Agent 开始复读,一直调同一个工具
  3. 以为是 ID 问题 :检查 tool_call_id,发现层级取错,改对了
  4. 以为修好了:结果还是不对,继续复读
  5. 怀疑模型:换了三个 GLM 模型,都不行
  6. 怀疑 append 逻辑:检查 AIMessage/ToolMessage,都对
  7. 打印 messages:看到 Human 出现了两次,且在最后
  8. 对比能跑通的例子:并排看代码,发现唯一差异是 prompt 模板里的顺序
  9. 改顺序 :把 ("human", "{user_input}") 从最后挪到前面,当场跑通
  10. 理解语义:原来有两种"历史",位置相反

这个坑的本质不是"LangChain 的语法",而是"消息时序"的语义理解。

任何用 message list 拼对话的系统(OpenAI 原生 API、LlamaIndex、自研框架)都有同样的坑,核心原则都是这一条:

"用户当前输入"是一条分界线:旧对话在它前面,当前任务的过程在它后面。

把这个原则记牢,任何框架你都能一眼看穿顺序对错。

最后吐槽一句:就这么一个位置差异,浪费了我三天。啊啊啊。

相关推荐
七夜zippoe1 小时前
多 Agent 协作架构:Pipeline 模式——串行流水线设计与实战
ai·架构·pipeline·agent·串行流水线
逆风飞翔的小叔2 小时前
【AI大模型】Langchain 多类型对话消息使用实战操作详解
langchain·langchain 消息类型·langchain 消息使用·langchain 消息详解·langchain 消息·langchain 消息总结
浮生望2 小时前
大模型结构化输出怎么选:从 JSON.parse、OutputParser 到 Zod
llm
AINative软件工程3 小时前
LLM Prompt Registry 工程实践:集中管理 Prompt,让模型调用不再散落在代码各处
后端·python·llm
Patrick在香港3 小时前
MCP 的 initialize 握手真的没了?67 行标准库实测 2026-07-28 规范
python·agent·claude·mcp·json-rpc
墨心@3 小时前
AI Agent 学习总结
人工智能·自然语言处理·agent·harness·datawhale共学
XLYcmy13 小时前
Dify 本地部署、Ollama 与 Xinference 集成:踩坑与最佳实践指南
llm·agent·dify·rag·ollama·rerank·harness
染指111013 小时前
134.Agent-多Agent框架-LangChain多智能体
人工智能·中间件·langchain·agents
YOLO数据集集合14 小时前
EvoAgent:面向PR研发治理的自进化Multi-Agent Harness系统
java·开发语言·目标检测·agent·自进化