主题
实战
要点
- 前面这一章拆开的能力,可以重新放回一条完整业务管线里
- 内容创作智能体适合按状态管线拆分,而不是堆在一个 Agent 入口里
- 长期记忆适合在流程开始时读取,在流程结束时按条件写回
- 当前意图可以决定进入选题、起草、审稿、SEO 等不同角色或子图
- 完整管线要同时处理 state、checkpointer、store、context 和容错路径
- 容错路径需要和主流程一起设计
内容
1. 到了这里,问题已经不是某一个节点怎么写
前面这一整章,其实一直在把 LangGraph 的几块能力拆开讲。
先讲状态、节点、边,再讲 reducer、条件路由、checkpointer、interrupt()、子图、多 Agent、Store 和容错。单看每一篇,它们都不难理解。但只要要做一个能长期演进的内容创作智能体,问题很快就不再是「某个节点怎么写」。
更核心的问题会变成:
这套系统到底该怎么拆。
如果还用前面最早的那种做法,把提示词、工具、记忆、外部调用、人工介入全堆在一个 Agent 入口里,功能当然能继续往上加,但代码会越来越难收。记忆读写混在一起,工具调用和回复生成混在一起,异常处理也全塞在一层里。写到后面,很多问题不是不能修,而是改一个地方总会牵一大片。
所以这一篇不再单独讲某个 API,而是把前面这一章已经讲过的东西,重新放回一条完整的内容创作管线里,看它们各自适合放在哪一层。
对照官方整体能力介绍,LangGraph 的价值经常体现在几件事一起出现时:状态持续流动、执行可以持久化、节点可以流式输出、流程可以暂停等人、长期记忆可以跨线程复用。单独看每个 API 都不复杂,但把它们放进一条业务管线,才会出现清晰的分层需求。
内容创作智能体可以按下面这条主线组织:
| 层 | 负责什么 | 对应能力 |
|---|---|---|
| 当前流程 | 草稿、意图、审核状态、错误信息 | StateSchema |
| 同一线程续写 | 多轮修改、暂停恢复、历史回溯 | Checkpointer |
| 跨线程偏好 | 语气、读者、发布渠道 | Store |
| 单次运行背景 | userId、teamId、locale | Runtime 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,
})这里的结构变化,是系统不再只有一个中央处理函数。
不同意图进入不同子图,子图里再各自挂工具、状态和控制流,这样功能加深以后,结构也不容易乱。
这里的 outlineSubgraph、draftSubgraph 和 reviewSubgraph 可以直接接前面子图那篇的写法。重点不在子图内部细节,而在于总管线只负责分派和汇总,不再把所有业务步骤都写在一层。
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 应用的组织方式。