Middleware

要点

  • 到前一篇为止,一个单 Agent 已经能接多个工具,也能在一轮里连续做几件事了
  • 可以把它理解成一层夹在 Agent 运行过程周围的薄壳
  • 先从最容易感受到价值的地方开始
  • 只改 system prompt 还不够的时候,就要开始动工具列表了
  • 工具明明设计好了,但外部接口偶尔会报错

内容

1. 同一个 Agent,白天和深夜不一定该做同样的事

到前一篇为止,一个单 Agent 已经能接多个工具,也能在一轮里连续做几件事了。

这时候很快会碰到一个更实际的问题。

比如你做的是一个 AI 伴侣。白天,用户说:

// daytime-scene.txt
帮我看看明天的安排,再顺手建一个 8 点起床提醒。

这类请求很正常,查日程、建提醒都可以照常做。

但如果深夜两点,用户说:

// night-scene.txt
我现在有点烦,顺便帮我把明天所有安排都取消掉。

这时候你可能不想让 Agent 立刻去动用户的日程。

你更希望它先安抚一下,再把这类高风险动作拦下来,或者至少换一种更谨慎的处理方式。

问题就在这里:

工具本身没有错,Agent 也没有错。

你只是希望同一个 Agent,在不同运行场景里,多一层规则。

Middleware 就是放这层规则的地方。

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

可以把它理解成一层夹在 Agent 运行过程周围的薄壳。

Canvas actions69%Exit zen mode

Drawing canvas 用户消息进来以后,Agent 还是会照常去做这些事:

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

Middleware 不负责替代这些步骤。

它负责在这些步骤的前后插手一下。

最常见的几种用法其实都很朴素:

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

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

3. 先做一个最实用的:根据场景改 system prompt

先从最容易感受到价值的地方开始。

还是刚才那个 AI 伴侣。我们希望它在深夜时更克制一点,不要把回复写得太像白天的办事助手。

这件事不用改工具,也不用重写 Agent。

直接在 Agent 外面挂一层 middleware 就够了。

// dynamic-system-prompt.ts
import * as z from 'zod'
 
import { createAgent, dynamicSystemPromptMiddleware, tool } from 'langchain'
 
const contextSchema = z.object({
 
  isQuietHours: z.boolean(),
 
})
 
const querySchedule = tool(
 
  async ({ date }) => `${date}:10:00 产品评审会,14:00 和小李 1v1`,
 
  {
 
    name: 'query_schedule',
 
    description: '查询用户某一天的日程安排',
 
    schema: z.object({
 
      date: z.string().describe('要查询的日期,例如 今天、明天、后天'),
 
    }),
 
  },
 
)
 
const agent = createAgent({
 
  model: 'openai:gpt-4.1-mini',
 
  tools: [querySchedule],
 
  contextSchema,
 
  middleware: [
 
    dynamicSystemPromptMiddleware<z.infer<typeof contextSchema>>((state, runtime) => {
 
      // 这里不是改用户消息,而是按运行时上下文决定这一轮的行为准则。
 
      // 同一个 Agent,在不同场景里可以临时换一套说话方式。
 
      if (runtime.context.isQuietHours) {
 
        return `
 
你是用户的深夜陪伴助手。
 
回复要短一点,先照顾用户情绪。
 
如果请求涉及高风险操作,不要直接执行,先提醒用户白天再确认一次。
 
        `.trim()
 
      }
 
      return `
 
你是用户的生活助理。
 
在需要时可以查询日程并提供简洁帮助。
 
      `.trim()
 
    }),
 
  ],
 
})
 
const result = await agent.invoke(
 
  {
 
    messages: [
 
      {
 
        role: 'user',
 
        content: '帮我看看明天的安排。',
 
      },
 
    ],
 
  },
 
  {
 
    // 这份 context 不会直接出现在消息里,
 
    // 但 middleware 可以在运行时读到它。
 
    context: { isQuietHours: true },
 
  },
 
)
 
console.log(result.messages.at(-1)?.text)

这一层最重要的地方,不是 API 名字,而是思路。

以前你可能会把「白天模式」和「深夜模式」硬塞进一个超长 systemPrompt 里,然后让模型自己猜。

现在这层逻辑可以明确写在 middleware 里,运行时到了哪种场景,就切哪种规则。

4. 再往前走一步:有些工具,深夜就先别给它看见

只改 system prompt 还不够的时候,就要开始动工具列表了。

还是同一个场景。假设你的 Agent 平时有这几个工具:

  • 查日程
  • 建提醒
  • 取消日程

白天都能用没有问题。

但深夜两点,你不想让它直接看到 cancel_schedule 这种工具。

这时候 middleware 可以在模型调用前,把工具列表过滤一遍。

// filter-tools.ts
import * as z from 'zod'
 
import { createAgent, createMiddleware, tool } from 'langchain'
 
