主题
运行时上下文
要点
- 前面这一章一路写下来,状态、子图、多 Agent、Store、容错都已经接起来了
state承载流程数据,context承载本次运行附带的背景条件- 节点通过第二个参数
runtime读取 context 和 store contextSchema用来声明运行时上下文边界- runtime 是节点读取外部背景和长期能力的入口,不应和 state 混用
- 会随流程推进而变化、需要 checkpoint 的值应留在 state 里
内容
1. 写到后面,总会碰到一些不该放进状态里的东西
前面这一章一路写下来,状态、子图、多 Agent、Store、容错都已经接起来了。
但只要开始写复杂图,很快就会碰到一类不好归类的信息。
比如:
当前用户是谁,这次请求来自哪个团队,文章要发布到哪个渠道,当前节点要不要读 Store,某个节点里要不要拿运行时传进来的依赖。
这些信息可以硬塞进图状态里。可一旦这么做,状态就会越来越胖,而且会混进很多并不属于「流程数据」的东西。
这时候最值得先想清楚的问题就变成了:
什么东西应该进 State,什么东西更适合放在运行时上下文里。
这一篇就专门讲这件事。
官方 Graph API 里,节点除了接收 state,还可以接收运行时对象。前面 Store 那篇也用到了这一点:节点通过 runtime.context 知道当前用户是谁,通过 runtime.store 读写长期记忆。它们属于本次运行时附带进来的背景和能力,不属于流程状态本身。
用内容创作智能体来分:
| 放在哪里 | 例子 | 原因 |
|---|---|---|
state | draft、status、reviewLogs、lastError | 会被节点更新,也可能需要 checkpoint |
context | userId、teamId、locale、publishChannel | 本次调用的外部背景,通常不由图生成 |
store | 写作语气、目标读者、历史偏好 | 跨线程长期存在 |
configurable | thread_id、checkpoint_id | 控制线程续写和历史恢复 |
这样分以后,复杂系统里最容易混的几类信息会各自有位置。State 不会因为塞进用户、团队、环境配置而膨胀;context 也不会承担草稿、审核状态这类应该随流程变化的数据。
2. 先把状态和上下文分开
最容易混的,就是把 state 和 context 当成同一个抽屉。
其实它们的角色不一样。
state 说的是这条图在运行过程中会被节点读写、会随着流程往下流动、也可能被 checkpoint 记住的东西。
context 更像这次运行时附带进来的背景信息。它可以被节点读取,但通常不是让节点一路改来改去的。
放到具体场景里就更容易看:
messages、status、draft、reviewLogs 这类东西,明显属于 state;userId、teamId、requestSource、locale、publishChannel、brandVoice 这类东西,则更像 context。
这条线清楚以后,后面的代码边界会稳定很多:不会把所有东西都塞进状态里,也不需要在节点里到处硬编码用户信息。
3. 节点的第二个参数,就是运行时上下文入口
前面几篇里已经零散用到过这一层。比如 Store 那篇里,我们会在节点里写:
typescript
// runtime-shape.ts
const readPreference: GraphNode<typeof State> = async (state, runtime) => {
const userId = runtime.context?.userId
const item = await runtime.store?.get(
['users', userId, 'profile'],
'preferences',
)
return {
notes: [item?.value?.tone ?? 'warm'],
}
}这里的第二个参数 runtime,就是节点拿运行时能力的入口。
它不是图状态的一部分,而是执行这一次节点时,LangGraph 带进来的运行时对象。你会在这里拿到:
runtime.contextruntime.store
有些节点依赖的不是状态字段,而是这些运行时信息。
也正因为这样,runtime 不是在图外随便都能拿到的对象,它只会在节点执行时作为第二个参数传进来。
4. contextSchema 是把上下文边界写清楚
只是在节点里随手写 runtime.context?.userId 还不够。更稳的做法,是在图编译时先声明上下文结构。
typescript
// context-schema.ts
import {
StateGraph,
StateSchema,
MessagesValue,
START,
END,
} from '@langchain/langgraph'
import type { GraphNode } from '@langchain/langgraph'
import { z } from 'zod'
const State = new StateSchema({
messages: MessagesValue,
notes: z.array(z.string()).default([]),
})
const ContextSchema = z.object({
userId: z.string(),
teamId: z.string().optional(),
locale: z.string().default('zh-CN'),
publishChannel: z.enum(['blog', 'wechat', 'docs']).default('blog'),
brandVoice: z.string().default('clear-and-practical'),
})
const respond: GraphNode<typeof State> = async (state, runtime) => {
const userId = runtime.context.userId
const locale = runtime.context.locale
const publishChannel = runtime.context.publishChannel
const brandVoice = runtime.context.brandVoice
return {
notes: [
`当前请求来自用户 ${userId},语言环境是 ${locale},发布渠道是 ${publishChannel},品牌语气是 ${brandVoice}`,
],
}
}
const graph = new StateGraph(State)
.addNode('respond', respond)
.addEdge(START, 'respond')
.addEdge('respond', END)
.compile({
contextSchema: ContextSchema,
})这一步最大的意义,不只是类型更全,而是图本身对外界依赖的运行时信息开始变清楚了。
后面谁来调用这张图、必须传什么、哪些是可选的,看 contextSchema 就能知道。
5. 调用图的时候,再把 context 带进来
图声明完上下文结构以后,运行时就要把这些信息传进去。
typescript
// invoke-with-context.ts
const result = await graph.invoke(
{
messages: [{ role: 'user', content: '帮我记一下以后回复我尽量简短。' }],
},
{
// 这里传进去的是这次运行附带的背景信息,
// 它不会像 state 那样在图里一路被节点修改
context: {
userId: 'user_001',
teamId: 'team_alpha',
locale: 'zh-CN',
publishChannel: 'wechat',
brandVoice: 'clear-and-practical',
},
},
)这一步可以把它理解成:图的状态是这轮流程要处理的数据,context 是这次运行附带进来的背景。
两者都跟当前调用有关,但职责不同。
6. 为什么 userId 更适合放在 context 里
这一点在前面的 Store 场景里最典型。
假设你把 userId 塞进状态里,会发生几件事:
- 它会跟着状态一起在节点之间流动
- 它可能被 checkpoint 记住
- 你的很多节点都会开始把它当流程字段处理
可问题是,userId 并不是这条业务流程自己产生出来的结果,它只是这次运行的外部背景。
更自然的做法,是把它放进 context,节点要用的时候去读,图本身不把它当核心状态的一部分。
typescript
// read-store-by-context.ts
import { InMemoryStore } from '@langchain/langgraph'
const store = new InMemoryStore()
await store.put(['users', 'user_001', 'profile'], 'preferences', {
tone: 'short',
language: 'zh-CN',
audience: 'junior-developers',
})
const readUserPreference: GraphNode<typeof State> = async (state, runtime) => {
const namespace = ['users', runtime.context.userId, 'profile']
const item = await runtime.store?.get(namespace, 'preferences')
const tone = item?.value?.tone ?? 'clear'
const audience = item?.value?.audience ?? 'junior-developers'
return {
notes: [`当前用户偏好语气:${tone},目标读者:${audience}`],
}
}
const graph = new StateGraph(State)
.addNode('readUserPreference', readUserPreference)
.addEdge(START, 'readUserPreference')
.addEdge('readUserPreference', END)
.compile({
store,
contextSchema: ContextSchema,
})这里如果没有 runtime.context.userId,图根本不知道该去哪一段 namespace 找资料。
所以这类信息最适合放在运行时上下文里,而不是塞进流程状态。
7. Store 也是运行时能力的一部分
前面那篇已经单独讲过 Store,这里再把它放回运行时上下文里看一遍,会更容易理解。
store 不是状态字段,也不是节点自己创建出来的。它更像图运行时额外附带进来的一个长期记忆能力。
typescript
// compile-with-store.ts
const graph = new StateGraph(State)
.addNode('readUserPreference', readUserPreference)
.addEdge(START, 'readUserPreference')
.addEdge('readUserPreference', END)
.compile({
// 只有在编译时把 store 传进去,
// 节点里才会真的拿到 runtime.store
store,
contextSchema: ContextSchema,
})这段代码里有两件事同时发生了:
contextSchema 说明这张图运行时需要哪些背景信息,store 则说明这张图运行时还能拿到一套长期记忆能力。
也就是说,运行时上下文不只是 context 这一个对象,runtime 其实承载的是整张图运行时能拿到的外部能力。
8. 什么时候不该放进 context
讲到这里,很容易反过来把很多东西都塞进 context。
这也不对。
如果某个值会随着图的推进不断被修改、会影响后续节点、也应该随着 checkpoint 一起记住,那它就更像状态,而不是运行时上下文。
比如:
- 当前草稿内容
- 当前审批状态
- 当前已经调用过哪些工具
- 当前这轮的失败信息
这些都应该留在 state 里。
所以一个很实用的判断标准是:
如果这个值更像这条流程自己跑出来的数据,就放进 state。
如果它更像这次运行附带进来的背景条件,就优先放进 context。
9. 放回完整系统里,它负责运行时边界
把这一篇和前面的几篇放回一起看,runtime context 正好卡在几个边界之间。
状态负责流动的数据,checkpointer 负责把线程继续接上,Store 负责跨线程长期记忆,而 runtime context 则负责把这次运行的背景条件交给图。
这样一来,前面几篇里出现过的很多东西就各自有了位置:
messages、status、draft 这些在 state,thread_id 在配置里控制线程延续,userId、teamId、locale、publishChannel、brandVoice 在 context,长期写作偏好通过 runtime.store 读写。
图按这种边界来写,会比把所有东西全塞进状态里更容易维护。
10. 总结
这一篇看起来讲的是一个小问题,实际是在重新划分几类容易混在一起的边界。
前面几篇已经把状态、Store、多 Agent 和实战管线都铺开了,但如果不把运行时上下文单独拎出来,读者很容易把 userId、长期记忆入口、线程配置这些东西全混进状态里。写小图时还感觉不到,一到复杂系统里就会越来越乱。
所以补上这一篇以后,LangGraph 这一章关于「图在运行时到底靠什么工作」这件事,也就更完整了。