news 2026/10/21 3:52:41

LangChain中间件实战:before_agent与before_model用法详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LangChain中间件实战:before_agent与before_model用法详解

我在帮一个团队做 Agent 平台改造的时候,遇到过一个很典型的问题:他们用 LangChain 已经搭好了问答机器人,但是想加入“把检索到的知识注进去”“有些工具不能让普通用户碰”“超预算的请求直接拦掉”这些逻辑时,发现无处下手。改 Prompt 吧,行为不稳定;塞在工具里吧,每次都要调一遍;用回调写吧,回调只能看不能改。后来 LangChain 1.0 引入中间件机制,before_agent、before_model这两个钩子正好把这类需求接住了。这篇文章我就围绕这两个中间件,讲清楚它们到底在什么时机触发、适合做什么、怎么写,以及实际项目里最容易踩的坑。

如果你已经熟悉基础的 Chain/Agent 用法,但正为“调用前想改东西却改不了”发愁,这篇文章应该能直接帮到你。

1. 为什么 LangChain 1.0 要引入中间件:从“只能看”到“可以动手改”

在 LangChain 1.0 之前,大部分开发者第一时间想到的扩展方式其实都不太顺手。我也想过用 Callback 拦截事件,发现它对某个事件响应时,能拿到的只是“输入是什么、输出是什么”,并不能安全地把输入改掉再传下去。想在模型调用前强行改 Prompt,只能靠包装一层自定义 Chain,或者覆写内部方法,本质上是在跟框架的内部实现较劲,版本一升级就崩。

1.1 Callback 的局限:观察者做不了决策者

Callback 在 LangChain 里的设计初衷是观察和记录。它就像车间里的监控摄像头,能拍到产品进出的全过程,但摄像头不能伸手去调整产线上的参数。如果你想在调用模型之前把某个系统提示词替换掉,Callback 只能记录“旧提示词是什么”,然后眼睁睁看着 LLM 用旧提示词跑完。

这是个架构层面的边界:回调是只读的,中间件是可读写的。before_model、before_agent这类钩子的价值就在于,它们把“改请求再放行”这个能力正式还给了开发者。我自己的感受是,从 0.x 迁移到 1.0,最先要适应的就是这个思维转变——以前是“我观察你调用模型”,现在是“我在调用模型之前动手改一批东西,你再拿去用”。

1.2 中间件约定:拿到请求对象,改完再返回

LangChain 1.0 的中间件虽然在不同版本里具体接口名略有差异,但核心模式是统一的:框架在执行到某个关键节点前,会调用你注册的钩子函数,传入一个请求对象和一个上下文对象。你在这个函数里修改请求,比如增删消息、改模型名、剔掉某些工具,然后返回修改后的请求和上下文,框架再继续往下走。

这里有个很关键的概念,就是上下文对象,可以把它理解成一个“随行背包”。前面钩子放进去的数据,后面钩子能读出来。比如你在before_agent里解析出用户身份存进ctx["user_role"],在before_model里就能直接读这个字段来决定要不要注入不同风格的提示词。跨钩子传数据,靠的就是这个 ctx,而不是全局变量。

1.3before_agent与before_model在生命周期中的位置

为了直观,我画过一张执行顺序表给自己用:

阶段触发节点典型用途
before_agentAgent 每一步规划动作之前权限检查、工具白名单控制、修改传给规划器的上下文
模型调用LLM 真正执行推理传给模型的 messages、tools、参数已经定型
before_model模型调用之前注入检索资料、改写 system prompt、限流、预算控制
模型输出拿到模型返回结果可观察,可记录
工具执行执行 Agent 选择的工具参数校验

大多数项目里,before_agent是比before_model更靠前的闸门,它在 Agent 准备决定“下一步做什么”时介入;而before_model是最后一次修改机会,过了它,message 内容就直接发往模型了。这两个钩子名称里都有 before,但职责边界其实很清晰:一个是管“Agent 要不要做、能做什么”,另一个是管“模型这次拿什么去思考”。搞清楚这个区别,后面写代码就不会乱。

