资讯详情

资讯详情

上下文工程实战:LangChain与LangGraph构建可控AI智能体

做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_idcall_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_namehistory), MessagesPlaceholder(variable_nameuser_input), ])MessagesPlaceholder专门用来留出对话历史和用户输入的位置。这个占位符让模板不再是一段固定文字而是一个可插拔框架。我强烈建议每个项目里都建立一套这种结构化模板因为后续不管加工具调用、加知识库内容都只需要往对应占位符里填充。2.3 LCEL链式调用的上下文流动LangChain的LCELLangChain 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[片段{i1}] {d.page_content} for i, d in enumerate(docs)) chain ( { context: retriever | format_docs, question: RunnablePassthrough(), history: lambda x: x.get(history, []), } | prompt | ChatOpenAI(modelgpt-4o-mini, temperature0) )这个链的意思是先并行计算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_chars800): 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(modelgpt-4o-mini) recent state[messages][-4:] # 只看最近两轮 prompt ChatPromptTemplate.from_messages([ (system, 你是规划者。根据用户目标生成不超过3步的简单计划。), (human, 用户目标{goal}\n最近消息{recent}), ]) response llm.invoke(prompt.format(goalstate[user_goal], recentrecent)) 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( contentresult, tool_call_idtool_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, keylambda 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消耗/轮82004400回复稳定性70%88%有效信息占比18%52%7. 关于上下文工程我的几条实战建议看完上面这些如果你准备在自己项目里推进上下文工程这五条掏心窝子的建议应该对你有帮助。第一从“记录输入输出”开始做上下文工程不要先追求复杂框架。先把你现在的系统里每一轮发给模型的数据dump出来看看哪些是模型真正需要的哪些是废话。把废话去掉往往比引入LangGraph收获更大。第二优先实现显式的State而不是依赖框架的隐式记忆。我自己早期用Memory组件时一旦链路变复杂就陷入魔法副作用。改成显式的消息列表和工具结果字典之后排查问题舒服太多了。第三给上下文写上“谁产生、谁使用、谁销毁”的注释。每个节点产生的字段要在节点的文档字符串里标明消费方是谁。这个看起来琐碎但智能体一复杂这堆注释能救你于水火。第四上线前准备一份黄金测试集。上下文工程的优化很多时候是“改了流程、吞掉知识、模型反而答得变差”。没有测试集就分不清是变好还是变坏。我自己的项目里至少准备了三十条真实场景问题每月更新一次。第五别迷信“上下文越长越好”。对多数模型而言短而精的上下文效果远好于“把所有资料都塞进去”。一句话把模型当同事把上下文当交给同事的材料你不是把整柜子文档都搬过去而是告诉他第几页第几行有用。我自己的体会是上下文工程是一次“从调模型到管流程”的思维升级。它需要你用工程眼光去审视每一场对话里信息的生产、消费和废弃。写这篇长文也是想把这些年压箱底的经验一次讲透。希望它能帮你绕过那些我踩过的坑把智能体从“偶尔灵光”推进到“稳定好用”。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →