☰
Langchain Agent 记忆机制剖析:短期 scratchpad 与长期 history 的协同流程
2026/9/29 4:28:18 网站建设 项目流程

1. 为什么 AgentExecutor 的记忆总像黑盒

很多人第一次用 Langchain 的 AgentExecutor,都会有一种“它好像记住了,又好像没记住”的错觉。你调用agent_executor.invoke({"input": "..."}),Agent 能自动决定要不要调工具、调几次、什么时候停,看起来挺聪明;可一旦你问“刚才那步工具算出来的结果是多少”,它又经常答不上来。问题不在模型,而在于你没分清 AgentExecutor 内部其实有两套完全不同的记忆通道:一套是单次 invoke 内、随工具调用不断膨胀的短期 scratchpad,另一套是跨多次 invoke、靠外部存储维持的长期 history。

这两套东西名字都带“记忆”,但生命周期、存放位置、注入 prompt 的位置全都不一样。scratchpad 是 AgentExecutor 自己在 while 循环里维护的临时消息列表,一次 invoke 结束就丢;history 是 RunnableWithMessageHistory 在 invoke 前后帮你读写的外部存储,靠 session_id 区分会话。搞混了它们,就会出现“工具输出下一轮消失”“历史消息没进 prompt”“多轮对话串了会话”这些典型症状。

这篇就围绕 Langchain Agent 的 scratchpad 与 history 协同流程展开,面向多轮对话加工具调用的场景。我会先给一套可复制的记忆配置骨架,再把 AgentExecutor 内部一次 invoke 的时序拆开,最后给出验证上下文是否按预期注入的调试动作,以及几个我实际踩过的坑。适合已经能跑通基础 Agent、但想搞清楚记忆到底怎么流动的人。

2. 前置准备:模型接入与依赖安装

在动记忆机制之前,得先有一个能稳定调用的 LLM 端点。我这边习惯用 TaoToken 做模型接入层,它兼容 OpenAI 风格的接口,Langchain 里直接配base_url和api_key就能用,省去在代码里硬编码各家差异的麻烦。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api ,注意 API 这条不带 UTM 参数。

先去控制台把 Key 建出来,地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,然后在 API Keys 页面生成密钥: https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。Key 拿到后建议放环境变量,别写进代码提交。

依赖这块,Langchain 拆包比较碎,装的时候注意版本对齐:

pip install langchain langchain-core langchain-community langchain-openai

如果你要用 Redis 做长期存储,再加一个:

pip install redis

环境变量这样设:

export TAOTOKEN_API_KEY="你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"

模型初始化时把 base_url 指过去:

import os from langchain_openai import ChatOpenAI llm = ChatOpenAI( model="gpt-4o-mini", api_key=os.environ["TAOTOKEN_API_KEY"], base_url=os.environ["TAOTOKEN_BASE_URL"], temperature=0, )

这一步跑通的标准很简单:单独llm.invoke("你好")能返回内容,就说明接入层没问题,后面记忆机制的调试才不会被网络问题干扰。

3. 可复制的 Agent 记忆配置骨架

先给完整骨架,再逐段解释。核心就两个占位符:history和agent_scratchpad,它们必须按固定顺序出现在 prompt 里。

from langchain.agents import AgentExecutor, create_openai_tools_agent from langchain_community.chat_message_histories import ChatMessageHistory from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.tools import tool @tool def python_safe_run(code: str) -> str: """执行一段安全的 Python 表达式并返回结果。""" try: result = eval(code, {"__builtins__": {}}, {}) return f"运行成功,结果:{result}" except Exception as e: return f"运行失败:{e}" tools = [python_safe_run] systext = "你是一个会调用工具的助手,需要计算时请调用 python_safe_run。" prompt = ChatPromptTemplate.from_messages([ ("system", systext), MessagesPlaceholder(variable_name="history"), ("user", "{input}"), MessagesPlaceholder(variable_name="agent_scratchpad"), ]) agent = create_openai_tools_agent(llm, tools, prompt) agent_executor = AgentExecutor( agent=agent, tools=tools, verbose=True, max_iterations=20, ) memory = ChatMessageHistory() agent_with_memory = RunnableWithMessageHistory( agent_executor, lambda session_id: memory, input_messages_key="input", history_messages_key="history", )

几个关键点必须说清楚。第一,history占位符放在 system 之后、当前 user 之前,这样历史对话会作为上下文出现在当前问题前面,符合对话模型的阅读顺序。第二,agent_scratchpad必须放在最后,因为它是本轮工具调用的临时记录,逻辑上属于“当前这轮正在发生的事”,放在 user 之后才合理。第三,RunnableWithMessageHistory的工厂函数lambda session_id: memory决定了 session_id 到存储对象的映射,生产环境里这里应该换成按 session_id 查 Redis 或数据库。

调用方式:

resp = agent_with_memory.invoke( {"input": "帮我算一下 1+1"}, config={"configurable": {"session_id": "user_1"}}, ) print(resp["output"])

第二次调用时,只要 session_id 还是user_1,上一轮的 Human 和 AI 消息就会被自动塞进history占位符。这就是长期记忆的全部魔法,没有更玄的东西。

4. 一次 invoke 内部:scratchpad 与 history 的协同时序

现在把 AgentExecutor 内部拆开看。假设用户输入“用 Python 分别算 1+1 到 5+5”,一次 invoke 里发生了什么。

进入RunnableWithMessageHistory.invoke后,它先做前置动作:拿 session_id 去存储里取历史消息,塞进input["history"]。然后调用内层AgentExecutor.invoke,此时传入的字典大致是:

{ "history": [HumanMessage("..."), AIMessage("...")], "input": "用 Python 分别算 1+1 到 5+5", "agent_scratchpad": [] }

