🍔 中间件不只是打日志:四个钩子、一次短路,和自带的工具

写在前面:上次我们看了一个 DeepAgents 的中间件骨架------createMiddleware({ }) 里面空空的,当时说"这个空壳子标出了'你可以在这里插手'"。这次它填满了。填满之后你会发现,中间件能做的事比"加个日志"多得多 :它能记账、能改写请求、能直接叫停整个流程,甚至能自带工具。readme 里那两句注释值得先抄下来------"用户 request,agent 中间,生成 response" 、"middleware 是 Agent 的一部分,不影响 Agent 的运行的情况下添加一些额外的功能"。今天就用两个测试文件,把中间件的四个钩子和三种特权全部过一遍。以下所有代码均来自课堂真实文件。


一、先看全局:Agent 的一天里有四个时间点

middleware-test.mjs 里的 loggingMiddleware,一次把四个钩子全用上了:

javascript 复制代码
const loggingMiddleware = createMiddleware({
    name: "loggingMiddleware",
    stateSchema: z.object({
        modelCallCount: z.number().default(0),
    }),
    // 监听Agent的全生命周期 hooks
    beforeAgent: (state) => {
        console.log("\n[Logging] agent 开始,消息数:", state.messages.length)
    },
    beforeModel: (state) => {
        console.log(`[Logging] 即将调用模型,当前消息数:${state.messages.length},
            已调用${state.modelCallCount}次模型
        `)
    },
    afterModel: (state) => {
        const last = state.messages.at(-1);
        const preview = typeof last?.content === "string" 
            ? last.content.slice(0, 280)
            : JSON.stringify(last.content)?.slice(0, 280);
        console.log(`[Logging] 模型返回:${preview}...`)
        return {
            modelCallCount: state.modelCallCount + 1,
        }
    },
    afterAgent: (state) => {
        console.log(`[Logging] agent 结束,
            累计模型调用:${state.modelCallCount}次\n`)
    }
});

四个钩子,对应 Agent 一次任务的四个时间点:

钩子 时机 相当于
beforeAgent 整个 Agent 启动前 上班打卡
beforeModel 每次调模型前 每次开会前点名
afterModel 每次模型返回后 开完会写纪要
afterAgent 整个 Agent 结束后 下班总结

注意"每次"这两个字。 Agent 的主循环可能调好几次模型(调工具、再推理、再调工具......),所以 beforeModel / afterModel 会被触发多轮,而 beforeAgent / afterAgent 各只触发一次。

这正是上次讲的 Agent 主循环------中间件就是挂在这个循环的各个节点上的钩子。

中间件有自己的"记忆"

javascript 复制代码
stateSchema: z.object({
    modelCallCount: z.number().default(0),
}),

这一行是本次最值得学的设计。

中间件不只是"读一下状态",它可以声明自己的状态字段 。这里声明了一个 modelCallCount,类型是数字、默认值 0。

然后:

javascript 复制代码
afterModel: (state) => {
    // ...
    return {
        modelCallCount: state.modelCallCount + 1,
    }
}

afterModel 返回的这个对象,就是"把新值写回状态"。 每调用一次模型就加一------这个中间件在给模型调用"记账"。

于是调用方就能拿到这个数:

javascript 复制代码
const { messages, modelCallCount } = await agent.invoke({
    messages: [new HumanMessage(text)]
});
console.log("回复", messages.at(-1)?.content);
console.log("modelCallCount", modelCallCount);

注意 agent.invoke() 的返回值里直接有 modelCallCount。

这说明中间件声明的状态字段,跟 messages 一样,都是 Agent 状态的一部分------中间件不是"外挂",它真的住在 Agent 里面。 这也正好呼应了开头那句注释:"middleware 是 Agent 的一部分"。

这解决了一个很实际的问题:怎么测量 Agent 干了多少活?

以前只能靠猜或者翻日志。现在中间件自己记着账,跑完直接问它要数字。而且这个数字是结构化的、可编程的------可以直接断言、可以上报监控、可以写进测试。

一个小细节:preview 的类型判断

javascript 复制代码
const last = state.messages.at(-1);
const preview = typeof last?.content === "string" 
    ? last.content.slice(0, 280)
    : JSON.stringify(last.content)?.slice(0, 280);

