☰
LangChain上下文工程实战:从Prompt模板到LangGraph状态管理
2026/10/5 3:02:43 网站建设 项目流程

大家做 LLM 应用,一开始都是被"魔法时刻"吸引的——模型居然能写诗、能写代码。但真到了做项目、上生产,你很快会发现,模型的表现 80% 由你喂给它的上下文决定,而围绕 LangChain 做开发,最该补的课就是上下文工程。我见过太多人把精力放在选模型、调参上,结果最后发现瓶颈全在上下文这块:该给的信息没给、不该给的信息塞了一大堆,模型被无关内容带偏,或者干脆把上下文窗口撑爆。

上下文工程,说白了就两件事:一是在有限的窗口里,把最有价值的信息放进模型视野;二是让这些信息在多次调用、多步 Agent 流程里保持正确、连贯、不越界。这篇文章不是上理论课,我尽量按 LangChain 的实际用法来拆,覆盖 prompt 模板、记忆机制、LangGraph 状态管理,最后用一个 FastAPI + LangChain + LangGraph 的真实 Agent 示例把方案串起来。适合刚入门 LangChain 的读者,也适合已经写了些 demo、正准备认真上生产的开发者。

1. 上下文工程是什么,LangChain 里它到底在管什么

先给个我自己的定义:上下文工程就是在模型调用前,把系统设定、用户诉求、历史对话、工具返回、检索片段这些信息,组装成一个符合模型理解的输入序列;同时在窗口约束下做取舍,保证组装结果不超限、不跑偏。这活儿听起来像是拼字符串,实际做起来牵扯到 token 计算、消息结构、状态持久化,一点都不简单。

1.1 上下文窗口是硬约束,先算清楚账

每个模型的上下文窗口都有上限。GPT-4o 这类常见模型,长上下文版本能到 128k,Claude 也到了 200k 级别。但"窗口 128k"不等于"你能用 128k"。每次调用都要留出输出 token 的空间,而且大多数模型在超长上下文的中间部分,注意力都会衰减,业界俗称 lost in the middle。我实测下来,超长上下文下模型对中间内容的召回能力,明显弱于开头和结尾。

所以在 LangChain 项目里,我的第一习惯是:每个模型调用前后都做 token 计算。你可以给链加一个回调,或者干脆在组装消息后、调用模型前,先调一次当前模型的 tokenizer 数一遍。别信"大概差不多",上下文一超就是硬报错,稍差一点就是静默截断,结果经常是模型拿了不完整的资料开始胡说。这个账最好写成工具函数,每次组装完消息就调一下,心里有数。

另一个容易忽略的是"输入和输出共用窗口"。你设定 max_tokens 只影响输出上限,但如果输入已经占了 120k,再大的输出上限也没用。我把这类思路整理成了一个固定预算表:总窗口减去输出保留,再减去系统指令和工具定义,剩下的才是给历史对话和检索结果的配额。配额定下来以后,后续所有裁剪都照着这个配额来。

1.2 上下文的三种形态:消息、状态、检索结果

在 LangChain 的世界里,"上下文"不是一个字符串,它至少有三层形态。

第一层是消息序列。现代模型接口基本都是 Chat 格式,由 system、user、assistant 消息按顺序排列。LangChain 里对应的是 SystemMessage、HumanMessage、AIMessage,工具调用还会产生 ToolMessage。上下文工程的基本功,就是能把各种信息塞进正确的消息类型、正确的顺序。顺序错了,模型的理解方向就偏了。

第二层是状态。一旦你做 Agent,或者在 LangGraph 里跑多步流程,上下文就不再只是"一次调用的 messages",而是整个执行流程共享的一个状态对象。状态里既有用户问题、也有中间结果、还有最终答案。怎么定义状态、怎么更新、怎么在每次节点调用时把它转成 messages,这是 LangChain 新体系里上下文的核心问题。

第三层是检索结果。做 RAG 时,库里捞出来的碎片也要进入上下文。这里要处理的是:怎么选、怎么排、怎么合并成一段,还得在你算好的 token 预算里操作。这三层不是分开的,最终都会组装成一个消息序列送进模型。上下文工程做的,其实就是这个"从状态到消息"的组装和裁剪工作。想清楚这条线,LangChain 里很多 API 的设计逻辑你就通了。

