DeepAgents 项目拆解:中间件机制、虚拟文件系统与权限模型

引子:只有 4 个源文件的项目,讲的是什么

DeepAgents 这个目录很小,源文件只有三个,加一个工作区目录:

文件 作用
src/middleware-test.mjs 三个自定义中间件:日志与调用计数、注入系统提示、关键词短路拦截
src/middleware-test2.mjs 中间件自带工具、拦截工具调用、用 Command 更新状态
src/deepagents/filesystem-agent.mjs 用 deepagents 的文件系统中间件,配合声明式权限规则
src/deepagents/workspace/ Agent 真正动手操作的工作区,里面是 secret.txt 和 todo.md

有意思的地方在于:这三个脚本里,你找不到 Agent 的主循环 。没有 while,没有"调用模型 → 解析工具调用 → 执行工具 → 把结果塞回消息列表"这一整套调度代码。它们只做了一件事------声明自己要什么能力 ,然后交给 createAgent 去组装。

这就是这篇文章要讲清楚的核心:中间件(middleware)到底是什么,它凭什么能把"能力"从"流程"里解耦出来;以及当 Agent 获得一个文件系统时,安全边界又该怎么画。


一、先搞清楚:Agent 到底是个什么东西

要给"中间件"下定义,得先说清它挂在什么东西上。

大模型本身是一个无状态的函数:给它一段文本,它返回一段文本。它不能查数据库、不能写文件、不能打电话。所谓 Agent(智能体),本质上就是给这个函数配上三样东西:

  1. 工具(Tools):一批可以被调用的函数,比如"查询天气""写入文件"。模型不会真的执行它们,它只会输出一段结构化的"我想调用 write_file,参数是......",由外层程序去执行,再把执行结果作为新消息喂回去。
  2. 循环(Loop):因为一次调用往往不够。模型可能先要求查资料,拿到结果后再要求写文件,写完后才给出最终回答。所以需要反复"模型 → 工具 → 模型",直到模型不再要求调用工具为止。
  3. 状态(State):这轮对话到目前为止的所有消息,以及你自己额外加的字段(计数器、用户 ID、任务清单......)。

把这三样串起来,就是最经典的 ReAct 模式。createAgent(来自 langchain 包)返回的就是这样一个已经组装好的 ReAct Agent,你只管调用它的 invoke()。

那么问题来了:如果我想要"每次调用模型前打印一条日志"、"限制对话轮数"、"发现敏感词就直接掐断",该怎么办?

最笨的办法是去改主循环的代码。但主循环是框架写死的,你改不了;就算能改,每加一个需求就动一次核心逻辑,代码很快就会变成一团乱麻。

中间件就是为这个场景诞生的:它让你在不碰主循环的前提下,在固定的"时点"插入自己的逻辑。


二、把 Agent 的一次运行想成一次"心跳"

LangChain 的中间件模型非常直观:它把 Agent 从开始到结束的整个过程,切成若干固定的时间点(术语叫 hook,钩子),然后允许你在这些时间点上挂函数。

节点式钩子(node-style hooks)------在特定时刻执行一次:

钩子 触发时机
beforeAgent Agent 启动前,每次 invoke 只跑一次
beforeModel 每次调用模型之前
afterModel 每次模型返回之后
afterAgent Agent 全部结束时,每次 invoke 只跑一次

包裹式钩子(wrap-style hooks)------像洋葱一样把某次调用"包"起来:

钩子 作用
wrapModelCall 包住每一次模型调用,你可以决定调不调、调几次、怎么改参数
wrapToolCall 包住每一次工具调用,同理

两者最本质的区别在于控制力 :节点式钩子只能在旁边"看一眼、改一下状态";包裹式钩子手里握着 handler(真正干活的那个函数),它可以选择不调用(短路)、调用一次(正常)、或者调用多次(重试)。

理解了"时点"这个概念,middleware-test.mjs 里那三个中间件就一点就通了。


三、亲手写三个中间件

