Transcript
00:00:00大家好。我叫 Jonathan Clem,也可以叫我 Jay Clem,我是
00:00:11Notion 的软件工程师,负责开发者平台,不过我主要专注于一款新产品
00:00:17大家可能听说过,叫 Notion Workers。在今天的研讨会中,我将
00:00:23简要介绍 Notion Workers 是什么,以及我们为什么基于 Vercel
00:00:28Sandbox 来构建它。然后我会带大家看一个 Workers 的迷你简化版,
00:00:36这样大家就能了解如何利用 Vercel Sandbox 来构建此类产品。
00:00:43如果你还不了解 Workers 是什么,它是一个 SDK 和运行时,让你能够编写
00:00:50自定义代码来扩展 Notion 的功能。比如将第三方数据同步到 Notion,
00:00:57或者为你的 AI Agent 编写自定义工具调用。我们甚至看到有人做了一些好玩又疯狂的事,比如用
00:01:04Notion Workers 订购日常采买或控制智能家居。我们也看到了很多复杂的实际工作流,
00:01:11特别是在 IT 和安全领域。Workers 的妙处在于,你无需
00:01:17管理任何基础设施。只需编写代码,或者让编程 Agent 帮你写,Notion
00:01:23会负责确保它始终在线、可用且正常运行。这对 Notion 用户,
00:01:30尤其是开发者来说是巨大的好处。他们不再需要等待 Notion 为他们构建官方
00:01:36集成。任何他们希望 Notion 能做但目前做不到的事,都可以自己直接动手写出来。
00:01:42因此,当我们刚开始构建 Notion Workers 时,我首要关心的就是
00:01:53安全性。在构建运行不可信用户代码的平台方面,我有一些经验。
00:01:59例如,我曾在 GitHub Actions 项目上工作了很长时间。我最担心的就是
00:02:04为这类产品搭建基础设施的难度,尤其是安全防护。这里有非常多的
00:02:09细节需要操心。例如,你必须确保用户编写的代码
00:02:14绝对无法触及 Notion 数据库,或者无法触及他们平时无权访问的
00:02:20Notion 服务。你还要确保用户之间不会互相影响。你不希望某个用户的
00:02:26代码能够访问另一个用户的代码,或者显而易见地访问到他们的密钥或任何类似
00:02:31东西。这不仅关乎安全性,还关乎公平性和资源共享。如果有一个用户
00:02:37正在执行消耗大量 CPU 和内存的操作,你要确保这不会
00:02:42不公平地影响到同时在尝试操作的其他用户。我想花一点时间
00:02:48稍微展开讲讲,分享一个小故事。比如资源共享,真的是一件非常
00:02:54棘手的事。这可能会占用我一点时间,但我很喜欢这个故事。当我们当时在构建
00:02:58GitHub Actions 时——这也是我们在考虑 Workers 时思考过的——只要你拥有一个
00:03:02支持执行任意代码的平台,人们立刻就会尝试用它来进行加密货币挖矿。
00:03:07几周前我和某人聊天,他们说:“好吧,
00:03:10你怎么检测这个呢?看 CPU 是不是拉满到 100% 不就行了吗?”
00:03:14其实没那么简单,因为一旦你的平台取得成功,挖矿者立刻就会
00:03:20开始互相分享脚本,这些脚本会修改他们
00:03:26运行 CPU 指令的方式,看起来完全像是无害的行为,但他们却在
00:03:31尽可能榨干极限资源,同时又不会在你的系统中被标记警告。
00:03:37所以这极其困难,需要多年的积累以及多层的安全与
00:03:42可观测性防护才能真正做好。另一件你需要担心的事是,当你拥有一个
00:03:48代码执行平台时,你得防范有人利用它发起拒绝服务(DoS)攻击,
00:03:54或者将其用作僵尸网络控制节点。而我们根本不希望在刚推出
00:04:00Notion Workers 时就去操心这堆麻烦事。我们想专注于自己热爱的事,也就是
00:04:07交付一款让用户爱不释手的产品。这就是为什么我们决定基于 Vercel Sandbox 来构建。
00:04:14我们一开始就能看出,Vercel Sandbox 是非常稳固的基础设施,开箱即用
00:04:18地帮我们解决了大部分这类难题。接下来我给大家做一个超快速的 Demo,
00:04:25展示一下什么是 Notion Workers。我这里有一个自定义 Agent,这个 Agent 的任务是告诉我
00:04:33这场研讨会是否“被诅咒了”。我写了一个叫水星逆行(Mercury Retrograde)的 Worker。不知道大家
00:04:40是否了解,当水星运行到特定位置形成逆行时,通常被认为
00:04:47是个不祥之兆。所以这个 Worker 有一个专门的自定义工具调用,调用了我找到的一个 API,它啥也不干,
00:04:54专门用来检测水星当前是否在逆行。所以我现在要提问我的 Agent:“我的
00:05:01研讨会受到诅咒了吗?”它会思考一会儿,然后只要网络没出问题,
00:05:14我们就会看到它开始发起工具调用。它正在使用我的水星逆行 Worker,调用
00:05:22该 Worker 暴露的唯一工具,来查询水星是否在逆行,随后
00:05:27Agent 会回复我们。我记得我把这个连接到了类似 GPT-54 nano 这样的模型。所以平时要快
00:05:37得多。不幸的是,这次好像是网络延迟。如果现场刚好有人查过水星是否在
00:05:46逆行,我提前透露一下,它确实在逆行。所以大概这就是为什么会卡住吧。我直接
00:05:53跳过这步。我们过一会儿再回头看看最终有没有拿到响应。不过我想我们显然
00:05:59已经知道答案了。好了,现在大家对 Notion Workers 有概念了。接下来我将
00:06:05带大家看一段我写的小脚本,它涵盖了接收用户代码、
00:06:13部署代码、安全执行代码,以及将代码信息告知 Agent 以便其
00:06:20能发起工具调用的许多基础任务。这边运行完了吗?啊,看这里。额,我想可能是……
00:06:28估计是我把水星逆行的 API 给关了,显然 API 没给响应。
00:06:35算了,回头我得去提个工单。酷。这里有一个基础脚本。我不要求
00:06:40大家非要完全跟着一步步写代码什么的。我主要是跳着讲,
00:06:44让大家了解一下在构建此类产品时我们解决过的那些问题。
00:06:49此类产品时需要解决的问题。不过如果你有兴趣,可以在 make notion/vercel ship 2026 workers 这个代码库中
00:06:55里包含了我接下来要运行的所有代码。
00:07:01好的。那我打开另外几张幻灯片。
00:07:08那么我们的目标是什么?我们有一个流式对话 Agent。我们要允许它去调用那些
00:07:16由自定义用户代码定义的工具,而这些代码是我们不信任、甚至不知道具体写了什么的。
00:07:22我们将使用 Vercel Sandbox 和 Vercel 的 Blob 存储服务来构建它。
00:07:26现在我来跑一个快速演示,希望我们这次没真被诅咒。
00:07:31基本上只需给它发一条消息。我先发个“hi”,希望能收到
00:07:36回复。过程会稍微有一点慢,因为大家可以看到后台正在进行一些部署。
00:07:40这次调用了一个工具。它刚刚调用了一个叫 say hello 的工具,并决定向我打招呼。
00:07:47我现在给它发一条普通消息,问它 1 加 1 等于几,这样我们就能看到它
00:07:52直接以流式输出普通回复。
00:07:58如果你用过 Vercel AI SDK,这用的就是其中的 Tool Agent Loop 循环。酷。我们得到了流式
00:08:04响应。你也可以调用复杂得多的 Worker。这里我有另一个展示给大家。
00:08:14在这个例子中,我们告诉它我正位于这栋建筑的地址。
00:08:17我有 90 分钟空闲,想去看一些历史遗迹,且步行时间不想超过 15 分钟。
00:08:24这说明了为什么在某些场景下它们比 MCP 服务器更有优势。使用 MCP
00:08:29服务器时,你可以调用一堆零散的工具。而如果你想做一些
00:08:33复杂的事情,你必须把每一步都描述给 Agent,它会走一步、消耗一些
00:08:38思考 Token,然后再走下一步。而这里将调用一个包含大型复杂
00:08:45搜索算法的 Worker,该算法利用了大量纽约市地理位置、交通时间和路线规划 API。
00:08:52这个工具叫 plan outing(出行规划)。
00:08:56然后该 Worker 会返回建议我们可以游览的历史遗迹。
00:09:00看起来它找到了自由树纪念碑(Freedom Tree Marker),那是市政厅公园附近的一处战俘与失踪人员纪念碑。
00:09:07太棒了。那么让我们来看看这是如何运作的。到底什么是 Worker?
00:09:14它就是用户代码,在这个例子中只是定义在文件里。通常情况下,它会由
00:09:20你的用户定义,或者存在 GitHub 某处,并使用 CLI 进行部署。但这里我们在
00:09:26仓库里保存了一些示例。每一个 Worker 都需要暴露工具名称、工具描述(以便
00:09:34Agent 能够路由到正确的工具)、输入 Schema(让 Agent 知道每次调用该工具时需要
00:09:38提供哪些输入),以及一个执行函数,用以说明当 Agent 调用此工具时
00:09:44实际运行的代码是什么。我们来看个例子。这就是大家刚才看到的
00:09:51问候工具(Greeter)。非常简单。这个 Worker 就是 index.ts 文件里的一个 JavaScript 模块。它导出单个
00:09:59名为 say hello 的 Worker。稍后大家会了解这个构建流程是如何运行的。但在本例中,
00:10:04我们所有模块导出的键名都映射到了工具名称上。所以这个工具将被命名为
00:10:09say hello。它包含简短的描述和输入 Schema。这里我只是用 Zod 来定义
00:10:16Schema,然后将其转换为 JSON Schema。这就是这些 Agent 所期望的格式。
00:10:22接着是一个简单的执行函数。不过你也可以编写复杂得多的函数。所以如果我
00:10:27打开 plan outing 的工作流,你可以看到它有一段长得多的描述,用来告知
00:10:34Agent 关于这个工具的所有信息,包括何时调用、能做什么。而它的脚本要长得多、
00:10:39复杂得多。我就不一一细讲了。我自己也只读过其中很小一部分,
00:10:45但它运行得非常好。这就是我们当下面临的世界。所以这些 Worker 会被
00:10:54构建并部署到 Vercel Blob 存储中。然后我们将通过某种方式获取这些 Worker 的
00:10:59内容,并将它们暴露给 Agent。接着,这些工具就会在
00:11:03沙盒中安全地被执行。对于构建部分,我们稍微分步来看。在 Notion Workers 中,我们拥有
00:11:09基于云端的部署和构建流程。但在本例中,我直接在磁盘上预构建了这些 Worker。因此每个
00:11:15Worker 都有一个包含所有已编译 TypeScript 代码和依赖项等的 tar 压缩包。
00:11:22这部分不算特别有趣,所以我就直接略过了。
00:11:26那么今天核心的问题是:我们如何从一份从未见过、且不信任的用户代码,
00:11:34转化为可以暴露给 Agent 并在沙盒中安全运行的工具?这里分为两个部分。
00:11:39第一部分是,一旦我们将用户代码存入 Blob 存储,
00:11:43我们该如何得知这段代码里写了什么?因为在 Agent 每次
00:11:48调用工具前,我们必须告诉它工具的名称是什么、输入 Schema 是什么,以及工具的描述
00:11:53是什么。而第二个问题是,当 Agent 决定调用该工具时,
00:11:58我们实际上该如何安全地执行它?我在 Notion Workers SDK
00:12:04的实现以及我们在这里的做法中,最喜欢的一点就是代码能够“自我描述”。我们在
00:12:11设计 Notion Workers 时不希望看到的,就是用户在写完定义工具的 TypeScript
00:12:16代码后,还得去创建一个静态 Manifest 清单文件来描述
00:12:21他们的 Worker,基本上就是重写一遍同样的内容。我们也不想要笨拙的本地构建流程,
00:12:27让他们必须运行脚本、进行分析、在本地编译然后再部署。我们希望从
00:12:34最佳的开发者体验出发:你只需要编写工具,而如何从中提取信息的硬骨头
00:12:40由我们来搞定。
00:12:46这是一张非常简单的图表,但在深入代码之前,我先给大家讲讲
00:12:52我们将如何解决这个问题。我们把用户编译好的代码放在 Blob 存储上。
00:12:59我们将利用该代码创建一个沙盒。这样当我们在沙盒中运行命令时,用户的
00:13:04代码就会存在于工作区根目录中。我们将导入用户编写的 index.js 文件。
00:13:11这就能向我们提供所有导出项的名称、描述以及输入 Schema。
00:13:19接下来的步骤可能会有些奇特,但效果非常好。我们对该
00:13:23模块调用 json.stringify,然后将其打印输出到标准输出(stdout)。这一切都在
00:13:29沙盒中发生,随后我们的部署脚本会接收该标准输出并进行解析,表示:“好了,
00:13:34我现在知道了这个 Worker 上所有工具的名称、输入和描述了。”在这个演示中,
00:13:40我们会将其直接传递给 Agent。但以 Notion Workers 为例,
00:13:44这是我们部署流水线的一部分。因此我们会获取所有这些信息并存入数据库,这样
00:13:49每次你运行自定义 Agent 时,我们就会直接从数据库中拉取这些工具描述
00:13:53信息。到目前为止,这个工作原理大家听明白了吗?好的。那我们来看看部署
00:14:02脚本。我得切回我的第一个提交记录。这个脚本集成了我们所有的部署以及
00:14:12调用 Agent 的逻辑。非常简单。在此例中,我们只需遍历所有的目录
00:14:18和 Worker。每个子目录都包含大家刚才看到的 Worker 代码。
00:14:23我们的任务是弄清楚如何从每个 Worker 中提取所有工具信息并填充
00:14:29这个 tools 对象。它会被传递给 Tool Loop Agent。这只是 Vercel 开发的
00:14:35AI SDK 的一部分。然后我们将用户的消息发送给该 Agent,以流式获取输出,并在脚本中
00:14:42打印到标准输出。所以我们需要解决的第一件事是:如何上传
00:14:48源码?这部分非常简单且快速。与其现场写代码,我打算直接在
00:14:53各个代码块之间跳转,这样大家就不用看着我敲键盘了。我保证我是会写
00:14:59代码的。只是觉得大家肯定不想看我现场打字。稍微往前跳一点,我们有这个名为
00:15:06upload source 的函数,稍后我会详细讲。但大家可以看到我已经导入了 Vercel Blob SDK。这相当
00:15:13简单。如果看一下这个 upload source 函数,我们基本上只是在调用这个
00:15:20put 函数。指定我们要将用户的代码包存储在 worker name/bundle.tar.gzip 路径下。
00:15:29我们从磁盘以流的形式读取文件,并将其存入 Blob 存储中。一旦完成,
00:15:35我们就能根据那个 Blob 创建沙盒。这就解决了上传部分的问题。
00:15:43下一步我们需要做的,是拿到那个打好包的源码,并据此创建
00:15:49一个沙盒。为此,我导入了 Vercel Sandbox SDK。下面还有另一个辅助
00:15:56函数,叫 create sandbox。我跳过其中的几个细节。但核心逻辑
00:16:03是,当 Blob 存储中有该对象后,我们将使用预签名 URL,并将
00:16:10其传递给沙盒服务。这样沙盒服务就能(我认为这里设置了 10 分钟
00:16:16过期时间)从 Blob 存储中拉取该文件包并用它来填充沙盒。这是
00:16:23我很喜欢 Vercel Sandbox 的功能之一,就是它支持这样的 tar 压缩包。构建
00:16:28用户代码的 tar 包非常简单,当你告诉沙盒服务要从 tar 包创建沙盒时,
00:16:35它会从存储服务或你提供的任何 URL 提取该包,并自动将其解压到
00:16:40工作区根目录下。这样所有文件就准备就绪供你使用了。这只需通过调用
00:16:46sandbox.create 即可完成。我们还没讲到最复杂的部分,不过很快就到了。
00:16:52另外一点是,为了简便起见,在这个例子里,我每次运行代码时
00:16:57都会创建全新的沙盒。而在实际生产中,你会希望使用快照(Snapshot)机制,这样就不用在每次
00:17:02工具被调用时,都去重新部署沙盒,或者重新从 Vercel Blob
00:17:09存储或你使用的其他云端 Blob 存储拉取文件流。平台上基本上内置了
00:17:15缓存机制。为了简单起见,我在这里直接跳过了。
00:17:19这就是沙盒的创建过程,而接下来才是真正有意思的地方。
00:17:23下一步我们需要从刚创建的沙盒中,提取出关于该 Worker
00:17:31中所定义的工具的信息。
00:17:36我在这里先稍微停顿一下,穿插几个小提示。通常来说,如果你在构建类似这样的
00:17:40生产级服务,务必要确保尽最大努力去停止并清理沙盒。在这个例子中,
00:17:44大家会看到 extract tools 的作用,但在使用完毕后,我们要确保显式删除了沙盒。
00:17:51在实际业务中,你还需要确保加入了类似异步清理队列的
00:17:56任务机制。你不会希望这些沙盒一直无限期挂在后台。
00:18:00现在我们来看看 extract tools 函数做了什么。我们拥有了沙盒,而我非常喜欢
00:18:06这些例子,因为它的精简程度看起来甚至有点滑稽,但效果却极其出色。我们在沙盒里
00:18:15直接使用 Node 二进制文件运行 node 命令,在脚本中导入用户
00:18:23编写的模块(即 index.js)。我们将其转为字符串(stringify),然后调用
00:18:30console.log。需要牢记的是,沙盒不同于 Web 服务器,你无法直接
00:18:36发送请求并获取回复。所有的输入和输出都是通过执行命令完成的,然后
00:18:42你可以让它将响应发送到某个轮询服务,或者像这里一样,直接让它
00:18:48输出日志到标准输出(stdout)。这里有几个实用建议:通常你不能直接 console.log 并信任
00:18:52输出到标准输出。这里有几个小建议。通常你不会想直接 console.log 并完全信任
00:19:00来自沙箱的所有输出。因为用户可能安装了其他包,或者
00:19:07运行着其他代码,这些代码可能会同时向标准输出流打印内容。
00:19:12就像我们在这里会使用的类 XML 标签。我只是在这个示例中没加而已。
00:19:18同时,你也要确保限制读取日志的大小。在这个示例中,我只是
00:19:23把所有输出收集到一个流中。但在生产级应用中绝不能这么做,因为日志可能会
00:19:29达到 1 GB,进而导致服务器内存溢出(OOM)。
00:19:34最好不要这样做,因为这可能产生数 GB 的流数据,从而导致服务器内存溢出(OOM)。
00:19:39Notion Workers 中就是这么做的:持续消费该数据流,直到看到我们关心的输出起始 Token
00:19:45为止。然后我们开始缓冲这部分描述内容,也就是我们特意打印出来的东西。
00:19:53一旦匹配到结束标签,就立刻停止处理输出流。这样一来,即使沙盒内部
00:19:58发生异常并打印出海量数据,
00:20:04也不会全堆积在内存里。我们可以直接丢弃无关数据,静等所需的内容出现。
00:20:08因此,一旦命令运行,我们就等待其结束并获取其标准输出。
00:20:14如果你以前用过 Zod,这看起来应该非常熟悉。我们只需将该字符串解析为 JSON。
00:20:22如果你以前用过 Zod 之类的库,这里看起来会很熟悉。我们只是将该字符串解析为 JSON。
00:20:27然后我们这里有一个 Zod 类型,
00:20:31用来验证它的结构是否符合我们的预期。
00:20:34切记绝对不能盲目信任这些数据,因为这是未经信任的用户代码。你根本无法预知
00:20:39用户会打出什么日志。所以你必须确保
00:20:43对其大小和大致结构进行校验。因此在本例中,我们所期望的是
00:20:50一个键(字符串类型)对应各个工具名称的 Record 对象。
00:20:57对应的 Object 包含 description(描述)以及 input schema(输入模式),也就是那个
00:21:02JSON schema。你会发现 execute 函数并不在这里。它只是
00:21:06没有被返回,这很好,因为 JSON.stringify 会自动跳过不可序列化的
00:21:11内容。所以它就被直接忽略了。我们只需从中提取出 description 和 input schema。
00:21:18回到上面,我们得到了 worker tools,也就是
00:21:23包含该 Worker 中导出的每个工具的 Record。
00:21:28接下来我们要做的,就是把这些数据转换成 AI SDK
00:21:36所期望的结构。并且我们需要为每个工具挂载一个执行函数(execution function)。
00:21:42显然,刚才仅把日志打印到标准输出时,我们并没有拿到执行函数。
00:21:46所以现在的关键是,既然有了工具的定义描述,我们要如何提供
00:21:51一个函数,让 SDK 每次想要调用这个 Worker 或其暴露的工具时
00:21:58能够直接调用?为此我写了一个名为 executeTool 的封装函数。让我们来看看
00:22:04它的实现。代码看起来应该挺眼熟的。它调用了同一个 createSandbox 函数,也就是
00:22:10创建一个全新的沙盒。即使你使用了缓存,Vercel Sandbox 也有一个叫作
00:22:16持久化(Persistence)的特性:每次沙盒挂起或停止时,它会缓存磁盘上的所有状态。
00:22:23但对于这种功能,你其实并不希望这样。你希望快照只保留包含用户代码的初始状态。
00:22:28但在此之后,你要确保每次
00:22:33执行工具时,都使用一个完全干净的新实例。这样一来,即使某次工具运行
00:22:38出了差错,也不会污染后续运行的工具环境。
00:22:43于是我们创建一个新沙盒,并在上面运行另一个 Node 脚本。这看起来也很眼熟,
00:22:49但稍微有一点不同。我们导入了用户编写的模块,从中提取出
00:22:56对应的工具——即创建 executeTool 封装时传入的工具名称。
00:23:03我们调用该工具上的 execute 函数,并将模型传入的输入参数
00:23:09传递给该 execute 函数。在这里我将类型设为了 unknown。不过这样做很安全,因为
00:23:19我们提供了……我是不是漏写了?在这个示例里我好像跳过了这一步。但正常情况下,
00:23:27我们……噢不,我写了。让我跳回上面这里。对,当我们在处理
00:23:34要发送给 Agent 的工具时,我们拿到了 JSON schema,并把它
00:23:39转回了 Zod 类型。因此我们不需要自己做任何解析。AI SDK 和工具循环的 Agent 代码
00:23:46会在每次工具被调用时,自动帮我们校验来自 Agent 的输入。
00:23:52所以我们可以基本信任这个值符合预期。接着我们将其传入
00:23:56这里的 execute 函数。当这个异步函数执行完毕后,我们对结果进行
00:24:03字符串化(stringify),打印到标准输出,然后等待——稍后我会细说
00:24:10这部分——等待命令执行完毕。同样,
00:24:14用完后直接销毁沙盒。随后我们解析 JSON,也就是用户编写的
00:24:21执行函数的返回值,并将其发回给工具循环
00:24:26Agent。所以这里的基本流程是:工具循环 Agent 表示“我想调用 plan_outing 工具”
00:24:35(就是刚才看到的使用交通 API 的那个)。这最终会带上 Agent 确定的输入参数
00:24:40来调用此处的函数。我们利用之前上传的用户编译代码 Blob 创建一个沙盒,
00:24:48然后在该沙盒上运行命令,调用用户的
00:24:55execute 函数。等待该函数返回后,我们将返回值打印到
00:25:00标准输出,解析它,然后发送回 Agent,Agent 将继续工具
00:25:05循环,要么发起另一次工具调用,要么直接回复用户。这里有几个实用小建议。同样的
00:25:13输出校验规则也适用于生产系统。再次强调,你要确保用户不能
00:25:19返回一个需要耗费 2GB 内存去解析的大对象。另外,你可能
00:25:28还需要提供一个 SDK。虽然我们在示例里没做,但在 Notion Workers SDK 中,
00:25:35类型都是预先设定好的,要求执行函数的返回值必须可被 JSON 序列化。
00:25:41如果你允许该函数返回任意内容,用户很容易踩坑:
00:25:47他们最终会返回无法序列化为 JSON 的东西,导致无法通过标准输出传输和解析。
00:25:52他们会感到非常困惑,搞不懂为什么 Worker 运行不了。接下来分享一段
00:25:57我们在开发 Notion Workers 时踩过的坑。你还
00:26:05要时刻注意,确保你在这里生成的 Node 进程无论发生什么都能正常退出。
00:26:11我们曾遇到过一个 Bug:用户代码运行并基本执行完毕了,
00:26:18但出于某种原因,这个 Node 进程就是不退出。它一直卡在那里,直到沙盒
00:26:25生命周期超时(大概是 5 分钟左右)。虽然不算灾难性后果,
00:26:30但也浪费了资源。后来我们发现(或者说想起来,我之前完全忘了
00:26:38Node 运行时有这个特性):用户运行的代码里用 setInterval 或 setTimeout 设置了定时器,
00:26:44特别是定时循环,或者未决(dangling)的 Promise 也会导致同样的问题。
00:26:52如果脚本运行完后还有任何定时器在挂起,Node 进程就
00:26:58永远不会自动返回,直到所有定时器全部执行完毕。如果是
00:27:03定时循环,它就会一直运行下去,直到沙盒强制挂掉。所以你一定要确保
00:27:08当试图执行的代码或函数真正完成时,显式地告知
00:27:14进程退出。这样它就会停止沙盒,进程随后正常返回。
00:27:23以上就是完整的极简工作流。我再重新运行一遍,并开启
00:27:29调试日志,以便大家直观了解执行过程。
00:27:42首先部署 departures worker。稍后我会一步步讲解。我们通过
00:27:48先上传该 Bundle 包来部署 departures worker。接着创建一个沙盒,用于提取
00:27:54该 Worker 的相关信息。我们成功提取出了这些工具。这就是把
00:28:01模块内容打印到标准输出时得到的结果:包含各个工具的键、描述以及输入模式。
00:28:08对简单的 greeter worker 重复同样的操作。然后把所有这些传递给 Agent,
00:28:13以便其发起工具调用并回复用户。主要内容就是这些。这是一个相当简明的
00:28:19示例,展示了如何获取未经信任的用户代码、进行存储,并以安全的方式解析其信息,
00:28:26从而写入持久化存储或直接发送给 Agent,最终让 Agent 安全地
00:28:31执行这些代码。我们大概还有 10 分钟。如果大家有任何问题,无论是关于
00:28:39Notion Workers,还是通用 Vercel 沙盒的使用,或是不可信代码执行,
00:28:46我都乐意解答。谢谢大家。
00:28:56对了,活动结束后,如果大家想交流 Notion 开发者平台的大体情况,
00:29:01你可以找我或者那边的同事 MJ 聊聊。她是 Notion 开发者平台的产品经理。
00:29:08你好,这是一个运维/运营方面的问题:你们是如何做限制的?毕竟用户可以编写任何代码。
00:29:15对。可能会有多个用户编写类似的工具。
00:29:20多个用户做什么?编写类似的工具。例如“规划纽约行程”。
00:29:24是的。可能会有 10 个不同的用户写了 10 段不同的代码,但实现的功能一样。
00:29:29对。你们会做拦截或去重吗,还是完全交给 Agent 判断?
00:29:33不拦截。如果多个用户都想做同样的事,我们由他们去。
00:29:37这一定程度上也是产品问题。比如同一组织内的多个用户,
00:29:42你需要确保……所以这既是运营问题也是产品问题。
00:29:46你要确保提供良好的共享机制。这样用户可以
00:29:50去搜索“是不是已经有实现这个功能的 Worker 了?”,避免重复
00:29:55造轮子。但就整个平台层面而言,我们不会限制——用户部署完全相同的代码概率极低,
00:30:02专门去搞去重并不值得。
00:30:19好的,似乎还有几个提问。我不确定麦克风在谁手里。
00:30:24我刚才没听到。啊,好的,非常抱歉。
00:30:29我以为耳机里收录到声音了。
00:30:33刚才的问题是:如果有许多用户部署了相同的代码,
00:30:40我们是否会采取某种运营手段来处理?答案是不处理,这更偏向产品层面。
00:30:45我们希望避免用户做重复劳动,因此正在为 Notion Workers 打造良好的共享机制。
00:30:50但从平台运营角度来看,
00:30:54即使大家把同一段代码部署 50 次,我们也不在乎。
00:30:56你们是否遇到过 Worker 运行时超时的难题?比如沙盒在代码还在
00:31:04正常执行时就过早关闭了?
00:31:07你刚才提到的超时问题具体是指什么?
00:31:10就是 Vercel 其他产品/工作流(如 Serverless Functions)与沙盒之间
00:31:15是否存在超时冲突?还是说一切运行都很顺畅?
00:31:20目前完全没遇到过问题。这套平台对我们来说相当稳定。
00:31:25不是在硬夸,说实在的,体验确实非常好。
00:31:32我们遇到的更多问题其实来自用户不小心写错代码。因此
00:31:36长远来看,我们的工作重点是消除这些容易踩坑的地方,让平台对开发者和非开发者都更加易用。
00:31:42太棒了,讲得很好。我想了解一下关于
00:31:48序列化用户初始代码的部分。我猜你们是为了确保安全或可运行。
00:31:52我想多问一点:你说对代码进行了字符串化,提取出了 Worker 标识,
00:31:57以获取用户代码所需的输入参数以及工具的描述信息。
00:32:04做这一步是为了让 Agent 能够执行这些代码吗?
00:32:10因为我注意到你们后续已经在运行用户的执行代码/执行函数了。
00:32:15所以我很好奇为什么还要多此一举,去额外获取工具本身的输入参数和描述信息?
00:32:21另外多做这一步,去获取输入参数和工具本身的描述。
00:32:24问得好。这在实际产品中会更清晰,不过这里做了简化。
00:32:29你的核心疑问是:为什么先执行一次用户代码来提取 Worker 信息,
00:32:34等调用工具时又重新执行一次?原因在于:在 Agent 发起工具调用
00:32:39甚至感知到该工具存在之前,我们必须先摸清
00:32:45工具里包含什么,并通过 SDK 暴露给 Agent。以 Notion Workers 为例,
00:32:50当运行 ntn-workers deploy 时,会触发构建流水线,
00:32:55执行构建的沙盒获取 Tar 包并存入存储系统。接着
00:33:02在一个全新的 Worker 中提取工具名称、描述和 Schema,
00:33:09存入 DynamoDB 等数据库。这样后续在工具被真正调用之前,
00:33:14完全不需要启动沙盒。你刚才提到了 Tar 包机制,我最好奇的恰恰是这一点:
00:33:21在分发层面,Tar 包的具体运作流程是怎样的?
00:33:26比如你们现在有开放的生态市场吗?还是说有其他分发机制?
00:33:31你是问实际的 Tar 包里装了什么对吧?对对。
00:33:33从技术实现机制来看,关于进入 Tar 包的内容,
00:33:38目前我们使用 esbuild 来打包。完整的部署流水线是:当你运行
00:33:47ntn-workers deploy 时,会请求一个 API 端点,本地电脑拿到预签名 URL 后打包
00:33:52所有源码并生成 Tar 包。随后在沙盒中使用 esbuild 执行构建,
00:33:58产物被存回 Blob 存储,以便后续能直接从中运行。
00:34:04我们目前还没有真正意义上的 Worker 插件市场,正在优先研发 Notion 工作区内部的
00:34:11共享机制,不过未来肯定有推出某种 Worker 市场的计划。
00:34:18在此期间,你可以直接在 GitHub 上分发,体验同样很好,大家现在就是这么做的:
00:34:23发布一个仓库,别人 Clone 下来运行 ntn-workers deploy 就能自己用了。
00:34:29是的。
00:34:34后排好像有位观众想提问。
00:34:37你好,我想问一下关于计费的问题。
00:34:40关于计费?
00:34:40是的,我刚才搜了一下(当然不用透露内部商业机密),
00:34:44自定义 Agent 似乎基于某种积分制。我猜这和资源消耗直接挂钩?
00:34:51对,没错。
00:34:51结合 Vercel 平台,这套计费机制在宏观层面是如何运作的?
00:34:54好的,问题是:结合 Vercel 平台,这些功能的计费是如何运作的?
00:35:02使用 Workers 的好处之一在于:许多团队原本给 Agent 挂载了巨大的 MCP 服务器指令集,
00:35:08每次调用任务时,Agent 都要消耗海量的思维 Token(Thinking Tokens)去一遍遍重复推演。
00:35:16那个智能体就会消耗大量的思考 Token,基本上就是一遍又一遍地重复做同一件事。
00:35:21因此,如果你能把智能体正在做的这些重复性任务提取出来,并将其作为 Worker 来部署,
00:35:33虽然仍需支付 Worker 的执行时长费用,但成本远低于 AI 算力 Token。
00:35:40运行自定义 Agent 时,系统会针对调用工具前 Agent 消耗的 Token 进行计费;
00:35:48对于自定义 Agent 在调用工具前所消耗的 Token,平台会对其计费。
00:35:53因此当工具运行时,会按另一种费率计费,消耗你的积分(比如 Notion AI 积分)的速度要慢得多得多。
00:36:01最后当 Agent 完成最终回复时,再恢复按标准 AI 额度计费。
00:36:05因此,如果你在使用 Notion 自定义 Agent,通过 Worker 处理重复任务能大幅降低成本。
00:36:16补充一点:并非非得绑定 Notion AI。我们今天演示了与 Notion AI 集成的工具调用,
00:36:24但 Workers 也能处理第三方数据同步到 Notion,这完全不需要任何 AI 功能。
00:36:31所以它不仅限于 AI 产品,很多人用它来做数据同步。
00:36:36比如我自己写的 Worker 会把 Letterboxd 动态同步到 Notion,这是我个人最喜欢的用法。
00:36:46好的,还有其他问题吗?
00:36:52那边还有一位?
00:37:06你是说通过自己的界面向用户暴露 Notion 自定义 Agent?
00:37:13目前我们还没有推出这个功能。
00:37:16抱歉。感谢 MJ 提醒。刚才的问题是:如果自己开发了 Worker 和自定义 Agent,
00:37:22是否有途径在自己的应用程序中将其暴露给最终用户?
00:37:28目前暂不支持。不过我们有一个 Alpha 内测版的自定义 Agent API,能实现该需求。
00:37:35只要在工作区定义好自定义 Agent 和 Worker,
00:37:40就可以通过 API 进行调用并获取流式响应。所以技术上是可行之举。
00:37:46目前用的人还不多,这仍属于公测阶段的 Alpha 早期功能。
00:37:57还有其他问题吗?
00:38:00好的,太棒了。非常感谢大家抽出时间
00:38:04来聆听今天的分享。如果大家对 Notion、Notion Workers
00:38:08或 Vercel Sandbox 有任何疑问,欢迎随时找 MJ 或我交流。谢谢!