2. 提示模板:把上下文工程落实到第一层

很多人觉得提示工程是"写话术",上下文工程是"管窗口",其实两者是一回事。提示模板就是最基础、最直接的上下文工程工具,因为模板决定了哪些信息能进入上下文、以什么结构进入。

2.1 PromptTemplate 比 f-string 强在哪

刚开始学 LangChain 时,我也用 Python 的 f-string 拼 prompt,感觉 PromptTemplate 多此一举。用久了才发现,差别不是语法,而是工程约束。

第一个差别是延迟绑定。f-string 拼完就定死了,PromptTemplate 可以在运行前先定义结构,运行后再填充变量。配合 partial_variables,还能把一部分固定内容,比如今天的日期、固定的输出格式,预填进去,而把经常变的部分留给调用时处理。第二个差别是校验。设了 validate_template=True 之后,模板里缺变量、多变量,在构建时就会报错,而不是等到模型调用后才发现。对生产项目来说,这个校验能省掉很多低级错误。第三个差别是输出解析。PromptTemplate 通常搭配 output parser 使用,能保证模型输出格式尽量可控,格式稳定了,后续解析和再组装上下文才稳定。

我在项目里一般这样组织:一个总模板文件,里面按 system、user、输出格式说明分块。system 只放角色和固定规则,user 只放每次不同的输入,输出格式说明单独成块。这样改格式时不用动业务代码,上下文结构也一目了然。

2.2 模板设计里的三个实战细节

第一个细节:把固定指令放在 system 消息里,而不是塞进 user 消息的开头。有些模型对 system 的遵从度更高,更重要的是,多轮对话时固定内容只进一次,不会跟随每一轮 user 消息重复,省 token。我见过有人把很长的 role 设定每次都拼进用户消息,结果一轮长对话下来,光重复设定就烧掉了上千 token。

第二个细节:控制模板中"输出格式示例"的篇幅。我见过一个团队,为了让模型输出 JSON,在模板里放了三段详细示例,光格式占掉 800 token。其实对现代模型来说,一条简洁的格式描述加上一个最小示例就够了。省下来的空间给真实内容,收益高得多。我自己一般把格式示例压到 150 token 以内。

第三个细节:不要把所有检索结果都平铺在模板里。如果文档碎片之间有重复信息,模型会被"多数派"误导,或者上下文里冗余内容太多。我现在的做法是:先做去重和压缩,再按与问题的相关性排序,把最相关的放在上下文的开头和结尾位置,中间放辅助材料。这个排序策略,就是利用了前面说的 lost in the middle 现象。别迷信"都给模型看"这句话,给得多不如给得准。

3. 记忆管理:别让模型既记不住又撑不死

上下文工程最现实的问题,是多轮对话。模型本身是无状态的,所谓"记忆",本质上是把历史消息重新放到上下文里。LangChain 的 Memory 系列组件就是干这个的,但用不好,模型要么"失忆",要么被历史淹没。

3.1 四种记忆策略怎么选

先列最常见的四种,帮你建立坐标系。

ConversationBufferMemory 最简单,把对话全部存下来,需要时全部塞进上下文。适合 demo,不适合任何有点规模的应用,因为对话稍长必爆窗口。

ConversationBufferWindowMemory 只保留最近 k 轮。效果够用,但缺点是中间的关键信息会突然"失忆"。用户前面说的偏好,聊几轮后模型就忘了,这对需要长期记忆的业务场景很致命。

ConversationSummaryMemory 把历史对话跑一次模型,压缩成摘要,然后只把摘要放进上下文。省 token,但摘要过程本身有延迟和成本,而且压缩会丢细节。对简单问答还行,对需要精确引用历史信息的场景,摘要里少了关键数字就是事故。

ConversationSummaryBufferMemory 是前面几个的折中:保留最近的消息原文,更早的对话压缩成摘要。这是我在只用 Memory 组件时期最常用的方案。它有个 max_token_limit 参数,用来控制"多少 token 以内的历史用原文,超出部分用摘要"。

