阅读视图

发现新文章,点击刷新页面。

Vercel Serverless 调国内 AI 接口 504?Edge Runtime 救了我

Mobile 端 AI 对话请求在 Vercel 上稳定 504 超时,本地却秒回。CORS 报错是假的,区域配置也没用。最终发现是 Vercel Serverless(AWS Lambda)到国内 DashScope 的网络出口根本不通。一行 export const runtime = "edge" 切到 Cloudflare 边缘网络,3 秒完成。这篇文章把排查过程、根因分析和解决方案一次性讲清楚。

0. 前景提要:项目架构与问题背景

先交代项目架构,方便理解后续为什么 Web 端和 Mobile 端表现不同。

项目结构

My-Notion/                    # pnpm workspace Monorepo
├── apps/
│   ├── web/                  # Web 端(Next.js)
│   └── mobile/               # Mobile 端(Expo / React Native)
├── packages/
│   └── ai/                   # AI 核心逻辑(共享包)
│       ├── server/           #   streamChat、streamRAG、ConvexDataSource...
│       ├── config/           #   模型配置、Base URL
│       ├── tools/            #   WebSearch 等工具
│       └── rag/              #   向量检索逻辑
└── services/
    └── ai/                   # AI 网关(Hono),独立部署到 Vercel
        ├── api/              #   Vercel Serverless / Edge 入口
        └── src/              #   路由、Convex 数据源、Sentry

为什么 Mobile 不直接用 Web 端的 API

Web 端的 AI 路由(/api/chat/api/rag-stream)是 Next.js API Route,跑在 apps/web 这个 Vercel 项目里。Mobile 端不能直接调这些路由,原因有三个:

  1. SSE 流式传输:Mobile 端需要 Server-Sent Events 格式的流式响应,Web 端的 /api/chat 用的是 NDJSON 格式,不兼容
  2. 密钥隔离:AI 服务的 LLM_API_KEY 不应该暴露在 Mobile 客户端,需要一个中间层代理
  3. 独立扩缩:AI 请求是重 IO 操作,和 Web 页面服务混在一起会互相影响

所以 Mobile 端的 AI 请求走独立部署的 services/ai(基于 Hono 的轻量 Node.js 服务),部署在 my-notion-ai.vercel.app

两条 AI 链路

Web 端:
  浏览器 → apps/web (Next.js API Route) → DashScope
           ↑ 同一个 Vercel 项目,Serverless Function

Mobile 端:
  App → services/ai (Hono) → DashScope
        ↑ 独立 Vercel 项目,Serverless Function

关键点:两条链路都跑在 Vercel Serverless(AWS Lambda)上,但它们是不同的 Vercel 项目,函数冷启动、预热策略、网络出口可能不同。这解释了为什么"Web 端偶尔慢,Mobile 端必超时"。

DashScope 是什么

DashScope 是阿里云的大模型服务平台,提供 OpenAI 兼容接口。项目用的模型是通义千问(Qwen),Base URL 是 https://dashscope.aliyuncs.com/compatible-mode/v1——这是一个国内节点

这就是问题的伏笔:Vercel 的服务器在海外,DashScope 的服务在国内,中间隔着一条不稳定的网络链路。

1. 开篇:文档正常,AI 炸了

项目是 Web + Mobile 双端架构,共享 packages/ai 核心逻辑。Mobile 端的 AI 请求走独立部署的 services/ai(Hono),域名是 my-notion-ai.vercel.app

上线后发现问题:

  • Mobile 文档功能(Convex)完全正常
  • Mobile AI 对话请求长期 pending,最终 504
  • Web 端 AI 功能偶尔也慢

浏览器网络面板显示的是 CORS 错误,但 OPTIONS /api/chat 返回 204,预检请求没问题。真正挂的是 POST /api/chat

CORS 报错是服务器 500/504 后的表象,不是根因。浏览器只在请求失败时才告诉你"可能是 CORS",实际上后端已经炸了。

2. 排查:五层剥洋葱

2.1 第一层:前端代码

检查 Mobile 端的请求逻辑——URL 正确、Header 正确、Body 格式正确。没有根本性错误。

结论:问题不在 Mobile 前端。

2.2 第二层:CORS

OPTIONS /api/chat 返回 204GET /api/health 返回 {"status":"ok"}。CORS 中间件 app.use("*", cors()) 全局开启,配置正确。

