LangChain v1 中间件实战:不碰主流程,也能给 Agent 装上"外挂"

LangChain v1 中间件实战:不碰主流程,也能给 Agent 装上"外挂"

面向正在用 LangChain / LangGraph 做 Agent 的后端和 AI 开发同学,读完你能独立写出日志、埋点、限流、敏感词拦截、动态工具这类"横切逻辑"。

前言

一句话:Agent 的业务逻辑不该被日志、计数、敏感词、权限这些杂事污染,中间件就是那个"不侵入主流程的插槽"。

本文用两个可运行的小脚本,把 LangChain v1 的 6 个生命周期钩子、状态注入、短路跳转、动态挂工具全部跑通,并附上我们真实踩到的坑。

知识点清单

  • createAgent 的四个核心配置:model / tools / systemPrompt / middleware
  • 6 个生命周期钩子的执行时机与执行次数
  • stateSchema:中间件如何给 Agent 增加自定义状态,并让 invoke() 把状态返回给你
  • 钩子返回值 = 状态补丁(不是副作用)
  • wrapModelCall:改写请求、重试、缓存、短路
  • beforeModel + canJumpTo + jumpTo:在模型调用前"掐断"整个流程
  • 中间件自带工具:顶层 tools: [] 也能让 Agent 拥有工具
  • wrapToolCall 返回 Command:同时改消息和改状态
  • 洋葱模型的注册顺序:谁在最外层

业务背景

假设你做了一个客服 Agent,上线后立刻会遇到这些需求:

需求 直觉做法(错) 实际问题
记录每次模型调用耗时和次数 在业务函数里到处 console.log 逻辑被日志淹没,删日志还要改业务
统一给所有回答加"请用一句话回答" 改 systemPrompt 不同场景要不同策略,改一次影响全部
命中敏感词直接拒绝 在入口 if 判断 只能拦用户输入,拦不住模型自己"越狱"
临时给 Agent 加一个查时间的工具 改 createAgent({ tools }) 工具是动态开关的,每次改代码不现实
统计工具被调用了多少次 每个工具函数里计数 工具几十个,重复代码爆炸

这些需求有个共同点:它们横跨所有业务,但不属于任何一条业务。业内管这叫"横切关注点",解决方案就是中间件(AOP 切面)。

放到 Agent 场景里,中间件的价值更直接:

  • 可观测性:不侵入业务,就能拿到每次模型调用、每次工具调用;
  • 成本控制:可以在调模型前直接短路,省掉 token;
  • 安全兜底:敏感词、越权文件操作,在钩子里统一拦截;
  • 能力扩展:工具可以由中间件动态注入,按租户/按场景开关。

整体业务思路

把一次 Agent 运行看成一条流水线:

erlang 复制代码
用户输入
  → beforeAgent(整次运行只跑一次:初始化、开 trace)
  → beforeModel(每轮模型调用前:检查、改状态、可短路)
  → wrapModelCall(包住模型调用:改 prompt、重试、缓存)
  → 模型
  → afterModel(每轮模型调用后:记账、改结果)
  → (有工具则 tools → 回到 beforeModel,形成循环)
  → afterAgent(整次运行只跑一次:汇总、上报)

选择钩子的判断标准只有一句:你要在"哪一步"动手?

  • 只想观察 → beforeXxx / afterXxx
  • 想改请求或改结果 → wrapXxx
  • 想跳过后续所有步骤 → beforeXxx + jumpTo

详细业务逻辑

1. 六个切点,各管一段

钩子 触发时机 次数 典型用途
beforeAgent Agent 启动,模型还没动 1 次 初始化上下文、打开始日志、建 trace
beforeModel 每轮调模型之前 每轮 校验输入、注入状态、短路
wrapModelCall 包住模型调用 每轮 改 systemMessage、加工具、重试、缓存
afterModel 模型返回之后 每轮 记录 token、清洗输出、计数
wrapToolCall 包住工具执行 每次工具调用 参数校验、权限、重试、结果包装
afterAgent Agent 结束 1 次 汇总统计、上报、落库