为什么要判断类型?因为消息的 content 不一定是个字符串。

在 LangChain 里,content 可能是一个字符串,也可能是内容块数组(比如同时包含文本和图片的结构化内容)。两种情况处理方式不同:

类型 处理
字符串 直接 slice(0, 280)
数组/对象 先 JSON.stringify 再截断

如果不判断就直接 .slice(),遇到数组类型的 content 会得到奇怪的结果(数组也有 slice 方法,但切的是元素不是字符)。

slice(0, 280) 的截断也很实用------日志是为了"看一眼发生了什么",不是"完整存档"。280 个字符足够判断模型在说什么,又不会把日志刷屏。

state.messages.at(-1) 用 at(-1) 而不是 [length - 1]------负索引取最后一个,这是 ES2022 引入的写法,比算长度简洁。


二、第一个特权:改写权

看完四个钩子,接下来是"你还能动手做什么"。第一个特权是改写请求。

javascript 复制代码
const addContextMiddleware = createMiddleware({
    name: "AddContextMiddleware",
    wrapModelCall: async (request ,handler) => {
        console.log("[Add Context] 注入额外的system上下文")
        return handler({
            ...request,
            // 覆盖systemPrompt
            systemMessage: request.systemMessage.concat(
                "\n\n请用一句话简洁回答"
            )
        })
    }
})

这个中间件干了什么?它偷偷往 system prompt 里加了一句话。

流程是这样的:

swift 复制代码
请求准备发出
  ↓
wrapModelCall 拦下来 → 拿到 request(包含 systemMessage、messages 等)
  ↓
改写:systemMessage 后面追加 "\n\n请用一句话简洁回答"
  ↓
handler(改过的 request) → 把请求交给真正的执行逻辑
  ↓
模型收到的是"被你改过"的请求

关键在 wrapModelCall 这个名字------"wrap"(包裹)。

它不是"在调用前后各插一段",而是把整个调用过程包起来 。签名是 (request, handler):

参数 是什么
request 这次调用的完整请求(可读可改)
handler 真正的执行逻辑------由你决定什么时候调、调几次、传什么

注意 return handler({...}) ------ 你必须自己把 handler 调起来。 不调,模型就不会被调用。

回忆一下上次讲的知识:包裹式钩子可以决定 handler 被调用零次、一次或多次。 这个中间件是"改完再调一次"------最常见的一种。

那个"零次"的可能性,马上就有一个例子。


三、第二个特权:叫停权

javascript 复制代码
const blockedContentMiddleware = createMiddleware({
    name: "BlockedContentMiddleware",
    beforeModel: {
        canJumpTo:["end"],
        hook: (state) => {
            const last = state.messages.at(-1);
            const text = typeof last?.content === "string"
                ? last.content: String(last?.content ?? "");
            if (text.includes("BLOCKED")) {
                console.log("[Blocked] 检测到Blocked,短路结束");
                return {
                    messages:[new AIMessage("该请求已被middleware 拦截,无法处理")],
                    jumpTo: "end",
                }
            }
        }
    }
})

这个中间件是个安检员------发现可疑内容,直接把整个流程掐掉。

钩子的"对象形式"

注意 beforeModel 这次不是函数,而是个对象:

javascript 复制代码
beforeModel: {
    canJumpTo: ["end"],
    hook: (state) => { ... }
}

为什么要写成对象?因为要声明 canJumpTo。

canJumpTo: ["end"] 的意思是------这个钩子有权跳到 end 节点。

这很像一种"权限声明":你不能随便跳,得先把"我能跳到哪"登记清楚。框架给跳转能力加了个白名单,避免中间件乱改流程走向。

这跟前面学 LangGraph 时的条件边是姊妹概念------那时是"图自己决定往下走哪条边",这里是"中间件中途把流程拽到别处"。但能力被显式声明约束着,不是想跳就跳。

短路:让 handler 被调用零次

javascript 复制代码
if (text.includes("BLOCKED")) {
    return {
        messages:[new AIMessage("该请求已被middleware 拦截,无法处理")],
        jumpTo: "end",
    }
}

一旦命中关键词,返回两样东西:

返回 作用
messages 伪造一条 AI 回复------告诉用户"被拦截了"
jumpTo: "end" 直接跳到结束,模型根本没被调用