结论:CORS 不是根因,只是请求失败的表层表现。

2.3 第三层:路由与部署入口

最初 /api/health 返回 404,经过以下修复后恢复正常:

  • 调整 Hono 路由前缀
  • 修正 Vercel catch-all API 入口
  • 清理错误的 vercel.json 重写规则

结论:路由问题已修复,但 AI 请求仍然超时。

2.4 第四层:模块加载

Vercel 尝试以 CJS 模式加载 ESM 产物,以及无法解析 workspace:* 依赖中的 .ts 源码。修复方式:

  • 创建 CJS 包装器 api/[[...route]].js 加载 dist/ 产物
  • 本地化 ConvexDataSource 逻辑,消除运行时对 workspace 源码的依赖

修复后 /api/health 稳定返回 200。

结论:模块加载问题已修复,但 POST /api/chat 仍然超时。

2.5 第五层:网络出口——真正的根因

/api/chat 路由中增加了分阶段日志和首包超时保护:

const CHAT_FIRST_EVENT_TIMEOUT_MS = 20_000;

const firstEventTimer = setTimeout(() => {
  if (!didReceiveFirstEvent) {
    didTimeoutBeforeFirstEvent = true;
    abortController.abort();
  }
}, CHAT_FIRST_EVENT_TIMEOUT_MS);

Vercel Runtime Logs 显示:

  • request_received ✅ 打出了
  • model_request_started ✅ 打出了
  • first_event_received ❌ 始终没出

请求进入了服务,也发起了对 DashScope 的调用,但首包永远收不到。300 秒后 Vercel 强制超时,返回 504 FUNCTION_INVOCATION_TIMEOUT

结论:Vercel Serverless 到 DashScope 的网络出口链路不稳定,请求卡在等待上游响应阶段。

3. 验证:本地秒回,线上卡死

本地启动 services/ai,测试 /api/chat

curl -s -N -X POST http://localhost:3001/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"你好"}],"model":"qwen-plus"}' \
  --max-time 30

服务端日志:

[services/ai][chat][xxx] request_received
[services/ai][chat][xxx] model_request_started
[services/ai][chat][xxx] first_event_received  {"elapsedMs":657}    ← 657ms 首包
[services/ai][chat][xxx] stream_completed      {"elapsedMs":1714}   ← 1.7s 完成

本地 1.7 秒完成,首包 657ms。DashScope 服务本身完全正常。

100% 确认:问题在 Vercel 运行环境到 DashScope 的网络链路,不在代码。

4. 尝试修复:换区域,没用

Vercel 默认把函数跑在美东(iad1),到国内 DashScope 的链路确实很远。手动把 Function Region 改到香港(hkg1),确认配置生效后重新测试。

结果:仍然 504 超时。

区域确实有影响,但不是唯一根因。Vercel 的 Serverless Function 跑在 AWS Lambda 上,即使入口区域是 hkg1,网络出口的路由仍然可能绕远或不稳定。你无法控制 AWS 内部的流量调度。

5. 尝试修复:换 DashScope Endpoint,Key 不通用

DashScope 提供三个区域的 OpenAI 兼容接口:

区域 Base URL
北京(国内) https://dashscope.aliyuncs.com/compatible-mode/v1
弗吉尼亚(美国) https://dashscope-us.aliyuncs.com/compatible-mode/v1
新加坡(国际) https://dashscope-intl.aliyuncs.com/compatible-mode/v1

心想换成新加坡国际站 endpoint,从 Vercel 到新加坡应该更通。结果:

401 Incorrect API key provided

国内站和国际站的 API Key 完全隔离,互不通用。 你的 Key 是国内站申请的,只能用国内站 endpoint。要用国际站,得重新注册阿里云国际站账号、开通百炼、申请新 Key。

6. 最终方案:Edge Runtime

6.1 关键洞察

Vercel 上有两种运行代码的方式,它们跑在完全不同的基础设施上:

Serverless Function Edge Function
底层 AWS Lambda Cloudflare Workers
运行时 完整 Node.js V8 引擎(浏览器级)
冷启动 500ms ~ 几秒 < 5ms
网络出口 AWS 区域内网 Cloudflare 边缘网络
超时限制 10~300 秒 30 秒

Serverless 走 AWS 的网络出口到 DashScope 不通,不代表 Edge 走 Cloudflare 的网络出口也不通。 这是两条完全不同的网络路径。

6.2 实操:一行声明切换

