LangChain Runtime 与上下文工程
代码仓库ChainReaction
写了几个 agent 之后,让人卡住的通常不是工具调用怎么写。麻烦的是”这次运行该拿到什么数据”:用户 ID 从哪来,上一轮对话的偏好存在哪,工具返回的 8000 字文档要不要整个塞回模型。这些问题在 create_agent 的入门示例里看不到,因为示例里只有一个 messages。
LangChain 把答案拆成了三块:state、context、store。名字都短,含义却经常被混着用。我见过把 user_id 塞进 state 的写法,也见过每次 invoke 都把数据库连接写进 messages 的写法。
这篇先把这三块拆开,再看 context_schema 和 runtime.context 怎么用,然后把 runtime 上剩下的字段挨个跑一遍。剩下的一节讲上下文工程:窗口怎么被填满,又该怎么打扫。
文中的输出都是在 Python 3.12 上跑 DeepSeek 得到的真实终端结果,脚本放在 Runtime/。
三个数据源
官方文档在 context engineering 页面给了一张表,按作用域把三者分开了。我补上读写方式:
| 数据源 | 文档里的别称 | 作用域 | 生命周期 | 典型内容 |
|---|---|---|---|---|
| Runtime Context | 静态配置 | 单次运行 | invoke 传入,运行结束即失效 | user_id、API key、数据库连接、权限 |
| State | 短期记忆 | 一个线程 | 由 checkpointer 持久化,跨轮次累积 | messages、上传的文件、认证状态、工具结果 |
| Store | 长期记忆 | 跨线程 | 存储还在就一直有 | 用户偏好、抽取出的记忆、历史数据 |
Runtime Context 是只读的。一次 invoke 传进去,整个运行里只能读,工具和中间件都改不了它。State 可变,工具返回 Command(update=...) 或者中间件返回一个 dict,都会被图的 reducer 合并回去。Store 要显式 get/put,写进去的东西不会自动出现在模型的消息列表里。
三者在一次运行里的流向大致是这样:
flowchart LR
subgraph "单次运行"
A[agent.invoke] -->|context=UserContext| C[Runtime.context]
A -->|messages| S[State]
end
subgraph "跨线程"
ST[(Store)]
end
C --> T[工具]
C --> M[中间件]
S --> T
S --> M
ST --> T
ST --> M
T -->|Command update| S
T -->|put| ST
M -->|返回 dict| S
工具和中间件都能读这三块,能写回去的只有 State 和 Store。
还有一对容易混的:thread_id 和 context。前者通过 config 传,管的是对话本身,也就是消息历史和 checkpoint 归哪个线程;后者通过 context 传,管的是这次运行要用的数据。文档在 agents 页面里说得很直白,thread_id 划定对话的范围,context 承载单次运行的数据,两者经常一起传。
我写了个脚本把三块同时打印出来。为了让 State 的”线程内”这个属性可见,agent 加了 checkpointer,invoke 时带上 thread_id:
1 | # Runtime/RuntimeFields.py(节选) |
真实输出,省略了对象地址:
1 | === [tool] ToolRuntime 实例上每个字段的真实取值 === |
一次工具调用里三块同时在线,来源各不相同:context 来自 invoke 的参数,state 来自这个线程的 checkpoint,store 来自你传给 create_agent 的那个存储实例。
context_schema 与 runtime.context
context_schema 是一个类型声明,告诉 create_agent 这次运行的 context 长什么样。文档用 dataclass 举例:
1 | from dataclasses import dataclass |
在工具里读它,靠 ToolRuntime 参数。参数名必须叫 runtime,这个名字和 config 都是保留的,自己定义工具参数时不能占用,否则运行时报错。
1 | # Runtime/ContextTool.py |
注意这个工具没有 user_id 参数。模型看不到它,也没有机会去编造它。真实运行输出:
1 | [tool] type(runtime) = ToolRuntime |
中间件里读 context 有两个入口。节点式钩子(before_model、after_model)直接收 Runtime 参数,包裹式钩子(wrap_model_call)从 request.runtime 拿。文档给的动态提示写法是 @dynamic_prompt:
1 | # Runtime/DynamicSystemPrompt.py |
同一个 agent,同一句提问,换掉 context 之后系统提示和回答都跟着变了:
1 | === 运行:role=admin, env=production === |
有个坑我踩过。context_schema 只做声明,不传 context 不会在 create_agent 阶段报错,也不会在 invoke 入口报错,要等你真去读字段的时候才炸:
1 | === 忘记传 context 会怎样 === |
报错点在中间件里那一行 ctx.role。如果这段逻辑藏在几层调用之外的工具里,你会先看到一条工具执行失败的记录,再往回找。稳妥的做法是在 before_agent 里加一句校验,context 缺字段就直接抛 ValueError,别让它跑进模型循环。
为什么静态依赖不该塞进 state
user_id、数据库连接、API key 这些东西塞进 state 也能跑,但要付三个代价。
State 会被 checkpointer 序列化。文档写得很清楚,state 通过 checkpointer 持久化到数据库或内存,这样线程随时能恢复。把数据库连接对象放进去,等于要求它可序列化,还得跟着线程活多久就活多久。连接池不这么用。
State 的作用域是线程,配置的作用域通常是整个应用。同一份 deployment_env 服务所有线程,放进 state 就得在每次开新线程时重复写一遍,每个线程各存一份副本,改配置时找不到单一来源。
还有个更隐蔽的问题。State 里的 messages 会直接进模型上下文,其他自定义字段默认不进 prompt,但只要有人写了个把 state 整体注入的中间件,敏感配置就跟着出去了。context 是显式的按运行传入,评审代码时能一眼看到谁传了什么。测试的时候也可以传一个假的 context 对象,不用真的连数据库。
文档在 runtime 页面用一句话概括了这件事:runtime context 提供的是依赖注入。不用硬编码,也不用全局变量,需要什么就在 invoke 时传进来。这样同一个 agent 在测试里拿到假连接,在生产里拿到真连接,中间一行代码都不用改。
runtime 上还有什么
文档在 tools 页面列了 ToolRuntime 能拿到的东西,一共八项:
| 组件 | 说明 | 典型用途 |
|---|---|---|
| State | 当前会话的可变数据 | 读对话历史,累计工具调用次数 |
| Context | invoke 时传入的只读配置 | 按用户身份调整回答 |
| Store | 跨会话的持久数据 | 存用户偏好,维护知识库 |
| Stream Writer | 工具执行期间发实时更新 | 长任务进度 |
| Execution Info | 本次执行的标识与重试信息 | thread_id、run_id、第几次尝试 |
| Server Info | LangGraph Server 的元数据 | assistant ID、认证用户 |
| Config | 本次执行的 RunnableConfig |
回调、tags、metadata |
| Tool Call ID | 本次工具调用的唯一标识 | 关联日志,拼 ToolMessage |
这是”能用来做什么”的清单,实际字段名要跑一下才知道。我把两个 dataclass 的字段都打印了:
1 | === ToolRuntime 的字段(langchain.tools.ToolRuntime)=== |
ToolRuntime 比 Runtime 多出 state、config、tool_call_id、tools。这符合直觉:中间件本来就在图里,拿得到 state;工具不在,得从 runtime 里补。tools 这个字段文档没提,实际是当前注册的工具列表,写权限过滤逻辑时可能用得上。
config 文档归在 ToolRuntime 那一栏,实际两个 runtime 上都有。它装的是本次执行的 RunnableConfig,从我的输出里能看到 metadata 里带着 ls_integration 和 thread_id,回调、tags 也在里面。要在工具里触发自定义回调或者按 tag 分流时才会用到它。
反过来,Runtime 上有三个字段文档没写:heartbeat、previous、control。我在本地 langgraph 1.2.12 上确认它们存在,但文档既然没承诺,我不建议依赖。heartbeat 打印出来是 _no_op_heartbeat,本地运行时是个空实现。
stream_writer
工具跑长任务时,让用户盯着一个转圈图标很难受。runtime.stream_writer 让工具往 custom 流里推消息:
1 | # Runtime/StreamWriter.py |
消费端在 stream_mode 里加上 "custom":
1 | for chunk in agent.stream( |
真实输出:
1 | === agent.stream(stream_mode=['custom', 'updates']) 收到的分片 === |
custom 分片和 updates 分片交错到达,顺序就是实际执行顺序。
server_info:本地是 None,起个 server 才有值
文档在 tools 页面写得很短:工具跑在 LangGraph Server 上时,用 runtime.server_info 拿 assistant ID、graph ID 和认证用户,本地开发时它是 None。前面 runtime_fields.py 输出里那行 runtime.server_info = None 就是这个情况。我想知道它真有值时长什么样,于是在本地起了一个 langgraph dev。
工程三个文件,放在 Runtime/langgraph_dev/。langgraph.json 声明图入口:
1 | { |
agent.py 里就是个普通的 create_agent,中间件和工具各打印一次 server_info,模型还是前面那套 DeepSeek 配置:
1 | # Runtime/langgraph_dev/agent.py(节选) |
起服务就一条命令,默认监听 127.0.0.1:2024:
1 | langgraph dev --no-browser --no-reload --port 2024 |
客户端用 langgraph_sdk 打过去。这个脚本会把同一个 agent 先在本地直接 invoke 一次,再通过 server 跑一次:
1 | # Runtime/ServerInfo.py(节选) |
两边的探针输出放在一起,差别很直接:
1 | === 1. 本地直接 invoke 同一个 agent === |
ServerInfo 就三个字段。assistant_id 是图在这个 server 上的注册 ID,我起了三次服务,它每次都是同一个值;graph_id 就是 langgraph.json 里 graphs 那个键。user 是认证用户,文档里的用法是 server.user.identity,我这边没配 auth,所以是 None。要让它有值,得给 langgraph.json 配 auth,或者用平台的认证。
两个细节记一下。
中间件和工具里看到的 server_info 是同一份值,assistant_id 完全一致。它描述的是”这次运行落在哪个 server 上”,不是某个节点的局部状态。
langgraph dev 在中文 Windows 上第一次没起来。langgraph-api 0.15.1 的 validation.py 第 14 行是 open(pathlib.Path(__file__).parent.parent / "openapi.json"),没指定编码,GBK 环境下按 GBK 去解这个 UTF-8 文件,直接崩在 import 阶段:
1 | File "...\site-packages\langgraph_api\validation.py", line 14, in <module> |
加一句 $env:PYTHONUTF8='1' 再起就正常了。这跟 agent 里写什么没关系,是服务端自己的编码问题。
几个坑
execution_info 需要 langgraph>=1.1.5,文档在 runtime 页面标注了这一点。我这边装的是 1.2.12。
thread_id 要有 checkpointer 才有值。第一次跑这个脚本时我没加 checkpointer,打印出来是 runtime.execution_info.thread_id = None;加上 InMemorySaver 并传 thread_id 之后才变成 thread-demo-1。
工具里用 runtime.tool_call_id 拼 ToolMessage 时,id 必须和触发它的那次工具调用一致。文档明确写了 ToolNode 会检查,缺了会抛 ValueError。自己构造 ToolMessage 时记得把这个字段带上。
runtime.stream_writer 必须在 LangGraph 的执行上下文里调用,脱离图直接 tool.invoke() 会失败。
上下文工程的实操
前面是”能拿到什么”,这一段是”拿到之后怎么放”。文档把可控的东西分成模型上下文(instructions、messages、tools、model、response format)和生命周期上下文,前者每次调用临时生效,后者会落到 state 里。这个区分决定了你该选哪个钩子。
system prompt 分层
把系统提示拆成固定骨架和动态片段。骨架放 create_agent 的 system_prompt,动态片段在 @dynamic_prompt 里拼,上一节的 role/env 例子就是这个模式。文档里还有一种写法是在 wrap_model_call 里改 request.system_message,用 content_blocks 追加而不是覆盖:
1 | from langchain.messages import SystemMessage |
文档特别提示 request.system_message 永远是 SystemMessage 对象,哪怕你创建 agent 时传的是字符串。想追加内容就走 content_blocks,直接拼字符串会把原有结构弄丢。
消息裁剪
裁剪是临时的:只改这一次发给模型的消息,state 不动。用 wrap_model_call 加 request.override(messages=...):
1 |
|
那几行 while 不是凑数的。消息列表里有 AIMessage(tool_calls=...) 和对应的 ToolMessage,从中间切会把它们拆散,模型接口会直接报错。切完要让第一条是 HumanMessage。
按需注入
有些上下文只在特定条件下才需要。文档给的两个例子是按用户上传的文件摘要注入、按用户所在辖区注入合规条款,两个都用 wrap_model_call 把新消息拼到列表末尾。文档里有句话值得记住:模型对末尾的消息更敏感,所以注入的内容放最后,别塞进开头。
注入用 request.override(messages=...) 拼,state 不受影响。如果注入的信息需要被后续轮次记住,那就得换节点式钩子,返回一个 dict 让它落进 state。
工具结果压缩
工具返回 8000 字文档,模型只需要其中一段。压缩放在 wrap_tool_call 里,拿到结果后重建一个 ToolMessage:
1 |
|
原地改 result.content 其实也能跑通,ToolMessage 的 model_config 是 {'extra': 'allow'},不是冻结模型。我选择重建,是为了把 tool_call_id 这类字段显式带上,压缩逻辑变复杂时不容易漏。
跑一遍看效果
三个技巧放进同一个 agent,跑三轮对话:
1 | === 第 1 轮:你好,先打个招呼。 === |
第 3 轮发生了两次模型调用。第一次裁剪前 5 条消息,裁到 3 条;工具返回 4800 字符,压到 219 字符;第二次模型调用前 state 里已经有 7 条消息,同样裁到 3 条。整个过程 state 从 2 涨到 8,说明裁剪和压缩都只作用于这一次调用,历史一条没少。
窗口的填充和打扫大致是这个节奏:
sequenceDiagram
participant U as 用户
participant MW as 中间件
participant M as 模型
participant T as 工具
U->>MW: 第 3 轮提问
MW->>MW: 裁剪到最近 3 条
MW->>M: 发送裁剪后的消息
M->>T: search_docs
T-->>MW: 4800 字符原始结果
MW->>MW: 压缩成 219 字符
MW->>M: 交回压缩后的 ToolMessage
M-->>U: 基于压缩结果回答
Note over MW: state 里始终是完整历史
如果目标是永久替换历史,裁剪就不合适。文档给的是 SummarizationMiddleware,超过阈值时它用另一个模型总结旧消息,把总结写回 state,后续轮次看到的是总结。我用 trigger=("messages", 3) 和 keep=("messages", 1) 跑了一遍,第四轮它还记得第一轮报的名字:
1 | --- 第 1 轮:我叫老张。 |
两条原始消息(我叫老张。 和对应的回答)在第 2 轮就被换成了一个 HumanMessage 里的总结,state 长度从此稳定在 3 条。裁剪是每次调用算一遍,便宜但每次都要重算;总结要多花一次模型调用,换来的是历史真的变短了。对话只有十几轮用裁剪,涨到几百条再上总结。
什么时候用中间件,什么时候写死
| 情况 | 建议 |
|---|---|
| 内容对所有用户、所有运行都一样 | 直接写进 system_prompt |
| 内容依赖 user_id、环境、权限 | context_schema 加 @dynamic_prompt |
| 内容依赖对话长度或历史 | 中间件读 request.messages |
| 内容来自外部存储 | 中间件读 runtime.store,或让工具按需取 |
| 只有少数轮次需要 | 让工具按需返回,别提前注入 |
| 每次调用都要变,历史不能改 | wrap_model_call 加 request.override() |
| 历史本身要永久变短 | SummarizationMiddleware 或 before_model 返回 dict |
文档 best practices 的第一条是 Start simple:先用静态 prompt 和固定工具集,真出现了第二种变体再抽中间件。我按这个原则试过,只有两三个分支的动态 prompt 直接写在 @dynamic_prompt 里比抽成类更好读。中间件真正的成本是后来的人要跳三个文件才能拼出完整的系统提示。
另一个判断标准是数据从哪来。数据已经在 context 或 store 里,中间件就是顺手的事;数据要靠一次网络请求才能拿到,那它应该是工具,让模型自己决定什么时候花这个钱。
小结
Runtime.context是单次运行的只读依赖,用context_schema声明类型,invoke(context=...)传入;工具里通过ToolRuntime参数读,中间件里通过Runtime参数或request.runtime读。不传 context 不会提前报错,会在读字段时抛AttributeError。- State 是线程内的可变数据,会被 checkpointer 持久化;Store 是跨线程的持久数据,要显式读写。数据库连接、API key 这类静态依赖放 context,不要放 state。
ToolRuntime有九个字段,文档列了其中八项的用途;Runtime上还有heartbeat、previous、control三个没写进文档,不建议依赖。server_info本地直接invoke是None,我起了个langgraph dev才拿到真实值,三个字段是assistant_id、graph_id、user;thread_id要有 checkpointer 才有值。- 上下文工程的四个常用动作:system prompt 分层、消息裁剪、按需注入、工具结果压缩。前三个用
wrap_model_call加request.override()做临时修改,压缩用wrap_tool_call重建ToolMessage。 - 临时修改和持久修改是两条路。要历史真的变短就用
SummarizationMiddleware或before_model返回 dict;只想让这一次调用省点 token,就用override。
脚本都在仓库的 Runtime/ 下:ContextTool.py、DynamicSystemPrompt.py、RuntimeFields.py、StreamWriter.py、ContextWindow.py、Summarization.py。server_info 那节的服务端工程在 Runtime/langgraph_dev/,客户端脚本是 Runtime/ServerInfo.py。

