ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

DeepAgents中间件实战:从洋葱模型到Agent工程化落地

DeepAgents中间件实战:从洋葱模型到Agent工程化落地 1. 从能跑到能管DeepAgents 中间件到底在解决什么如果你已经用 LangChain 搭过几个 Agent大概率经历过这个阶段Demo 跑得飞起一旦接入真实业务就开始失控。工具调用顺序乱、上下文越滚越长、某个工具报错整个链路直接崩、想加个日志得改一堆代码。这些问题不是模型能力不够而是Agent 的执行流程缺少可插拔的管控层。DeepAgents 的中间件机制就是冲着这个痛点来的。它借鉴了 Web 框架里 middleware 的经典设计思路——在 Agent 执行的关键节点上开放钩子让你能在不改动核心逻辑的前提下插入日志、限流、上下文压缩、错误恢复、权限校验等横切关注点。关键词里的createMiddleware就是这套机制的入口函数而LangChain则是它依托的底层生态。这篇文章适合两类人看一类是已经写过 LangChain Agent、正被工程化问题折磨的开发者另一类是正在选型 Agent 框架、想搞清楚中间件这个抽象到底值不值得引入的技术负责人。我会从设计动机讲起拆解中间件的执行模型给出可直接复现的代码最后分享几个我在实际项目里踩过的坑。先说结论中间件不是银弹但它是 Agent 从玩具走向生产环境的必经之路。没有它你的业务逻辑会和框架逻辑缠成一团有了它你才能像搭积木一样组合能力。2. DeepAgents 中间件的执行模型拆解2.1 为什么是洋葱模型而不是事件总线很多人第一次接触中间件会问为什么不干脆用事件监听发个on_tool_call事件谁想处理谁注册不就行了事件总线的问题在于它只通知不拦截。你没法在事件回调里决定这次工具调用我不让它执行也没法修改即将传给模型的上下文。而 Agent 场景里拦截和改写恰恰是最刚需的能力——比如检测到某个工具连续失败三次你得直接短路掉后续调用比如上下文超过阈值你得在发送前压缩历史。DeepAgents 采用的是经典的洋葱模型请求从外层中间件逐层进入到达核心执行逻辑后再逐层穿出。每一层都能拿到下一个处理者的引用可以选择调用它、跳过它、或者包裹它。这种结构天然支持前置处理和后置处理也支持在任意层中断链路。用生活化的类比事件总线像公司群里的通知消息你看到了但管不了洋葱模型像审批流每一级都能签字、驳回或者加备注。2.2 中间件的生命周期钩子DeepAgents 的中间件围绕 Agent 的一次完整执行周期设计了若干钩子。虽然具体命名可能随版本演进但核心节点是稳定的理解这些节点比记住函数名更重要钩子阶段触发时机典型用途执行前Agent 开始处理输入时注入系统提示、初始化状态、权限预检模型调用前每次请求 LLM 之前上下文裁剪、敏感词过滤、token 预算控制模型调用后收到 LLM 响应之后响应解析、格式校验、重试判断工具调用前即将执行某个工具时参数校验、限流、审计日志工具调用后工具返回结果之后结果脱敏、缓存写入、错误包装执行后整个 Agent 流程结束时资源清理、指标上报、会话归档这张表建议你贴在显示器旁边。写中间件时先问自己我要做的事属于哪个阶段选错钩子会导致逻辑执行时机不对比如在工具调用后做参数校验就晚了。2.3 createMiddleware 的签名与返回值逻辑createMiddleware是定义中间件的工厂函数。它的设计哲学是配置即中间件——你传入一个配置对象它返回一个符合中间件接口的实例。一个中间件的配置对象通常包含三部分name唯一标识用于调试和去重、hooks各生命周期钩子的实现、options中间件自身的配置参数比如限流的阈值、日志的级别。这里有个容易忽略的细节中间件的注册顺序决定了洋葱的层叠顺序。先注册的在外层后注册的在内层。这意味着如果你有一个全局日志中间件和一个业务限流中间件日志应该注册在前面这样它能记录到限流拦截的情况。顺序搞反了被拦截的请求就不会出现在日志里。提示调试中间件顺序问题时可以在每个中间件的入口和出口各打一条带name的日志执行轨迹会呈现清晰的嵌套结构一眼就能看出层叠关系。3. 手写第一个中间件从日志到上下文压缩3.1 最小可用中间件结构化日志先从一个最朴素的日志中间件开始把骨架跑通。假设你已经装好了 DeepAgents 和 LangChain 相关依赖pip install deepagents langchain langchain-openai然后定义一个日志中间件from deepagents import createMiddleware def logging_middleware(logger): def before_tool_call(context, next_handler): logger.info( tool_call_start, toolcontext.tool_name, argscontext.tool_args, sessioncontext.session_id, ) result next_handler(context) logger.info( tool_call_end, toolcontext.tool_name, successresult.success, duration_msresult.duration_ms, ) return result return createMiddleware( namestructured_logging, hooks{ before_tool_call: before_tool_call, }, )注意next_handler(context)这一行——它就是洋葱模型的核心。调用它意味着把控制权交给下一层不调用就意味着我在这里截断了。返回值要原样透传否则上层拿不到结果。这个中间件看起来简单但它解决了一个真实痛点Agent 出问题时你需要知道它到底调了什么工具、传了什么参数、花了多久。靠 print 打日志在 Demo 里够用在生产环境里必须有结构化的、可检索的记录。3.2 上下文压缩中间件控制 token 成本Agent 跑久了上下文会爆炸这是所有做 Agent 开发的人都躲不开的问题。关键词里有人搜ai agent token是什么意思本质上就是在关心成本。一个对话轮次多了之后历史消息累积到几万 token每次调用都在烧钱而且模型对超长上下文的注意力也会下降。上下文压缩中间件的作用是在模型调用前这个钩子里对历史消息做裁剪或摘要def context_compression_middleware(max_tokens4000, keep_recent6): def before_model_call(context, next_handler): messages context.messages estimated estimate_tokens(messages) if estimated max_tokens: recent messages[-keep_recent:] older messages[:-keep_recent] summary summarize_messages(older) context.messages [ {role: system, content: f历史摘要{summary}} ] recent return next_handler(context) return createMiddleware( namecontext_compression, hooks{before_model_call: before_model_call}, )这里有几个实操要点。第一keep_recent不能太小否则模型会丢失最近的对话连贯性表现为答非所问。我一般保留最近 6 到 8 轮。第二摘要本身也要花一次模型调用所以要判断压缩省下的 token是否值得摘要消耗的 token短上下文就别压了。第三摘要提示词要明确要求保留用户意图、已确认的事实、未完成的任务否则摘要容易丢关键信息。注意压缩后的消息结构要符合底层模型的角色规范system 消息的位置和数量在不同模型上行为不一致建议压缩后跑一遍回归测试。3.3 错误恢复中间件让 Agent 不那么脆工具调用失败是常态——网络抖动、API 限流、参数格式错误。默认情况下一次失败可能就让整个 Agent 流程中断。错误恢复中间件在工具调用后钩子里判断结果决定是重试、降级还是向上抛出def retry_middleware(max_retries2, backoff1.5): def after_tool_call(context, next_handler): result next_handler(context) attempt 0 while not result.success and attempt max_retries: attempt 1 sleep(backoff ** attempt) result next_handler(context) if not result.success: result.fallback_message ( f工具 {context.tool_name} 暂时不可用已尝试 {attempt} 次 ) return result return createMiddleware( nametool_retry, hooks{after_tool_call: after_tool_call}, )关键设计点是退避策略。固定间隔重试在遇到限流时只会加剧问题指数退避1.5 的幂次能有效错开请求。另外重试次数要有上限无限重试会让 Agent 卡死。最后失败后不要直接抛异常而是给模型一个可读的降级消息让它有机会换个思路继续任务——这比直接崩溃体验好得多。4. 中间件组合与执行顺序的实战陷阱4.1 顺序错了逻辑全废中间件最反直觉的地方在于同样的中间件集合换个注册顺序行为可能完全不同。举个真实例子。我做过一个项目同时注册了权限校验和参数补全两个中间件。权限校验检查用户是否有权调用某工具参数补全负责给缺失的参数填默认值。最初我把权限校验放在外层结果发现用户调用一个需要补全参数的工具时权限校验因为参数不全直接拒绝了。正确的顺序应该是参数补全在内层先执行权限校验在外层拿到补全后的完整参数再判断。这个 bug 排查了整整一个下午因为两个中间件单独测试都正常。场景错误顺序正确顺序原因校验补全校验在外补全在外校验需要完整参数日志限流限流在外日志在外日志要记录被限流的请求缓存脱敏脱敏在外缓存在外缓存应存脱敏后的数据压缩审计审计在外压缩在外审计要基于原始上下文这张表是我从多个项目里总结出来的建议收藏。判断顺序的通用原则是先做改变数据的后做读取数据做判断的先做记录的后做拦截的。4.2 中间件之间的状态传递中间件之间经常需要共享状态。比如限流中间件统计了调用次数审计中间件想把这个数字写进日志。DeepAgents 通常提供两种方式一是通过context对象挂载自定义字段二是通过中间件实例的闭包变量。我推荐用context挂载因为它是请求级别的天然隔离不同会话。闭包变量是进程级别的多会话并发时会串数据。踩过一次坑用闭包变量存当前用户结果两个用户同时请求时A 的操作被记到了 B 的账上。def rate_limit_middleware(limit10): def before_tool_call(context, next_handler): key f{context.session_id}:{context.tool_name} count context.get_state(key, 0) 1 context.set_state(key, count) if count limit: raise RateLimitExceeded(f{context.tool_name} 调用超限) return next_handler(context) return createMiddleware( namerate_limit, hooks{before_tool_call: before_tool_call}, )context.get_state和set_state是请求级存储会话结束自动清理不用手动管理生命周期。4.3 中间件不该做的事写中间件容易上瘾什么都想往里塞。但有几类逻辑放中间件里是错的业务逻辑不要放中间件。中间件是横切关注点如果某个逻辑只对特定工具生效它应该写在工具实现里而不是中间件里加一堆 if-else 判断工具名。重计算不要放中间件。中间件在每个请求都会执行如果你在里面做 embedding 计算或者大文件解析延迟会直接叠加到每次调用上。这类操作应该前置到会话初始化阶段。有副作用的操作要谨慎。中间件可能因为重试被多次执行如果你的中间件里有写数据库这种操作重试时就会重复写入。要么保证幂等要么把副作用移到明确的钩子里。5. 把中间件接入真实 Agent 流程5.1 与 LangChain Agent 的装配方式DeepAgents 的中间件最终要挂到 Agent 实例上。装配时通常有两种模式全局中间件和工具级中间件。全局中间件对所有工具调用生效适合日志、限流、审计这类通用能力。工具级中间件只对特定工具生效适合参数校验、结果脱敏这类针对性逻辑。from deepagents import DeepAgent agent DeepAgent( modelgpt-4o-mini, tools[search_tool, database_tool, email_tool], middleware[ logging_middleware(logger), context_compression_middleware(max_tokens4000), retry_middleware(max_retries2), rate_limit_middleware(limit10), ], tool_middleware{ database_tool: [sql_injection_guard()], email_tool: [pii_redaction()], }, )装配顺序就是执行顺序前面讲的顺序原则在这里直接适用。我习惯把日志放最外层这样任何被拦截、被压缩、被重试的请求都能被完整记录。5.2 中间件的可观测性中间件跑起来之后你得知道它到底有没有生效、效果如何。这里分享几个我常用的观测手段。第一给每个中间件打点。在钩子入口和出口记录时间戳算出每个中间件的耗时。如果某个中间件耗时占比过高说明它做了不该做的事。第二统计拦截率。限流中间件拦截了多少请求、权限中间件拒绝了多少调用、压缩中间件平均压缩比是多少。这些指标能直接反映中间件的价值。第三追踪中间件链路。给每个请求生成一个 trace_id所有中间件的日志都带上它出问题时能完整还原一次调用的中间件执行路径。def tracing_middleware(tracer): def wrap(hook_name): def hook(context, next_handler): span tracer.start_span(hook_name, trace_idcontext.trace_id) try: return next_handler(context) finally: span.end() return hook return createMiddleware( nametracing, hooks{ before_model_call: wrap(model_call), before_tool_call: wrap(tool_call), }, )这套打点方案在排查为什么这次调用特别慢时特别有用能一眼看出瓶颈在哪个中间件。5.3 性能开销的实测数据中间件不是免费的。我在一个中等规模的项目里做过对比测试Agent 平均每次任务调用 5 次工具、3 次模型中间件带来的额外开销如下中间件平均额外耗时说明结构化日志2-5ms取决于日志写入方式异步写入更快上下文压缩50-200ms含一次摘要模型调用是主要开销错误重试0ms无失败时只在失败时产生退避等待限流1ms纯内存计数链路追踪3-8ms取决于 span 上报方式结论很清晰压缩中间件是性能大头其他中间件开销可忽略。所以压缩的触发阈值要调好别频繁触发。我的经验是把阈值设在模型上下文窗口的 70% 左右既留了余量又不会过度压缩。6. 几个只有踩过才知道的坑6.1 中间件里的异常会吞掉原始错误中间件里抛异常时如果不做处理上层看到的往往是中间件的异常而不是原始的工具错误。这会让排查变得极其困难。我的做法是在中间件里用 try-except 包裹next_handler调用捕获后附加中间件上下文再重新抛出def safe_middleware(): def before_tool_call(context, next_handler): try: return next_handler(context) except Exception as e: e.middleware_context { middleware: safe_middleware, tool: context.tool_name, } raise return createMiddleware(namesafe, hooks{before_tool_call: before_tool_call})这样错误堆栈里既有原始异常又有中间件信息定位效率翻倍。6.2 异步中间件的坑如果 Agent 是异步执行的中间件钩子也必须是异步的。混用同步和异步会导致事件循环阻塞表现为偶尔卡住几秒。所有钩子函数统一用async defnext_handler也要await。这个坑在本地测试时不容易发现因为并发低一上生产就暴露。6.3 中间件配置的热更新生产环境经常需要调整中间件参数比如临时把限流阈值调高。如果中间件参数是启动时写死的改一次就得重启服务。建议把中间件的关键参数抽到配置中心中间件每次执行时读取最新值。代价是每次多一次配置读取但换来的是不用重启就能调参的灵活性。6.4 别在中间件里做模型调用除了压缩中间件这种确实需要模型能力的场景其他中间件尽量不要调用模型。中间件里的模型调用会形成Agent 调模型 → 中间件调模型 → 中间件里的模型又触发中间件的潜在递归稍不注意就无限循环。如果非要在中间件里用模型务必加递归深度保护。7. 中间件这套抽象值不值得用回到最开始的问题。我的判断是当你的 Agent 超过 3 个工具、需要处理真实用户请求、或者有成本/合规要求时中间件就是必需品。它带来的最大价值不是某个具体功能而是关注点分离。业务逻辑写在工具里管控逻辑写在中间件里两者独立演进。想加个新能力写个中间件注册进去就行不用动核心代码。想调整策略改中间件配置就行不用重新测试整个 Agent。代价是引入了一层抽象调试时需要理解洋葱模型的执行顺序。但这个学习成本比起把日志、限流、压缩、重试全塞进工具函数里造成的混乱简直不值一提。我现在的习惯是新项目一开始就把日志和错误恢复中间件搭好这两个是基础设施。上下文压缩等上下文真的开始变长了再加。限流和权限校验等接入真实用户时再加。不要一上来就把所有中间件都堆上去按需引入每个中间件都要能说清楚它解决了什么具体问题。最后分享一个小心得写中间件时先写测试用例再写实现。因为中间件的执行时机和顺序很容易搞错有了测试用例改顺序、加中间件时能立刻发现回归问题。我吃过这个亏一个顺序调整导致线上权限校验失效事后补测试才发现问题所在。
返回列表