3.2 我调记忆参数的真实经验

经验一:max_token_limit 不是越大越好。很多人为了不丢记忆把它调得很大,结果窗口被历史占满,系统指令和检索结果反而没空间,模型表现断崖式下跌。我一般先给工具、检索、系统指令预留空间,剩下的再分给历史。比如总窗口 8000,我可能给系统指令 800、工具定义 1200、检索结果 2000、输出保留 1000,剩下 3000 以内才放历史原文。

经验二:消息顺序别搞反。模型对消息顺序极其敏感,尤其是做过微调的模型。LangChain 里组装历史时,一定要保持"旧消息在前、新消息在后"。有些封装库在持久化恢复时会把顺序弄乱,我排查过好几次"模型怎么不记得刚才说过的话",最后发现是顺序颠倒了。调试时先把 messages 按顺序打出来,一眼就能看出问题。

经验三:对话历史里的工具输出要小心。如果你的 Agent 在过程中调用过工具,那条 ToolMessage 通常很长,而且对后续对话来说往往没那么重要。直接全部保留非常费 token。我的做法是,从历史里把长工具输出截断,只保留"调用了什么工具、结果是什么样的"这个级别的摘要,或者干脆只保留工具名和关键返回值。这样历史长度能压掉一半以上。

4. LangGraph 状态:新一代上下文的正确打开方式

LangChain 现在主推的路线是 LangGraph。很多从旧版 Chain 转过来的人会懵,觉得怎么组件都不认识了。其实 LangGraph 的核心变化就是:把上下文从一个链里的内部变量,变成了一个显式的、可持久化的状态对象。

4.1 为什么我从纯 Chain 迁移到 StateGraph

我最初用的都是 SequentialChain 这类东西,后来项目要做一个多工具、多轮次、需要人工确认的 Agent,Chain 就撑不住了。原因在于 Chain 的上下文是隐式的、线性的,你很难在中间停下来、保留现场、再继续。一旦有分支、循环、中断恢复,Chain 的写法就变得非常别扭。

LangGraph 的 StateGraph 把"流程"变成了"图",每个节点接收状态、返回状态更新,图的执行由状态驱动。对上下文工程来说,最大的好处是状态可见、可控。你可以在任意节点把当前的 state 打出来看,可以在任意节点做上下文裁剪,可以把状态序列化到数据库,通过 checkpointer 实现真正意义上的跨请求记忆。这是 Chain 时代很难做到的,也是后来我发现"LangChain 入门之后还得学 LangGraph"的根本原因。

4.2 状态定义与上下文裁剪实践

LangGraph 里定义一个对话状态,最常用的是 MessagesState,实际上就是一个带 add_messages reducer 的字典。add_messages 的作用是:当多个节点都要往 state 里加消息时,不是覆盖,而是追加,只有相同 id 的消息才更新。理解这个 reducer,你才明白为什么 LangGraph 里消息列表不会莫名其妙地丢。

真正要处理的还是裁剪。LangGraph 里提供了 trim_messages 工具,你可以按 token 数、消息数或者自定义规则来裁剪历史消息。我常用的写法是保留 system 消息和最近 N 轮对话,更早的用一句话摘要代替,然后把裁剪后的列表作为上下文传给模型。

有一点必须提醒:裁剪的是"送进模型"的消息,不是"状态里存的消息"。在 LangGraph 里,这两者是分离的。状态里可以存完整的执行历史,方便追溯和持久化;但每个节点调用模型时,先基于状态生成一份裁剪后的 messages 再调用。这个分离非常重要,否则你为了控制窗口,把状态里的历史也删了,后面想调试都没法看。我见过有人直接在节点里对 state["messages"] 做切片赋值,结果一个循环跑完,历史全没了。

5. 实战:一个 FastAPI + LangGraph Agent 的上下文设计

