别再手写 Agent 基建了:用 deepagents 中间件,30 行代码搞定文件系统 / 记忆 / 上下文压缩

你有没有过这种体验:想做一个"能读写文件、能记住用户偏好、聊久了还不爆上下文"的 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,就做到了两件事:

  1. 用 wrapped 替换掉原始工具结果(可以给结果加审计标记、脱敏、格式化);
  2. 顺手把 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)。

所以上面三条的含义是:

  1. 想读 /secret.txt?→ 命中第一条,拒绝;
  2. 想写 /todo.md?→ 命中第二条,放行;
  3. 想写 /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

它内部分四步走(直接来自源码注释):

  1. 监控对话长度是否达到阈值;
  2. 触发时,把旧消息卸载到 backend 存储;
  3. 对卸载的部分生成摘要;
  4. 用摘要替换旧消息,保留最近上下文。

跑一遍,你会在磁盘上看到被"卸载"出来的历史文件:

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 运行小贴士

  1. 依赖装好:项目用的是 qwen-plus(通过 DashScope 的 OpenAI 兼容模式),.env 里配好 OPENAI_BASE_URL / OPENAI_API_KEY / MODEL_NAME 即可。
  2. 示例都用了 recursionLimit(如 { recursionLimit: 20 })来控制多轮工具调用的上限,别漏了这个参数,否则可能中途报"递归超限"。
  3. filesystem-agent.mjs / summarization-agent.mjs 每次运行都会清空并重建工作区 ,方便反复实验;memory-agent.mjs 则刻意保留记忆文件,这样才看得出"跨会话记忆"的效果。

跑起来吧,把中间件当成你的"能力插槽",剩下的,就交给业务逻辑了。

相关推荐
知几蜗牛1 小时前
Java 17实现Agent工具调用的白名单、预算与审计门禁
人工智能
橘和柠1 小时前
vLLM高吞吐推理引擎:本地高并发推理服务
人工智能
云和数据.ChenGuang1 小时前
langchain4j InMemoryEmbeddingStore常用的方法
java·人工智能·windows·java-ee·fastapi·springai
小天源1 小时前
GEO 品牌监测系统实战:基于 Node.js + Playwright 实现多平台采集与报告导出
人工智能·node.js·geo·品牌检测·ai诊断
skywalk81631 小时前
光明语言入榜计划:GitHub Linguist 注册指南・光明语言模块参考
人工智能·编程·光明
天空鸟_时光不老1 小时前
07-检查点与状态持久化
java·人工智能·spring boot·spring·spring cloud·kafka·maven
Latchh1 小时前
PDF打开不要密码却显示已加密,前端怎么判断
前端·图像处理·人工智能·计算机视觉·pdf
thinking_talk1 小时前
企业AI记忆产品科学选型框架
人工智能·机器学习·ai记忆
卿卿的产品经理日记2 小时前
【AI产品经理实战】Day 33|计算机科学速成:从巴贝奇到“AI是围墙“
人工智能·aigc·产品经理