普通视图

发现新文章,点击刷新页面。
今天 — 2026年5月3日首页

Mobile 端 AI 请求真机调试:从"线上没日志"到四层问题定位

作者 Daybreak
2026年5月2日 15:29

同一个 Mobile 项目,expo start --web 跑得好好的,真机扫码后 AI 对话一直转圈,Vercel 线上日志一条都没有。请求根本没到服务端,但原因远不止"网络不通"这么简单。这篇文章从一次真机调试讲起,把 Vercel 路由冲突、Edge Runtime 识别、SSE 平台分流、环境变量管理、国内网络限制五个层面的问题一次性讲清楚。

1. 开篇:Web 能用,真机不行

我的项目是一个 AI 原生的类 Notion 应用,Web 端和 Mobile 端共享同一套 AI 请求逻辑。某天我在真机上测试 Mobile 端的 AI 对话功能,发送消息后一直转圈,最终走到 onError 回调。

切到 Web 端(expo start --web),同样的代码、同样的 AI 服务地址,一切正常。

更诡异的是——Vercel 线上日志里一条请求记录都没有。请求像凭空消失了一样。

"线上没日志"意味着两种可能:请求根本没到服务端,或者请求到了但没进入业务代码。 这个判断成了后续排查的分水岭。

2. 前景提要:项目的 AI 请求架构

在讲问题之前,先交代一下项目的 AI 请求链路,因为后面的每个问题都和这个架构有关。

2.1 Monorepo 结构

My-Notion/
├── apps/
│   ├── web/              # Next.js Web 应用
│   └── mobile/           # Expo React Native 应用
├── packages/
│   ├── ai/               # AI 核心逻辑(共享)
│   ├── business/         # 业务状态(共享)
│   └── convex/           # 数据库逻辑(共享)
└── services/
    └── ai/               # AI 网关(独立部署到 Vercel)
        ├── api/
        │   ├── chat.ts   # /api/chat 入口
        │   └── [[...route]].js  # catch-all 路由
        └── src/
            └── index.ts  # Hono 主应用

2.2 AI 请求链路

Mobile App
  └─ fetch("https://my-notion-ai.vercel.app/api/chat")
       └─ Vercel (services/ai)
            └─ DashScope (阿里云 AI 服务)

Mobile 端直接请求 services/ai 部署在 Vercel 上的 API,不经过 Web 端的 Next.js。这是因为 Expo React Native 不走 Next.js 的 API Route,需要独立的 AI 服务入口。

2.3 SSE 流式响应

AI 对话使用 SSE(Server-Sent Events)实现流式输出。但 React Native 对 ReadableStream 的支持不完整,需要按平台分流:

if (Platform.OS === "web") {
  // Web 端:ReadableStream 逐块读取,实现真正的流式
  const reader = response.body?.getReader();
  // ...
} else {
  // Native 端:response.text() 一次性读取
  const text = await response.text();
  processSSEBuffer(text + "\n", callbacks);
}

Web 端能实时看到 AI 逐字输出,Native 端则是等 DashScope 完全响应后一次性显示——不流式,但能用。

3. 第一层:Vercel 路由冲突——请求到了,但进不了业务代码

3.1 发现问题

services/ai/api/ 目录下有两个文件:

  • api/chat.ts — Hono 格式,声明了 export const runtime = "edge"export default app
  • api/[[...route]].js — Serverless catch-all,内容是:
const { handle } = require("@hono/node-server/vercel");
const app = require("../dist/services/ai/src/index.js").default;
module.exports = handle(app);

