关注

【langgraph 从入门到精通】State 与 Schema — 让 Agent 拥有结构化记忆


在这里插入图片描述

第 3 章:State 与 Schema — 让 Agent 拥有结构化记忆

3.1 本章目标

学完本章后,你将能够:

  • 使用 Pydantic BaseModel 定义结构化的 Agent 状态(State)
  • 理解 State 在 LangGraph 工作流中的流转方式
  • 掌握 previous 参数的用法和实现状态链式传递
  • 在多步骤流程中正确传递和更新状态
  • 构建旅行规划助手 v0.2:从用户输入中提取结构化信息

3.2 核心概念(图文讲解)

3.2.1 生动类比:手术台上的病历

想象你在医院做手术。手术台上有一本病历,记录了你的所有关键信息:姓名、年龄、过敏史、血压、心率、手术部位等。每个参与手术的人(麻醉师、主刀医生、护士)都会:

  1. 读取病历上的信息
  2. 执行自己的专业操作
  3. 更新病历(记录麻醉剂量、手术进展、生命体征等)
  4. 把病历传给下一个人

LangGraph 的 State(状态) 就是这本「病历」——它是一个贯穿整个工作流的数据结构,每个 Task 都可以读取和更新它。在 Functional API 中,这个「病历」通过函数参数和返回值在 Task 之间传递。

3.2.2 状态流转图

用户输入

extract_requirements()

generate_itinerary()

format_output()

最终结果

原始输入

提取需求

读取raw_input

LLM解析

填充destination

填充days

填充budget

填充preferences

生成行程

读取结构化信息

LLM生成行程

填入itinerary字段

格式化输出

汇总所有字段

格式化Markdown

填入final_output

3.2.3 关键术语解释

术语含义在病历中的类比
State贯穿工作流的数据结构,使用 Pydantic BaseModel 定义手术台上的病历
SchemaState 的结构定义,规定有哪些字段、每个字段的类型病历的格式模板
previous 参数在 task 中接收上一个 task 执行的返回值上一个医生传递的病历
状态流转State 在 Task 之间传递和更新的过程病历在不同医生之间传递
不可变性每个 task 返回新的 State 实例,而不是修改原有实例每次更新盖新章,不涂改原内容

3.3 实战:旅行规划助手 v0.2

3.3.1 场景描述

在 v0.1 中,用户输入是一段自由文本,LLM 直接生成回复。但真实场景中,我们需要从用户输入中提取出结构化的信息(目的地、天数、预算、偏好),然后基于这些结构化信息生成行程,最后格式化为统一美观的输出。v0.2 将整个流程拆分为三个 Task,使用 Pydantic State 在它们之间传递数据。

3.3.2 完整代码

# ============================================================
# 旅行规划助手 v0.2 — 第 3 章完整可运行代码
# 保存为 ch03_state_schema.py 后运行:python ch03_state_schema.py
# 运行前请确保已设置:export OPENAI_API_KEY="your-key"
# 依赖:langgraph>=1.2.9, langchain-openai>=0.3.0, pydantic>=2.0
# ============================================================

import os  # 导入操作系统模块,用于读取环境变量

from pydantic import BaseModel, Field  # 从 Pydantic 导入基类和字段描述工具
from typing import Optional  # Optional 类型用于声明可选字段

from langgraph.func import entrypoint, task  # 导入 LangGraph Functional API 核心装饰器
from langchain_openai import ChatOpenAI  # 导入 OpenAI 聊天模型封装