services/ai/api/chat.ts 中创建 Edge 版入口:

import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { cors } from "hono/cors";
import OpenAI from "openai";

export const runtime = "edge";        // 关键:声明为 Edge Runtime
export const preferredRegion = "hkg1"; // 优先在香港执行

const app = new Hono().basePath("/api");
app.use("*", cors());

app.post("/chat", async (c) => {
  const openai = new OpenAI({
    apiKey: process.env.LLM_API_KEY,
    baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
  });

  return streamSSE(c, async (stream) => {
    const response = await openai.chat.completions.create({
      model: "qwen-plus",
      messages: [...],
      stream: true,
    });

    for await (const chunk of response) {
      const text = chunk.choices[0]?.delta?.content;
      if (text) {
        stream.writeSSE({ event: "content", data: JSON.stringify({ type: "content", text }) });
      }
    }
    stream.writeSSE({ event: "done", data: JSON.stringify({ type: "done" }) });
  });
});

export default app;

Vercel 的路由规则中,具体路径(api/chat.ts)优先于 catch-all(api/[[...route]].js),所以 /api/chat 走 Edge,其他路由继续走 Serverless。

6.3 结果

curl -s -N -X POST https://my-notion-ai.vercel.app/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"请用中文详细解释什么是向量数据库,至少200字"}],"model":"qwen-plus"}' \
  --max-time 30 -w '\nHTTP_CODE: %{http_code}\nTIME_TOTAL: %{time_total}s\n'
HTTP_CODE: 200
TIME_TOTAL: 3.658670s

从 300 秒超时到 3.66 秒完成。问题彻底解决。

7. 为什么 Web 端"偶尔慢"

Web 端的 AI 路由也跑在 Vercel Serverless 上,用的是同一个 AWS 网络出口。那为什么 Web 端只是"偶尔慢"而不是"必超时"?

原因有两个:

  1. Web 端是 Next.js 项目,Vercel 对 Next.js 有更好的优化(函数预热、增量静态生成),冷启动更快
  2. 偶尔慢 = 同一根因,只是因为 Next.js 的优化偶尔让请求抢在超时前完成

把 Web 端的 /api/chat/api/editor-ai/streamText 也迁移到 Edge Runtime 后,稳定性进一步提升。

8. Edge Runtime 的限制

Edge 不是万能的。它的核心限制是只能用 Web 标准 API