AgentExecutor 内部维护一个scratchpad_messages = [],然后进入 while 循环,最多跑max_iterations次。每一轮它把当前 scratchpad 塞进 inputs,渲染 prompt 给 LLM。LLM 如果返回 tool_calls,就执行工具,把工具返回的 ToolMessage 追加进 scratchpad,进入下一轮;如果 LLM 不再要求调工具,循环结束,返回最终 AI 消息。

把 5 次计算展开成回合看,scratchpad 是这样累积的:

回合LLM 看到的 scratchpad 内容工具返回后新增
1空ToolMessage("2")
2[ToolMessage("2")]ToolMessage("4")
3[ToolMessage("2"), ToolMessage("4")]ToolMessage("6")
4[ToolMessage("2"), ToolMessage("4"), ToolMessage("6")]ToolMessage("8")
5[..., ToolMessage("8")]ToolMessage("10")

循环结束后,AgentExecutor 返回最终 AI 消息。此时RunnableWithMessageHistory的后置钩子触发,把本轮的(HumanMessage, AIMessage)打包,调用memory.add_messages(new_round)写进 ChatMessageHistory。注意,写进去的只有这一对,scratchpad 里那些 ToolMessage 默认不会进 memory。

所以协同关系可以概括成:history 负责跨 invoke 的对话连续性,scratchpad 负责单次 invoke 内的工具链连续性。两者在 prompt 里各占一个位置,互不覆盖。下一次 invoke 时,scratchpad 重新从空列表开始,history 则从存储里重新加载。

5. 验证上下文是否按预期注入

光看代码不够,得实际验证。我常用的办法是开verbose=True,然后观察每次 LLM 调用时 prompt 的实际内容。AgentExecutor 在 verbose 模式下会打印出每轮发给模型的完整消息列表,你能直接看到 history 和 scratchpad 被替换成了什么。

更精确的做法是加一个自定义回调,在 chain 开始时打印输入:

from langchain_core.callbacks import BaseCallbackHandler class PromptDebugHandler(BaseCallbackHandler): def on_llm_start(self, serialized, prompts, **kwargs): for i, p in enumerate(prompts): print(f"===== LLM 第 {i+1} 次调用,prompt 长度 {len(p)} =====") print(p[:800]) print("===== 截断 =====") debug = PromptDebugHandler()

调用时挂上:

agent_with_memory.invoke( {"input": "把上一步结果再乘以 3"}, config={ "configurable": {"session_id": "user_1"}, "callbacks": [debug], }, )

验证要点有三个。第一,第二次 invoke 时,prompt 里应该能看到上一轮的 Human 和 AI 消息,说明 history 注入成功。第二,如果本轮触发了工具,prompt 里应该能看到 ToolMessage,说明 scratchpad 在单轮内正常累积。第三,跨 invoke 时,上一轮的工具输出不应该出现在新 prompt 里,如果出现了,说明你把 scratchpad 误写进了长期存储。

还可以直接检查 memory 内容:

print(memory.messages)

正常情况下,这里只有成对的 Human 和 AI,没有 ToolMessage。如果发现 ToolMessage 混进来了,那就是记忆钩子被改过,或者哪里手动 add 了。

6. 本篇常见错排查

报错一:Input to ChatPromptTemplate is missing variables {'history'}

原因通常是 prompt 里写了MessagesPlaceholder("history"),但调用时没通过RunnableWithMessageHistory包装,或者包装了但没传history_messages_key="history"。检查包装器的参数名和占位符名是否完全一致,大小写敏感。

报错二:多轮对话串会话

典型表现是 user_1 的历史跑到了 user_2 的对话里。根因在工厂函数lambda session_id: memory返回了同一个全局 memory 对象,所有 session 共用一份。正确做法是按 session_id 建字典或查 Redis:

store = {} def get_history(session_id: str): if session_id not in store: store[session_id] = ChatMessageHistory() return store[session_id] agent_with_memory = RunnableWithMessageHistory( agent_executor, get_history, input_messages_key="input", history_messages_key="history", )

报错三:工具输出下一轮消失

这不是 bug,是默认行为。scratchpad 本来就不进长期存储。如果你确实需要跨轮保留工具细节,得自定义记忆包装器,重载_exit方法,把本轮 scratchpad 一起写进 memory。但要注意,这样会让 history 越来越长,token 消耗上升很快,建议配合截断策略。

报错四:max_iterations触顶后返回空

Agent 陷入工具循环,比如工具一直返回错误、模型反复重试。把max_iterations调大只是拖延,真正要做的是让工具返回明确的失败信息,并在 system prompt 里告诉模型“工具失败时直接告知用户,不要重试”。

报错五:history 占位符位置放错

有人把MessagesPlaceholder("history")放在agent_scratchpad之后,结果历史消息被工具链消息隔开,模型理解错乱。记住顺序:system → history → user → agent_scratchpad,这个顺序不要动。

7. 下一步:把记忆接进真实工程

骨架跑通之后,接下来就是工程化。长期存储从内存换成 Redis,用RedisChatMessageHistory替换ChatMessageHistory,session_id 用真实用户 ID 或对话 ID。如果要做长期编码或 Agent 类应用,可以考虑用 Coding Plan 把模型调用和额度管理统一起来: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。想先在线验证模型对话效果,可以直接用模型对话页面试: https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。接入细节和参数说明在文档里: https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你在用 Claude Code 这类工具,Anthropic 兼容接入的说明在这里: https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=claudecode-anthropic&utm_campaign=rewrite 。

最后留一个我实际调优时的小技巧:在 system prompt 里显式告诉模型“history 是历史对话,agent_scratchpad 是本轮工具记录”,模型对两个占位符的利用会更准确,尤其在工具调用密集的场景下,能明显减少“忘记上一步结果”的情况。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询