LangChain Agent 中间件实战(上):生命周期、状态、短路与工具包

LangChain Agent 中间件实战(上):生命周期、状态、短路与工具包装

当 Agent 只有一次模型调用时,直接在调用前后写业务逻辑并不困难。真正麻烦的是 Agent 开始循环以后:模型可能决定调用工具,工具执行完又回到模型;一次用户请求可能产生多次模型调用;日志、统计、内容拦截和提示词增强也可能同时存在。

如果把这些逻辑全部塞进主流程,Agent 很快就会变得难以维护。Middleware 的价值,正是把这些横切能力放到 Agent 生命周期的固定位置中。

本文用两个完整示例讲清楚以下问题:

  • 如何监听 Agent 和模型调用的生命周期;
  • 如何给 Agent 增加自定义状态;
  • 如何在模型调用前动态补充 System 上下文;
  • 如何在命中规则时跳过模型并提前结束;
  • 如何通过 Middleware 向 Agent 注册工具;
  • 如何包装工具结果,并用 Command 同时更新消息与统计状态。

一、先准备一个可复用的模型实例

两个示例都使用同一种模型初始化方式:

js 复制代码
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";

const model = new ChatOpenAI({
  model: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  temperature: 0,
});

这里把模型名称、API Key 和接口地址都交给环境变量,代码本身不绑定具体模型服务。temperature: 0 则让实验结果更稳定,方便观察 Middleware 是否按预期执行。

接下来先从一个不带工具的 Agent 开始。

二、Middleware 处在 Agent 的什么位置

可以先把一次典型执行抽象成下面这条链路:

text 复制代码
用户消息
  ↓
beforeAgent
  ↓
beforeModel
  ↓
wrapModelCall → 模型调用 → afterModel
  ↓
模型是否请求工具?
  ├─ 否:afterAgent → 返回结果
  └─ 是:wrapToolCall → 执行工具 → 再次进入模型阶段

这张图里有两类扩展点:

  1. beforeAgent、beforeModel、afterModel、afterAgent 是生命周期钩子,适合记录日志、校验输入和更新状态;
  2. wrapModelCall、wrapToolCall 是包装器,能够拿到本次请求以及继续执行的 handler,适合改写请求、包装返回值。

二者的差别很重要:钩子关注"某个阶段已经到来",包装器则真正处在调用链中间,可以决定给下一层传什么。

三、用自定义状态统计模型调用次数

先实现一个日志 Middleware:

js 复制代码
import { z } from "zod";
import { createMiddleware } from "langchain";

const loggingMiddleware = createMiddleware({
  name: "LoggingMiddleware",

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

  beforeAgent: (state) => {
    console.log(
      `\n[Logging] Agent 开始,消息数:${state.messages.length}\n`
    );
  },

  beforeModel: (state) => {
    console.log(
      `[Logging] 即将调用模型,当前消息数:${state.messages.length},` +
      `已调用 ${state.modelCallCount} 次模型\n`
    );
  },

  afterModel: (state) => {
    const last = state.messages.at(-1);
    const preview =
      typeof last?.content === "string"
        ? last.content.slice(0, 200)
        : JSON.stringify(last?.content).slice(0, 200);

    console.log(`[Logging] 模型返回:${preview}...\n`);

    return {
      modelCallCount: state.modelCallCount + 1,
    };
  },

  afterAgent: (state) => {
    console.log(
      `[Logging] Agent 结束,累计模型调用:${state.modelCallCount} 次\n`
    );
  },
});

1. stateSchema 不只是校验参数

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

这段定义给 Agent 状态增加了 modelCallCount 字段,初始值为 0。它和内置的 messages 一起贯穿执行过程,因此不同钩子都能读取它。

需要更新状态时,钩子返回一个局部更新对象即可:

js 复制代码
return {
  modelCallCount: state.modelCallCount + 1,
};

这里不是在钩子中直接修改 state,而是明确返回本阶段产生的状态更新。这样,状态变化仍然由 Agent 的执行图统一处理。

