别把 Agent 写成一团 Prompt:用 LangGraph 把多 Agent 系统变成可控状态机

核心结论:LangGraph 的价值不只是"能画图",而是把 Agent 从一段不可控的长 Prompt,拆成可观察、可恢复、可路由、可中断的状态机。多 Agent 架构真正解决的也不是"角色更多",而是把复杂任务里的上下文、工具权限、执行路径和人工确认边界拆清楚。

很多人第一次写 Agent,会自然写成一个"大而全"的助手:一个 system prompt 里塞进所有规则,绑定所有工具,然后把用户问题丢进去让模型自己判断。小 demo 能跑,但一到真实业务就会出现几个问题:

  • 工具越来越多,模型容易选错工具;
  • Prompt 越来越长,token 成本和干扰都上升;
  • 任务需要重试、分支、人工确认时,只靠一次 LLM 调用很难控制;
  • 出错后不知道卡在哪一步,也不好恢复;
  • 多个专业任务混在一个 Agent 里,职责边界模糊。

LangGraph 适合解决的正是这类问题:它不是替代 LangChain 组件,而是在组件之上提供一层图编排引擎。节点负责执行动作,边负责控制流,状态负责在节点之间传递上下文。

从链式调用到图式编排

如果只是"输入 -> Prompt -> 模型 -> 输出",LCEL 或普通 LangChain Chain 已经够用。但 Agent 系统经常不是线性的。

比如库存助手可能需要:

  1. 判断用户是不是在查库存;
  2. 如果是,调用库存工具;
  3. 工具返回 JSON;
  4. 把 JSON 转成人能看的回答;
  5. 如果工具结果异常,进入兜底;
  6. 如果涉及高风险操作,中断等待人工确认;
  7. 如果一次任务包含多个子任务,交给不同 Agent。

这已经不是一条链,而是一张有分支、有循环、有状态、有恢复点的图。

flowchart TD Start([START]) --> Agent[Agent 判断意图] Agent -->|需要工具| Tool[ToolNode 执行工具] Agent -->|不需要工具| End([END]) Tool --> Summary[格式化工具结果] Summary --> End

LangGraph 的基本抽象很简单:

  • State:整张图共享的状态;
  • Node:读取 state,返回部分 state 更新;
  • Edge:决定下一个节点;
  • Checkpointer:保存状态,支持恢复;
  • Interrupt:在图中暂停,等待外部输入;
  • Prebuilt:封装常见 Agent loop、工具节点、多 Agent supervisor。

最小图:状态比函数返回值更重要

当前 demo 里的 basic-graph.mjs 展示了 LangGraph 最小结构:

js 复制代码
import { Annotation, END, START, StateGraph } from "@langchain/langgraph"

const StateAnnotation = Annotation.Root({
  text: Annotation({
    reducer: (_prev, next) => next,
    default: () => "",
  }),
})

const step1 = state => ({ text: `${state.text} -> step1` })
const step2 = state => ({ text: `${state.text} -> step2` })

const graph = new StateGraph(StateAnnotation)
  .addNode("step1", step1)
  .addNode("step2", step2)
  .addEdge(START, "step1")
  .addEdge("step1", "step2")
  .addEdge("step2", END)
  .compile()

const result = await graph.invoke({ text: "hello" })

这段代码真正值得关注的不是 step1step2,而是 Annotation.Root

Annotation 定义的是图的状态协议。每个字段都可以指定:

  • default:没有输入时的默认值;
  • reducer:多个节点更新同一字段时怎么合并。

这里的 reducer 是 (_prev, next) => next,含义是"后一次更新覆盖前一次"。这适合 routeanswermessage 这类单值字段。

但如果是消息流,通常不能覆盖,而要追加。LangGraph 内置的 MessagesAnnotation 就是为消息数组准备的状态结构。

工程上要记住一句话:**LangGraph 里的节点不是互相直接调用,而是通过 state 间接通信。**这也是它能恢复、中断、观察执行过程的基础。

条件路由:把"模型自己想"变成显式控制流

conditional-routing.mjs 演示了分支:

js 复制代码
const router = state => {
  const isMath = /[+\-*/]/.test(state.query)
  return { route: isMath ? "math" : "chat" }
}