理论讲再多,不如来一个能跑的方案。我就以最近做的一个"项目助理 Agent"为例,技术栈是 FastAPI + LangChain + LangGraph,功能是处理用户的项目问题、查询项目进度、调用工具更新任务状态。整个过程里上下文怎么组织,我拆开讲。

5.1 场景与上下文结构设计

这个 Agent 需要做到三件事:一是多轮对话,用户可以说"上回让你查的那个任务,现在状态改一下";二是能调用查询工具和更新工具;三是工具调用结果要能影响后续对话。如果上下文结构设计不好,这三件事会互相打架:历史一长模型就忘,工具结果一多就挤爆窗口。

我的上下文结构分四层。第一层是 system 层,放角色设定、可用工具列表、行为规则,固定不变,每次调用都放最前面。第二层是用户诉求层,放当前这轮的真实问题,放在 system 之后、历史之前的显眼位置。第三层是历史对话层,从状态里取最近的 k 轮,更早的摘要化。第四层是工具结果层,也就是当前轮次工具调用的返回内容,在 Agent 循环里以 ToolMessage 的形式承载,放在用户消息之后、模型生成之前。

这个顺序不是瞎定的。system 在最前,符合模型对指令的注意力偏好;当前用户诉求要突出,防止模型被历史带偏;历史只保留近期关键内容,避免窗口浪费。我实际对比过,同样的模型,这个结构的任务完成率比"一股脑把历史堆在最前面"的方案高不少。

5.2 核心代码与启动过程

我简化一下核心代码结构,保留最关键的部分。

from typing import Annotated, TypedDict from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, ToolMessage, trim_messages from langgraph.graph import StateGraph, START, END from langgraph.graph.message import add_messages from langgraph.checkpoint.memory import InMemorySaver class AgentState(TypedDict): messages: Annotated[list, add_messages] model = ChatOpenAI(model="gpt-4o", temperature=0) model_with_tools = model.bind_tools([query_project_tool, update_task_tool]) def should_continue(state: AgentState): if state["messages"][-1].tool_calls: return "continue" return "end" def agent_node(state: AgentState): # 裁剪上下文:只保留 system 和最近 6 条消息,按 token 上限控制 trimmed = trim_messages( state["messages"], strategy="last", max_tokens=4000, start_on="human", include_system=True, ) response = model_with_tools.invoke(trimmed) return {"messages": [response]} def tools_node(state: AgentState): last_ai = state["messages"][-1] outputs = [] for call in last_ai.tool_calls: result = execute_tool(call) outputs.append(ToolMessage(content=result, tool_call_id=call["id"])) return {"messages": outputs} graph = StateGraph(AgentState) graph.add_node("agent", agent_node) graph.add_node("tools", tools_node) graph.add_edge(START, "agent") graph.add_edge("tools", "agent") graph.add_conditional_edges("agent", should_continue, {"continue": "tools", "end": END})

关键点有三个。一是 trim_messages 必须在调用模型前做,而且要用 include_system=True 确保系统指令不丢。二是 checkpointer,我用 InMemorySaver 做示例,生产环境换成 Redis 或 Postgres 持久化,这样多轮对话之间状态能从数据库恢复,上下文才真正跨请求延续。三是 FastAPI 里的用法:每个会话请求带着 session_id 来,LangGraph 通过 checkpointer 和 thread_id 把不同用户、不同会话的状态隔离。

from fastapi import FastAPI app = FastAPI() @app.post("/chat") async def chat(session_id: str, user_input: str): config = {"configurable": {"thread_id": session_id}} messages = [HumanMessage(content=user_input)] result = graph.invoke({"messages": messages}, config=config) return {"reply": result["messages"][-1].content}

这里有个我踩过的坑:如果在运行时发现同一个线程的历史越来越长,不要在应用层简单"清空会话"。正确做法是在 LangGraph 里对 state["messages"] 定期做裁剪或归档,归档后的长文存数据库,每次组装上下文时只取需要的那部分。这样既能跨请求恢复,又能控制每次调用的窗口。

5.3 从能跑到能上线,我改了什么

