Cloudflare Workers + AI Gateway:从零搭建AI对话服务的完整链路
这几年做AI应用的人应该都有一个共同的感受模型本身已经不怎么稀缺了真正稀缺的是把模型接进真实业务链路里的那点耐心。我自己在本地写一个调用大模型的脚本最快十分钟就能跑通可一旦要让别人也能用要能上线、能鉴权、能限流、能查日志、能控制成本整条链路就突然变长。Cloudflare这两年给AI开发者做的工具链恰好就是在解决这个“变长”的问题。如果把这条链路拆开看一头是代码另一头是生产环境。Cloudflare相当于在中间加了一把钥匙用Workers来跑你的业务逻辑用AI Gateway统一接管模型调用用D1存会话数据用KV做限流计数用Access管入口身份。这些零件拼在一起之后从一行代码到线上URL之间的缝隙基本都被堵上了。下文主要讲我实际部署过的路径以及每个环节里那些文档里不会写的坑。1. AI应用上线前真正缺的是一条完整链路1.1 AI应用真正卡住的地方在代码之外写过完整AI项目的朋友应该都有体会。本地脚本只要保证三点模型能答、参数够用、不崩。但一个服务要上线跑通只是起点。接口要定义身份要确认数据要落库异常要兜底日志要可查还要考虑有人用你的接口时怎么保证不被恶意刷爆。这些事每一项都不难但每一项都占时间。传统做法是把服务部署在一台云服务器上再用容器管理依赖接一个网关做鉴权与限流配一个数据库存消息再搞一套监控告警。这套东西不是不能跑而是启动成本太高。尤其是个人开发者或者小团队往往还没有等到业务逻辑写完就已经把力气耗在了“怎么把服务安全地暴露在公网上”。Cloudflare的另一种走法是把这些零碎能力全部做成平台的一部分。代码上传后自动获得一个公网地址模型调用通过网关统一转发数据层选一个数据库就行鉴权既有零信任入口也支持自己写Token校验。少了很多“拼积木”的动作之后才能把注意力放回业务本身。1.2 Cloudflare的零件是怎么拼出闭环的很多人对Cloudflare的印象还停留在CDN和DNS其实它这些年做的已经是一个完整的应用开发平台。我自己常把这套组合比喻成一套工具箱你需要解决的问题Cloudflare里对应的零件生活化类比业务代码跑在哪里Workers街边的共享工位大模型从哪个入口调用Workers AI / AI Gateway统一的前台接线员会话记录、用户数据放哪D1 / KV / R2文件柜、便签条、仓库谁能进你的服务Access / 自定义鉴权门禁卡容量和费用怎么控制限流 观测面板水电表这一套东西拼在一起就有意思了你不必再管理服务器不用自己搭负载均衡不用专门为密钥写一套复杂的配置服务模型供应商想换改一个ID就行。从代码到上线的每个环节都有人管这就是标题里那把“钥匙”的真正含义。1.3 这套思路适合谁、不适合谁先说适合谁。如果你在做一个AI对话Demo、内部工具、小流量产品或者想验证某个AI想法能不能成立这套链路可以帮你把成本压到很低。免费的请求额度对开发和轻度使用基本够用等真正有用户再按量付费。不适合谁也比较明确如果你要做大模型本身的训练、要做超低延迟的GPU推理集群、要用到大量有状态的WebSocket长连接服务那还是得老老实实回到专用服务器或者私有化部署上去。Workers不是万能的它的运行时长、内存、依赖都有限制本质上它更适合“无状态有边界”的业务逻辑。2. Workers、AI Gateway与数据底座四个关键零件怎么选2.1 业务代码放哪为什么优先用WorkersWorkers是整套链路的核心载体。它运行在Cloudflare全球边缘节点上你写的JavaScript或TypeScript代码会被部署到靠近用户的位置用户访问时不用绕到某个固定区域的服务器而是直接命中最近的节点。用起来很简单一个export default的fetch函数就是入口export default { async fetch(request: Request, env: Env): PromiseResponse { // 路由、鉴权、业务逻辑都写在这里 }, };这个入口就是你的服务。没有显式的服务器概念没有端口监听也不需要配置文件里写IP。请求进来代码跑完返回响应结束。我个人非常喜欢这种简单因为它逼着你把业务拆成一个个小的无状态函数写着写着代码自然就整洁了。不过也要清楚它的边界。Workers里一个请求的执行时长和CPU时间都是有限制的如果代码里做了大量同步计算或者循环了几百万次很容易超时。这就意味着不适合把“把整个知识库加载进来再逐条比对”之类的重活放在Worker里做——那种活应该交给向量数据库或者后端专用服务。2.2 模型入口选哪个Workers AI还是AI Gateway模型调用是整个AI服务最核心的一环这块Cloudflare给了两条路一条是Workers AI直接用平台内置的模型推理能力代码里调用不关心底层供应商另一条是AI Gateway它是一个统一的网关层前端所有Worker都可以通过同一个URL格式调用模型后端可以接不同的模型供应商。AI Gateway对我的吸引力主要在几个点。第一是统一入口你可以在一个地方看到所有模型供应商的请求量、耗时、错误率不用每个平台看一遍。第二是缓存重复的请求可以命中缓存省掉一部分token费用。第三是重试与回退某个供应商超时或报错时网关可以自动换一个备用模型。这几个功能在生产环境里特别有用因为模型供应商的稳定性并不总是可预期指望“单点调用一次成功”太天真。实操中我建议这样选你的模型和供应商比较固定而且希望延迟尽量低那就直接用Workers AI如果你要对接多个供应商或者想统一观测、缓存、回退那就把AI Gateway放在前面。两者不是互斥的也可以组合。2.3 数据怎么存D1、KV、R2的分工逻辑AI服务里最常见的状态无非三类会话消息、用户信息、文件资源。Cloudflare给了三个存储很多人一开始容易混。存储特性适合场景不适合场景D1基于SQLite的数据库会话记录、用户表、订单状态大量并发写、超大数据量KV全球分布式键值限流计数、功能开关、配置快照强一致读、事务性操作R2对象存储图片、PDF、模型输出文件需要秒级更新的热数据聊天服务的会话记录我一般放在D1里。它支持SQL查询灵活能按用户、按时间排序。建一张简单的表就行CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id, created_at);限流计数放在KV里因为只需要记录“这个用户在这个时间窗口内访问了几次”是天然的高频读、偶尔写KV的全球分发特性也让限流判断在不同区域保持一致。R2则用来存用户上传的文件、生成的图片这些体积大的对象按需读取不占数据库空间。2.4 鉴权与密钥管理把门锁好再出门AI服务上线后最怕的一件事是密钥被人扒走。模型供应商的密钥、数据库连接信息、内部Token这些一旦出现在前端代码或日志里基本等于把钱包交给别人。我的做法分三层。第一层如果服务面向固定的内部团队直接开Access零信任入口用户用邮箱验证码登录就行不用自己写登录页。第二层如果服务要开放给第三方就在Worker里自己校验Authorization头客户端每次请求带一个Bearer Token服务端比对预设值。第三层密钥和Token一律放在Worker的环境变量或Secret里代码里通过env读取绝不在源码里写死。另外还要处理跨域。如果你的AI服务是给某个前端页面用的要明确允许的来源否则任何网站都能直接往你的接口发请求。有人觉得CORS只是浏览器限制不是真正的安全边界但至少它能把“随手开个控制台就能打你接口”这种事挡掉一部分。3. 从零部署一个带历史记录和限流的对话服务3.1 初始化项目并绑定资源光讲概念没有用直接上手跑一个实例。我们做一个最典型的东西带会话历史的AI对话接口带每用户限流支持跨域调用。先在本地建项目npm create cloudflarelatest -- my-ai-chat cd my-ai-chat npm install然后登录账号并初始化数据库。Cloudflare的命令行工具会引导你创建D1数据库和KV命名空间创建完之后把它们的ID填进wrangler.tomlname my-ai-chat main src/index.ts compatibility_date 2025-01-01 [[d1_databases]] binding DB database_name chat-db database_id 替换为你的数据库ID migrations_dir migrations [[kv_namespaces]] binding RATE_LIMIT id 替换为你的KV命名空间ID这里有个容易踩的坑是database_id填的是数据库本身的ID不是数据库名字。命令创建之后返回的那串长ID别填错。绑定之后先执行迁移命令把上面的建表SQL跑一遍确认messages表真正建出来了。3.2 核心代码逐段拆解项目生成后src/index.ts就是整个服务的入口。完整代码不复杂但每一段都有讲究export interface Env { DB: D1Database; RATE_LIMIT: KVNamespace; GATEWAY_URL: string; API_KEY: string; ALLOWED_ORIGIN: string; } const SYSTEM_PROMPT 你是一个简洁、友好的中文助手。; export default { async fetch(request: Request, env: Env): PromiseResponse { if (request.method OPTIONS) { return new Response(null, { headers: corsHeaders(env), }); } const url new URL(request.url); if (url.pathname /api/chat) { return handleChat(request, env); } return new Response(not found, { status: 404 }); }, }; async function handleChat(request: Request, env: Env) { if (request.headers.get(Authorization) ! Bearer ${env.API_KEY}) { return new Response(unauthorized, { status: 401 }); } const body (await request.json()) as { user_id: string; message: string; }; if (!body.user_id || !body.message) { return new Response(bad request, { status: 400 }); } // 限流每个用户每分钟最多3次 const now Math.floor(Date.now() / 1000); const windowKey ${body.user_id}:${Math.floor(now / 60)}; const count Number((await env.RATE_LIMIT.get(windowKey)) || 0); if (count 3) { return new Response(rate limited, { status: 429 }); } await env.RATE_LIMIT.put(windowKey, String(count 1), { expirationTtl: 90, }); // 读取最近的会话历史 const history await env.DB.prepare( SELECT role, content FROM messages WHERE user_id ? ORDER BY created_at DESC LIMIT 6 ) .bind(body.user_id) .all(); const messages [ { role: system, content: SYSTEM_PROMPT }, ...history.results.reverse(), { role: user, content: body.message }, ]; // 调用模型网关 const resp await fetch(${env.GATEWAY_URL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${env.API_KEY}, }, body: JSON.stringify({ model: your-model-id, messages, max_tokens: 512, }), }); const data: any await resp.json(); const reply data.choices?.[0]?.message?.content || 抱歉我没有理解你的问题。; // 把用户消息和回复都写进数据库 await env.DB.prepare( INSERT INTO messages (user_id, role, content) VALUES (?, ?, ?), (?, ?, ?) ) .bind(body.user_id, user, body.message, body.user_id, assistant, reply) .run(); return new Response(JSON.stringify({ reply }), { headers: corsHeaders(env), }); } function corsHeaders(env: Env) { return { Access-Control-Allow-Origin: env.ALLOWED_ORIGIN, Access-Control-Allow-Headers: Content-Type, Authorization, }; }代码里几个值得注意的地方限流窗口用的是分钟级固定窗口windowKey里包含user_id和当前分钟数这样每个用户在每一分钟内有独立计数。expirationTtl设为90秒是为了让过期的Key自动清理又不至于在窗口切换时立刻丢数据。历史记录只取了最近6条SQL里按时间倒序拼messages数组时再reverse恢复成时间正序这样模型看到的才是从早到晚的对话。调用网关时复用同一个API Key实际生产环境建议把网关的Key和业务鉴权Key分开避免一个Key泄漏全盘暴露。3.3 参数怎么定才不至于上线就返工很多人部署AI服务时把精力全放在写代码上结果上线后被两个参数折磨上下文长度和限流阈值。先看上下文长度。绝大多数模型的上下文都是有限的有的模型是8K有的更大。如果无限塞历史最终报错的都是token长度超限。代码里只取最近6条假设每条消息平均150 token系统提示算200 token输出预留512 token那么每次请求大概消耗 200 6×150 512 1612 token远低于常见模型的8K窗口。即使对话轮次很多也只回顾最近三轮效果上基本够用成本上也可控。再看限流阈值。3次/分钟这个数值是我随便给的真实场景要根据用户预期和模型成本来算。假设每个用户每天用10次每次请求花0.002元那一个用户一天成本约0.02元1000个活跃用户也就20元。把阈值改成10次/分钟也不会有太大成本压力。关键是限流要挡住的是“异常流量”而不是“正常用户”所以阈值定在正常峰值的3到5倍比较合理。成本这块顺便给个算账方法用网关面板上统计的token数乘以模型单价的百万token价格就能得到预估成本。你自己的API Key对应的供应商面板也会给出账单两边对照着看一旦数字对不上多半是缓存没生效或者有人在刷接口。3.4 部署上线域名、日志与发布清单代码写完后部署命令就一行npm run deploy部署完成后会得到一个workers.dev后缀的公网地址。自定义域名也很简单在控制台给Worker绑定一个域名并配置DNS解析几分钟就能生效。要注意workers.dev的地址本身对数据保护没啥问题但如果面向客户还是建议绑一个自己的域名看起来专业一些。本地调试阶段我的习惯是用wrangler dev起一个本地实例跑业务逻辑但数据库和KV的连接会根据配置走真实远端资源。这种处理的好处是本地行为和线上尽量一致不会出现“本地跑通了线上跑不通”的尴尬。如果只是想快速验证代码语法也可以不加任何参数直接起本地模拟存储。刚开始接触这套链路的话我更推荐让本地直接连远端因为配置和密钥的坑越早暴露越好。日志查看用命令wrangler tail它会实时打印线上请求日志。我习惯在部署之后立刻tail一下自己发几个请求确认鉴权、限流、数据库、模型调用四段链路都正常再交给真实用户。真上线之前建议过一遍清单绑定资源是否都生效、环境变量是否已经设置、限流响应状态码是否统一、CORS来源是否准确、数据库迁移是否已执行。4. 实操中常见的坑与排查实录4.1 模型请求闷声超时前端只看到一个504症状是前端等很久最后返回504或者timeout。排查方向不是业务代码而是模型网关的响应时间。不同模型供应商的响应速度差异很大有的模型思考时间长容易超过默认超时。我的处理方式是在AI Gateway面板里把重试次数调成1再配置一个备用模型做回退。当主模型连续失败或超时网关自动切到备用模型用户体验上只是慢了一点至少不会直接失败。另外如果确实需要长输出把max_tokens调大但也要同步把Worker那边的响应等待时间留足。4.2 上下文长度爆掉模型开始胡说模型对超出上下文的处理方式很迷惑有的直接报错有的把很早的消息截断但更麻烦的是它开始“失忆”明明之前告诉过它的事实后面又答错。这个问题基本都出在“无脑把历史全塞进去”。解决思路是给历史记录设上限取最近的N条就够了。我踩过一次坑当时取了最近50条结果长对话下直接把token打满之后的请求全部失败。后来改成动态截断根据剩余token预算倒推可以保留多少条历史保留最近的消息丢弃最旧的。代码里为了简洁用了固定6条生产环境可以改成按token数计算。4.3 限流好像生效了又好像没生效限流失效的典型场景是明明设置了每分钟3次但用户在第4次时依然能过。问题多半出在KV的写入时机。固定窗口的实现里读计数和写计数是两步操作两个请求同时进来时可能都读到旧的计数于是都放行造成超卖。如果业务对限流准确性要求很高可以改用D1事务计数或者直接依赖网关层自带的限流能力。如果只是防止一般滥用KV固定窗口的轻微超卖完全可以接受。我自己的取舍是面向普通用户的小服务KV够了面向付费API的就用更严格的方案。4.4 本地测试正常部署到线上就报错最常见的两个原因一是本地的wrangler版本和线上运行时版本不一致导致某些新API在线上不可用二是依赖了本地环境特有的全局对象比如process.env而Workers运行时需要用env参数传入。排查方法很简单部署后第一时间看wrangler tail和日志确定是哪个环节报的错。环境变量类的错误通常会在日志里直接提示undefined版本类错误会提示某个API不存在对症处理就行。为了减少这类问题我会把compatibility_date固定在一个已知版本不随意升级。4.5 密钥不能碰但日志里全是它有一次我排查线上问题随手打了完整的请求体里面带着模型API Key日志直接打到了控制台。虽然只是测试环境但习惯非常危险因为日志一旦泄露等于把密钥公开了。从那之后我给自己定了几条规矩写日志时只打脱敏后的字符串比如只保留前4位后4位绝不把Authorization头原始值打印到日志Secret类变量绝不放进wrangler.toml这种会进版本库的文件里改用Secret命令单独设置一旦怀疑密钥泄露立即在控制台轮换不要存任何侥幸心理。5. 把这条链路延展到更多场景5.1 从单聊到多租户上面例子把所有用户的消息放在同一张表里靠user_id区分。如果做成多租户产品把租户ID也加进主键就够比如每条消息都有tenant_id和user_id两个维度查询时WHERE条件用tenant_id过滤限流Key用tenant_iduser_id组合。权限上在Worker入口先解析当前请求属于哪个租户再决定能访问哪些数据。5.2 用缓存把成本打下来AI Gateway的缓存适合那些结果相对稳定的请求比如固定的问题列表、产品说明问答。启用缓存后相同的model和messages组合会直接命中缓存不再消耗token。实际的优化效果取决于业务形态如果用户问的问题重复率高缓存能把成本砍掉一截如果全是长尾个性化问题缓存命中率就很低。5.3 可观测与告警小服务可以靠wrangler tail手动看日志用户量上来之后建议把AI Gateway面板的指标和Cloudflare控制台的请求指标一起看。请求量、错误率、P95耗时、5xx占比这几个指标比单个日志更重要。设定一个简单的规则错误率超过5%就告警就能在用户抱怨之前发现问题。这个告警可以直接用平台的事件通知功能配上邮箱或群机器人不用额外搭监控系统。我个人实际操作下来的体会是Cloudflare这套链路最大的价值不是某一个零件多强而是它把“从写代码到部署上线”的过程中那些最琐碎、最容易被忽视的环节全部兜住了。第一次把对话服务部署到公网的时候我发现真正花时间的根本不是模型调用而是想清楚谁在调用、调用量多大、数据怎么存、密钥怎么管、单点故障怎么兜底。这些问题想清楚了代码本身反而只占一小部分。如果你正在做AI应用我的建议是从最小的闭环开始先做一个只处理单个请求的接口把鉴权和限流加上再把历史记录存进D1最后接上日志。每一步都跑通了再往里面加功能。这把钥匙拿到手之后剩下的就是怎么把门里的东西收拾得更好看了。