const graph = new StateGraph(StateAnnotation)
  .addNode("router", router)
  .addNode("math", mathNode)
  .addNode("chat", chatNode)
  .addEdge(START, "router")
  .addConditionalEdges("router", state => state.route, {
    math: "math",
    chat: "chat",
  })
  .addEdge("math", END)
  .addEdge("chat", END)
  .compile()

这里的关键 API 是 addConditionalEdges。它把"下一步去哪"从节点逻辑里拆出来,变成图结构的一部分。

这在 Agent 工程里很重要。很多系统的问题不是模型不会回答,而是你把所有控制权都交给了模型。比如:

  • 是否需要工具调用;
  • 工具调用失败是否重试;
  • 是否需要人工确认;
  • 是否交给另一个 Agent;
  • 是否结束任务。

这些决策不一定都应该由 LLM 自由发挥。能用规则明确表达的,就应该落到图的边上;必须依赖语义理解的,再交给模型。

顺带一提,demo 里的 mathNode 使用了 eval,这只适合教学演示。真实系统里不能把用户输入直接交给 eval,应该换成受限表达式解析器或后端计算服务。

循环和重试:本质还是条件边

loop-retry.mjs 里的重试图很典型:

js 复制代码
const attempt = state => {
  const tries = state.tries + 1
  const ok = tries >= 3

  return {
    tries,
    ok,
    message: ok ? `第 ${tries} 次成功` : `第 ${tries} 次失败,继续重试`,
  }
}

const graph = new StateGraph(StateAnnotation)
  .addNode("attempt", attempt)
  .addEdge(START, "attempt")
  .addConditionalEdges("attempt", state => (state.ok ? "done" : "retry"), {
    retry: "attempt",
    done: END,
  })
  .compile()

循环不是特殊能力,而是条件边指回之前的节点。

flowchart TD Start([START]) --> Attempt[attempt] Attempt -->|retry| Attempt Attempt -->|done| End([END])

真实业务里,重试通常要更克制:

  • 设置最大重试次数;
  • 区分可重试错误和不可重试错误;
  • 对外部 API 加退避策略;
  • 把失败原因写入 state;
  • 避免让 LLM 无限修复自己的输出。

LangGraph 提供的是控制流能力,不会替你决定重试策略。策略仍然是工程设计的一部分。

Checkpointer:Agent 记忆不只是聊天历史

checkpointer-memory.mjsMemorySaver 保存会话状态:

js 复制代码
const checkpointer = new MemorySaver()
const app = graph.compile({ checkpointer })

const user1 = { configurable: { thread_id: "用户-小张" } }
const user2 = { configurable: { thread_id: "用户-小李" } }

await app.invoke({}, user1)
await app.invoke({}, user1)
await app.invoke({}, user2)

这里最关键的是 thread_id。同一张图在不同 thread 下有不同状态。源码里的 recordVisit 每执行一次就把 visitCount + 1,所以同一个用户会延续上次状态,不同用户互不影响。

这和"把聊天记录塞回 prompt"不是一回事。Checkpointer 保存的是图执行状态,可以包括:

  • 当前执行到哪一步;
  • 消息历史;
  • 工具调用结果;
  • 重试次数;
  • 人工确认前的上下文;
  • 多 Agent 的中间交接信息。

demo 用的是内存存储,适合本地演示。生产环境通常要换成 Redis、SQLite、Postgres 或其他持久化方案。否则进程重启后状态就没了。

Interrupt:高风险动作必须能停下来

graph-interrupt.mjs 模拟了一个转账确认流程:

js 复制代码
const waitConfirm = state => {
  const text = interrupt({
    hint: "终端里输入「确认」或备注后回车,图才会继续",
    actionSummary: state.actionSummary,
  })

  return { userInput: String(text) }
}

const graph = new StateGraph(StateAnnotation)
  .addNode("showTransfer", showTransfer)
  .addNode("waitConfirm", waitConfirm)
  .addEdge(START, "showTransfer")
  .addEdge("showTransfer", "waitConfirm")
  .addEdge("waitConfirm", END)
  .compile({ checkpointer: new MemorySaver() })

第一次执行时,图会停在 interrupt

js 复制代码
const paused = await graph.invoke({}, config)
console.log(paused.__interrupt__?.[0]?.value)

用户输入后,再用 Command({ resume }) 恢复:

js 复制代码
const done = await graph.invoke(new Command({ resume: line }), config)