3.1 日志中间件:用 stateSchema 记一笔账

js 复制代码
const loggingMiddleware = createMiddleware({
  name: "LoggingMiddleware",
  stateSchema: z.object({
    modelCallCount: z.number().default(0),
  }),
  beforeAgent: (state) => {
    console.log("\n [Logging] agent 开始, 消息数:", state.messages.length);
  },
  beforeModel: (state) => {
    console.log(`模型即将调用, 已调用${state.modelCallCount}次`);
  },
  afterModel: (state) => {
    return { modelCallCount: state.modelCallCount + 1 };
  },
  afterAgent: (state) => {
    console.log(`agent 结束, 已调用${state.modelCallCount}次`);
  },
});

这里有个关键设计:中间件可以扩展 Agent 的状态。

stateSchema 用 zod 声明了一个新字段 modelCallCount,默认值为 0。声明之后,这个字段就变成了 Agent 全局状态的一部分------所有钩子都能读到它,invoke() 的返回值里也能拿到它。这就是 middleware-test.mjs 结尾能写出 const { messages, modelCallCount } = await agent.invoke(...) 的原因。

注意钩子的返回值语义:返回一个普通对象,框架就把它按"状态更新"处理,合并进 Agent 状态 。所以 afterModel 里 return { modelCallCount: state.modelCallCount + 1 } 就完成了记账。没有返回值(或返回 undefined)则代表"我不改任何东西"。

这也解释了为什么 beforeModel 里打出来的计数总是"上一轮"的数字:计数器是在模型返回之后才加一的。

3.2 上下文注入:wrapModelCall 的洋葱结构

js 复制代码
const addContextMiddleware = createMiddleware({
  name: "AddContextMiddleware",
  wrapModelCall: async (request, handler) => {
    return handler({
      ...request,
      systemMessage: request.systemMessage.concat("\n\n 请用一句话简洁回答"),
    });
  },
});

这个中间件演示了包裹式钩子的典型用法:拦截请求 → 改造请求 → 交给下一环。

request 里装着这次模型调用的全部信息(消息列表、系统提示、工具定义等),handler 则是"继续往下走"的开关。这里先把系统提示拼上一句额外要求,再把改造后的 request 交给 handler------于是模型实际上收到了一条被"偷偷改了"的系统提示。

一个容易忽略的细节:request.systemMessage 不是字符串,而是一个支持 .concat() 的消息对象。这也是为什么不能简单写 +=。

3.3 短路拦截:让 Agent 提前"认输"

js 复制代码
const blockedContentMiddleware = createMiddleware({
  name: "BlockedContentMiddleware",
  beforeModel: {
    canJumpTo: ["end"],
    hook: (state) => {
      const last = state.messages.at(-1);
      const text = typeof last?.content === "string" ? last.content : String(last?.content ?? "");
      if (text.includes("BLOCKED")) {
        return {
          messages: [new AIMessage("该请求已被 middleware 拦截, 无法处理")],
          jumpTo: "end",
        };
      }
    },
  },
});

这是最有工程价值的一个:在模型被调用之前就把请求拦下来。

它的写法和前面不一样------beforeModel 不再是一个函数,而是一个对象 { canJumpTo, hook }。这是框架的硬性要求:只要你想在钩子里执行"跳转",就必须先声明你能跳到哪些节点 ,canJumpTo: ["end"] 表示允许跳到 end 节点(也就是立刻结束整轮运行)。没声明就跳,框架会直接报错。

拦截逻辑本身很简单:取最后一条消息,如果里面含有 BLOCKED,就构造一条 AI 消息说明请求已被拦截,然后 jumpTo: "end",整轮 Agent 运行就此终止。模型一次都没被调用过------这就是为什么它叫"短路":省掉了 token,也避免了危险请求进入模型。

3.4 三种"返回"的语义,别搞混

把上面三个中间件放在一起看,会发现钩子有四种"说话方式":