# ============================================================
# 第一步:定义 State(状态)—— 使用 Pydantic BaseModel
# State 是贯穿整个工作流的数据结构,每个 Task 都可以读取和更新它
# ============================================================
class TravelPlanState(BaseModel):
    """旅行规划状态:工作流中所有 Task 共享的数据结构。
    每个字段都带有类型注解和描述,既是文档也是运行时验证。
    """

    # 用户原始输入(整个工作流的起点)
    raw_input: str = Field(
        default="",  # 默认值:空字符串
        description="用户的原始自然语言输入",  # 字段说明,便于理解和调试
    )

    # 从用户输入中提取的结构化信息
    destination: str = Field(
        default="",  # 默认值:空字符串
        description="旅行目的地,如:东京、巴黎、纽约",
    )
    days: int = Field(
        default=0,  # 默认值:0 天
        description="旅行天数,如:3、5、7",
    )
    budget: str = Field(
        default="",  # 默认值:空字符串
        description="预算范围,如:经济型、中等、豪华",
    )
    preferences: str = Field(
        default="",  # 默认值:空字符串
        description="用户的偏好,如:美食、历史文化、自然风光、购物",
    )

    # 中间产物:LLM 生成的行程内容
    itinerary: str = Field(
        default="",  # 默认值:空字符串
        description="LLM 生成的详细行程内容",
    )

    # 最终输出:格式化后的完整结果
    final_output: str = Field(
        default="",  # 默认值:空字符串
        description="格式化后的最终输出内容",
    )


# ============================================================
# 第二步:初始化 LLM(全局复用)
# ============================================================
llm = ChatOpenAI(
    model="gpt-4o-mini",  # 使用 gpt-4o-mini 模型
    temperature=0.3,  # 降低温度,让信息提取更准确/确定
)


# ============================================================
# 第三步:定义 Task 1 — 提取结构化需求
# ============================================================
@task  # 标记为 LangGraph 任务
def extract_requirements(state: TravelPlanState) -> TravelPlanState:
    """从用户的原始输入中提取结构化信息。
    读取 state.raw_input,调用 LLM 解析,填充 destination/days/budget/preferences 字段。
    """
    # 从 state 中读取用户的原始输入
    user_input = state.raw_input  # 获取 state 中的 raw_input 字段

    # 构建提示词:要求 LLM 以 JSON 格式返回结构化信息
    prompt = f"""请从以下用户输入中提取旅行相关的结构化信息:

用户输入:{user_input}

请以 JSON 格式返回,包含以下字段:
- destination: 目的地(中文名称)
- days: 旅行天数(整数)
- budget: 预算(如:经济型、中等、豪华)
- preferences: 偏好(如:美食、历史文化、自然风光)

只返回 JSON,不要包含其他内容。"""

    # 调用 LLM 进行信息提取
    response = llm.invoke(prompt)  # response 是 AIMessage 对象

    # 解析 LLM 返回的 JSON 内容
    import json  # 导入 json 模块用于解析 LLM 返回的 JSON 字符串
    try:
        # 尝试从 LLM 回复中提取 JSON(LLM 可能用 ```json 包裹)
        content = response.content  # 获取 LLM 返回的文本
        if "```json" in content:
            # 如果 LLM 使用 Markdown 代码块包裹 JSON,提取中间部分
            content = content.split("```json")[1].split("```")[0].strip()
        elif "```" in content:
            # 如果 LLM 使用普通代码块包裹 JSON,提取中间部分
            content = content.split("```")[1].split("```")[0].strip()
        data = json.loads(content)  # 将 JSON 字符串解析为 Python 字典
    except (json.JSONDecodeError, IndexError):
        # 如果 JSON 解析失败,使用默认值(避免整个流程崩溃)
        data = {}

    # 创建一个新的 state 副本,并更新提取到的字段
    # 使用 model_copy() 创建副本,然后 update 更新字段——保持不可变性
    updated_state = state.model_copy(
        update={
            "destination": data.get("destination", "未知目的地"),  # 提取目的地,默认值兜底
            "days": data.get("days", 1),  # 提取天数,默认 1 天兜底
            "budget": data.get("budget", "中等"),  # 提取预算,默认中等兜底
            "preferences": data.get("preferences", "综合体验"),  # 提取偏好,默认值兜底
        }
    )
    return updated_state  # 返回更新后的 state(新实例,不修改原实例)


