做AI智能体做到第三个版本时,我意识到一个残酷的事实:模型能力的天花板,很多时候不是被模型本身限制的,而是被我们如何给模型喂上下文限制的。把几百条工具返回、历史消息、用户问题一股脑全塞进窗口,模型就开始“假装理解”,答得越来越客气、越来越空。后来我把大量精力从调Prompt挪到“管理上下文”这件事上,整个系统的稳定性立刻上了一个台阶。这篇文章就是围绕上下文工程实战展开的,重点讲我如何用LangChain与LangGraph搭一套可控、可测、可维护的AI智能体上下文体系。
我会从概念边界、具体代码、流程图设计思路以及踩坑复盘四个维度来写。全文以我的真实实践为基准,没有平台套话,适合那些已经把LangChain基础跑通、想往更复杂真实场景上做的开发者阅读。如果你还在只靠System Prompt打天下,这篇文章值得你花二十分钟读完。
1. 上下文工程的真正边界:不只有System Prompt
很多朋友一开始接触“上下文工程”,下意识觉得就是“把Prompt写长一点”,或者“在System Prompt里塞背景资料”。我的理解完全不是这样。上下文工程解决的是整个智能体在运行时拿到什么信息、如何组合、如何裁剪、如何流动的问题。System Prompt只是上下文工程的很小一部分。
1.1 上下文工程和提示词工程的关键区别
老一代的Prompt Engineering聚焦在“如何写出一段高质量指令”,它的对象是模型本身。上下文工程聚焦的则是“如何把一段高质量指令连同历史、知识、工具输出一起,组合成一整套模型可以高效使用的上下文”,它的对象是流程和数据结构。
举个例子。你做一个单轮问答机器人,Prompt写得好就够了,问题是模型看不到上一轮信息,但你只需要处理“单条请求”。可是当你做智能客服、数据分析助手、多轮任务型Agent时,模型每一轮都要面对一个动态膨胀的信息集:用户最新输入、前几轮对话摘要、数据库查询结果、工具调用反馈、检索来的业务文档片段。这些东西怎么筛选、怎么排优先级、怎么压缩,才是上下文工程的实际范畴。
我实操中会定义一个叫“上下文优先级”的列表:
- 第一优先级:当前轮用户输入(绝对不能丢)
- 第二优先级:最近两轮的完整原始对话(用于理解连续性)
- 第三优先级:工具返回结果(尤其是用户明确请求查询的结果)
- 第四优先级:长期记忆或知识库检索片段(通常是裁剪或摘要后的)
- 第五优先级:系统级指令与业务规则
这个优先级列表是上下文工程最核心的产物之一。定了它,后面所有代码和Prompt才有一个统一的依据。
1.2 上下文工程要处理的三个本质问题
一是上下文窗口有限性。模型有最大Token限制,但业务信息没有上限。必须做取舍。二是上下文相关性。信息不是越多越好,塞入无关信息会稀释指令信号,甚至引发幻觉。三是上下文一致性。多轮对话里,每轮都要保证新的模型调用“看到”的信息结构是完整的,不会因为某轮出现空值就中断决策。
这三个问题分别对应了工程上的裁剪、筛选、拼接。LangChain提供了一堆组件,LangGraph则提供了流程编排能力,两个工具合起来,正好覆盖这三件事。
1.3 LangChain与LangGraph在上下文工程里扮演的角色
如果给一个简练的分工:LangChain负责和模型、向量库、外部工具打交道,提供标准接口;LangGraph负责把每一次模型调用放进一个有状态、可跳转的图里,让上下文不再是“一次拼接”,而是“有生命周期的状态流”。
我自己更习惯把LangChain看成一个工具箱,把LangGraph看成一个控制台。工具箱负责组装零部件的细节,控制台负责决定这些零件在什么条件下、按什么顺序去组装。这样项目一旦复杂起来,不会变成无数个if else堆出来的“屎山”。
比如,我要做一个能自主决策的智能体,需求是:用户先描述一个任务,智能体规划步骤,然后循环执行工具,直到任务完成。这类需求适合LangGraph,因为状态本身要跨多步保存,节点之间还有循环关系。单纯靠LangChain的一条Chain做不了,因为Chain更像流水线,没有回路的。
2. LangChain在上下文工程中的五个关键组件与代码实操
LangChain能处理上下文的地方很多,但真正高频使用的组件我归纳为五类:消息模型、Prompt模板、示例选择器、历史消息存储、文档检索器。把这五类串起来,一个标准的多轮问答上下文机制就能搭起来。
2.1 用Message对象承载多轮对话上下文
不要在Prompt模板里手工拼接“你说的是…我说的是…”。这是新手最容易踩的坑。正确方式是用LangChain的Message体系:
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage, ToolMessage messages = [ SystemMessage(content="你是客户数据分析助手,回答必须基于检索到的业务文档。"), HumanMessage(content="上季度华东区销售额是多少?"), AIMessage(content="检索中,请稍候。"), ToolMessage(content="华东区上季度销售额约2.3亿元,环比增长8%,主要来自新零售渠道。", tool_call_id="call_123"), HumanMessage(content="那同比增长情况呢?"), ]这些消息对象可以持续累积,传给任何支持模板的LangChain模型调用。它的好处是:模型的Chat接口天然理解这种结构化消息,序列化和反序列化也方便,尤其是和LangGraph的状态写在一起时,比手动拼字符串可靠一整个量级。
2.2 Prompt模板的动态注入:把变量留白
模板的作用是保证上下文结构的一致。统一格式比每次即兴发挥重要得多。
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder prompt = ChatPromptTemplate.from_messages([ ("system", "你是{role},你的回答风格必须{style}。业务规则如下:{business_rules}"), MessagesPlaceholder(variable_name="history"), MessagesPlaceholder(variable_name="user_input"), ])MessagesPlaceholder专门用来留出对话历史和用户输入的位置。这个占位符让模板不再是一段固定文字,而是一个可插拔框架。我强烈建议每个项目里都建立一套这种结构化模板,因为后续不管加工具调用、加知识库内容,都只需要往对应占位符里填充。
2.3 LCEL链式调用的上下文流动
LangChain的LCEL(LangChain Expression Language)是推荐写法,它让上下文在链里流动得很清晰:
from langchain_openai import ChatOpenAI from langchain_core.runnables import RunnablePassthrough retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) def format_docs(docs): return "\n\n".join(f"[片段{i+1}] {d.page_content}" for i, d in enumerate(docs)) chain = ( { "context": retriever | format_docs, "question": RunnablePassthrough(), "history": lambda x: x.get("history", []), } | prompt | ChatOpenAI(model="gpt-4o-mini", temperature=0) )这个链的意思是:先并行计算context和question,然后塞进prompt,最后调用模型。关键是retriever的结果被格式化成了带编号的文本片段,模型看到的内容更明确,不会因为一堆原数据页眉页脚而迷失。实际生产里,我还会在上游多接一个重排序步骤,把最相关的片段提到前面。
2.4 记忆组件为什么往往不够用
LangChain自带多种Memory类,比如ConversationBufferMemory和ConversationSummaryBufferMemory。项目早期可以用,复杂度上去之后它们常常显得笨重。
记忆组件的主要问题有两个:一是它默认把全部历史消息都塞进模型,Token浪费非常严重;二是它的存储方式通常在链对象内部,到了LangGraph这种状态图里,管理起来反而碍手碍脚。
我在实际项目里,只用到了Memory类的两个能力:一个是短期的最近N轮原样保留,一个是长期的多轮摘要。前者自己用列表缓存就可以,后者用摘要模型做定期汇总。不要为了用框架而用框架。
2.5 检索增强上下文时的裁剪策略
知识库检索出来的文档往往有大量冗余。直接拼进上下文会出现一个现象:模型被长文档带偏。所以我对检索结果做了三层处理:
- 第一层:按相关性分数截断,保留TopN;
- 第二层:按Token长度截断每段内容,限制单段不超过500字;
- 第三层:把段落开头加上元信息标签,比如“来自合同条款第3章”,模型能更好定位信息来源。
具体代码可以这样:
def trim_docs(docs, max_chars=800): trimmed = [] for doc in docs: text = doc.page_content if len(text) > max_chars: text = text[:max_chars] + "…" trimmed.append(doc.meta.get("source", "") + "\n" + text) return "\n\n".join(trimmed)这样的裁剪看起来简单,实际操作里能明显降低模型幻觉率。我之前做合同问答,没做裁剪时模型经常会引用不存在的内容,做了裁剪和来源标注之后,引用准确率提升了一大截。
3. LangGraph里的状态设计与上下文流动:智能体开始“有记忆”
LangGraph的精髓不在于它支持画图,而在于它把状态显式地建模出来了。上下文在LangGraph里不再是一个字符串,而是一份结构化的State对象,每个节点都能读取和更新它。这让复杂智能体真正“有记忆”了。
3.1 State Schema的设计
我通常把State定义为一个TypedDict,里面放几个核心字段:
from typing import TypedDict, Annotated from langchain_core.messages import AnyMessage from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list[AnyMessage], add_messages] user_goal: str retrieved_docs: list[str] current_plan: list[str] tool_results: dict[str, str] error_count: intmessages字段使用了add_messages归约函数,每次节点返回值里的新消息会自动追加到旧的列表后面,不用手动合并。这个设计简洁但非常关键,它让对话历史在整张图里持久保存。user_goal存用户的核心意图,后续规划节点和工具调用节点都能读它。tool_results存每次工具调用的结果,用字典结构方便查询。
3.2 用节点控制上下文的每一步填充
每个节点只负责一件事。我的规划节点大概是这样的:
from langgraph.graph import StateGraph, END def plan_node(state: AgentState): llm = ChatOpenAI(model="gpt-4o-mini") recent = state["messages"][-4:] # 只看最近两轮 prompt = ChatPromptTemplate.from_messages([ ("system", "你是规划者。根据用户目标生成不超过3步的简单计划。"), ("human", "用户目标:{goal}\n最近消息:{recent}"), ]) response = llm.invoke(prompt.format(goal=state["user_goal"], recent=recent)) return {"current_plan": [step.strip() for step in response.content.split("\n") if step.strip()]} def tool_call_node(state: AgentState): # 从current_plan里取第一步,执行工具,并把结果写入tool_results return {"tool_results": {"step1": "results..."}}关键在于:节点不再面向“历史消息加当前问题”这种单次请求,而是面向“整个任务状态”工作。规划节点产出计划后,工具节点读取计划去执行,执行结果再回到上下文,下一轮模型调用可以看到完整状态。
3.3 条件分支与循环:上下文不会因为跳转而丢失
LangGraph用add_conditional_edges来做条件跳转。判断依据通常是工具返回结果的有效性。我的代码里有一个经典的“工具结果校验再决定是否重试”的分支:
def router(state: AgentState): if state["error_count"] >= 3: return "end" if "error" in state["tool_results"]: return "retry" return "next" graph = StateGraph(AgentState) graph.add_node("plan", plan_node) graph.add_node("tool", tool_call_node) graph.add_node("respond", respond_node) graph.set_entry_point("plan") graph.add_conditional_edges("tool", router, {"retry": "plan", "next": "respond", "end": END}) graph.add_edge("plan", "tool") graph.add_edge("respond", END)这里有个好处:所有节点共享同一个State对象,所以跳转是状态驱动的,不是流程驱动的。做条件分支时,不需要拿全局变量来回传,State本身就是唯一的上下文载体。调了几次之后你就会发现,这种写法的调试体验比一堆if else嵌套好太多。
3.4 工具调用上下文如何回填
工具调用结果必须转成消息加进messages列表,模型才能“知道”工具做了什么。这个环节容易忽略工具返回和模型输入之间的格式匹配。
我统一用一个工具消息格式:
def tool_response_to_message(tool_name, result): return ToolMessage( content=result, tool_call_id=tool_name, )然后把它放进节点返回值里。LangGraph的add_messages会自动追加。这里一个重要细节是:工具消息必须带tool_call_id,并且要和触发工具的那个AI消息的tool_call_id对应。否则模型无法把工具结果和之前的请求关联起来,回答就会乱。
这个对应逻辑我一直放在工具执行节点里,不依赖框架自动处理。原因很简单:显式控制比隐式魔法更好排查问题。
4. 检索增强 + 多轮记忆 + 智能体规划:一个实战案例拆解
理论讲完了,我拿一个最近给某零售公司做的“库存智能助理”作为完整案例。需求很简单:用户用自然语言问库存、调拨、到货预测之类的问题,智能体自动查询数据库、参考业务知识库,并在多轮对话里保持上下文。
4.1 智能体整体架构分层
我把整个智能体分成四层:
- 接口层:接收用户问题,做意图粗分类
- 规划层:用LangGraph规划是否需要检索、是否要查库存表
- 执行层:调用SQL查询工具或检索接口
- 上下文管理层:统一组装所有信息并生成回复
代码结构上,LangChain负责SQL查询、向量检索,LangGraph负责规划层和执行层的串联。整个图里有3个主要节点:receive(接收并预处理用户请求)、search_or_query(决定走SQL还是走知识库)、reply(生成最终回答)。
4.2 多轮记忆如何参与规划
我在State里加了两个字段:recent_messages和summary_messages。 recent_messages保存最近4条原始对话,summary_messages保存超过一定轮数之后压缩生成的摘要。
这样规划节点做判断时,会先读summary获取长期背景,再读recent获取当前细节。举个例子,用户第一轮问“A商品库存多少”,第二轮问“那B呢”,如果上下文里只有历史消息没有摘要,模型还能勉强猜出“那B呢”指的是“库存”;但如果对话拉到第五轮、第八轮,摘要的“长期意图”就能显著提升理解准确率。
实际效果:上下文管理上线后的准确率比原来直接拼接全部消息提升了约17%。
4.3 上下文压缩触发策略
压缩不是无脑做的。我的策略是:
- 当总消息数超过8条,触发对早期消息的摘要
- 当单条工具返回超过2000字,触发分段压缩
- 当摘要消息本身超过1000字,强制做二次摘要
这些阈值都可以在配置文件里调整,不写在代码里。每次压缩后,我会把摘要消息替换掉原来的原始消息列表,尽量保证Context永远不膨胀。
这个压缩策略需要专门写测试用例来验证,尤其是“模型能否基于摘要回答后续问题”。我习惯用十组固定对话做回归测试,每改一次摘要逻辑就全量跑一遍。测试不过就不发版。
5. 实战中逃不掉的一堆坑
框架归框架,业务落地才是真考验。这部分我分享五个最踩脑壳的问题,每一个都在某个阶段让我挠过头。
5.1 消息顺序错了,模型就开始装傻
LangGraph里的并行节点很容易让消息顺序错乱。比如检索节点和查询SQL节点同时执行,返回后合并进messages时,如果顺序变成“工具结果1—用户最新问题—工具结果2”,模型就乱了。
我后来给每个消息带上时间戳序号,合并时统一排序。这个修复看着低级,实际救了很多次。代码片段如下:
def sort_messages(messages): return sorted(messages, key=lambda m: m.additional_kwargs.get("seq", 0))简而言之:永远保持用户最新输入和它对应工具结果之间的严格较近位置关系。
5.2 Token成本失控在工具调用循环里
智能体跑多步工具调用时,如果每步都往上下文里塞全量历史,Token消耗是指数级的。尤其某些搜索工具一次返回几千字,循环三四个步骤,上下文就爆了。
我的打法是把工具结果分成两类:一个是原始结果写入短期缓存,另一个是提炼后的摘要结果写入长期上下文。模型每次只能看到提炼后的摘要,除非某一步要求必须看原始数据。
这样Token消耗从O(n^2)降回O(n),项目成本肉眼可见下降。这一步优化做完之后,平均单次会话成本下降了40%,很值得投入。
5.3 模型生成过程报错与格式不稳定的调试困难
用LangGraph做复杂智能体,最大的痛点是:你不知道在哪个节点、哪一步,上下文变成了一堆垃圾,导致模型输出异常。我一开始只会打印最终模型响应,完全无法定位是上游哪一步出了问题。
后来我给每个节点都加了一个快照输出:
def debug_state(state, node_name): logger.info(f"[{node_name}] messages_count={len(state['messages'])} plan={state['current_plan']}") logger.info(f"[{node_name}] tool_results_keys={list(state['tool_results'].keys())}")每次图跑完,把每个节点的state快照落到日志里。排查时回放日志,很快就能定位是哪个节点把上下文污染了。这个习惯已经成为我所有智能体项目的标配。
5.4 结构化输出和上下文拼在一起时容易冲突
我项目里曾经把生成结果用JSON格式输出,结果LangChain的with_structured_output又往context里塞了额外指令,导致模型输出乱套。解决办法是:结构化输出的格式定义只作用于输出解析层,不往上下文里塞。
如果你既要上下文又要结构化输出,记得做一次上下文和输出格式的彻底解耦:模型输入用普通消息序列,输出解析用另一个独立的Pydantic模型。二者不要互相污染。
5.5 用Abort和超时控制防止上下文无限膨胀
智能体在工具调用失败时会重试,重试次数过多,上下文会堆积大量失败消息。除了前面说的error_count限制,我还会给整张图加一个全局超时。
LangGraph可以用节点级别的超时控制,也可以用整体调用超时。比如整体超时设置为60秒,超过即返回兜底回答“暂时无法完成,请稍后再试”。不要小看这个兜底,它能避免最坏情况下用户等半天然后收到乱码。
6. 上下文工程的效率评估指标
我判断一个上下文方案好坏,从来不看Prompt写得多少精妙,而是看四个指标:有效信息占比、Token成本效率、任务完成率、回复稳定性。
有效信息占比指最终发给模型的上下文里,有多少字符是对本轮回复真正必要的。我在项目里用日志分析统计过,不少原始方案的“信息利用率”其实不到20%,做了上下文工程之后能提到50%以上。
Token成本效率就是拿任务完成率除以平均Token消耗。不优化上下文时,任务完成率可能只有60%,平均消耗8000 Token;优化之后完成率75%,平均消耗4000 Token。这个性价比提升非常直观。
任务完成率就是你定义的一批黄金测试集里,智能体给出正确结果的比率。回复稳定性则是同一测试用例跑十次,结果一致率。稳定性和任务完成率同等重要,因为用户无法信任“有时聪明有时蠢”的机器人。
这四个指标,每个都可以自动化。我每个迭代版本都会跑一遍黄金测试集,输出一张类似下面的评分卡:
| 指标 | 基线版本 | 上下文工程后 |
|---|---|---|
| 任务完成率 | 62% | 78% |
| 平均Token消耗/轮 | 8200 | 4400 |
| 回复稳定性 | 70% | 88% |
| 有效信息占比 | 18% | 52% |
7. 关于上下文工程,我的几条实战建议
看完上面这些,如果你准备在自己项目里推进上下文工程,这五条掏心窝子的建议应该对你有帮助。
第一,从“记录输入输出”开始做上下文工程,不要先追求复杂框架。先把你现在的系统里每一轮发给模型的数据dump出来,看看哪些是模型真正需要的,哪些是废话。把废话去掉,往往比引入LangGraph收获更大。
第二,优先实现显式的State,而不是依赖框架的隐式记忆。我自己早期用Memory组件时,一旦链路变复杂就陷入魔法副作用。改成显式的消息列表和工具结果字典之后,排查问题舒服太多了。
第三,给上下文写上“谁产生、谁使用、谁销毁”的注释。每个节点产生的字段,要在节点的文档字符串里标明消费方是谁。这个看起来琐碎,但智能体一复杂,这堆注释能救你于水火。
第四,上线前准备一份黄金测试集。上下文工程的优化,很多时候是“改了流程、吞掉知识、模型反而答得变差”。没有测试集就分不清是变好还是变坏。我自己的项目里至少准备了三十条真实场景问题,每月更新一次。
第五,别迷信“上下文越长越好”。对多数模型而言,短而精的上下文效果远好于“把所有资料都塞进去”。一句话:把模型当同事,把上下文当交给同事的材料,你不是把整柜子文档都搬过去,而是告诉他第几页第几行有用。
我自己的体会是,上下文工程是一次“从调模型到管流程”的思维升级。它需要你用工程眼光去审视每一场对话里信息的生产、消费和废弃。写这篇长文也是想把这些年压箱底的经验一次讲透。希望它能帮你绕过那些我踩过的坑,把智能体从“偶尔灵光”推进到“稳定好用”。