LangChain实战:系统提示词、工具封装与记忆体构建Agent应用
要说LangChain的入门教程网上确实不少但大部分都停留在“调一个模型、问一句话”的阶段。真正做应用的时候光会调模型远远不够——系统怎么设计、上下文怎么传、工具怎么让模型调用、返回格式怎么固定、多轮对话怎么记住之前说过什么这些才是卡住大多数人的坎。这篇要聊的就是LangChain Quick Start系列的第三部分也是我最近觉得最实战的一个部分系统提示词、工具封装、上下文管理、结构化返回、记忆体。这五个词串起来其实就是一条完整的Agent应用链路。我尽量拆细一点把每块的原理、示例代码、踩坑点都写清楚。不管你是刚看完前面两部分的新手还是已经写过一些Demo但总觉得“不对味”的开发者这篇应该都能给你一些新的启发。1. 这五个东西到底在解决什么问题先说个事情LangChain本身不是一个“模型”而是一套让大模型“用得起来”的工程框架。它解决的核心问题就是把大模型从“聊天玩具”变成“能干活的应用”。那五个关键词正好对应五个工程上绕不开的环节。1.1 从“对话”到“任务”的关键一步如果你只是把Prompt丢给ChatGPT聊聊天那确实用不上LangChain。但一旦你要做大模型应用比如客服机器人、文档问答、自动排班、数据分析助手你就会发现模型输出不稳定、格式乱七八糟、上下文越聊越长、该调的工具调不到。这些问题LangChain的这五个模块就是拿来挨个解决的。我把这条链路理解成一句话你通过系统提示词给模型立人设通过工具封装给模型“手和脚”通过上下文管理告诉模型当前的处境通过结构化返回拿到干净的数据通过记忆体让模型记得你们聊过什么。1.2 为什么Quick Start系列要把这五个一起讲单独拆开看每个点都有独立教程。但实际开发中它们几乎总是同时出现。比如你要做一个“能查天气的语音助手”你需要系统提示词告诉模型“你是天气助手回答要简洁”需要工具封装一个查天气的函数给模型调用需要上下文管理让模型知道“用户是北京用户现在是早上”需要结构化返回把天气数据转成前端能用的JSON还需要记忆体让模型记得用户上次问过哪个城市。这五个是咬合在一起的。所以这篇我按“串联Demo”的方式来写单个模块拆开讲清楚最后再拼起来你会看得更明白。2. 核心模块逐个拆解2.1 系统提示词给模型立好“人设”和“规矩”系统提示词System Prompt是很多初学者的第一个误区——很多人以为无非就是“你是一个助手”然后就不管了。实际上一条好的系统提示词可以大幅提升输出质量减少后期清洗成本。我见过不少团队系统提示词写得好后续的解析代码能省一半。2.1.1 系统提示词到底怎么写才有效我的经验有三条人设要具体、约束要可执行、格式要明确。人设具体指的是不要只说“你是助手”要说“你是某公司的售后客服语气专业且友好不要使用emoji不要用感叹号”。约束可执行指的是每条规则都应该是“可以/不可以”的二元判断比如“如果用户问非售后问题请礼貌表示无法回答并引导到人工”。格式明确指的是如果你需要固定输出就直接在提示词里写清楚“最后一行输出JSON”。举个例子我写过这样一条系统提示词system_template 你是售前客服助手“小X”服务于一家电商平台的售前咨询。 你的职责 1. 回答关于订单、物流、商品的常见问题。 2. 如果用户询问售后问题请回复“售后问题请拨打客服热线人工坐席会帮您处理。” 3. 如果用户的问题你无法确定请直接说“我帮您转接人工”不要编造。 回复要求 - 每次回复控制在50字以内语气热情但不过度。 - 不要使用表情符号和网络流行语。 - 如果用户要求报价必须使用如下格式输出 当前价格{商品名}活动价{价格}元。 这条提示词把“人设”“边界”“格式要求”都钉死了。你把这段作为system消息传进模型它后续的行为会被牢牢框住不会乱跑。这个技巧在做垂直场景时特别管用。2.1.2 什么时候需要动态拼接系统提示词固定提示词适合简单场景但真实项目里系统提示词往往需要拼接动态信息。比如你做一个法律问答助手系统提示词里需要带上最新的法规版本做一个公司内部知识助手提示词里需要带上当前用户的部门权限。在LangChain里这可以直接用字符串拼接也可以用LangChain的SystemMessage和PromptTemplate配合。我的建议是上下文较短时直接拼接没问题但一旦你的动态内容比较多一定要用PromptTemplate做模板管理否则最后维护起来会非常痛苦。from langchain_core.prompts import ChatPromptTemplate from langchain_core.messages import SystemMessage system_msg 你是{company}的{role}。 当前用户等级{user_level}。 请根据用户等级提供对应的服务注意不要透露其他等级用户才能看到的信息。 prompt ChatPromptTemplate.from_messages([ (system, system_msg), (human, {user_input}) ]) formatted prompt.format(company某电商平台, role客服, user_levelVIP, user_input想查一下订单)你看到没有动态的部分通过模板变量传进去维护起来一目了然。系统提示词是整个对话的地基这块用心了后面所有的事情都会顺很多。2.2 工具封装让大模型长出“手和脚”工具封装Tool Calling是我个人认为LangChain最惊艳的部分。它让模型不再是“嘴上说说”而是真的能去执行函数、查数据库、调API。说白了就是让模型能“动手干活”。2.2.1 为什么需要把函数“包”给模型大模型自己是不能直接执行代码的它只能生成文本。但通过工具封装你可以把一组函数描述给模型模型在回答问题时会判断“这个问题该调哪个函数参数是什么”然后返回一个结构化的调用请求。LangChain接收到请求后执行你定义的函数把结果再交给模型模型基于结果继续组织回复。这背后的原理叫Function Calling是很多大模型API原生支持的能力。LangChain的作用就是帮你把“封装、注册、执行、回传”这一整套流程串起来你只需要写一个普通的Python函数再装饰一下就行。2.2.2 用tool装饰器快速封装LangChain封了一个tool装饰器写法非常顺手。比如你要做一个查询订单状态的工具from langchain_core.tools import tool tool def query_order_status(order_id: str) - str: 查询订单发货状态返回最新物流信息。 Args: order_id: 用户订单号格式如 20250101XXXX。 # 这里实际会去调后端的订单接口 # 这里简化为模拟数据 mock_data { 20250101ABC: 已发货预计3天内到达, 20250101XYZ: 已签收, } return mock_data.get(order_id, 订单号不存在请核对后重试)注意看函数里的三引号docstring很多人会忽略它但这个docstring真的非常关键——它会被LangChain自动解析作为“工具描述”喂给模型。模型是靠这个描述来判断该不该用这个工具的。所以docstring一定要写清楚这个函数干嘛的每个参数什么意思。我见过不少同学封装工具后模型死活不调用一问才发现是docstring写得太抽象模型根本看不懂这个工具是干嘛的。封装好之后把这个工具绑定到模型上from langchain_openai import ChatOpenAI llm ChatOpenAI(modelgpt-3.5-turbo, temperature0) llm_with_tools llm.bind_tools([query_order_status]) response llm_with_tools.invoke(帮我查一下订单 20250101ABC 的状态) print(response.tool_calls)运行成功后你会看到response.tool_calls里带上了工具名和参数这就是模型在“说”我想调用query_order_status参数值是20250101ABC。接下来你只需要把这个调用请求解析出来真正执行函数再把结果传给模型。2.2.3 工具调用如何和模型“对话”完整链路是这样的用户提问 - LLM判断需要调用工具 - 返回tool_calls - 你执行函数 - 把结果作为ToolMessage回传 - LLM整合结果回复用户示例代码如下我建议你把这段完整跑一遍这是理解工具调用的关键from langchain_core.messages import HumanMessage, ToolMessage # 第一步让模型判断该调哪个工具 user_input 帮我查一下订单 20250101ABC 的状态 messages [HumanMessage(contentuser_input)] response llm_with_tools.invoke(messages) # 第二步执行工具调用 if response.tool_calls: # 拿到模型要调用的工具名和参数 tool_call response.tool_calls[0] tool_name tool_call[name] tool_args tool_call[args] # 实际执行函数 result query_order_status.invoke(tool_args) print(工具返回, result) # 第三步把工具结果回传给模型 messages.append(response) messages.append(ToolMessage(contentresult, tool_call_idtool_call[id])) # 第四步模型基于工具结果回复 final_response llm.invoke(messages) print(模型最终回复, final_response.content)这个流程很典型。你可以把它包装成一个“循环”——模型说调工具就调调完再喂回去直到模型不再想调工具直接给出最终答案。LangChain的Agent内部就是这样一个AgentExecutor在干这件事初学者先手写一遍更容易理解内部原理。2.2.4 简单函数怎么转成工具如果手头已经有现成的普通函数不想写装饰器可以用StructuredTool.from_function构建from langchain_core.tools import StructuredTool def calculate_shipping_fee(weight: float, province: str) - float: 计算运费。默认首重1kg内8元续重每公斤2元偏远地区额外加5元。 base 8 extra max(0, weight - 1) * 2 remote 5 if province in [新疆, 西藏] else 0 return base extra remote calc_tool StructuredTool.from_function( funccalculate_shipping_fee, namecalculate_shipping_fee, description计算商品运费输入重量kg和省份名 )这样你的老函数就能直接变成模型可调用工具不用改原来的业务代码。这是我特别推荐的一种封装方式——逻辑和LangChain弱耦合以后即使不用LangChain也能直接复用。2.3 上下文让对话有“连续性”上下文Context这个词听起来很简单但真正做到位并不容易。上下文大致分两类一类是当前这轮对话的历史消息另一类是从外部拉取的事实信息比如数据库里的用户数据、检索到的文档片段。2.3.1 为什么裸调模型会“失忆”你直接调一次模型API它是没有任何记忆的。它只会根据你传到API里的消息列表去理解当前状态。所以想让模型“知道上下文”你必须在每次请求时把之前聊过的消息一起传进去。LangChain里有MessagesPlaceholder这个概念可以帮助你把历史消息“嵌入”到Prompt的指定位置。我建议用以下模式来维护多轮对话from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder prompt ChatPromptTemplate.from_messages([ (system, 你是订单助手回答要简洁准确。), MessagesPlaceholder(variable_namehistory), (human, {input}) ]) # 然后拼接消息 from langchain_core.messages import AIMessage, HumanMessage history [ HumanMessage(content我的订单号是20250101ABC), AIMessage(content收到我帮您查一下该订单。), ] messages prompt.format_messages(historyhistory, input那它什么时候到)这里的关键是MessagesPlaceholder它会从变量history中把一组消息原样插入到System消息后面。这样模型就知道你们聊到哪儿了。2.3.2 只传“最近几条”还是全量传入这是一个绕不开的取舍问题。全量传入很简单但消息列表会越来越长一方面浪费Token另一方面当内容超过模型的上下文窗口时还会直接报错。我常用的策略有几种只保留最近N轮用一个ConversationBufferWindowMemory之类的东西收窄窗口。对历史做摘要每聊几轮就把之前的内容压缩成一段摘要文本。关键信息结构化存储把用户姓名、订单号等Key-Value抽取出来永远只传状态不传流水账。我强烈建议在真实项目里用第三种思路——把对话中“需要长期记住的事实”抽出来单独存而对话历史本身只保留最近几轮。这样既省Token又能确保关键信息不丢。2.3.3 从外部检索出来的“上下文”另一种上下文是知识库检索结果。比如做文档问答你需要先把用户问题拿去检索向量库把Top-K个文档片段取出来再拼到Prompt里让模型基于这些片段回答。这个思路在LangChain里叫RAGRetrieval Augmented Generation。虽然这篇不展开讲RAG但上下文管理的思想是一样的决定什么该进Prompt什么不该进是应用设计里最核心的判断。2.4 结构化返回从文本里提取“数据”大模型默认输出是一段自然语言文本。但在应用里我们需要的是JSON、YAML、表格或者干脆是一个Python对象。结构化返回Structured Output就是解决这个问题的。2.4.1 最基础的方式强制JSON格式在Prompt里明确要求“只输出JSON”是一种简单的做法。但不稳定模型偶尔会夹带注释或额外文字。LangChain提供了一些更稳妥的方式我主要推荐两种。第一种是让模型返回原始JSON然后你用json.loads解析from langchain_core.prompts import PromptTemplate import json prompt PromptTemplate.from_template( 提取用户问题中的订单信息只输出JSON不要输出其他文字格式 {{order_id: xxx, products: [xxx], amount: 0}} 用户问题{input} ) chain prompt | llm response chain.invoke({input: 我要退回订单20250101ABC里的蓝牙耳机和充电器总共199元}) print(response.content) # 预期输出{order_id: 20250101ABC, products: [蓝牙耳机, 充电器], amount: 199}这种方式最透明也不怎么依赖库适合快速验证。2.4.2 用Pydantic定义输出结构更工程化、更推荐的方式是结合Pydantic的with_structured_output。你先定义一个数据类LangChain负责让模型直接输出符合这个类结构的对象并且自带校验。from pydantic import BaseModel, Field from typing import List class OrderInfo(BaseModel): order_id: str Field(description订单号) products: List[str] Field(description商品列表) amount: float Field(description订单金额) structured_llm llm.with_structured_output(OrderInfo) result structured_llm.invoke(我要退回订单20250101ABC里的蓝牙耳机和充电器总共199元) print(result) # OrderInfo(order_id20250101ABC, products[蓝牙耳机, 充电器], amount199.0)这里有个很省心的地方你不光拿到了对象还拿到了类型校验。如果模型输出格式不对这个调用大概率会直接报错而不是静默错误方便你提前发现问题。这种方式对做数据提取类应用特别友好比如从用户留言里提取工单字段、从邮件里提取日程信息等。2.4.3 结构化返回常见的坑这个环节的坑主要在三个地方模型本身的格式能力参差不齐同一个写法换一个小模型可能就不稳定了。所以如果你用小模型跑项目建议先用json.dumps强制序列化Prompt或者干脆换模型。with_structured_output对底层API有要求部分模型不支持Function Calling那这个接口就会走JSON schema模式速度会变慢需要注意。别把“结构化”做成“把字段名硬编码进Prompt”应该好好用Pydantic的Field(description...)把字段意图描述清楚模型才会更正确地填充。2.5 记忆体让Agent“记住”聊过的内容最后是最容易误解的模块——记忆体Memory。在LangChain里Memory不只是“存对话历史”而是一种让Agent在多次交互之间携带信息的机制。因为模型本身是无状态的记忆体负责把状态“喂”回去。2.5.1 先搞懂三种常用记忆体LangChain里记忆体有非常多的实现但初学者先把这三种用明白就够了名称原理适合场景ConversationBufferMemory直接缓存所有对话消息对短期会话比较友好开发调试方便ConversationBufferWindowMemory只保留最近N条消息控制Token成本时常用ConversationSummaryMemory对历史消息做摘要后存储长对话场景省Token但损失细节这三种我建议都动手试一下。各自的使用区别主要在构造参数和检索方式上。如果你是跟着新版LangChain0.2/0.3学习需要留意一下现在的趋势是直接用MessagesPlaceholder管理历史而不是依赖旧的Memory类。旧版Memory类拿来实现快速原型很方便但新版项目里我倾向于自己维护消息列表。2.5.2 新版推荐自己用列表管理记忆现在我的习惯是直接维护一个messages列表每轮对话把用户消息和模型回复都append进去下一轮再传入。这种做法的最大优点就是透明。你随时可以打印这个列表看看到底往模型发送了什么定位问题方便得多。from langchain_core.messages import AIMessage, HumanMessage messages [] messages.append(SystemMessage(content你是订单助手请简洁回答。)) while True: user_input input(你) messages.append(HumanMessage(contentuser_input)) response llm.invoke(messages) messages.append(AIMessage(contentresponse.content)) print(助手, response.content)这段代码就是最朴素的记忆体。你会发现每次请求messages列表里都带着完整的历史。实际项目中再在这个基础上加上“窗口裁剪”“摘要压缩”“关键信息抽取”就逐步演变成一个成熟的记忆方案了。2.5.3 记忆体不只是为了“寒暄”做应用时记忆体更重要的一层意义是承载业务状态。比如你已经确认了下单商品、收件地址、优惠券那后面的对话就不需要用户重新说一遍。你可以把这份状态存在记忆体里下一轮Prompt生成时直接注入。这就好比你跟客服电话沟通对方上来就知道“您上次说订单不要了要退款”体验完全不同。我们做对话产品这种细节体验往往是用户留存的分水岭。3. 实操串联一个真正能跑的订单助手前面拆得差不多了我把它们装进一个完整的Demo里。这个Demo模拟一个售前订单助手支持查订单状态、调用运费计算工具、记住用户订单号、结构化返回订单信息。建议你照抄下来跑一遍就会有“原来如此”的感觉。3.1 环境准备与依赖安装写代码前先确认环境。我用的是LangChain 0.2以上的API建议你也用新一点的版本。直接装依赖包pip install langchain langchain-openai pydantic注意langchain-openai是独立包别只装langchain。新版LangChain把各家的模型接口拆到独立包里了装错的话ChatOpenAI导入不进来。如果你用的是其他模型可以按对应包名安装接口结构是相似的。大模型这块以OpenAI兼容接口为例我在代码里写了一个示例配置。如果你没有可用的API Key可以先把代码逻辑理顺等到有工具环境时再替换成你本地部署的模型。3.2 完整代码串联整体逻辑先定义好系统提示词封装两个工具绑定到模型然后模拟两轮对话。第二轮对话时模型会记住第一轮里用户提到的订单号并且可以再次调用工具获取状态。import json from typing import List from pydantic import BaseModel, Field from langchain_openai import ChatOpenAI from langchain_core.tools import tool from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, ToolMessage # ---------- 1. 初始化模型 ---------- llm ChatOpenAI( modelgpt-3.5-turbo, temperature0, ) # ---------- 2. 系统提示词 ---------- SYSTEM_PROMPT 你是某电商平台的售前订单助手“小X”。 功能边界 1. 只能回答订单查询、运费计算、商品咨询等售前问题。 2. 售后问题请礼貌转接人工热线。 3. 如果用户没有提供订单号请不要编造主动询问。 回答风格 - 简洁准确不用表情符号。 # ---------- 3. 工具封装 ---------- tool def query_order_status(order_id: str) - str: 查询订单发货状态返回最新物流信息。 Args: order_id: 用户订单号格式如 20250101ABC。 mock_db { 20250101ABC: 已发货预计3天内到达, 20250101XYZ: 已签收, 20250101QWE: 仓库处理中尚未发货, } return mock_db.get(order_id, 订单号不存在请核对后重试) tool def calculate_shipping_fee(weight: float, province: str) - float: 计算商品运费返回运费金额。 Args: weight: 商品重量单位kg。 province: 收货省份。 base 8 extra max(0, weight - 1) * 2 remote 5 if province in [新疆, 西藏] else 0 return base extra remote # ---------- 4. 绑定工具 ---------- tools [query_order_status, calculate_shipping_fee] llm_with_tools llm.bind_tools(tools) # ---------- 5. 运行Agent循环 ---------- def run_agent(user_input: str, messages: list): messages.append(HumanMessage(contentuser_input)) response llm_with_tools.invoke(messages) messages.append(response) # 如果模型需要调用工具 while response.tool_calls: for tool_call in response.tool_calls: fn_name tool_call[name] fn_args tool_call[args] print(f[调用工具] {fn_name}({fn_args})) if fn_name query_order_status: result query_order_status.invoke(fn_args) elif fn_name calculate_shipping_fee: result calculate_shipping_fee.invoke(fn_args) else: result 未知工具 messages.append(ToolMessage(contentstr(result), tool_call_idtool_call[id])) # 把工具结果喂回去再让模型继续 response llm_with_tools.invoke(messages) messages.append(response) return response.content # ---------- 6. 模拟对话 ---------- messages [SystemMessage(contentSYSTEM_PROMPT)] reply run_agent(我想查一下订单 20250101ABC 到哪里了, messages) print(助手, reply) reply2 run_agent(顺便帮我算一下一个5kg的包裹发到浙江要多少运费, messages) print(助手, reply2)跑一下你会看到类似这样的输出[调用工具] query_order_status({order_id: 20250101ABC}) 助手 您的订单 20250101ABC 已发货预计3天内到达。 [调用工具] calculate_shipping_fee({weight: 5.0, province: 浙江}) 助手 5kg包裹寄到浙江的运费是16元。整个过程模型完全是自己“判断”要调用哪个工具的你只是把工具描述塞给了它。这就是LangChain里最经典的一个Agent循环。3.3 更多扩展方向这个Demo还能往不少方向扩展。例如把工具替换成真实后端API接口把tools列表加到十几个在Messages里同时维护多轮窗口把最终回复解析成固定的JSON给前端。每一步都是当前模块的延伸能往前走的空间非常大。4. 常见问题与排查技巧实录实战中我会经常碰到一些看起来怪怪的Bug整理几个高频的按“症状-原因-解法”列在下面。这些都是我从实际调试中总结出来的能帮你省不少时间。常见症状可能原因解决方案模型始终不调用工具工具的description写得太模糊或者工具名过于抽象检查docstring和description用自然语言写清“什么时候该用”明明调用了工具但报参数缺失模型生成参数时类型或必填字段没对上给每个参数加类型注解和注释必要时用Pydantic定义args_schema多轮对话后越来越慢历史消息无限制累积超出模型窗口或增加Token成本加窗口裁剪或把旧消息做摘要再注入返回的JSON解析失败模型在JSON里夹带注释或说明文字改用with_structured_output或在Prompt里最大程度明确“只输出JSON”工具里的另一个函数报错把Agent带崩工具内部异常没有拦截直接抛到Agent循环里在工具函数内部捕获异常返回可读的字符串不要抛异常出去感觉“模型没记住之前的内容”没有把历史消息正确拼到下一次请求里检查消息列表的append逻辑确认每轮都传入了messages4.1 工具不调用的深层排查这个最让人头疼。我的排查顺序是先打印模型返回的原始内容看它是不是想调工具但参数填错再把工具的description和参数描述都改得更直白最后换一个能力更强的模型试一下。有些小模型本身对Function Calling的支持就不好不是代码的问题。4.2 消息列表管理的可怕坑有个常见的坑在循环里用了同一个列表但忘了在第二轮把前一轮的AIMessage也append进去。这样模型看到的历史里只有用户消息没有自己回复会觉得“话没说完”导致输出奇怪。调试时可以随时打印messages列表看看消息之间的顺序是不是符合真实对话的交替节奏。4.3 工具内异常务必拦截工具函数是在Agent循环内部被调用的如果你在函数里直接抛出异常整个Agent循环会直接崩掉而不是优雅地回复“出错了”。所以每个工具函数里尽量把业务逻辑包在try...except里所有异常都转成一句人类可读的字符串返回。模型拿到这句话后自然会组织成对用户友好的回答。别小看这个习惯线上出问题无力回天的时候很多都是这么救回来的。4.4 区分上下文窗口和记忆体还有一个很多人混淆的概念模型自身的上下文窗口和LangChain记忆体不是一个东西。上下文窗口是硬件限制记忆体是软件策略。你可以在记忆体里存100轮对话但如果超出模型窗口照样报错。所以做记忆体时永远要想着“只把最该给模型看的部分喂进去”而不是一股脑全塞。这个理念贯穿整个LangChain应用开发始终。5. 实操心得与扩展建议我自己的感觉是LangChain的学习曲线并不是“从简单到难”而是“从会用到会设计”。前两步只要照着文档就能做出来但到了系统提示词、工具封装、上下文、记忆体这些环节考验的就是工程判断力了。同一个功能一百个人有一百种写法但能跑得稳、容易维护、省Token的往往是思路最清晰的那一小部分人。我给你几个建议第一刚开始别急着嵌套各种高级链就先线性地写Agent循环遇到问题打印每一步的消息列表把链路搞明白比什么都强。第二每个小功能都单独做一个小测试脚本比如单独测工具调用单独测结构化输出这样定位问题快。第三文档里如果看到某个类被标为“deprecated”尽量去做迁移老接口虽然能用但往往后续维护成本高。这篇提到的五个模块我私下觉得是整个LangChain应用开发的“内功心法”。系统提示词是地基工具封装是能力外延上下文管理是信息裁剪结构化返回是数据出口记忆体是状态维护。把这五个串起来你会发现所谓Agent、所谓自动化本质上就是一套“输入-工具-记忆-输出”的循环。最后分享一个小技巧不管是哪个模块调试时先打印模型的原始输出不要只盯着解析后的结果看。模型到底生成了什么、想干什么、为什么会走错只有原始输出会告诉你后面你排查问题的速度能快一半。