2. 一次 Agent 调用不一定只调用一次模型

当前这个 Agent 没有工具,正常情况下会直接生成答案,因此 modelCallCount 通常增加一次。但加入工具后,链路可能变成:

text 复制代码
第一次模型调用:决定使用哪个工具
工具调用:执行具体操作
第二次模型调用:阅读工具结果并组织最终答案

因此统计"Agent 被调用几次"和统计"模型被调用几次"是两件事。把计数放在 afterModel,统计的正是模型实际返回的次数。

3. 消息内容不一定永远是字符串

日志代码没有直接调用 last.content.slice(...),而是先判断类型:

js 复制代码
const preview =
  typeof last?.content === "string"
    ? last.content.slice(0, 200)
    : JSON.stringify(last?.content).slice(0, 200);

这让预览逻辑既能处理普通文本,也能处理数组或其他结构化内容。last?.content 和 last?. 还避免了消息数组为空时直接报错。

四、用 wrapModelCall 动态增强 System 上下文

下一步是在每次模型调用前追加一条约束:要求模型用一句话间接回答。

js 复制代码
const addContextMiddleware = createMiddleware({
  name: "AddContextMiddleware",

  wrapModelCall: async (request, handler) => {
    console.log("[Add Context] 注入额外的 System 上下文");

    return handler({
      ...request,
      systemMessage: request.systemMessage.concat(
        "\n\n请用一句话间接回答"
      ),
    });
  },
});

wrapModelCall 接收两个关键参数:

  • request:当前模型调用的请求,其中包含消息、System Message、模型配置等信息;
  • handler:调用链中的下一步。把处理后的请求交给它,模型调用才会继续。

这里使用了一个很稳妥的改写方式:

js 复制代码
return handler({
  ...request,
  systemMessage: request.systemMessage.concat("\n\n请用一句话间接回答"),
});

...request 保留原请求的其余字段,只替换 systemMessage;concat 返回追加内容后的新消息。于是 Agent 原本的系统提示词仍在,只是本次模型调用多了一条要求。

这与在创建 Agent 时写死 System Prompt 的用途不同:创建时的 systemPrompt 是基础角色设定,wrapModelCall 更适合在运行阶段根据请求动态调整。

五、在 beforeModel 中短路整个流程

有些请求根本不应该发送给模型。例如,演示中约定只要最后一条消息包含大写的 BLOCKED,就立即返回固定响应。

js 复制代码
import { AIMessage, createMiddleware } from "langchain";

const blockContentMiddleware = 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",
        };
      }
    },
  },
});

1. 为什么这里的 beforeModel 是对象

普通日志钩子只需要一个函数:

js 复制代码
beforeModel: (state) => {
  // 只观察或更新状态
}

而拦截逻辑需要改变执行图走向,因此使用对象形式:

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

canJumpTo 先声明这个钩子允许跳转到哪些节点,hook 再在命中条件时返回 jumpTo: "end"。这让"允许跳到哪里"和"什么时候跳"分开表达。

2. 短路时仍要补上一条 Agent 回复

命中规则后返回的不只有跳转指令:

js 复制代码
return {
  messages: [new AIMessage("该请求已被 Middleware 拦截,无法处理")],
  jumpTo: "end",
};

AIMessage 会进入消息状态,调用方仍能从最后一条消息中读取到清晰结果;jumpTo: "end" 则阻止流程继续进入模型节点。

这时模型没有被调用,所以前面的 afterModel 不会为本次拦截增加计数。它恰好说明:Middleware 的短路不是"让模型拒绝回答",而是根本不把请求发给模型。

当然,这里的字符串包含判断只是为了展示控制流程。它只匹配大写的精确关键词,不能被当作完整的内容安全策略。

六、把三个 Middleware 组合到 Agent 中

现在将日志、上下文增强和拦截器按顺序交给 createAgent:

js 复制代码
import {
  createAgent,
  HumanMessage,
} from "langchain";