记忆口诀:before 是事前检查,after 是事后记账,wrap 是连皮带肉包起来。

2. 案例一:日志 + 调用次数统计

需求:不碰业务代码,统计"这次对话调用了几次模型",并把结果返回给调用方。

js 复制代码
const loggingMiddleware = createMiddleware({
  name: "LoggingMiddleware",
  // ① 给 Agent 状态加一个字段,默认 0
  stateSchema: z.object({
    modelCallCount: z.number().default(0)
  }),
  beforeAgent: (state) => {
    console.log("[Logging] agent 开始,消息数:", state.messages.length);
  },
  beforeModel: (state) => {
    console.log(`[Logging] 即将调用模型,已调用 ${state.modelCallCount} 次`);
  },
  afterModel: (state) => {
    const last = state.messages.at(-1);          // 刚返回的 AIMessage
    const preview = typeof last?.content === "string"
      ? last.content.slice(0, 280)
      : JSON.stringify(last?.content)?.slice(0, 280);
    console.log("[Logging] 模型返回:", preview);
    // ② 返回对象 = 状态补丁,交给 LangGraph 合并
    return { modelCallCount: state.modelCallCount + 1 };
  },
  afterAgent: (state) => {
    console.log(`[Logging] agent 结束,累计模型调用:${state.modelCallCount} 次`);
  }
});

三个关键点:

  1. stateSchema 是"注册字段" ,不是普通变量声明。注册后所有钩子都能读,invoke() 也会把它返回(const { modelCallCount } = await agent.invoke(...))。
  2. default(0) 必须写 。少了它,初始值是 undefined,undefined + 1 = NaN,最终你会拿到 modelCallCount: NaN。
  3. 钩子返回值是"状态补丁",不是随便返回。LangGraph 会把它 merge 进 state 通道。

3. 案例二:改写发给模型的 system prompt

需求:所有回答都强制"一句话简洁回答",但不改业务里的 systemPrompt。

js 复制代码
const addContextMiddleware = createMiddleware({
  name: "AddContextMiddleware",
  wrapModelCall: async (request, handler) => {
    // request: 本次模型调用的完整请求(model / messages / systemMessage / tools)
    // handler: 真正去调模型的函数
    return handler({
      ...request,                          // 其余字段原样保留
      systemMessage: request.systemMessage.concat("\n\n 请用一句话简洁回答")
    });
  }
});

三个容易翻车的细节:

  • request.systemMessage 是 SystemMessage 对象,不是字符串 。.concat() 是 BaseMessage 的方法,返回一个新的 SystemMessage;它不是 String.prototype.concat。
  • 必须 return handler(...)。只改请求不调用 handler,模型根本不会执行。
  • 不能同时改 systemPrompt 和 systemMessage ,框架会直接抛错:Cannot change both systemPrompt and systemMessage in the same request。

顺带一提,wrapModelCall 里"不调 handler、直接返回一个 AIMessage"就是另一种短路------常用于缓存命中或兜底回答。

4. 案例三:敏感词短路,模型一次都不调

需求:命中敏感词时,直接拒绝,既省钱又快。

js 复制代码
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")) {
        return {
          messages: [new AIMessage("该请求已被中间件拦截,无法处理")],
          jumpTo: "end"
        };
      }
    }
  }
});

为什么要写成 { hook, canJumpTo } 而不是直接写函数?

因为跳转是特权操作,框架要求你显式声明"允许跳到哪",运行时校验,越权会抛:

yaml 复制代码
Invalid jump target: xxx, must be one of: end.

可跳目标只有三个:model、tools、end。本例的 end 表示"整张图直接结束"。

另外注意 messages 是追加型通道 :你返回一条 AIMessage,是往历史里补一条,而不是覆盖。

