Skip to content

Middleware

要点

  • Middleware 是夹在 Agent 运行过程周围的薄壳,负责在调用前后插手,但不替代 Agent 的核心步骤。
  • 常见用法:动态改 system prompt、根据上下文过滤可用工具、在工具报错时做兜底、记录运行信息。
  • 同一 Agent 在不同运行场景里可以临时换一套规则,比如生产环境禁用高危工具、非变更窗口限制操作范围。
  • Middleware 不适合接管具体业务逻辑,业务逻辑应该回到工具函数里写。

1. 背景:同一个 Agent,不同场景不一定该做同样的事

到前一篇为止,一个单 Agent 已经能接多个工具,也能在一轮里连续做几件事了。这时候很快会碰到一个更实际的问题。

比如你做的是一个技术运维助手。在正常工作日,用户说:

text
帮我看看 payment 服务状态,如果错误率超过 5%,就创建一个高优先级工单。

这类请求很正常,查状态、创建工单都可以照常做。但如果是在凌晨两点的非变更窗口,用户说:

text
payment 服务有点慢,帮我直接重启一下所有实例。

这时候你可能不想让 Agent 立刻去动生产服务。你更希望它先说明风险,把这类高风险动作拦下来,或者至少换一种更谨慎的处理方式。

问题就在这里:工具本身没有错,Agent 也没有错。你只是希望同一个 Agent,在不同运行场景里,多一层规则。Middleware 就是放这层规则的地方。

2. Middleware 放在 Agent 循环的外面,但又贴着它

可以把它理解成一层夹在 Agent 运行过程周围的薄壳。用户消息进来以后,Agent 还是会照常去做这些事:

  • 调模型。
  • 决定要不要用工具。
  • 执行工具。
  • 再继续往下走。

Middleware 不负责替代这些步骤。它负责在这些步骤的前后插手一下。最常见的几种用法其实都很朴素:

  • 改一下这一轮的 system prompt。
  • 根据上下文决定哪些工具能用。
  • 在工具报错时做一层兜底。
  • 记录一些运行信息,方便后面排错。

这一篇先只讲前面三种,不去展开 tracing。

3. 最实用的:根据场景改 system prompt

先从最容易感受到价值的地方开始。还是刚才那个运维助手。我们希望它在非变更窗口时更克制一点,不要把回复写得太像白天的办事助手。

这件事不用改工具,也不用重写 Agent。直接在 Agent 外面挂一层 middleware 就够了。

typescript
import * as z from "zod";
import { createAgent, dynamicSystemPromptMiddleware, tool } from "langchain";

const contextSchema = z.object({
  isChangeWindow: z.boolean(),
});

const getServiceStatus = tool(
  async ({ service }) => `${service}:运行正常,QPS 1200,P99 180ms`,
  {
    name: "get_service_status",
    description: "查询某个微服务当前的运行状态",
    schema: z.object({
      service: z.string().describe("要查询的服务名"),
    }),
  },
);

const agent = createAgent({
  model: "openai:gpt-4.1-mini",
  tools: [getServiceStatus],
  contextSchema,
  middleware: [
    dynamicSystemPromptMiddleware<z.infer<typeof contextSchema>>(
      (state, runtime) => {
        // 这里不是改用户消息,而是按运行时上下文决定这一轮的行为准则。
        // 同一个 Agent,在不同场景里可以临时换一套说话方式。
        if (!runtime.context.isChangeWindow) {
          return `
你是技术运维助手,当前不在变更窗口内。

回复要短一点,先说明当前不是变更窗口。
如果请求涉及重启服务、删除数据等高风险操作,不要直接执行,先提醒用户等变更窗口再确认。
        `.trim();
        }

        return `
你是技术运维助手。

在需要时可以查询服务状态并提供简洁帮助。
      `.trim();
      },
    ),
  ],
});

const result = await agent.invoke(
  {
    messages: [
      {
        role: "user",
        content: "帮我看看 payment 服务状态。",
      },
    ],
  },
  {
    // 这份 context 不会直接出现在消息里,
    // 但 middleware 可以在运行时读到它。
    context: { isChangeWindow: false },
  },
);

console.log(result.messages.at(-1)?.text);

这一层最重要的地方,不是 API 名字,而是思路。以前你可能会把「正常模式」和「非变更窗口模式」硬塞进一个超长 systemPrompt 里,然后让模型自己猜。现在这层逻辑可以明确写在 middleware 里,运行时到了哪种场景,就切哪种规则。

4. 再往前走一步:有些工具,非变更窗口就先别给它看见

只改 system prompt 还不够的时候,就要开始动工具列表了。还是同一个场景。假设你的 Agent 平时有这几个工具:

  • 查服务状态
  • 创建工单
  • 重启服务

白天都能用没有问题。但非变更窗口,你不想让它直接看到 restart_service 这种工具。这时候 middleware 可以在模型调用前,把工具列表过滤一遍。

typescript
import * as z from "zod";
import { createAgent, createMiddleware, tool } from "langchain";

const contextSchema = z.object({
  isChangeWindow: z.boolean(),
});