# ============================================================
# 第四步:定义 Task 2 — 生成行程
# ============================================================
@task  # 标记为 LangGraph 任务
def generate_itinerary(state: TravelPlanState) -> TravelPlanState:
    """根据结构化信息生成详细的旅行行程。
    读取 state 中的 destination/days/budget/preferences,调用 LLM 生成行程。
    """
    # 构建包含结构化信息的提示词
    prompt = f"""你是一个专业的旅行规划师。请根据以下信息生成一份详细的旅行行程:

目的地:{state.destination}
旅行天数:{state.days} 天
预算:{state.budget}
偏好:{state.preferences}

请按天组织行程,每天包含:
- 上午活动(景点参观、体验活动)
- 午餐推荐(当地特色餐厅)
- 下午活动(景点参观、购物)
- 晚餐推荐(当地特色餐厅)
- 晚间活动(可选:夜市、夜景、演出)

最后给出总预算估算。"""

    # 调用 LLM 生成行程
    response = llm.invoke(prompt)  # 发送提示词,等待 LLM 回复

    # 更新 state:将 LLM 生成的行程填入 itinerary 字段
    updated_state = state.model_copy(
        update={"itinerary": response.content}  # 将 LLM 输出存入 itinerary
    )
    return updated_state  # 返回更新后的 state


# ============================================================
# 第五步:定义 Task 3 — 格式化输出
# ============================================================
@task  # 标记为 LangGraph 任务
def format_output(state: TravelPlanState) -> TravelPlanState:
    """将行程和结构化信息组合成美观的最终输出格式。
    不涉及 LLM 调用,纯 Python 字符串格式化。
    """
    # 构建 Markdown 格式的最终输出
    final = f"""# 🌍 旅行规划报告

## 基本信息
- **目的地**:{state.destination}
- **旅行天数**:{state.days} 天
- **预算**:{state.budget}
- **偏好**:{state.preferences}

## 详细行程

{state.itinerary}

---
*由 LangGraph 旅行规划助手 v0.2 生成*
"""

    # 更新 state:将格式化后的内容填入 final_output 字段
    updated_state = state.model_copy(
        update={"final_output": final}  # 将格式化文本存入 final_output
    )
    return updated_state  # 返回更新后的 state


# ============================================================
# 第六步:定义 Entrypoint — 编排整个工作流
# ============================================================
@entrypoint()  # 标记为工作流入口
def travel_planner_v2(raw_input: str) -> TravelPlanState:
    """旅行规划助手 v0.2 入口函数。
    编排三个 Task:提取需求 → 生成行程 → 格式化输出。
    使用 previous 参数进行状态链式传递。
    """

    # 第一步:创建初始 State,将用户输入存入 raw_input 字段
    initial_state = TravelPlanState(raw_input=raw_input)

    # 第二步:调用 extract_requirements,传入初始 state
    # .result() 等待 Task 完成,返回更新后的 state
    state_v1 = extract_requirements(initial_state).result()

    # 第三步:调用 generate_itinerary,将上一步的结果作为输入
    # 这就是「状态链式传递」——每个 Task 的输出成为下一个 Task 的输入
    state_v2 = generate_itinerary(state_v1).result()

    # 第四步:调用 format_output,将上一步的结果作为输入
    state_v3 = format_output(state_v2).result()

    # 返回最终 state(包含完整的所有字段)
    return state_v3


# ============================================================
# 程序入口:运行 Agent
# ============================================================
if __name__ == "__main__":
    # 检查 API Key 是否已配置
    if not os.environ.get("OPENAI_API_KEY"):
        print("错误:未设置 OPENAI_API_KEY 环境变量!")
        print("请先运行:export OPENAI_API_KEY='your-api-key-here'")
        exit(1)

    # 模拟用户输入
    user_query = "我想去日本东京玩5天,预算中等,喜欢美食和历史文化。"

    print("=" * 60)
    print("旅行规划助手 v0.2 正在处理...")
    print("=" * 60)

    # 调用入口函数,传入用户输入
    final_state = travel_planner_v2.invoke(user_query)

    # 打印最终输出
    print(final_state.final_output)  # 输出格式化后的完整报告
    print("=" * 60)

    # 额外展示:打印 State 中所有字段的值(用于调试和理解)
    print("\n[调试信息] State 各字段内容:")
    print(f"  destination: {final_state.destination}")
    print(f"  days: {final_state.days}")
    print(f"  budget: {final_state.budget}")
    print(f"  preferences: {final_state.preferences}")
    print(f"  itinerary 长度: {len(final_state.itinerary)} 字符")
    print(f"  final_output 长度: {len(final_state.final_output)} 字符")