const agent = createAgent({
  model,
  tools: [],
  systemPrompt: "你是一个助手。",
  middleware: [
    loggingMiddleware,
    addContextMiddleware,
    blockContentMiddleware,
  ],
});

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

console.log("回复:", messages.at(-1)?.content);
console.log("modelCallCount:", modelCallCount);

这次输入会在 beforeModel 阶段被拦截。调用方不需要捕获异常,而是照常读取最终消息和状态;区别在于模型调用次数仍是 0。

如果换成普通问题,执行链则会继续进入 wrapModelCall,在 System Message 后追加回答要求,再调用模型。

由此可以看到三个 Middleware 的职责很清晰:

Middleware 关注点 是否改变流程
LoggingMiddleware 生命周期日志和模型调用计数 更新状态,不主动短路
AddContextMiddleware 修改本次模型请求的 System Message 请求继续向下传递
BlockedContentMiddleware 检查最后一条消息 命中后跳到 end

七、通过 Middleware 给 Agent 增加工具

前面的 Agent 把 tools 设为空数组。接下来继续保持顶层 tools: [],但通过 Middleware 注册一个获取当前 UTC 时间的工具。

js 复制代码
import { z } from "zod";
import { tool } from "langchain";

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

这个工具不需要参数,所以 Schema 是空对象。执行结果来自:

js 复制代码
new Date().toISOString()

返回值是 UTC 时间的 ISO 8601 字符串,与工具描述保持一致。

然后定义工具 Middleware:

js 复制代码
import {
  createMiddleware,
  ToolMessage,
} from "langchain";
import { Command } from "@langchain/langgraph";

const extendedToolMiddleware = createMiddleware({
  name: "ExtendedToolsMiddleware",

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

  tools: [getCurrentTime],

  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],
      },
    });
  },

  afterAgent: (state) => {
    console.log(
      `[Tool] Agent 结束,Middleware 统计工具调用:` +
      `${state.toolInvocationCount}`
    );
  },
});

1. tools 可以由 Middleware 提供

创建 Agent 时依旧可以这样写:

js 复制代码
const agent = createAgent({
  model,
  tools: [],
  systemPrompt: "你是一个助手",
  middleware: [extendedToolMiddleware],
});

顶层没有注册工具,但 ExtendedToolsMiddleware 的 tools: [getCurrentTime] 会把工具纳入 Agent。这样可以把"一组工具 + 这组工具的调用策略 + 统计逻辑"封装成一个整体。

2. handler(request) 才是真正执行工具的位置

wrapToolCall 首先读取工具名称与参数并打印日志:

js 复制代码
const toolName = request.tool?.name ?? request.toolCall.name;
console.log(toolName, request.toolCall.args ?? {});

随后执行:

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

这一步把请求交给实际工具。因为它位于包装器内部,所以可以同时实现"调用前记录参数"和"调用后处理结果"。

3. 为什么先判断 ToolMessage

js 复制代码
if (!ToolMessage.isInstance(result)) {
  return result;
}

只有标准 ToolMessage 才进入后续包装逻辑。若下游返回其他有效结果,就原样透传,避免贸然读取 tool_call_id 等专属字段。

当结果确实是 ToolMessage 时,代码创建一条新工具消息:

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

这里不仅追加了可观察标记,还保留了两个关键关联字段:

  • tool_call_id:把工具结果和模型发起的那次工具调用对应起来;
  • name:保留工具名称。

如果只改内容却丢失调用 ID,模型侧的工具调用与结果就无法正确对应。

4. 用 Command 一次完成两种状态更新

包装工具结果后,代码没有直接返回 wrapped,而是返回:

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

这里要同时完成两件事:

  1. 把工具调用计数加一;
  2. 把包装后的 ToolMessage 写回消息状态。

Command 正适合表达这种"本次工具调用完成后,状态应该如何更新"的结果。messages 字段走消息状态自身的累加逻辑,所以 [wrapped] 表示追加这条工具结果,而不是用一条消息替换全部历史。

八、完整执行一次工具调用

