本文读的是 LangChain v1 官方文档的 Context Engineering 一页。这页文档本身写得比较散------十几个代码片段平铺过去。我想把它重新组织成一个更清楚的结构:两个正交的维度 ,再把每个维度对齐到具体的 API。文中代码基本沿用官方示例,模型名(
gpt-5.5、claude-sonnet-4-6等)也保持原样。
定义与边界
LangChain 给上下文工程下的定义是:
用正确的格式,把正确的信息和工具提供给 LLM,让它能完成任务。
这个定义本身平淡,但它划出的边界值得注意:它不谈模型能力,只谈「调用模型之前,你准备了什么」。模型是固定的黑盒,你能动的只有输入------系统提示词、消息历史、可用工具、返回格式,以及这些东西背后的数据从哪来。上下文工程就是把这部分工程化。
在 LangChain v1 里,这件事几乎完全落在 middleware 和 tool 的运行时接口上。所以这篇会先讲清楚 Agent 的执行循环、middleware 挂在哪,再展开两个维度。
一、Agent 循环与 middleware 的挂载点
create_agent 构造出来的 Agent,运行时是一个两步循环:
text
┌─────────────────────────────────────────────┐
│ model call:带 prompt + tools 调一次 LLM │
└───────────────────┬─────────────────────────┘
│ 模型要求调工具?
┌────────┴────────┐
是 否 → 结束,返回结果
│
┌──────────▼──────────────────────────────────┐
│ tool execution:执行工具,结果作为消息回填 │
└───────────────────┬─────────────────────────┘
└──────→ 回到 model call
middleware 就是挂在这个循环各个位置上的钩子。文档主要用到两个,但整套钩子值得先列全,因为它们决定了「你想改的东西该在哪一层改」:
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
@dynamic_prompt |
每次 model call 前,计算系统提示词 | 按状态/身份改写 system prompt |
@wrap_model_call |
包裹整个 model call | 瞬态改 messages / tools / model / response_format |
before_model / after_model |
model call 前后 | 记日志、改状态、条件跳转 |
@wrap_tool_call |
包裹单次工具执行 | 拦截工具输入输出、加护栏 |
wrap_* 是包裹 语义:它拿到一个 handler,自己决定改完请求再调用 handler(request),还能对返回值二次加工。这一点后面会反复用到。
二、两个正交的维度
文档把可控的东西分成三类上下文,又分出三个数据来源。这两组东西其实是正交的两个维度,分开看更清楚:
- 维度 A:你在控制循环的哪个环节? ------ Model Context / Tool Context / Life-cycle Context
- 维度 B:这份数据活多久、谁写? ------ Runtime Context / State / Store
任何一个环节,都可以从任意一个数据源取数。比如「动态改系统提示词」是维度 A 里的 Model Context,它的输入既可能来自 State(对话多长了),也可能来自 Store(用户偏好),还可能来自 Runtime Context(用户角色)。文档里那十几个片段,本质就是 A×B 的组合列举。理解了两个维度各自是什么,这些片段就不用一个个背了。
先讲维度 B(数据源),因为它是维度 A 的输入。
三、维度 B:三个数据源
Runtime Context ------ 不可变的运行配置
一次 invoke 期间固定不变的配置:用户 ID、API key、数据库连接、角色、部署环境。它由调用方在启动时传入,Agent 运行过程中不会改写它。
用法是三步:dataclass 定 schema → create_agent(context_schema=...) → invoke(context=...)。工具和 middleware 通过 runtime.context 读:
python
from dataclasses import dataclass
from langchain.tools import tool, ToolRuntime
from langchain.agents import create_agent
@dataclass
class Context:
user_id: str
api_key: str
db_connection: str
@tool
def fetch_user_data(query: str, runtime: ToolRuntime[Context]) -> str:
"""用运行配置去查数据。"""
user_id = runtime.context.user_id
api_key = runtime.context.api_key
db_connection = runtime.context.db_connection
results = perform_database_query(db_connection, query, api_key)
return f"Found {len(results)} results for user {user_id}"
agent = create_agent(model="gpt-5.5", tools=[fetch_user_data], context_schema=Context)
result = agent.invoke(
{"messages": [{"role": "user", "content": "Get my data"}]},
context=Context(user_id="user_123", api_key="sk-...", db_connection="postgresql://..."),
)
注意 ToolRuntime[Context] 这个泛型参数------它让 runtime.context 带上类型,IDE 能补全、类型检查能报错。这是把「凭证、连接」这类东西从提示词里赶出去的正确姿势:它们不该出现在给模型看的文本里,而应该走 Runtime Context,只有工具能碰到。
State ------ 会话级的可变状态
当前这轮会话中会变化的数据:消息历史、上传的文件、认证标志、工具产出的中间结果。它的生命周期是单个会话(在 LangGraph 里对应一个 thread),配了 checkpointer 就能随线程持久化、断点续跑,但不跨会话。
State 本质是一个带 reducer 的字典。最常见的 reducer 就是 messages 那条------新消息是追加 而不是覆盖,所以循环里每一轮的消息会累积起来。读用 runtime.state(工具里)或 request.state(middleware 里);写不能直接改字典,而要让工具返回一个 Command,由框架合并进 State:
python
from langchain.tools import tool, ToolRuntime
from langchain.agents import create_agent
from langgraph.types import Command
@tool
def authenticate_user(password: str, runtime: ToolRuntime) -> Command:
"""认证用户,并把结果写回 State。"""
if password == "correct":
return Command(update={"authenticated": True})
return Command(update={"authenticated": False})
agent = create_agent(model="gpt-5.5", tools=[authenticate_user])
为什么写 State 要绕一层 Command、而不是直接赋值?因为状态更新要走 reducer 合并、要能被 checkpointer 记录、要在并行分支下可预测。Command(update=...) 是把「我想改什么」声明出来交给框架,而不是就地改一个共享字典------这跟 Redux 里 dispatch 一个 action 是同一个道理。
Store ------ 跨会话的长期存储
跨会话持久的数据:用户偏好、写作风格、历史洞察、feature flag。它是一个 KV 存储,按 (namespace,) 元组 + key 组织,get / put 读写,通过 store=InMemoryStore()(生产上换成持久实现)挂到 Agent 上:
python
from langchain.tools import tool, ToolRuntime
from langchain.agents import create_agent
from langgraph.store.memory import InMemoryStore
@tool
def save_preference(preference_key: str, preference_value: str,
runtime: ToolRuntime[Context]) -> str:
"""把用户偏好写进 Store。"""
user_id = runtime.context.user_id
store = runtime.store
existing = store.get(("preferences",), user_id)
prefs = existing.value if existing else {}
prefs[preference_key] = preference_value
store.put(("preferences",), user_id, prefs)
return f"Saved preference: {preference_key} = {preference_value}"
store.get 返回的不是裸值,而是一个带 .value 的条目(还带版本、时间戳等元数据),所以读的时候是 existing.value。namespace 用元组是为了做多租户隔离------("preferences",) 配上 user_id 这个 key,天然按用户分区。
三者对照
| Runtime Context | State | Store | |
|---|---|---|---|
| 生命周期 | 单次 invoke,不变 | 单个会话(thread),可变 | 跨会话,持久 |
| 写入方 | 调用方在 invoke(context=) 传入 |
工具返回 Command(update=...) |
显式 store.put(...) |
| 读取入口 | runtime.context |
runtime.state / request.state |
runtime.store |
| 是否类型化 | 是(dataclass schema) | 弱(dict + reducer) | 否(KV) |
| 放什么 | 凭证、连接、角色、环境 | 消息、文件、认证标志 | 偏好、历史、feature flag |
一条判断规则:这份数据在一次调用里会变吗?跨会话还要吗? 不变且单次用完 → Runtime Context;会变、但会话结束就没意义 → State;要跨会话记住 → Store。
四、维度 A:控制循环的哪个环节
Model Context:动态构造这一次调用的输入
这是最主要的一类,控制的是每次 model call 喂进去的五样东西:system prompt、messages、tools、model、response_format。它们都可以在 middleware 里按数据源动态决定。
系统提示词用 @dynamic_prompt,返回一个字符串:
python
from langchain.agents.middleware import dynamic_prompt, ModelRequest
@dynamic_prompt
def context_aware_prompt(request: ModelRequest) -> str:
role = request.runtime.context.user_role
env = request.runtime.context.deployment_env
base = "You are a helpful assistant."
if role == "admin":
base += "\nYou have admin access. You can perform all operations."
elif role == "viewer":
base += "\nYou have read-only access."
if env == "production":
base += "\nBe extra careful with any data modifications."
return base
其余四样都走 @wrap_model_call + request.override(...)。override 返回一个改过的请求副本,只对这一次 handler(request) 生效。下面是三个有代表性的例子。
按对话长度换模型(成本/质量权衡下沉到运行时):
python
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.chat_models import init_chat_model
large_model = init_chat_model("claude-sonnet-4-6")
standard_model = init_chat_model("gpt-5.5")
efficient_model = init_chat_model("gpt-5.4-mini")
@wrap_model_call
def state_based_model(request: ModelRequest, handler) -> ModelResponse:
n = len(request.messages)
model = large_model if n > 20 else standard_model if n > 10 else efficient_model
return handler(request.override(model=model))
按角色裁剪工具面(权限收敛在这里,而不是靠提示词求模型别乱调):
python
@wrap_model_call
def context_based_tools(request: ModelRequest, handler) -> ModelResponse:
role = request.runtime.context.user_role
if role == "editor":
tools = [t for t in request.tools if t.name != "delete_data"]
request = request.override(tools=tools)
elif role not in ("admin", "editor"):
tools = [t for t in request.tools if t.name.startswith("read_")]
request = request.override(tools=tools)
return handler(request)
按会话阶段切换返回格式(前几轮要简,后面要带推理和置信度):
python
from pydantic import BaseModel, Field
class SimpleResponse(BaseModel):
answer: str = Field(description="A brief answer")
class DetailedResponse(BaseModel):
answer: str = Field(description="A detailed answer")
reasoning: str = Field(description="Explanation of reasoning")
confidence: float = Field(description="Confidence score 0-1")
@wrap_model_call
def state_based_output(request: ModelRequest, handler) -> ModelResponse:
fmt = SimpleResponse if len(request.messages) < 3 else DetailedResponse
return handler(request.override(response_format=fmt))
这里有一个必须分清的机制点:request.override(...) 是瞬态的,Command(update=...) 是持久的。 前者只改「这一次递给模型的请求」,不落进 State,下一轮循环从原始状态重新计算;后者是真的把 State 改了,之后每一轮都看得到。用 override 往 messages 里塞一段临时上下文,和把它 append 进 State,行为完全不同------前者不会污染历史,后者会。这两者混淆是很难查的一类 bug。
顺带看一个 override 改 messages 的例子,它同时展示了「从 State 取数据」:
python
@wrap_model_call
def inject_file_context(request: ModelRequest, handler) -> ModelResponse:
"""把本会话上传过的文件信息,临时拼进这一次调用。"""
uploaded = request.state.get("uploaded_files", [])
if uploaded:
desc = "\n".join(f"- {f['name']} ({f['type']}): {f['summary']}" for f in uploaded)
messages = [*request.messages, {"role": "user", "content": f"可引用的文件:\n{desc}"}]
request = request.override(messages=messages)
return handler(request)
文件清单存在 State(会话级),但每次调用是瞬态注入给模型的------用完即弃,不会把这段说明永久钉进对话历史。这正是「State 存数据」和「override 用数据」的分工。
Tool Context:工具的读与写
工具是 Agent 真正对外产生副作用的地方。它两头都接着数据源:入参里声明一个 ToolRuntime,就能读 runtime.state / runtime.store / runtime.context;要写,就返回 Command(改 State)或调 store.put(改 Store)。上面 Runtime Context、State、Store 三节的代码其实已经把这些都演示过了,这里不重复。
要点是:工具能读能写持久状态,是 Agent 从「会对话」变成「会办事」的关键。一个只读提示词、不碰 State/Store 的工具,本质还是个函数调用;能读认证标志、能把偏好写回 Store 的工具,才让 Agent 具备了跨轮次、跨会话的记忆和状态机行为。
Life-cycle Context:步骤之间的动作
有些逻辑不属于某一次 model call,而是发生在循环的步骤之间------最典型的是上下文压缩。LangChain 内置了 SummarizationMiddleware:
python
from langchain.agents.middleware import SummarizationMiddleware
agent = create_agent(
model="gpt-5.5",
tools=[...],
middleware=[
SummarizationMiddleware(
model="gpt-5.4-mini", # 用便宜模型做摘要
trigger={"tokens": 4000}, # 超过 4000 token 触发
keep=("messages", 20), # 保留最近 20 条,其余压成摘要
),
],
)
它做的事:监控 State 里的消息,一旦 token 超过阈值,就用一个(通常更便宜的)模型把较早的消息总结掉、替换进 State,把上下文窗口腾出来。这是「正确的信息也包括别塞太多」在框架层的自动化,而且它改的是 State(持久),所以压缩效果对后续每一轮都生效------这跟前面 override 那种瞬态修改是两回事。
五、把两个维度合起来
回到最初那张 A×B 的表。文档里每个片段,都能定位成「在某个环节,用某个数据源」:
| Model Context | Tool Context | Life-cycle | |
|---|---|---|---|
| Runtime Context | 按角色改 prompt / 裁工具 | 工具拿 api_key 查库 | ------ |
| State | 按对话长度换模型 / 换格式 | 工具读认证标志 | 超长时触发摘要 |
| Store | 按偏好定 prompt / 模型 | 工具存取用户偏好 | ------ |
真正写代码时,你做的永远是同一件事:在循环的某个环节(Model / Tool / Life-cycle),从某个数据源(Context / State / Store)取出需要的数据,构造出这一次要喂给模型的输入。 剩下的都是这个句式的具体填空。
六、几个实践判断
文档结尾给的建议不多,结合上面的机制,有几条值得单独强调:
- 先静态,再动态。 能写死的 prompt 和工具就先写死,确有分支需求了再加 middleware。动态逻辑越多,越难判断某一次调用到底喂了什么进去。
- 凭证走 Runtime Context,不进提示词。 只有工具该碰 api_key、连接串,把它们放进给模型看的文本里既浪费 token 又有泄漏面。
- 分清瞬态与持久。
request.override只影响当次调用,Command(update=)和store.put是持久写入。想清楚你改的东西该活多久,是避免一类隐蔽 bug 的前提。 - 盯 token 与延迟。 动态注入越多,上下文越长、越贵、越慢;
SummarizationMiddleware是现成的止损手段,但它本身也要额外调一次模型,别无脑开。 - 一次加一个 middleware 再测。 多个
wrap_model_call是层层包裹的,叠在一起时执行顺序和相互覆盖不直观,逐个加进去好定位。
小结
LangChain 这页文档真正有价值的,不是那些 API 名字,而是它背后的组织方式:把「喂给模型的东西」拆成「控制哪个环节」和「数据活多久」两个正交维度 。API 会变、会加,但这两个维度是稳定的思考框架------@dynamic_prompt、@wrap_model_call、request.override、ToolRuntime、Command、store.put,各自都能填进这张表里的某一格。看懂了格子,API 只是查一下的事。
标签 :上下文工程 Context Engineering LangChain AI Agent LLM 工程