news 2026/10/23 3:20:16

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
上下文工程实战: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_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: int

messages字段使用了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消耗/轮82004400
回复稳定性70%88%
有效信息占比18%52%

7. 关于上下文工程,我的几条实战建议

看完上面这些,如果你准备在自己项目里推进上下文工程,这五条掏心窝子的建议应该对你有帮助。

第一,从“记录输入输出”开始做上下文工程,不要先追求复杂框架。先把你现在的系统里每一轮发给模型的数据dump出来,看看哪些是模型真正需要的,哪些是废话。把废话去掉,往往比引入LangGraph收获更大。

第二,优先实现显式的State,而不是依赖框架的隐式记忆。我自己早期用Memory组件时,一旦链路变复杂就陷入魔法副作用。改成显式的消息列表和工具结果字典之后,排查问题舒服太多了。

第三,给上下文写上“谁产生、谁使用、谁销毁”的注释。每个节点产生的字段,要在节点的文档字符串里标明消费方是谁。这个看起来琐碎,但智能体一复杂,这堆注释能救你于水火。

第四,上线前准备一份黄金测试集。上下文工程的优化,很多时候是“改了流程、吞掉知识、模型反而答得变差”。没有测试集就分不清是变好还是变坏。我自己的项目里至少准备了三十条真实场景问题,每月更新一次。

第五,别迷信“上下文越长越好”。对多数模型而言,短而精的上下文效果远好于“把所有资料都塞进去”。一句话:把模型当同事,把上下文当交给同事的材料,你不是把整柜子文档都搬过去,而是告诉他第几页第几行有用。

我自己的体会是,上下文工程是一次“从调模型到管流程”的思维升级。它需要你用工程眼光去审视每一场对话里信息的生产、消费和废弃。写这篇长文也是想把这些年压箱底的经验一次讲透。希望它能帮你绕过那些我踩过的坑,把智能体从“偶尔灵光”推进到“稳定好用”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 3:20:04

企业私有化AI部署实战:从显存估算到本地智聊机器人落地

企业级私有化AI这个词听上去挺唬人,说直白点就是:在自己的服务器上把一个大模型跑起来,再包一层聊天界面和权限控制,让公司内部的人通过网页就能和它对话,所有数据都留在内网。大多数团队评估这个方案,都是…

作者头像 李华
网站建设 2026/10/23 3:19:23

AI辅助判图实战:智慧口岸安检的目标检测与边缘部署全解析

项目标题里的“精准防线”,加上“守护每一次跨境通行”这个定位,我第一反应不是宏大叙事,而是那几年在口岸现场熬过的无数个日夜。那时候我们接了一个某口岸智慧查验系统的建设项目,核心任务就一条:在不拖慢通关速度的…

作者头像 李华
网站建设 2026/10/23 3:19:22

改进YOLOv8的通信设备零部件检测:HAFB-2算法实践复盘

通信设备零部件识别与检测,听起来是个很“工业”的题目,但是真正在产线上待过的人都知道,这活儿远比想象中复杂。我去年参与了一个通信设备制造企业的质检项目,核心任务就是把贴片电容、连接器、散热片、屏蔽罩这些零部件的识别与…

作者头像 李华
网站建设 2026/10/23 3:18:56

AI产品调优别只靠感觉,这套自动化评估框架值得试试

1. 别再让感觉主导你的AI产品调优做AI产品这几年,我见过太多团队在调优阶段“凭感觉”行事:效果不如预期,就改个提示词看看;准确率降了,就怀疑是数据问题;上线后用户反馈不好,才手忙脚乱地复盘。…

作者头像 李华
网站建设 2026/10/23 3:18:27

驯化AI实操指南:从提示词到上下文,让大模型稳定输出

“驯化AI”这个词,我琢磨了很久。和AI系统打了这么长时间交道,最大的感受是:AI不是一台等着你下指令的机器,它更像一个有点聪明、但性格不太稳定的伙伴。你说“帮我总结一下这份文档”,它给你列几条重点;下…

作者头像 李华
网站建设 2026/10/23 3:17:52

Flutter鸿蒙适配:用Result包装与错误码分级实现异常自愈

如果你维护过一个跑了两年多的 Flutter 业务项目,第一次听到要把组件适配到鸿蒙时,心里最先跳出来的大概率不是 UI 怎么排,而是那些在 Android/iOS 上行之有效的异常处理,到鸿蒙上还能不能靠得住。我这边陆续适配过首页信息流、扫…

作者头像 李华