5. 案例四:中间件自带工具,并统计工具调用

需求:不改 createAgent 的 tools,让中间件动态提供一个工具,同时统计被调用了多少次。

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

const extendedToolsMiddleware = createMiddleware({
  name: "ExtendedToolsMiddleware",
  stateSchema: z.object({
    toolInvocationCount: z.number().default(0)
  }),
  tools: [getCurrentTime],                   // ① 中间件自带工具
  wrapToolCall: async (request, handler) => {
    console.log(`[Tools] 即将执行: ${request.tool?.name ?? request.toolCall.name}`);
    const result = await handler(request);    // ② 真正执行工具

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

    const wrapped = new ToolMessage({
      content: `${result.content}\n[wrapToolCall] 已由中间件包裹`,
      tool_call_id: result.tool_call_id,
      name: result.name
    });

    // ③ 返回 Command:既更新状态,又替换消息
    return new Command({
      update: {
        toolInvocationCount: request.state.toolInvocationCount + 1,
        messages: [wrapped]
      }
    });
  },
  afterAgent: (state) => {
    console.log(`[Tools] agent 结束,工具调用 ${state.toolInvocationCount} 次`);
  }
});

为什么工具执行完要返回 Command 而不是直接返回 ToolMessage?

  • 直接返回 ToolMessage:只能替换这一次的工具结果;
  • 返回 Command({ update }):能同时"改消息 + 改状态",这是记账、写审计日志、回填业务字段的标准姿势。

实测这一次 "给我当前时间" 的完整链路是 4 条消息:

scss 复制代码
HumanMessage("给我当前时间")
AIMessage(tool_calls: get_current_time)     ← 模型决定调工具
ToolMessage("2026-...Z\n[wrapToolCall] ...")
AIMessage("当前时间是:...")

对应的 token 消耗也印证了循环:第一次 175 输入 token(决定调工具),第二次 246(带着工具结果再问模型)。

6. 执行顺序:洋葱模型

多个中间件同时注册时,顺序不是随机的:

  • 同一类钩子按注册顺序执行 :先 logging 的 beforeModel,再 blocked 的;
  • wrapModelCall 是洋葱 :先注册的在最外层(先拿到请求,最后拿到结果);
  • 代理型钩子(wrap)与通知型钩子(before/after)不要混着理解:前者能改数据流,后者只能观察/补丁。

核心代码片段

把三个中间件组装进 Agent:

js 复制代码
const agent = createAgent({
  model,
  tools: [],
  systemPrompt: "你是一个助手。",
  middleware: [
    loggingMiddleware,          // 生命周期日志 + 计数
    addContextMiddleware,       // 改写 systemMessage
    blockedContentMiddleware    // 敏感词短路
  ]
});

const { messages, modelCallCount } = await agent.invoke({
  messages: [new HumanMessage("这句话包含Block 关键词")]
});

实测输出(节选):

csharp 复制代码
[Logging] agent 开始,消息数: 1
[Logging] 即将调用模型,当前消息数:1, 已调用 0 次模型
[Add Context]注入额外system上下文
[Logging]模型返回: 这句话包含"Block"关键词。...
[Logging] agent 结束,累计模型调用:1次
modelCallCount: 1

踩坑点 & 避坑总结

坑 1:敏感词判断大小写不匹配,拦截成了"死代码"

js 复制代码
if (text.includes("BLOCKED"))     // 全大写
// 输入:"这句话包含Block 关键词"  → includes("BLOCKED") === false

实测日志里只有 [Logging],没有 [Blocked],模型照常回答------看起来演示了拦截,其实一次都没触发。

修法:

js 复制代码
if (text.toUpperCase().includes("BLOCK")) { ... }

教训:演示代码里的"命中分支"一定要用能命中的输入跑一遍,否则你验证的只是"没崩",不是"逻辑对"。

