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结构化与向量检索分工调度触发系统 cronCron 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 触发一个 WorkerWorker 找到对应用户的会话对象往里面塞一条系统消息触发 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 cloudflarelatest my-agent -- --templatecloudflare-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 拆成入口、会话、工具、记忆、调度这五块的分法本身就是一个很清晰的架构参考。