拓冰建站拓冰建站
首页 / 资讯中心 / 正文

LangGraph自定义状态与归约器:构建智能体记忆系统的核心技术

1. 项目概述为什么我们需要自定义状态如果你已经开始用LangGraph构建智能体大概率已经体验过它内置的StateGraph和MessagesState带来的便利。开箱即用消息列表自动管理确实省心。但当你试图构建一个稍微复杂点的应用比如一个需要记住用户偏好、维护对话历史摘要、或者跟踪多轮任务执行状态的客服机器人时你可能会立刻感到束手束脚。内置的MessagesState就像一个标准尺寸的行李箱装日常用品没问题但一旦你想带点特殊装备它就塞不下了。这就是自定义状态Custom State和归约器Reducer登场的时刻。它们不是LangGraph的高级功能而是构建真正实用、健壮应用的核心基石。简单来说自定义状态让你能定义智能体的“记忆体”里到底存什么而归约器则定义了当新信息涌入时如何更新这份记忆。这就像给你的智能体配备了一个可自定义结构、且有智能整理规则的笔记本而不是一个只能往里扔纸条的盒子。在社区里我看到很多新手卡在“如何让智能体记住上次对话中用户提到的公司名称”或者“如何累计计算任务步骤”这类问题上。答案往往就藏在TypedDict和Annotated这两个Python特性与LangGraph的结合中。通过本篇文章我将带你从“知道有这回事”到“能亲手设计并实现”彻底掌握如何为你的LangGraph智能体扩容记忆让它真正变得“聪明”起来。2. 核心概念拆解TypedDict、Annotated与Reducer在动手写代码之前我们必须把几个核心概念和它们之间的关系理清楚。很多教程直接扔代码但如果不明白背后的设计哲学一旦需求变化你还是会无从下手。2.1 TypedDict定义状态的“数据结构”TypedDict是Python 3.8中typing模块提供的一个工具用于为字典指定明确的键和值的类型。在LangGraph中我们用它来声明状态的结构。为什么不用普通的Dict或者Pydantic模型TypedDict在提供类型安全方便IDE提示和静态类型检查的同时保持了字典的灵活性并且与LangGraph底层对状态的序列化/反序列化处理方式非常契合。Pydantic模型当然也可以用但TypedDict更轻量是LangGraph官方推荐和内部大量使用的模式。一个典型的状态定义如下from typing import TypedDict, List from langchain_core.messages import BaseMessage class AgentState(TypedDict): # 对话消息历史 messages: List[BaseMessage] # 用户偏好的产品类型 preferred_category: str # 本次会话中查询过的产品ID列表 queried_product_ids: List[str] # 对话轮次计数 turn_count: int这个AgentState类型定义了一个字典它必须有messages、preferred_category、queried_product_ids和turn_count这四个键并且值的类型也必须匹配。这就从结构上规范了你的状态避免了键名拼写错误或赋值类型错误导致的运行时诡异问题。2.2 Annotated 与 Reducer定义状态的“更新规则”仅有结构定义还不够。当一个新的值需要更新到状态中时LangGraph需要知道如何操作。是直接覆盖追加到列表还是进行数值累加这就是归约器Reducer的工作。在LangGraph中我们使用typing.Annotated来将状态字段的类型与一个归约器函数关联起来。Annotated[X, Y]可以理解为“类型为X但附加上元数据Y”。在这里X是字段的类型Y就是处理更新逻辑的归约器。归约器Reducer的本质是一个函数它接收两个参数当前状态值current和新输入的值update然后返回合并后的新值。LangGraph内置了几个最常用的归约器add_messages: 专用于List[BaseMessage]。将新的消息列表追加到现有消息列表的末尾。这是对话系统的核心。add: 用于列表。将新列表中的所有元素追加到当前列表中。replace: 直接替换。用新值完全覆盖旧值。如果没有用Annotated指定归约器LangGraph默认使用replace也就是直接覆盖。这对于preferred_category这样的字段可能是合适的用户最新说的偏好覆盖旧的但对于messages或queried_product_ids直接覆盖就意味着历史丢失这显然是灾难性的。因此正确的状态定义应该是from typing import TypedDict, List, Annotated from langgraph.graph.message import add_messages from langchain_core.messages import BaseMessage class AgentState(TypedDict): messages: Annotated[List[BaseMessage], add_messages] # 消息追加 preferred_category: str # 默认replace直接替换 queried_product_ids: Annotated[List[str], add] # 列表追加 turn_count: int # 默认replace但我们需要累加怎么办看到问题了吗turn_count我们希望每次对话轮次加1但内置归约器没有sum或increment。这引出了下一个关键点自定义归约器。2.3 自定义归约器实现任意更新逻辑当内置归约器无法满足需求时你需要自己写。自定义归约器就是一个遵循(current, update) - new签名的简单函数。例如实现一个increment归约器用于计数器def increment(current: int, update: int) - int: 归约器将update加到current上。update通常为1但也可传递其他数值。 return current update # 在状态定义中使用 class AgentState(TypedDict): # ... 其他字段 turn_count: Annotated[int, increment] # 使用自定义归约器现在在节点函数中你只需要返回{turn_count: 1}状态中的turn_count就会自动加1。如果你想一次加5就返回{turn_count: 5}。归约器的逻辑完全由你控制。再举一个复杂点的例子维护一个对话历史摘要summarydef update_summary(current: str, update: str) - str: 归约器将新的摘要片段update以特定格式追加到当前摘要current之后。 if not current: return update return f{current}\n---\n{update} class AgentState(TypedDict): # ... 其他字段 conversation_summary: Annotated[str, update_summary]关键理解Annotated和归约器机制是LangGraph实现可预测状态管理的基石。它明确规定了每个字段的更新方式使得无论从哪个节点、以何种顺序更新状态结果都是确定性的。这对于构建复杂、可能含有循环或分支的工作流至关重要。3. 实战构建一个具有记忆的客户服务智能体理论说得再多不如动手实现一个。我们来构建一个模拟的电商客户服务智能体它需要记住用户偏好的商品类别。记录本次会话中用户询问过的所有产品ID。统计对话轮次。维护核心的对话消息历史。3.1 定义完整的状态结构首先我们定义出完整的状态类型。这里我们会综合运用默认替换、内置归约器和自定义归约器。from typing import TypedDict, List, Annotated from langchain_core.messages import BaseMessage, HumanMessage, AIMessage from langgraph.graph.message import add_messages, add # 自定义归约器计数器累加 def increment(current: int, update: int) - int: return current update # 自定义归约器更新最后活跃时间戳假设为字符串格式 def update_last_active(current: str, update: str) - str: # 总是用最新的时间戳替换旧的 return update class CustomerServiceState(TypedDict): 电商客服智能体的状态定义 # 核心对话历史使用内置的消息追加归约器 messages: Annotated[List[BaseMessage], add_messages] # 用户当前偏好的商品类别如“笔记本电脑”、“蓝牙耳机” # 未使用Annotated默认使用replace归约器最新查询会覆盖旧值 preferred_category: str # 本次会话中用户明确询问过的产品ID列表使用列表追加归约器 queried_product_ids: Annotated[List[str], add] # 对话轮次计数器使用自定义的累加归约器 turn_count: Annotated[int, increment] # 用户最后活跃时间模拟使用自定义的替换归约器 last_active: Annotated[str, update_last_active] # 一个标志位表示是否需要转接人工客服 # 默认replace一旦被设置为True除非特定重置节点否则会保持True need_human_help: bool3.2 创建节点函数如何读写状态定义了状态结构接下来就要在节点函数中使用它。节点函数接收一个state字典其结构符合CustomerServiceState并返回一个字典这个字典包含了要更新到状态中的字段和值。LangGraph会根据状态定义中的归约器自动将返回值合并到当前状态中。from datetime import datetime def process_user_input(state: CustomerServiceState): 节点函数处理用户输入提取关键信息更新状态。 # 1. 获取最新的用户消息 last_message state[messages][-1] if not isinstance(last_message, HumanMessage): # 理论上不会这里只是防御性编程 return {} user_input last_message.content # 2. 初始化本次节点要更新的状态字典 updates {} # 3. 更新对话轮次触发increment归约器current值会自动传入 updates[turn_count] 1 # 每次调用此节点轮次1 # 4. 更新最后活跃时间触发update_last_active归约器 updates[last_active] datetime.now().isoformat() # 5. 模拟从用户输入中提取信息 # 例如简单判断是否包含类别关键词 if 笔记本 in user_input or 电脑 in user_input: updates[preferred_category] 笔记本电脑 # 触发replace elif 耳机 in user_input: updates[preferred_category] 蓝牙耳机 # 模拟提取产品ID假设输入中有类似“我想看下产品ABC-123”的句子 # 这里用一个简单的正则示例实际应用可能需要更复杂的NLP import re product_id_match re.search(r产品\s*([A-Z0-9-]), user_input) if product_id_match: product_id product_id_match.group(1) # 将产品ID追加到列表触发add归约器 updates[queried_product_ids] [product_id] # 6. 判断是否需要人工客服 if 转人工 in user_input or 人工客服 in user_input: updates[need_human_help] True # 触发replace # 返回要更新的字段字典 return updates def generate_agent_response(state: CustomerServiceState): 节点函数生成客服助手的回复。 # 基于当前完整状态生成回复 # 这里为了演示我们生成一个简单的文本回复实际中会调用LLM context_info f 对话轮次{state.get(turn_count, 0)} 用户偏好{state.get(preferred_category, 暂无)} 查询过的产品{, .join(state.get(queried_product_ids, [])) or 无} # 模拟一个回复 if state.get(need_human_help): response_text 已收到您转接人工客服的请求正在为您排队请稍候。\n context_info else: response_text f您好我是客服助手。根据您的历史{context_info} 请问还有什么可以帮您 # 关键将助手的回复作为消息追加到状态中 # 返回的字典中messages键对应的值将触发add_messages归约器 updates { messages: [AIMessage(contentresponse_text)] } # 也可以同时更新其他状态比如对话轮次在process_user_input已经更新了这里可以不再更新 return updates3.3 组装图并运行测试现在我们将节点组装到图中并观察状态如何随着工作流演变。from langgraph.graph import StateGraph, END # 1. 创建图并指定我们自定义的状态类型 workflow StateGraph(CustomerServiceState) # 2. 添加节点 workflow.add_node(process_input, process_user_input) workflow.add_node(generate_response, generate_agent_response) # 3. 设置边构建一个简单的线性流程处理输入 - 生成回复 - 结束 workflow.set_entry_point(process_input) workflow.add_edge(process_input, generate_response) workflow.add_edge(generate_response, END) # 4. 编译图 app workflow.compile() # 5. 初始化状态并运行 initial_state: CustomerServiceState { messages: [HumanMessage(content你好我想看看笔记本电脑。)], preferred_category: , queried_product_ids: [], turn_count: 0, last_active: , need_human_help: False, }让我们用stream方式运行观察每一步的状态变化# 第一次调用 for event in app.stream(initial_state, stream_modevalues): print(event) print(- * 50)输出会展示经过process_input和generate_response节点后的完整状态。你会看到turn_count变为1preferred_category变为“笔记本电脑”messages列表里追加了助手的回复。接着我们模拟第二轮对话基于上一轮结束的状态继续# 获取第一轮结束后的状态在实际流式API中你需要保存这个状态 # 这里我们手动模拟第二轮的用户输入 next_state { messages: [HumanMessage(content刚才说的那个产品ABC-123的配置能再详细点吗)], # 其他字段不需要在输入中提供图会从上一轮最终状态继承 } print(\n 第二轮对话 ) for event in app.stream(next_state, stream_modevalues): print(event) print(- * 50)在第二轮的状态输出中你将看到turn_count从1变成了2increment归约器生效。queried_product_ids列表里追加了“ABC-123”add归约器生效。preferred_category保持为“笔记本电脑”因为本轮输入未提及新类别该字段未在updates中返回故不被更新。messages列表包含了所有历史消息和本轮的新消息add_messages归约器生效。这就是自定义状态和归约器的威力你只需要在节点函数中关心本次需要“更新”什么状态的持久化、合并逻辑完全由框架自动、可靠地处理。4. 高级模式与最佳实践掌握了基础用法后我们来看一些更深入的模式和实践中容易踩的坑。4.1 状态字段的初始化与空值处理在上面的例子中我们初始化状态时给了所有字段一个初始值如空列表、空字符串、0。这是非常推荐的做法。虽然LangGraph不强制但未初始化的字段在首次被节点更新时归约器函数接收到的current参数会是None。如果你的归约器没有处理None的情况就会导致运行时错误。最佳实践始终为状态定义中的所有字段提供合理的初始值。对于使用add或add_messages的列表字段初始化为空列表[]对于使用replace的字符串或布尔字段初始化为空字符串或False对于使用自定义归约器的字段根据归约器逻辑决定如increment的计数器初始化为0。4.2 复杂归约器与副作用归约器应该是纯函数吗理想情况下是的它只根据current和update计算新值。但有时你可能需要基于状态更新触发一些副作用比如当need_human_help变为True时发送一个通知。不建议在归约器函数内直接写发送邮件、调用API等副作用。因为归约器可能在图编译、优化或重放时被多次调用。正确的做法是将副作用放在一个专门的节点中。这个节点检查状态如state[“need_human_help”]如果条件满足则执行操作并可能返回一个更新来重置该标志位。4.3 状态序列化与持久化当你将图部署为长期运行的服务时状态需要在对话之间持久化。LangGraph的状态一个字典通常是可JSON序列化的前提是你使用的类型如BaseMessage本身支持序列化。关键步骤在每轮对话结束后获取最终状态final_state。使用json.dumps()序列化状态。对于包含BaseMessage的状态LangChain提供了messages_to_dict和messages_from_dict等工具函数来辅助。将序列化后的字符串存储到数据库如Redis、PostgreSQL或会话存储中键通常与用户或会话ID关联。下次对话开始时从存储中反序列化状态并将其作为initial_state传入app.stream()。import json from langchain_core.messages import messages_to_dict, messages_from_dict def serialize_state(state: CustomerServiceState) - str: 序列化状态特别注意messages字段的处理。 state_copy dict(state) # 将BaseMessage列表转换为字典列表 state_copy[messages] messages_to_dict(state[messages]) return json.dumps(state_copy) def deserialize_state(state_str: str) - CustomerServiceState: 反序列化状态。 state_dict json.loads(state_str) # 将字典列表转换回BaseMessage列表 state_dict[messages] messages_from_dict(state_dict[messages]) # 确保其他字段类型正确这里依赖json.loads的基础类型转换 return state_dict4.4 调试技巧如何观察状态变化对于复杂的工作流状态如何一步步变化可能不直观。除了使用stream_modevalues查看每个节点后的状态你还可以在节点函数内部加入详细的日志。def process_user_input(state: CustomerServiceState): import logging logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) logger.info(f进入节点。当前状态: { {k: v for k, v in state.items() if k ! messages} }) logger.info(f最新消息: {state[messages][-1].content[:100]}...) # ... 处理逻辑 ... logger.info(f本节点即将更新: {updates}) return updates更高级的调试可以使用LangGraph的追踪Tracing功能将其集成到LangSmith等平台可视化每一步的状态变迁。5. 常见问题与避坑指南在实际项目中我遇到了不少关于状态管理的“坑”。这里总结一下希望能帮你节省时间。问题1为什么我的列表字段每次都被覆盖而不是追加症状在节点中返回{my_list: [new_item]}结果状态中my_list永远只有最后一个new_item。原因状态定义中该字段没有使用Annotated[List[...], add]而是直接定义为my_list: List[str]。这导致LangGraph使用了默认的replace归约器。解决检查状态类定义确保需要追加的列表字段使用了Annotated并指定了add归约器。问题2自定义归约器不生效状态值没有变化。排查步骤检查函数签名确保是def reducer(current: T, update: T) - T。update的类型注解很重要它决定了节点返回的更新值应该是什么类型。检查节点返回值确保节点函数返回的字典中对应键的值类型与归约器期望的update类型匹配。例如increment期望update是int如果你返回{turn_count: True}就会出错。检查初始值首次更新时current可能是None或初始值。确保你的归约器能正确处理这些情况。问题3状态变得异常庞大特别是messages字段影响性能和成本。背景add_messages会无限制地累积所有历史消息长对话会导致状态巨大增加内存消耗和LLM调用的令牌数。解决方案摘要化Summarization添加一个定期运行的节点将旧的messages通过LLM总结成一段浓缩的conversation_summary然后从状态中清除或归档旧消息。这需要你设计一个包含summary字段和相应归约器的状态。滑动窗口Sliding Window实现一个自定义归约器只保留最近N条消息。from typing import List from langchain_core.messages import BaseMessage def sliding_window_messages(current: List[BaseMessage], update: List[BaseMessage], window_size: int 10) - List[BaseMessage]: 保留最近window_size条消息。 combined current update return combined[-window_size:] if len(combined) window_size else combined # 在状态定义中使用注意Annotated目前不支持带额外参数的函数需要柯里化或使用类 # 需要定义一个工厂函数 def create_sliding_window_reducer(window_size): def reducer(current: List[BaseMessage], update: List[BaseMessage]) - List[BaseMessage]: combined current update return combined[-window_size:] if len(combined) window_size else combined return reducer # 在状态类中假设 messages: Annotated[List[BaseMessage], create_sliding_window_reducer(10)]外部存储将完整历史存储在外部数据库如向量库状态中只保留最近几条消息或摘要。问题4多个节点同时更新同一个字段顺序和结果是否符合预期理解在LangGraph中节点是顺序执行的即使在有分支的情况下每条路径也是顺序的。状态更新是增量且顺序应用的。如果节点A和节点B都更新字段X那么后执行的节点B的更新会基于节点A更新后的值进行归约。这通常是符合直觉的。关键在于你的归约器操作是否满足结合律对于复杂操作很重要以及你是否清楚工作流中节点的执行顺序。掌握了自定义状态和归约器你就解锁了LangGraph最核心的状态管理能力。它从“一个处理消息的管道”变成了一个可以承载复杂业务逻辑和记忆的“状态机”。接下来你可以尝试用这些知识去设计更智能的对话历史管理、用户画像构建或多步骤任务跟踪让你的智能体真正拥有长期记忆和上下文感知能力。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门