2. before_model:LLM 调用前最后一道“补给线”

before_model是我用得最多的钩子。它解决的问题很直观:每一次模型调用之前,你还有机会塞点东西进去,或者决定这次调用干脆不要发了。从效果上来讲,它比改 Prompt 模板要稳得多,因为它是在真实调用链路上生效的,不依赖 LLM 是否能“听你的话”去执行某个隐含指令。

2.1 什么时候该用 before_model

我自己归纳下来,before_model适合的场景有这几类:

  • RAG 知识注入:根据用户问题检索资料,把资料拼进 system prompt,而不是让模型自己“想象”知识。
  • 上限控制:比如每分钟最多跑多少个请求,预算快用完了,直接返回一个降级提示。
  • 会话级配置:不同用户注入不同语气、不同地区语言偏好,甚至不同模型的 system prompt。
  • 补充动态参数:把当前时间、用户权限、库存数量等信息填到消息里。

它跟传统做法最大的区别是:以前你在 Chain 里加一个“准备消息”的步骤,现在这个步骤变成了模型调用前的强制关卡。好处是,即使 Agent 在中间步骤里又发了一次模型调用,before_model依然会生效。换句话说,不管模型被调多少次,每次都过了这道补给线。

2.2 核心写法示例:在模型调用前注入知识库上下文

我常用的大概是这样一份结构,字段名请你以自己安装的版本为准,但思路是通用的:

from langchain.middleware import before_model from knowledge_store import KnowledgeStore store = KnowledgeStore(index_name="hr_policy") @before_model def inject_retrieved_context(request, ctx): # 1. 拿到用户最近一条消息作为检索 query user_query = request.messages[-1]["content"] # 2. 检索知识库,取 top_k 条资料 docs = store.search(user_query, top_k=3) # 3. 拼接成参考资料块 context_block = "\n\n".join( f"[资料{i+1}] {doc.page_content}" for i, doc in enumerate(docs) ) # 4. 追加一条 system 消息,把资料塞给模型 request.messages.append( { "role": "system", "content": ( "你是一名企业内部客服助手。请优先依据下面的资料回答;" "如果资料不足,请明确回答'资料不足'。\n\n" f"参考资料:\n{context_block}" ), } ) # 5. 把检索结果元数据放入上下文,方便后面钩子做审计 ctx["retrieved_docs"] = [doc.metadata for doc in docs] return request, ctx

这里有一个容易被忽略的细节:request.messages的顺序。有些项目习惯把 system 消息放在最前面,有些则允许在中间追加。如果你在before_model里把知识库资料往后追加,而模型上下文窗口又比较紧张,建议把资料放在一个独立 system 消息里,并且紧跟在主 system 提示之后。这样模型在读取时更容易把资料纳入参考范围,而不是把它当成普通闲聊内容。

2.3 用 before_model 做限流与预算控制

除了注入知识,before_model另一个很实用的场景是硬性控制。我遇到过不少做企业内部工具的团队,模型调用费用每个月都在涨,他们都想在入口处做限制,但又不想在业务代码里到处埋点。中间件天然适合做这件事:

from middlewares import before_model @before_model def budget_enforcer(request, ctx): usage = ctx["cost_tracker"].today() if usage > 50.0: # 超过 50 美元,直接降级 return denied_response(ctx, "预算已用完,请明天再试") model_name = request.model_name estimated_cost = estimate_cost(model_name, request.messages) if estimated_cost > 5.0: # 超预算请求直接换成小模型 request.model_name = "lightweight-model" return request, ctx

你可以看到中间件的威力:同一个函数,既能在预算超限时直接阻断调用,又能在单次请求预估成本过高时自动降级模型。这在没有中间件的时候,往往要拆成好几个分支逻辑散落在各处,而且容易漏。现在它们被收敛在同一个关卡里,逻辑清晰,测试也容易写。