可用 不可用
fetchRequestResponse fs(文件系统)
ReadableStream require()(只能用 import
crypto.randomUUID() Node.js crypto.createHash()
setTimeout http/net 模块
openai SDK convex SDK
ai (Vercel AI SDK) @langchain/core
@clerk/nextjs/server serpapi

所以 /api/rag-stream/api/rag-complete 不能跑在 Edge 上——它们依赖 convex@langchain,内部用了 Node.js API。

桶导出陷阱

即使你的路由只用 streamChat,但如果通过桶导出 import:

import { streamChat } from "@notion/ai/server";

整个 server/index.ts 会被加载,包括 streamRAG(依赖 @langchain)和 ConvexDataSource(依赖 convex)。Edge 环境下,import 阶段就会报错,不管你调不调用。

解决方案:内联 OpenAI 调用,不走桶导出。这就是为什么 Edge 版的 /api/chat 直接用 openai SDK,而不是 import { streamChat } from "@notion/ai/server"

9. DashScope Base URL 也做了可配置化

顺便做了一个小改进——把 DashScope 的 Base URL 改为环境变量可配置:

// packages/ai/config/baseurl.ts
export const DASHSCOPE_BASE_URL =
  process.env.DASHSCOPE_BASE_URL ||
  "https://dashscope.aliyuncs.com/compatible-mode/v1";

这样如果后续注册了国际站 Key,只需设置环境变量 DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1,不用改代码。

10. 总结:Serverless 调国内 API 的排查清单

如果你的 Vercel Serverless Function 调国内服务(DashScope、通义千问、文心一言等)遇到超时,按这个清单排查:

步骤 检查项 方法
1 前端代码是否正确 本地 curl 直接测后端 API
2 CORS 是否配置 检查 OPTIONS 请求是否返回 204
3 路由是否挂载 检查 health 接口是否正常
4 模块是否加载成功 检查 Vercel Runtime Logs 有无启动错误
5 上游是否可达 加分阶段日志,确认首包是否收到
6 本地是否正常 本地跑同一条链路对比
7 换 Edge Runtime export const runtime = "edge"

核心经验:

  • 浏览器 CORS 报错 ≠ CORS 问题,大概率是后端 500/504
  • Vercel Serverless 和 Edge 走不同的网络出口,一个不通不代表另一个也不通
  • 分阶段日志是排查超时问题的利器——没有日志,你只能猜
  • Edge Runtime 不是银弹,依赖 Node.js API 的路由不能迁移
  • 桶导出会把不兼容的代码拉进 Edge 环境,需要内联或拆分入口

一行 export const runtime = "edge",省了迁移服务器、注册国际站、改 DNS 的全部成本。


本文基于 My-Notion 项目的真实踩坑经历撰写——一个 AI 原生的个人版 Notion,Web + Mobile 双端架构,AI 服务部署在 Vercel 上。欢迎 Star ⭐

幽灵依赖:本地跑得好好的,线上部署却炸了

这是我在开发 My-Notion 项目时踩的一个真实坑——本地开发一切正常,推到 GitHub 后 CI/CD 构建直接失败。排查后发现是幽灵依赖(Phantom Dependencies)问题,而罪魁祸首竟然是 AI Agent 用错了包管理器。

问题复现

某天我 push 代码后,GitHub Actions 的 Build 流水线报红了:

Error: Cannot find module '@qdrant/js-client-rest'

奇怪,我本地跑得好好的啊。

看了一下代码,packages/ai/rag/qdrantVectorStore.ts 里确实用了 @qdrant/js-client-rest

import { QdrantClient } from "@qdrant/js-client-rest";

再看 packages/ai/package.json,依赖声明是这样的:

{
  "dependencies": {
    "@langchain/qdrant": "^1.0.1",
    // ... 其他依赖
  }
}

注意——@qdrant/js-client-rest 并没有在 package.json 中声明,但代码里直接 import 了它。

本地能跑是因为 @langchain/qdrant 依赖了 @qdrant/js-client-rest,而 npm 在安装时会把它提升(hoist)到 node_modules 根目录,所以代码能找到这个包。但线上用 pnpm 构建时,pnpm 严格的依赖结构不允许访问未声明的依赖,直接报错。

什么是幽灵依赖

幽灵依赖(Phantom Dependencies)是指代码中实际使用了某个包,但该包没有在 package.json 中显式声明,而是通过其他包的依赖间接引入的

用一张图来解释:

你的代码
  └─ import { QdrantClient } from "@qdrant/js-client-rest"  ← 直接使用
       ↑
       │  (没有在 package.json 中声明)
       │
@langchain/qdrant (package.json 中声明了)
  └─ @qdrant/js-client-rest  ← 间接依赖

你的代码能访问 @qdrant/js-client-rest,完全是因为 @langchain/qdrant 装了它。但这个关系是隐式的、脆弱的——一旦 @langchain/qdrant 升级版本不再依赖 @qdrant/js-client-rest,或者换了一个替代包,你的代码就会莫名其妙地挂掉。

为什么 npm 会有幽灵依赖,pnpm 不会

核心区别在于 node_modules 的目录结构。

npm 的扁平结构(Flat)

npm v3+ 采用扁平化安装,所有依赖(包括间接依赖)都会被提升到 node_modules 根目录:

node_modules/
├── @qdrant/js-client-rest/     ← 被提升上来了,你的代码能直接访问
├── @langchain/qdrant/
│   └── node_modules/
│       └── (空的,因为被提升了)
├── langchain/
├── openai/
└── ...

这种设计的好处是安装快、兼容性好,但代价就是幽灵依赖——你可以 import 任何被提升到根目录的包,不管你有没有声明它。

pnpm 的严格结构(Strict)

pnpm 采用软链接 + 硬链接的方式,每个包只能访问自己声明的依赖:

node_modules/
├── .pnpm/                           ← 真实存储位置
│   ├── @qdrant+js-client-rest@1.17.0/
│   │   └── node_modules/
│   │       └── @qdrant/js-client-rest/
│   └── @langchain+qdrant@1.0.1/
│       └── node_modules/
│           ├── @langchain/qdrant/
│           └── @qdrant/js-client-rest/  ← 软链接,只有 @langchain/qdrant 能访问
├── @langchain/qdrant/               ← 软链接到 .pnpm
├── langchain/                        ← 软链接到 .pnpm
└── (没有 @qdrant/js-client-rest!)   ← 你的代码找不到它

在 pnpm 的结构下,@qdrant/js-client-rest 只存在于 @langchain/qdrant 的依赖树中,你的代码如果不显式声明,根本访问不到。

这正是 pnpm 的设计初衷——通过严格的依赖隔离,在开发阶段就暴露幽灵依赖问题,而不是等到线上部署才炸。

这个坑是怎么产生的

在我的场景中,问题出在 AI Agent 用了 npm install 而不是 pnpm add 来安装包。

我:帮我安装 @langchain/qdrant
Agent:npm install @langchain/qdrant   ← 用了 npm!

npm 安装后,@qdrant/js-client-rest 被提升到了 node_modules 根目录。Agent 在写代码时,直接 import 了 @qdrant/js-client-rest,本地运行完全没问题——因为 npm 的扁平结构让它"看得见"这个包。

但 CI/CD 环境用的是 pnpm,严格的依赖结构直接暴露了这个幽灵依赖。

怎么解决

1. 显式声明依赖

把代码中实际使用的间接依赖,显式添加到 package.json 中:

pnpm add @qdrant/js-client-rest
  {
    "dependencies": {
      "@langchain/qdrant": "^1.0.1",
+     "@qdrant/js-client-rest": "^1.17.0"
    }
  }

这是最根本的解决方案——你用了什么就声明什么,不依赖其他包的间接引入。

2. 清理并重装依赖

如果项目之前用 npm 装过包,node_modules 里可能残留着扁平结构下的幽灵依赖。需要彻底清理后用 pnpm 重装:

# 删除所有 node_modules
find . -name "node_modules" -type d -prune -exec rm -rf {} +

# 删除 lock 文件(如果有 package-lock.json)
find . -name "package-lock.json" -delete

# 用 pnpm 重新安装
pnpm install

重装后,pnpm 的严格结构会立刻暴露所有幽灵依赖——import 不到的包就是没声明的,一个个补上就行。

3. 让 AI Agent 统一使用 pnpm

问题的根源是 Agent 用了 npm。为了防止再犯,我让 Agent 写了一个全局 Skill,后续所有安装包的操作都强制使用 pnpm:

## 包管理器规则
- 本项目使用 pnpm 作为包管理器
- 安装依赖:pnpm add <package>
- 安装开发依赖:pnpm add -D <package>
- 全局禁止使用 npm install / yarn add
- Monorepo 中安装到指定包:pnpm --filter <package-name> add <dep>

这样 Agent 每次对话都会读取这条规则,不会再出现用错包管理器的问题。

如何检测幽灵依赖

除了等 pnpm 报错,还有更主动的检测方式:

pnpm 的 --strict-peer-dependencies

pnpm install --strict-peer-dependencies

安装时严格检查 peer dependencies,有冲突直接报错而不是静默跳过。

dpdm 工具

dpdm 可以扫描代码中的依赖引用,找出未声明的依赖:

npx dpdm src/index.ts

knip 工具

knip 可以检测未使用的依赖、未声明的依赖、以及各种死代码:

npx knip

总结

npm pnpm
依赖结构 扁平化,间接依赖提升到根目录 严格隔离,只能访问声明的依赖
幽灵依赖 本地不会报错,线上可能炸 开发阶段直接暴露
安装速度 较慢 快(硬链接 + 内容寻址)
磁盘占用 每个项目独立存储 全局存储,多项目共享

幽灵依赖的本质是依赖声明和实际使用不一致。npm 的扁平结构掩盖了这个问题,让它在本地"看起来没问题",但线上部署时就会暴露。pnpm 的严格结构在开发阶段就强制你声明所有使用的依赖,虽然前期多写几行 package.json,但换来的是部署时的安心。

如果你也在用 pnpm + Monorepo,建议:

  1. 永远不要混用 npm 和 pnpm——一旦用 npm 装过包,node_modules 结构就被污染了
  2. 代码中 import 了什么,package.json 就声明什么——不要依赖间接依赖
  3. 让 AI Agent 也遵守包管理器规则——写好项目规则文件,防止 Agent 用错工具
  4. CI/CD 用 pnpm 构建——线上构建和本地开发保持一致,问题在本地就能发现

本文基于 My-Notion 项目的真实踩坑经历撰写,项目是一个 AI 原生的个人版 Notion,欢迎 Star ⭐

❌