const contextSchema = z.object({
 
  isQuietHours: z.boolean(),
 
})
 
const querySchedule = tool(
 
  async ({ date }) => `${date}:10:00 产品评审会,14:00 和小李 1v1`,
 
  {
 
    name: 'query_schedule',
 
    description: '查询用户某一天的日程安排',
 
    schema: z.object({
 
      date: z.string().describe('要查询的日期'),
 
    }),
 
  },
 
)
 
const cancelSchedule = tool(
 
  async ({ date }) => `${date}:日程取消申请已提交`,
 
  {
 
    name: 'cancel_schedule',
 
    description: '取消用户某一天的重要日程',
 
    schema: z.object({
 
      date: z.string().describe('要取消的日期'),
 
    }),
 
  },
 
)
 
const quietHoursMiddleware = createMiddleware({
 
  name: 'QuietHoursToolFilter',
 
  contextSchema,
 
  wrapModelCall: (request, handler) => {
 
    // 白天照常放行。
 
    if (!request.runtime.context.isQuietHours) {
 
      return handler(request)
 
    }
 
    // 深夜时,把高风险工具先从这一轮可见工具里拿掉。
 
    const filteredTools = request.tools.filter((tool) => tool.name !== 'cancel_schedule')
 
    return handler({
 
      ...request,
 
      tools: filteredTools,
 
    })
 
  },
 
})
 
const agent = createAgent({
 
  model: 'openai:gpt-4.1-mini',
 
  tools: [querySchedule, cancelSchedule],
 
  contextSchema,
 
  middleware: [quietHoursMiddleware],
 
})
 
const result = await agent.invoke(
 
  {
 
    messages: [
 
      {
 
        role: 'user',
 
        content: '帮我把明天的安排都取消掉。',
 
      },
 
    ],
 
  },
 
  {
 
    context: { isQuietHours: true },
 
  },
 
)
 
console.log(result.messages.at(-1)?.text)

这一段代码里,工具本身完全没改。

变的是这一轮模型到底能看到哪些工具。

这类写法很适合做:

  • 深夜模式
  • 权限控制
  • 某些阶段先隐藏高级工具
  • 新用户只开放一部分能力

它的好处是很直接。

模型根本看不到不该用的工具,自然就不会调它。

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

还有一种场景很常见。

工具明明设计好了,但外部接口偶尔会报错。

比如日程服务超时、天气接口挂了、数据库临时没连上。

如果你什么都不做,这类异常可能会直接把整轮对话打断。

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

// tool-error-middleware.ts
import { createAgent, createMiddleware, ToolMessage, tool } from 'langchain'
 
import * as z from 'zod'
 
const getWeather = tool(
 
  async ({ city }) => {
 
    // 这里故意模拟外部服务出错。
 
    throw new Error(`天气服务暂时不可用:${city}`)
 
  },
 
  {
 
    name: 'get_weather',
 
    description: '查询某个城市未来的天气情况',
 
    schema: z.object({
 
      city: 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: [getWeather],
 
  middleware: [handleToolErrors],
 
})
 
const result = await agent.invoke({
 
  messages: [
 
    {
 
      role: 'user',
 
      content: '帮我看看明天上海天气。',
 
    },
 
  ],
 
})
 
console.log(result.messages.at(-1)?.text)

这一层很适合留给用户一个更平滑的结果。

用户看到的不会是一大段报错栈,而是一句还能接得住对话的话。

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

刚开始接 middleware,最容易写乱的地方不是 hook 名字,而是边界。

有些逻辑应该放 middleware,有些不该。

如果你要改的是这一轮运行规则,比如深夜模式、权限、工具暴露范围、错误兜底,那放 middleware 很合适。

因为这些东西横跨整条调用链,不属于某一个工具,也不该散在每个工具里重复写。

但如果你要做的是某个工具自己的业务逻辑,比如「提醒写进哪张表」「取消日程时要不要发通知」,那还是回到工具函数里写。

middleware 不适合接管具体业务。

还有一个很实用的判断办法:

如果这段逻辑删掉以后,工具本身还能独立成立,那它大概率适合写成 middleware。

如果删掉以后,工具就根本不完整了,那它通常不该放到 middleware 里。

7. 到这里,Agent 已经不像一段裸调用了

写完这一篇以后,单个 Agent 的样子会开始不一样。

它不再只是:

  • 接消息
  • 调工具
  • 回答案

中间已经多了一层运行规则。

同一个 Agent,在不同上下文里可以有不同说话方式;

同一个工具列表,在不同场景里可以只开放一部分;

同一个工具出错时,也可以先被接住,再继续把这轮对话走完。

下一篇再往后接,就可以专门讲 tracing。

到那时,关注点就不是「这一层规则怎么加」,而是「Agent 实际跑的时候,到底都发生了什么」。