Vercel 的路由解析规则是:catch-all [[...route]] 会匹配所有 /api/* 请求,包括 /api/chat

这意味着,即使 chat.ts 声明了 export const runtime = "edge",Vercel 也不会把它当作独立的 Edge Function——因为 [[...route]].js 已经接管了 /api/chat 这个路由。

3.2 为什么 Web 端不受影响

Web 端有自己的 Next.js Route Handler 处理 /api/chat,根本不走 services/ai 的 Vercel 部署。所以 Web 端从来没触发过这个路由冲突。

3.3 catch-all 的问题

[[...route]].js 是 Node.js Serverless 函数,它 require("../dist/services/ai/src/index.js")。而 src/index.ts 使用了:

import "dotenv/config";
import { randomUUID } from "crypto";

这些是 Node.js 专用模块。在 Serverless Runtime 中:

  • 如果 dist/ 没有正确构建,require 直接失败 → 请求 500/502
  • 即使 dist/ 存在,Serverless 函数到 DashScope 国内节点的网络不稳定,可能超时

无论哪种情况,请求都不会进入 chat.ts 的业务代码,所以 Vercel 日志里看不到你的业务日志。

3.4 修复:删除 catch-all,改为原生 Edge Function

删除 api/[[...route]].js,让 api/chat.ts 作为独立 Edge Function 被 Vercel 识别。

同时将 api/chat.ts 从 Hono 格式改为 Vercel 原生 Edge Function 格式:

// 之前:Hono 格式
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono().basePath("/api");
app.post("/chat", async (c) => { ... });
export default app;

// 之后:Vercel 原生 Edge Function
export const runtime = "edge";
export async function POST(request: Request): Promise<Response> { ... }

关键区别:

Hono 格式 Vercel 原生格式
入口 export default app export async function POST
Runtime 识别 可能被 catch-all 劫持 Vercel 直接识别为 Edge Function
SSE 输出 streamSSE() (Hono API) new ReadableStream() (Web 标准)
CORS app.use("*", cors()) 手动处理 OPTIONS + 响应头

3.5 SSE 输出格式的变化

Hono 的 streamSSE 输出格式:

event: content
data: {"type":"content","text":"..."}

原生 ReadableStream 手动编码的格式:

event: content
data: {"type":"content","text":"..."}

格式完全一致——客户端的 processSSEBufferdata: 前缀解析,忽略 event: 行,解析 JSON 里的 type 字段来分发。客户端代码无需任何修改。

4. 第二层:环境变量管理——本地开发走线上还是走本地

4.1 问题

检查 .env 发现:

EXPO_PUBLIC_AI_SERVICE_URL=https://my-notion-ai.vercel.app

本地开发时 AI 请求直接打到 Vercel 线上服务,而不是本地 services/ai 源码。如果改了 AI 逻辑想验证,必须先推代码等 Vercel 部署——开发效率极低。

4.2 Expo 环境变量优先级

Expo 遵循 .env.local > .env.production > .env 的优先级。之前踩过的坑:

  • .env.local 覆盖 .env,导致本地开发走线上地址
  • .env.production--no-dev 模式下覆盖 .env
  • localhost 在真机上指向手机自身,必须用局域网 IP

4.3 修复:启动命令区分本地和线上

.env 保持线上域名作为默认值,通过启动命令行内覆盖为本地地址:

{
  "scripts": {
    "dev": "expo start",
    "dev:local": "EXPO_PUBLIC_AI_SERVICE_URL=http://localhost:3001 expo start",
    "dev:all": "concurrently \"pnpm run dev\" \"pnpm run dev:convex\"",
    "dev:all:local": "concurrently \"pnpm run dev:local\" \"pnpm run dev:convex\""
  }
}

Expo 的优先级是 process.env(行内设置)> .env 文件,所以 dev:local 的行内变量会覆盖 .env 的值。

命令 AI 地址 场景
pnpm dev https://my-notion-ai.vercel.app(读 .env) 默认走线上
pnpm dev:local http://localhost:3001(行内覆盖) 走本地 AI 源码

真机调试时把 localhost:3001 换成局域网 IP 即可。

4.4 EAS Build 的环境变量

.env.gitignore 中,EAS 云端构建时无法读取。EXPO_PUBLIC_ 变量必须在 eas.jsonenv 字段中显式声明:

{
  "build": {
    "preview": {
      "env": {
        "EXPO_PUBLIC_AI_SERVICE_URL": "https://my-notion-ai.vercel.app"
      }
    },
    "production": {
      "env": {
        "EXPO_PUBLIC_AI_SERVICE_URL": "https://my-notion-ai.vercel.app"
      }
    }
  }
}

5. 第三层:国内网络限制——.vercel.app 域名被拦截

5.1 真相大白

路由冲突修复后,重新部署 services/ai 到 Vercel,真机测试——还是不行。

仔细一想:我的手机没开代理

.vercel.app 域名在国内被 DNS 污染/网关拦截,请求根本出不去。这就是为什么 Vercel 线上日志一条都没有——请求从手机发出后,在网络层就被拦截了,根本没到 Vercel。

Web 端没问题是因为电脑开了代理。

5.2 这个问题的本质

这不是代码问题,而是基础设施问题。在国内使用 Vercel 部署的服务,移动端用户大概率会遇到:

  • .vercel.app 域名被 DNS 污染,解析失败
  • 即使解析成功,HTTPS 连接也可能被网关重置
  • 表现为 fetch 超时或 Network request failed,没有任何服务端日志

5.3 长期方案

方案 复杂度 效果
services/ai 绑自定义域名 + Cloudflare CDN 完全解决
eas.json 中指向国内可达的代理地址 部分解决
自建国内服务器部署 AI 服务 完全解决

当前阶段,开发测试时开手机代理即可。后续上线需要绑定自定义域名。

6. 第四层:SSE 平台分流——Web 和 Native 的 ReadableStream 差异

6.1 问题

React Native 对 ReadableStream 的支持不完整。Web 端 response.body.getReader() 正常工作,但 Native 端可能导致 SSE 流读取卡住,AI 请求一直转圈。

6.2 修复:按平台分流

async function parseSSEStream(
  response: Response,
  callbacks: StreamCallbacks,
): Promise<void> {
  if (Platform.OS === "web") {
    await parseSSEStreamWeb(response, callbacks);   // ReadableStream 逐块读取
  } else {
    await parseSSEStreamNative(response, callbacks); // response.text() 一次性读取
  }
}

Web 端保持流式体验,Native 端牺牲流式效果换取稳定性。等 React Native 对 ReadableStream 的支持完善后,可以统一为流式方案。

6.3 Native 端 SSE 解析的注意事项

response.text() 会等整个响应完成后才返回。这意味着:

  • Native 端 AI 请求会一直等到 DashScope 完全响应后才一次性显示
  • 用户看到的是"转圈 → 突然出现完整回复",而不是"逐字输出"
  • 如果 DashScope 响应时间较长,用户可能以为请求卡死了

这是当前方案的已知限制,后续可以通过引入 react-native-sse 等第三方库实现原生端的流式体验。

7. tsconfig 的隐藏坑:WebWorker lib

7.1 问题

api/chat.ts 改为 Vercel 原生 Edge Function 格式后,使用了 request.json()new Response() 等 Web 标准 API。但 tsconfig.jsonlib 只有 ["ES2022"],缺少 "WebWorker"

TypeScript 不认识 Edge 环境下的 RequestResponsecrypto.randomUUID() 等全局类型,编译报错。

7.2 修复

{
  "compilerOptions": {
    "lib": ["ES2022", "WebWorker"]
  },
  "include": [
    "api/**/*",
    "src/**/*",
    ...
  ]
}