这类能力在真实 Agent 产品里非常关键。凡是涉及外部副作用的动作,比如转账、下单、发邮件、删数据、提交工单,都不应该只靠模型自己决定。

比较合理的边界是:

  • 查询类操作可以自动执行;
  • 写入类操作需要权限控制;
  • 高风险写入必须 interrupt;
  • interrupt 前要把动作摘要写清楚;
  • resume 后也要记录确认人、确认内容和时间。

ToolNode:工具调用不是结束,业务表达才是结束

当前源码里的 prebuilt-tool-node.mjs 比普通 demo 多了一步 summarize,这是一个很好的工程处理。

工具定义如下:

js 复制代码
const getProductStock = tool(
  async ({ sku }) => getProductBySku(sku),
  {
    name: "get_product_stock",
    description: "按 SKU 查商品名与库存,SKU 如 SKU-001。",
    schema: z.object({
      sku: z.string().describe("商品 SKU"),
    }),
  }
)

模型节点负责判断是否调用工具:

js 复制代码
async function agent(state) {
  const response = await llm.invoke([
    new SystemMessage(
      "你是库存查询助手。遇到 SKU 库存问题时必须先调用 get_product_stock,且只调用一次。不要编造库存。"
    ),
    ...state.messages,
  ])

  return { messages: [response] }
}

然后用 LangGraph 内置的 ToolNode 执行工具,用 toolsCondition 判断是否进入工具节点:

js 复制代码
const toolNode = new ToolNode(tools)

const graph = new StateGraph(MessagesAnnotation)
  .addNode("agent", agent)
  .addNode("tools", toolNode)
  .addNode("summarize", summarizeToolResult)
  .addEdge(START, "agent")
  .addConditionalEdges("agent", toolsCondition, ["tools", END])
  .addEdge("tools", "summarize")
  .addEdge("summarize", END)
  .compile()

重点在 summarizeToolResult

js 复制代码
function summarizeToolResult(state) {
  const lastMessage = state.messages.at(-1)
  const rawContent = String(lastMessage?.content ?? "")

  try {
    const data = JSON.parse(rawContent)

    const content = data.found
      ? `商品 ${data.name}(${data.sku})当前库存还有 ${data.stock} 件。`
      : `没有查到 SKU ${data.sku} 对应的商品库存。`

    return { messages: [new AIMessage(content)] }
  } catch {
    return {
      messages: [new AIMessage(`工具返回了无法解析的结果:${rawContent}`)],
    }
  }
}

为什么不让 LLM 拿到工具结果后自己总结?因为库存查询这种场景不需要模型自由发挥。工具已经返回结构化数据,最终回答可以由确定性代码生成。这样可以减少幻觉,也能保证格式稳定。

这是一个很实用的判断:**LLM 适合做语义判断,不一定适合做所有结果格式化。**能确定性处理的部分,尽量用代码处理。

createAgent:常用 Agent Loop 的封装

prebuilt-agent.mjs 使用了更高层的 createAgent

js 复制代码
const agent = createAgent({
  model,
  tools: [getProductStock],
  systemPrompt:
    "你是仓库助手。问库存时必须调用 get_product_stock(模拟数据),禁止编造。",
  checkpointer: new MemorySaver(),
})

const result = await agent.invoke(
  { messages: [new HumanMessage("SKU-002 还剩多少库存?")] },
  { configurable: { thread_id: "demo-thread" } }
)

createAgent 适合常规工具型 Agent:模型判断工具、调用工具、再继续生成回答。它内部仍然是图,只是帮你封装了常见节点和边。

默认建议是:

  • 简单工具助手:优先用 createAgent
  • 需要插入确定性业务节点:自己写 StateGraph
  • 需要人工确认、复杂路由、重试恢复:自己写图;
  • 多个专业 Agent 协同:用 supervisor 模式。

不要为了"显得架构复杂"而手写所有图。工程里默认应该选择足够简单但可扩展的方案。

多 Agent:不是越多越好,而是职责要窄

multi-agent-supervisor.mjs 是一个 supervisor-worker 模式:

  • weather_agent 只查天气;
  • trivia_agent 只查城市小知识;
  • supervisor 只负责任务分派和最终汇总。