提示:denied_response我这里指的是直接构造一个假响应并短路后续 LLM 调用。不同框架对“短路”的接口实现不同,有些是设置request.blocked = True,有些是抛一个特殊异常。建议你在本地环境先做一个小实验,看看哪种方式在你的版本里最干净。

2.4 注意:不要在 before_model 里做重活

中间件确实强大,但要克制。它本质上是 LLM 调用前的一个同步关卡,如果在这里做高延迟的网络请求,或者大块文本处理,整个调用链都会被拖慢。我见过有人在before_model里直接同步调外部向量数据库,结果每一次模型调用都多出一百多毫秒延迟。实际上检索这类操作完全可以前置到before_agent或者更早的阶段,把结果暂存到 ctx 里,before_model只负责把 ctx 里的东西拼进消息。

这个分工很重要:你希望在哪个阶段承担“等待”,是检索的结果等待,还是“模型调用的准备阶段”等待。如果检索本身比较慢,把它放到before_model会让每轮调用都觉得卡;放到更早的环节,感知上会好很多。

3. before_agent:Agent 规划动作前的那道闸门

比起before_model,before_agent更容易让人困惑。第一次听这个名字,很多人以为它是“Agent 启动前只跑一次”的钩子,用起来才发现,它其实是跟着 Agent 的规划循环走的。理解这一点,是正确使用它的前提。

3.1 触发时机:它会在每一步动作规划前反复触发

在 LangChain 1.0 的中间件体系里,Agent 的执行不是一次性的“提问-回答”,而是一个循环:模型观察当前状态,决定调用哪个工具,执行工具,再把观察结果交给模型,直到模型给出最终答案。before_agent就是在这个循环的每一步里,在模型决定“下一步该做什么”之前被调用。

所以如果你在before_agent里打印一条日志,很可能会看到它在一次完整问答中被打印多次。这不是 bug,而是它的设计语义:给开发者一个机会,在 Agent 每次动作前检查现状。比如用户权限在中间步骤已经变了,或者某一步开始反复使用同一个工具需要干预,你都有机会拦下来。

3.2 典型用途:权限控制、工具白名单、目标拦截

我总结下来,before_agent最适合做三件事:

  • 工具权限过滤:根据用户角色,直接从他可见的工具列表里移除不该用的工具,而不是在 Prompt 里说“你不要用数据库工具”。后者经常失效,前者是硬性的,模型根本看不到那个工具。
  • 多轮上下文维护:如果 Agent 在多次动作中需要保留某些临时状态,可以在该钩子里统一维护并写入 ctx。
  • 行为护栏:当检测到 Agent 开始重复调用某个工具,或者陷入循环时,可以在这里把它拽出来。

这三个需求都有一个共同点:它们和模型推理的下一跳强相关,不适合放在before_model,因为before_model更关心这一次调用的输入,而不是 Agent 下一步要干什么。把两者拆开,各管各的,后期调试会很舒服。

3.3 核心写法示例:按角色裁剪工具列表

我先给一个权限控制的例子:

from langchain.middleware import before_agent @before_agent def restrict_tools_for_guest(request, ctx): user_role = ctx.get("user_role", "guest") if user_role == "guest": # 游客不能使用数据库执行和发票发送工具 request.tools = [ t for t in request.tools if t.name not in {"db_execute", "send_invoice"} ] return request, ctx

这段代码的价值在于:模型本身没有拒绝“使用敏感工具”的责任,是中间件在替它做决定。如果把db_execute留在工具列表里,哪怕 Prompt 里写了“只有管理员可以调用”,模型在某些场景下仍然可能误调用,因为它是按概率生成文本的,不是严格执行规则的。从工具列表层面摘除之后,模型连 token 序列里生成该工具名的可能都没有了,安全边界彻底闭合。

再举一个行为护栏的例子。如果 Agent 连续三轮都调用了同一个工具,你大概率要怀疑它陷入了死循环:

@before_agent def loop_guard(request, ctx): tool_history = ctx.setdefault("tool_history", []) if len(tool_history) >= 3 and all( h.get("tool") == tool_history[-1].get("tool") for h in tool_history[-3:] ): request.answered = True request.final_answer = "检索过程过于重复,我直接给你当前已知信息。" return request, ctx