3.3.3 运行结果展示

运行 python ch03_state_schema.py 后,你将看到类似以下输出:

============================================================
旅行规划助手 v0.2 正在处理...
============================================================
# 🌍 旅行规划报告

## 基本信息
- **目的地**:东京
- **旅行天数**:5 天
- **预算**:中等
- **偏好**:美食、历史文化

## 详细行程

**第 1 天:浅草文化与下町风情**
- 上午:参观浅草寺与雷门,感受江户时代的文化氛围
- 午餐:浅草今半,品尝百年历史的寿喜烧
- 下午:漫步合羽桥道具街,探索日本厨具文化
- 晚餐:在吾妻桥附近的居酒屋体验地道日式下酒菜
- 晚间:隅田川散步,欣赏东京晴空塔夜景

**第 2 天:皇居周边与银座美食**
- 上午:参观皇居东御苑,了解日本皇室历史
...

---
*由 LangGraph 旅行规划助手 v0.2 生成*
============================================================

[调试信息] State 各字段内容:
  destination: 东京
  days: 5
  budget: 中等
  preferences: 美食、历史文化
  itinerary 长度: 1247 字符
  final_output 长度: 1370 字符

3.3.4 代码逐段解析

第一部分:Pydantic State 定义

class TravelPlanState(BaseModel):
    raw_input: str = Field(default="", description="用户的原始自然语言输入")
    destination: str = Field(default="", description="旅行目的地")
    days: int = Field(default=0, description="旅行天数")
    ...

使用 Pydantic BaseModel 定义 State 有三大好处:(1) 自动类型验证——如果传入了错误类型,Pydantic 会抛出清晰的错误;(2) IDE 自动补全——所有字段都有完整的类型提示;(3) 文档即代码——Field(description="...") 既是注释,也可被其他工具读取。

第二部分:状态更新模式

updated_state = state.model_copy(
    update={"destination": data.get("destination", "未知目的地")}
)

model_copy(update={...}) 是 Pydantic v2 的推荐方式——它会创建一个新的 State 实例,只更新指定的字段,其他字段保持不变。这是「不可变更新」模式,避免了直接修改原对象可能带来的副作用。

第三部分:状态链式传递

state_v1 = extract_requirements(initial_state).result()
state_v2 = generate_itinerary(state_v1).result()
state_v3 = format_output(state_v2).result()

每个 Task 接收上一个 Task 的返回结果作为输入,这就是「状态链式传递」。数据像流水线一样依次经过每个 Task,每个 Task 只负责更新自己关心的字段。

第四部分:previous 参数模式(可选写法)

在 LangGraph Functional API 中,你也可以使用 previous 参数让 Task 自动接收上一个 Task 的输出:

@task
def generate_itinerary(previous: TravelPlanState) -> TravelPlanState:
    # previous 参数自动接收上一个 Task 的返回值
    # 不需要手动传递,LangGraph 运行时自动处理
    ...

不过在本教程中,我们采用显式传递的方式,因为这样代码更清晰、更容易理解和调试。

3.4 API 速查

API类型说明签名/导入路径
BaseModelPydantic 数据模型基类from pydantic import BaseModel
Field()函数定义字段的元数据(默认值、描述等)from pydantic import Field
model_copy(update={...})方法创建 State 副本并更新指定字段new_state = state.model_copy(update={"field": value})
model_dump()方法将 State 序列化为字典data = state.model_dump()
.result()方法等待 Task 完成并返回结果result = my_task(state).result()
previous参数约定在 Task 中声明以接收上一个 Task 的返回值def my_task(previous: MyState) -> MyState:

3.5 常见错误与避坑指南

错误 1:直接修改 State 而不是创建新实例

错误代码

@task
def my_bad_task(state: TravelPlanState) -> TravelPlanState:
    # 错误:直接修改原 State 实例的属性
    state.destination = "东京"  # 直接修改原对象
    state.days = 5
    return state  # 返回的是被修改过的原对象

正确代码