这就是"零次调用"的实战版本。

对比一下那个"注入上下文"的中间件------它调了 handler(模型被调用了一次)。而这个中间件在命中条件时压根不走到模型那一步。流程被拦腰截断,省下了一次模型调用的钱和时间。

而且注意它伪造了一条 AIMessage 塞进 messages。这是个很周到的设计------如果没有这条消息,调用方拿到的 messages 会停在用户提问那里,看起来"Agent 没反应"。补上这条 AI 回复,调用方拿到的对话记录才是完整的:

scss 复制代码
[HumanMessage] 这句话包含Block 关键词 BLOCKED
[AIMessage]    该请求已被middleware 拦截,无法处理   ← 中间件补的

为什么这个中间件特别有现实意义

把 BLOCKED 换成真实规则,这就是内容安全中间件的雏形:

触发词 现实中的用途
BLOCKED 演示用的占位符
敏感词 内容合规
越权指令 防 prompt 注入
超预算标志 成本熔断

"在模型被调用之前拦下来"这件事的价值在于------省钱、省时间、还防住了风险。 如果等到模型回答完再去检查,钱已经花了。

测试用例是"故意触发"

javascript 复制代码
for (const text of [
    // "用中文说:langchain createAgent 中的middleware 是什么",
    "这句话包含Block 关键词 BLOCKED"
]) {

两个测试用例,注释掉一个、启用一个------这个选择很有意思。

  • 注释掉的这句:用中文说:langchain createAgent 中的middleware 是什么 ------ 正常的提问,走完整流程(日志会完整打印、模型会被调用、modelCallCount 变成 1)
  • 启用的这句:这句话包含Block 关键词 BLOCKED ------ 故意撞线,验证短路逻辑

换成启用后面那句之后,日志的输出会明显变短 :beforeAgent → beforeModel(检测到 BLOCKED)→ 直接就结束了,中间没有 afterModel。

这是个很聪明的调试手法:用测试用例的开关,切换"正常路径"和"异常路径"。 想验证哪条就打开哪个。


四、第三个特权:自带装备

middleware-test2.mjs 展示了另一个能力------中间件可以给 Agent 送工具。

javascript 复制代码
import {
    createAgent,
    createMiddleware,
    HumanMessage,
    AIMessage,
    ToolMessage,
    tool
} from "langchain";

const getCurrentTime = tool(() => new Date().toISOString(), {
    name: "get_current_time",
    description: "返回当前UTC 时间ISO 8601 字符串",
    schema: z.object({})
});

这里用 tool() 定义了一个工具。 三个要素跟前面学的完全一致:

要素 值
执行函数 () => new Date().toISOString()
name get_current_time
description "返回当前UTC 时间ISO 8601 字符串"
schema z.object({})------空对象,因为这个工具不需要参数

z.object({}) 这个"空 schema"值得留意------无参数工具也要显式声明 schema 为空对象,而不是省掉不写。这样模型明确知道"这个工具不用传参"。

工具挂在中间件上

javascript 复制代码
const extendedToolsMiddleware = createMiddleware({
    name: "ExtendedToolsMiddleware",
    stateSchema: z.object({
        toolInvocationCount: z.number().default(0),
    }),
    tools: [getCurrentTime],
    // ...
})

const agent = createAgent({
    model,
    tools: [], // 顶层tools
    systemPrompt:"你是一个助手",
    middleware: [
        extendedToolsMiddleware
    ]
});

看那两个地方:

javascript 复制代码
// 中间件里
tools: [getCurrentTime],

// createAgent 里
tools: [], // 顶层tools

顶层工具是空的,但 Agent 实际能用 get_current_time------因为工具是中间件带进来的。

那个注释 // 顶层tools 就是在提示这个对比:工具有两个来源。

工具来源 适合放什么
createAgent 的顶层 tools 这个 Agent 的核心能力
中间件的 tools 这个中间件配套的工具

为什么把工具放在中间件里?因为它和中间件的逻辑是配套的。

这个中间件除了提供 get_current_time,还要统计工具调用的次数 。工具和统计逻辑是一个整体------打包在一起,才能复用:想让另一个 Agent 也具备"带统计的时间工具",只要把这个中间件挂上去就行,不用记得"还要单独加那个工具"。

这就是"封装"的价值:能力 + 配套机制,一起打包,一起搬运。

wrapToolCall:包裹工具执行

javascript 复制代码
wrapToolCall: async (request, handler) => {
    const toolName = request.tool?.name ?? request.toolCall.name;
    console.log(`[Tools] 即将执行工具:${toolName}`,
        "args:",
        request.toolCall.args ?? {}
    );
    const result = await handler(request);
    if(!ToolMessage.isInstance(result)) return result;
    
    const wrapped = new ToolMessage({
        content: `${result.content}\n[wrapToolCall]
        已由ExtendedToolsMiddleware 包裹`,
        tool_call_id: result.tool_call_id,
        name: result.name,
    });

    return new Command({
        update: {
            toolInvocationCount: request.state.toolInvocationCount + 1,
            messages: [wrapped],
        }
    })
},

这是 wrapToolCall------wrapModelCall 的姊妹版本,只不过包裹的对象从"模型调用"变成了"工具调用"。

逐段看:

第一段:执行前记录

javascript 复制代码
const toolName = request.tool?.name ?? request.toolCall.name;
console.log(`[Tools] 即将执行工具:${toolName}`, "args:", request.toolCall.args ?? {});

request.tool?.name ?? request.toolCall.name 这个双重取法是防御式写法 ------不同版本/场景下工具名可能挂在 tool 上,也可能挂在 toolCall 上,两个都试一遍。

第二段:真正执行

javascript 复制代码
const result = await handler(request);

跟 wrapModelCall 一样------你得自己把 handler 调起来。

第三段:类型守卫

javascript 复制代码
if(!ToolMessage.isInstance(result)) return result;

这行很重要:工具不一定返回 ToolMessage。

比如有的工具会返回一个 Command(用来更新状态),而不是标准的工具消息。这时候不能按 ToolMessage 处理,直接原样返回。

ToolMessage.isInstance() 是 LangChain 提供的类型守卫 方法------比 result instanceof ToolMessage 更可靠(跨包/多实例场景下 instanceof 有时会失灵)。先判断类型再处理,是处理"别人返回的东西"时的基本素养。

第四段:包装结果

javascript 复制代码
const wrapped = new ToolMessage({
    content: `${result.content}\n[wrapToolCall]
    已由ExtendedToolsMiddleware 包裹`,
    tool_call_id: result.tool_call_id,
    name: result.name,
});

它把工具返回的内容加工了一道------原文后面追加了一行标记。

这一行标记是教学演示用的:证明"工具的结果真的经过了中间件的包装"。但在真实场景里,这个位置能干的事很实际:

加工方式 用途
追加提示 告诉模型"这是中间件处理过的"
脱敏替换 把工具返回的敏感信息打码
截断 工具返回太长,先压缩再给模型
格式化 把原始数据转成模型更好读的结构

注意 tool_call_id 和 name 都原样带上了 ------这两个字段是工具消息的"身份证",模型靠 tool_call_id 把"工具结果"跟"刚才那次调用"对应起来。改了内容可以,改丢这两个字段,对话就对不上了。

第三种改状态的方式:Command

javascript 复制代码
return new Command({
    update: {
        toolInvocationCount: request.state.toolInvocationCount + 1,
        messages: [wrapped],
    }
})

注意这里返回的是 Command,不是普通对象。

而前面 afterModel 里改状态,返回的是普通对象:

javascript 复制代码
// afterModel 的写法
return {
    modelCallCount: state.modelCallCount + 1,
}

两种写法都能改状态,区别在于钩子的类型:

钩子类型 改状态的方式
节点式(beforeModel / afterModel) 直接 return 对象
包裹式(wrapModelCall / wrapToolCall) return new Command({ update: {...} })

Command 是从 @langchain/langgraph 引入的------这张"命令"可以同时做两件事:更新状态 + 指定下一步去哪。

这里的 update 里干了两件事:

javascript 复制代码
update: {
    toolInvocationCount: request.state.toolInvocationCount + 1,  // 记账 +1
    messages: [wrapped],                                          // 把包装后的工具消息塞回去
}

因为包裹式钩子"接管了"整个返回过程,所以它得负责把该更新的东西都更新掉------包括把工具结果放回消息列表。

这也解释了为什么上面那个 ToolMessage.isInstance 判断很重要:如果工具返回的不是 ToolMessage,就不能用这套逻辑包装,得原样放行。

request.state.toolInvocationCount 说明包裹式钩子能通过 request.state 读到当前状态 ------跟节点式钩子从参数里拿 state 是同样的信息,只是包装在不同位置。

跑一次看看

javascript 复制代码
for (const text of [
    // "王者荣耀 日本比赛夺冠了,谁是MVP?",
    "给我当前时间"
]) {
    const {messages, toolInvocationCount} = await agent.invoke({
        messages: [new HumanMessage(text)],
    });
    console.log(messages, toolInvocationCount);
}

测试用例又是个"二选一":

用例 预期行为
王者荣耀 日本比赛夺冠了,谁是MVP?(注释掉) 一般问题,可能不需要工具
给我当前时间(启用) 明确需要工具------必然触发 get_current_time

为什么选"给我当前时间"?因为这是个"必然用工具"的问题。

模型自己不知道当前时间(它的知识有截止日期),所以它只能去调工具。这样就能确保 wrapToolCall 一定被触发------不然中间件的代码根本没机会跑,你也不知道自己写对没写对。

这是挑测试用例的一个原则:想让某段代码被执行,就设计一个"绕不开它"的输入。

最后解构出 toolInvocationCount ------又是中间件记的账。 跟前面 modelCallCount 是同一个套路:中间件自己统计数据,调用方跑完直接取。


五、中间件的"权限光谱"

把今天这两个文件的能力汇总,中间件的权限其实是逐级递增的:

层级 能力 实现方式 课堂例子
1 看见 只读 state,打日志 beforeAgent / afterAgent
2 记账 stateSchema + 返回新值 modelCallCount / toolInvocationCount
3 改写 wrapModelCall 改 request 追加 system prompt
4 中断 jumpTo + canJumpTo 声明 BLOCKED 短路
5 供装备 中间件的 tools 字段 get_current_time
6 加工结果 wrapToolCall 包装返回值 追加标记 / 脱敏 / 截断

从"只能看"到"能拦能改能加装备"------这一条光谱,就是中间件的全部威力。

回到开头那句 readme 注释:

"middleware 是 Agent 的一部分,不影响 Agent 的运行的情况下添加一些额外的功能"

"不影响运行"这四个字,正好对应光谱的前两级------打日志、记账,确实不改变 Agent 的行为。

但今天我们看到的是光谱的全部 ------改写 prompt 会改变输出、短路会改变流程、包装工具结果会改变模型看到的信息。"不影响运行"是入门用法,"精确地影响运行"才是中间件的完整能力。

而"精确"这两个字,体现在那些限制上 :短路要先用 canJumpTo 声明权限、改状态要走 stateSchema 声明的字段、包裹式钩子返回要包成 Command。框架给你能力,但每一处都留着约束------能改,但要按规矩改。


PS:这篇看下来,最有意思的是"记账"这件事------modelCallCount 和 toolInvocationCount 这种字段,看着不起眼,但它把"Agent 干了多少活"变成了一个可以编程的数字 。以前你只能翻日志数,现在跑完直接断言"这次调用不超过 3 次"。下一篇我们看 DeepAgents 官方给的三个"开箱即用"中间件------不用自己写,挂上就有。

相关推荐
程序员老赵2 小时前
Docker 部署 Dolibarr:轻松搭建开源 ERP/CRM 平台
运维·前端·后端
星禾元亨2 小时前
同一件事有三份资料,AI 该信哪一份?冲突判定、优先级链与处置流程
前端·人工智能
Dovis(誓平步青云)2 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
穆梓兰煊2 小时前
TS5.7 vs 裸JS:AI应用少3坑
前端
榆瓷2 小时前
闲着没事,我把 WebGPU 的样板代码封成了一个库
前端
136096757232 小时前
页面打不开不是 Nginx 的错
前端·后端
xieter2 小时前
【技术精选】TypeScript 高级类型体操:条件类型与模板字面量深度剖析 (2026-10-06)
javascript
ss2733 小时前
AI全栈实战 | 3.3-02 Python 并发:有 GIL 为什么还用多线程,asyncio 和 JS 事件循环同源不同味
开发语言·javascript·python
liangshanbo12153 小时前
高级前端面试题:package.json 常见字段
前端·json