Skip to content

实战

要点

  • 前面这一章拆开的能力,可以重新放回一条完整业务管线里
  • 内容创作智能体适合按状态管线拆分,而不是堆在一个 Agent 入口里
  • 长期记忆适合在流程开始时读取,在流程结束时按条件写回
  • 当前意图可以决定进入选题、起草、审稿、SEO 等不同角色或子图
  • 完整管线要同时处理 state、checkpointer、store、context 和容错路径
  • 容错路径需要和主流程一起设计

内容

1. 到了这里,问题已经不是某一个节点怎么写

前面这一整章,其实一直在把 LangGraph 的几块能力拆开讲。

先讲状态、节点、边,再讲 reducer、条件路由、checkpointer、interrupt()、子图、多 Agent、Store 和容错。单看每一篇,它们都不难理解。但只要要做一个能长期演进的内容创作智能体,问题很快就不再是「某个节点怎么写」。

更核心的问题会变成:

这套系统到底该怎么拆。

如果还用前面最早的那种做法,把提示词、工具、记忆、外部调用、人工介入全堆在一个 Agent 入口里,功能当然能继续往上加,但代码会越来越难收。记忆读写混在一起,工具调用和回复生成混在一起,异常处理也全塞在一层里。写到后面,很多问题不是不能修,而是改一个地方总会牵一大片。

所以这一篇不再单独讲某个 API,而是把前面这一章已经讲过的东西,重新放回一条完整的内容创作管线里,看它们各自适合放在哪一层。

对照官方整体能力介绍,LangGraph 的价值经常体现在几件事一起出现时:状态持续流动、执行可以持久化、节点可以流式输出、流程可以暂停等人、长期记忆可以跨线程复用。单独看每个 API 都不复杂,但把它们放进一条业务管线,才会出现清晰的分层需求。

内容创作智能体可以按下面这条主线组织:

负责什么对应能力
当前流程草稿、意图、审核状态、错误信息StateSchema
同一线程续写多轮修改、暂停恢复、历史回溯Checkpointer
跨线程偏好语气、读者、发布渠道Store
单次运行背景userIdteamIdlocaleRuntime context
失败路径重试、降级、人工介入条件边 / Command / interrupt

这张表也是后面拆代码的依据。每类数据先找到自己的位置,节点和边才不容易越写越乱。

2. 先看没有重构之前,系统会卡在哪

先把问题讲具体一点。

一个最常见的内容创作入口,早期通常会长这样:

接收用户消息,拼系统提示词,把历史消息塞进去,需要时查记忆,需要时检索资料,最后再生成草稿或修改建议。

刚开始这么写没有问题。可一旦需求往前走,下面这些东西就会开始互相缠住:

用户长期偏好要跨会话保留,当前这轮对话的状态还得继续接上,某些问题要交给不同角色处理,有些节点失败以后不能直接整条断掉,某些场景下还要暂停等人工确认。

这时候继续把所有逻辑挂在一个入口上,代码不会立刻坏掉,但结构已经开始吃力了。

3. 重构以后,这条管线更像一张图

把内容创作智能体换成 LangGraph 来看,思路会不一样。

你不再把它当成「一次模型调用」,而是把它当成一条会持续流动的状态管线。用户消息进来以后,不是直接冲向一个万能 Agent,而是先经过一串明确的阶段:

  • 读取长期记忆
  • 判断当前意图
  • 进入合适的角色或子图
  • 需要时调工具、检索或进入审稿
  • 生成草稿、修改建议或发布版本
  • 写回该留下的长期信息
  • 如果中途出问题,再按失败路径处理

这样拆开以后,很多原来缠在一起的问题会落到不同层里。

4. 先把状态边界划出来

这条管线里,最先要划清楚的不是节点,而是状态。

一个内容创作智能体里,最容易混在一起的通常有三类东西:

当前线程里的即时对话状态,跨线程的长期资料,以及某一轮工作流临时产生的中间结果。

放到 LangGraph 里,我们可以先把线程里的状态收进一份 StateSchema

typescript
// content-agent-state.ts
import { StateSchema, MessagesValue, ReducedValue } from '@langchain/langgraph'

import { z } from 'zod'

const appendToolNotes = (current: string[], update: string[]) => {
  return [...current, ...update]
}

