做AI应用开发的同学,应该对Agent和Middleware这两个词都不陌生。Agent(智能体)负责编排任务、调用工具、完成多步推理,Middleware(中间件)则常被描述成“请求处理管道里的拦截器”。单看概念都挺好理解,但真要把它们放到同一个工程里配合使用,很多问题就浮现出来了:中间件到底在哪个时机执行?它和Agent的多轮循环怎么配合?为什么用了中间件之后请求反而变慢了?这一篇是这个知识系列的第四篇,前几篇把Agent的基础流程、工具调用、上下文管理都理了一遍,这一篇专门把Agent和Middleware放在一起深入拆解。文章面向已经跑通基本Agent流程、正在做工程化改造的开发者,目标是让你看完之后能自己设计出一套干净、可维护、可插拔的中间件机制,而不是只知道几个框架API的调用方式。
1. Agent与Middleware的关系:先看执行链,再看横切逻辑
1.1 Agent不是一次调用,而是一条有状态、有异常、有循环的执行链
很多人对Agent的第一印象是“调用大模型然后返回结果”,但真正落地过Agent的同学都知道,一个完整的Agent任务远不止一次模型调用。拿一个简单的“帮我查一下天气并决定要不要带伞”来说,它的实际执行过程是:接收用户输入,组装系统提示词和对话历史,交给模型规划下一步,模型可能返回一个“调用天气查询工具”的指令,Agent执行工具调用拿到天气数据,再把新数据喂回模型,模型结合数据给出最终回答。如果过程中模型认为还需要查别的信息,这个循环还会继续。
整条链路下来,至少有输入处理、上下文组装、规划、工具调用、结果回填、模型响应、输出整理这几个阶段,而且每一轮之间都有状态流转,随时可能抛出异常。这里涉及的关键点在于:这样一个多阶段、有状态、可循环的执行链,天然存在多个“可插入”的位置。你完全可以把整条链路想成一条流水线,而中间件就是挂在流水线上不同位置的工位。
生活里也有类似的场景。做饭看起来很整体,但实际是洗菜、切菜、炒菜、装盘好几道工序,每一道工序之间你都能插手——比如切完菜先尝一口咸淡,炒完菜再检查一下摆盘。Agent的执行链也是这样,每个阶段之间都有可以放逻辑的空隙。中间件就是抓住这些空隙,把独立的处理逻辑挂进去。
1.2 中间件不是为了炫技,而是为了收拾“横切关注点”
如果只是写一两个Demo级Agent,你确实可以不引入中间件——直接在Agent的核心代码里加日志、加鉴权、加统计,怎么方便怎么来。但一旦Agent开始承载真实业务,你会发现有一堆逻辑和“Agent的智能”本身没有直接关系,却在链路里无处不在:每个步骤要打日志,每轮模型调用要统计token,每次工具调用要检查权限和频率,出错了要重试,响应超时要熔断,用户会话要恢复历史记忆……
这类逻辑在工程上叫做“横切关注点”。它们的特点是:与核心业务无关,但贯穿业务的所有环节。如果你把它们直接写进Agent的核心流程,最直接的后果就是核心代码被塞满与推理无关的杂务,改一个日志格式都要翻遍整条调用链,而且同一个逻辑在多处重复,改漏一处就是线上事故。
中间件要解决的正是这个问题。它把横切逻辑抽成独立的处理单元,统一挂在执行链的约定位置上。Agent的核心代码只需要关注“怎么推理、怎么调用工具”,其余的事情全部交给中间件在合适的时机插入处理。
这里可以类比装修时的水电管线。一套房子装修完,水电管线并不是等墙刷完再凿洞塞进去,而是在砌墙阶段就预埋好管道。中间件就是给Agent预埋的管道系统,每根管道负责一类能力。想加一个新功能,不需要拆墙,只需要找到对应的管道口接上就行。没有管道系统房子也能住,但后续想升级水电、改造线路,就会难受得想砸墙。
2. 中间件机制的核心原理:时机、顺序与上下文
2.1 执行时机:中间件到底能挂在哪些阶段
既然Agent是一条执行链,那中间件理论上可以挂在链上的任何节点。从实践来看,绝大多数Agent框架的中间件都集中在几个标准时机:
- 请求进入时(用户输入刚到达,还没做任何处理)
- 上下文组装后、模型调用前
- 模型响应的前后
- 工具调用前后
- 异常抛出时
- 最终输出前
你可能会想:是不是要为每个时机都设计专门的接口?比如beforeModel、afterTool、onError……其实不需要。成熟的做法是只提供一个统一的执行链,中间件通过“在调用next()之前写逻辑还是之后写逻辑”来决定自己执行在哪个阶段。这个设计极大降低了框架的复杂度,也让中间件的组合变得灵活。
理解这个机制的关键,在于记住中间件处理的是“数据”而不是“时机”。时机是由你在链路中的位置决定的,而你能做什么,取决于你能拿到什么数据、能修改什么数据、能不能中断后续执行。这就要说到Context对象了。
2.2 洋葱模型:一次请求如何穿过多层中间件
几大主流Web框架里的洋葱模型,在Agent中间件里同样适用,而且理解起来更直观。假设你注册了三个中间件A、B、C,请求从入口进来,执行的顺序是:A的前置逻辑 -> B的前置逻辑 -> C的前置逻辑 -> 核心处理器 -> C的后置逻辑 -> B的后置逻辑 -> A的后置逻辑。
用代码来表达会更清楚。看这个简化示例:
const chain = [ async (ctx, next) => { console.log("A 前置:请求刚进来"); await next(); console.log("A 后置:响应即将返回"); }, async (ctx, next) => { console.log("B 前置:鉴权开始"); await next(); console.log("B 后置:鉴权结束"); }, async (ctx, next) => { console.log("C 前置:记录请求参数"); await next(); console.log("C 后置:记录响应结果"); }, ];执行这组中间件,控制台输出的顺序是:
A 前置:请求刚进来 B 前置:鉴权开始 C 前置:记录请求参数 (核心处理逻辑执行) C 后置:记录响应结果 B 后置:鉴权结束 A 后置:响应即将返回这个“先进后出”的形状,就是洋葱模型。为什么设计成这样?因为每个中间件都可以在放行之前做准备工作,在收到下游结果之后做收尾工作,并且可以同时知道“请求进去时的状态”和“响应出来时的状态”。比如日志中间件,前置阶段记录开始时间,后置阶段计算耗时;限流中间件,前置阶段判断是否放行,后置阶段释放配额。
拿快递站分拣包裹来类比:包裹从发件地出发,依次经过A站、B站、C站,各站都会在包裹上贴一张条(前置操作);到了收件地之后再原路返回,经过C站、B站、A站时可能又会各做一次扫描登记(后置操作)。每一站去做自己的事情,不需要知道其他站具体做了什么,只要按顺序把自己的活干好就行。
2.3 Context:中间件之间怎么共享数据
中间件一环扣一环,它们之间必须有数据传递机制。直接改全局变量显然不行,多个并发请求会互相串数据;靠函数参数逐个传递也不行,中间件的数量不固定,没法穷举所有参数。所以几乎所有实现都会引入一个统一的上下文对象,通常叫做Context。
一个AgentContext至少要包含几类信息:
- 链路元数据:requestId、userId、sessionId,用来追踪一次完整的请求
- 请求信息:用户原始输入、当前处理到哪一步、目标工具名称与参数
- 运行状态:模型调用次数、累计token消耗、已收集的中间结果
- 外部资源:模型客户端、任务队列、工具注册表等
Context像一个贯穿整条链路的快递单,每个中间件都可以往上追加信息,也可以读取前面中间件留下的信息。它保证了一次请求的所有处理单元共享同一份状态,同时避免了全局变量带来的并发污染。
这里有一个很容易踩的坑:Context本身是一个对象,如果你在某个中间件里修改了Context上的某个字段,而下游中间件也依赖这个字段,那么执行顺序就直接影响了行为。因此,在设计Context的时候,建议把字段分区:只读字段(请求信息、链路元数据)和可变字段(运行状态、中间结果)明确分开,避免下游被上层意外的修改坑到。后面实战部分我会再展开讲这个。
3. 自己实现一套可插拔的中间件机制
3.1 中间件的接口怎么定义最顺手
不依赖任何框架,我们自己动手设计一套Agent中间件机制,其实只需要三个核心元素:中间件接口、执行链组装逻辑、Context对象。
中间件接口,一句话定义足够:接收Context和一个next函数,做自己的事情,然后决定是否调用next。
用TypeScript表达就是:
type NextFn = () => Promise<void>; interface Middleware { name: string; handle(ctx: AgentContext, next: NextFn): Promise<void>; }这个接口有几个内在含义需要理解透。
第一,为什么用handle(ctx, next)而不是handle(ctx, done)?因为把“继续执行”的控制权交给中间件自己,前置逻辑和后置逻辑才能写在同一个函数里。中间件调用next()之前的内容就是前置逻辑,之后的内容就是后置逻辑,不调用next就等于中断链路。这种设计让中间件既能做拦截,也能做增强。
第二,next只能调用一次。一个中间件如果调了两次next,下游链路会被执行两遍,这通常是灾难性的。框架要在执行链层面做防护,中间件自己也要有这个意识。
第三,name字段看起来简单,实际排查问题时极其有用。后面我讲日志排查会专门说为什么要给每个中间件命名。
3.2 把中间件织入Agent的执行循环
Agent核心部分不需要关心中间件的存在,它只需要提供一个“真正干活”的方法。假设我们有一个Agent类,内部有一个coreRun方法,负责执行真正的规划-工具调用-模型推理循环。我们要做的,是把中间件链包在coreRun外围。
用一个经典方式:把中间件数组reduce成一个最终函数。反向遍历,最后一个注册的中间件最内层,第一个注册的最外层。
class Agent { private middlewares: Middleware[] = []; use(mw: Middleware) { this.middlewares.push(mw); return this; } private async coreRun(ctx: AgentContext) { // 这里执行真正的Agent逻辑:规划、工具调用、模型推理循环 // 例如:while (needContinue) { plan(); callTools(); } ctx.output = await this.executePlan(ctx); } async run(input: string): Promise<AgentOutput> { const ctx = new AgentContext(input); const chain = this.middlewares.reduceRight( (next, mw) => async () => { await mw.handle(ctx, next); }, () => this.coreRun(ctx) ); await chain(); return ctx.output; } }reduceRight在这里的作用是把中间件一层层包起来。假设注册了[A, B, C],最终生成的chain结构是这样的:A.handle(ctx, B.handle(ctx, C.handle(ctx, coreRun)))。执行顺序上面已经验证过:A前置 -> B前置 -> C前置 -> coreRun -> C后置 -> B后置 -> A后置。
代码只有几行,但它把中间件的“可插拔”完全落地了:新增中间件只需要调用agent.use(new SomeMiddleware()),删除中间件只需要去掉对应注册语句,无需改动Agent核心逻辑。这套机制带来的好处,实际工程里体会尤其深——Agent的策略调整和系统能力的扩展被彻底解耦了。
3.3 生命周期语义用约定代替接口
很多同学会问:before/after/onError这些生命周期是不是要单独定义成接口?我的建议是不要。过早抽象会增加使用成本,而且Agent的执行链本身已经把before和after表达清楚了,错误处理则约定用try/catch包住next()来解决。
具体约定是这样的:
- 中间件里next()之前的部分 = 请求前逻辑。适合做鉴权、限流、上下文组装、日志开始计时。
- 中间件里next()之后的部分 = 请求后逻辑。适合做统计、归档、响应修改。
- 用try/catch包住next() = 错误处理逻辑。捕获异常后,你可以记录日志然后继续抛,也可以吞掉异常并返回兜底结果。
- 如果中间件没有后置逻辑,调用next()之后直接return就行,不要画蛇添足。
这样做的好处是,任何中间件的生命周期语义都是自解释的——看代码结构就知道它做了什么。而不是非要查文档才知道某个名为afterModel的钩子在哪里触发。
4. 实战:记忆注入与限流熔断两个中间件
4.1 记忆注入中间件:让每个请求都带着上下文
Agent要做多轮对话,必须把历史对话放进去。直接在Agent核心代码里拼历史记录当然能跑,但用中间件来做会灵活得多。
核心思路是:每个请求进来时,先从记忆存储中取出与该会话相关的历史摘要,注入到Context里供模型调用时使用;请求结束后,再把这一轮的新内容更新回记忆存储。
class MemoryMiddleware implements Middleware { name = "memory"; constructor(private store: MemoryStore) {} async handle(ctx: AgentContext, next: NextFn) { // 前置:读取历史记忆并注入上下文 const history = await this.store.fetch(ctx.sessionId); ctx.memory = this.trimToFit(history, ctx.maxTokenBudget); await next(); // 后置:把这一轮的新记录写回 if (ctx.userInput && ctx.output) { await this.store.append(ctx.sessionId, { user: ctx.userInput, assistant: ctx.output, }); } } }这里面有一个非常关键的细节:注入的时机。这个中间件必须在“上下文组装”之前执行,但也不能太靠前,因为读取记忆本身需要sessionId,而sessionId是鉴权中间件解析出来的。所以注册顺序里,鉴权必须在记忆注入之前。
还有一个token控制的问题。历史对话是无限增长的,不可能每次全量注入。trimToFit这一步就是压缩历史:按预算截断,超出部分用摘要替代。我踩过的坑是,一开始没有做token预算控制,历史稍微长一点就直接把整个上下文塞进模型,结果不仅成本飙升,模型响应质量还明显下降——因为太长反而干扰了模型对重点的把握。
4.2 限流与熔断中间件:保护Agent背后的资源
Agent本身不产生稀缺资源,稀缺的是它背后的模型调用额度和工具服务的承载能力。如果一个用户触发Agent内部某个高频工具,又没有限流,很短时间内就能把上游服务打垮。限流中间件要做的就是按维度计数,超过阈值直接拦截。
class RateLimitMiddleware implements Middleware { name = "rate-limit"; constructor(private limiter: Limiter) {} async handle(ctx: AgentContext, next: NextFn) { const key = `${ctx.userId}:${ctx.currentToolName}`; const allowed = await this.limiter.tryAcquire(key, { limit: 10, windowMs: 60_000, }); if (!allowed) { throw new RateLimitError(`工具调用过于频繁,请稍后再试`); } await next(); } }这个中间件的核心是tryAcquire返回false就立刻中断链路。注意中断要抛异常,而不是静默返回。为什么?因为上层需要有感知——可能是提示用户限流,也可能是触发降级方案。静默吞掉会让调用方误以为Agent正常工作。
比限流更进一步的保护是超时熔断。一个Agent内部可能有多轮模型调用和工具调用,整体耗时可长可短。如果不能控制Agent的最长执行时间,一个卡住的工具调用会把整个请求拖死。超时中间件用Promise.race实现:
class TimeoutMiddleware implements Middleware { name = "timeout"; constructor(private timeoutMs: number) {} async handle(ctx: AgentContext, next: NextFn) { let timer: NodeJS.Timeout; try { await Promise.race([ next(), new Promise((_, reject) => { timer = setTimeout( () => reject(new TimeoutError(`Agent执行超时(${this.timeoutMs}ms)`)), this.timeoutMs ); }), ]); } finally { clearTimeout(timer); } } }这里有个容易被忽视的问题:Promise.race抛弃的那个Promise(比如一个还没跑完的工具调用)并不会神奇地停止执行,它还在后台继续跑。所以要配合取消机制:超时后给下游传一个AbortSignal,让真正执行中的任务能响应取消。否则就会出现“请求已经超时返回了,但后台的资源还挂着不释放”的情况。
4.3 注册顺序:中间件也讲究先来后到
有了多个中间件之后,注册顺序就变成了一件需要认真对待的设计决策。同一个中间件集合,顺序不同,行为完全不同。
我总结了一个适合大多数Agent场景的推荐注册顺序,从外到内:
| 顺序 | 中间件 | 为什么放这里 |
|---|---|---|
| 1 | requestId生成与链路日志 | 需要覆盖全程,必须在最外层 |
| 2 | 鉴权/身份识别 | 没有身份,后面所有操作没意义 |
| 3 | 限流/熔断 | 尽早拦截无效请求,避免消耗模型资源 |
| 4 | 记忆注入/上下文组装 | 依赖已通过鉴权的身份信息 |
| 5 | Agent核心推理循环 | 处理实际业务 |
| 6 | 错误兜底/重试 | 要能捕获核心循环的异常 |
| 7 | 统计与归档 | 全程结束后统一记录 |
这里稍微展开讲讲为什么限流要在记忆注入前面。如果一个限流不合格的请求先做了一堆上下文组装、记忆读取、甚至模型调用,然后才被发现要被拦截,那资源已经白白消耗了。限流作为一道经济防线,必须尽可能早。而日志放在最外层,是因为一旦链路中间某处抛异常,最外层还能捕获到,保证日志不缺失。
注册顺序如果乱了,最典型的问题就是限流前的日志把所有被拦截的请求也记录成“成功执行”,统计报表直接失真。我当时就是被这种假数据坑过,排查半天才发现是中间件顺序反了。
5. 常见问题与排查技巧实录
5.1 中间件不生效,先查这三点
这类问题最多发。中间件注册了,代码也写了,但执行链路里就是没有它的痕迹。排查顺序照着做:
第一,确认use的时候注册的是同一个实例。我见过有人每次请求都new一个新的中间件实例,注册列表里确实有它,但那个实例的函数体是空的,等于什么都没做。正确的做法是在应用启动时创建中间件实例,用依赖注入或者单例管理。
第二,确认中间件内部调用了next()。不调用next,链路就在你这个中间件这里停住了,后面的所有步骤都不会执行。这可能是故意的(比如鉴权失败拦截),但也可能是无意漏写的。排查时看日志停在哪里,一目了然。
第三,确认注册发生在run之前。中间件机制本质上是“启动时组装好一条链,请求进来时直接走链”,如果你在run之后才use,这次请求已经组装完的链里就没有它。
5.2 执行顺序和预期不一致
这个问题通常出在多层Agent嵌套。很多真实系统里,一个外层Agent会调用内层Agent,两层各自注册了中间件。如果你在外层和内层都设置了日志中间件,一个请求可能会经过两套链,日志顺序交织起来就很难辨认。
解决办法是给每个中间件打日志时带上requestId和中间件的name字段。比如这样:
async handle(ctx: AgentContext, next: NextFn) { logger.info(`[${ctx.requestId}] ${this.name} enter`); await next(); logger.info(`[${ctx.requestId}] ${this.name} exit`); }这样无论多少层嵌套,只要requestId相同,把日志按时间排一下序,就知道实际执行顺序是什么了。之前我会凭直觉猜“应该先执行A再执行B”,但实际顺序往往跟直觉有出入,有了日志就不再靠猜。
5.3 Context里的数据串了或者丢了
Context是对象引用传递,如果你在中间件里把ctx的某个子对象赋值给一个模块级变量,或者把同一个Context实例复用到多个并发请求里,数据串了是必然的。
我要强调三条铁律:
- 每次请求必须new一个独立的Context实例,任何中间件都不能缓存Context引用。
- 跨中间件传递临时数据,统一放到ctx.state命名空间下,不要随意在Context根上挂字段。根字段结构一旦被各种中间件乱挂,很快就会变成一团乱麻,看代码都不知道某个字段是谁写的。
- 不要把模型完整响应这类大对象挂在Context上等后置中间件用。Context整个生命周期都活在内存里,大对象堆积会拖高GC压力,只保留关键字段或者引用ID更有意义。
5.4 异常被吞掉或者链路被重复执行
next()被调用两次的问题,前面功能设计时提过。框架层面其实很好防御:用一个executed标志位包一下next:
const chain = this.middlewares.reduceRight( (next, mw) => async () => { let called = false; const guardedNext = async () => { if (called) { throw new Error(`中间件 ${mw.name} 重复调用 next()`); } called = true; await next(); }; await mw.handle(ctx, guardedNext); }, () => this.coreRun(ctx) );异常吞掉的问题则更难察觉。有些中间件为了“稳妥”,习惯性把next()包在try/catch里然后不重新抛出。这么做一时爽,但会让上游彻底失去对错误状态的感知。Agent链路里,错误处理策略一定要统一:要么记录后继续抛,要么记录后中断并用兜底结果返回。最忌讳的是同一个链路里有的中间件吞异常,有的不吞,最终行为完全不可预期。
5.5 中间件数量膨胀导致延迟上升
中间件确实会引入性能损耗,每一个中间件都是一次函数调用和一次await调度。当链路里挂了二三十个中间件时,光调度开销就可观。这里我个人的体感是:核心链路上的中间件数量控制在5个以内最舒服,更多逻辑可以通过合并中间件来减少层数。
比如把日志、 trace、错误标记合并成一个可观测性中间件,而不是拆成三个。另外,重逻辑不要直接放在中间件里同步执行,放到后台worker队列里异步处理更稳妥。像“请求后归档”“统计写入”这类操作,异步化之后用户完全无感,中间件自身也轻快很多。
把散落在Agent各处的日志、重试、限流、统计代码收拢成中间件之后,我的调试效率明显上了一个台阶。以前遇到线上问题,要在Agent核心流程里翻来翻去才能定位某个横切逻辑,现在只要看链路日志里的中间件name,就能判断问题出在哪一环。最后分享一个小技巧:给每个中间件起名字,并不是为了好看,而是排查问题时,你只需要grep日志里中间件name和requestId,整条链路的执行轨迹就出来了。这个套路不仅适合Agent,所有管线型架构都通用。