LLM-Switch 代理:Flutter 适配与踩坑记录

Cloudflare Workers 代理部署全流程

一、背景与动机

LLM-Switch 是一个 AI 模型供应商管理 App,核心诉求是:无论在什么平台/工具中使用 API Key,都能统一监控用量

问题在于:

  • Trae IDE、Claude Code、Cursor 等工具内置的 API 调用无法被本地应用直接监控
  • 本地代理服务器(proxy_server.dart)需要保持电脑开机运行,不够可靠
  • 需要一个永不宕机的代理层来拦截和记录所有 API 请求

解决方案:将代理逻辑部署到 Cloudflare 的全球边缘节点(Workers),利用其免费额度实现零成本运维。

二、架构设计

#mermaid-svg-KmleJymDZp7s7xIR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KmleJymDZp7s7xIR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KmleJymDZp7s7xIR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KmleJymDZp7s7xIR .error-icon{fill:#552222;}#mermaid-svg-KmleJymDZp7s7xIR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KmleJymDZp7s7xIR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KmleJymDZp7s7xIR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KmleJymDZp7s7xIR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KmleJymDZp7s7xIR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KmleJymDZp7s7xIR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KmleJymDZp7s7xIR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KmleJymDZp7s7xIR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KmleJymDZp7s7xIR .marker.cross{stroke:#333333;}#mermaid-svg-KmleJymDZp7s7xIR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KmleJymDZp7s7xIR p{margin:0;}#mermaid-svg-KmleJymDZp7s7xIR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KmleJymDZp7s7xIR .cluster-label text{fill:#333;}#mermaid-svg-KmleJymDZp7s7xIR .cluster-label span{color:#333;}#mermaid-svg-KmleJymDZp7s7xIR .cluster-label span p{background-color:transparent;}#mermaid-svg-KmleJymDZp7s7xIR .label text,#mermaid-svg-KmleJymDZp7s7xIR span{fill:#333;color:#333;}#mermaid-svg-KmleJymDZp7s7xIR .node rect,#mermaid-svg-KmleJymDZp7s7xIR .node circle,#mermaid-svg-KmleJymDZp7s7xIR .node ellipse,#mermaid-svg-KmleJymDZp7s7xIR .node polygon,#mermaid-svg-KmleJymDZp7s7xIR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KmleJymDZp7s7xIR .rough-node .label text,#mermaid-svg-KmleJymDZp7s7xIR .node .label text,#mermaid-svg-KmleJymDZp7s7xIR .image-shape .label,#mermaid-svg-KmleJymDZp7s7xIR .icon-shape .label{text-anchor:middle;}#mermaid-svg-KmleJymDZp7s7xIR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KmleJymDZp7s7xIR .rough-node .label,#mermaid-svg-KmleJymDZp7s7xIR .node .label,#mermaid-svg-KmleJymDZp7s7xIR .image-shape .label,#mermaid-svg-KmleJymDZp7s7xIR .icon-shape .label{text-align:center;}#mermaid-svg-KmleJymDZp7s7xIR .node.clickable{cursor:pointer;}#mermaid-svg-KmleJymDZp7s7xIR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KmleJymDZp7s7xIR .arrowheadPath{fill:#333333;}#mermaid-svg-KmleJymDZp7s7xIR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KmleJymDZp7s7xIR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KmleJymDZp7s7xIR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KmleJymDZp7s7xIR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KmleJymDZp7s7xIR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KmleJymDZp7s7xIR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KmleJymDZp7s7xIR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KmleJymDZp7s7xIR .cluster text{fill:#333;}#mermaid-svg-KmleJymDZp7s7xIR .cluster span{color:#333;}#mermaid-svg-KmleJymDZp7s7xIR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KmleJymDZp7s7xIR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KmleJymDZp7s7xIR rect.text{fill:none;stroke-width:0;}#mermaid-svg-KmleJymDZp7s7xIR .icon-shape,#mermaid-svg-KmleJymDZp7s7xIR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KmleJymDZp7s7xIR .icon-shape p,#mermaid-svg-KmleJymDZp7s7xIR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KmleJymDZp7s7xIR .icon-shape .label rect,#mermaid-svg-KmleJymDZp7s7xIR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KmleJymDZp7s7xIR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KmleJymDZp7s7xIR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KmleJymDZp7s7xIR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Base URL

转发请求
转发请求
SSE / JSON 响应
ctx.waitUntil()

异步写入用量
GET /proxy/usage

查询用量
返回用量数据
更新仪表盘
外部工具

Trae / Claude Code / Cursor
Cloudflare Worker

llm-switch-proxy.xxx.workers.dev
真实 API

DeepSeek / GPT-4 / Claude-3
D1 数据库

SQLite (proxy_request_logs)
Flutter App

定时轮询 /proxy/usage
LLM-Switch App

仪表盘 + 用量记录

核心接口:

端点 方法 功能
/proxy/health GET 健康检查 + D1 连接状态
/proxy/usage?since=&limit= GET 拉取用量记录(增量)
/proxy/usage DELETE 清空所有记录
其他所有路径 ANY 转发到目标 API

三、项目文件结构

text 复制代码
llm_switch/cf-proxy/
├── wrangler.toml        # Wrangler 配置(Worker 名/D1 绑定/环境变量)
├── package.json          # Node.js 依赖(wrangler)
├── schema.sql            # D1 数据库建表语句
└── src/
    └── index.js          # Worker 核心逻辑(~380 行)

wrangler.toml

toml 复制代码
name = "llm-switch-proxy"
main = "src/index.js"
compatibility_date = "2024-12-01"

[[d1_databases]]
binding = "DB"
database_name = "llm-switch-usage"
database_id = "e030a225-3868-4084-8cb4-2120da9e501a"

[vars]
TARGET_BASE_URL = "https://api.deepseek.com"

schema.sql

sql 复制代码
CREATE TABLE IF NOT EXISTS proxy_request_logs (
    id              TEXT PRIMARY KEY,
    provider_name   TEXT NOT NULL DEFAULT 'Cloudflare代理',
    model_name      TEXT NOT NULL DEFAULT 'unknown',
    app_type        TEXT NOT NULL DEFAULT 'llm',
    input_tokens    INTEGER NOT NULL DEFAULT 0,
    output_tokens   INTEGER NOT NULL DEFAULT 0,
    cost            REAL NOT NULL DEFAULT 0,
    latency_ms      INTEGER NOT NULL DEFAULT 0,
    status_code     INTEGER NOT NULL,
    error_message   TEXT,
    created_at      INTEGER NOT NULL
);

index.js 核心逻辑(~380 行)

主要模块:

  1. CORS 处理 --- Access-Control-Allow-Origin: *,所有接口支持跨域
  2. 费用估算 --- 根据模型名称(deepseek/gpt-4/claude-3 等)自动计算 $input/1M * 输入 + $output/1M * 输出
  3. 请求转发 --- 剥离 Cloudflare 特定头(cf-connecting-ip 等),转发到 TARGET_BASE_URL
  4. 流式响应处理 --- 对 SSE 流式请求,tee 流读取 [DONE] 前的最后一条 data 帧解析 usage
  5. 非流式响应处理 --- 解析 response.json().usage 获取 prompt_tokens/completion_tokens
  6. D1 写入 --- ctx.waitUntil() 异步写入,不阻塞响应
  7. 用量查询 --- 支持 ?since=ISO时间&limit=N 增量查询

四、部署步骤

4.1 前置准备

  • Cloudflare 账号
  • Node.js 22.x + npm 10.x
  • API Token(权限:Workers Scripts 编辑、D1 编辑、Account Settings 读取)

Cloudflare 登录地址:https://dash.cloudflare.com/login

三种登录方式,一般选取谷歌进行登录。

这里可以进行语言的切换。

4.2 API Token 权限配置

在 Cloudflare 控制台 → 个人资料 → API 令牌 → 创建令牌 → 自定义,

点击右上角的图像可以看到,配置文件的这个选项。然后进行以下的操作:

添加三个权限:

权限类型 级别 资源
Workers Scripts 编辑 包括 - 所有区域
D1 编辑 包括 - 所有区域
Account Settings 读取 包括 - 所有区域

Token 格式:cfut_xxx...(只显示一次,立即保存)

4.3 安装 Wrangler

bash 复制代码
cd llm_switch/cf-proxy
npm install

4.4 注册 workers.dev 子域名

每个 Cloudflare 账户需要注册一次。可通过 Cloudflare Dashboard 或 API:

powershell 复制代码
# 查询当前子域名
Invoke-RestMethod -Uri "https://api.cloudflare.com/client/v4/accounts/{account_id}/workers/subdomain" `
  -Method GET -Headers @{"Authorization"="Bearer $TOKEN"}
# 返回: { "subdomain": "3395769576hy" }

4.5 创建 D1 数据库

bash 复制代码
npx wrangler d1 create llm-switch-usage
# 输出: database_id = "e030a225xxxxxxx501a"

将返回的 database_id 填入 wrangler.toml

4.6 建表(必须先远程执行)

bash 复制代码
npx wrangler d1 execute llm-switch-usage --remote --file=schema.sql

注意:必须加 --remote,否则只在本地执行。

4.7 部署 Worker

由于 Wrangler CLI 在某些 Windows 环境下存在交互式认证问题(卡在 "Getting User settings..."),改用 Cloudflare REST API 直接上传。

创建 deploy.js(部署后已删除):

javascript 复制代码
const fs = require('fs');
const https = require('https');

const ACCOUNT_ID = '30ad86xxxxxxxx';
const API_TOKEN = 'cfuxxxxx';
const SCRIPT_NAME = 'llm-switch-proxy';
const D1_ID = 'exxxxxxxxxxxxx';

const metadata = JSON.stringify({
  main_module: 'index.js',
  bindings: [
    { type: 'd1', name: 'DB', database_id: D1_ID }  // 关键:type 必须是 "d1"
  ],
  vars: { TARGET_BASE_URL: 'https://api.deepseek.com' },
  compatibility_date: '2024-12-01',
});

// 构建 multipart/form-data 上传
// PUT /client/v4/accounts/{id}/workers/scripts/{name}

4.8 验证部署

bash 复制代码
# 健康检查
curl https://llm-switch-proxy.3395769576hy.workers.dev/proxy/health
# {"status":"ok","target":"https://api.deepseek.com","platform":"cloudflare","records":1}

# 用量查询
curl https://llm-switch-proxy.3395769576hy.workers.dev/proxy/usage
# [{"id":"...","providerName":"Cloudflare代理","modelName":"deepseek-chat",...}]

五、Flutter App 端适配

5.1 问题:Web 平台跨域超时

Flutter Web 的 package:http 底层使用 XMLHttpRequest,对 Cloudflare Worker 的 HTTPS 跨域请求出现 TimeoutException(即使 Worker 已设置 CORS)。

5.2 解决方案:条件导出 + dart:html

text 复制代码
lib/services/
├── proxy_service.dart       # 条件导出入口
├── proxy_service_io.dart    # 移动端:http 包
└── proxy_service_web.dart   # Web 端:dart:html HttpRequest

proxy_service.dart 核心一行:

dart 复制代码
export 'proxy_service_io.dart' if (dart.library.html) 'proxy_service_web.dart';

Web 版使用 dart:htmlHttpRequest.request(),绕过 http 包的跨域问题。

六、关键踩坑记录

现象 原因 解决
D1 绑定类型错误 binding DB has an unknown type d1_database API metadata 中 type 字段值不对 改为 "type": "d1",字段用 database_id 而非 id
D1 绑定不生效 /proxy/usage 返回 env.DB is undefined d1_databases 顶层字段 API 不支持 将 D1 绑定放入 bindings 数组,type 为 "d1"
Wrangler 卡认证 停在 "Getting User settings..." Windows xdg.config 日志目录权限 + 交互式 OAuth 改用 REST API 直接上传 Worker
Schema 仅本地执行 部署后 Worker 查询失败 wrangler d1 execute 默认 --local --remote 标志
端口占用 SocketException: 端口只允许使用一次 多个 flutter run 残留进程 Get-NetTCPConnection 查进程 → Stop-Process
_languageCode 私有访问 Flutter 编译错误 私有字段跨类访问 改用公开 getter languageCode
子域名未注册 You need to register a workers.dev subdomain 账户首次使用 Workers API 调用或 Dashboard 注册一次

七、使用方式

  1. 在 LLM-Switch App → 设置 → 代理监控 → 输入 https://llm-switch-proxy.3xxxxxxxxxhy.workers.dev → 连接
  2. 将外部工具(Claude Code / Cursor / 自定义应用)的 Base URL 改为上述地址
  3. App 每 30 秒自动拉取最新用量,仪表盘实时更新

八、扩展性

  • 多模型支持 :修改 TARGET_BASE_URL 或根据请求路由动态判断目标 API
  • 密钥禁用 :Worker 端暂未实现 /proxy/keys/disable,可后续添加
  • 用量保护 :可添加 USAGE_TOKEN 环境变量保护查询接口
  • 成本优化:Worker 免费额度 10 万次/天,D1 免费 5GB 存储,日常使用足够
相关推荐
浮江雾7 小时前
Flutter第十节-----Flutter布局与组件全解析
android·开发语言·前端·学习·flutter·入门
浮江雾7 小时前
Flutter第12节-----Flutter中Dio插件网络请求全解析
flutter
末代iOS程序员华仔9 小时前
Cursor + GitOps:自动化运维新姿势
flutter·ios·swift
世人万千丶9 小时前
鸿蒙Flutter Flex嵌套布局技巧
学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
世人万千丶10 小时前
鸿蒙Flutter Flex布局性能优化
学习·flutter·性能优化·harmonyos·鸿蒙·鸿蒙系统
WaywardOne11 小时前
Flutter组件化方案(AI总结)
前端·flutter·ai编程
YM52e11 小时前
鸿蒙 Flutter 渐变效果详解:LinearGradient、RadialGradient、SweepGradient
android·学习·flutter·华为·harmonyos·鸿蒙
YM52e11 小时前
鸿蒙 Flutter BoxDecoration装饰:打造精美UI效果
学习·flutter·ui·华为·harmonyos·鸿蒙
YM52e13 小时前
鸿蒙Flutter Card组件:Material设计风格卡片
android·学习·flutter·华为·harmonyos·鸿蒙