const State = new StateSchema({
  messages: MessagesValue,

  intent: z.string().default('draft'),

  activeRole: z.string().default('content-router'),

  retrievedMemories: new ReducedValue(
    z.array(z.string()).default([]),

    { reducer: appendToolNotes },
  ),

  toolNotes: new ReducedValue(
    z.array(z.string()).default([]),

    { reducer: appendToolNotes },
  ),

  draftArticle: z.string().default(''),

  lastError: z.string().default(''),

  status: z.string().default('idle'),
})

这一层先只做一件事:把线程里会随着流程推进而变化的东西放好。

而长期资料,比如用户偏好的文章语气、目标读者、发布渠道和常用结构,不直接塞进这份状态里。它们更适合放在 Store 里,按用户去取。

5. 第一段流程,通常先读长期记忆

内容创作智能体在写作前,通常需要先把这个用户该带进来的长期资料读出来。

typescript
// load-long-term-memory.ts
import type { GraphNode } from '@langchain/langgraph'

const loadLongTermMemory: GraphNode<typeof State> = async (state, runtime) => {
  const userId = runtime.context?.userId

  const namespace = ['users', userId ?? 'anonymous', 'profile']

  const preferenceItem = await runtime.store?.get(namespace, 'preferences')

  const tone = preferenceItem?.value?.tone ?? 'clear'

  const language = preferenceItem?.value?.language ?? 'zh-CN'

  const audience = preferenceItem?.value?.audience ?? 'junior-developers'

  return {
    toolNotes: [
      `用户长期偏好:language=${language}`,

      `用户长期偏好:tone=${tone}`,

      `用户长期偏好:audience=${audience}`,
    ],

    status: 'memory-loaded',
  }
}

这里的价值不只是「多读了一次数据」,而是让后续节点都能基于用户背景继续处理。

这一步如果还像以前一样夹在提示词拼接里,就会越来越隐蔽。单独拆成节点以后,读长期记忆这件事就清楚了。

6. 第二段流程,判断当前这轮到底该找谁

内容创作智能体并不是每一轮都直接写正文。有时是做选题,有时是列大纲,有时是起草,有时是审稿、改标题或补资料。

所以第二步通常不会直接回复,而是先判断当前请求属于哪类问题,再决定让谁接手。

typescript
// route-intent.ts
import type { GraphNode } from '@langchain/langgraph'

import { Command } from '@langchain/langgraph'

const routeIntent: GraphNode<typeof State> = (state) => {
  const lastMessage = state.messages.at(-1)?.text ?? ''

  if (lastMessage.includes('选题') || lastMessage.includes('大纲')) {
    return new Command({
      update: {
        intent: 'outline',

        activeRole: 'outline-agent',
      },

      goto: 'outlineSubgraph',
    })
  }

  if (lastMessage.includes('审稿') || lastMessage.includes('优化')) {
    return new Command({
      update: {
        intent: 'review',

        activeRole: 'review-agent',
      },

      goto: 'reviewSubgraph',
    })
  }

  return new Command({
    update: {
      intent: 'draft',

      activeRole: 'draft-agent',
    },

    goto: 'draftSubgraph',
  })
}

这一层其实就是把前面讲过的路由、Command 和多角色拆分重新放回真实业务里。

7. 第三段流程,角色内部各走各的子图

到了这里,前面的子图和多 Agent 几篇开始进入同一条业务管线。

列大纲不需要走审稿那套逻辑,审稿也不需要重新进入起草流程。所以在重构后的系统里,更自然的做法通常是让每类能力有自己的一块子图。

typescript
// role-subgraphs.ts
const graph = new StateGraph(State)

  .addNode('loadLongTermMemory', loadLongTermMemory)

  .addNode('routeIntent', routeIntent)

  .addNode('outlineSubgraph', outlineSubgraph)

  .addNode('draftSubgraph', draftSubgraph)

  .addNode('reviewSubgraph', reviewSubgraph)

  .addNode('persistLongTermMemory', persistLongTermMemory)

  .addNode('fallbackReply', fallbackReply)

  .addEdge(START, 'loadLongTermMemory')

  .addEdge('loadLongTermMemory', 'routeIntent')

  .addEdge('outlineSubgraph', 'persistLongTermMemory')

  .addEdge('draftSubgraph', 'persistLongTermMemory')

  .addEdge('reviewSubgraph', 'persistLongTermMemory')

  .addEdge('persistLongTermMemory', END)

  .compile({
    checkpointer,

    store,

    contextSchema: ContextSchema,
  })

