资讯动态

LangChain内置中间件实战:让Agent从能跑到稳跑

发布时间:2026/10/20 3:21:23 来源:尧图企业网站定制
前阵子帮一个做客服工单自动分类的团队排查线上问题现象很典型同样一句话的工单隔几分钟提交分类结果能差出两个方向去而且高峰时段接口动不动超时。翻他们的代码一看就明白了——LLM调用、工具调用、记忆读写全揉在一个函数里链路里到底哪一步慢、哪一步重复调用、哪个上下文被污染了完全无感。这不是个例我见过太多Agent项目死在能跑和能稳定跑之间而LangChain框架里那层经常被忽略的内置中间件能力恰恰是跨越这个坎的关键。如果你正在用LangChain搭Agent或者已经在生产环境里跑但心里没底这篇文章值得停下来看看。我会从一次故障复盘讲起把LangChain提供的回调钩子、模型级缓存、重试与回退、消息持久化以及新版中间件API这五类内置能力逐个拆开每个都给出可直接抄的配置代码和最容易踩的坑。读完你应该能回答一个问题你的Agent链路里哪些中间件该上、怎么上、上完怎么看效果。1. 拆解需求Agent链路里到底缺了什么1.1 没有中间件的Agent项目长什么样先说那个客服工单分类项目的原始结构。他们用LangChain搭了一条链先调一次大模型把工单文本做意图识别再根据意图去知识库检索然后把检索结果和原始工单一起喂给模型生成分类结论最后把结论写入数据库。听起来分工明确但实际跑起来有几个问题一直困扰着他们。第一个问题重复调用不可见。同一个工单在测试环境里跑一次要消耗两到三千个token按他们日均两万张工单的量来算费用相当可观。试过在业务代码里加日志看每次请求的返回内容勉强能知道模型答了什么但根本不知道同一段检索结果是不是被重复塞给了模型、哪些调用其实是白花了。第二个问题失败处理全靠运气。大模型服务偶尔会限流或者返回异常他们最初只写了简单的try-except捕获到异常就重新跑整条链。赶上高峰时段一个工单可能重试三四次每次重试都是从LLM调用开始前面已经完成的检索、记忆读取全部白做链路还越跑越慢。第三个问题上下文污染说不清。他们为了省事把用户的历史工单全部拼进system prompt结果一个人处理过的工单一多prompt能撑到接近上万token模型注意力被历史内容带偏分类准确率肉眼可见地下降。这三个问题放到一起本质上是链路缺少了一层管道——在模型调用前后、链的各个节点之间需要有一批可插拔的处理逻辑负责观察、缓存、容错、记忆管理。这就是中间件的定位。1.2 LangChain内置中间件的边界在哪需要先澄清一个容易混淆的点LangChain里中间件不是一个单独的、包治百病的组件而是一组散落在不同子模块里的内置扩展机制。它们名字不同、挂载方式不同、解决的问题也不同但在架构上扮演的角色是一致的——包裹在Runnable链的外层在请求进来和结果出去的路上做辅助处理。用管道来类比的话从用户输入到模型输出中间会经过若干节管道而中间件就是在这条管道上开的口有的口是让你看的回调钩子有的口是让你存的缓存组件有的口是帮你兜底的重试与回退有的口是帮你记住上下文的消息持久化。这些内置能力覆盖了Agent开发里最常用的几类横切关注点不需要你去重写它们——大多数场景下配好参数就能用。我见过不少人一上来就想引入类似Web框架里那种通用中间件体系比如自定义一套洋葱模型把所有逻辑全部包进去。这个想法没有问题但在动手之前最好先把LangChain内置的这些东西用熟。原因很简单内置中间件和LangChain的Runnable协议深度绑定它们能拿到最完整的运行时上下文模型调用前后的payload、token用量、异常信息你自己撸一套很难做到这个粒度。2. 回调钩子观察每一次调用的行车记录仪2.1 三种接入方式从零配置到全自定义回调钩子Callbacks是LangChain内置中间件里最基础、也最容易被忽略的一个。它解决的是可观测性——你的Agent在链条的每一步都做了什么、输入输出是什么、耗时多长、token用多少这些信息不会自动出现在任何地方只有主动挂上钩子才能拿到。最简单的接入方式是直接用内置的StdOutCallbackHandler把关键事件打到控制台。做本地调试的时候非常好用from langchain_core.callbacks import StdOutCallbackHandler from langchain_openai import ChatOpenAI model ChatOpenAI(modelgpt-4o-mini, temperature0) chain model result chain.invoke( 把这句话分类为咨询/投诉/售后: 我的订单三天没发货了, config{callbacks: [StdOutCallbackHandler()]} )跑一次就能在终端里看到完整的调用轨迹模型开始接收了哪些prompt、返回了什么内容、消费了多少token。这个输出对理解一条链的执行顺序帮助很大尤其是你的链里有多个模型调用和工具调用的时候——哪一个先执行、哪个prompt被拼到了什么程度一目了然。第二种接入方式是通过BaseCallbackHandler写一个子类把特定事件重定向到日志系统或监控面板。这是生产环境里更推荐的做法。我在给那个客服项目梳理链路时写了这样一个处理器专门记录LLM调用耗时和token开支import time from langchain_core.callbacks import BaseCallbackHandler class LLMCostTracker(BaseCallbackHandler): def __init__(self): self.start_time None self.total_tokens 0 def on_llm_start(self, serialized, prompts, **kwargs): self.start_time time.perf_counter() # 在这里把prompts写入日志方便后续排查上下文拼接问题 # 注意prompts是列表里面是消息内容可能很长建议截断保存 def on_llm_end(self, response, **kwargs): if self.start_time is not None: cost_ms (time.perf_counter() - self.start_time) * 1000 self.start_time None llm_output response.llm_output or {} token_usage llm_output.get(token_usage, {}) prompt_tokens token_usage.get(prompt_tokens, 0) completion_tokens token_usage.get(completion_tokens, 0) self.total_tokens prompt_tokens completion_tokens # 这里写你的日志发送逻辑推送到日志中心或者时序数据库 # 我习惯把 model_name、总耗时、token量拼成一条结构化日志第三种方式是把调用链上所有事件统一挂接通过chain上的with_config方法传递给子节点。这一点很关键LangChain的链是支持嵌套的你在最外层挂的回调会向下传播到内部所有Runnable节点所以不需要在每个子链上单独挂handler。2.2 回调里最容易出问题的两个细节第一个细节回调处理器内部不要做耗时过长的操作。如果你在on_llm_end里同步调用外部API推送日志遇到日志服务抖动整条LLM调用会被拖住。我吃过这个亏上线一个回调后P95延迟从1.8秒涨到了2.9秒排查半天才发现是回调里同步上报数据导致的。解决方案是让回调处理器与主链路解耦——把要上报的数据先放进内存队列由后台线程统一批量发送。第二个细节on_llm_end拿到的response里的llm_output不同模型实现不一样。OpenAI系会带token_usage但如果你切换到了其他模型提供商这个字段可能就不存在直接取会抛KeyError。用.get()并给默认值能避免这种崩溃但这反过来也意味着token统计可能不准需要在日志里标注统计口径。3. 缓存、重试与回退让模型调用省钱又抗造3.1 三类内置缓存的选型逻辑LangChain提供了LLM级别的缓存机制核心思路是相同提示词在相同模型参数下直接返回上一次的结果不再真正调用模型接口。对于Agent应用里那些高频、结果稳定的查询场景比如固定格式的意图识别、分类打标收益非常直接。内置的方案常见的这三种缓存方案存储位置适用场景注意点InMemoryCache进程内存单机、低并发验证重启即失效内存占用随缓存增长SQLiteCacheSQLite文件单机多进程、需要持久化注意并发写入锁冲突RedisCacheRedis多实例部署、共享缓存需要额外维护Redis服务接入方式很简单以SQLiteCache为例from langchain_core.caches import SQLiteCache from langchain.globals import set_llm_cache set_llm_cache(SQLiteCache(database_path.langchain_cache.db))只需要在项目启动时调用一次set_llm_cache后续所有LLM调用都会自动走缓存逻辑。但我得提醒你LangChain的LLM缓存匹配是全量匹配——prompt文本任何一个字符变了都算不命中包括中间的空格、换行。那个客服项目里他们一开始天真地以为相似的工单能共享缓存实际上模型输入里包含了检索结果片段稍微有点动态变化就全部命中不了。所以用缓存之前先看看你的链输入里哪些部分是高重复的把它们单独拆成一个子链去跑缓存效果会好很多。3.2 重试与回退的配置参数经验模型接口在高峰期的错误率会明显上升尤其容易遇到限流和超时。LangChain对Runnable提供了两个非常实用的内置方法.with_retry()和.with_fallbacks()。.with_retry()控制的是同一个模型调用失败后以什么策略重试from langchain_openai import ChatOpenAI import openai base_model ChatOpenAI(modelgpt-4o-mini, temperature0) retry_model base_model.with_retry( retry_if_exception_type( openai.RateLimitError, openai.APITimeoutError, openai.APIConnectionError, ), stop_after_attempt3, wait_exponential_jitterTrue, )我推荐retry_if_exception_type只列重试了有意义的异常。比如服务端返回上下文长度超限这类错误你再怎么重试也没用只会浪费更长的时间和配额。stop_after_attempt设置成2到3次比较平衡再多了用户体验会很差。wait_exponential_jitterTrue会在指数退避的基础上增加随机抖动避免多实例同时重试把接口打得更惨。.with_fallbacks()则是在当前模型不可用时降级到备用模型primary_model ChatOpenAI(modelgpt-4o-mini, temperature0) fallback_model ChatOpenAI(modelgpt-4o, temperature0) # 故意降级配置 chain ( primary_model.with_fallbacks([fallback_model]) .with_retry(stop_after_attempt2) )这里有个经验要提醒你我在真实项目里不推荐把fallback模型和主模型设成同一个提供商的不同型号因为限流往往发生在服务商全局主模型被限流备用模型大概率也在限流。有条件的话备用模型尽量选另一家服务商或者至少是不同接入网关的模型。做不到也不要硬凑有这个意识就行。4. 消息持久化与会话隔离给Agent装上短期记忆4.1 正确挂载历史组件的姿势Agent要连续对话就必须把历史消息传给模型。LangChain里最常用的方案是RunnableWithMessageHistory它本质上是一个包装器自动把某个会话ID的历史消息拼进下一次调用的输入同时把最新一轮消息写入历史存储。from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_community.chat_message_histories import SQLChatMessageHistory chain_with_history RunnableWithMessageHistory( model, get_session_historylambda session_id: SQLChatMessageHistory( session_idsession_id, connection_stringsqlite:///chat_history.db, ), input_messages_keyinput, history_messages_keyhistory, ) response chain_with_history.invoke( {input: 我的订单还没有发货}, config{configurable: {session_id: user_10001}}, )关键点在于get_session_history这个回调它接收一个任意的会话ID返回一个BaseChatMessageHistory实例。这意味着你可以按用户ID、按工单ID、按业务线随便切分会话模型不会记错人。那个客服项目的处理方式是按customer_id做会话隔离这样同一个客户的多次咨询共享上下文不同客户之间互不干扰。4.2 会话存储的三条实战心得第一个心得是历史消息上限必须主动控制。先不说长上下文在费用上的压力单纯从模型效果来看历史消息里夹杂了大量已经无关的寒暄和旧细节会把当前的分类决策带偏。我一般会限制只保留最近10到20轮消息或者用一个trim_messages组件在写入之前截断。from langchain_core.messages import trim_messages trimmer trim_messages(strategylast, token_countermodel, max_tokens4000)第二个心得不要在业务代码里手动拼历史消息进prompt。我看到不少团队为了绕过历史组件把整个历史消息串成一个长字符串塞进system提示里这会让消息的role信息全部丢失模型经常搞不清哪句是用户说的、哪句是自己之前说的。用消息列表结构来存储和取用语义清晰得多。第三个心得并发场景下注意历史写入的顺序。SQLChatMessageHistory这类组件在同一个session_id被并发调用时可能出现历史覆盖。我在一个项目里遇到过两个并发请求同时读取历史、同时写入后写的那条把前一条顶没了。如果你们的Agent有并发写入同一个会话的需求要么在业务层做会话锁要么自己包一层队列串行化写入。5. 新版中间件APIbefore、after与wrapper钩子5.1 中间件API的基础用法LangChain在新版本里提供了更正式的中间件抽象核心是LangChainMiddleware它允许你给任意一个Runnable挂上before、after和wrapper三类钩子。和回调相比这套API更贴近传统的中间件模型——你直接在请求处理管线上布置逻辑拿到的不只是通知而是对payload的直接控制。from langchain_core.runnables.middleware import LangChainMiddleware def audit_before(payload): # payload里带request的输入内容 # 这里可以记录完整输入、打印日志、甚至直接修改输入字段 print(f前置钩子输入: {payload.key_repr}) return payload def audit_after(payload): # payload里带response输出 print(f后置钩子输出状态: {payload.ok}) return payload middleware LangChainMiddleware( beforeaudit_before, afteraudit_after, nameaudit-middleware, ) chain_with_mw model.with_middleware(middleware) result chain_with_mw.invoke(帮我查询订单状态)这套API的价值在于它不依赖链的执行顺序而是作为包裹层作用于整个Runnable。也就是说你不需要修改链内部任何结构就能在外部加上统一的前置检查、后置校验逻辑。我给客服项目做输入脱敏时就是在这里处理的——在before钩子里检查有没有身份证号、手机号这类敏感信息有就先用占位符替换再进入模型调用。5.2 中间件挂载顺序容易绕晕的执行链如果你给一个链挂了多个中间件执行顺序需要特别留意。LangChain的中间件组合逻辑接近后进先出最后挂的中间件最先执行它的before钩子相应的after钩子的执行顺序则是反过来。这跟很多Web框架的洋葱模型是一致的。我画个简化描述假设链上有mw1和mw2两个中间件挂载顺序是model.with_middleware(mw1, mw2)那么实际上请求经过的顺序是mw1.before - mw2.before - model - mw2.after - mw1.after。理解了这一点你在设计中间件时就有一个原则和核心业务越远的通用逻辑越放外层越贴近模型调用的逻辑越放内层。比如鉴权、限流这种和具体模型无关的横切能力放外层而针对某个模型输出做格式强校验的放内层。放反了会导致外层中间件的after钩子拿到的已经是内层加工过的结果逻辑上容易产生幻影数据。不过这里也要提醒一点不同版本里中间件API的命名和挂载方式可能有调整。我在线上用的是比较新的版本如果你的版本较旧没有with_middleware这个方法一种过渡方案是先继续使用回调钩子或者把需要包裹的逻辑拆成独立的Runnable用RunnableLambda接在链的头部和尾部效果类似但没那么优雅。6. 串联实测一个客服Agent加装中间件后的前后对比6.1 测试场景与加装清单前面讲了很多单一组件的用法这一节把它们在同一个场景下串起来看看整体效果。我在本地搭了个简易的客服工单分类Agent结构是输入工单文本 - 意图识别 - 检索知识库 - 生成分类结论 - 写入记录。加装中间件后的清单如下回调LLMCostTracker统计每次LLM调用的耗时和token量。缓存SQLiteCache缓存固定格式的意图识别结果。重试与回退主模型gpt-4o-mini重试2次回退到备用模型。消息历史SQLChatMessageHistory按用户ID隔离限制最近10轮。中间件before钩子里做敏感信息脱敏after钩子里做返回格式校验。测试数据用的是模拟的500份客服工单每份工单平均200个汉字左右。6.2 实测结果与数据解读指标加装前加装后变化单工单平均处理时间4.2秒3.1秒下降26%模型接口调用次数500次318次下降36%总token消耗约46万约29万下降37%高峰时段失败率9.8%1.9%下降81%分类准确率人工抽检200条88.5%89.0%基本持平略升先说最直观的费用数据token消耗下来了主要贡献是把意图识别这一步接了缓存。客服工单的意图类型有限很多表达相近的问题会被模型反复理解缓存命中率在测试集上大概是35%已经非常可观了。而处理时间下降一方面因为缓存命中省去了网络往返另一方面是重试策略更合理不再一失败就整个链重跑。失败率的下降主要靠重试和回退配置。限流类的异常被重试吸收掉大部分真正扛不住的才触发回退模型。测试期间最高峰并发是50个请求同时进来加了指数退避加抖动后没有再出现因为重试风暴导致的连环超时。分类准确率没有明显提升但也没有因为加了历史消息和脱敏而下降。这一点其实挺重要很多团队的中间件加完会为了省而牺牲效果比如过度缓存导致结果陈旧、历史消息截断导致上下文不够。从实测看只要缓存按固定高重复场景拆链部署、历史消息限制在一个合理token预算内效果不受影响。6.3 加装后暴露出来的新问题一个值得说的问题加了缓存后工单分类难度的分布发生了变化。一些简单工单瞬间返回结果而复杂工单要完整走一遍全链路P95延迟反而从4.8秒涨到了5.6秒。原因很好理解——缓存命中只覆盖了简单重复场景真正耗时的是那些逻辑复杂的工单它们的延迟本来就在高百分位上。如果你们的业务有SLA要求建议对复杂工单单独设计超时阈值不要用统一的timeout一刀切。另一个问题是脱敏钩子的性能和准确性。正则扫描在每一条工单上增加了约15毫秒处理时间整体占比不高但偶尔会把联系电话这种正常业务词误判成电话号码开头而打码导致模型拿到语义残缺的输入。后来调了脱敏规则只在数字连续超过7位的片段上触发误伤情况明显减少。7. 常见坑位盘点与选型建议7.1 三个我亲身踩过的坑第一个坑是缓存键粒度太粗。早期我图省事对整个链开了InMemoryCache结果链输入里只要带了个变化的时间戳字段缓存直接全部失效等于没配。后来改成只对子链做缓存命中率才稳定下来。这条经验尤其适用于Agent场景链输入里往往拼了检索结果、历史消息这些动态内容全链缓存基本是伪命题要把缓存放到底层、放在输入足够稳定的位置。第二个坑是回调处理器没有做异常隔离。我在一个回调里写过告警推送逻辑结果那个推送服务在凌晨出了故障抛出来的异常反而把本来已经成功的LLM调用标记成了失败。LangChain的回调体系在运行时吞不吞异常、怎么吞不同版本表现不一样但你在自定义回调里一定要自己try-except做到回调挂了不影响主链。第三个坑是新老API混用导致中间件不生效。有段时间我在老链上用chain.__call__()这种方式跑以为挂的中间件会像invoke一样生效实际并没有。LangChain那段时间在推Runnable统一协议老接口不少都走不到中间件层。排查时一度以为是中间件代码写错了后来统一改用invoke就正常了。7.2 哪些项目建议先上哪个中间件根据项目阶段来选比一上来全家桶更稳妥刚把Agent跑通、准备联调测试的项目优先上回调钩子。你先得看清链路里发生了什么。已经开始对接真实业务的上重试与回退。这一步能显著降低生产事故率配置成本也低。业务费用敏感、且存在明显高重复输入的上模型缓存。注意拆分缓存粒度。需要多轮对话或按人隔离上下文的上消息持久化并且控制历史长度。有多接口、多团队协作需要统一做脱敏、校验、审计的再考虑新版中间件API。这个顺序不是绝对的但它符合先能观测再能容错然后省钱最后统一治理的演进路径。跳过观测阶段直接上后面的能力出了问题没有数据支撑排查范围会被拉得很大。我在实际项目里加装中间件时还有一个习惯每个中间件都单独做一个开关用环境变量控制是否启用。这样发布后如果发现某个中间件影响了效果可以快速地逐个关掉对比而不是临时改代码重新发版。这个习惯帮我省过好几次半夜救火的尴尬。中间件本身是辅助机制但它需要和业务一起灰度、一起验证把它当成一等公民来对待Agent才有机会从能跑走向稳跑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