这个钩子跟before_model不同,它操作的是“Agent 的执行决策”,而不是“模型的消息输入”。你说它是给 Agent 戴的缰绳也好,说是保护机制也好,总之它能让你在模型真正犯傻前拉一把。

注意:before_agent里如果要对ctx做持久化状态维护,务必关注它的作用域。有些版本里 ctx 是整条 Agent 链共用的,有些则可能随 step 重置。我习惯把所有跨步骤状态显式放进ctx,并且用setdefault初始化,这样即使框架行为有差异,也不至于因为缺少一个 key 直接崩溃。

3.4 与 before_model 的职责边界

我遇到不少同学会把用户身份校验放在before_model,把知识注入放在before_agent,反着用也能跑通,但长期维护会越来越乱。我建议按下面这个规则来分:

判定维度before_agentbefore_model
关注对象Agent 的整套执行循环单次模型调用的输入输出
执行频率每一步动作规划前每次模型调用前
适合做的事工具过滤、动作级权限、循环保护上下文注入、单次成本控制、消息改写
不适合做的事往消息里塞大段资料(会重复触发)修改 Agent 的工具列表

这个表格基本就是我在项目里给同事的“分诊表”。遇到需求,先判断它关注的是“整个 Agent 的动作”还是“一次模型调用”,就知道该挂在哪个钩子下面了。

4. 实战组合:给“内部知识问答 Agent”同时挂上两道中间件

纸上谈兵不如直接做一个例子。我这里用一个虚构的“内部 HR 知识助手”来演示两道中间件如何组合。这个场景不算复杂,但足够说明问题:Agent 既要能回答问题,又要能查部门数据库,还要防止普通员工执行危险操作。

4.1 业务需求拆解

我给它定了三个需求:第一,普通员工提问时,只能检索知识库和查询公开制度,不能打开“工资调整接口”;第二,每一位员工提问前,系统必须把最新的公司制度资料注入给模型,避免模型凭旧知识回答问题;第三,每一个问题都要记录日志,包括用户是谁、模型最终回复了什么。

如果不用中间件,这三个需求会散落在业务代码、Agent 内部甚至 Prompt 里,互相纠缠。用了中间件后,每个需求都能找到自己的位置:权限过滤挂before_agent,资料注入挂before_model,日志可以放before_model顺便记录模型收到的完整消息。

4.2 完整代码骨架

我给你一个可以直接照思路改写的骨架:

from langchain.agents import create_agent from langchain.middleware import before_agent, before_model from knowledge_store import KnowledgeStore from audit import write_audit_log store = KnowledgeStore(index_name="hr_policy") @before_agent def hr_agent_permission(request, ctx): user = ctx["current_user"] # 普通员工不能调用工资调整工具 if user.role == "employee": request.tools = [ t for t in request.tools if t.name != "salary_adjustment" ] return request, ctx @before_model def hr_agent_inject(request, ctx): # 每次模型调用前,都把最新制度资料注入 user_query = request.messages[-1]["content"] docs = store.search(user_query, top_k=5) context_block = "\n\n".join( f"[制度资料{i+1}] {doc.page_content}" for i, doc in enumerate(docs) ) request.messages.append( { "role": "system", "content": ( "你是 HR 制度解答助手。请优先依据下面制度资料回答;" "如果资料无法覆盖问题,请直接说'需要转人工'。\n\n" f"{context_block}" ), } ) ctx["last_injected_docs"] = [doc.metadata for doc in docs] # 同时做审计日志 write_audit_log( user=ctx["current_user"].name, messages=request.messages, tool_names=[t.name for t in request.tools], ) return request, ctx agent = create_agent( model="default-model", tools=[...], # 知识库工具、工资调整工具等 middleware={ "before_agent": hr_agent_permission, "before_model": hr_agent_inject, }, ) result = agent.run("我想查一下这个月的考勤制度")

