第 2 章 · 状态与 Reducer
🎯 本章目标
- 彻底搞懂一句话:节点返回的是"增量",没有 reducer 的字段会被"覆盖"
- 掌握 reducer 的签名
(left, right) => 新值,以及default何时起作用(首轮left是undefined) - 用自定义 reducer 亲手实现两个最常见需求:累加数字、追加数组
- 理解
MessagesAnnotation为什么能让对话"越聊越长"(它的messages自带追加 reducer) - 会用
RemoveMessage/REMOVE_ALL_MESSAGES裁剪、清空历史 - 认识 LangGraph v1 的新式状态写法
new StateSchema({...}),以及ReducedValue/UntrackedValue,并知道何时该选它 - 会用
input/outputschema 把内部中间字段对上、下游"隐身"
本章代码
📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的
code/完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。
| 文件 | 内容 |
|---|---|
code/01-overwrite-vs-merge.ts | 默认覆盖语义:后者覆盖前者;部分更新 vs 整体更新 |
code/02-custom-reducer.ts | 自定义 reducer:累加数字、追加数组,外加"无 reducer"对照组 |
code/03-messages-and-remove.ts | MessagesAnnotation 的追加语义 + RemoveMessage / REMOVE_ALL_MESSAGES |
code/04-state-schema-v1.ts | v1 新式写法 new StateSchema + ReducedValue / UntrackedValue |
code/05-input-output-schema.ts | 输入 / 输出 schema:让中间字段对上下游隐身 |
一、状态的"合并":从覆盖说起
第 1 章我们留下了一句话:节点返回的是"增量",框架负责合并进状态。
但"合并"到底怎么合并?这里藏着本章第一个、也是最重要的坑:
⚠️ 一个字段如果没写 reducer,默认行为是"覆盖"(overwrite)—— 后写的把先写的整个顶掉。
看名字会误以为 state = { ...state, ...update } 是"合并",对普通字段来说确实如此(没被更新的字段保留)。但同一个字段被两个节点先后写过,就只有最后一次生效。
示例 1:亲眼看到"后者覆盖前者"
typescript
/**
* 示例 1:默认的"覆盖"语义(overwrite)
*
* 这是 LangGraph 状态系统里最容易踩坑的一点:
* —— 一个字段如果**没有** reducer,那么节点写什么、它就是什么,
* 后一个节点写同名字段,会把前一个节点的值**直接覆盖**掉。
*
* 本示例分两段:
* 第一段:两个节点依次写同名字段,亲眼看到"后者覆盖前者"。
* 第二段:对比"节点只返回部分字段" vs "节点返回整体状态"。
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { banner } from "../../lib/model.js";
// ── 状态定义:注意这里所有字段都没有 reducer ────────────────────
const DraftState = Annotation.Root({
/** 当前草稿文本 —— 没有 reducer,默认"覆盖" */
draft: Annotation<string>,
/** 最后一版是谁写的 */
editor: Annotation<string>,
/** 已经执行到第几步 */
steps: Annotation<number>,
});
// ── 节点 1:写第一版草稿 ──────────────────────────────────────
function firstDraft(state: typeof DraftState.State) {
console.log(` [节点 firstDraft] 收到的状态:${JSON.stringify(state)}`);
const update = {
draft: "第一版:LangGraph 是一个编排框架。",
editor: "firstDraft",
steps: 1,
};
console.log(` [节点 firstDraft] 返回增量:${JSON.stringify(update)}`);
return update;
}
// ── 节点 2:又写一版草稿(同名字段)──────────────────────────
function secondDraft(state: typeof DraftState.State) {
console.log(` [节点 secondDraft] 收到的状态:${JSON.stringify(state)}`);
const update = {
draft: "第二版:LangGraph 是一个「有状态」的编排框架。",
editor: "secondDraft",
steps: 2,
};
console.log(` [节点 secondDraft] 返回增量:${JSON.stringify(update)}`);
return update;
}
// ── 第二段用的状态:演示"部分更新 vs 整体更新" ────────────────
const ProfileState = Annotation.Root({
name: Annotation<string>,
age: Annotation<number>,
city: Annotation<string>,
});
// 只返回想改的字段(推荐)
function updateCityPartially(state: typeof ProfileState.State) {
console.log(` [节点 updateCityPartially] 收到:${JSON.stringify(state)}`);
const update = { city: "杭州" }; // 只改 city,name/age 原样保留
console.log(` [节点 updateCityPartially] 返回增量:${JSON.stringify(update)}`);
return update;
}
// 返回携带整体状态的对象(不必要,且有风险)
function updateAgeWithSpread(state: typeof ProfileState.State) {
console.log(` [节点 updateAgeWithSpread] 收到:${JSON.stringify(state)}`);
const update = { ...state, age: state.age + 1 }; // 把整个 state 又原样带了回去
console.log(` [节点 updateAgeWithSpread] 返回增量:${JSON.stringify(update)}`);
return update;
}
async function main() {
banner("示例 1:默认覆盖语义 —— 后者覆盖前者");
// ── 第一段:覆盖演示 ──────────────────────────────────────
const draftGraph = new StateGraph(DraftState)
.addNode("firstDraft", firstDraft)
.addNode("secondDraft", secondDraft)
.addEdge(START, "firstDraft")
.addEdge("firstDraft", "secondDraft")
.addEdge("secondDraft", END)
.compile();
console.log('👤 初始输入:{ draft: "", editor: "", steps: 0 }\n');
const draftResult = await draftGraph.invoke({
draft: "",
editor: "",
steps: 0,
});
console.log("\n📦 最终状态:");
console.log(JSON.stringify(draftResult, null, 2));
console.log(
`\n⚠️ draft 只剩第二版的文字、editor 只剩 secondDraft、steps 只剩 2 ——` +
`\n 第一版的信息被彻底覆盖了,因为它们都没有 reducer。`
);
// ── 第二段:部分更新 vs 整体更新 ──────────────────────────
console.log("\n" + "─".repeat(72) + "\n");
console.log("第二段:部分更新(推荐)vs 整体更新(多余)\n");
const profileGraph = new StateGraph(ProfileState)
.addNode("updateCityPartially", updateCityPartially)
.addNode("updateAgeWithSpread", updateAgeWithSpread)
.addEdge(START, "updateCityPartially")
.addEdge("updateCityPartially", "updateAgeWithSpread")
.addEdge("updateAgeWithSpread", END)
.compile();
console.log(
'👤 初始输入:{ name: "蜗牛", age: 28, city: "北京" }\n'
);
const profileResult = await profileGraph.invoke({
name: "蜗牛",
age: 28,
city: "北京",
});
console.log("\n📦 最终状态:");
console.log(JSON.stringify(profileResult, null, 2));
console.log(
`\n✅ name 和 city 都被完整保留:` +
`\n - updateCityPartially 只返回了 { city },name/age 没被动 —— 这就是"增量"合并。` +
`\n - updateAgeWithSpread 返回了 { ...state, age },结果一样,但写法多余:` +
`\n 框架本来就会合并,你不必把整张表再抄一遍。`
);
}
main().catch((e) => {
console.error("❌ 运行失败:", e);
process.exit(1);
});运行:
bash
npx tsx 02-state-and-reducers/code/01-overwrite-vs-merge.ts真实输出:
========================================================================
示例 1:默认覆盖语义 —— 后者覆盖前者
========================================================================
👤 初始输入:{ draft: "", editor: "", steps: 0 }
[节点 firstDraft] 收到的状态:{"draft":"","editor":"","steps":0}
[节点 firstDraft] 返回增量:{"draft":"第一版:LangGraph 是一个编排框架。","editor":"firstDraft","steps":1}
[节点 secondDraft] 收到的状态:{"draft":"第一版:LangGraph 是一个编排框架。","editor":"firstDraft","steps":1}
[节点 secondDraft] 返回增量:{"draft":"第二版:LangGraph 是一个「有状态」的编排框架。","editor":"secondDraft","steps":2}
📦 最终状态:
{
"draft": "第二版:LangGraph 是一个「有状态」的编排框架。",
"editor": "secondDraft",
"steps": 2
}
⚠️ draft 只剩第二版的文字、editor 只剩 secondDraft、steps 只剩 2 ——
第一版的信息被彻底覆盖了,因为它们都没有 reducer。
────────────────────────────────────────────────────────────────────────
第二段:部分更新(推荐)vs 整体更新(多余)
👤 初始输入:{ name: "蜗牛", age: 28, city: "北京" }
[节点 updateCityPartially] 收到:{"name":"蜗牛","age":28,"city":"北京"}
[节点 updateCityPartially] 返回增量:{"city":"杭州"}
[节点 updateAgeWithSpread] 收到:{"name":"蜗牛","age":28,"city":"杭州"}
[节点 updateAgeWithSpread] 返回增量:{"name":"蜗牛","age":29,"city":"杭州"}
📦 最终状态:
{
"name": "蜗牛",
"age": 29,
"city": "杭州"
}
✅ name 和 city 都被完整保留:
- updateCityPartially 只返回了 { city },name/age 没被动 —— 这就是"增量"合并。
- updateAgeWithSpread 返回了 { ...state, age },结果一样,但写法多余:
框架本来就会合并,你不必把整张表再抄一遍。请对着日志看两点:
① 第一段:draft/editor/steps 只剩最后一次的值。 secondDraft 运行时收到的 draft 已经是 firstDraft 写的那一版,但它返回的新值把旧值整个顶掉了。
② 第二段:没返回的字段会被保留。 updateCityPartially 只返回 { city },secondDraft 运行前 name、age 依然在。
💡 一句话记忆:字段的"合并规则"由它的 reducer 决定。没写 reducer = 覆盖;写累了
left + right才叫累加。第 1 章的name每次被覆盖,正是因为它没写 reducer。
⚠️ 为什么"返回整体状态"是个坏习惯? 平时看不出问题,但一旦你不小心
{ ...旧快照, 某字段 },就会把旧快照里已经过期的其他字段一起写回去,在并行分支(第 3 章)里会直接覆盖别的分支刚写好的值。只返回你要改的字段。
二、reducer:让字段学会"累加"
Annotation<类型>() 可以直接调用,也可以传一个配置对象:
typescript
Annotation<T>({
reducer: (left, right) => 新值, // 怎么合并
default: () => 初始值, // 第一次写时,left 从哪来
});reducer(left, right):left是状态里当前已有的值,right是这个节点这次写入的值。你返回什么,字段就变成什么。default():当一个字段第一次被写入时,left是undefined。若你直接undefined + right就会得到NaN。default提供起始值,让"累加"有个从 0 开始的起点。
示例 2:累加数字 + 追加数组
typescript
/**
* 示例 2:自定义 reducer —— 让字段学会"累加"而不是"覆盖"
*
* Annotation<T>({ reducer, default }) 的两个关键参数:
* - reducer: (left, right) => 新值
* left = 状态里当前已有的值
* right = 节点这次想写进去的值
* - default: () => 初始值
* 当这个字段"第一次被写"时,left 是 undefined,
* 此时框架用 default() 的返回值作为 left。
*
* 本示例并排演示:
* ① 累加数字(预算累加器)—— reducer 相加
* ② 追加数组(操作日志)—— reducer concat
* ③ 对照组:一个**没有 reducer** 的数组,会被每次覆盖
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { banner } from "../../lib/model.js";
const BudgetState = Annotation.Root({
/** 预算总额 */
budget: Annotation<number>,
/** 已花费金额 —— 累加:每一次的 right 会加到 left 上 */
spent: Annotation<number>({
reducer: (left, right) => left + right,
default: () => 0,
}),
/** 操作日志 —— 追加:把这次的 right 拼到 left 后面 */
log: Annotation<string[]>({
reducer: (left, right) => left.concat(right),
default: () => [],
}),
/** 对照组:没有 reducer 的数组,只会保留最后一次写进去的值 */
overwrittenLog: Annotation<string[]>,
});
// ── 三个"花钱"节点:每个都写 spent / log / overwrittenLog ──────
function buyCoffee(state: typeof BudgetState.State) {
console.log(` [买咖啡] 收到 spent=${state.spent}, log=${JSON.stringify(state.log)}`);
return {
spent: 18,
log: ["☕ 买咖啡 -18"],
overwrittenLog: ["☕ 买咖啡 -18"],
};
}
function buyLunch(state: typeof BudgetState.State) {
console.log(` [买午餐] 收到 spent=${state.spent}, log=${JSON.stringify(state.log)}`);
return {
spent: 35,
log: ["🍜 买午餐 -35"],
overwrittenLog: ["🍜 买午餐 -35"],
};
}
function buyBook(state: typeof BudgetState.State) {
console.log(` [买书] 收到 spent=${state.spent}, log=${JSON.stringify(state.log)}`);
return {
spent: 68,
log: ["📚 买书 -68"],
overwrittenLog: ["📚 买书 -68"],
};
}
async function main() {
banner("示例 2:自定义 reducer(累加数字 / 追加数组)");
const graph = new StateGraph(BudgetState)
.addNode("buyCoffee", buyCoffee)
.addNode("buyLunch", buyLunch)
.addNode("buyBook", buyBook)
.addEdge(START, "buyCoffee")
.addEdge("buyCoffee", "buyLunch")
.addEdge("buyLunch", "buyBook")
.addEdge("buyBook", END)
.compile();
// 注意:初始输入里**没有** spent,也没有 log
// —— 它们的初始值来自 reducer 的 default()
console.log('👤 初始输入:{ budget: 200 }(故意不给 spent / log)\n');
const result = await graph.invoke({ budget: 200 });
console.log("\n📦 最终状态:");
console.log(JSON.stringify(result, null, 2));
console.log(
"\n✅ 看点:" +
`\n 1. spent = ${result.spent}(18 + 35 + 68)—— reducer 让每次写的值"累加"了。` +
`\n 2. log 有 ${result.log.length} 条 —— reducer 让每次写的数组"追加"了。` +
`\n 3. overwrittenLog 只有 ${result.overwrittenLog.length} 条 —— 没有 reducer,只有最后一次生效。` +
`\n 4. 第一个节点读到的 spent 是 0(来自 default),不是 undefined。`
);
}
main().catch((e) => {
console.error("❌ 运行失败:", e);
process.exit(1);
});运行:
bash
npx tsx 02-state-and-reducers/code/02-custom-reducer.ts真实输出:
========================================================================
示例 2:自定义 reducer(累加数字 / 追加数组)
========================================================================
👤 初始输入:{ budget: 200 }(故意不给 spent / log)
[买咖啡] 收到 spent=0, log=[]
[买午餐] 收到 spent=18, log=["☕ 买咖啡 -18"]
[买书] 收到 spent=53, log=["☕ 买咖啡 -18","🍜 买午餐 -35"]
📦 最终状态:
{
"budget": 200,
"spent": 121,
"log": [
"☕ 买咖啡 -18",
"🍜 买午餐 -35",
"📚 买书 -68"
],
"overwrittenLog": [
"📚 买书 -68"
]
}
✅ 看点:
1. spent = 121(18 + 35 + 68)—— reducer 让每次写的值"累加"了。
2. log 有 3 条 —— reducer 让每次写的数组"追加"了。
3. overwrittenLog 只有 1 条 —— 没有 reducer,只有最后一次生效。
4. 第一个节点读到的 spent 是 0(来自 default),不是 undefined。三个看点,逐一对照上面的日志:
① spent 从 0 一路累加到 121。 第一个节点读到 spent=0,这个 0 不是我们传进去的(初始输入只有 budget),而是 default: () => 0 给的。第二、三个节点分别读到 18、53,说明每次写入都被"加"上去了。
② log 越加越多。 left.concat(right) 把这次的数组拼到已有数组后面,于是 3 次写入后变成 3 条。
③ overwrittenLog 只剩最后 1 条。 它和 log 写入的内容完全一样,唯一区别是没有 reducer,所以每次被覆盖 —— 这就是示例 1 的结论在这里的活体演示。
💡 reducer 不一定要"累加"。 任意的
(left, right) => ...都行,比如取最大值Math.max(left, right)、保留最新一条right、去重后再拼…… 它本质是"两个值怎么合成一个新值"的规则。
⚠️
right是你要注意的类型。 对Annotation<number[]>,节点返回[x]时right是[x](数组);返回x时right是x。混用会让 reducer 逻辑出错,保持一致最省心。
三、消息:为什么对话能"越聊越长"
第 1 章的 ReAct 智能体里,messages 是越加越多的。这靠的就是官方预置状态 MessagesAnnotation:
typescript
import { MessagesAnnotation } from "@langchain/langgraph";
// 它等价于手写:
const StateAnnotation = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (left, right) => left.concat(right), // 追加,而不是覆盖
default: () => [],
}),
});除此之外,它还支持 RemoveMessage(按 id 删某条)和 REMOVE_ALL_MESSAGES(清空全部)。这三个工具,是"上下文管理"的基础。
示例 3:追加、删除、清空
typescript
/**
* 示例 3:消息的"追加"语义,以及删除消息
*
* MessagesAnnotation 是官方预置的状态,它给 messages 字段配好了 reducer
* —— 节点返回数组时是"追加",不是"覆盖"。这是多轮对话能成立的根本原因。
*
* 本示例分三段:
* ① 追加:真实多轮模型对话,手动把上轮消息带回,观察条数只增不减
* ② RemoveMessage:按 id 删掉指定的某条消息,观察条数减少
* ③ REMOVE_ALL_MESSAGES:一次性清空全部历史
*/
import {
StateGraph,
START,
END,
MessagesAnnotation,
REMOVE_ALL_MESSAGES,
} from "@langchain/langgraph";
import {
HumanMessage,
AIMessage,
RemoveMessage,
type BaseMessage,
} from "@langchain/core/messages";
import { createModel, banner } from "../../lib/model.js";
import { assertChatKey } from "../../lib/env.js";
/** 把一批消息打印成一行行的清单 */
function printMessages(title: string, messages: BaseMessage[]) {
console.log(` ${title}(共 ${messages.length} 条):`);
messages.forEach((m, i) => {
const text = String(m.content).replace(/\s+/g, " ").slice(0, 60);
console.log(` ${i}. [${m.getType()}] id=${m.id} ${text}`);
});
}
async function main() {
banner("示例 3:消息追加、RemoveMessage 与清空历史");
assertChatKey();
const model = createModel({ temperature: 0 });
// ── 图:一个节点,把消息交给模型,把回复"追加"回去 ────────────
async function chatNode(state: typeof MessagesAnnotation.State) {
console.log(` [节点 chat] 交给模型的消息条数:${state.messages.length}`);
const res = await model.invoke(state.messages);
// 返回数组 —— MessagesAnnotation 的 reducer 会把它 concat 上去
return { messages: [res] };
}
const chatGraph = new StateGraph(MessagesAnnotation)
.addNode("chat", chatNode)
.addEdge(START, "chat")
.addEdge("chat", END)
.compile();
// ── ① 追加语义:多轮对话 ───────────────────────────────────
console.log("【第一段】追加语义:把上轮消息带回,模拟多轮对话\n");
let history: BaseMessage[] = [];
const questions = [
"你好,请用一句话介绍你自己。",
"请记住:我叫蜗牛。",
"我叫什么名字?",
];
for (const q of questions) {
history = [...history, new HumanMessage(q)];
console.log(`👤 提问:${q}`);
const result = await chatGraph.invoke({ messages: history });
history = result.messages;
const last = history[history.length - 1];
console.log(`🤖 回答:${String(last.content).replace(/\s+/g, " ").slice(0, 80)}`);
console.log(`📊 当前消息条数:${history.length}\n`);
}
console.log("📜 三轮对话结束后的完整消息列表:");
printMessages("消息列表", history);
// ── ② RemoveMessage:删掉指定的第一条人类消息 ──────────────
console.log("\n" + "─".repeat(72) + "\n");
console.log("【第二段】RemoveMessage:按 id 删除指定消息\n");
const firstHumanId = history[0].id!;
console.log(`🎯 目标是删除第一条消息,id=${firstHumanId}`);
console.log(` 内容:"${String(history[0].content).replace(/\s+/g, " ")}"`);
const trimGraph = new StateGraph(MessagesAnnotation)
.addNode("removeFirst", (state: typeof MessagesAnnotation.State) => {
console.log(` [节点 removeFirst] 删除前条数:${state.messages.length}`);
return { messages: [new RemoveMessage({ id: firstHumanId })] };
})
.addEdge(START, "removeFirst")
.addEdge("removeFirst", END)
.compile();
const afterRemove = await trimGraph.invoke({ messages: history });
console.log(` [节点 removeFirst] 删除后条数:${afterRemove.messages.length}`);
printMessages("删除后的消息列表", afterRemove.messages);
// ── ③ REMOVE_ALL_MESSAGES:清空全部历史 ────────────────────
console.log("\n" + "─".repeat(72) + "\n");
console.log("【第三段】REMOVE_ALL_MESSAGES:一次性清空历史\n");
const clearGraph = new StateGraph(MessagesAnnotation)
.addNode("clearHistory", (state: typeof MessagesAnnotation.State) => {
console.log(` [节点 clearHistory] 清空前条数:${state.messages.length}`);
// 先丢一个"清空哨兵",再追加一条新消息,表示"从零开始"
return {
messages: [
new RemoveMessage({ id: REMOVE_ALL_MESSAGES }),
new AIMessage("(历史已清空,我们重新开始。)"),
],
};
})
.addEdge(START, "clearHistory")
.addEdge("clearHistory", END)
.compile();
const afterClear = await clearGraph.invoke({ messages: history });
console.log(` [节点 clearHistory] 清空后条数:${afterClear.messages.length}`);
printMessages("清空后的消息列表", afterClear.messages);
console.log(
"\n✅ 看点:" +
`\n 1. 三轮对话里,条数按 2 → 4 → 6 递增 —— messages 是"追加"的。` +
`\n 2. RemoveMessage({ id }) 精确删掉那一条,6 → 5。` +
`\n 3. RemoveMessage({ id: REMOVE_ALL_MESSAGES }) 把 6 条全清掉,只留下哨兵之后的消息。`
);
}
main().catch((e) => {
console.error("❌ 运行失败:", e);
process.exit(1);
});运行:
bash
npx tsx 02-state-and-reducers/code/03-messages-and-remove.ts真实输出(模型措辞每次会略有不同,属正常现象):
========================================================================
示例 3:消息追加、RemoveMessage 与清空历史
========================================================================
【第一段】追加语义:把上轮消息带回,模拟多轮对话
👤 提问:你好,请用一句话介绍你自己。
[节点 chat] 交给模型的消息条数:1
🤖 回答:你好,我是由深度求索公司创造的AI助手,随时乐于为你提供帮助。
📊 当前消息条数:2
👤 提问:请记住:我叫蜗牛。
[节点 chat] 交给模型的消息条数:3
🤖 回答:好的,蜗牛,我会在这次对话中记住你的名字。
📊 当前消息条数:4
👤 提问:我叫什么名字?
[节点 chat] 交给模型的消息条数:5
🤖 回答:你叫蜗牛。
📊 当前消息条数:6
📜 三轮对话结束后的完整消息列表:
消息列表(共 6 条):
0. [human] id=cee57e55-32c8-4986-b8e3-a8d44a5eda95 你好,请用一句话介绍你自己。
1. [ai] id=2684a880-0121-4d2d-900a-16fe2a4bd611 你好,我是由深度求索公司创造的AI助手,随时乐于为你提供帮助。
2. [human] id=44503ba0-982a-4aaf-aba7-b58112010e66 请记住:我叫蜗牛。
3. [ai] id=75cc712d-384e-47a8-89c6-f6514db52192 好的,蜗牛,我会在这次对话中记住你的名字。
4. [human] id=cf975bae-8fd9-4c81-b1bc-29faba7f43a6 我叫什么名字?
5. [ai] id=e858ca6e-0e6e-4732-bf47-b4c8d9c49f8f 你叫蜗牛。
────────────────────────────────────────────────────────────────────────
【第二段】RemoveMessage:按 id 删除指定消息
🎯 目标是删除第一条消息,id=cee57e55-32c8-4986-b8e3-a8d44a5eda95
内容:"你好,请用一句话介绍你自己。"
[节点 removeFirst] 删除前条数:6
[节点 removeFirst] 删除后条数:5
删除后的消息列表(共 5 条):
0. [ai] id=2684a880-0121-4d2d-900a-16fe2a4bd611 你好,我是由深度求索公司创造的AI助手,随时乐于为你提供帮助。
1. [human] id=44503ba0-982a-4aaf-aba7-b58112010e66 请记住:我叫蜗牛。
2. [ai] id=75cc712d-384e-47a8-89c6-f6514db52192 好的,蜗牛,我会在这次对话中记住你的名字。
3. [human] id=cf975bae-8fd9-4c81-b1bc-29faba7f43a6 我叫什么名字?
4. [ai] id=e858ca6e-0e6e-4732-bf47-b4c8d9c49f8f 你叫蜗牛。
────────────────────────────────────────────────────────────────────────
【第三段】REMOVE_ALL_MESSAGES:一次性清空历史
[节点 clearHistory] 清空前条数:6
[节点 clearHistory] 清空后条数:1
清空后的消息列表(共 1 条):
0. [ai] id=412b8f7e-4cf9-4b76-9433-2e0e4f1bd4f8 (历史已清空,我们重新开始。)
✅ 看点:
1. 三轮对话里,条数按 2 → 4 → 6 递增 —— messages 是"追加"的。
2. RemoveMessage({ id }) 精确删掉那一条,6 → 5。
3. RemoveMessage({ id: REMOVE_ALL_MESSAGES }) 把 6 条全清掉,只留下哨兵之后的消息。三点对照:
① 条数递增证明"追加"。 第 2 轮提问时,交给模型的是 3 条消息(上一轮的 human + ai + 本轮 human)。模型能回答"你叫蜗牛",正是因为 蜗牛 这个名字留在了历史里。如果 messages 是覆盖语义,第 1 条 human 早被冲掉,模型不可能记得。
② RemoveMessage({ id }) 精确删除。 它的工作原理是:reducer 在合并时,发现 right 里有一个 RemoveMessage,就把 left 中 id 相同的那条剔除掉。所以 RemoveMessage 必须带上真实存在的 id,删不存在的 id 会报错。
③ REMOVE_ALL_MESSAGES 清空。 它是一个特殊哨兵:reducer 一遇到它,就把之前所有消息丢弃,只保留它之后的消息。所以返回 [清空哨兵, 新 AI 消息] 的结果是 —— 历史归零,从头开始。
💡 和第 1 章连起来看:ReAct 智能体循环多轮不爆上下文,靠的正是这套机制。真实项目里你会在
agent节点前加一个"裁剪历史"的节点,用RemoveMessage或REMOVE_ALL_MESSAGES把过长的对话压下去(第 7 章会讲持久化与记忆,那里会再见到它)。
⚠️
RemoveMessage不会"凭空减少"条数之外的副作用:它只影响状态里的messages数组,不会撤销模型已经产生的调用。裁剪是"事后清理",不是"时光倒流"。
四、LangGraph v1 的新式状态写法
前面一直用 Annotation.Root。v1 还带来了一种更贴近"数据结构"的写法:new StateSchema({...})。它把每个字段用类型 schema 声明,并直接在字段上选好"通道(Channel)":
| 你想要的行为 | Annotation.Root 写法 | v1 StateSchema 写法 |
|---|---|---|
| 普通字段(覆盖) | Annotation<string> | z.string() |
| 累加 / 追加(带 reducer) | Annotation<T>({ reducer, default }) | new ReducedValue(值schema, { reducer }) |
| 瞬态字段(不持久化) | 无内建等价物 | new UntrackedValue(schema) |
ReducedValue:就是带 reducer 的字段。它的默认值来自值schema是否能接受undefined(例如z.number().default(0))。UntrackedValue:值会正常参与本次运行、节点能读到,但不进入 checkpoint(持久化快照)。适合放"临时缓存""本次才有效的上下文",避免污染存储。
示例 4:new StateSchema 跑一个最小图
typescript
/**
* 示例 4:LangGraph v1 的新式状态写法 —— new StateSchema({...})
*
* 在 v1 里,除了熟悉的 Annotation.Root,还多了一种更"贴近数据结构"的写法:
*
* const MyState = new StateSchema({
* query: z.string(), // 普通字段(覆盖语义)
* total: new ReducedValue(...), // 需要 reducer 的字段
* scratch: new UntrackedValue(...), // 瞬态字段(不进 checkpoint)
* });
*
* 它和 Annotation.Root 表达的是同一件事,只是把"通道(Channel)"的选择
* 直接写进了字段定义里。本示例用一个最小图把三种字段都演示一遍,
* 结尾给出两种写法的对比与选择建议。
*/
import {
StateGraph,
START,
END,
StateSchema,
ReducedValue,
UntrackedValue,
} from "@langchain/langgraph";
import * as z from "zod/v4";
import { banner } from "../../lib/model.js";
// ── 一个必要的小适配器 ────────────────────────────────────────
/**
* LangGraph 的 StateSchema 字段要求对象实现"标准 schema":
* ~standard.validate() —— 校验 / 取值
* ~standard.jsonSchema —— 导出 JSON Schema(给可视化 / Studio 用)
*
* 本仓库锁定的 zod 3.25,它的 ~standard 只实现了 validate,缺 jsonSchema,
* 所以直接传 zod 对象会在**类型检查**时报错(运行时其实用不到 jsonSchema)。
* 这里补一个占位实现,其余能力原样转发给 zod。
*
* 小提示:如果你用的是 zod 4.1+,通常可以直接把 zod 对象传给 StateSchema,
* 不再需要这层适配。
*/
function asStandardSchema<Out, In = Out>(schema: z.ZodType<Out, In>) {
const std = schema["~standard"];
return {
"~standard": {
version: 1 as const,
vendor: std.vendor,
validate: (value: unknown) => std.validate(value),
types: undefined as unknown as
| { readonly input: Out; readonly output: Out }
| undefined,
jsonSchema: {
input: (_options: { readonly target: string }) =>
({}) as Record<string, unknown>,
output: (_options: { readonly target: string }) =>
({}) as Record<string, unknown>,
},
},
};
}
// ── 用 StateSchema 定义状态 ──────────────────────────────────
const PipelineState = new StateSchema({
/** 普通字段:等价于 Annotation<string>,新值直接覆盖旧值 */
query: asStandardSchema(z.string()),
/** 累加器:把每次写入的数字累加起来(预算 / 计费场景常用) */
tokens: new ReducedValue<number, number>(
asStandardSchema(z.number().default(0)),
{ reducer: (current, next) => current + next }
),
/** 追加数组:把每次写入的字符串追加进去(操作日志场景) */
steps: new ReducedValue<string[], string>(
asStandardSchema(z.array(z.string()).default(() => [])),
{
inputSchema: asStandardSchema(z.string()),
reducer: (current, next) => [...current, next],
}
),
/** 瞬态字段:会留在本次运行的状态里,但不参与持久化追踪 */
scratch: new UntrackedValue<string>(asStandardSchema(z.string())),
});
// ── 类型从 Schema 上"取"下来(v1 的写法)─────────────────────
type State = typeof PipelineState.State;
type Update = typeof PipelineState.Update;
function planNode(state: State): Update {
console.log(` [节点 plan] 收到 query=${JSON.stringify(state.query)}`);
return {
steps: "plan:拆解问题",
tokens: 120,
scratch: "plan 的临时便签",
};
}
function searchNode(state: State): Update {
console.log(` [节点 search] 收到 steps=${JSON.stringify(state.steps)}`);
console.log(
` [节点 search] 上一节点留下的 scratch=${JSON.stringify(state.scratch)}`
);
return {
steps: "search:检索资料",
tokens: 340,
scratch: "search 的临时便签",
};
}
function writeNode(state: State): Update {
console.log(` [节点 write] 收到 steps=${JSON.stringify(state.steps)}`);
return {
query: `${state.query}(已作答)`,
steps: "write:组织答案",
tokens: 80,
scratch: "write 的临时便签",
};
}
async function main() {
banner("示例 4:LangGraph v1 的 StateSchema 写法");
const graph = new StateGraph(PipelineState)
.addNode("plan", planNode)
.addNode("search", searchNode)
.addNode("write", writeNode)
.addEdge(START, "plan")
.addEdge("plan", "search")
.addEdge("search", "write")
.addEdge("write", END)
.compile();
console.log('👤 初始输入:{ query: "什么是 LangGraph?" }\n');
const result = await graph.invoke({ query: "什么是 LangGraph?" });
console.log("\n📦 最终状态:");
console.log(JSON.stringify(result, null, 2));
console.log(
"\n✅ 看点:" +
`\n 1. tokens = ${result.tokens}(120 + 340 + 80)—— ReducedValue 在累加。` +
`\n 2. steps 有 ${result.steps.length} 条 —— ReducedValue 在追加。` +
`\n 3. query 被最后的节点覆盖成了「已作答」。` +
`\n 4. scratch 是 UntrackedValue:能在节点间看到,但不参与持久化追踪。`
);
}
main().catch((e) => {
console.error("❌ 运行失败:", e);
process.exit(1);
});运行:
bash
npx tsx 02-state-and-reducers/code/04-state-schema-v1.ts真实输出:
========================================================================
示例 4:LangGraph v1 的 StateSchema 写法
========================================================================
👤 初始输入:{ query: "什么是 LangGraph?" }
[节点 plan] 收到 query="什么是 LangGraph?"
[节点 search] 收到 steps=["plan:拆解问题"]
[节点 search] 上一节点留下的 scratch="plan 的临时便签"
[节点 write] 收到 steps=["plan:拆解问题","search:检索资料"]
📦 最终状态:
{
"query": "什么是 LangGraph?(已作答)",
"tokens": 540,
"steps": [
"plan:拆解问题",
"search:检索资料",
"write:组织答案"
],
"scratch": "write 的临时便签"
}
✅ 看点:
1. tokens = 540(120 + 340 + 80)—— ReducedValue 在累加。
2. steps 有 3 条 —— ReducedValue 在追加。
3. query 被最后的节点覆盖成了「已作答」。
4. scratch 是 UntrackedValue:能在节点间看到,但不参与持久化追踪。⚠️ 一个真实的版本坑(务必读):本仓库锁定的是
zod@3.25。它的~standard只实现了validate,缺jsonSchema,而 LangGraph 的StateSchema字段类型要求两者都有 —— 所以直接把 zod 对象传给StateSchema运行时能跑通、但类型检查会报错。示例里的asStandardSchema()就是为此存在的补丁:补一个jsonSchema占位,其余能力转发给 zod。等你的项目升级到zod@4.1+,通常可以直接把 zod 对象传进去,这层适配就能删掉。(本项目对它是"完整文件的一部分",所以代码块里保留了它,npx tsc --noEmit零错误。)
两种写法怎么选?
| 维度 | Annotation.Root(旧) | new StateSchema(v1 新) |
|---|---|---|
| 定义 reducer | Annotation<T>({ reducer }) | new ReducedValue(schema, { reducer }) |
| 默认值 | default: () => x | 值 schema 接受 undefined(如 .default(x)) |
| 类型来源 | typeof State.State | typeof State.State(一致) |
| 好处 | 简单、社区示例多 | 字段即 schema,能导出 JSON Schema / 对接 Studio |
| 适合 | 绝大多数手写场景 | 需要 schema 自描述、工具链集成的场景 |
💡 建议:日常手写用
Annotation.Root完全够用,书里后续章节也以它为主。当你需要"状态能被可视化、能被外部系统读结构"时,再切到StateSchema。两者能混用,不必二选一。
五、输入 / 输出 schema:让中间字段"隐身"
真实系统里,状态总有三种边界:
- 上游能传进来的(比如用户问题)
- 内部临时用的(比如草稿、检索片段、token 计数)
- 最后该暴露出去的(比如最终答案)
new StateGraph({ state, input, output }) 让你把这三者写清楚:
invoke(输入) 节点读写的完整状态 invoke() 返回值
┌─────────┐ ┌─────────────────────┐ ┌─────────┐
│ input │ ──只允许这些字段──► │ state(完整内部) │ ──只暴露──► │ output │
│question │ │ question/draft/answer │ │ answer │
└─────────┘ └─────────────────────┘ └─────────┘state:图内部完整的共享状态,节点读写的就是它。input:invoke()只接受这些字段。output:invoke()跑完后只返回这些字段 —— 中间字段对外隐身。
示例 5:藏住内部草稿
typescript
/**
* 示例 5:输入 / 输出 schema —— 让中间字段"隐身"
*
* 真实系统里,状态往往既有"上游能传进来的",也有"内部临时用的",
* 还有"最后才该暴露出去的"。new StateGraph({ state, input, output })
* 让你把这三种边界讲清楚:
*
* state —— 图内部完整的共享状态(节点读写的都是它)
* input —— invoke() 只接受的字段(多传了会被忽略/报错)
* output —— 图跑完后只返回的字段(中间字段对外隐身)
*
* 本示例的状态里有一个内部草稿字段 draft:
* input 只接受 question
* output 只暴露 answer
* draft 永远只活在图内部
*/
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
import { banner } from "../../lib/model.js";
// ── 完整内部状态:nodes 读写的就是它 ──────────────────────────
const OverallState = Annotation.Root({
/** 用户的问题(来自 input) */
question: Annotation<string>,
/** 内部草稿(不对外暴露) */
draft: Annotation<string>,
/** 最终答案(暴露给 output) */
answer: Annotation<string>,
});
// ── 只允许传进来的字段 ────────────────────────────────────────
const InputState = Annotation.Root({
question: Annotation<string>,
});
// ── 只允许返回出去的字段 ──────────────────────────────────────
const OutputState = Annotation.Root({
answer: Annotation<string>,
});
// ── 节点 1:读 question,写内部草稿 draft ─────────────────────
function draftNode(state: typeof OverallState.State) {
console.log(` [节点 draft] 收到的完整内部状态:${JSON.stringify(state)}`);
return {
draft: `(内部草稿)针对「${state.question}」的初步思路:先定义,再举例。`,
};
}
// ── 节点 2:读内部草稿,产出最终答案 answer ───────────────────
function finishNode(state: typeof OverallState.State) {
console.log(` [节点 finish] 收到的完整内部状态:${JSON.stringify(state)}`);
return {
answer: `关于「${state.question}」:LangGraph 是把工作流建模成「有状态图」的编排框架。`,
};
}
async function main() {
banner("示例 5:输入 / 输出 schema(中间字段隐身)");
const graph = new StateGraph({
state: OverallState, // 内部完整状态
input: InputState, // invoke() 只能传 question
output: OutputState, // invoke() 只返回 answer
})
.addNode("makeDraft", draftNode)
.addNode("finish", finishNode)
.addEdge(START, "makeDraft")
.addEdge("makeDraft", "finish")
.addEdge("finish", END)
.compile();
console.log('👤 输入:{ question: "什么是 LangGraph?" }\n');
const result = await graph.invoke({ question: "什么是 LangGraph?" });
console.log("\n📦 invoke() 返回的结果:");
console.log(JSON.stringify(result, null, 2));
console.log(`\n🔑 结果的字段名:${JSON.stringify(Object.keys(result))}`);
console.log(
`draft 在结果里吗? → ${"draft" in result ? "在(没藏住)" : "不在(已隐身)"}`
);
console.log(
`question 在结果里吗? → ${
"question" in result ? "在(没藏住)" : "不在(已隐身)"
}`
);
console.log(
"\n✅ 看点:" +
`\n 1. 两个节点内部都能读到 question 和 draft(完整状态对节点可见)。` +
`\n 2. 但 invoke() 的返回值里只有 answer —— draft、question 都被 output schema 挡住了。` +
`\n 3. 同理,调用方也只能传 question,传别的字段没有意义 —— 这就是 input schema 的作用。`
);
}
main().catch((e) => {
console.error("❌ 运行失败:", e);
process.exit(1);
});运行:
bash
npx tsx 02-state-and-reducers/code/05-input-output-schema.ts真实输出:
========================================================================
示例 5:输入 / 输出 schema(中间字段隐身)
========================================================================
👤 输入:{ question: "什么是 LangGraph?" }
[节点 draft] 收到的完整内部状态:{"question":"什么是 LangGraph?"}
[节点 finish] 收到的完整内部状态:{"question":"什么是 LangGraph?","draft":"(内部草稿)针对「什么是 LangGraph?」的初步思路:先定义,再举例。"}
📦 invoke() 返回的结果:
{
"answer": "关于「什么是 LangGraph?」:LangGraph 是把工作流建模成「有状态图」的编排框架。"
}
🔑 结果的字段名:["answer"]
draft 在结果里吗? → 不在(已隐身)
question 在结果里吗? → 不在(已隐身)
✅ 看点:
1. 两个节点内部都能读到 question 和 draft(完整状态对节点可见)。
2. 但 invoke() 的返回值里只有 answer —— draft、question 都被 output schema 挡住了。
3. 同理,调用方也只能传 question,传别的字段没有意义 —— 这就是 input schema 的作用。对照日志:节点内部能看到完整的 question 和 draft(第二行 finish 节点的日志里就有 draft),但 invoke() 的返回值里 Object.keys 只有 ["answer"] —— draft 和 question 都"隐身"了。
⚠️ 命名坑:节点名不能和状态字段重名。写本示例时我先用了节点名
"draft",结果直接报错:draft is already being used as a state attribute (a.k.a. a channel), cannot also be used as a node name.改成"makeDraft"就好了。记住这条规则,能省一个报错。
💡
input/outputschema 的价值:它是"接口契约"。调用方一眼知道该传什么、会拿到什么;内部随便加草稿、检索中间结果、调试字段,都不会泄漏到invoke()的返回值里。对做 API / 微服务的团队尤其重要。
本章小结
本章速查表
| 你要做的事 | 代码 |
|---|---|
| 普通字段(覆盖语义) | Annotation<类型> |
| 累加 / 追加字段 | Annotation<T>({ reducer, default }) |
| 累加数字 | reducer: (l, r) => l + r, default: () => 0 |
| 追加数组 | reducer: (l, r) => l.concat(r), default: () => [] |
| 消息状态(追加 + 可删) | MessagesAnnotation |
| 按 id 删某条消息 | new RemoveMessage({ id })(RemoveMessage 来自 @langchain/core/messages) |
| 清空全部消息 | new RemoveMessage({ id: REMOVE_ALL_MESSAGES })(REMOVE_ALL_MESSAGES 来自 @langchain/langgraph) |
| v1 新式状态 | new StateSchema({ 字段: schema | ReducedValue | UntrackedValue }) |
| 带 reducer 的字段 | new ReducedValue(值schema, { reducer, inputSchema? }) |
| 瞬态字段(不持久化) | new UntrackedValue(schema) |
| 从 v1 Schema 取类型 | typeof MyState.State / typeof MyState.Update |
| 隐藏中间字段 | new StateGraph({ state, input, output }) |
三句话总结
- 字段的合并规则由它的 reducer 决定:没写 reducer 就是"覆盖",写了
(left, right)才能累加 / 追加。节点永远只返回"要改的增量"。 default是"第一次写"的救兵:首轮left是undefined,靠default()给个起点,累加才从 0 开始而不是NaN。- 状态有边界:
input/outputschema 把"能进来的""内部用的""能出去的"分清楚,中间字段对上下游隐身 —— 这是把图接进真实系统的关键。
动手练习
- 给示例 2 增加一个
history字段(Annotation<string[]>({ reducer: (l, r) => l.concat(r), default: () => [] })),在每个节点里追加一句"谁在什么时候花了多少",最后打印完整消费流水。 - 把示例 3 的第二段改成"删除最近两条消息"(提示:用
history.slice(-2)拿到两条消息的id,返回两个RemoveMessage),观察条数变化。 - 把示例 5 的
outputschema 改成同时暴露answer和draft,再跑一次,确认Object.keys(result)里出现了draft—— 亲手验证outputschema 就是"唯一的出口过滤器"。
下一章预告
第 3 章我们讲节点、边与控制流:条件边怎么写、怎么并行发多条边再汇总、怎么用 Command 让节点"自己决定"跳到哪里,以及并行分支汇合时 reducer 为什么能救命(提示:重读本章示例 1 的 ⚠️)。搞懂状态之后,就该让状态在图上"跑起来"了。