第一版代码很简单,直接把所有 messages 丢给模型,结果跑了两轮对话就卡在 token 上限。第二版加了 trim_messages,按最近 N 条裁剪,能跑了,但用户前置的某个信息还是会被裁掉。第三版改成"最近原文 + 更早摘要"的方案,同时把工具输出单独压缩,才算稳定下来。

这个迭代过程说明,上下文工程没有一次到位的方案,必须根据你实际的对话长度、工具返回大小、模型窗口来调。上线之前,我建议你用真实数据压一遍:模拟一个 20 轮以上的会话,把所有上下文指标打出来,看看 token 消耗、裁剪触发次数、模型回复质量,再决定参数怎么定。

6. 上下文类问题排查手册

最后这部分是我长期调试攒下来的问题清单,每个都是实际发生过的,不是教科书式理论。遇到上下文诡异的毛病,先来这里对号入座。

6.1 高频问题定位方法

问题一:模型回答偏了,跟当前用户问题无关。先看历史消息是不是太长、太抢眼。我通常把裁剪后的 messages 打印出来,数一下 system 和当前用户消息在第几个 token 位置。如果当前用户消息排在几千 token 之后,大概率模型已经被历史带跑了。解决:把当前用户诉求提到靠前位置,或者用单独的 prompt 先做一轮"提炼当前意图"。

问题二:上下文明明没超,模型却"忘"了检索到的内容。先怀疑 lost in the middle,看你把检索结果放在哪了。我试验过,放在用户问题之后、紧挨着问题,比放在一大段历史后面要有效得多。这个问题在 RAG 场景尤其明显,检索质量再高,位置不对也会被模型忽略。

问题三:Agent 循环次数多了以后,费用暴涨、响应奇慢。这多半是每轮循环都把完整历史和工具输出塞给模型。我的办法是:每轮循环之前压缩工具输出,并且限制 Agent 最大迭代步数,循环内只保留本轮的中间消息,历史的汇总放循环外。我见过一个简单任务因为循环设计不当,消耗了正常情况 5 倍的 token。

6.2 几个容易被忽略的细节

细节一:Token 计数要按模型走。不同模型的 tokenizer 不一样,你用 tiktoken 数出来的数字,跟 Claude 的 tokenizer 数出来的不一样。做裁剪时,一定要用当前模型的 tokenizer 来数,否则 max_tokens 设置形同虚设。LangChain 里可以根据模型名动态获取对应编码器。

细节二:别把 SystemMessage 放在消息中间。我见过有人把动态生成的"临时指令"作为一条消息插在历史上,结果模型对后面的指令遵从度骤降。临时的额外指令,要么合并进最初的 system,要么放在当前用户消息里,不要打断历史消息的连续性。

细节三:工具描述本身就是上下文的一部分。LangChain 绑定工具时,工具的函数签名和 description 会被序列化进模型可用的工具列表。如果你给每个工具写一大段描述,十几个工具就是上千 token,这会直接挤占你留给对话和检索的空间。所以工具的 description 要写得又准又短,不是越长越好。我在压完工具描述后,整个 Agent 的 token 消耗下降了近两成。

细节四:异步和多线程并存时,状态会串。FastAPI 里 async 接口配合线程池跑 LangGraph,如果 checkpointer 没有做线程隔离,同一个 session_id 的会话可能并发写同一个状态。我目前的方案是给会话状态加版本号,写入前做乐观锁校验,简单有效。别等到线上出现"用户 A 的问题跑到用户 B 的上下文里"这种事故,再回来补这个。

这些坑,基本都是上下文工程里的边界问题。我从一开始觉得"上下文嘛,就是把消息拼起来"到现在每次架构新 Agent,先画状态流转、再定裁剪策略、最后才写业务逻辑,变化背后就是产品上线后被各种上下文问题折磨出来的经验。

如果你现在刚开始做 LangChain 项目,我的建议是别一上来就堆各种高级组件。先把你自己的消息结构和状态模型定义清楚,再逐步加工具、加记忆、加检索。上下文工程做好了,模型才能真的下地干活,否则再好的模型也只是个偶尔聪明、经常掉线的玩具。希望这篇基于我实际经验整理的内容,能帮你少走几圈弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询