你可能已经注意到,我在两个中间件里都引用了ctx["current_user"]。这说明在进入 Agent 之前,我们已经把用户信息塞进了上下文。中间件的设计目标就是彼此协作,有的负责读 ctx,有的负责写 ctx,大家在一个请求生命周期里共享状态。

4.3 执行顺序与中间结果说明

这串代码跑起来后,一次典型的执行流程是这样的:

  1. 用户提问进入 Agent 框架;
  2. hr_agent_permission先执行,普通员工视角下的工具列表里已经没有salary_adjustment;
  3. 框架开始准备模型调用,hr_agent_inject执行,检索制度资料并追加到消息列表;
  4. 模型看到的是“全套工具可见列表 + 最新制度资料”;
  5. 模型返回答案,Agent 根据是否有需要执行的动作决定继续还是结束。

因为这个 Agent 主要做问答,所以大概率一次就结束了。但如果模型第一轮决定调知识库工具,第二轮它还需要再思考,那么hr_agent_permission在第二轮仍会执行,hr_agent_inject也会再次执行。这就是中间件高覆盖率的体现:不管 Agent 内部循环多少轮,规则始终保鲜。

4.4 这样组合的好处

我特别喜欢这种写法的原因,是真个系统的逻辑都暴露在明面上。以前排查“为什么模型没看到最新制度”,要去查 Prompt 模板、查检索逻辑、查有没有缓存;现在只需要看before_model的日志就知道它到底注入了哪些资料。以前排查“为什么普通员工能调用工资接口”,要翻工具定义;现在只需要看before_agent里有没有把工具滤掉。

中间件把“横切关注点”拉平到了同一个平面,这在工程上是质变。

5. 真正容易踩的坑:顺序、异常、状态与“回调陷阱”

中间件好用,但不代表没有坑。这一节我把自己实际踩过、以及帮别人排查过的几类问题集中说一下,都挺典型的。

5.1 钩子执行顺序可能不是你想象的那样

中间件的注册顺序和执行顺序,不同框架有不同策略,有的按注册顺序,有的是后注册的先执行。我的建议是,绝不在业务代码里依赖隐式顺序。如果你有两个before_model,一个做知识注入,一个做敏感词过滤,最稳妥的做法是在一个中间件里显式调用两个处理函数,而不是注册两个钩子,靠框架“碰巧”按正确顺序执行。

我当时的做法是:把多个小函数组合成一个“复合中间件”,在内部按自己控制的顺序调用。这样即使框架升级、注册语义变了,我的业务代码也不受影响。

5.2 异常在中间件里怎么传

中间件里抛异常会终止整条链,这不是坏事,但你需要想清楚哪些异常是要终结调用,哪些是要降级的。我的建议是自定义一个异常类型,比如AgentDeniedError,在所有需要“拦截请求但不至于把进程搞崩”的场景里统一抛它,并且在最外层处理成用户友好的回复。

另外,不要在中间件里吞掉异常后硬着头皮继续执行。比如before_model里检索知识库失败,如果你直接把这个异常吞了,模型就会在缺资料的情况下回答,错误可能更隐蔽。正确做法是至少打个 warning,并且把“资料检索失败”这个状态写进 ctx,让后面的钩子可以对用户说“当前资料获取失败”。

5.3 ctx 作用域:同一个 Agent 实例,多次运行会串数据吗

这是我看过最多人踩的坑。ctx 如果设计成请求级,那么每次agent.run()都会创建一个干净的 ctx;但如果你自己不小心把可变对象放到了 Agent 实例的共享空间里,比如把工具历史列表定义成了类属性,那么第一次请求留下的状态会被第二次请求读到,造成诡异的串话现象。

我排查过的一个真实案例是:用户 A 问了敏感问题后,用户 B 再提问,模型竟然能“想起来”用户 A 问过什么。最终定位就是那个工具历史被放在了共享位置,而不是 ctx 里。记住:中间件状态必须跟着请求走,不要抱任何侥幸心理。

5.4 中间件里再调 LLM:小心“套娃”

