上一篇我们搭好了 LangGraph 的骨架:State / Node / Edge、compile() 铁律,以及「跟 LangChain 怎么分工」。但说句实话——如果你还不懂 State 更新是怎么合进去的,后面一写对话 Agent,messages 要么叠成重复灾难,要么被整份覆盖成一张白纸。

State 才是 LangGraph 的心脏。这一篇我们就把它掰开:StateSchema 怎么声明、reducer 管合并、MessagesValue 为什么不能用裸 concat 凑合,再把 addNode / addEdge / addSequence 这套 Graph API 摸熟。

老规矩,本文 API 均以官网最新文档核对过(Graph API overviewUse the graph API)。网上不少教程仍在教 Annotation.Root / MessagesAnnotation——那是 Legacy 写法,本篇主路径跟官网走 StateSchema

在这里插入图片描述

一、StateSchema:每个 key 是一条 channel

构图第一件事:定义 State。官网现在的主推是 StateSchema——每个字段对应一条 channel(状态通道),各自有独立的「收到更新后怎么合」规则。

import {
  StateSchema,
  MessagesValue,
  ReducedValue,
  UntrackedValue,
} from "@langchain/langgraph";
import * as z from "zod";

const State = new StateSchema({
  // 对话历史:内置消息感知 reducer
  messages: MessagesValue,

  // 简单字段:Zod schema = last-value(新值直接覆盖)
  status: z.string(),
  userId: z.string(),

  // 带默认值的 last-value
  retryCount: z.number().default(0),

  // 需要自定义合并逻辑:ReducedValue
  llmCalls: new ReducedValue(z.number().default(0), {
    reducer: (current, update) => current + update,
  }),

  // 临时缓存:默认不进 checkpoint(别拿它存对话!)
  tempCache: new UntrackedValue(z.record(z.string(), z.unknown())),
});

type FullState = typeof State.State;   // 完整状态
type StateUpdate = typeof State.Update; // 节点可返回的 Partial

四种字段速查:

