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

0 阅读13分钟

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


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

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

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 主循环——中间件就是挂在这个循环的各个节点上的钩子。

中间件有自己的"记忆"

stateSchema: z.object({
    modelCallCount: z.number().default(0),
}),

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

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

然后:

afterModel: (state) => {
    // ...
    return {
        modelCallCount: state.modelCallCount + 1,
    }
}

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

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

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 的类型判断

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 引入的写法,比算长度简洁。


二、第一个特权:改写权

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

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 里加了一句话。

流程是这样的:

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

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

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

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

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

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

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


三、第二个特权:叫停权

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 这次不是函数,而是个对象:

beforeModel: {
    canJumpTo: ["end"],
    hook: (state) => { ... }
}

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

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

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

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

短路:让 handler 被调用零次

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

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

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

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

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

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

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

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

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

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

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

测试用例是"故意触发"

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

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

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

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

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


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

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

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()
nameget_current_time
description"返回当前UTC 时间ISO 8601 字符串"
schemaz.object({})——空对象,因为这个工具不需要参数

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

工具挂在中间件上

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

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

看那两个地方:

// 中间件里
tools: [getCurrentTime],

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

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

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

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

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

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

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

wrapToolCall:包裹工具执行

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 的姊妹版本,只不过包裹的对象从"模型调用"变成了"工具调用"。

逐段看:

第一段:执行前记录

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

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

第二段:真正执行

const result = await handler(request);

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

第三段:类型守卫

if(!ToolMessage.isInstance(result)) return result;

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

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

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

第四段:包装结果

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

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

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

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

// afterModel 的写法
return {
    modelCallCount: state.modelCallCount + 1,
}

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

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

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

这里的 update 里干了两件事:

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

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

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

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

跑一次看看

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 官方给的三个"开箱即用"中间件——不用自己写,挂上就有。