WebWorker lib 提供了 Edge Runtime 环境下的类型定义。同时 include 中加入 "api/**/*",确保 api/chat.ts 被 TypeScript 编译器覆盖。

7.3 为什么之前没报错

之前 api/chat.ts 使用 Hono 格式,c.req.json() 是 Hono 的方法,类型由 Hono 自己提供。改成原生 request.json() 后,类型来源从 Hono 切换到了 Web 标准 API,才触发了这个问题。

8. vercel.json 的配套修改

8.1 之前

{
  "version": 2,
  "buildCommand": "pnpm build",
  "functions": {
    "api/[[...route]].js": {
      "memory": 1024,
      "maxDuration": 60
    }
  }
}

functions 配置的是已删除的 [[...route]].js,Edge Function 不需要在这里声明。

8.2 之后

{
  "buildCommand": "pnpm build"
}

Edge Function 由 Vercel 自动识别(通过 export const runtime = "edge" 声明),不需要在 vercel.json 中额外配置。

9. 完整改动清单

文件 改动 解决的问题
services/ai/api/chat.ts Hono 格式 → Vercel 原生 Edge Function 路由冲突 + Runtime 识别
services/ai/api/[[...route]].js 删除 消除 catch-all 路由劫持
services/ai/vercel.json 移除 Serverless 函数配置 配套 catch-all 删除
services/ai/tsconfig.json WebWorker lib + api include Edge 环境类型定义
apps/mobile/package.json dev:local / dev:all:local 命令 本地开发走本地 AI
apps/mobile/.env AI 地址保持线上域名 默认走线上,本地开发用命令覆盖