@task
def my_good_task(state: TravelPlanState) -> TravelPlanState:
    # 正确:使用 model_copy() 创建新实例,保持不可变性
    updated_state = state.model_copy(
        update={
            "destination": "东京",  # 在新实例上更新字段
            "days": 5,
        }
    )
    return updated_state  # 返回全新的实例

原因:直接修改原实例可能导致意外的副作用(例如其他 Task 也引用了同一个 State 对象)。不可变更新是 LangGraph 设计的最佳实践,也是函数式编程的核心原则。

错误 2:State 字段类型不匹配

错误代码

class TravelPlanState(BaseModel):
    days: int = Field(default=0, description="旅行天数")

# 在某个 Task 中
updated_state = state.model_copy(
    update={"days": "5天"}  # 错误:days 是 int 类型,但传入了字符串 "5天"
)

正确代码

class TravelPlanState(BaseModel):
    days: int = Field(default=0, description="旅行天数")

# 在某个 Task 中
updated_state = state.model_copy(
    update={"days": 5}  # 正确:传入 int 类型,与字段声明一致
)

原因:Pydantic 会进行运行时类型验证。如果传入的类型不匹配,在某些情况下 Pydantic 会尝试自动转换(如 "5" 转为 5),但 "5天" 无法转换,会抛出 ValidationError

错误 3:忘记给 State 字段设置默认值

错误代码

class TravelPlanState(BaseModel):
    destination: str  # 错误:没有默认值,创建实例时必须传入
    days: int
    budget: str

正确代码

class TravelPlanState(BaseModel):
    destination: str = Field(default="")  # 正确:有默认值,可以逐步填充
    days: int = Field(default=0)
    budget: str = Field(default="")

原因:State 在工作流中是被逐步填充的。如果字段没有默认值,创建初始 State 时就必须传入所有字段的值,这违背了「逐步构建」的设计理念。

错误 4:在 LLM 提示词中未处理 JSON 解析失败

错误代码

@task
def extract_requirements(state: TravelPlanState) -> TravelPlanState:
    response = llm.invoke(prompt)
    data = json.loads(response.content)  # 错误:LLM 可能返回非 JSON 格式
    return state.model_copy(update={"destination": data["destination"]})

正确代码

@task
def extract_requirements(state: TravelPlanState) -> TravelPlanState:
    response = llm.invoke(prompt)
    try:
        content = response.content
        # 处理 LLM 可能用 Markdown 代码块包裹的情况
        if "```" in content:
            content = content.split("```")[1].split("```")[0].strip()
        data = json.loads(content)
    except (json.JSONDecodeError, IndexError, KeyError):
        data = {}  # 解析失败时使用空字典,后续用 .get() 安全取值
    return state.model_copy(
        update={
            "destination": data.get("destination", "未知"),
            "days": data.get("days", 1),
        }
    )

原因:LLM 的输出不是 100% 可靠的。它可能返回非 JSON 文本、用 Markdown 代码块包裹 JSON、或者 JSON 格式不完整。始终添加 try-except 保护并用 .get() 提供默认值。

3.6 最佳实践总结

  1. State 字段都设默认值:State 在工作流中被逐步填充,每个字段都应该有合理的默认值。这样创建初始 State 时只需要传入关键字段,其他字段在后续 Task 中逐步完善。

  2. 使用 model_copy() 而非直接修改:保持 State 的不可变性——每个 Task 返回新实例,而不是修改原实例。这避免了副作用,让代码的行为更可预测。

  3. LLM 解析加容错:任何从 LLM 输出中解析结构化数据的操作都必须添加 try-except 保护。LLM 的输出格式不稳定,提供默认值兜底可以避免整个流程崩溃。

  4. State 字段命名清晰:使用有意义的字段名(如 raw_inputitineraryfinal_output),让每个字段的用途一目了然。配合 Field(description="...") 提供额外文档。

  5. Task 职责单一:每个 Task 只做一件事——提取需求、生成行程、格式化输出。不要在一个 Task 中混入多种职责,这样代码更易测试、更易维护、更易复用。


转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_42439274/article/details/162849834

文章来源crawl

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--