我一直觉得,大模型项目做到后期,拼的往往不是模型选得多好、Prompt写得多花哨,而是上下文工程做得到不到位。很多人用LangChain搭应用,最开始只顾着把链子搭起来,跑通一个demo就兴高采烈,结果一上真实场景就露馅:回答跑偏、记忆混乱、Token成本飙升、偶尔还会报错说超了上下文长度。这些问题七成以上都出在上下文管理上。
所以这篇LangChain系列的实战分享,我想专门把"上下文工程"这件事拆开揉碎聊一聊。它不是什么高深莫测的玄学,而是实打实的方法论加工具组合。我写下这些,既是给自己做个沉淀,也希望给正在用LangChain做AI应用、特别是做Agent方向的朋友一些可以直接照抄的参考资料。无论你是刚入门LangChain的新手,还是已经在调教"AI下地干活"的开发者,这个主题都值得仔细过一遍。
1. 上下文工程的核心认知——为什么说"上下文决定上限"
1.1 上下文工程到底在解决什么问题
先说一个我的直观理解:大模型本质上是一个"瞬间记忆"极强的对话者,但它自己不会主动判断该记什么、不该记什么。你给它什么上下文,它就基于什么作答。上下文工程,就是一套系统地决定"给模型看什么、不看什么、按什么顺序给它看"的方法。
很多人以为上下文工程就是"把能塞的都塞进Prompt里",这个想法坑了不少人。模型虽然有几十万Token的窗口,但你把三天前的所有聊天记录、十几份参考资料统统塞进去,它并不会因此变得更聪明,反而会被噪音干扰,注意力被稀释,回答质量直线下降。上下文工程的本质,不是做加法,而是做减法、排序和动态调度——在正确的时机,把最关键的信息,以最清晰的结构,送到模型面前。
LangChain之所以在这个领域特别值得聊,是因为它本身提供了大量现成的组件,专门用来处理上下文生命周期:从PromptTemplate做模板化注入,到ConversationBufferMemory做短期记忆,再到各种文档加载器、检索器做外部知识的筛选和注入。理解整条链路上的上下文流转,比记住某个类的API参数重要得多。
1.2 上下文工程的三个核心维度
我在实际项目中,一般会把上下文工程拆成三个维度来设计。
第一个维度是窗口管理。不管模型窗口多大,Token容量总是有限的,成本和延迟也随Token数上升。窗口管理解决的是"怎么在有限空间里放下最重要信息"的问题。
第二个维度是记忆设计。对话类应用最麻烦的就是"失忆"。用户上一轮说过的偏好、之前确认过的事实、中途改变的需求,模型默认都会忘记。记忆设计决定了哪些信息要跨轮保留、保留多久、存在哪里。
第三个维度是动态检索与注入。项目做大了以后,知识库、文档、历史会话的体积会非常庞大,不可能全都塞进上下文。这时候要借助向量检索、摘要、压缩等手段,把最相关的片段在运行时抽取出来,动态地注入到Prompt里。
这三个维度不是割裂的。做一个真实项目时,你往往是同时设计这三条线,再通过LangChain把它们串成一个完整的上下文流。我下面几个部分就按这三个维度展开,最后再给一个综合实战案例。
2. 上下文窗口管理:从"能放多少"到"该放什么"
2.1 窗口大小的误区与Token成本意识
先说一个很多人踩过的坑:选模型时只看窗口大小,觉得窗口越大越好。是的,大窗口给了你更多操作空间,但空间大不等于你可以乱堆东西。
我们必须先建立Token的成本意识。市面上主流模型的计费都按Token来,你每往上下文里多塞1000个Token,不仅成本多一分,每次请求的响应时间也会肉眼可见地增加。更关键的是,当无关内容占满窗口时,模型对真正重要信息的注意力会被摊薄,专业说法叫"迷失在中间"——因为注意力机制的特性,模型通常对开头和结尾的内容最敏感,对中段内容容易遗忘。
在我做过的一个客服知识库项目里,最开始我把一份完整的操作手册(大约2万Token)直接塞给模型,让它对应用户问题作答。结果模型经常引用错章节,甚至编造出手册里不存在的内容。后来换了策略,先做检索再注入,每次只放相关片段(约1500到2000Token),准确率反而大幅提升,成本也降了一半多。
2.2 LangChain里的窗口裁剪与Token计量
LangChain提供了get_num_tokens这一类工具来预估Token消耗(底层调的是tiktoken),但它给出的估算值只是一个参考,不同模型家族的实际分词规则略有差异。我建议在正式环境里不要卡着最大Token数去设计,留出20%到30%的余量,否则一旦遇到多轮对话中用户输入变长,很容易触顶报错。
我自己常用一个简单的"三层裁剪"策略:
- 第一层:设定硬上限。按照模型窗口的70%设定一个阈值,任何时刻注入的上下文总量不能超过这个数。
- 第二层:按优先级排序。外部资料排在检索结果范围内,按相关度从高到低放;历史对话只保留最近几轮加摘要;系统指令永远放最前。
- 第三层:动态压缩。如果上述内容加在一起还是超限,就触发摘要压缩逻辑,把较早的对话浓缩成几句话。
在LangChain里,ConversationSummaryBufferMemory就是用类似思路设计的组件,它综合了缓冲和摘要两种策略,超过阈值之后自动触发摘要。我后面第三节会细讲。
2.3 系统指令的位置和权重
窗口管理还有一个容易忽略的细节:系统指令放在哪。前面提到,模型对上下文首尾的关注度最高,所以系统指令要放到Prompt最开头,核心业务规则尽量靠前;检索到的支撑性资料可以放中间偏后一点;当前轮的用户问题放最末尾。这样安排是顺应注意力机制的特点,让最重要的指令占据首尾的高注意力区域。
我有一次调试一个Agent应用,发现模型老是忽略我设定的"必须先查数据库再回答"的规则。排查了半天,发现是因为我把这条规则埋在了大段业务文档的中后段。把规则移到系统指令的第一段之后,问题立刻消失了。别小看这个位置调整,很多看似诡异的模型行为,根因就在上下文的排版顺序上。
3. 记忆系统设计:让AI会话不"失忆"
3.1 短期记忆与长期记忆的边界
记忆设计是最能拉开项目质量差距的地方。先理清概念:短期记忆和长期记忆,不是用同一个组件就能搞定的。
短期记忆指的是当前会话窗口里需要持续保留的信息,比如这轮对话中用户提过的偏好、确认过的选项、刚给过的约束条件。在LangChain里,这类记忆往往通过ConversationBufferMemory、ConversationBufferWindowMemory这类内存组件来实现。
长期记忆则是跨会话持久化的信息,比如用户上次留下的偏好设置、历史订单信息、某个项目的长期背景。自建应用时,长期记忆一般要落到外部存储(数据库、向量库、Redis之类),在下一次会话开始时通过检索灌入上下文。
很多新手容易犯的错误,是把所有历史一股脑塞进BufferMemory。短期记忆组件只是帮你把"当前轮对话"暂存起来,并不负责筛选。如果一场对话持续了三十轮,BufferMemory产出的历史文本就能轻松超过上下文上限。这时候要么限制窗口轮数,要么做摘要压缩,要么把部分信息转存到长期记忆。没有一套策略干到底的银弹,只能按场景取舍。
3.2 LangChain记忆组件的取舍与实测
LangChain的记忆组件有好几个,我挑几个常用的讲下适用场景。
ConversationBufferMemory最简单,完整保存每一轮问答。它适合对话轮次少、要求高保真的场景,比如短会话的售前咨询。缺点也很明显,对话一长,会话历史体量急剧膨胀,非常容易超Token限制。
ConversationBufferWindowMemory只保留最近K轮。它的思路是"太早的话不用记",适合闲聊类、指令类应用。但要注意,K设小了,用户中途提过的关键信息会丢失;K设大了,成本又上去了。一般K取5到8轮比较均衡。
我实际用得最多的是ConversationSummaryMemory。它把历史对话做摘要再存下来,但是会把很多细节丢掉,所以适用于需要保留"主线信息"而不是"逐字记录"的场景,比如项目复盘助手、长周期协助类Agent。
还有ConversationSummaryBufferMemory,结合了Buffer和Summary两种策略:在Token阈值内保留原始对话,超过阈值就触发摘要。这算是短期记忆里比较省心的方案,也是一般项目起步时的推荐选择。
还要多说一句,LangChain的记忆组件在较新版本里经历了不小的变动,ConversationBufferMemory这类老组件有些已经标记为遗留状态,官方更推荐用BaseChatMessageHistory配合RunnableWithMessageHistory,或者干脆做一套自定义的存储方案。我个人建议是,如果你搞清楚了上述每种记忆策略的取舍,用哪个类其实只是技术细节,换接口的成本很低。
3.3 用外部存储搭建真正可用的长期记忆
真正能支撑生产环境的长期记忆,大多得自己搭。LangChain官方的RedisChatMessageHistory、PostgresChatMessageHistory都提供了接入方式,方便把历史会话落到数据库里。但这里有个关键点:持久化只是第一步,更重要的是检索策略。
我做一个企业知识助手的时候,设计了一套"标签+向量"双通道的长期记忆方案。用户的关键信息(比如"偏好简明回答""关注成本控制")被打上标签存到结构化字段里;每次会话启动时,初始化Prompt会把这些高优先级标签注入系统指令;同时,历史会话的语义向量存在向量库里,当用户提到相关内容时,通过ConversationalRetrievalChain检索出历史片段动态注入。
这样设计的好处是:高频强约束信息常驻上下文(占空间很小),低频弱相关信息按需检索出来(不占常驻空间)。既保证了模型不会忘记用户的核心偏好,又避免了历史越积越大导致上下文爆炸。
4. 提示词模板与上下文构建:让每一粒Token都花在刀刃上
4.1 PromptTemplate里的变量注入与格式陷阱
LangChain的PromptTemplate是上下文工程里最不起眼却最常用的工具。很多人觉得它就是个字符串模板,但实际上它承担的任务是:把动态信息稳定、结构清晰地注入到Prompt指定位置。
模板设计有一条核心原则:结构与信息分离。固定的系统指令、业务规则、输出格式说明,写成模板里的常量部分;用户问题、检索结果、历史摘要、动态参数,通过变量注入。这样一来,不同来源的信息各归其位,后续做裁剪和格式微调时也不用手忙脚乱。
我举个具体例子,一个多轮对话的模板结构大概长这样:
system: 你是企业内部IT支持助手。请严格遵循以下规则: 1. 答案优先引用知识库内容 2. 如果知识库无相关内容,明确说明"未找到对应资料" 3. 回答不超过200字 context: {retrieved_docs} history: {conversation_history} user: {current_question}这里面retrieved_docs来自检索器,conversation_history来自记忆模块,current_question是当前用户输入。模板把三块信息放在不同区域,既方便模型区分信息来源,也方便我们在注入前对每一块分别做裁剪。如果这三块内容挤在一起没有一个清晰边界,模型就容易把检索到的资料误当成用户的话,或者把历史对话当成当前指令。
4.2 上下文压缩:别做搬运工,要做精炼师
除了模板注入,上下文工程里一个高阶技巧是上下文压缩。LangChain里有ContextualCompressionRetriever,它的思路挺有意思:先用常规检索器拉回一堆候选文档,再用一个小模型(通常是LLM)对候选内容做压缩和提炼,只保留和当前问题真正相关的句子。
这个方法在知识库问答里特别实用。比如一个关于产品故障排查的问题,检索器可能拉回五篇文档共3000Token,但真正和故障A相关的只有三句话。直接把3000Token塞进去,不仅浪费窗口,还会让模型被其他故障的排查步骤干扰。用了压缩检索器之后,可能只保留200Token的核心内容,效果却反而更精准。
不过这里也有个代价:每轮检索都要额外调用一次小模型做压缩,延迟和成本都会上升。所以我会建议对性能敏感的场景慎用,或者把压缩逻辑放到后台异步完成、缓存结果。对于离线分析、管理后台这类对延迟不敏感的场景,可以放心用。
4.3 结构化输出与上下文引导的联动
上下文工程还有一个很容易被漏掉的应用:用上下文引导模型输出结构化内容。LangChain的StructuredOutputParser或较新版本中的with_structured_output就是为了解决这个痛点。它会在Prompt里注入目标格式的说明,并要求模型以JSON等格式返回。
看起来这只是输出解析问题,但它和上下文设计强相关。因为解析器注入的格式说明会占用Prompt空间,而你自己的业务指令、示例数据同样要占空间。格式说明写得过于冗长,反而会把业务信息的比重挤下去。我的经验是,给模型做"少样本示例"时,例子不要多,两三个足够;格式说明尽量精简,把"必须输出JSON"和"字段含义"写清楚就行,其余的交给模型发挥。你注入的上下文越能直击任务本质,模型的输出就越稳定。
5. 实操:一个完整的多轮客服Agent上下文链路实现
5.1 场景设计与整体上下文流
理论讲了半天,我分享一个自己做过并持续调优的项目:面向企业IT部门的内部客服Agent,用一个FastAPI服务承载,底层用LangChain组织逻辑,会话状态用LangGraph管理。用户可以在网页上直接提问,比如"打印机连不上公司Wi-Fi怎么办""申请软件的流程是什么"。
这个场景对上下文工程的要求有几个特点:需要多轮对话澄清问题细节,需要参考知识库的具体章节,还需要在回答中带上当前用户的部门、权限等级等信息。如果这些信息组织不好,模型很可能给出一个通用但不贴合实际的回答。
我设计的上下文流是这样的:
会话启动时:从用户系统拉取身份信息(部门、角色、历史工单数),组装进系统指令开头。这部分是常驻信息,占据约300Token。
每轮用户提问时:先做问题改写(把"它坏了"这种指代不明的说法结合历史改成完整问题),然后用改写后的问题去知识库做向量检索,取Top3相关片段。每个片段限制在500Token以内。
历史会话处理:当前会话最近5轮对话保留原文,更早的对话由摘要模块压缩成一段背景说明。
组装Prompt:最后按"系统指令+身份信息+知识库片段+历史摘要+最近对话+当前问题"的顺序拼装,并对总Token做检查,超限时按优先级砍掉知识库冗余片段。
5.2 关键代码链路与参数调优
在LangChain里,这个链路可以组织成这样一个简化版本(我隐去了部分工程细节):
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_community.chat_message_histories import RedisChatMessageHistory from langchain_core.output_parsers import StrOutputParser prompt = ChatPromptTemplate.from_messages([ ("system", "你是IT支持助手。用户身份信息:\n{department}\n{role}\n必须遵循:{rules}"), ("system", "以下是检索到的知识库内容:\n{docs}"), MessagesPlaceholder(variable_name="history"), ("human", "{input}"), ]) chain = prompt | model | StrOutputParser() chain_with_history = RunnableWithMessageHistory( chain, lambda session_id: RedisChatMessageHistory(session_id, redis_url="redis://localhost:6379/0"), input_messages_key="input", history_messages_key="history", )这里有个值得注意的地方:MessagesPlaceholder是LangChain里处理聊天历史的标准接口,它会自动把历史消息列表插入到指定位置。所以构建history时,我们要做的是保证历史消息类型正确——用户消息用HumanMessage,助手回复用AIMessage,中间的系统消息不要混进来,否则模型对消息角色的判断会乱掉。
至于参数调优,我踩过几次坑后总结出三个关键点:一是temperature,客服场景取0到0.2比较稳,高了容易发挥过头;二是知识库检索的top_k,我这边取3最均衡,取多了后被压缩的难度增大,取少了则经常搜不准;三是历史窗口轮数,综合成本和准确率,最近5轮原文加历史摘要的模式最好。这些参数在不同项目里最优值不一样,但调整方向是一致的——先保证上下文干净,再去调模型能力。
5.3 LangGraph会话状态里的上下文管理
如果项目只是简单的"问一句、答一句",上面的链路已经够用。但做Agent类应用时,比如让模型自主决定要调用知识库、要查数据库、要发起一个工单,问题就复杂了。这种场景我用LangGraph来管理状态,因为它的节点之间天然存在状态传递,而上下文就藏在这份状态里。
LangGraph的一个核心概念是State,它会在各个节点之间传递。我的做法是把系统指令、用户身份、检索结果、历史摘要都放进State的字段里,每个节点读取自己需要的部分、更新自己负责的部分。比如"检索节点"只负责往State里写docs字段,"回答节点"从State里组装好Prompt再调模型。
这里要特别提醒一个LangGraph的坑:默认的State模式是覆盖写,如果一个节点返回的字段覆盖了其他节点刚写好的值,后面节点就看不到被覆盖的数据了。所以涉及多节点协作时,要么用add_messages这类合并操作符,要么让每个节点只返回自己独有的键。我第一次搭多节点Agent时就在这里栽过,排查了半天才发现是State覆盖导致上下文丢失。
6. 常见问题与故障排查实录
6.1 上下文"漂移"导致回答越来越偏
最典型的问题:对话进行到第10轮之后,模型开始重复之前已经回答过的问题,或者突然忘记用户最开始提的需求。这种就是上下文漂移,通常是因为历史信息在窗口里被挤占或顺序错乱。
排查思路分三步。第一步,先打印出当前Prompt完整内容,看看历史消息是否完整、顺序是否颠倒;第二步,检查历史消息的role是否正确,有没有把用户消息和助手消息位置搞反;第三步,检查记忆策略,如果用的是窗口记忆,确认K值是否小到把关键信息丢掉了。
我见过一个很隐蔽的案例:某项目用ConversationSummaryBufferMemory,摘要触发阈值设得太低,导致早期信息被过早压缩成了摘要。模型读到的是"用户之前问了某个问题,已解决",但具体错误代码、设备型号全在原始内容里,一压缩就没了。这其实是"摘要策略"和"事实保真"之间的矛盾。我的建议是,涉及具体编号、版本号、金额这类硬信息的场景,不要轻易用摘要压缩,宁可多占一点Token也要保留原文。
6.2 Token超限:报错信息与应急降级
另一个高频问题是模型报maximum context length exceeded。报这个错,通常不是单纯"内容太多",而是你某一环的注入逻辑没控制住。常见原因有:长文档检索结果直接原样塞入、历史对话无限累积、系统指令里拼入了大段重复数据。
我的应急处理流程是:首先,检查Prompt各段Token占比,确认哪一段是罪魁祸首(可以在代码里按段打点算Token);其次,给各段设置独立的长度上限,比如检索结果硬上限1500Token,历史摘要硬上限500Token;最后,在链路最外层加一个兜底逻辑——如果总Token还是超限,就把检索结果按相关度截断到只剩最相关的一段,宁可让答案不完美,也不要让请求直接报错。
另外,缓存也是降本增效的好手段。把同样问题+同样上下文的问答结果缓存起来,不仅能省费用,还能避免重复命中同样的超限问题。我在项目里会给每个会话做一个内容哈希,命中缓存直接返回,实测能省三成左右的Token消耗。
6.3 上下文里的敏感信息泄漏风险
最后必须聊一个很多人忽略的问题:上下文里塞了什么东西,模型就能看到什么东西。如果你把用户身份证号、内部系统密码、未公开的业务数据放进Prompt里,那这些信息在每次请求时都会被发送给模型服务商。这在企业内部应用里是个大忌。
我的做法是三层过滤:第一层,入参清洗,用户输入里敏感字段先脱敏再入Prompt;第二层,检索结果过滤,给知识库文档打上密级标签,高密级文档不出现在低权限用户的检索结果里;第三层,输出检查,在返回用户前对模型输出做一次关键词匹配,发现敏感模式就拦截重写。上下文工程不只是"怎么放得下、放得准",还要管住"什么不能放"。
7. 我自己踩过几次坑之后的几点心得
写到这里,标题里"上下文工程"这件事能聊的,基本都过了一遍。最后分享几个比较个人化的体会,也算给这篇做个收尾。
第一,上下文工程没有"一劳永逸"的配置。不同模型、不同场景、不同数据规模,最优策略都不一样。我现在的习惯是每次改动都做AB对比,把Prompt分段的Token占比、首轮回答准确率、超限报错率这些指标记录下来,用数据说话,而不是靠感觉调参。
第二,先设计好上下文流,再动手写代码。很多人(包括以前的我自己)是一上来就写chain = prompt | model,跑通了再考虑记忆、检索、压缩这些事,结果后面改起来牵一发动全身。其实花半天时间把"系统指令放哪、历史怎么存、检索结果怎么切、超限怎么办"画清楚,后面能省好几天的返工。
第三,上下文工程和Agent是天然绑定的。LangChain发展到现在,Agent相关的组件(比如LangGraph)越来越多地接管了"调度"这件事,而任何调度最终都离不开上下文的状态传递。如果你想做的项目是那种"AI真的要下地干活"——不是简单问答,而是让模型自己决定调用什么工具、按什么顺序执行——那上下文工程就是你绕不开的基本功。先把这条基本功练扎实,再上复杂Agent架构,你会顺手很多。
这篇文章讲的都是我自己在实际项目里验证过的做法,不一定放之四海而皆准,但方向肯定是通的。如果你在跑LangChain项目时也遇到过上下文方面的疑难杂症,欢迎顺着这些思路去排查,很多时候问题就藏在Prompt的某一段角落里。