坑 2:忘了 default(0),计数变 NaN

stateSchema 里写 z.number() 不给默认值,初始是 undefined,+1 后是 NaN,一路污染到 invoke() 返回值。自定义状态字段,永远给默认值。

坑 3:jumpTo: "end" 会跳过 afterAgent

beforeModel 跳到 end 是直接连到图末尾,不经过 afterAgent 节点。所以一旦拦截生效,你会看到:

csharp 复制代码
[Blocked] 检测到敏感词,短路结束

而没有 "Logging agent 结束"。如果"结束也要记账",收尾逻辑要放在别处(比如 wrapModelCall 里或者包一层外层函数)。

坑 4:SystemMessage.concat 不是字符串拼接

它是 BaseMessage 的方法,返回一个新消息对象。写成 request.systemMessage + "..." 会得到 "[object Object]..."。

坑 5:只改请求,不调 handler

wrapModelCall 里忘写 return handler(...),模型不会被调用,你会拿到一个"空跑"的结果,且很难排查。wrap 钩子的铁律:改了请求一定要往下传。

坑 6:脚本依赖没装全,第一行就报错

所有脚本都用 import "dotenv/config",但 package.json 里没有 dotenv,直接报:

lua 复制代码
ERR_MODULE_NOT_FOUND: Cannot find package 'dotenv'

补一句 pnpm add dotenv 即可。

坑 7:不在项目目录运行,读不到 .env

dotenv 读的是当前工作目录 的 .env。在仓库根目录跑 node demo/src/xxx.mjs 会拿不到 API Key,正确姿势是 cd demo 再跑。

总结

  • 中间件的本质是 Agent 运行流水线上的切面,解决"横切关注点",让业务代码保持干净。
  • 6 个钩子按"事前 / 事后 / 包裹"记忆,选钩子只看一件事:你要在哪一步动手。
  • stateSchema + 钩子返回补丁,是中间件与 Agent 交换数据的唯一通道,默认值不能省。
  • 短路要满足两个条件:对象形式的钩子 + canJumpTo 白名单 + jumpTo ,并且要接受"跳过后 afterAgent 不执行"这个副作用。
  • 工具也能由中间件注入;wrapToolCall 返回 Command 才能同时改消息与状态。
  • 写完一定要用能命中分支的输入跑一遍,否则"拦截逻辑"可能从未执行。

下一篇我们换到 DeepAgents,看官方把三个最常用的能力------文件系统、长期记忆、上下文摘要------封装成了什么样,以及它们各自会在哪里咬你一口。

相关推荐
泡海椒18 分钟前
jquick-pdf 生成征信报表(相关矩阵版):靛蓝主题报告里动态图与说明面板并排的实测笔记
后端
IT枫斗者枫哥21 分钟前
Java 退款请求超时,先查原单还是重试?
java·后端
特立独行的猫A25 分钟前
我用仓颉写了个多平台发文工具:一份 Markdown,一条命令发六个平台
java·后端·python
HUANGZHENXUAN12327 分钟前
代码解析(二):detect_video.py —— 一个纯函数 + 一段主循环
后端
A1Book31 分钟前
设计一个 Agent 调度工作流:状态机、工具注册与多模型路由的工程实践
agent
爱勇宝1 小时前
《道德经》第 13 章:别让评价系统控制你的心
前端·后端·产品
掘金者阿豪1 小时前
集中式与分布式数据库怎么选?从架构原理到电科金仓的实践思考
前端·后端
张彦峰ZYF2 小时前
升级了不等于变好了:AI回归测试的固定评测集、版本对比、质量阈值与CI门禁
人工智能·后端·模型或提示词升级·语言模型没有编译器与确定性输出·总体、切片与回退清单·红线、护栏与目标·ci门禁把判定写进合并路径
程序员沉梦听雨2 小时前
【搜索引擎】Kibana使用指南
java·后端·学习笔记