📖 本章学习目标
- ✅ 掌握 Reducer 的高级用法与自定义 Reducer 编写
- ✅ 理解私有状态、输入状态与输出状态的分离设计
- ✅ 学会设计复杂嵌套状态结构
- ✅ 掌握状态的运行时更新与节点通信模式
- ✅ 能够根据业务场景做出合适的状态设计决策
- ✅ 避免常见的状态管理陷阱和性能问题
一、State 设计的重要性
1、State 是 Agent 的"大脑"
如果说节点是 Agent 的"手脚",那 State 就是 Agent 的"大脑"——它记录了 Agent 在任务执行过程中的一切信息。State 设计得好,Agent 就聪明;State 设计得差,Agent 就混乱。
糟糕的状态设计会带来:
- ❌ 节点之间数据传递混乱,难以追踪
- ❌ 状态膨胀,携带大量无用信息
- ❌ 难以测试和调试
- ❌ 扩展新功能时需要大量重构
- ❌ 序列化/反序列化性能问题
State就像旅行箱——带太多会超重(性能问题),带太少不够用(功能缺失),关键是精选必需品。
2、State 设计的核心原则
| 原则 | 说明 | 重要性 |
|---|---|---|
| 最小化 | 只存必要的信息 | ⭐⭐⭐⭐⭐ |
| 不可变性 | 节点返回新数据,不直接修改 State | ⭐⭐⭐⭐⭐ |
| 语义清晰 | 字段名表达明确的业务含义 | ⭐⭐⭐⭐ |
| Reducer 一致 | 每个字段有明确的合并策略 | ⭐⭐⭐⭐⭐ |
| 类型安全 | 使用 TypeScript 严格类型 | ⭐⭐⭐⭐ |
二、Reducer 深度解析
1、Reducer 的本质
Reducer 是一个函数,决定"当节点返回新值时,State 字段如何更新":
Reducer 签名:(currentValue: T, updateValue: T) => T
Reducer 的执行时机:
LangGraph 内置了几种常见 Reducer:
import {
messagesStateReducer, // 消息追加,支持消息更新/删除
} from '@langchain/langgraph';
2、常用 Reducer 模式
模式1:覆盖(默认)
import { Annotation } from '@langchain/langgraph';
const CoverState = Annotation.Root({
// 模式1:覆盖(默认)—— 新值直接替换旧值
status: Annotation<string>(),
currentPhase: Annotation<string>(),
});
这是默认的 Reducer 行为:(curr, update) => update,适用于状态标记(status/phase)、当前值等单一字段,比如status从"processing"变为"completed",旧值被完全替换(注意:如果未设置default值,首次访问可能为undefined)。
模式2:追加
const AppendState = Annotation.Root({
// 模式2:追加 —— 新数组追加到旧数组
logs: Annotation<string[]>({
reducer: (curr, update) => [...curr, ...update],
default: () => [],
}),
searchResults: Annotation<string[]>({
reducer: (curr, update) => [...curr, ...update],
default: () => [],
}),
});
追加模式:(curr, update) => [...curr, ...update],适用于日志、错误列表、搜索结果等累积型数据。每次节点返回新数组,都会拼接到现有数组后面。 必须设置default: () => [],否则首次访问会报错。
模式3:累加
const AccumulateState = Annotation.Root({
// 模式3:累加 —— 数值累加
totalTokens: Annotation<number>({
reducer: (curr, update) => curr + update,
default: () => 0,
}),
retryCount: Annotation<number>({
reducer: (curr, update) => curr + update,
default: () => 0,
}),
});
累加模式:(curr, update) => curr + update,适用于计数器、Token 消耗统计、分数等数值字段。节点返回增量值(如1),Reducer自动累加到总数必须设置default,提供初始值。
模式4:合并对象
const MergeState = Annotation.Root({
// 模式4:合并对象 —— 新对象字段合并到旧对象
metadata: Annotation<Record<string, unknown>>({
reducer: (curr, update) => ({ ...curr, ...update }),
default: () => ({}),
}),
entities: Annotation<Record<string, string>>({
reducer: (curr, update) => ({ ...curr, ...update }),
default: () => ({}),
}),
});
合并模式:(curr, update) => ({ ...curr, ...update }),适用于灵活的元数据、配置项等 KV 结构,新对象的字段会覆盖同名字段,其他字段保留,适合在多轮对话中累积提取的实体信息。
完整示例对比:
// 四种模式的综合示例
const ReducerExamplesState = Annotation.Root({
status: Annotation<string>(), // 覆盖
logs: Annotation<string[]>({ // 追加
reducer: (curr, update) => [...curr, ...update],
default: () => [],
}),
totalTokens: Annotation<number>({ // 累加
reducer: (curr, update) => curr + update,
default: () => 0,
}),
metadata: Annotation<Record<string, unknown>>({ // 合并
reducer: (curr, update) => ({ ...curr, ...update }),
default: () => ({}),
}),
});
3、高级 Reducer:保留最新 N 条
// 只保留最近 N 条记录的 Reducer 工厂函数
function keepLatestN<T>(n: number) {
return (curr: T[], update: T[]): T[] => {
return [...curr, ...update].slice(-n);
};
}
const SlidingWindowState = Annotation.Root({
// 只保留最近 10 条消息(防止 Token 超限)
recentLogs: Annotation<string[]>({
reducer: keepLatestN(10),
default: () => [],
}),
// 只保留最近 5 次搜索结果
recentSearches: Annotation<string[]>({
reducer: keepLatestN(5),
default: () => [],
}),
});
keepLatestN是一个"Reducer 工厂函数"——返回一个 Reducerslice(-n)取数组最后 n 个元素,实现滑动窗口效果- 这是生产环境中控制上下文长度的常用技巧
- 适用场景:聊天记录、搜索历史、操作日志等需要限制大小的列表
4、消息 Reducer 的特殊能力
messagesStateReducer 除了追加,还支持更新和删除已有消息:
import { messagesStateReducer } from '@langchain/langgraph';
import { RemoveMessage, AIMessage } from '@langchain/core/messages';
// 删除某条消息:传入带有相同 id 的 RemoveMessage
const state = {
messages: [
new HumanMessage({ id: 'msg-1', content: '你好' }),
new AIMessage({ id: 'msg-2', content: '你好!' }),
]
};
// 删除 msg-1
const update = [new RemoveMessage({ id: 'msg-1' })];
const newMessages = messagesStateReducer(state.messages, update);
// 结果:只剩 msg-2
// 更新 msg-2 的内容
const update = [
new AIMessage({
id: 'msg-2',
content: '你好!很高兴见到你!' // 新内容
})
];
const updatedMessages = messagesStateReducer(state.messages, update);
RemoveMessage是特殊消息类型,指示 Reducer 删除对应 id 的消息,这是实现消息修剪(Memory Pruning)的底层机制,第10章的记忆管理大量使用这个特性。
消息的操作主要有三种:
- 追加:传入普通 Message,自动添加到末尾
- 更新:传入相同id的Message,替换原消息
- 删除:传入RemoveMessage,删除对应id的消息
三、输入/输出状态分离
1、为什么要分离
默认情况下,graph.invoke() 的输入和输出都是完整的 State。但在生产中,你往往希望:
- 输入:只接受用户提供的必要字段
- 输出:只返回对外有意义的结果字段
- 内部:节点之间共享更多的中间状态
好处:
- ✅ 保护内部实现细节,API更简洁
- ✅ 减少网络传输的数据量
- ✅ 避免因内部字段变化导致API破坏
- ✅ 符合最小权限原则
2、使用 InputState 和 OutputState
图内部使用的完整状态定义:
import { Annotation, StateGraph } from '@langchain/langgraph';
// 完整的内部状态(节点间共享)
const OverallState = Annotation.Root({
question: Annotation<string>(),
searchResults: Annotation<string[]>({
reducer: (c, u) => [...c, ...u],
default: () => [],
}),
reasoning: Annotation<string>(),
answer: Annotation<string>(),
});
定义输入和输出状态:
// 输入状态:只需要用户提供 question
const InputState = Annotation.Root({
question: Annotation<string>(),
});
// 输出状态:只对外暴露 answer
const OutputState = Annotation.Root({
answer: Annotation<string>(),
});
/**
* 代码解读:
* - InputState:用户调用 graph.invoke() 时需要提供的字段
* - OutputState:graph.invoke() 返回给调用方的字段
* - 这样外部用户不需要知道searchResults、reasoning等内部字段
*/
构建图时指定输入输出类型:
// 构建图时指定输入输出类型
const graph = new StateGraph({
stateSchema: OverallState,
input: InputState,
output: OutputState,
})
.addNode('search', searchNode)
.addNode('reason', reasonNode)
.addNode('answer', answerNode)
.addEdge('__start__', 'search')
.addEdge('search', 'reason')
.addEdge('reason', 'answer')
.addEdge('answer', '__end__')
.compile();
完整使用示例:
async function main() {
// 只需提供 input 中定义的字段
const result = await graph.invoke({
question: 'TypeScript 相比 JavaScript 有什么优势?'
});
// result 只包含 output 中定义的字段
console.log(result.answer); // ✅ 可以访问
console.log(result.searchResults); // ❌ TypeScript 编译错误
}
四、私有状态与节点通信
1、节点私有状态
有时,两个节点之间需要传递一些临时数据,但这些数据不应该污染全局 State。LangGraph 支持节点私有通信。
场景示例: 写作流程中的草稿评审
import { Annotation, StateGraph, Command } from '@langchain/langgraph';
// 主状态:对外暴露的字段
const MainState = Annotation.Root({
topic: Annotation<string>(),
finalResult: Annotation<string>(),
});
// 内部私有状态(仅在特定子流程中使用)
const PrivateState = Annotation.Root({
topic: Annotation<string>(),
draftContent: Annotation<string>(), // 私有中间字段
reviewComments: Annotation<string>(), // 私有中间字段
finalResult: Annotation<string>(),
});
MainState负责对外暴露的简洁接口,PrivateState负责内部工作流使用的详细状态。draftContent/reviewComments只在写作-评审循环中使用,完成后只将finalResult写入MainState。
2、使用 Command 进行动态路由
Command 是 LangGraph 的高级特性,允许节点同时更新状态和决定下一步:
import { Command } from '@langchain/langgraph';
async function reviewNode(state: typeof PrivateState.State) {
const needsRevision = state.draftContent.length < 100;
if (needsRevision) {
// 返回 Command:更新状态 + 跳转到特定节点
return new Command({
update: { reviewComments: '内容太短,需要扩充' },
goto: 'writeNode', // 重新跳回写作节点
});
}
return new Command({
update: { finalResult: state.draftContent },
goto: '__end__',
});
}
Command 对象同时携带状态更新(update)和路由决策(goto),这比 addConditionalEdges 更灵活——路由逻辑可以在节点内根据运行时数据决定。使用goto: '__end__' 直接结束图的执行。
注意:使用 Command 的节点不能再与 addConditionalEdges 混用。
Command 的优势:
- 路由决策可以基于复杂的业务逻辑
- 可以同时更新多个字段
- 支持动态跳转,不受预定义边限制
Command vs addConditionalEdges 对比:
| 特性 | Command | addConditionalEdges |
|---|---|---|
| 路由灵活性 | ⭐⭐⭐⭐⭐ 运行时动态决定 | ⭐⭐⭐ 预定义映射 |
| 状态更新 | ✅ 可同时更新 | ❌ 需单独节点 |
| 复杂度 | 中等 | 简单 |
| 适用场景 | 复杂业务逻辑 | 简单条件分支 |
五、复杂状态设计案例
1、研究助手的状态设计
下面是一个完整的研究助手 Agent 状态设计案例,展示真实场景中的状态结构:
import { Annotation, messagesStateReducer } from '@langchain/langgraph';
import { BaseMessage } from '@langchain/core/messages';
// 搜索结果的数据结构
interface SearchResult {
url: string;
title: string;
snippet: string;
relevanceScore: number;
}
定义研究报告结构:
// 研究报告的数据结构
interface ResearchReport {
summary: string;
keyFindings: string[];
sources: string[];
generatedAt: string;
}
完整状态定义:
const ResearchAgentState = Annotation.Root({
// 用户的原始问题(覆盖)
userQuery: Annotation<string>(),
// 分解后的子问题列表(去重追加)
subQuestions: Annotation<string[]>({
reducer: (c, u) => [...new Set([...c, ...u])], // 去重追加
default: () => [],
}),
// 搜索结果(按相关度合并并截断)
searchResults: Annotation<SearchResult[]>({
reducer: (curr, update) =>
[...curr, ...update]
.sort((a, b) => b.relevanceScore - a.relevanceScore)
.slice(0, 20), // 只保留最相关的20条
default: () => [],
}),
// 对话历史(消息追加)
messages: Annotation<BaseMessage[]>({
reducer: messagesStateReducer,
default: () => [],
}),
// 最终报告(覆盖)
report: Annotation<ResearchReport | null>({
default: () => null,
}),
// 执行阶段追踪(覆盖)
currentPhase: Annotation<'planning' | 'searching' | 'analyzing' | 'writing'>(),
// Token 消耗统计(累加)
totalTokensUsed: Annotation<number>({
reducer: (c, u) => c + u,
default: () => 0,
}),
});
代码解读:
userQuery:用户原始问题,后续节点可能需要引用subQuestions:去重追加,避免重复搜索相同问题searchResults:智能合并策略- 拼接新旧结果
- 按relevanceScore降序排序
- 截取前20条,控制状态大小
messages:使用内置messagesStateReducer,支持追加/更新/删除report:最终输出的研究报告,可为null表示未完成currentPhase:枚举类型,追踪执行阶段,便于监控和调试totalTokensUsed:累加模式,成本控制的关键指标
状态流转示意:
六、最佳实践和踩坑指南
💡 实践 1:避免在 State 中存储大型对象
❌ 不好的做法:
const BadState = Annotation.Root({
// 把完整的网页 HTML 存入 State —— 可能几百 KB!
rawHtmlContent: Annotation<string>(),
fullDocumentText: Annotation<string>(),
embeddings: Annotation<number[][]>(), // 向量数据
});
✅ 推荐做法:
const GoodState = Annotation.Root({
// 只存关键摘要,大型数据存外部存储
contentSummary: Annotation<string>(),
documentRef: Annotation<string>(), // 存 URL 或 ID 引用
embeddingRef: Annotation<string>(), // 向量数据库的引用
});
原因:State 在检查点时会被序列化存储,过大的 State 会造成:
- 内存占用高
- 序列化/反序列化慢
- 数据库存储成本高
- 网络传输开销大
💡 实践 2:用类型枚举代替字符串
❌ 不好的做法:
return { phase: 'step2' };
✅ 推荐做法:
type Phase = 'planning' | 'executing' | 'reviewing' | 'done';
const state: { phase: Phase } = { phase: 'planning' };
// 使用时 TypeScript 会检查
return { phase: 'executing' }; // ✅ 正确
return { phase: 'exeucting' }; // ❌ 编译错误:拼写错误
原因:TypeScript 联合类型提供编译时检查,避免拼写错误导致的运行时 Bug。
💡 实践 3:为所有字段设置 default 值
❌ 不好的做法:
const BadState = Annotation.Root({
count: Annotation<number>(), // 没有default,首次访问可能undefined
});
✅ 推荐做法:
const GoodState = Annotation.Root({
count: Annotation<number>({
default: () => 0, // 明确的初始值
}),
});
原因:未设置default的字段,首次访问时可能是undefined,导致运行时错误。
💡 实践 4:Reducer 必须是纯函数
❌ 不好的做法:
// 错误的Reducer:依赖外部变量
let counter = 0;
const badReducer = (curr: number, update: number) => {
counter++; // 副作用!
return curr + update;
};
✅ 推荐做法:
// 正确的Reducer:纯函数
const goodReducer = (curr: number, update: number) => {
return curr + update; // 无副作用,只依赖输入
};
原因:Reducer 可能被多次调用,副作用会导致不可预测的行为。
⚠️ 常见问题
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 直接修改 State 对象 | 状态更新不生效或产生副作用 | 永远返回新对象,不直接修改 state.xxx = value |
| Reducer 函数不纯 | 随机行为、难以复现的 Bug | Reducer 必须是纯函数,不能有副作用或随机性 |
| 忘记 default 工厂函数 | 首次读取字段时报 TypeError | 所有自定义 Annotation 都应设置 default |
| State 字段命名与内置冲突 | 框架行为异常 | 避免使用 __start__、__end__ 等内置名称 |
| 状态过于庞大 | 序列化慢、内存占用高 | 只存关键字段,大数据存外部存储 |
| Reducer 逻辑复杂 | 难以调试和维护 | 保持 Reducer 简单,复杂逻辑放节点中 |
📝 本章小结
核心知识点回顾
| 知识点 | 关键要点 | 应用场景 |
|---|---|---|
| Reducer 模式 | 覆盖/追加/累加/合并 四种基础模式 | 根据字段语义选择合适的 Reducer |
| 滑动窗口 | keepLatestN 工厂函数 | 控制聊天记录、日志大小 |
| 消息管理 | messagesStateReducer 支持追加/更新/删除 | 对话历史管理、记忆修剪 |
| 输入/输出分离 | InputState + OutputState + OverallState | 生产级 API 设计,保护内部状态 |
| Command 对象 | 同时携带状态更新和路由决策 | 复杂动态路由场景 |
| 状态设计原则 | 最小化、不可变、语义清晰、类型安全 | 所有 LangGraph 项目 |
🎯 动手练习
练习 1:设计电商 Agent 的 State
- 目标:为一个购物助手设计完整的 State
- 要求:
- 包含用户偏好(preferences)、商品搜索结果(searchResults)
- 购物车(cartItems)、订单状态(orderStatus)
- 每个字段有合适的 Reducer
- 添加 TypeScript 类型定义
- 验收标准:
- 至少包含6个字段
- 每种 Reducer 模式至少使用一次
- 能通过 TypeScript 编译检查
练习 2:实现去重 Reducer
- 目标:实现一个"追加但不重复"的 Reducer
- 要求:
- 基于某个字段(如
id)判断是否重复 - 创建通用工厂函数
uniqueByIdReducer(idField: string) - 测试多次追加相同 id 的数据
- 基于某个字段(如
- 验收标准:
- State 中只保留一份相同 id 的数据
- 后追加的数据覆盖先前的数据
- 支持任意类型的数组
练习 3:使用 Command 实现重试机制
- 目标:节点执行失败时,最多重试 3 次后才报错
- 要求:
- 在 State 中记录重试次数
retryCount - 节点用 Command 控制重试流程
- 超过3次后跳转到错误处理节点
- 在 State 中记录重试次数
- 验收标准:
- 失败时自动重试,最多3次
- 第4次失败后跳转到errorHandler节点
- 能正确记录每次重试的原因
练习 4:实现输入输出分离
- 目标:为一个问答系统设计InputState和OutputState
- 要求:
- OverallState包含至少5个内部字段
- InputState只暴露1-2个必要字段
- OutputState只返回最终答案
- 测试外部无法访问内部字段
- 验收标准:
- TypeScript 编译时阻止访问内部字段
- invoke() 只需提供input字段
- 返回值只包含output字段
📚 延伸阅读
下一章:第5章 —— 条件边与动态路由


被折叠的 条评论
为什么被折叠?



