1. 从一次踩坑说起:为什么中间件才是 Agent 的胜负手
去年年底我接手了一个内部知识库问答 Agent 的活儿,需求听起来不复杂:能查文档、能调接口、能记住上下文、能多轮追问。我一开始用的是最朴素的写法,一个AgentExecutor加几个 Tool,跑通 demo 只花了半天。结果上线第三天就出事了——用户连续追问五轮之后,Agent 开始胡言乱语,把上一轮的检索结果当成这一轮的事实依据,还煞有介事地编了个不存在的接口返回值。
排查了两天才定位到根因:不是模型不行,也不是 Prompt 写得烂,而是上下文管理、工具调用、状态持久化这三件事全糊在一个循环里,没有任何一层做拦截和治理。这就好比你家水管、电线、燃气管全捆在一起走,平时没事,一旦哪根出问题,整栋楼都得停。
后来我把这套逻辑重构成了带中间件层的结构,用的就是DeepAgents 的中间件机制(底层依托 LangChain 的createMiddleware体系)。重构之后,同样的问题再没复现过,而且新增一个"敏感词过滤"能力只花了二十分钟——写一个中间件挂上去就行,完全没动主流程代码。
这篇就围绕AI Agent 中间件这个主题,把 DeepAgents 中间件到底解决什么问题、核心机制怎么运转、怎么落地写一个自己的中间件、以及我踩过的那些坑,一次性讲透。不管你是刚接触 LangChain 的新手,还是已经搭过几套 Agent 的老手,应该都能从里面抠出点能直接抄的东西。
先给个一句话定位:DeepAgents 中间件是夹在 Agent 主循环和具体执行动作之间的一层可插拔治理层,负责在请求进入模型前、工具调用前后、响应返回前这些关键节点做拦截、改写、记录和兜底。它不改变 Agent 的核心决策逻辑,但决定了这个 Agent 能不能上生产。
2. DeepAgents 中间件到底在解决什么问题
2.1 没有中间件的 Agent 长什么样
先看一个典型的"裸奔" Agent 结构。用 LangChain 搭的话,大概是这么个流程:
# 伪代码,展示裸奔结构 while not done: response = llm.invoke(messages) # 调模型 if response.has_tool_call: result = execute_tool(response.tool) # 调工具 messages.append(result) # 塞回上下文 else: done = True这段代码能跑,但问题一大堆。我列一下实际生产中会遇到的:
- 上下文无限膨胀:每轮工具返回都往
messages里塞,十轮之后 token 直接爆掉,要么报错要么被截断,截断之后模型就"失忆"了。 - 工具调用没有权限校验:模型说调
delete_user就真调了,没有任何拦截。 - 失败没有重试和降级:工具超时了,整个 Agent 就卡死或者直接抛异常给用户。
- 没有可观测性:出了事你根本不知道是哪一轮、哪个工具、什么参数导致的。
- 敏感信息裸奔:用户手机号、身份证号直接进模型上下文,合规上过不去。
这些问题有个共同点:它们都不是"决策"问题,而是"治理"问题。模型该不该调工具是决策,调之前要不要检查权限是治理。把治理逻辑硬塞进主循环,代码会迅速腐烂成一坨意大利面。
2.2 中间件层的核心价值定位
DeepAgents 中间件的思路,本质上是把"治理"从主循环里抽出来,做成一个个独立的、可组合的钩子。你可以把它理解成 Web 开发里的 Express/Koa 中间件——请求进来,一层层过,每层都能决定是放行、改写还是直接返回。
它带来的直接好处有这么几个:
| 维度 | 无中间件 | 有中间件 |
|---|---|---|
| 上下文管理 | 手动拼接,易爆 | 中间件统一裁剪、摘要 |
| 权限控制 | 散落在各 Tool 里 | 集中拦截,一处配置 |
| 可观测性 | 靠打日志,零散 | 统一埋点,全链路追踪 |
| 失败处理 | 各写各的 | 统一重试/降级策略 |
| 扩展新能力 | 改主流程 | 挂一个中间件 |
我特别想强调最后一行。中间件最大的价值不是解决当下问题,而是让"未来加需求"这件事变得便宜。你想想,产品经理今天要加个"回答必须带引用来源",明天要加个"超过三次工具调用就转人工",后天要加个"输出前做一次敏感词过滤"——如果每次都要动主循环,你迟早会把主循环改成一个谁都不敢碰的黑盒。而中间件模式下,这些都是独立文件,加一个挂一个,互不干扰。
2.3 和 LangChain 原生能力的边界
这里得说清楚一个容易混淆的点:LangChain 本身有 Callback、有Runnable的before/after,那 DeepAgents 中间件和它们是什么关系?
我的理解是:LangChain 的 Callback 偏"观察",中间件偏"干预"。Callback 主要是让你知道发生了什么(打日志、上报指标),它不太方便去改写输入输出、中断流程。而中间件是可以在链路中间"动手"的——改 Prompt、改工具参数、直接短路返回、抛异常终止。
DeepAgents 的createMiddleware就是在这个基础上,把干预点标准化了。它定义了几个明确的钩子位置,你只需要实现对应的方法,框架会在正确的时机调用你。这比自己去 hack Callback 要干净得多,也更可控。
3. 核心机制拆解:中间件的钩子与执行顺序
3.1 四个关键拦截点
DeepAgents 中间件最核心的设计,是把 Agent 的一次完整执行拆成了几个可拦截的节点。我按执行顺序梳理一下,这也是理解整个机制的钥匙:
- 请求前置(beforeModel):在消息发给模型之前触发。这里能拿到完整的 messages 列表,可以裁剪、可以注入系统提示、可以做敏感信息脱敏。
- 模型响应后(afterModel):模型返回之后、工具执行之前触发。这里能拿到模型的原始输出,可以校验它要调的工具是否合法、参数是否合规。
- 工具执行前(beforeTool):真正调用工具之前触发。可以做权限校验、参数二次加工、限流。
- 工具执行后(afterTool):工具返回之后触发。可以做结果清洗、错误包装、缓存写入。
这四个点基本覆盖了 Agent 一次循环里所有"值得干预"的位置。你写中间件,就是选一个或多个点去实现。
注意:不同版本的 DeepAgents 钩子命名可能略有差异,有的叫
wrapModelCall、wrapToolCall,但语义是一致的。核心是理解"前置/后置"这个二分法,命名只是壳。
3.2 洋葱模型与执行顺序
多个中间件叠加时,执行顺序遵循经典的洋葱模型。假设你挂了 A、B、C 三个中间件,那么:
请求进入 → A前置 → B前置 → C前置 → 模型/工具 → C后置 → B后置 → A后置 → 返回这个顺序非常关键,直接决定了你的中间件能不能正确工作。举个实际例子:如果你有一个"脱敏中间件"和一个"日志中间件",脱敏必须在前置阶段先执行,日志才能记录到脱敏后的内容。如果你把日志挂在脱敏外层,那日志里就会记下原始敏感信息,等于白脱敏。
我踩过一次这个坑:把限流中间件挂在了重试中间件的外层,结果重试的每一次都被算作一次独立请求,限流阈值瞬间被打满。后来把顺序调过来——限流在外、重试在内——才符合预期。顺序错了,逻辑再对也是错的。
3.3 中间件的状态传递
中间件之间怎么共享数据?比如脱敏中间件想把"这次请求涉及的用户 ID"传给后面的审计中间件。DeepAgents 一般会提供一个 context 对象,贯穿整条链路。
def my_middleware(): def before_model(state, context): # 从 context 里读,或者往里写 context["user_id"] = extract_user_id(state["messages"]) return state return {"beforeModel": before_model}这个 context 是单次请求级别的,请求结束就销毁,不会跨请求污染。这点很重要——我见过有人把 context 写成了全局变量,结果并发一上来,A 用户的上下文串到了 B 用户那里,出了严重的数据泄露。永远不要把请求级状态放到全局。
4. 手把手写一个生产级中间件
4.1 环境准备与依赖确认
动手之前先把环境理清楚。DeepAgents 中间件依赖 LangChain 的核心包,版本上要留意,不同大版本 API 差异不小。
pip install langchain langchain-core deepagents # 确认版本,建议锁死 pip show langchain-core我一般会在项目里建一个requirements.txt把版本钉死,因为 LangChain 生态迭代快,今天能跑的代码下个月可能就报ImportError。这不是危言耸听,我被坑过不止一次。
langchain==0.3.x langchain-core==0.3.x deepagents==0.x.x提示:如果你的项目还要接 Redis 做状态存储(热词里提到的 redis 做中间件,其实指的是会话状态持久化,和这里的治理中间件是两个概念,别搞混),那还要额外装
redis和langchain-redis。
4.2 第一个中间件:上下文裁剪
这是我认为最该优先做的中间件,没有之一。因为 token 爆炸是 Agent 上生产后第一个会撞上的墙。
思路很简单:在beforeModel阶段检查 messages 的总长度,超阈值就做裁剪。裁剪策略我一般用"保留系统提示 + 最近 N 轮 + 对早期内容做摘要"。
from langchain_core.messages import SystemMessage, trim_messages def context_trim_middleware(max_tokens=4000, keep_last=6): def before_model(state, context): messages = state["messages"] # 先做 token 估算,粗略按字符数 / 3 估 estimated = sum(len(str(m.content)) for m in messages) // 3 if estimated <= max_tokens: return state # 超了就裁剪,保留系统消息和最近几轮 trimmed = trim_messages( messages, max_tokens=max_tokens, strategy="last", token_counter=len, include_system=True, ) context["trimmed"] = True return {**state, "messages": trimmed} return {"beforeModel": before_model}这里有几个细节值得说。第一,token 估算我用的是"字符数除以 3"这种土办法,因为精确计算要调 tokenizer,每次请求都算一遍开销不小。粗略估算留 20% 余量就够了。第二,include_system=True一定要开,否则系统提示被裁掉,Agent 直接"失忆",行为会变得莫名其妙。第三,我在 context 里打了个trimmed标记,方便后面审计中间件知道这次请求被裁剪过。
实测下来,这个中间件能把长对话场景的 token 消耗压下来 60% 以上,而且因为保留了最近几轮,用户几乎感知不到"失忆"。
4.3 第二个中间件:工具权限校验
工具权限这事,我强烈建议不要写在每个 Tool 内部。原因很简单:Tool 是给模型看的,模型可能被诱导去调不该调的工具;而权限是业务规则,应该独立于 Tool 存在。
TOOL_PERMISSIONS = { "search_docs": ["user", "admin"], "query_database": ["admin"], "delete_record": ["admin"], } def tool_permission_middleware(get_user_role): def before_tool(tool_name, tool_input, context): role = get_user_role(context) allowed = TOOL_PERMISSIONS.get(tool_name, []) if role not in allowed: # 直接短路,返回一个"拒绝"结果给模型 return { "blocked": True, "reason": f"当前角色 {role} 无权调用 {tool_name}", } return None # 返回 None 表示放行 return {"beforeTool": before_tool}这个中间件的关键设计是短路返回。当权限不足时,不是抛异常终止整个 Agent,而是返回一个结构化的"拒绝结果",让模型自己决定下一步——比如告诉用户"这个操作需要管理员权限"。这样用户体验是连贯的,而不是突然报错。
注意:短路返回的内容一定要清晰告诉模型"发生了什么",否则模型可能会反复重试同一个被拒的工具,陷入死循环。我一般会在拒绝信息里明确写"请勿重试,直接告知用户"。
4.4 第三个中间件:可观测性埋点
这个中间件不改变任何行为,但它是你排查问题的命根子。我要求团队里所有 Agent 项目必须挂这个。
import time import logging logger = logging.getLogger("agent.trace") def observability_middleware(): def before_model(state, context): context["model_start"] = time.time() context["trace_id"] = generate_trace_id() return state def after_model(response, context): cost = time.time() - context.get("model_start", time.time()) logger.info({ "trace_id": context["trace_id"], "stage": "model", "latency": round(cost, 3), "output_len": len(str(response)), }) return response def after_tool(tool_name, result, context): logger.info({ "trace_id": context.get("trace_id"), "stage": "tool", "tool": tool_name, "result_len": len(str(result)), }) return result return { "beforeModel": before_model, "afterModel": after_model, "afterTool": after_tool, }埋点数据我一般会打到结构化日志里,再接到监控平台。有了 trace_id,一次请求从进到出经过哪些中间件、每个环节耗时多少、调了哪些工具,全都串得起来。出问题的时候,直接拿 trace_id 一搜,比翻半天日志高效太多。
4.5 组装与注册
三个中间件写好了,怎么挂上去?DeepAgents 的createMiddleware一般支持传入一个中间件列表,按顺序执行。
from deepagents import create_agent, createMiddleware agent = create_agent( model=llm, tools=[search_docs, query_database], middleware=createMiddleware([ observability_middleware(), # 最外层,记录全链路 context_trim_middleware(), # 上下文治理 tool_permission_middleware(get_user_role), # 权限 ]), )顺序上我的经验是:可观测性放最外层,治理类放内层。因为可观测性要记录的是"最终实际执行"的内容,放外层能捕获到所有内层中间件处理后的结果。而权限校验要尽量靠近工具执行,避免被其他中间件干扰。
5. 常见问题与排查实录
5.1 中间件不生效的几种典型情况
这是新手最容易卡住的地方。我整理了一个速查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 中间件完全没被调用 | 钩子名写错 | 打印中间件注册日志,确认 key 名 |
| 前置生效后置不生效 | 前置里抛了异常 | 检查前置返回值是否合法 |
| 顺序和预期不符 | 列表顺序反了 | 记住洋葱模型,外层先执行 |
| 改了 state 但没生效 | 没返回新 state | 前置必须 return 修改后的 state |
| 并发下数据串了 | 用了全局变量 | 改用 context 传请求级状态 |
我重点说下第一条。钩子名这东西,不同版本真的会变。我遇到过beforeModel和before_model两种写法,写错了框架不报错,就是静默不调用,特别坑。建议在中间件里加一行日志,确认它真的被触发了。
5.2 上下文裁剪把关键信息裁没了
这个坑我踩得很深。有一次用户问"我刚才提到的那个订单号是多少",结果裁剪中间件把包含订单号的那轮对话裁掉了,Agent 一脸茫然。
解决办法是给裁剪加"锚点保护"。具体做法是:在裁剪前,扫描 messages,把包含关键实体(订单号、用户 ID、金额等)的消息标记为"不可裁",裁剪时跳过它们。
def is_anchor_message(msg): # 简单实现:包含数字 ID 模式的消息视为锚点 import re return bool(re.search(r"\b\d{6,}\b", str(msg.content)))这个逻辑不复杂,但能极大提升裁剪后的对话连贯性。你也可以做得更精细,比如用一个小模型来判断哪条消息重要,但那样开销就上去了,看场景取舍。
5.3 工具重试导致的重复副作用
重试中间件是个好东西,但用在有副作用的工具上会出大事。比如"下单"工具超时了,你重试一次,用户就被下了两单。
我的处理原则是:只对幂等工具开启自动重试。在工具定义里加一个idempotent标记,重试中间件只对标记为 True 的工具生效。
IDEMPOTENT_TOOLS = {"search_docs", "query_database"} def retry_middleware(max_retry=2): def after_tool(tool_name, result, context): if tool_name not in IDEMPOTENT_TOOLS: return result if is_error(result) and context.get("retry_count", 0) < max_retry: context["retry_count"] = context.get("retry_count", 0) + 1 return {"retry": True} return result return {"afterTool": after_tool}提示:非幂等工具的重试,一定要配合业务侧的幂等键(比如订单号去重),否则迟早出事故。
5.4 中间件性能开销
中间件多了,每次请求都要过一遍,开销是实打实的。我实测过,三个轻量中间件(裁剪、权限、埋点)加起来大概增加 5-15ms,基本可以忽略。但如果你在中间件里做了同步的网络调用(比如每次都查一次数据库校验权限),那开销就上去了。
优化思路有两个:一是缓存,权限这类变化不频繁的数据可以缓存几分钟;二是异步化,埋点日志这种不阻塞主流程的操作,扔到后台队列去写。
6. 中间件设计的几条经验法则
写了这么多中间件,我总结出几条自己一直遵守的原则,分享出来供参考。
第一,一个中间件只做一件事。我见过有人把裁剪、脱敏、埋点全塞一个中间件里,结果改一处影响三处,维护起来痛不欲生。拆开之后,每个文件职责单一,测试也好写。
第二,中间件要能独立开关。通过配置控制每个中间件是否启用,这样出问题的时候可以快速关掉某个中间件定位问题,而不用改代码重新部署。
第三,中间件不能假设自己一定被调用。框架版本升级、配置错误都可能导致中间件失效,所以核心业务逻辑不能依赖中间件来保证正确性。中间件是"增强",不是"必需"。
第四,给中间件写测试。尤其是权限和裁剪这种逻辑,一定要有单元测试覆盖边界情况。我一般会构造一批"恶意输入"来测权限中间件,比如让模型尝试调用不存在的工具、传超长参数等。
第五,日志要能区分中间件。每个中间件打日志时带上自己的名字,排查时一眼就能看出是哪一层出的问题。
最后再分享一个我最近在用的技巧:把中间件的执行情况也纳入 Agent 的自我评估。比如裁剪中间件触发了,就在最终响应里加一个隐式的标记,让评估系统知道这次回答是基于裁剪后的上下文生成的,准确率评估时要打个折扣。这个思路有点绕,但确实能提升整体评估的准确性。
这套中间件体系搭下来,我的 Agent 项目从"能跑 demo"到"敢上生产"之间,少走了至少两个月的弯路。如果你现在正卡在 Agent 稳定性问题上,不妨从上下文裁剪和权限校验这两个中间件开始动手,收益最直接。