这里的结构变化,是系统不再只有一个中央处理函数。

不同意图进入不同子图,子图里再各自挂工具、状态和控制流,这样功能加深以后,结构也不容易乱。

这里的 outlineSubgraphdraftSubgraphreviewSubgraph 可以直接接前面子图那篇的写法。重点不在子图内部细节,而在于总管线只负责分派和汇总,不再把所有业务步骤都写在一层。

8. 第四段流程,最后再把该留下的东西写回去

内容创作智能体和普通问答系统最大的区别之一,就是有些东西值得被长期记住。

比如用户明确表达了新的偏好,或者某个长期资料需要更新。更稳定的做法,是在流程快结束时留一个专门节点处理写回,而不是边回复边顺手写。

typescript
// persist-long-term-memory.ts
import type { GraphNode } from '@langchain/langgraph'

const persistLongTermMemory: GraphNode<typeof State> = async (
  state,
  runtime,
) => {
  const userId = runtime.context?.userId

  const namespace = ['users', userId ?? 'anonymous', 'profile']

  // 走到这里时,最后一条消息有可能已经是助手回复,

  // 所以这里要回头找最近一条用户消息,再决定要不要写回长期记忆

  const lastUserMessage =
    [...state.messages]

      .reverse()

      .find((message) => message.getType() === 'human')?.text ?? ''

  // 这里只演示一种最小判断:用户明确提到读者偏好,就写回 Store

  if (lastUserMessage.includes('以后都写给初中级开发者')) {
    await runtime.store?.put(namespace, 'preferences', {
      language: 'zh-CN',

      tone: 'clear',

      audience: 'junior-developers',
    })
  }

  return {
    status: 'completed',
  }
}

把这一步单独留出来以后,长期记忆的读写边界会更清楚:

开头读进来,中间拿来参与决策,结尾根据需要再写回去。

9. 容错这层,不能等出事了再补

如果只是把意图路由、子图和长期记忆接起来,这条管线已经能跑。

但落到业务里,如果某一层失败后整条图直接断掉,用户侧体验和排查都会受到影响。所以容错不适合最后临时打补丁,应该从一开始就留在结构里。

typescript
// error-fallback.ts
import type { GraphNode } from '@langchain/langgraph'

const fallbackReply: GraphNode<typeof State> = (state) => {
  return {
    messages: [
      {
        role: 'assistant',

        content: `我这次处理文章时遇到了一点问题:${state.lastError || '未知错误'}。我先保留已有草稿,后面可以继续从这里修改。`,
      },
    ],

    status: 'fallback',
  }
}

这类节点看上去很普通,但放在完整系统里就会很重要。因为它意味着:

即使某个角色、某个工具、某个子图出错,系统也还能保住一条对外可用的回复路径。

10. 如果把整条管线串起来,大概就是这个样子

把前面几段合在一起,这条重构后的内容创作智能体管线,大概可以概括成下面这条顺序:

用户消息先进来,系统先读取长期记忆;读完以后判断当前意图,决定该走选题、大纲、起草还是审稿;进入对应子图以后,各自处理自己的工具和内部流程;快结束时把值得长期保存的写作偏好写回 Store;如果中间有节点失败,再切到备用回复路径。

这时候,前面这一整章讲过的东西,基本都落到了同一条系统里:

  • StateSchema 负责线程状态
  • checkpointer 负责把线程继续接上
  • Store 负责跨会话长期记忆
  • Command 和条件边负责控制流
  • 子图负责拆业务模块
  • 多 Agent 负责角色分工
  • 容错节点负责让系统别轻易断掉

11. 总结

走到这里,这一章也差不多收住了。

如果只把 LangGraph 看成一个图编排库,也能使用。但前面这一整章一路写下来,可以看出它更适合那些状态会持续流动、角色会分工、流程会暂停恢复,而且系统还需要长期演进的 AI 应用。

所以这一篇的重点不只是「把内容创作智能体重构了一遍」,而是把前面那些分散的能力重新放回一个系统视角里。写到这里,LangGraph 这一章就不只是 API 笔记,而是一套可以承接复杂 AI 应用的组织方式。

基于 MIT 协议开源