10. 排查方法论总结

这次调试涉及四个层面的问题,每个层面的排查思路不同:

层面 现象 排查方法 根因类型
路由层 线上无业务日志 检查 Vercel 路由文件是否冲突 架构设计
环境变量 本地开发走线上 检查 .env 优先级和实际值 配置管理
网络层 请求超时/无响应 确认客户端网络环境(代理/DNS) 基础设施
运行时 SSE 解析卡住 检查平台 API 兼容性 平台差异

关键经验:

  1. "线上没日志"不等于"请求没到服务端" — 也可能是请求到了但被错误的路由/函数吞掉了
  2. Web 能用不代表 Native 能用ReadableStream、CORS、网络环境都有平台差异
  3. 环境变量优先级是隐式规则.env.local 覆盖 .env 这种行为,不看文档根本想不到
  4. Vercel 的路由解析有优先级 — catch-all 会劫持具体路由,即使你声明了 export const runtime = "edge"
  5. 国内 + Vercel = 必须考虑网络可达性.vercel.app 域名在国内不可达是基础设施问题,不是代码 Bug

11. Edge Runtime vs Serverless Runtime

这次调试反复涉及 Vercel 的两种运行时,最后做一个对比:

Edge Runtime Serverless Runtime
运行环境 V8 isolate(类似 Cloudflare Workers) Node.js(AWS Lambda)
冷启动 < 1ms 数百 ms 到数秒
最大执行时间 30s(免费)/ 60s(Pro) 10s(默认)/ 60s(Pro)/ 300s(Enterprise)
网络稳定性 边缘节点,全球分布 集中式,受区域网络影响
Node.js API 不支持(无 fs、crypto 等) 完整支持
适合场景 AI 流式响应、API 代理、短请求 长耗时任务、需要 Node.js API 的场景

AI 对话场景选择 Edge Runtime 的原因:

  • DashScope 国内节点到 Vercel Serverless(AWS)的网络出口不稳定,偶发 10-20s 超时
  • Edge Runtime 的边缘节点(如 hkg1 香港)到国内网络更稳定
  • SSE 流式响应需要长连接,Edge Runtime 的冷启动更快

但 RAG 相关路由因为依赖 convex@langchain(使用 Node.js API),仍需保留在 Serverless Runtime。


本文基于 My-Notion 项目的真实调试经历撰写——一个 AI 原生的个人版 Notion,采用 pnpm workspace Monorepo 架构,Web + Mobile 双端。欢迎 Star ⭐

昨天 — 2026年5月2日首页

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

作者 Daybreak
2026年5月2日 07:55

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 ⭐

昨天以前首页

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

作者 Daybreak
2026年4月26日 16:41

这是我在开发 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 ⭐

❌
❌