当一张图的所有节点都共用同一个 Schema 时,信息边界模糊、接口臃肿。
LangGraph 的多重 Schema 机制让你精确控制哪些数据对外可见、哪些只在内部流转。
在前面的课程中,我们学到了 LangGraph 的基础:所有节点共享同一个 State(OverallState),图的输入和输出也都是这同一个 State。这在简单场景下工作良好,但随着图的复杂度增加,会暴露出几个明显的问题。
想象一个问答系统:用户只需要提供一个"问题",得到一个"答案"。但图内部在计算过程中,会产生很多临时数据——搜索的关键词、中间推理的草稿、评分数值等。如果使用单 Schema,这些数据全部暴露给调用者:
调用者只想要 answer,却拿到了所有内部字段,接口臃肿、容易误用。
有时候,node_1 需要传递一个中间值给 node_2,但这个中间值与图的最终输入/输出毫无关系。如果你把它放进 OverallState,就不得不在整个图的"公共接口"里暴露这个实现细节。
LangGraph 提供了两种机制来解决这两个问题:私有状态(Private State)用于节点间传递内部数据,Input/Output Schema用于在图的边界处做数据过滤。这就是本节课的核心内容。
注意:私有状态(PrivateState)是另一个维度的概念,它存在于节点与节点之间的"通道"上,甚至不出现在 OverallState 里。我们接下来逐一讲解。
私有状态(Private State)是指:某些数据只在特定的节点之间流通,既不是图的输入,也不是图的输出,更不属于全局 OverallState。它是节点间"私聊"的便签条,其他节点和外部调用者都看不到。
LangGraph 允许节点函数在其类型注解(Type Hint)中声明自己使用的 Schema。节点可以:用 OverallState 作为输入类型,但返回 PrivateState;也可以接受 PrivateState 作为输入,返回 OverallState。LangGraph 的路由引擎会自动匹配这些类型,确保数据正确流转。
| 维度 | OverallState(全局状态) | PrivateState(私有状态) |
|---|---|---|
| 可见范围 | 图的所有节点 + 外部调用者 | 只有声明了该 Schema 的特定节点 |
| 生命周期 | 从 START 到 END 全程存在 | 只在特定节点之间传递时存在 |
| 出现在输出中 | 是 | 否(不在 OverallState 里) |
| 定义方式 | 作为 StateGraph 的主 Schema | 单独定义一个 TypedDict,由节点类型注解引用 |
| 典型用途 | 问题、答案、对话历史等核心数据 | 中间计算值、临时缓存、节点间协议数据 |
下面是 notebook 中私有状态的完整示例代码。我们逐段拆解分析:
from typing_extensions import TypedDict
from IPython.display import Image, display
from langgraph.graph import StateGraph, START, END
# ① 全局状态:图的"公开面板",输入输出都通过它
class OverallState(TypedDict):
foo: int
# ② 私有状态:只在 node_1 和 node_2 之间传递
class PrivateState(TypedDict):
baz: int
foo。这是整张图对外暴露的接口:调用者传入 {"foo": 1},图执行完毕返回 {"foo": 3}。baz 字段根本不存在于 OverallState,所以永远不会出现在最终结果里。baz。它是 node_1 产出、node_2 消费的"临时中间值"。你可以把它理解成:node_1 在纸条上写了 baz=2 塞给 node_2,node_2 用完就扔掉,不归档到任何地方。# node_1:接受 OverallState,返回 PrivateState
def node_1(state: OverallState) -> PrivateState:
print("---Node 1---")
# 从 OverallState 读取 foo,加 1 后写入 PrivateState 的 baz
return {"baz": state['foo'] + 1}
# node_2:接受 PrivateState,返回 OverallState
def node_2(state: PrivateState) -> OverallState:
print("---Node 2---")
# 从 PrivateState 读取 baz,加 1 后写入 OverallState 的 foo
return {"foo": state['baz'] + 1}
node_1(state: OverallState) -> PrivateState 这个签名告诉 LangGraph:在执行 node_1 时,把当前 OverallState 传给它;node_1 返回的字典会被解读为 PrivateState,而非直接合并到 OverallState。LangGraph 的内部路由引擎根据这个类型注解来决定数据流向。# 构建图——StateGraph 的主 Schema 是 OverallState
builder = StateGraph(OverallState)
builder.add_node("node_1", node_1)
builder.add_node("node_2", node_2)
builder.add_edge(START, "node_1")
builder.add_edge("node_1", "node_2")
builder.add_edge("node_2", END)
graph = builder.compile()
# 运行:传入 OverallState,foo=1
result = graph.invoke({"foo": 1})
# 输出:
# ---Node 1---
# ---Node 2---
# {'foo': 3} ← 只有 foo,baz 从未出现
baz 字段从未出现在最终的 invoke() 返回值中。因为它只属于 PrivateState,而 StateGraph 最终返回的是 OverallState。这正是私有状态设计的目的:节点间的临时数据不污染公开接口。
在引入 Input/Output Schema 之前,我们先用 notebook 里的例子,直观感受单 Schema 方式的局限。
class OverallState(TypedDict):
question: str # 用户的问题
answer: str # 最终答案
notes: str # 内部推理笔记(中间数据)
def thinking_node(state: OverallState):
# 思考节点:产出中间笔记和初步答案
return {"answer": "bye", "notes": "... his name is Lance"}
def answer_node(state: OverallState):
# 答案节点:产出最终答案
return {"answer": "bye Lance"}
graph = StateGraph(OverallState)
graph.add_node("answer_node", answer_node)
graph.add_node("thinking_node", thinking_node)
graph.add_edge(START, "thinking_node")
graph.add_edge("thinking_node", "answer_node")
graph.add_edge("answer_node", END)
graph = graph.compile()
graph.invoke({"question": "hi"})
# 实际输出(包含用户不需要的字段):
# {
# 'question': 'hi', ← 用户自己知道,不需要再返回
# 'answer': 'bye Lance', ← 用户真正需要的
# 'notes': '... his name is Lance' ← 内部中间数据,泄露给外部!
# }
痛点一(输入侧):调用者无法只传 question——如果不传 answer 和 notes,它们在 State 中就是 None,节点函数可能因此出错或需要额外处理。
痛点二(输出侧):notes 是内部实现细节,调用者看到它会感到困惑,甚至可能依赖这个本不该暴露的字段,造成接口耦合。
这两个痛点在简单示例里看似无关紧要,但在真实生产场景中——图有数十个字段、外部系统依赖其输出——就会成为严重的设计问题。Input/Output Schema 就是针对这两个痛点的解决方案。
StateGraph 支持三个构造参数来定义多重 Schema:
graph = StateGraph(
OverallState, # 第一个参数:内部全局状态(节点间通信用)
input_schema=InputState, # 关键字参数:定义 invoke() 接受的输入结构
output_schema=OutputState # 关键字参数:定义 invoke() 返回的输出结构
)
这两个参数的作用:
Input/Output Schema 不改变图内部的运行逻辑——节点之间仍然用完整的 OverallState 通信。Schema 仅仅是在图的边界处(进入图时 / 离开图时)做数据的裁剪。就像一个 API 的请求/响应序列化层,并不影响后端业务逻辑。
当图使用多重 Schema 时,节点函数的类型注解可以明确声明它使用的是哪个 Schema,增加代码可读性:
# thinking_node:只需要读 question(InputState 的字段)
# 使用 InputState 类型注解,表明它只关心输入字段
def thinking_node(state: InputState):
return {"answer": "bye", "notes": "... his name is Lance"}
# answer_node:需要读取所有字段,最终输出 OutputState
# -> OutputState 类型注解表明它的返回值会被过滤为 OutputState
def answer_node(state: OverallState) -> OutputState:
return {"answer": "bye Lance"}
节点函数上的类型注解(-> OutputState)主要起到文档说明和 IDE 提示的作用。真正决定 invoke() 最终返回哪些字段的,是 StateGraph(output_schema=OutputState) 这个参数。两者结合使用,代码意图最清晰。
下面是 notebook 中 Input/Output Schema 的完整实现代码,我们逐行深入解析:
# ① 输入 Schema:定义调用者可以/必须传入的字段
class InputState(TypedDict):
question: str
# ② 输出 Schema:定义 invoke() 返回给调用者的字段
class OutputState(TypedDict):
answer: str
# ③ 内部全局状态:图内部节点间通信的完整数据结构
class OverallState(TypedDict):
question: str # 来自 InputState
answer: str # 最终写入 OutputState
notes: str # 纯内部字段,不在 Input/Output Schema 中
question。当调用 graph.invoke({"question": "hi"}) 时,LangGraph 会验证传入的字典符合 InputState 的结构。你不需要传 answer 或 notes,它们会以默认值(None 或不存在)初始化在 OverallState 中。answer。图执行完毕时,LangGraph 从最终的 OverallState 中只提取 answer 字段返回。question 和 notes 即便存在于 OverallState,也不会出现在 invoke() 的返回值里。def thinking_node(state: InputState):
# state 只包含 question,因为此节点声明了 InputState 类型
# 但返回值会被合并到 OverallState 中
return {"answer": "bye", "notes": "... his is name is Lance"}
def answer_node(state: OverallState) -> OutputState:
# state 包含完整的 OverallState(question + answer + notes)
# -> OutputState 声明返回值语义上对应 OutputState
return {"answer": "bye Lance"}
# 关键!StateGraph 第一个参数是内部 Schema
# input_schema 和 output_schema 是过滤器参数
graph = StateGraph(
OverallState,
input_schema=InputState,
output_schema=OutputState
)
graph.add_node("answer_node", answer_node)
graph.add_node("thinking_node", thinking_node)
graph.add_edge(START, "thinking_node")
graph.add_edge("thinking_node", "answer_node")
graph.add_edge("answer_node", END)
graph = graph.compile()
# 调用者只需传 InputState 声明的字段
result = graph.invoke({"question": "hi"})
# 实际输出(经过 OutputState 过滤):
# {'answer': 'bye Lance'}
#
# 注意:question 和 notes 都不见了!
# 图内部确实有这两个字段,只是输出时被过滤掉了。
输出结果从单 Schema 时的 {'question': 'hi', 'answer': 'bye Lance', 'notes': '... his name is Lance'} 变为了简洁的 {'answer': 'bye Lance'}。这就是 output_schema 过滤的效果。
| 阶段 | 可见数据 | 说明 |
|---|---|---|
| invoke() 传入 | {"question": "hi"} |
由 InputState 约束,只需传 question |
| thinking_node 执行后 | OverallState: {question, answer:"bye", notes:"..."} |
节点内部可以读写所有字段 |
| answer_node 执行后 | OverallState: {question, answer:"bye Lance", notes:"..."} |
answer 被更新,其他字段不变 |
| invoke() 返回 | {"answer": "bye Lance"} |
由 OutputState 过滤,只返回 answer |
| 对比维度 | 私有状态(PrivateState) | Input/Output Schema |
|---|---|---|
| 解决的问题 | 节点间的临时中间数据,不想进入 OverallState | 图的外部接口臃肿,输入/输出字段过多 |
| 作用范围 | 图的内部(节点之间) | 图的边界(调用者 ↔ 图) |
| 配置方式 | 在节点函数的类型注解中声明 | 在 StateGraph() 构造函数参数中声明 |
| 对 OverallState 的影响 | 私有字段不属于 OverallState | OverallState 不变,只是过滤输入输出 |
| 典型使用场景 | 中间计算结果、节点间的协议数据 | API 接口设计、隐藏内部实现细节 |
在一个复杂的图中,你完全可以同时使用私有状态和 Input/Output Schema:
# 同时使用两种机制的设计示例
class InputState(TypedDict):
user_query: str # 用户输入
class OutputState(TypedDict):
final_answer: str # 最终答案
class OverallState(TypedDict):
user_query: str # 来自 InputState
search_results: list # 搜索结果
draft_answer: str # 草稿答案
final_answer: str # 写入 OutputState
class PrivateState(TypedDict):
relevance_score: float # 私有:节点间传递评分
refined_query: str # 私有:精炼后的查询词
# 节点 A:接受 InputState,内部处理后传递 PrivateState
def query_refiner(state: InputState) -> PrivateState:
refined = state["user_query"].strip().lower()
return {"refined_query": refined, "relevance_score": 0.0}
# 节点 B:接受 PrivateState,写回 OverallState
def searcher(state: PrivateState) -> OverallState:
results = do_search(state["refined_query"])
return {"search_results": results, "draft_answer": ""}
# 节点 C:接受完整 OverallState,产出 final_answer
def synthesizer(state: OverallState) -> OutputState:
answer = synthesize(state["search_results"], state["user_query"])
return {"final_answer": answer}
# 构建图,同时配置 Input/Output Schema
graph = StateGraph(
OverallState,
input_schema=InputState,
output_schema=OutputState
)
# ... add_node, add_edge, compile ...
把图想象成一个封装良好的模块:
· input_schema 定义"模块的参数"——调用方需要提供什么
· output_schema 定义"模块的返回值"——调用方能得到什么
· OverallState 是"模块内部状态"——实现细节,外部不感知
· PrivateState 是"局部变量"——只在特定函数间传递,连内部状态都不写入
搜索图中,检索节点产出"文档列表 + 相关性评分",排序节点消费后输出"已排序的文档列表"。相关性评分是中间计算值,对最终用户毫无意义。用私有状态承载评分,避免污染 OverallState。
class ScoredDocsState(TypedDict):
# 私有:带评分的文档,只在检索和排序节点之间流通
scored_docs: list[tuple[str, float]]
思维链(Chain-of-Thought)场景:LLM 内部推理节点产出详细的推理步骤,而最终答案节点只需要简洁的结论。推理步骤字符串可以作为私有状态,不进入 OverallState。
class ReasoningState(TypedDict):
# 私有:详细推理过程,最终用户不需要看到
chain_of_thought: str
confidence: float
将 LangGraph 图作为后端服务暴露给前端或其他微服务时,接口字段应当最小化。前端只关心"问题"和"答案",不需要知道图内部经历了几轮搜索、产生了哪些中间笔记。
# 前端友好的接口
class ChatInput(TypedDict):
message: str # 用户发送的消息
session_id: str # 会话 ID
class ChatOutput(TypedDict):
reply: str # AI 回复
tokens_used: int # 消耗的 token 数(计费用)
当图部署到 LangGraph Cloud 时,input_schema 和 output_schema 会自动生成 API 文档和请求验证。这是生产部署的最佳实践——明确的 Schema 让 API 自文档化,减少调用方误用的可能。
在多 Agent 系统或并行图中,子图可能有自己的 OverallState,与父图不同。通过定义明确的 input/output Schema,子图对父图暴露清晰的接口,两者的内部状态互不干扰。
原型阶段用一个 OverallState 涵盖所有字段,快速验证逻辑。不要过早优化。
图需要被外部系统调用(API、前端、其他微服务),或者你发现 invoke() 的返回值字段过多,影响可用性。
你发现 OverallState 里有些字段"只被相邻两个节点使用,其他地方从不读写",这正是私有状态的候选。
无论使用多少 Schema,OverallState 始终是图内部节点间通信的完整数据结构。确保它包含所有节点实际需要读写的字段。
多重 Schema 的本质是信息封装:把"对谁有用的数据"和"对谁无用的数据"明确分开。这与面向对象的封装思想、API 的最小化暴露原则是一脉相承的。LangGraph 只是提供了在图层面实现这种封装的语言工具。