你有没有过这种体验:想做一个"能读写文件、能记住用户偏好、聊久了还不爆上下文"的 Agent,结果光是把这些能力拼起来就写了一两千行,业务逻辑反而没几行?
这就是
deepagents想解决的问题。而它的核心武器,只有一个词:中间件(Middleware)。
这篇文章不聊虚的,我们直接拿 demo 里的五个文件说事。看完你会拿到一套可以复制粘贴的心智模型:
vbnet
createAgent(底层 Loop) + middleware(能力插件) = 可落地的复杂 Agent
一、先搞清楚:createAgent 和 middleware 到底是什么关系
很多人第一次看 createAgent 会犯懵:它和 createReactAgent、和 LangGraph 到底啥区别?
一句话讲清楚:
- LangChain 给你一堆 AI 开发积木(模型、工具、消息);
- LangGraph 是搭复杂工作流的底层蓝图(state、循环、持久化);
- createAgent 是 LangChain 提供的一个"开箱即用的 Agent 启动器",帮你把底层的活全打理好;
- deepagents 则是在这之上,把"复杂 Agent 常需要的那些能力"打包成中间件。
createAgent 的入参就这么几个:
js
createAgent({
model, // 用哪个模型
tools: [], // 有哪些工具
systemPrompt, // 人设
middleware: [], // 👈 今天的主角
});
中间件是什么? 说白了就是:用户 request → [一层层中间件] → Agent 执行 → [一层层中间件] → response。
中间件函数被插入到每一次 Agent 运行的中间 ,提供额外功能,而不改变 Agent 本身的运行逻辑。这是它最美的地方------能力是"挂"上去的,不是"焊"死在主干里的。
💡 金句 :中间件不是给 Agent 加功能,是给 Agent 的生命周期加钩子。
那"生命周期"具体指哪些时刻?我们直接动手写一个。
二、手写第一个中间件:把 Agent 的每一步都看清
看 middleware-test.mjs。我们用 createMiddleware 造一个"日志中间件",顺便统计模型被调用了多少次。
js
import { createAgent, createMiddleware, HumanMessage, AIMessage } from "langchain";
import { z } from "zod";
const loggingMiddleware = createMiddleware({
name: "LoggingMiddleware",
// 1️⃣ 给 Agent 状态"扩展字段"
stateSchema: z.object({
modelCallCount: z.number().default(0),
}),
// 2️⃣ 生命周期 hooks
beforeAgent: (state) => {
console.log("[Logging] agent 开始,消息数:", state.messages.length);
},
beforeModel: (state) => {
console.log(`[Logging] 即将调用模型,当前消息数:${state.messages.length},
已调用 ${state.modelCallCount} 次模型`);
},
afterModel: (state) => {
const last = state.messages.at(-1);
console.log(`[Logging] 模型返回:${String(last?.content).slice(0, 80)}...`);
// 3️⃣ 返回的对象会**合并进 state**
return { modelCallCount: state.modelCallCount + 1 };
},
afterAgent: (state) => {
console.log(`[Logging] agent 结束,累计模型调用:${state.modelCallCount} 次`);
},
});
这里藏着三个关键点,别划走:
1. stateSchema:中间件可以自己"长出"新的状态字段
modelCallCount 不属于 Agent 原生状态,是中间件自己加的。加了之后,所有 hook 都能读到它,最终 invoke 的返回值里也能拿到它:
js
const { messages, modelCallCount } = await agent.invoke({
messages: [new HumanMessage(text)],
});
console.log("modelCallCount:", modelCallCount); // ✅ 中间件种下的字段,跑出来了
这就是中间件的第一个超能力:它不只是旁观者,它能改状态。
2. hook 的返回值 = state 的 patch
afterModel 里 return { modelCallCount: state.modelCallCount + 1 },不是随便 return 的------这个对象会被合并(merge)进 Agent 的 state。所以"统计调用次数"这种需求,不需要搞什么全局变量,让 state 自己记就完事。
3. 生命周期 hooks 的完整清单
| Hook | 触发时机 | 典型用途 |
|---|---|---|
beforeAgent |
Agent 整体开始 | 初始化、埋点 |
beforeModel |
每次调模型前 | 注入上下文、内容审核 |
afterModel |
每次模型返回后 | 统计、后处理 |
afterAgent |
Agent 整体结束 | 汇总、落库 |
三、进阶玩法:拦请求 & 拦工具
光记日志太小儿科。中间件真正香的地方在于------它可以拦截并改写请求,甚至直接把流程"拐弯"。
3.1 wrapModelCall:动手改发给模型的 prompt
addContextMiddleware 演示了怎么在模型调用前"偷偷"加料:
js
const addContextMiddleware = createMiddleware({
name: "AddContextMiddleware",
// 包裹模型调用:拿到 request,改完再交给 handler 继续
wrapModelCall: async (request, handler) => {
console.log("[ADD Context] 注入额外 system 上下文");
return handler({
...request,
// 覆盖 / 追加 systemMessage
systemMessage: request.systemMessage.concat("\n\n 请用一句话简洁回答"),
});
},
});
这就是一个"提示词注入中间件"。 你可以用它做:统一加系统约束、动态拼 RAG 检索结果、按租户切换人设......而 Agent 主逻辑一行都不用改。
3.2 beforeModel + jumpTo:直接短路,让 Agent 提前结束
有些内容根本不该送进模型。blockedContentMiddleware 给了标准答案:
js
const blockedContentMiddleware = createMiddleware({
name: "BlockedContentMiddleware",
beforeModel: {
canJumpTo: ["end"], // 声明:这个 hook 有权跳到 end
hook: (state) => {
const last = state.messages.at(-1);
const text = typeof last?.content === "string" ? last.content : "";
if (text.includes("BLOCKED")) {
console.log("[Blocked] 检测到 BLOCKED 内容,短路结束");
return {
messages: [new AIMessage("该请求已被 middleware 拦截,无法处理")],
jumpTo: "end", // 👈 不调模型了,直接结束
};
}
},
},
});
注意这个细节:hook 既能是函数,也能是 { canJumpTo, hook } 对象 。只有声明了 canJumpTo,你才有资格用 jumpTo 把流程"拐走"。
💡 金句 :
canJumpTo是中间件的"权限声明"------先声明能跳到哪,才允许跳。这是一种很聪明的"显式优于隐式"。
3.3 wrapToolCall + Command:给工具调用套一层壳
middleware-test2.mjs 玩得更花。它让中间件自己提供工具,并且包裹工具的每一次执行:
js
import { Command } from "@langchain/langgraph";
import { createMiddleware, tool, ToolMessage } from "langchain";
// 中间件可以自带工具------不用污染顶层 tools
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) => { // 👈 包裹工具执行
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;
// 给工具结果"盖章",同时用 Command 更新 state
const wrapped = new ToolMessage({
content: `${result.content}\n[wrapToolCall]`,
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(`[Tools] 总调用次数: ${state.toolInvocationCount}`);
},
});
这里的核心是 Command ------LangGraph 里表示"我要同时改状态 + 控制流向"的指令。wrapToolCall 返回一个 Command,就做到了两件事:
- 用
wrapped替换掉原始工具结果(可以给结果加审计标记、脱敏、格式化); - 顺手把
toolInvocationCount+1。
这个模式极其实用 :想做工具级别的审计日志、限流、结果脱敏、成本统计,全在 wrapToolCall 里搞定,不用改任何一个工具的定义。
四、真正的杀手锏:deepagents 内置的三大中间件
上面都是"基本功"。而 deepagents 之所以叫"复杂 Agent 的半成品框架",是因为它把六个最常用的能力直接做成了中间件:文件系统、记忆、上下文压缩......
我们看三个 demo 里的实战。
4.1 文件系统中间件:让 Agent 会 ls / read / write / edit
filesystem-agent.mjs 直接给 Agent 装上了一套"类 CLI 的文件操作能力":
js
import { createFilesystemMiddleware, FilesystemBackend } from "deepagents";
const agent = createAgent({
model,
tools: [],
systemPrompt: `工作区根路径为 / 。用 ls、read_file、write_file、edit_file 操作文件,
路径以 / 开头。中文回答。`,
middleware: [
createFilesystemMiddleware({
backend: new FilesystemBackend({
rootDir: workspaceDir, // 真实磁盘目录
virtualMode: true, // 👈 虚拟模式:把 rootDir 当成 "/"
}),
}),
],
});
这里最妙的是 virtualMode: true。 它把 Agent 眼里的 / 映射到真实的 workspaceDir:
- Agent 说"读
/secret.txt"→ 实际读workspaceDir/secret.txt; - Agent 说"写
/todo.md"→ 实际写workspaceDir/todo.md; - Agent 永远出不了这个沙箱目录 ,看不到你项目里的
src/、.env。
这本身就是一层天然的安全边界。但 demo 更进一步------用 permissions 做细粒度权限控制:
js
const permissions = [
{ operations: ["read"], paths: ["/secret.txt"], mode: "deny" }, // 机密文件不许读
{ operations: ["write"], paths: ["/todo.md"], mode: "allow" }, // 只许写这个
{ operations: ["write"], paths: ["/**"], mode: "deny" }, // 其它一律不许写
];
// ...
createFilesystemMiddleware({ permissions, backend });
这套规则的语义,直接抄官方定义:
规则按声明顺序求值,命中第一条即生效(first match wins),默认放行(permissive default)。
所以上面三条的含义是:
- 想读
/secret.txt?→ 命中第一条,拒绝; - 想写
/todo.md?→ 命中第二条,放行; - 想写
/hack.txt?→ 前两条都不中,命中第三条,拒绝。
demo 里的 expectDenied 函数就是用来验证这点的------它断言模型确实收到了一条 permission denied 的 tool 错误:
js
const denied = messages.some(
(m) => m.getType?.() === "tool"
&& m.status === "error"
&& /permission denied/.test(String(m.content))
);
console.log(denied ? "OK: 已拒绝" : "未触发拒绝(异常)");
⚠️ 一个容易踩的坑 :
permissions对ls/read_file/write_file/edit_file/glob/grep都生效,但对execute(执行 shell)不生效 ------因为 shell 命令能绕过路径规则访问任何文件。所以如果你用的是带执行能力的后端,要么禁用execute,要么用CompositeBackend把权限路径限定到路由前缀,否则 deepagents 会直接抛ConfigurationError。这是一种"与其让你误以为安全,不如直接报错"的设计。
注意 demo 里的顺序 :先 rmSync 清空工作区、再写入 secret.txt,然后才建 Agent。这是一种很干净的"每次运行都是全新环境"的做法,非常适合 demo 和测试。
4.2 记忆中间件:让 Agent 跨会话记住你
memory-agent.mjs 解决的是另一个痛点:Agent 怎么记住"这个用户是谁、这个项目是什么"?
答案是------把记忆存成文件,再在每次对话时注入 system prompt。
js
import { createMemoryMiddleware, FilesystemBackend } from "deepagents";
const projectMemoryPath = "/AGENTS.md"; // 项目事实
const preferencesMemoryPath = "/memory/preferences.md"; // 用户偏好
const backend = new FilesystemBackend({ rootDir: workspaceDir, virtualMode: true });
const agent = createAgent({
model,
tools: [],
systemPrompt: [
"你是项目助手。工作区根路径为 /, 可用 ls, read_file, write_file, edit_file。",
"根据 <agent_memory> 回答: 用户要求记住时,必须立刻 edit_file, 且按类型写入对应文件。",
`- ${projectMemoryPath}: 项目说明、技术栈、架构、仓库约定`,
`- ${preferencesMemoryPath}: 用户个人偏好(语言、包管理器、回答风格等)`,
"不要混写: 项目事实不要写入 preferences,个人偏好不要写入 AGENTS.md。",
].join("\n"),
middleware: [
createFilesystemMiddleware({ backend }),
createMemoryMiddleware({
backend,
sources: [projectMemoryPath, preferencesMemoryPath], // 👈 要加载的记忆文件
}),
],
});
createMemoryMiddleware 干了什么?看它的类型定义就懂了:
Loads memory content from configured sources and injects into the system prompt. Supports multiple sources that are combined together.
也就是说:它把 sources 里的文件内容读出来,拼接好,塞进 systemPrompt,并用 <agent_memory> 标签包起来。 Agent 看到 <agent_memory> 里那段,就等于"想起"了之前的事。
demo 的 prompt 序列把这个过程演示得清清楚楚:
js
const prompts = [
"根据记忆,这个项目是做什么的?只回答一句。", // ① 读记忆
"请记住: 我常用的包管理器是 pnpm。", // ② 写偏好
"请记住: 本仓库主入口脚本是 src/deepagents/memory-agent.mjs。", // ③ 写项目事实
"我常用什么包管理器?本demo主入口脚本路径是什么?各用一行回答。", // ④ 验证
];
跑完之后,工作区里真的多出了两个文件,各司其职:
markdown
# /AGENTS.md
## Technical Stack
- JavaScript/Node.js
...
## Repository Conventions
- Project facts go in `/AGENTS.md`
- User preferences go in `/memory/preferences.md`
- Never mix the two.
markdown
# /memory/preferences.md
## Package Manager
- pnpm
这套设计的精髓,是把"记忆"这件事从黑盒变成了白盒:
- 记忆 = 磁盘上的 Markdown 文件,你可以直接打开看、手动改、纳入 Git 版本管理;
- 用
sources分成"项目事实"和"用户偏好"两个文件,靠 systemPrompt 约束不许混写; - 因为记忆是文件,Agent 还能用文件系统中间件的
edit_file自己维护它。
💡 金句 :记忆不是记得多,而是记得对地方 。项目事实进
AGENTS.md,个人偏好进preferences.md------分门别类,才不会一团浆糊。
顺带一提,createMemoryMiddleware 还有个 addCacheControl 选项:开启后会在这段记忆块上打 cache_control: { type: "ephemeral" } 标记,给 Anthropic 这类支持 prompt caching 的 provider 省 token 费。记忆内容通常不变,正好是缓存的最佳对象。
4.3 上下文压缩中间件:聊到 100 轮也不爆 token
最后一个痛点:聊久了,上下文越来越长,迟早撑爆窗口。
summarization-agent.mjs 的解法很优雅------到阈值就把旧对话"卸载"到磁盘,用摘要替换掉:
js
import { createSummarizationMiddleware } from "deepagents";
const summaryPrompt = `
你是对话摘要助手。请用中文总结以下对话,包含:
1. 讨论的主要话题
2. 达成的关键结论或决定
3. 继续对话所需的重要上下文
保持简洁,不要罗列无关细节。
待摘要的对话:
{conversation}
摘要:
`;
createSummarizationMiddleware({
model,
backend,
historyPathPrefix: "/conversation_history", // 旧对话存哪
summaryPrompt, // 用啥 prompt 总结
trigger: { type: "messages", value: 8 }, // 消息数超 8 就触发
keep: { type: "messages", value: 4 }, // 保留最近 4 条
});
三个参数就是全部心智负担:
| 参数 | 含义 | demo 取值 |
|---|---|---|
trigger |
达到什么规模就触发摘要 | 消息数 > 8 |
keep |
摘要后保留最近多少条原始消息 | 最近 4 条 |
historyPathPrefix |
被卸载的历史存到哪 | /conversation_history |
它内部分四步走(直接来自源码注释):
- 监控对话长度是否达到阈值;
- 触发时,把旧消息卸载到 backend 存储;
- 对卸载的部分生成摘要;
- 用摘要替换旧消息,保留最近上下文。
跑一遍,你会在磁盘上看到被"卸载"出来的历史文件:
markdown
# /conversation_history/session_76dbefa9.md
## Summarized at 2026-10-05T08:36:15.988Z
Human: 请记住:我的宠物猫叫小橘。
AI: 好的,记住了!你的宠物猫叫小橘。
Human: 请记住:我住在北京。
AI: 好的,记住了!你住在北京。
Human: 请记住:我喜欢喝拿铁。
而 Agent 那边,systemPrompt 里早就埋好了伏笔:
js
systemPrompt: "你是会话助手......若看到[此前对话摘要],请据此继续对话。"
于是 demo 的最后一问------"我的猫叫什么、住哪、喜欢喝什么、生日是哪天?"------即使原始消息早被卸载/摘要掉了,Agent 依然能答上来。因为摘要把关键事实(small橘/北京/拿铁/5月1日)压缩保留了下来。
💡 金句 :上下文压缩的秘诀不是"删掉",而是"提炼"。 丢的是冗余,留的是事实。
五、收个尾:一张图记住 deepagents 的中间件世界观
把上面五个文件串起来,你会发现 deepagents 的设计哲学其实特别统一:
scss
┌────────────────────────────────────────────────────────┐
│ createAgent │
│ (底层 Agent Loop:调模型 → 调工具 → 循环,全帮你打理) │
├────────────────────────────────────────────────────────┤
│ middleware │
│ │
│ 自定义能力 内置能力(deepagents) │
│ ├─ 生命周期 hooks ├─ Filesystem 文件系统 │
│ ├─ wrapModelCall 改 prompt ├─ Memory 记忆 │
│ ├─ wrapToolCall 包工具 ├─ Summarization 上下文压缩│
│ └─ jumpTo 短路流程 └─ (子Agent / Skills / ...) │
└────────────────────────────────────────────────────────┘
什么时候该用哪个?
- 想加日志/埋点/统计 → 写个生命周期 hooks 的中间件;
- 想统一改 prompt、动态注入上下文 →
wrapModelCall; - 想审计/脱敏/限流工具调用 →
wrapToolCall+Command; - 想拦截不合规内容、提前结束 →
beforeModel+canJumpTo; - 想让 Agent 操作文件 →
createFilesystemMiddleware(记得配permissions); - 想跨会话记住用户/项目 →
createMemoryMiddleware(把记忆存成 md 文件); - 想聊不爆上下文 →
createSummarizationMiddleware(卸载 + 提炼)。
回到开头那个问题:为什么 deepagents 能 30 行搞定文件系统 / 记忆 / 上下文压缩?
因为它把"复杂 Agent 的通用基建"沉淀成了中间件,你只需要声明"我要什么能力",而不必重写"这个能力怎么实现"。
LangChain 给你积木,LangGraph 给你蓝图,而 deepagents 直接给你一栋"半成品精装房"------你只需要装修(写业务),不用打地基(搭基建)。
附:demo 运行小贴士
- 依赖装好:项目用的是
qwen-plus(通过 DashScope 的 OpenAI 兼容模式),.env里配好OPENAI_BASE_URL/OPENAI_API_KEY/MODEL_NAME即可。 - 示例都用了
recursionLimit(如{ recursionLimit: 20 })来控制多轮工具调用的上限,别漏了这个参数,否则可能中途报"递归超限"。 filesystem-agent.mjs/summarization-agent.mjs每次运行都会清空并重建工作区 ,方便反复实验;memory-agent.mjs则刻意保留记忆文件,这样才看得出"跨会话记忆"的效果。
跑起来吧,把中间件当成你的"能力插槽",剩下的,就交给业务逻辑了。