flowchart TD User[用户问题] --> Supervisor[Supervisor 调度员] Supervisor -->|天气问题| WeatherAgent[weather_agent] Supervisor -->|城市小知识| TriviaAgent[trivia_agent] WeatherAgent --> WeatherTool[lookup_weather] TriviaAgent --> TriviaTool[lookup_city_trivia] WeatherTool --> Supervisor TriviaTool --> Supervisor Supervisor --> Final[最终回答]

子 Agent 的定义很清晰:

js 复制代码
const weatherAgent = createAgent({
  name: "weather_agent",
  description: "专门查天气",
  model,
  tools: [lookupWeatherTool],
  systemPrompt:
    "你只处理天气。用户提到城市时,必须调用 lookup_weather,且只调用一次。拿到工具结果后,用中文简短说明天气、温度和空气质量,然后停止。",
})

另一个 Agent 只处理城市小知识:

js 复制代码
const triviaAgent = createAgent({
  name: "trivia_agent",
  description: "专门讲与城市相关的小知识;必须调用 lookup_city_trivia。",
  model,
  tools: [lookupCityTriviaTool],
  systemPrompt:
    "你只讲城市小知识。必须先调用 lookup_city_trivia,且只调用一次。拿到工具结果后,用中文一句话转述,不要编造工具里没有的内容,然后停止。",
})

Supervisor 的提示词也做了边界约束:

js 复制代码
const workflow = createSupervisor({
  agents: [weatherAgent.graph, triviaAgent.graph],
  llm: model,
  outputMode: "last_message",
  includeAgentName: "inline",
  prompt: `你是调度员,只负责分派子任务和在最后汇总答案,不要自己编造天气或城市小知识。

- 问天气、气温、下不下雨、空气质量 → 用 weather_agent
- 问小知识、名胜、历史、一句介绍 → 用 trivia_agent

执行规则:
1. 一次只交给一个 agent。
2. 如果用户同时要求天气和城市小知识,先调用 weather_agent,再调用 trivia_agent。
3. 某个子任务已经得到结果后,不要再次把同一子任务交给同一个 agent。
4. 当所有要求的信息都已拿到后,直接输出最终中文答案并结束,不要继续 handoff。
`,
})

这里有两个配置值得注意:

  • outputMode: "last_message":最终状态里更关注最后回答,避免输出过多中间消息;
  • includeAgentName: "inline":把 Agent 名称放进消息里,便于 supervisor 理解是谁返回的结果。

多 Agent 的收益来自职责隔离,而不是 Agent 数量。拆分的标准应该是:

  • 工具集是否明显不同;
  • Prompt 规则是否互相干扰;
  • 子任务是否可以独立完成;
  • 是否需要不同权限;
  • 是否需要不同模型或成本策略;
  • 是否需要并行或分阶段处理。

如果只是两个很相似的问答能力,强行拆成两个 Agent 反而会增加调度成本和失败点。

stream:观察执行路径,而不是盲猜 Agent 在干什么

源码里通过 streamMode: ["updates", "values"] 同时观察增量和全量状态:

js 复制代码
const nodePath = []
let finalState = null

const stream = await app.stream(input, { streamMode: ["updates", "values"] })

for await (const event of stream) {
  const [mode, payload] = event

  if (mode === "updates" && payload && typeof payload === "object") {
    nodePath.push(...Object.keys(payload))
  } else if (mode === "values") {
    finalState = payload
  }
}

console.log("路径:", nodePath.join(" → "))
const last = finalState?.messages?.at(-1)
console.log(last?.content ?? finalState?.messages)

updates 更适合看"哪个节点刚刚更新了状态",values 更适合拿最终完整状态。

调试 Agent 系统时,不要只看最终回答。至少要看三件事:

  • 实际走过哪些节点;
  • 调用了哪些工具;
  • 每个 Agent 是否越权处理了不属于自己的任务。

这也是图编排相比黑盒 Agent 的优势:你可以把执行路径暴露出来,用日志、断点、可视化图和状态快照定位问题。

方案对比:什么时候用哪种编排方式

方案 适合场景 优点 代价
普通函数调用 固定流程、无 LLM 决策 简单、稳定、成本低 不适合语义路由
LCEL / Chain 线性 LLM 流程 表达简洁,适合管道 分支、循环、恢复较弱
createAgent 常规工具调用助手 少写样板代码 复杂控制流不够透明
手写 StateGraph 有分支、循环、中断、恢复 控制力强,可观察 需要设计状态结构
Supervisor 多 Agent 多职责、多工具、多阶段任务 上下文隔离,扩展性好 调度成本更高,链路更复杂

