运行时上下文

要点

  • 前面这一章一路写下来,状态、子图、多 Agent、Store、容错都已经接起来了
  • 前面几篇里,其实已经零散用到过这一层了
  • 只是在节点里随手写 runtime.context?.userId 还不够
  • 图声明完上下文结构以后,真正运行时就要把这些信息传进去
  • 这一点在前面的 Store 场景里最典型

内容

1. 写到后面,总会碰到一些不该放进状态里的东西

前面这一章一路写下来,状态、子图、多 Agent、Store、容错都已经接起来了。

但只要真的开始写复杂图,很快就会碰到一类特别别扭的信息。

比如:

当前用户是谁,这次请求来自哪个团队,当前节点要不要读 Store,某个节点里要不要拿运行时传进来的依赖。

这些信息你当然可以硬塞进图状态里。可一旦这么做,状态就会越来越胖,而且会开始混进很多并不属于「流程数据」的东西。

这时候最值得先想清楚的问题就变成了:

什么东西应该进 State,什么东西更适合放在运行时上下文里。

这一篇就专门讲这件事。

2. 先把状态和上下文分开

最容易混的,就是把 statecontext 当成同一个抽屉。

其实它们的角色不一样。

state 说的是这条图在运行过程中会被节点读写、会随着流程往下流动、也可能被 checkpoint 记住的东西。

context 更像这次运行时附带进来的背景信息。它可以被节点读取,但通常不是让节点一路改来改去的。

放到具体场景里就更容易看:

messagesstatusdraftreviewLogs 这类东西,明显属于 stateuserIdteamIdrequestSourcelocale 这类东西,则更像 context

Canvas actions74%Exit zen mode

Drawing canvas 只要这条线一清楚,后面很多代码就会顺很多。你不会再一股脑把所有东西都塞进状态里,也不会在节点里到处硬编码用户信息。

3. 节点的第二个参数,就是运行时上下文入口

前面几篇里,其实已经零散用到过这一层了。比如 Store 那篇里,我们会在节点里写:

// 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.context
  • runtime.store

有时候,图里真正应该依赖的不是状态字段,而是这些运行时信息。

也正因为这样,runtime 不是在图外随便都能拿到的对象,它只会在节点真正执行时作为第二个参数传进来。

4. contextSchema 是把上下文边界写清楚

只是在节点里随手写 runtime.context?.userId 还不够。更稳的做法,是在图编译时把上下文长什么样先声明清楚。

// 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'),
 
})
 
const respond: GraphNode<typeof State> = async (state, runtime) => {
 
  const userId = runtime.context.userId
 
  const locale = runtime.context.locale
 
  return {
 
    notes: [`当前请求来自用户 ${userId},语言环境是 ${locale}`],
 
  }
 
}
 
const graph = new StateGraph(State)
 
  .addNode('respond', respond)
 
  .addEdge(START, 'respond')
 
  .addEdge('respond', END)
 
  .compile({
 
    contextSchema: ContextSchema,
 
  })

这一步最大的意义,不只是类型更全,而是图本身对外界依赖的运行时信息开始变清楚了。

后面谁来调用这张图、必须传什么、哪些是可选的,看 contextSchema 就能知道。

5. 调用图的时候,再把 context 带进来

图声明完上下文结构以后,真正运行时就要把这些信息传进去。

// invoke-with-context.ts
const result = await graph.invoke(
 
  {
 
    messages: [{ role: 'user', content: '帮我记一下以后回复我尽量简短。' }],
 
  },
 
  {
 
    // 这里传进去的是这次运行附带的背景信息,
 
    // 它不会像 state 那样在图里一路被节点修改
 
    context: {
 
      userId: 'user_001',
 
      teamId: 'team_alpha',
 
      locale: 'zh-CN',
 
    },
 
  },
 
)

这一步可以把它理解成:图的状态是这轮流程要处理的数据,context 是这次运行附带进来的背景。

两者都跟当前调用有关,但职责不同。

6. 为什么 userId 更适合放在 context 里

这一点在前面的 Store 场景里最典型。

假设你把 userId 塞进状态里,会发生几件事:

  • 它会跟着状态一起在节点之间流动
  • 它可能被 checkpoint 记住
  • 你的很多节点都会开始把它当流程字段处理

可问题是,userId 并不是这条业务流程自己产生出来的结果,它只是这次运行的外部背景。

更自然的做法,是把它放进 context,节点要用的时候去读,图本身不把它当核心状态的一部分。

// 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',
 
})
 
const readUserPreference: GraphNode<typeof State> = async (state, runtime) => {
 
  const namespace = ['users', runtime.context.userId, 'profile']
 
  const item = await runtime.store?.get(namespace, 'preferences')
 
  return {
 
    notes: [
 
      `当前用户偏好语气:${item?.value?.tone ?? 'warm'}`,
 
    ],
 
  }
 
}
 
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 不是状态字段,也不是节点自己创建出来的。它更像图运行时额外附带进来的一个长期记忆能力。

// 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. 放回完整系统里,它的位置就很清楚了

把这一篇和前面的几篇放回一起看,会发现这层其实正好卡在中间。

状态负责流动的数据,checkpointer 负责把线程继续接上,Store 负责跨线程长期记忆,而 runtime context 则负责把这次运行的背景条件交给图。

这样一来,前面几篇里出现过的很多东西就都各回各位了:

messagesstatusdraft 这些在 statethread_id 在配置里控制线程延续,userIdteamIdlocalecontext,长期偏好资料通过 runtime.store 读写。

图一旦按这种边界来写,结构会比把所有东西全塞进状态里清楚很多。

10. 总结

这一篇看起来讲的是一个小问题,实际上它把很多容易写乱的边界重新摆正了。

前面几篇已经把状态、Store、多 Agent 和实战管线都铺开了,但如果不把运行时上下文单独拎出来,读者很容易把 userId、长期记忆入口、线程配置这些东西全混进状态里。写小图时还感觉不到,一到复杂系统里就会越来越乱。

所以补上这一篇以后,LangGraph 这一章关于「图在运行时到底靠什么工作」这件事,也就更完整了。