Skip to content

运行时上下文

要点

  • 前面这一章一路写下来,状态、子图、多 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 读写长期记忆。它们属于本次运行时附带进来的背景和能力,不属于流程状态本身。

用内容创作智能体来分:

放在哪里例子原因
statedraftstatusreviewLogslastError会被节点更新,也可能需要 checkpoint
contextuserIdteamIdlocalepublishChannel本次调用的外部背景,通常不由图生成
store写作语气、目标读者、历史偏好跨线程长期存在
configurablethread_idcheckpoint_id控制线程续写和历史恢复

这样分以后,复杂系统里最容易混的几类信息会各自有位置。State 不会因为塞进用户、团队、环境配置而膨胀;context 也不会承担草稿、审核状态这类应该随流程变化的数据。

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

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

其实它们的角色不一样。

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

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

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

messagesstatusdraftreviewLogs 这类东西,明显属于 stateuserIdteamIdrequestSourcelocalepublishChannelbrandVoice 这类东西,则更像 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.context
  • runtime.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 则负责把这次运行的背景条件交给图。

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

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

图按这种边界来写,会比把所有东西全塞进状态里更容易维护。

10. 总结

这一篇看起来讲的是一个小问题,实际是在重新划分几类容易混在一起的边界。

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

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

基于 MIT 协议开源