我的默认建议是:先用最小可控方案,不要一上来就多 Agent。

  • 一个工具型问答助手,用 createAgent
  • 需要确定性节点或人工确认,改成 StateGraph
  • 当 Prompt 和工具集开始互相干扰,再拆多 Agent;
  • 当子任务需要并行、独立权限或专业角色,再引入 supervisor。

常见误区

误区一:多 Agent 一定比单 Agent 强。

不一定。多 Agent 会引入调度、通信、状态膨胀和更多 LLM 调用。只有当职责隔离带来的收益大于这些成本时,才值得拆。

误区二:所有路由都交给 LLM。

能用规则判断的路由,优先用代码。比如重试次数、工具返回状态、权限级别,这些不需要模型决定。

误区三:工具返回结果必须再让 LLM 总结。

库存、价格、订单状态这类结构化结果,很多时候用代码格式化更稳。

误区四:MemorySaver 就是生产级记忆。

MemorySaver 只是内存存储。生产环境要考虑持久化、过期策略、并发、状态迁移和隐私清理。

误区五:interrupt 只是交互效果。

interrupt 本质是风险控制边界。它应该出现在高风险副作用之前,而不是事后补救。

工程落地建议

设计 LangGraph 应用时,可以按这个顺序思考:

  1. 先定义状态,而不是先写节点。状态字段决定系统能否恢复和调试。
  2. 把确定性逻辑写成普通节点,把语义判断留给 LLM。
  3. 给每个工具写清楚 schema 和 description,减少模型误调用。
  4. 工具输出尽量结构化,最好是 JSON。
  5. 对高风险动作加 interrupt,并保留 action summary。
  6. 对循环加最大次数,避免无限 Agent loop。
  7. 对多 Agent 明确职责,不让 worker 处理 supervisor 的事情。
  8. 用 stream 或日志记录节点路径,别只保存最终回答。
  9. 本地 demo 可以用 MemorySaver,生产要换持久化 checkpointer。
  10. 能用单 Agent 解决时,不要急着拆多 Agent。

总结

LangGraph 最值得学的不是 API 名字,而是一种 Agent 工程思维:把不可控的长上下文推理,拆成可控的状态流转。

在这个 demo 里,基础图说明了 state 如何在节点间传递;条件边说明了路由和循环怎么表达;MemorySaver 说明了状态如何跨调用保存;interrupt 说明了高风险流程如何暂停恢复;ToolNode 和 createAgent 说明了常见工具型 Agent loop 怎么封装;supervisor 则展示了多 Agent 如何按职责协作。

真正的工程判断在于:什么时候让模型判断,什么时候用代码控制;什么时候单 Agent 足够,什么时候必须拆多 Agent;什么时候追求灵活,什么时候必须稳定。

LangGraph 的定位可以概括成一句话:它不是让 Agent 更"神秘",而是让 Agent 更像一个可以调试、可以恢复、可以治理的工程系统。

相关推荐
平凡但不平庸的码农1 小时前
Go Channel详解
开发语言·后端·golang
子安柠1 小时前
深入理解 Go 语言文件操作:从基础到最佳实践
开发语言·后端·golang
Achou.Wang1 小时前
go语言中使用等待组(waitgroups)和内存屏障(barriers)进行同步
开发语言·后端·golang
CoderJia程序员甲1 小时前
GitHub 热榜项目 - 周榜(2026-05-10)
人工智能·ai·大模型·llm·github
feasibility.1 小时前
多模态模型Qwen-3.5在Llama-Factory使用+llama.cpp量化导出+部署流程(含报错处理)
人工智能·llm·多模态·量化·llama.cpp·vlm·llama-factory
专职2 小时前
02-Langchain大模型提示词工程应用实践
langchain
Kiyra2 小时前
Agent 的记忆不是存数据库就行:上下文预算与轻量记忆的设计实战
数据库·人工智能·后端·面试·职场和发展·哈希算法
一个处女座的程序猿2 小时前
MultiAgent之OpenClaw:QuantClaw的简介、安装和使用方法、案例应用之详细攻略
llm·openclaw·quantclaw
ConardLi2 小时前
Harness 实践:让 Agent 全自动制作知识讲解视频
前端·人工智能·后端