字段类型 更新行为 典型用途
Zod(如 z.string() Last-value:新值覆盖旧值 userIdstatus、中间结果
ReducedValue 自定义 (current, update) => next 计数累加、列表追加
MessagesValue 内置消息 reducer(append / 按 ID 替换 / 删除) 几乎所有聊天 Agent
UntrackedValue 临时值,默认不持久化进 checkpoint 一次性缓存;别存对话历史

TypeScript 里节点参数用 typeof State.State,或直接上官方的 GraphNode<typeof State>,少踩类型坑。

二、Reducer:更新怎么合进黑板

节点不会(也不该)亲手改全局对象。它只返回一份 Partial Update;框架拿着这份更新,去找对应 channel 的 reducer,算出新值写回。

自定义 reducer 的心智模型:

reducer(current, update) => next
         ↑         ↑
    黑板上已有值   节点这次交回来的值

最典型的累加:

llmCalls: new ReducedValue(z.number().default(0), {
  reducer: (current, update) => current + update,
}),

// 节点返回 { llmCalls: 1 } → 0 + 1 = 1
// 再返回 { llmCalls: 1 } → 1 + 1 = 2

铁律再强调一遍:Node 只返回要改的字段。返回整份 State,碰上带 reducer 的 channel,等于把「更新值」理解成「全量替换输入」,轻则重复消息,重则逻辑全歪。

没配 reducer 的 Zod 字段呢?默认就是 覆盖:你返回 { status: "done" },旧的 "running" 直接被拍掉——这对 userId / status 是对的,对 messages 则是灾难。所以对话列表绝不能用裸 z.array(...) 糊弄过去。

三、MessagesValue:对话列表的正确姿势

聊天 Agent 里,messages 几乎永远是 State 的主角。官网给了现成的 MessagesValue,内部就是消息感知 reducer,帮你搞定:

  • 新消息:追加到列表末尾
  • 同 ID 消息:替换已有那条(HITL 改稿、流式落盘很常用)
  • RemoveMessage:按 ID 删掉指定消息
  • 短格式输入:也可以丢 { role: "user", content: "hi" },框架会尽量归一成 Message 对象

节点写法极其克制——只返回新增的那几条

import { GraphNode, StateSchema, MessagesValue } from "@langchain/langgraph";
import { AIMessage } from "@langchain/core/messages";

const State = new StateSchema({
  messages: MessagesValue,
});

const agentNode: GraphNode<typeof State> = async (state) => {
  // ... 调模型拿到 response
  return { messages: [response] }; // 只交新增;MessagesValue 负责 append
  // return { messages: [...state.messages, response] }; // 别这么干,容易重复
};

为啥别自己写个 concat 凑合?

老教程里常见这种手写 reducer:

reducer: (left, right) => left.concat(Array.isArray(right) ? right : [right])

追加是能跑。但人机协作、流式、按 ID 修正已有 AIMessage 时,裸 concat 不会替换同 ID,只会越积越多。MessagesValue 就是为这些场景准备的,新项目直接用它,别发明轮子。

旧世界对照(看懂就行,别当主路径)

你在旧博客 / 旧笔记里可能看到:

Legacy 现在等价
Annotation.Root({ messages: Annotation({ reducer: messagesStateReducer, default: () => [] }) }) messages: MessagesValue
MessagesAnnotation / ...MessagesAnnotation.spec new StateSchema({ messages: MessagesValue, ...其它字段 })
手写 Annotation + 自定义 concat 优先 MessagesValue;非消息字段用 ReducedValue

官网对照表里 Annotation.Root 明确标成 Legacy。还能用,但新代码请写 StateSchema

四、StateGraph API 速查

State 定义好了,就轮到拼图。

addNode:挂上干活的函数

.addNode("chat", chatNode)
  • 名字必须图内唯一,后面 addEdge 全靠这个字符串——typo 会在 compile 或运行时报复你
  • 函数签名:(state) => PartialUpdate,支持 async
  • 推荐类型:const chatNode: GraphNode<typeof State> = async (state) => { ... }

addEdge:固定下一步

.addEdge(START, "chat")   // 入口;也可用 "__start__"
.addEdge("chat", END)     // 出口;也可用 "__end__"
.addEdge("a", "b")        // a 完一定去 b

线性图:从 START 出发,最终都要能走到 END

addSequence:线性多节点偷懒写法

一连串「A→B→C」可以一次加完。注意官方签名是 [名称, 函数] 元组数组,不是旧笔记里「名字数组 + 函数数组」那种双参数写法:

const graph = new StateGraph(State)
  .addSequence([
    ["step1", step1Node],
    ["step2", step2Node],
    ["step3", step3Node],
  ])
  .addEdge(START, "step1")
  .addEdge("step3", END)
  .compile();

等价于分别 addNode 三次,再加上 step1→step2→step3 的边。分支、回环还是老老实实 addEdge / 条件边(下一篇)。

五、完整示例:MessagesValue + ReducedValue

一个最小「聊天节点」:对话走 MessagesValue 追加,顺手用 ReducedValue 统计调了几次模型——跟官网 Quickstart 同款结构,模型换成我们系列的 ChatOllama

import {
  StateGraph,
  StateSchema,
  MessagesValue,
  ReducedValue,
  GraphNode,
  START,
  END,
} from "@langchain/langgraph";
import { ChatOllama } from "@langchain/ollama";
import { HumanMessage } from "@langchain/core/messages";
import * as z from "zod";

const State = new StateSchema({
  messages: MessagesValue,
  llmCalls: new ReducedValue(z.number().default(0), {
    reducer: (current, update) => current + update,
  }),
});

const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });

const chatNode: GraphNode<typeof State> = async (state) => {
  const response = await llm.invoke(state.messages);
  return {
    messages: [response], // MessagesValue → append
    llmCalls: 1,          // ReducedValue → 累加 1
  };
};

const graph = new StateGraph(State)
  .addNode("chat", chatNode)
  .addEdge(START, "chat")
  .addEdge("chat", END)
  .compile();

const result = await graph.invoke({
  messages: [new HumanMessage("什么是 reducer?用一句话解释。")],
});

console.log(result.llmCalls); // 1
console.log(result.messages.length); // 2:Human + AI

跑通之后你可以改成两节点线性流(比如 chat → format),用 addSequence 串起来练手——先把「只返回增量」练成肌肉记忆,再去碰条件边。

常见坑

  1. Node 返回全量 messages:应只返回 [newMessage][...state.messages, newMessage] 在 MessagesValue 下等于再 append 一整份历史,消息会爆炸式重复。
  2. 标识字段误用累加 reduceruserIdstatus 用 Zod last-value 即可。给字符串加 concat 式 reducer,你会得到奇奇怪怪的拼接结果。
  3. ReducedValue 忘记 default:首次更新时 current 可能是 undefined(a, b) => a + b 直接 NaN 或报错。数字累加记得 z.number().default(0)
  4. Annotation.Root / MessagesAnnotation 旧教程带偏:能跑,但是 Legacy;新代码统一 StateSchema + MessagesValue
  5. addSequence 抄错签名:正确是 .addSequence([["a", fnA], ["b", fnB]]),不是 .addSequence(["a","b"], [fnA, fnB])
  6. 节点名和边字符串不一致"chat" 建成了,边却写成 "Chat"——compile / 运行阶段找打灯笼。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