中间件里直接调用 LLM 通常不算好主意。before_model本身就在模型调用链上,此时你再发起一次模型调用,轻则增加延迟,重则触发幽灵递归——新的模型调用又经过同一个中间件,又一次触发模型调用,最后把 token 配额烧光。

如果你确实需要在调用主模型前先让一个小模型做意图分类,建议把这个分类逻辑放到更早的阶段,比如在before_agent之前单独处理,然后把分类结果放进 ctx。这样主链路仍然只有一次模型调用。

5.5 中间件 vs 自定义工具 vs 预处理:到底选谁

这类问题几乎每次技术评审都会被问到。我的选择标准很简单:

  • 中间件:用于横切逻辑,和具体工具无关,比如权限过滤、预算控制、日志审计。
  • 自定义工具:用于给 Agent 增加动作能力,比如查数据库、发邮件、计算指标,是 Agent 决策后的“手和脚”。
  • 预处理模块:用于链在进入 Agent 前的准备阶段,通常只运行一次,比如用户身份解析、参数清洗。

要判断一项逻辑该放哪,就问一个问题:它是“每次都要强制生效的规则”,还是“Agent 可选的能力”。前者是中间件,后者是工具。把不该作为工具的规则做成了工具,就会给模型留下“可选跳过”的余地;把工具做成了中间件,就会让 Agent 丧失动态选择能力。这个边界想清楚,架构就不会太歪。

我实际用下来最大的感受是:中间件把很多以前跟 Prompt 较劲的事变成了跟代码较劲。以前我想让模型“不要乱用工具”,要在 Prompt 里措辞激烈一点再碰运气;现在直接在before_agent里把工具摘了,干净利落。如果你想在新的 Agent 项目里少一点玄学、多一点可控,我建议把“权限、注入、审计”这三件套优先往中间件上迁移,不出半天你就会感受到这种做法的好处。

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

VHDL四位BCD计数器工程详解:分频、扫描显示与仿真避坑指南

简介:一套基于VHDL的4位十进制动态扫描显示电路设计文件,对应DTCNT9999.zip,适用于EDA技术学习者、数字逻辑课程师生及FPGA入门工程师。设计实现0~9999计数及动态扫描显示,涵盖计数器逻辑、段驱动解码与扫描控制等核心…

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

STM32F10x标准库3.6.0:嵌入式硬实时开发的确定性基准

简介:本资源为STM32F10x系列官方标准外设库最终版(v3.6.0),面向嵌入式初学者、高校课程设计者及基于Cortex-M3内核的固件开发工程师,解决底层寄存器操作复杂、开发效率低、代码可移植性差等核心痛点。压缩包共960个文件…

作者头像 李华
网站建设 2026/10/21 3:51:57

WorkBuddy企业版白皮书:从超级个体到超级团队的协作转型路线图

WorkBuddy 企业版产品白皮书终于上线了,免费领取通道已经打开。这本白皮书聊的主题,就是标题里那句“从超级个体到超级团队”。我们团队在过去大半年里,几乎每周都在讨论一个有点扎心的问题:AI 工具已经把单兵作战能力拉得很高了&…

作者头像 李华
网站建设 2026/10/21 3:51:05

AI大模型赋能企业数字化转型:ERP/MES落地实践与避坑指南

开头:一场被ERP/MES包围却依然“拍脑袋”的会议我参加过不少制造业的数字化例会。屏幕上是整齐的看板,ERP里的订单流、MES里的工单执行率、WMS里的库存周转天数、APS里的排产达成率,每个数据都清清楚楚。但真到决策环节,还是那句老…

作者头像 李华
网站建设 2026/10/21 3:50:44

AI基础设施开源论坛解读:从算力调度到模型服务的底座构建

算力、数据、模型,这三样东西如今在AI圈的讨论热度几乎天天都在刷新。可如果让我给一个刚入行的朋友点出最容易被低估的部分,我一定会说:基础设施。今年开源社区的年度峰会上,AI 基础设施开源论坛的完整议程正式对外发布&#xff…

作者头像 李华