本文读的是 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,运行时是一个两步循环:
┌─────────────────────────────────────────────┐ │ model call:带 prompt + tools 调一次 LLM │ └───────────────────┬─────────────────────────┘ │ 模型要求调工具? ┌────────┴────────┐ 是 否 → 结束,返回结果 │ ┌──────────▼──────────────────────────────────┐ │ tool execution:执行工具,结果作为消息回填 │ └───────────────────┬─────────────────────────┘ └──────→ 回到 model callmiddleware 就是挂在这个循环各个位置上的钩子。文档主要用到两个,但整套钩子值得先列全,因为它们决定了「你想改的东西该在哪一层改」:
| 钩子 | 触发时机 | 典型用途 |
|---|---|---|
@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读:
fromdataclassesimportdataclassfromlangchain.toolsimporttool,ToolRuntimefromlangchain.agentsimportcreate_agent@dataclassclassContext:user_id:strapi_key:strdb_connection:str@tooldeffetch_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)returnf"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:
fromlangchain.toolsimporttool,ToolRuntimefromlangchain.agentsimportcreate_agentfromlanggraph.typesimportCommand@tooldefauthenticate_user(password:str,runtime:ToolRuntime)->Command:"""认证用户,并把结果写回 State。"""ifpassword=="correct":returnCommand(update={"authenticated":True})returnCommand(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 上:
fromlangchain.toolsimporttool,ToolRuntimefromlangchain.agentsimportcreate_agentfromlanggraph.store.memoryimportInMemoryStore@tooldefsave_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.valueifexistingelse{}prefs[preference_key]=preference_value store.put(("preferences",),user_id,prefs)returnf"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,返回一个字符串:
fromlangchain.agents.middlewareimportdynamic_prompt,ModelRequest@dynamic_promptdefcontext_aware_prompt(request:ModelRequest)->str:role=request.runtime.context.user_role env=request.runtime.context.deployment_env base="You are a helpful assistant."ifrole=="admin":base+="\nYou have admin access. You can perform all operations."elifrole=="viewer":base+="\nYou have read-only access."ifenv=="production":base+="\nBe extra careful with any data modifications."returnbase其余四样都走@wrap_model_call+request.override(...)。override返回一个改过的请求副本,只对这一次handler(request)生效。下面是三个有代表性的例子。
按对话长度换模型(成本/质量权衡下沉到运行时):
fromlangchain.agents.middlewareimportwrap_model_call,ModelRequest,ModelResponsefromlangchain.chat_modelsimportinit_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_calldefstate_based_model(request:ModelRequest,handler)->ModelResponse:n=len(request.messages)model=large_modelifn>20elsestandard_modelifn>10elseefficient_modelreturnhandler(request.override(model=model))按角色裁剪工具面(权限收敛在这里,而不是靠提示词求模型别乱调):
@wrap_model_calldefcontext_based_tools(request:ModelRequest,handler)->ModelResponse:role=request.runtime.context.user_roleifrole=="editor":tools=[tfortinrequest.toolsift.name!="delete_data"]request=request.override(tools=tools)elifrolenotin("admin","editor"):tools=[tfortinrequest.toolsift.name.startswith("read_")]request=request.override(tools=tools)returnhandler(request)按会话阶段切换返回格式(前几轮要简,后面要带推理和置信度):
frompydanticimportBaseModel,FieldclassSimpleResponse(BaseModel):answer:str=Field(description="A brief answer")classDetailedResponse(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_calldefstate_based_output(request:ModelRequest,handler)->ModelResponse:fmt=SimpleResponseiflen(request.messages)<3elseDetailedResponsereturnhandler(request.override(response_format=fmt))这里有一个必须分清的机制点:request.override(...)是瞬态的,Command(update=...)是持久的。前者只改「这一次递给模型的请求」,不落进 State,下一轮循环从原始状态重新计算;后者是真的把 State 改了,之后每一轮都看得到。用 override 往 messages 里塞一段临时上下文,和把它 append 进 State,行为完全不同——前者不会污染历史,后者会。这两者混淆是很难查的一类 bug。
顺带看一个 override 改 messages 的例子,它同时展示了「从 State 取数据」:
@wrap_model_calldefinject_file_context(request:ModelRequest,handler)->ModelResponse:"""把本会话上传过的文件信息,临时拼进这一次调用。"""uploaded=request.state.get("uploaded_files",[])ifuploaded:desc="\n".join(f"-{f['name']}({f['type']}):{f['summary']}"forfinuploaded)messages=[*request.messages,{"role":"user","content":f"可引用的文件:\n{desc}"}]request=request.override(messages=messages)returnhandler(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:
fromlangchain.agents.middlewareimportSummarizationMiddleware 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 EngineeringLangChainAI AgentLLM 工程