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:html 的 HttpRequest.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 存储,日常使用足够
相关推荐
yuanlaile12 小时前
2026年全新Flutter 跨平台开发全栈学习资料整理
flutter·getx·鸿蒙 next·flutter开发鸿蒙·flutter入门实战·flutter全栈
恋猫de小郭1 天前
Genkit Dart 1.0 发布,Flutter 原生的 AI Agent 终于完整了
android·前端·flutter
第10086个小白1 天前
flutter桌面端打包方式(Inno Setup Compiler)版本
前端·flutter
陆断枫2 天前
Flutter 3.47 动画引擎盘点:Impeller 全平台,和那些"新特性"
flutter
程序员老刘2 天前
Flet 1.0 思路很好,可惜来晚了
flutter·ai编程·客户端
m0_738185822 天前
Flutter 鸿蒙化实战:qr_code_scanner_plus 适配 OpenHarmony,二维码扫描
flutter·华为·harmonyos·鸿蒙
恋猫de小郭2 天前
Flutter + EmbeddingGemma 2,谷歌发布完全端侧的 AI Edge Foresight
android·前端·flutter
君赏3 天前
基于 FlutterPatch 快速接入 Flutter 热更新
flutter
律宏阔3 天前
Dart FFI 内存管理:用 using + Arena 替代嵌套 try-finally
前端·flutter
律宏阔3 天前
Flutter 调用 Go:从 c-shared + ffigen 到 @Native + Native Assets 踩坑记录
前端·flutter