这几年做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 业务代码放哪:为什么优先用Workers
Workers是整套链路的核心载体。它运行在Cloudflare全球边缘节点上,你写的JavaScript或TypeScript代码会被部署到靠近用户的位置,用户访问时不用绕到某个固定区域的服务器,而是直接命中最近的节点。
用起来很简单,一个export default的fetch函数就是入口:
export default { async fetch(request: Request, env: Env): Promise<Response> { // 路由、鉴权、业务逻辑都写在这里 }, };这个入口就是你的服务。没有显式的服务器概念,没有端口监听,也不需要配置文件里写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 cloudflare@latest -- my-ai-chat cd my-ai-chat npm install然后登录账号并初始化数据库。Cloudflare的命令行工具会引导你创建D1数据库和KV命名空间,创建完之后把它们的ID填进wrangler.toml:
name = "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): Promise<Response> { 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_id+user_id组合。权限上,在Worker入口先解析当前请求属于哪个租户,再决定能访问哪些数据。
5.2 用缓存把成本打下来
AI Gateway的缓存适合那些结果相对稳定的请求,比如固定的问题列表、产品说明问答。启用缓存后,相同的model和messages组合会直接命中缓存,不再消耗token。实际的优化效果取决于业务形态:如果用户问的问题重复率高,缓存能把成本砍掉一截;如果全是长尾个性化问题,缓存命中率就很低。
5.3 可观测与告警
小服务可以靠wrangler tail手动看日志,用户量上来之后,建议把AI Gateway面板的指标和Cloudflare控制台的请求指标一起看。请求量、错误率、P95耗时、5xx占比这几个指标,比单个日志更重要。设定一个简单的规则:错误率超过5%就告警,就能在用户抱怨之前发现问题。这个告警可以直接用平台的事件通知功能,配上邮箱或群机器人,不用额外搭监控系统。
我个人实际操作下来的体会是,Cloudflare这套链路最大的价值,不是某一个零件多强,而是它把“从写代码到部署上线”的过程中,那些最琐碎、最容易被忽视的环节全部兜住了。第一次把对话服务部署到公网的时候,我发现真正花时间的根本不是模型调用,而是想清楚谁在调用、调用量多大、数据怎么存、密钥怎么管、单点故障怎么兜底。这些问题想清楚了,代码本身反而只占一小部分。
如果你正在做AI应用,我的建议是从最小的闭环开始:先做一个只处理单个请求的接口,把鉴权和限流加上,再把历史记录存进D1,最后接上日志。每一步都跑通了,再往里面加功能。这把钥匙拿到手之后,剩下的就是怎么把门里的东西收拾得更好看了。