主题
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 实际跑的时候,到底都发生了什么」。