const getServiceStatus = tool(async ({ service }) => `${service}:运行正常`, {
  name: "get_service_status",
  description: "查询某个微服务当前的运行状态",
  schema: z.object({
    service: z.string().describe("要查询的服务名"),
  }),
});

const restartService = tool(
  async ({ service }) => `${service}:重启申请已提交`,
  {
    name: "restart_service",
    description: "重启某个微服务的所有实例",
    schema: z.object({
      service: z.string().describe("要重启的服务名"),
    }),
  },
);

const changeWindowMiddleware = createMiddleware({
  name: "ChangeWindowToolFilter",
  contextSchema,
  wrapModelCall: (request, handler) => {
    // 变更窗口内照常放行。
    if (request.runtime.context.isChangeWindow) {
      return handler(request);
    }

    // 非变更窗口时,把高风险工具先从这一轮可见工具里拿掉。
    const filteredTools = request.tools.filter(
      (tool) => tool.name !== "restart_service",
    );

    return handler({
      ...request,
      tools: filteredTools,
    });
  },
});

const agent = createAgent({
  model: "openai:gpt-4.1-mini",
  tools: [getServiceStatus, restartService],
  contextSchema,
  middleware: [changeWindowMiddleware],
});

const result = await agent.invoke(
  {
    messages: [
      {
        role: "user",
        content: "payment 服务有点慢,帮我重启一下。",
      },
    ],
  },
  {
    context: { isChangeWindow: false },
  },
);

console.log(result.messages.at(-1)?.text);

这一段代码里,工具本身完全没改。变的是这一轮模型到底能看到哪些工具。这类写法很适合做:

  • 非变更窗口保护。
  • 权限控制。
  • 某些阶段先隐藏高级工具。
  • 新用户只开放一部分能力。

它的好处是很直接:模型根本看不到不该用的工具,自然就不会调它。

5. 工具执行时出错了,也别把整轮对话撞断

还有一种场景很常见。工具明明设计好了,但外部接口偶尔会报错。比如监控服务超时、工单系统挂了、数据库临时没连上。如果你什么都不做,这类异常可能会直接把整轮对话打断。

middleware 可以在工具调用外面包一层,把错误转成一条工具结果,再交回模型继续往下走。

typescript
import { createAgent, createMiddleware, ToolMessage, tool } from "langchain";
import * as z from "zod";

const getServiceStatus = tool(
  async ({ service }) => {
    // 这里故意模拟外部服务出错。
    throw new Error(`监控服务暂时不可用:${service}`);
  },
  {
    name: "get_service_status",
    description: "查询某个微服务当前的运行状态",
    schema: z.object({
      service: z.string().describe("要查询的服务名"),
    }),
  },
);

const handleToolErrors = createMiddleware({
  name: "HandleToolErrors",
  wrapToolCall: async (request, handler) => {
    try {
      // 正常情况下,工具还是按原样执行。
      return await handler(request);
    } catch (error) {
      // 报错时,不直接把整轮调用打断。
      // 这里返回一条 ToolMessage,让模型知道「工具失败了」,再自己组织回复。
      return new ToolMessage({
        content: `工具调用失败,请先不要继续依赖这个结果。错误信息:${String(error)}`,
        tool_call_id: request.toolCall.id!,
      });
    }
  },
});

const agent = createAgent({
  model: "openai:gpt-4.1-mini",
  tools: [getServiceStatus],
  middleware: [handleToolErrors],
});

const result = await agent.invoke({
  messages: [
    {
      role: "user",
      content: "帮我看看 payment 服务状态。",
    },
  ],
});

console.log(result.messages.at(-1)?.text);

这一层很适合留给用户一个更平滑的结果。用户看到的不会是一大段报错栈,而是一句还能接得住对话的话。

6. 写 middleware 时,先把边界想清楚

刚开始接 middleware,最容易写乱的地方不是 hook 名字,而是边界。有些逻辑应该放 middleware,有些不该。

如果你要改的是这一轮运行规则,比如非变更窗口模式、权限、工具暴露范围、错误兜底,那放 middleware 很合适。因为这些东西横跨整条调用链,不属于某一个工具,也不该散在每个工具里重复写。

但如果你要做的是某个工具自己的业务逻辑,比如「工单写进哪张表」「重启服务时要不要通知下游」,那还是回到工具函数里写。middleware 不适合接管具体业务。

还有一个很实用的判断办法:如果这段逻辑删掉以后,工具本身还能独立成立,那它大概率适合写成 middleware。如果删掉以后,工具就根本不完整了,那它通常不该放到 middleware 里。

7. 总结

写完这一篇以后,单个 Agent 的样子会开始不一样。它不再只是:接消息、调工具、回答案。中间已经多了一层运行规则。

同一个 Agent,在不同上下文里可以有不同说话方式;同一个工具列表,在不同场景里可以只开放一部分;同一个工具出错时,也可以先被接住,再继续把这轮对话走完。

下一篇再往后接,就可以专门讲 tracing。到那时,关注点就不是「这一层规则怎么加」,而是「Agent 实际跑的时候,到底都发生了什么」。

基于 MIT 协议开源