第 12 期 | 工具调用 (Tool Calling):让 LLM 具备行动力
(申请发送: agentupdate)
🎯 本期学习目标
嘿,各位未来的 LangChain 全栈大师们!欢迎来到《LangChain 全栈大师课》的第五期。今天,我们要解决一个几乎所有对话式 AI 都绕不开的“老大难”问题——记忆力。你的智能客服小助手,如果每一句话都像第一次见面,那用户体验简直是灾难。本期,我们将:
- 洞悉 AI 的“健忘症”根源:理解为什么大语言模型(LLM)天生是无状态的,以及记忆对构建流畅对话的重要性。
- 掌握 LangChain Memory 模块家族:深入学习 LangChain 提供的各种记忆类型,如 Buffer、Window、Summary 等,了解它们的核心机制和适用场景。
- 为智能客服注入“长期记忆”:通过实战代码,学会如何在我们的“智能客服知识库”项目中集成不同 Memory 模块,让小助手真正拥有上下文感知能力。
- 化解记忆管理中的“坑”:探讨在生产环境中管理记忆时可能遇到的挑战,如 Token 限制、成本、持久化和多用户并发等,并给出高阶解决方案。
📖 原理解析
AI 的“健忘症”:为什么 LLM 本身是无状态的?
同学们,我们都知道,大语言模型(LLM)是当前 AI 领域最耀眼的明星。它们能生成令人惊叹的文本,回答复杂的问题,甚至进行创意写作。但这里有一个你可能没注意到的“小秘密”:LLM 本身是无状态的。
什么意思?简单来说,每次你向 LLM 发送一个请求,它都会将其视为一个全新的、独立的事件。它不会“记住”你上一个请求说了什么,也不会“记住”你们之间已经进行了多少轮对话。每一次调用,都是一次“一锤子买卖”。
这就像你每次和一个人说话,对方都会瞬间失忆,完全不记得你们刚才聊过什么。试想一下,如果你问客服:“我想重置密码。”客服回答:“好的,请问您想重置什么?”然后你再问:“那我的用户名呢?”客服又问:“什么用户名?”——是不是想掀桌子了?
在 LangChain 语境下,我们每次调用一个 Chain 或 Agent,都会向 LLM 传递一个完整的 Prompt。如果我们要让 LLM 记住历史对话,就必须把之前的对话内容也打包进当前的 Prompt 里。这正是 Memory 模块存在的根本原因。它就像一个“记忆中枢”,负责:
- 存储 (Store):把用户和 AI 的对话内容记录下来。
- 检索 (Retrieve):在新的对话轮次开始时,将相关的历史对话提取出来。
- 注入 (Inject):把提取出的历史对话,格式化后作为上下文,注入到发送给 LLM 的 Prompt 中。
- 更新 (Update):完成一轮对话后,将最新的对话内容添加到记忆中。
LangChain Memory 架构概览:打造客服的“最强大脑”
LangChain 的 Memory 模块就是为了解决 LLM 的“健忘症”而生。它提供了一系列开箱即用的工具,让你的 AI 应用能够拥有上下文感知能力。核心思想是,将对话历史作为输入的一部分,动态地传递给 LLM。
下图展示了 Memory 模块在我们智能客服项目中的工作流:
graph TD
subgraph 用户交互
User[用户请求:我想查订单] --> FrontEnd(智能客服前端/API)
end
subgraph LangChain 核心处理
FrontEnd --> LC_Chain{LangChain Agent/Chain}
LC_Chain -- 1. 获取最新用户输入 --> MemModule[Memory 模块]
MemModule -- 2. 检索历史对话 --> LC_Chain
LC_Chain -- 3. 结合当前输入与历史对话,构建完整 Prompt --> LLM_API(大语言模型 API)
LLM_API -- 4. 生成回复 --> LC_Chain
LC_Chain -- 5. 将当前对话轮次存入 --> MemModule
MemModule -- 6. 更新记忆状态 --> DB(持久化存储,可选)
LC_Chain -- 7. 返回回复 --> FrontEnd
end
FrontEnd --> UserResponse[智能客服回复:请提供订单号]图解说明:
- 用户请求:用户通过前端界面发送消息。
- LangChain Chain/Agent 接收:智能客服的核心逻辑(由 LangChain Chain 或 Agent 封装)接收到用户请求。
- Memory 模块介入:在将请求发送给 LLM 之前,Chain/Agent 会与 Memory 模块交互。
- 检索历史对话:Memory 模块根据当前会话 ID 检索相关的历史对话内容。
- 构建 Prompt:将当前用户输入和检索到的历史对话结合起来,形成一个包含完整上下文的 Prompt。
- 调用 LLM:将构建好的 Prompt 发送给大语言模型 API。
- LLM 生成回复:LLM 根据上下文生成智能回复。
- Memory 模块更新:Chain/Agent 收到 LLM 的回复后,会将当前的用户输入和 LLM 的回复一并存入 Memory 模块,更新对话历史。
- 可选持久化:如果配置了持久化,Memory 模块会将记忆存储到数据库中,以便跨会话或服务重启后依然能保留记忆。
- 返回回复:智能客服将回复返回给用户。
通过这个流程,我们的智能客服小助手就拥有了“记忆力”,能够理解多轮对话的上下文,提供更自然、更连贯的服务。
LangChain 核心 Memory 类型:你的客服该用哪种“脑回路”?
LangChain 提供了多种 Memory 类型,每种都有其独特的优势和适用场景。理解它们的工作原理,是选择正确“脑回路”的关键。
1. ConversationBufferMemory:最简单直观的“记忆缓冲区”
- 原理:它就像一个无限大的笔记本,将用户和 AI 的每一句话都原封不动地记录下来,并直接作为历史对话传递给 LLM。
- 优点:实现简单,信息完整,不会丢失任何细节。
- 缺点:随着对话轮次增加,历史对话会越来越长,可能迅速超出 LLM 的 Token 限制,并增加 API 成本。
- 适用场景:短对话、测试阶段、对上下文完整性要求极高且对话轮次可控的场景。
2. ConversationBufferWindowMemory:有限窗口的“滑动记忆”
- 原理:与
ConversationBufferMemory类似,但它只保留最近k轮对话。当新的对话进入时,最旧的对话会被“推出”窗口,就像一个滑动窗口。 - 优点:有效控制历史对话长度,避免 Token 溢出,降低成本。
- 缺点:超过
k轮的早期对话会被完全遗忘,可能丢失重要上下文。 - 适用场景:大部分客服场景,既需要上下文,又需要控制 Token 成本,且用户通常只关注最近几轮对话。
3. ConversationSummaryMemory:化繁为简的“记忆摘要”
- 原理:它不存储原始对话,而是定期使用 LLM 对历史对话进行“总结”,生成一个简洁的摘要。新的对话会结合这个摘要和当前输入一起发送给 LLM。
- 优点:极大地压缩了历史对话的长度,显著节省 Token,适合长对话和需要长期记忆的场景。
- 缺点:总结过程本身会消耗 LLM Token,且总结可能会丢失一些细枝末节的信息。总结的质量高度依赖于 LLM 的表现。
- 适用场景:需要长时间会话、对 Token 成本敏感、对对话细节要求不那么苛刻的客服场景,例如长期跟踪一个复杂工单。
4. ConversationSummaryBufferMemory:摘要与窗口的“混合记忆”
- 原理:结合了
ConversationBufferWindowMemory和ConversationSummaryMemory的优点。它会保留最近k轮的完整对话(窗口记忆),同时对更早的对话进行总结(摘要记忆)。当窗口内的对话也超出 Token 限制时,它会触发总结。 - 优点:兼顾了短期对话的细节和长期对话的简洁性,是很多复杂场景的理想选择。
- 缺点:实现相对复杂,管理两种记忆模式。
- 适用场景:既需要关注近期细节,又需要回顾早期概况的复杂客服流程。
5. VectorStoreRetrieverMemory:语义匹配的“外部知识库记忆”
- 原理:这种记忆类型不直接存储对话文本,而是将对话轮次嵌入为向量,并存储到向量数据库(VectorStore)中。当需要检索记忆时,它会根据当前输入进行语义搜索,找出最相关的历史对话片段。
- 优点:可以处理海量历史数据,只检索最相关的上下文,避免 Token 爆炸,实现“长期记忆”和“选择性记忆”。
- 缺点:需要额外的向量数据库基础设施,检索效果依赖于嵌入模型和向量数据库的性能,实现相对复杂。
- 适用场景:客服小助手需要从海量历史对话、用户文档或产品手册中检索信息,实现更智能、更精准的辅助。这正是我们“智能客服知识库”项目后期会重点探索的方向!
在本期,我们将主要聚焦于前三种基础且常用的 Memory 类型,为你打下坚实的基础。
💻 实战代码演练 (客服项目中的具体应用)
好了,理论听起来很酷,但代码才是王道!现在,让我们把这些记忆模块应用到我们的“智能客服知识库”项目中。想象一下,用户正在和我们的客服小助手交流,询问产品功能、故障排查。
我们将使用 Python 和 LangChain 来构建这些带有记忆的对话链。为了演示方便,我们会使用一个 MockLLM 来模拟大语言模型的行为,这样你无需配置真实的 API Key 也能运行代码。当然,在实际项目中,你只需将其替换为 ChatOpenAI 或其他真实 LLM 实例即可。
import os
from langchain.memory import (
ConversationBufferMemory,
ConversationBufferWindowMemory,
ConversationSummaryMemory,
ConversationSummaryBufferMemory
)
from langchain_core.prompts import PromptTemplate
from langchain.chains import LLMChain, ConversationChain
from langchain_core.messages import AIMessage, HumanMessage
# 为了演示,我们使用一个Mock LLM,这样你无需配置真实的API Key
# 在生产环境中,你会用 ChatOpenAI 或其他 LLM 替代
class MockLLM:
"""
一个简单的模拟大语言模型,用于演示LangChain Memory模块。
它会根据输入模拟一些常见回复,并模拟总结行为。
"""
def __init__(self, response_delay=0):
self.response_delay = response_delay
def invoke(self, prompt: str) -> str:
"""
模拟LLM的调用,根据prompt内容给出预设回复。
"""
import time
time.sleep(self.response_delay) # 模拟网络延迟或计算时间
# 检查prompt中是否包含历史对话,这些通常由memory_key注入
if "历史对话:" in prompt:
# 简单地截取历史对话部分,避免回复过于冗长
history_start = prompt.find("历史对话:")
history_end = prompt.find("\n当前用户:")
if history_start != -1 and history_end != -1 and history_start < history_end:
history = prompt[history_start:history_end].strip()
else:
history = "无历史对话"
else:
history = "无历史对话"
if "总结以下对话:" in prompt:
# 模拟总结行为
return "这是一段关于用户咨询产品功能和故障排除的对话总结。"
elif "你好" in prompt or "Hello" in prompt:
return "你好!我是你的智能客服小助手,很高兴为你服务。有什么可以帮你的吗?"
elif "重置密码" in prompt:
return "重置密码请访问我们的官方网站,点击'忘记密码'链接,按照指示操作即可。"
elif "用户名" in prompt:
return "找回用户名需要您提供注册时的邮箱或手机号进行验证,请问您方便提供吗?"
elif "产品功能" in prompt:
return "我们的产品主要有A、B、C三大核心功能,您具体想了解哪一个呢?"
elif "故障" in prompt or "出问题" in prompt:
return "很抱歉给您带来不便。请问您遇到了什么具体的问题?我将尝试为您排查。"
elif "谢谢" in prompt or "感谢" in prompt:
return "不客气!很高兴能帮到您。还有其他问题吗?"
else:
# 默认回复,包含对当前输入的简单反馈
return f"我收到了你的消息:'{prompt.split('当前用户:')[-1].strip() if '当前用户:' in prompt else prompt}'。基于我们之前的交流({history}),请问还有什么可以为您解答的?"
# 实例化我们的模拟LLM
llm = MockLLM()
# --- 1. ConversationBufferMemory: 最直接的记忆方式 ---
print("--- 演示 ConversationBufferMemory ---")
# Prompt模板,包含一个占位符用于注入历史对话
template_buffer = """
你是一个友好的智能客服小助手,请根据历史对话和当前用户提问,给出专业且有帮助的回复。
历史对话:
{history}
当前用户: {input}
智能客服:
"""
prompt_buffer = PromptTemplate.from_template(template_buffer)
# 实例化记忆模块
# memory_key 默认为 'history',output_key 默认为 'output'
buffer_memory = ConversationBufferMemory(memory_key="history")
# 构建一个ConversationChain,它会自动处理prompt和memory
# verbose=True 会打印出Chain的详细运行过程,方便调试
buffer_conversation = ConversationChain(
llm=llm,
memory=buffer_memory,
prompt=prompt_buffer,
verbose=True
)
# 模拟多轮对话
print("\n--- 第一轮对话 ---")
response1 = buffer_conversation.invoke({"input": "你好,我想咨询一下你们的产品功能。"})
print(f"客服回复: {response1['response']}")
# 检查记忆内容
print(f"\n当前记忆内容:\n{buffer_memory.load_memory_variables({})}")
print("\n--- 第二轮对话 ---")
response2 = buffer_conversation.invoke({"input": "主要有哪些核心功能呢?"})
print(f"客服回复: {response2['response']}")
print(f"\n当前记忆内容:\n{buffer_memory.load_memory_variables({})}")
print("\n--- 第三轮对话 (模拟无关问题,看记忆是否完整) ---")
response3 = buffer_conversation.invoke({"input": "我最近电脑有点卡,该怎么办?"}) # 这是一个与产品功能无关的问题
print(f"客服回复: {response3['response']}")
print(f"\n当前记忆内容:\n{buffer_memory.load_memory_variables({})}")
# 可以看到,buffer_memory 完整地记录了所有对话,包括无关的。
# 随着对话轮次增加,'history' 会越来越长。
# --- 2. ConversationBufferWindowMemory: 窗口记忆,控制长度 ---
print("\n\n--- 演示 ConversationBufferWindowMemory ---")
# Prompt模板可以复用,因为只是记忆策略改变,注入的history格式不变
template_window = """
你是一个友好的智能客服小助手,请根据最近的对话历史和当前用户提问,给出专业且有帮助的回复。
最近对话:
{history}
当前用户: {input}
智能客服:
"""
prompt_window = PromptTemplate.from_template(template_window)
# 实例化窗口记忆模块,只保留最近2轮(4条消息:2用户+2AI)对话
window_memory = ConversationBufferWindowMemory(memory_key="history", k=2)
window_conversation = ConversationChain(
llm=llm,
memory=window_memory,
prompt=prompt_window,
verbose=True
)
# 模拟多轮对话
print("\n--- 第一轮对话 ---")
window_conversation.invoke({"input": "你好,我遇到一个产品登录问题。"})
print(f"当前记忆内容:\n{window_memory.load_memory_variables({})}")
print("\n--- 第二轮对话 ---")
window_conversation.invoke({"input": "我输入了正确的用户名和密码,但一直提示错误。"})
print(f"当前记忆内容:\n{window_memory.load_memory_variables({})}")
print("\n--- 第三轮对话 (超出窗口,最旧的被移除) ---")
window_conversation.invoke({"input": "请问是网络问题还是账户被锁定?"})
print(f"当前记忆内容:\n{window_memory.load_memory_variables({})}")
# 注意观察,第一轮对话已经被移出了记忆,只保留了最近两轮。
print("\n--- 第四轮对话 (继续超出窗口) ---")
window_conversation.invoke({"input": "那我要怎么排查呢?"})
print(f"当前记忆内容:\n{window_memory.load_memory_variables({})}")
# 记忆中只剩下最近的两轮。
# --- 3. ConversationSummaryMemory: 摘要记忆,节省Token ---
print("\n\n--- 演示 ConversationSummaryMemory ---")
# 摘要记忆需要LLM来生成总结,所以我们的MockLLM需要能处理“总结”请求
template_summary = """
你是一个友好的智能客服小助手,请根据对话的总结和当前用户提问,给出专业且有帮助的回复。
对话总结:
{history}
当前用户: {input}
智能客服:
"""
prompt_summary = PromptTemplate.from_template(template_summary)
# 实例化摘要记忆模块,需要传入一个LLM来做总结
summary_memory = ConversationSummaryMemory(llm=llm, memory_key="history")
summary_conversation = ConversationChain(
llm=llm,
memory=summary_memory,
prompt=prompt_summary,
verbose=True
)
# 模拟多轮对话
print("\n--- 第一轮对话 ---")
summary_conversation.invoke({"input": "你好,我的订单状态显示已发货,但我还没收到。"})
print(f"当前记忆内容:\n{summary_memory.load_memory_variables({})}")
# 此时,history 应该还是空的,因为还没到需要总结的程度。
print("\n--- 第二轮对话 ---")
summary_conversation.invoke({"input": "订单号是 ABC123456789。"})
print(f"当前记忆内容:\n{summary_memory.load_memory_variables({})}")
# 此时,memory 内部可能已经对前两轮进行了初步总结。
print("\n--- 第三轮对话 (触发总结) ---")
# 摘要记忆会在内部累积对话,达到一定长度或每次对话后,会调用LLM进行总结
# 在这个MockLLM的场景下,我们假设它在第二轮结束后就会总结
summary_conversation.invoke({"input": "请帮我查询一下物流信息。"})
print(f"当前记忆内容:\n{summary_memory.load_memory_variables({})}")
# 观察 history,它不再是原始对话,而是一个由LLM生成的摘要。
# 实际的 ConversationSummaryMemory 会在每次对话后更新总结。
# --- 4. ConversationSummaryBufferMemory: 结合窗口和摘要 ---
print("\n\n--- 演示 ConversationSummaryBufferMemory ---")
# 模板与 summary 类似,因为它最终也是以总结的形式呈现历史
template_summary_buffer = """
你是一个友好的智能客服小助手,请根据对话的总结和最近的对话历史,结合当前用户提问,给出专业且有帮助的回复。
对话总结:
{history}
当前用户: {input}
智能客服:
"""
prompt_summary_buffer = PromptTemplate.from_template(template_summary_buffer)
# 实例化摘要缓冲区记忆模块,max_token_limit 控制何时触发总结
# 超过 max_token_limit 后,最旧的完整对话会被总结,以腾出空间。
summary_buffer_memory = ConversationSummaryBufferMemory(
llm=llm,
max_token_limit=100, # 这里的100 token是模拟值,实际会根据LLM的tokenizer计算
memory_key="history"
)
summary_buffer_conversation = ConversationChain(
llm=llm,
memory=summary_buffer_memory,
prompt=prompt_summary_buffer,
verbose=True
)
# 模拟多轮对话
print("\n--- 第一轮对话 ---")
summary_buffer_conversation.invoke({"input": "你好,我想了解一下你们的服务条款。"})
print(f"当前记忆内容:\n{summary_buffer_memory.load_memory_variables({})}")
# 此时可能还是完整对话
print("\n--- 第二轮对话 ---")
summary_buffer_conversation.invoke({"input": "特别是关于退款政策的部分。"})
print(f"当前记忆内容:\n{summary_buffer_memory.load_memory_variables({})}")
print("\n--- 第三轮对话 (可能触发总结或部分总结) ---")
summary_buffer_conversation.invoke({"input": "如果我在购买后7天内申请退款,能全额退吗?"})
print(f"当前记忆内容:\n{summary_buffer_memory.load_memory_variables({})}")
# 观察 history,你会发现它会保留最近的完整对话,而更早的对话则被总结。
# 如果对话继续进行,当完整对话部分超出max_token_limit时,最旧的完整对话会被LLM总结成更短的文本,
# 并添加到总结部分,从而腾出空间给新的完整对话。
代码解析与客服项目应用:
MockLLM:我们用它模拟了真实的 LLM 行为,包括对特定关键词的回复以及对“总结”请求的响应。在你的实际项目中,这里会是ChatOpenAI(temperature=0.7)或其他 LLM。ConversationBufferMemory:最基础的记忆。适用于客服小助手处理一些简单、短期的咨询,例如:“请问你们营业时间?”“周一到周五上午9点到下午6点。”“谢谢。”这种场景下,即使是完整的记忆也不会占用太多 Token。ConversationBufferWindowMemory:这是最常用的记忆类型之一。对于客服小助手来说,用户通常只关心最近几轮的对话。例如,用户咨询了产品功能,然后问了价格。客服只需要记住最近的产品功能讨论,而不需要记住一个月前的咨询。k=2意味着只保留最近 2 轮对话(用户问 + AI 答)。ConversationSummaryMemory:当用户与客服小助手进行长时间、复杂的问题排查时,比如一个跨越数小时甚至数天的故障诊断,完整记忆会迅速膨胀。这时,ConversationSummaryMemory就能派上用场。它会定期将之前的对话总结成一小段文本,这样即使对话持续很长时间,LLM 接收到的历史上下文也始终保持在一个可控的长度,极大地节省了 Token 成本。ConversationSummaryBufferMemory:这是前两者的智能结合。在排查一个复杂问题时,用户可能需要客服小助手记住最近几步操作的细节(窗口记忆),同时也要对之前的大概问题背景有所了解(摘要记忆)。这个模块完美地平衡了细节和概括性,是构建高级客服小助手的利器。
通过这些实战演练,你应该对 LangChain 的 Memory 模块有了更直观的理解。选择哪种记忆策略,取决于你的智能客服小助手需要处理的对话类型、对话长度以及你对 Token 成本的考量。
坑与避坑指南
作为一名有经验的架构师,我必须提醒你,Memory 虽然强大,但也并非没有“坑”。稍不注意,就可能掉进成本黑洞、性能瓶颈甚至数据泄露的陷阱。
1. Token 限制与成本:警惕“记忆溢出”
- 坑:无限制地使用
ConversationBufferMemory,或者ConversationBufferWindowMemory的k值设置过大,会导致历史对话 Token 迅速膨胀。这不仅可能超出 LLM 的上下文窗口限制,导致对话中断或效果变差,更会急剧增加你的 API 调用成本。大模型是按 Token 计费的,历史对话越长,每次调用花钱越多。 - 避坑指南:
- 合理选择 Memory 类型:大部分客服场景,
ConversationBufferWindowMemory是一个很好的起点,将k设置为 2-5 轮通常足够。对于长对话,优先考虑ConversationSummaryMemory或ConversationSummaryBufferMemory。 - 监控 Token 使用:在开发和测试阶段,集成 Token 计数器(如
tiktoken)来估算每次调用的 Token 数量,并结合 LLM 提供商的计费模型,预估成本。 - 限制单轮对话长度:在 Prompt 设计时,明确告知 LLM 保持回复简洁,或通过 Agent 逻辑控制回复的输出长度。
- 合理选择 Memory 类型:大部分客服场景,
2. 记忆的持久化:服务重启,记忆还在吗?
- 坑:LangChain 默认的 Memory 模块(如
ConversationBufferMemory)只是内存中的对象。一旦你的 Python 进程重启、服务器宕机,或者用户切换了会话(例如从网页端切换到手机端),所有的历史对话都会丢失,用户体验将大打折扣。 - 避坑指南:
- 集成外部存储:在生产环境中,你几乎总是需要将记忆持久化到外部存储。LangChain 提供了多种持久化 Memory 的选项,例如
PostgresChatMessageHistory、RedisChatMessageHistory等。 - 实现会话 ID 管理:为每个用户或每个会话生成一个唯一的 ID,并将其与存储在数据库中的聊天记录关联起来。这样,当用户再次访问时,你可以根据会话 ID 重新加载历史对话。
- 示例 (概念性代码,实际需配置数据库连接):
# from langchain_community.chat_message_histories import RedisChatMessageHistory # from langchain.memory import ConversationBufferWindowMemory # # # 假设你已经配置了 Redis 连接 # session_id = "user_123_session_abc" # 每个用户/会话的唯一ID # message_history = RedisChatMessageHistory(session_id=session_id, url="redis://localhost:6379/0") # # persisted_memory = ConversationBufferWindowMemory( # memory_key="history", # k=5, # chat_memory=message_history # 将 Redis 历史集成到 Memory 中 # )
- 集成外部存储:在生产环境中,你几乎总是需要将记忆持久化到外部存储。LangChain 提供了多种持久化 Memory 的选项,例如