cloudflare-os 实战:基于 Workers 的 AI Agent 运行时架构拆解
1. 从标题到落地:cloudflare-os 到底想解决什么问题
第一次看到cloudflare-os这个名字,我下意识以为又是一个把 Linux 塞进浏览器沙箱的玩具项目。翻了一圈社区讨论和仓库结构之后才反应过来,它跟传统操作系统半点关系都没有——这里的 “OS” 更接近 “Agent Operating System” 的意思,是一套跑在 Cloudflare Workers 之上的 AI Agent 运行时骨架。换句话说,它想做的事情是:把“一个能自己调工具、自己记状态、自己决定下一步干什么的智能体”这件事,从你本地那台随时会断电的笔记本上,搬到全球边缘网络上。
这件事为什么值得单独拎出来讲?因为过去一年我帮不少团队做过 Agent 落地,踩得最狠的坑几乎都集中在同一类问题上:本地跑得好好的 Agent,一旦要 7×24 小时在线、要接多个渠道、要并发处理几十个会话,立刻就散架。你得自己维护一台常驻服务器,自己写状态存储,自己处理冷启动,自己搞定定时任务。而 Cloudflare Workers 这套东西天生就是干这个的——边缘节点常驻、按请求计费、自带 KV 和 D1 和 Durable Objects 这些状态原语。cloudflare-os本质上就是把这些原语按 Agent 的思维方式重新组织了一遍,让你不用从零搭地基。
它适合谁?我认为有三类人应该认真看:一是手里已经有一个能跑的 Agent demo、想把它变成真正能对外服务的产品的独立开发者;二是想理解“Agent 运行时”到底由哪些部件构成、不想只停留在调 API 层面的工程师;三是被各种 Agent 框架的抽象层绕晕、想看看一个更贴近基础设施的实现长什么样的技术负责人。这篇文章我会按我自己的理解,把它的设计思路、核心部件、实操路径和踩坑经验完整拆一遍,代码和配置都会给到可以直接抄的程度。
需要先说明一点:cloudflare-os这个项目本身在快速演进,不同时间点拉下来的仓库结构可能有差异。我下面讲的是基于它当前主流形态的合理还原,涉及具体 API 名称的地方,你以自己拉到的版本为准,但设计逻辑和踩坑点是通用的。
2. 整体架构拆解:为什么是 Workers 而不是一台服务器
2.1 Agent 运行时的四个必备部件
在聊cloudflare-os之前,得先把“一个 Agent 运行时到底需要什么”这件事说清楚,不然看它的设计会觉得莫名其妙。我自己的总结是四个部件,缺一不可:
- 推理入口:接收用户输入,调用大模型,产出“下一步动作”的决策。这是大脑。
- 工具执行层:把模型吐出来的工具调用真正执行掉,比如查数据库、发请求、读写文件。这是手脚。
- 状态与记忆:保存对话历史、长期记忆、任务中间态。没有它,Agent 每次都是失忆的。这是海马体。
- 调度与触发:定时任务、事件驱动、多轮循环的编排。这是让 Agent 能“自己动起来”而不是被动等输入的关键。
传统做法是把这四个部件塞进一个 Node 进程里,用内存存状态,用 cron 做调度。问题在于:内存状态一重启就没,单进程扛不住并发,cron 在容器里经常不准。cloudflare-os的思路是把这四个部件分别映射到 Cloudflare 的边缘原语上,让每个部件都用最适合它的基础设施来承载。
2.2 边缘原语与 Agent 部件的映射关系
这张映射表是我理解整个项目的钥匙,建议你先把它记住:
| Agent 部件 | 传统实现 | cloudflare-os 的承载方式 | 选择理由 |
|---|---|---|---|
| 推理入口 | Express 路由 | Workers 的 fetch handler | 边缘就近响应,冷启动极低 |
| 工具执行 | 本地函数调用 | Workers 内部调用 + 外部 API | 天然隔离,按需执行 |
| 短期状态 | 进程内存 | Durable Objects | 单实例强一致,适合会话 |
| 长期记忆 | 数据库 | D1 / Vectorize | 结构化与向量检索分工 |
| 调度触发 | 系统 cron | Cron Triggers + Queues | 平台级可靠调度 |
| 静态资源 | 本地文件 | R2 / Workers Assets | 免运维,全球分发 |
为什么短期状态要用 Durable Objects 而不是 KV?这是很多人第一个会问的问题。KV 是最终一致的,你写进去的数据可能要几十秒才能在全球读到,而 Agent 的会话状态是“读-改-写”高频交替的,用 KV 会出现经典的竞态:两个请求同时读到旧状态,各自改完写回,后写的覆盖先写的。Durable Objects 保证同一个 ID 的请求串行落到同一个实例上,天然解决了这个问题。这个选择背后是对 Agent 会话本质的理解——它是有状态的、需要串行的,不是无状态的 HTTP 请求。
2.3 为什么这个架构对 Agent 特别友好
Agent 的工作负载有个很反直觉的特点:它的计算是“突发 + 长尾”的。一次对话可能触发十几次工具调用,每次调用之间要等模型返回,中间大量时间在等 IO。用传统服务器,你得按峰值并发预留资源,大部分时间在空转烧钱。Workers 的按请求计费模型刚好匹配这种负载——模型在思考的时候你不付费,工具在等外部 API 的时候你不付费。
另一个被低估的点是地理分布。如果你的 Agent 要服务全球用户,传统方案要么单点部署让远端用户忍受延迟,要么多区域部署然后头疼状态同步。边缘运行时天然把推理入口铺到了离用户最近的地方,而状态层通过 Durable Objects 的 location hint 又能把会话固定在某个区域,兼顾了就近响应和状态一致。这个组合在 Agent 场景下比在普通 Web 场景下价值更大,因为 Agent 的交互轮次多,每轮省下的延迟会累积。
3. 核心部件逐个拆:从入口到记忆的完整链路
3.1 推理入口:Worker 如何接住第一跳
入口这一层看起来简单,其实藏着不少设计取舍。cloudflare-os的入口 Worker 通常做三件事:鉴权、路由分发、把请求转交给对应的 Durable Object 会话。我见过不少人图省事,直接在入口 Worker 里调模型、跑工具,结果就是所有逻辑挤在一个文件里,状态没地方放,并发一上来就乱。
正确的做法是把入口做薄。它只负责识别“这个请求属于哪个会话”,然后stub = env.SESSION.get(sessionId)拿到对应的 Durable Object 存根,把请求转发过去。会话 ID 的生成策略是个细节:如果是聊天场景,用用户 ID + 渠道 ID 组合;如果是任务场景,用任务 ID。这个 ID 决定了状态落在哪个实例上,一旦定错,后面迁移状态会很痛苦。
export default { async fetch(request, env) { const url = new URL(request.url); const sessionId = url.searchParams.get("sid") || "default"; const id = env.SESSION.idFromName(sessionId); const stub = env.SESSION.get(id); return stub.fetch(request); } };这段代码短到不像话,但它是整个架构的地基。入口 Worker 不持有任何状态,所以它可以被无限复制到全球任何节点,用户从哪个节点进来就在哪个节点处理第一跳。
3.2 会话对象:Durable Object 里的状态机
Durable Object 是cloudflare-os里最核心也最容易写错的部分。它本质上是一个单线程的、有状态的、可以被寻址的对象。你可以把它想象成一个“永远在线、只服务一个会话的小服务”。所有对这个会话的请求都会排队进入同一个实例,天然串行。
在 Agent 场景里,这个特性太重要了。我举个真实踩过的坑:早期我用 KV 存对话历史,用户快速连发两条消息,两条请求几乎同时到达,各自读到相同的历史,各自追加自己的消息,写回后只剩一条。用户看到的现象是“我发的第二条把第一条顶掉了”。换成 Durable Object 之后,两条请求自动排队,第一条处理完第二条才读到最新历史,问题消失。
会话对象内部通常维护这么几个东西:消息历史数组、当前任务状态、待执行的工具调用队列、以及一个指向长期记忆的引用。每次收到新消息,它把历史拼成 prompt 调模型,拿到工具调用就执行,执行完把结果塞回历史,再调模型,直到模型不再要求调工具为止。这个循环就是 Agent 的心跳。
async fetch(request) { const { message } = await request.json(); this.history.push({ role: "user", content: message }); let response = await this.callModel(this.history); while (response.toolCalls?.length) { for (const call of response.toolCalls) { const result = await this.executeTool(call); this.history.push({ role: "tool", content: result }); } response = await this.callModel(this.history); } this.history.push({ role: "assistant", content: response.content }); return new Response(JSON.stringify({ reply: response.content })); }这个 while 循环是 Agent 和普通聊天机器人的分水岭。普通机器人一问一答,Agent 会自己决定要不要再调一次工具、再想一轮。但这里有个必须注意的点:循环一定要设上限。我见过模型陷入“调工具-看结果-再调同一个工具”的死循环,把配额烧光。加一个maxIterations计数器,超过就强制返回,这是保命措施。
3.3 工具执行:把模型的话变成真实动作
工具执行层是安全风险最集中的地方。模型吐出来的工具名和参数本质上是不可信输入,你不能直接eval或者无脑执行。cloudflare-os的常见做法是维护一个工具注册表,每个工具是一个显式声明的函数,模型只能调用注册表里有的工具,参数要经过校验。
const tools = { get_weather: { schema: { city: "string" }, run: async ({ city }) => { const res = await fetch(`https://api.example.com/weather?city=${encodeURIComponent(city)}`); return await res.json(); } }, save_note: { schema: { content: "string" }, run: async ({ content }) => { await this.env.DB.prepare("INSERT INTO notes (content) VALUES (?)").bind(content).run(); return { ok: true }; } } };这里有几个实操心得。第一,工具的参数校验不能省,哪怕模型看起来很聪明,它也会偶尔吐出格式不对的 JSON,或者把city传成一个对象。第二,工具的执行要有超时,外部 API 卡住不能把整个会话拖死。第三,涉及写操作的工具要考虑幂等,因为 Agent 重试是常态,同一个save_note可能被调用两次。
注意:工具注册表是安全边界。绝对不要让模型动态生成工具名去调用,也不要让工具的参数直接拼进 SQL 或 shell 命令。所有外部输入都要走参数化查询或转义。
3.4 记忆分层:短期、长期、向量三者的分工
记忆这块是很多人做 Agent 时最糊的地方。cloudflare-os的思路是把记忆分成三层,各用各的存储:
- 短期记忆:当前会话的消息历史,放在 Durable Object 内存里,会话结束可以丢弃或归档。
- 长期结构化记忆:用户偏好、任务记录这类需要持久化的,放 D1,用 SQL 查询。
- 语义记忆:需要“相似度检索”的知识,放 Vectorize,用向量相似度召回。
为什么要分三层?因为它们的访问模式完全不同。短期记忆是高频读写、强一致、生命周期短;结构化记忆是低频写、按条件查、生命周期长;语义记忆是写一次查多次、按相似度排。用同一种存储硬扛三种模式,必然有一头难受。我早期图省事全塞进 D1,结果语义检索要靠LIKE模糊匹配,召回质量惨不忍睹,换成 Vectorize 之后才正常。
3.5 调度与触发:让 Agent 自己动起来
被动等输入的 Agent 只是聊天机器人,能自己定时干活的才是真 Agent。cloudflare-os用 Cron Triggers 做定时唤醒,用 Queues 做异步任务分发。比如“每天早上八点给用户推一份摘要”这种需求,就是 Cron 触发一个 Worker,Worker 找到对应用户的会话对象,往里面塞一条系统消息,触发 Agent 跑一轮。
# wrangler.toml [triggers] crons = ["0 8 * * *"] [[queues.producers]] queue = "agent-tasks" binding = "TASK_QUEUE"这里有个容易忽略的点:Cron 触发的是 Worker,不是 Durable Object。Worker 收到触发后要自己去定位会话对象。如果你的用户量很大,一次 Cron 要唤醒几万个会话,不能同步循环,得把任务拆成消息丢进 Queue,让消费者慢慢处理。否则 Worker 的执行时间上限会直接把你卡死。
4. 实操路径:从零把一个 Agent 跑起来
4.1 环境准备与项目初始化
先把工具链装好。你需要 Node 18 以上、wrangler CLI、以及一个 Cloudflare 账号。初始化命令很直接:
npm create cloudflare@latest my-agent -- --template=cloudflare-os cd my-agent npm install npx wrangler login登录之后,wrangler.toml是整个项目的配置中心,你得把用到的绑定都声明清楚。下面是我常用的一个配置骨架,把 Durable Objects、D1、Vectorize、Queues 都挂上:
name = "my-agent" main = "src/index.js" compatibility_date = "2024-09-01" [[durable_objects.bindings]] name = "SESSION" class_name = "AgentSession" [[migrations]] tag = "v1" new_classes = ["AgentSession"] [[d1_databases]] binding = "DB" database_name = "agent-db" database_id = "your-db-id" [[vectorize]] binding = "VECTOR_INDEX" index_name = "agent-memory" [[queues.producers]] queue = "agent-tasks" binding = "TASK_QUEUE"配置里最容易出错的是migrations。Durable Object 的类一旦声明,后续改类名或加新类都要走 migration,不能直接改。我第一次改类名没加 migration,部署直接报错,排查了半天才想起来这回事。
4.2 会话对象的完整实现
把前面讲的循环、工具、记忆串起来,一个能跑的会话对象大概长这样:
export class AgentSession { constructor(state, env) { this.state = state; this.env = env; this.history = []; this.maxIterations = 8; } async fetch(request) { const { message } = await request.json(); this.history.push({ role: "user", content: message }); let iterations = 0; let response = await this.callModel(); while (response.toolCalls?.length && iterations < this.maxIterations) { iterations++; for (const call of response.toolCalls) { const result = await this.executeTool(call); this.history.push({ role: "tool", name: call.name, content: JSON.stringify(result) }); } response = await this.callModel(); } this.history.push({ role: "assistant", content: response.content }); await this.persist(); return new Response(JSON.stringify({ reply: response.content })); } async callModel() { const res = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${this.env.OPENAI_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: "gpt-4o-mini", messages: this.history, tools: TOOL_SCHEMAS }) }); return await res.json(); } async persist() { await this.state.storage.put("history", this.history.slice(-50)); } }persist里我做了个截断,只存最近 50 条。为什么?因为 Durable Object 的存储有大小限制,历史无限增长迟早撑爆。更重要的原因是,把全部历史塞进 prompt 会让 token 成本线性上涨,而且模型对超长上下文的注意力会稀释。截断 + 摘要 + 向量召回,是控制上下文长度的标准组合拳。
4.3 工具注册与安全校验
工具这块我单独拎出来讲,因为它是出事最多的地方。一个健壮的工具执行函数应该包含:工具存在性检查、参数 schema 校验、执行超时、错误捕获。
async executeTool(call) { const tool = TOOLS[call.name]; if (!tool) return { error: `unknown tool: ${call.name}` }; const args = typeof call.arguments === "string" ? JSON.parse(call.arguments) : call.arguments; const validation = validateSchema(args, tool.schema); if (!validation.ok) return { error: validation.message }; try { return await Promise.race([ tool.run.call(this, args), new Promise((_, reject) => setTimeout(() => reject(new Error("tool timeout")), 10000)) ]); } catch (e) { return { error: e.message }; } }Promise.race做超时是个实用技巧,但要注意它只是让外层不再等待,被超时的那个 Promise 本身还在跑。如果工具有副作用(比如写数据库),超时后它可能还在后台执行。所以有副作用的工具最好自己内部也做超时和幂等。
4.4 部署与本地调试
本地调试用wrangler dev,它会起一个本地模拟环境,Durable Objects、D1 都能在本地跑。但有个坑:本地模拟的 Durable Object 行为和线上不完全一致,尤其是并发和持久化。我建议本地只做逻辑调试,并发相关的测试一定要上wrangler dev --remote或者直接部署到预览环境。
npx wrangler dev # 本地模拟 npx wrangler dev --remote # 连真实边缘环境 npx wrangler deploy # 正式部署 npx wrangler tail # 实时看日志wrangler tail是我用得最多的命令,线上出问题第一件事就是开它看实时日志。Agent 的 bug 往往藏在模型返回的中间态里,不看日志根本猜不到。
5. 常见问题与排查技巧实录
5.1 会话状态丢失或串号
这是最高频的问题,表现是用户 A 看到用户 B 的对话,或者刷新后历史没了。根因几乎都是会话 ID 生成有问题。排查顺序:先确认idFromName的入参是不是稳定且唯一的,再确认入口 Worker 有没有正确透传 sessionId。我见过一个案例,sessionId 用了Date.now(),结果每次请求都是新会话,历史永远为空。
提示:会话 ID 一旦确定就不能变。如果业务上需要“同一用户多设备共享会话”,用用户 ID;如果需要“每设备独立”,用用户 ID + 设备指纹。别用时间戳、随机数这类不稳定值。
5.2 模型返回的工具调用解析失败
模型偶尔会返回格式不规范的 JSON,比如参数里带注释、带尾逗号。直接JSON.parse会抛异常。稳妥做法是包一层容错解析,失败时把原始字符串回传给模型让它重试,而不是直接报错给用户。
function safeParse(str) { try { return JSON.parse(str); } catch { const cleaned = str.replace(/\/\/.*$/gm, "").replace(/,\s*([}\]])/g, "$1"); try { return JSON.parse(cleaned); } catch { return null; } } }5.3 循环不收敛烧光配额
Agent 陷入死循环是真实会发生的。除了设maxIterations,还可以加一个“重复检测”:如果连续两次工具调用的名字和参数完全相同,直接中断。这个检测能拦住大部分无意义的循环。
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 历史丢失 | 会话 ID 不稳定 | 打印每次请求的 sessionId | 改用稳定 ID |
| 状态串号 | ID 碰撞 | 检查 ID 生成规则 | 加命名空间前缀 |
| 工具解析失败 | 模型输出不规范 | 记录原始返回 | 加容错解析 |
| 循环不收敛 | 无迭代上限 | 统计迭代次数 | 设上限 + 重复检测 |
| 响应超时 | 工具阻塞 | 看 tail 日志定位 | 加工具超时 |
| 成本异常 | 上下文过长 | 统计 token 数 | 截断 + 摘要 |
5.4 冷启动与延迟优化
Workers 的冷启动已经很低,但 Durable Object 首次唤醒、D1 首次查询还是会有可感知的延迟。优化手段有几个:把不常变的数据缓存在会话对象内存里,减少 D1 查询;把模型调用改成流式返回,让用户先看到字;把工具调用能并行的并行,别串行等。
我实测下来,流式返回对体感延迟的改善最明显。用户看到第一个字的时间从两三秒降到几百毫秒,哪怕总时长没变,感受完全不同。
6. 我踩过的坑和几条实在建议
先说一个最容易被忽视的:别把 Agent 的所有逻辑都塞进一个 Durable Object。会话对象应该只管会话状态和编排,重活(比如批量数据处理、复杂计算)应该丢给 Queue 或者单独的 Worker。我早期把所有东西塞进会话对象,结果一个耗时任务把整个会话阻塞住,用户发消息半天没响应。Durable Object 是单线程的,它被占住的时候,同一个会话的所有请求都得排队。
第二个坑是密钥管理。模型 API key、数据库凭证这些绝对不能硬编码在代码里,用wrangler secret put存成环境变量。我见过有人把 key 提交到公开仓库,第二天就被刷爆了。wrangler secret存的值在代码里通过env.XXX访问,不会出现在源码和日志里。
第三个是成本监控。Agent 的 token 消耗很容易失控,尤其是循环多、上下文长的时候。建议在会话对象里记录每次调用的 token 数,定期汇总。Cloudflare 的用量面板能看到请求数,但看不到 token 数,这个得自己埋点。
最后分享一个我觉得很值的设计习惯:给每个工具写清楚它什么时候该被调用。模型的工具选择质量,很大程度上取决于工具描述的清晰度。描述里写清楚“这个工具用于查询实时天气,参数是城市名,返回温度和天气状况”,比只写个函数名效果好太多。我做过对比,把工具描述从一句话扩成三句话,工具调用的准确率明显提升。这个投入产出比很高,值得花时间打磨。
这套东西我目前用在几个小项目上,跑了大半年,稳定性比我之前自己维护服务器好太多。边缘运行时的按需计费模型对 Agent 这种突发负载特别友好,账单比我预期的低。如果你手里有个 Agent 想让它真正在线跑起来,cloudflare-os这套思路值得认真研究一遍,哪怕你不用 Cloudflare,它把 Agent 拆成入口、会话、工具、记忆、调度这五块的分法,本身就是一个很清晰的架构参考。