调用代码非常简单:

js 复制代码
const { messages, toolInvocationCount } = await agent.invoke({
  messages: [new HumanMessage("给我当前时间")],
});

console.log(messages);
console.log(toolInvocationCount);

从执行链看,发生的是:

text 复制代码
用户询问当前时间
  ↓
模型看到 get_current_time 工具并发起调用
  ↓
wrapToolCall 打印工具名和参数
  ↓
handler 执行 get_current_time
  ↓
原始结果被包装成新的 ToolMessage
  ↓
Command 追加消息,并把 toolInvocationCount 加一
  ↓
模型读取工具结果,生成最终回复
  ↓
afterAgent 输出累计工具调用次数

要注意,"一次工具调用"与"一次模型调用"依然不是同一个计数。这个请求通常需要模型先决定调用工具,再根据工具结果生成答案,而 toolInvocationCount 只在工具真正执行并返回 ToolMessage 后增加。

九、两个示例串起来后,应该怎样选择扩展点

可以把选择规则压缩成下面这张表:

需求 合适的扩展点 核心写法
Agent 开始或结束时打印日志 beforeAgent / afterAgent 读取 state
每次模型调用前后统计 beforeModel / afterModel 返回局部状态更新
动态修改模型请求 wrapModelCall 修改 request 后调用 handler
命中规则后不再调用模型 对象形式的 beforeModel canJumpTo + jumpTo
给 Agent 增加一组工具 Middleware 的 tools 工具与策略一起封装
观察或包装工具执行 wrapToolCall 在 handler 前后处理
工具完成后同时更新多个状态 Command update: { ... }

十、总结

Middleware 并不是简单的"模型调用前后各执行一个函数"。在 Agent 场景中,它至少承担了三层职责:

  1. 观察执行过程:通过生命周期钩子记录消息数、模型调用次数和工具调用次数;
  2. 改变请求与结果 :通过 wrapModelCall 增强 System Message,通过 wrapToolCall 包装工具结果;
  3. 控制 Agent 图的走向 :在 beforeModel 中返回 jumpTo: "end",让请求不经过模型就结束。

更关键的是,状态不需要藏在模块级全局变量里。stateSchema、钩子的局部更新以及 Command 共同构成了一条清晰的数据流:每一项统计都属于本次 Agent 状态,每一次修改也都显式地回到执行图中。

上篇解决的是"怎样扩展 Agent 的运行过程"。下篇将继续解决另一个更实际的问题:怎样让 Agent 在受控目录中读写文件,怎样把项目事实和用户偏好变成长期记忆,以及长对话超过阈值后,怎样同时维护原始消息、滚动摘要和历史归档。

相关推荐
半个落月1 小时前
LangChain Agent 中间件实战(下):DeepAgents文件权限、长期记忆与对话摘要
javascript·langchain
u0111026753 小时前
JPG、PNG、WebP 怎么选?按透明度、体积和使用场景做判断
前端·javascript·vue.js·图像处理·ai作画
OnlineProxy3 小时前
为 LangChain 和 CrewAI 构建自定义工具:将代理 IP 轮换封装至智能体函数中
网络协议·tcp/ip·langchain
hhb_6184 小时前
一键生成数据看板,ECharts实战演示
前端·javascript·echarts
const_qiu6 小时前
第一章 1.1-1.2:基础模型调用与 TypeScript 入门 —— Java 开发者的视角
java·javascript·typescript
for_ever_love__8 小时前
LangChain 降级、重试与超时:让服务在模型抽风时还能用
python·自然语言处理·langchain·agent·超时·重试·with_retry
for_ever_love__9 小时前
LangChain 批量调用与并发控制:batch、abatch 与速率限制
人工智能·langchain·异步·config·invoke·abatch
衍辉channel10 小时前
衍辉AI速递 10.14|谷歌发布Gemini 4 Argon模型等9条AI资讯
javascript
vipxieliang10 小时前
JavaScript 事件循环与任务队列:从 setTimeout 到 Promise
前端·javascript