我在帮一个团队做 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_modelLLM 调用前最后一道“补给线”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_namehr_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_k3) # 3. 拼接成参考资料块 context_block \n\n.join( f[资料{i1}] {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_agentAgent 规划动作前的那道闸门比起before_modelbefore_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_namehr_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_k5) context_block \n\n.join( f[制度资料{i1}] {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( userctx[current_user].name, messagesrequest.messages, tool_names[t.name for t in request.tools], ) return request, ctx agent create_agent( modeldefault-model, tools[...], # 知识库工具、工资调整工具等 middleware{ before_agent: hr_agent_permission, before_model: hr_agent_inject, }, ) result agent.run(我想查一下这个月的考勤制度)你可能已经注意到我在两个中间件里都引用了ctx[current_user]。这说明在进入 Agent 之前我们已经把用户信息塞进了上下文。中间件的设计目标就是彼此协作有的负责读 ctx有的负责写 ctx大家在一个请求生命周期里共享状态。4.3 执行顺序与中间结果说明这串代码跑起来后一次典型的执行流程是这样的用户提问进入 Agent 框架hr_agent_permission先执行普通员工视角下的工具列表里已经没有salary_adjustment框架开始准备模型调用hr_agent_inject执行检索制度资料并追加到消息列表模型看到的是“全套工具可见列表 最新制度资料”模型返回答案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 项目里少一点玄学、多一点可控我建议把“权限、注入、审计”这三件套优先往中间件上迁移不出半天你就会感受到这种做法的好处。