你返回什么 框架怎么理解
什么都不返回 / undefined 我不改任何东西,继续
一个普通对象 { modelCallCount: 1 } 这是状态更新,合并进 Agent 状态
{ messages: [...], jumpTo: "end" } 这是控制指令:改状态 + 跳转并结束
一个 Command 对象 也是状态与流转指令,常用于包裹式钩子中

第四种在 middleware-test2.mjs 里出现,后面会展开。这里只要记住一条:普通对象 = 数据,jumpTo / Command = 控制流。


四、给 Agent 一双手:文件系统中间件

4.1 为什么 Agent 需要一个文件系统

如果 Agent 只能靠"消息列表"记东西,会遇到两个绕不开的墙:

第一,上下文窗口是有限的。 模型一次能读进去的文字有上限。一个长任务动辄产生几万字的中间结果,全塞进消息列表,轻则变贵,重则直接超限。

第二,对话是易失的。 这一轮结束后,消息列表就没了。Agent 无法"记住"上一轮做了什么,也无法把成果沉淀下来。

文件系统正好治这两种病:中间结果写进文件、上下文里只留一句"结果已存到 /notes.md" ;成果落盘后跨轮次持久存在。这也是 Deep Agents 这类框架把"文件系统访问"列为四大核心能力之一的原因------另外三项是任务规划、子智能体、详细提示词。

4.2 后端(Backend):文件系统可以是"虚拟"的

deepagents 把文件系统抽象成了一个可插拔的后端(backend) ,也就是"存储驱动"。同一套 ls / read_file 工具,底层可以接到完全不同的地方:

后端 文件存在哪 典型场景
StateBackend 内存里的 Agent 状态 默认选项,临时草稿、测试
FilesystemBackend 真实的磁盘目录 本地项目、需要落盘的场景
PersistentBackend / Store 后端 键值存储 跨会话的长期记忆
CompositeBackend 按路径路由到不同后端 混合策略,比如 /memories/ 走持久层

本项目用的是 FilesystemBackend,所以 Agent 写下的文件会真实出现在 workspace/ 目录里------这一点你打开 src/deepagents/workspace/todo.md 就能验证。

同时它开启了 virtualMode: true。这个开关的作用是路径沙箱 :Agent 眼里的根目录 / 其实映射到 rootDir 指向的物理目录,任何试图用 ../ 往上翻越、或者用符号链接绕出去的操作都会被挡下。如果关掉它并把 rootDir 设成系统根目录,Agent 理论上可以读写整台机器上的任何文件------这是绝对不能接受的。

4.3 Agent 拿到的是六个工具

挂上文件系统中间件后,模型的可调用工具就多了一组:

工具 用途
ls 列出目录内容,带大小、修改时间等元信息
read_file 读文件内容(带行号,支持 offset/limit 读大文件)
write_file 创建新文件
edit_file 对文件做"精确字符串替换",相当于外科手术式的修改
glob 按模式找文件,例如 **/*.md
grep 在文件内容里搜关键词,支持只列文件名 / 列内容 / 只数数量

用 edit_file 而不是"读出来、改一改、整个写回去",是个很聪明的设计:Agent 只需要说明"把 A 替换成 B",不用重复输出整份文件。这既省 token,也降低了把文件写坏的风险。

4.4 权限模型:声明式、按顺序、先匹配先赢

光有工具还不够------还得能限制 Agent 能碰哪些文件。这就是 permissions 的用武之地,本项目里的三条规则非常典型:

js 复制代码
const permissions = [
  { operations: ["read"],  paths: ["/secret.txt"], mode: "deny"  },
  { operations: ["write"], paths: ["/todo.md"],    mode: "allow" },
  { operations: ["write"], paths: ["/**"],         mode: "deny"  },
];

每条规则由三部分组成:

  • operations:管的是读(read)还是写(write);
  • paths:一组 glob 路径模式;
  • mode:allow 放行,deny 拒绝。

规则的核心是求值顺序 :从上往下逐条比对,第一条匹配上的规则就直接决定结果 (first-match-wins);如果一条都没匹配上,默认允许。

用这个规则再读一遍上面的三条:

  1. 读 /secret.txt → 命中第 1 条 → 拒绝。机密文件不可读。
  2. 写 /todo.md → 第 1 条只管读,不匹配;第 2 条匹配 → 放行。
  3. 写 /其他任何文件 → 第 1、2 条都不匹配;第 3 条 /** 匹配 → 拒绝。

最终效果是:Agent 只能写一个文件,其他一律不许写,机密文件不许读。 三条声明就画出了一个最小权限边界------这正是声明式权限相比"在代码里到处写 if 判断"的优势:规则集中、一眼可审、容易验证。

顺带一提,deny 的写法也体现在脚本开头:

js 复制代码
fs.rmSync(workspaceDir, { recursive: true, force: true });
fs.mkdirSync(workspaceDir);
fs.writeFileSync(path.join(workspaceDir, "secret.txt"), "机密:不得读取", "utf8");

每次运行前把工作区整个删掉重建 ,是为了保证每次演示的初始状态完全一致------否则上一轮 Agent 写下的 todo.md 会残留下来,污染结果。这在测试里是标准做法,但在生产环境当然是绝对的禁忌。


五、中间件还能"带货":自带工具与拦截工具调用

middleware-test2.mjs 展示了中间件的另外两种能力。

5.1 中间件可以自带工具

js 复制代码
const extendedToolMiddleware = createMiddleware({
  name: "ExtendedToolMiddleware",
  stateSchema: z.object({ toolInvocationCount: z.number().default(0) }),
  tools: [getCurrentTime],
  // ...
});

注意这里的 createAgent 调用里,顶层 tools: [] 是空的------工具是从中间件里带进来的。

这不是语法糖,而是一种很实用的封装方式:一个能力(比如"文件系统""时间查询""数据库访问")往往同时需要"工具"和"配套的钩子逻辑"。把两者打进同一个中间件,就等于把能力做成了一个可插拔的插件 。想加就加进 middleware 数组,想撤就删掉,完全不用动 Agent 的主体代码。

5.2 wrapToolCall:改写工具结果 + 更新状态

js 复制代码
wrapToolCall: async (request, handler) => {
  const result = await handler(request);
  if (!ToolMessage.isInstance(result)) return result;

  const wrapped = new ToolMessage({
    content: `${result.content}\n[wrapToolCall] 已经由ExtendedToolMiddleware包裹`,
    tool_call_id: result.tool_call_id,
    name: result.name,
  });

  return new Command({
    update: {
      toolInvocationCount: request.state.toolInvocationCount + 1,
      messages: [wrapped],
    },
  });
},

逐句拆解:

  1. handler(request) ------ 真正执行工具,拿到结果。
  2. ToolMessage.isInstance(result) ------ 类型守卫。工具结果不一定是 ToolMessage,是别的类型就原样返回,不瞎改。
  3. 构造一条新的 ToolMessage,在原有内容后面追加一句"已被中间件包裹"。关键是要把 tool_call_id 和 name 一起带上,否则这条消息就和它对应的那次工具调用对不上号,Agent 的消息结构会错乱。
  4. 返回 Command。Command 是 LangGraph 里的"带指令的状态更新包":update 里的字段会按各自的 reducer 合并进状态------messages 是追加 ,toolInvocationCount 是覆盖。

这里有个必须留意的坑:一旦你返回 Command,就等于接管了这次调用的输出 。如果你忘了把 wrapped 放进 update.messages,工具的执行结果就凭空消失了,模型会看到"调用了一个工具但什么都没返回"。

最后,afterAgent 钩子在结束时把 toolInvocationCount 打出来,完成一次完整的能力闭环:注册工具 → 拦截调用 → 改写结果 → 统计次数 → 输出报告。


六、工程上容易踩的几个坑

1. 权限规则的顺序不能乱。 因为规则是"先匹配先赢",把 { write, /** , deny } 写在 { write, /todo.md, allow } 前面,/todo.md 就会被永久封死------而且不报错,只是写不进去。越具体的规则越要往前放,这是声明式权限最常见的翻车点。

2. 想跳转,先声明 canJumpTo。 短路拦截必须写成 { canJumpTo: ["end"], hook: ... } 的形式。只返回 jumpTo 而不声明跳转目标,框架会拒绝这个跳转。

3. virtualMode 不要关。 它是路径沙箱的开关。关掉并配上系统根目录,就等于把整台机器交给了模型。

4. 循环要有刹车的。 filesystem-agent.mjs 在 invoke 的第二个参数里传了 { recursionLimit: 20 }。模型可能反复调用工具停不下来,这个上限就是最后一道保险------超过就抛错,而不是无限烧钱。

5. 批量操作要看"完整返回体",别只看成功条数。 这条虽然本项目的脚本没踩,但值得记着:文件操作失败往往不会抛异常,而是默默返回"成功 0 条"。此时真正的原因通常藏在返回对象的 reason 之类字段里,只看计数永远查不出来。


七、总结

把整个项目串起来看,它其实回答了三个层次的问题:

第一个层次是"扩展"。 createMiddleware 把 Agent 的运行过程切成 beforeAgent、beforeModel、afterModel、afterAgent 这些固定时点,再加上 wrapModelCall、wrapToolCall 两个包裹式钩子。于是"加日志""加计数器""改提示词""加拦截"都不再需要动主循环------流程归框架,能力归中间件。

第二个层次是"落地"。 中间件不只写逻辑,还能通过 stateSchema 扩展状态、通过 tools 携带工具。这使得一个中间件可以是完整的、自洽的能力单元;deepagents 提供的 createFilesystemMiddleware 就是最好的例子:一套中间件,一口气给了 Agent 六个文件工具、一套可插拔的后端、以及一层声明式权限。

第三个层次是"边界"。 Agent 拿到了动文件的手,就必须同时给它画好牢笼。FilesystemBackend 的 virtualMode 把根目录锁在一个沙箱里,permissions 用"先匹配先赢"的规则精确控制每个路径的读写权限。能力越大,边界越要显式写出来,而不是靠默认值兜着。

最后回到那个最朴素的观察:这三个脚本里没有主循环。它们只是在描述"我想要什么",而"怎么做到"交给了框架。 这正是现代 Agent 框架试图解决的问题------把易变的业务逻辑和稳定的执行引擎彻底分开,让你把注意力放在真正重要的地方:Agent 应该有什么能力,以及这些能力不该被用在什么地方。

相关推荐
知几蜗牛1 小时前
GPT-6.1 Sol迁移指南:从token单价转向每任务成本门禁
人工智能
知几蜗牛1 小时前
WSL Containers GA:本地AI容器的生命周期、网络与治理验收
人工智能
FPGA信号处理1 小时前
《随机信号分析与处理》第1章 随机变量基础:习题解答
人工智能·机器学习·概率论
爱喝雪碧的可乐1 小时前
CSDN|爆火哑巴AI Jev模型深度实战|技术博客
人工智能·大模型·jev
知几蜗牛1 小时前
从ProvenanceGuard理解多源RAG的claim-to-source验证
人工智能
RoboWizard1 小时前
2026年企业级NVMe SSD推荐哪些品牌?
大数据·人工智能
Gu_WenYun1 小时前
半导体产业供需再平衡,新一轮扩产周期下如何用基金布局半导体?
人工智能·金融·业界资讯
段一凡-华北理工大学1 小时前
大模型与智能体在工业的应用~系列文章10:可靠性篇:大模型的“幻觉“与工业安全,如何让 AI 可信
大数据·人工智能·安全·大模型幻觉·工业智能化·高炉炼铁智能化·ai可信度
A7bert7771 小时前
【SAM3部署至AGX Orin】环境配置→模型部署→问题记录
c++·人工智能·深度学习·ubuntu