Skip to content

第 2 章 · 状态与 Reducer ​

🎯 本章目标 ​

  • 彻底搞懂一句话:节点返回的是"增量",没有 reducer 的字段会被"覆盖"
  • 掌握 reducer 的签名 (left, right) => 新值,以及 default 何时起作用(首轮 left 是 undefined)
  • 用自定义 reducer 亲手实现两个最常见需求:累加数字、追加数组
  • 理解 MessagesAnnotation 为什么能让对话"越聊越长"(它的 messages 自带追加 reducer)
  • 会用 RemoveMessage / REMOVE_ALL_MESSAGES 裁剪、清空历史
  • 认识 LangGraph v1 的新式状态写法 new StateSchema({...}),以及 ReducedValue / UntrackedValue,并知道何时该选它
  • 会用 input / output schema 把内部中间字段对上、下游"隐身"

本章代码

📌 代码约定:正文的代码块都保持可直接运行(含 import 与模型初始化)。若某段为聚焦概念的节选,会明确标注「节选」并指向同名的 code/ 完整文件。所有示例的输出都是真实运行结果,不是编造的示例输出。

文件内容
code/01-overwrite-vs-merge.ts默认覆盖语义:后者覆盖前者;部分更新 vs 整体更新
code/02-custom-reducer.ts自定义 reducer:累加数字、追加数组,外加"无 reducer"对照组
code/03-messages-and-remove.tsMessagesAnnotation 的追加语义 + RemoveMessage / REMOVE_ALL_MESSAGES
code/04-state-schema-v1.tsv1 新式写法 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 新)
定义 reducerAnnotation<T>({ reducer })new ReducedValue(schema, { reducer })
默认值default: () => x值 schema 接受 undefined(如 .default(x))
类型来源typeof State.Statetypeof 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 / output schema 的价值:它是"接口契约"。调用方一眼知道该传什么、会拿到什么;内部随便加草稿、检索中间结果、调试字段,都不会泄漏到 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 })

三句话总结

  1. 字段的合并规则由它的 reducer 决定:没写 reducer 就是"覆盖",写了 (left, right) 才能累加 / 追加。节点永远只返回"要改的增量"。
  2. default 是"第一次写"的救兵:首轮 left 是 undefined,靠 default() 给个起点,累加才从 0 开始而不是 NaN。
  3. 状态有边界:input / output schema 把"能进来的""内部用的""能出去的"分清楚,中间字段对上下游隐身 —— 这是把图接进真实系统的关键。

动手练习

  1. 给示例 2 增加一个 history 字段(Annotation<string[]>({ reducer: (l, r) => l.concat(r), default: () => [] })),在每个节点里追加一句"谁在什么时候花了多少",最后打印完整消费流水。
  2. 把示例 3 的第二段改成"删除最近两条消息"(提示:用 history.slice(-2) 拿到两条消息的 id,返回两个 RemoveMessage),观察条数变化。
  3. 把示例 5 的 output schema 改成同时暴露 answer 和 draft,再跑一次,确认 Object.keys(result) 里出现了 draft —— 亲手验证 output schema 就是"唯一的出口过滤器"。

下一章预告

第 3 章我们讲节点、边与控制流:条件边怎么写、怎么并行发多条边再汇总、怎么用 Command 让节点"自己决定"跳到哪里,以及并行分支汇合时 reducer 为什么能救命(提示:重读本章示例 1 的 ⚠️)。搞懂状态之后,就该让状态在图上"跑起来"了。


← 上一章:第一个图 | 返回总目录 | 下一章:节点、边与控制流 →

内容基于 LangGraph.js 官方文档与示例(langchain-ai/langgraphjs,MIT License)整理并重排为中文学习路径