万字长文:编辑器集成Vercel AI SDK

0 阅读6分钟

一、整体实现思路

今天来写一写有关块级编辑器里面集成 AI SDK 的功能,初次接触,过程比较艰辛,以下是开发复盘。

  • AI 功能实现的逻辑: 前端请求 → 路由分发 → 到达 /api/documents/chat 接口 → 交给 AI Controller 处理。Controller 从环境变量读取配置,调用阿里云大模型,再把流返回给前端。

给编辑器安装 AI 扩展,引入三个核心能力(都是原生开源库自带):

  1. AIExtension: 告诉编辑器“你有 AI 功能了”。
  2. DefaultChatTransport: 告诉 AI “请求往哪发”。
  3. AIMenuController: 在编辑器里显示 AI 的菜单。

比较复杂的请求逻辑在官方 @blocknote/xl-ai 里面已经封装好了,我只需要传入 URL 和必要的配置。

但光有逻辑不够,前端 UI 要有事件响应,所以要在 BlockNote 主题里注入控制器。

我参考官方示例,自定义 UI 布局,把 AI 功能放到菜单最后一栏,并且支持斜杠触发 AI 功能。

后端路由怎么处理 AI 请求?后端具体逻辑如何实现?这是本次开发的难点。

本质是接收前端 JSON 数据,转发给阿里云,再把阿里云的流式响应传回前端。引入 AI SDK 相关包,再通过环境变量配置 Provider,编写 POST 接口并注册路由,再做好 Controller 的处理。

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。

二、UI层:React slot 添加自定义AI扩展

首先修改的文件里,App.tsx 就是在根组件里创建 BlockNoteEditor 实例的时候,要引入 BlockNote 原生支持的 AI SDK 集成相关包,完成 AI 扩展的安装。

  const editor = useCreateBlockNote(
    {
      dictionary: {
        ...(lang === "zh" ? zh : en),
        ai: lang === "zh" ? aiZh : aiEn,
      },
      uploadFile,
      extensions: [
        AIExtension({
          transport: new DefaultChatTransport({
            api: "http://localhost:3001/api/documents/chat",
            body: {
              systemPrompt: aiDocumentFormats.html.systemPrompt,
            },
          }),
          streamToolsProvider: aiDocumentFormats.html.getStreamToolsProvider({
            defaultStreamTools: {
              add: true,
              update: true,
              delete: true,
            },
          }),
          documentStateBuilder:
            aiDocumentFormats.html.defaultDocumentStateBuilder,
        }),
      ],
    },
    [lang],
  );

这里注意初始化 Editor 实例时,AI 的英文/中文模式要自己维护,之前做了国际化,用 lang 判断是否为中文,不是则用英文。

 dictionary: {
        ...(lang === "zh" ? zh : en),
        ai: lang === "zh" ? aiZh : aiEn,
      }

后面是 Editor 部分,要禁用原生默认 UI,因为我们要用 Controller 手动挂载 AI 组件。

  return (
    <BlockNoteView
      editor={editor}
      theme={themeValue}
      onChange={handleSave}
      // 禁用默认 UI因为我们要用 Controller 手动挂载 AI 零件
      formattingToolbar={false}
      slashMenu={false}
    >
      {/* A. AI 核心控制器(必放) */}
      <AIMenuController />

      {/* B. 自定义格式化工具栏:把 AI 按钮塞进去 */}
      <FormattingToolbarController
        formattingToolbar={() => (
          <FormattingToolbar>
            {...getFormattingToolbarItems()}
            <AIToolbarButton />
          </FormattingToolbar>
        )}
      />

      {/* C. 自定义斜杠菜单:把 AI 选项合并进去 */}
      <SuggestionMenuController
        triggerCharacter="/"
        getItems={async (query) =>
          filterSuggestionItems(
            [
              ...getDefaultReactSlashMenuItems(editor),
              ...getAISlashMenuItems(editor),
            ],
            query,
          )
        }
      />
    </BlockNoteView>
  );

所以这里又引入了 BlockNote AI 相关的 UI 库,把一些 AI 相关组件导入进来,比如 AIMenuController(AI 核心控制器),自定义格式化工具栏,把 AI 按钮加进去。

这里用到了一个对象展开语法,有两种作用。

  • 数组合并(对于数组展开是顺序相加): 在 getItems 函数中,[...getDefaultReactSlashMenuItems(editor), ...getAISlashMenuItems(editor)]

  • 属性透传(对于对象展开是键值对拷贝): 在 <AIToolbarButton /> 上方的 {...getFormattingToolbarItems()}

getFormattingToolbarItems() 执行后,返回的是一堆 React 元素(默认的加粗、斜体按钮等)。... 把这些元素从数组里“倒出来”。<AIToolbarButton /> 是我们自己塞进去的新元素。

另外在 SuggestionMenuController 里增加斜杠触发逻辑,把 AI 选项合并进去。

这就是 UI 层的改动,用到了 React组件插槽(Slot),支持自行深度定制和后续扩展。

三、后端服务与路由配置

服务端,也就是后端服务里,全局的 index.ts 里要配置好 dotenv 和 config,目的是解析当前环境变量,能读取到 API Key。

import "dotenv/config";
import express from "express";
import type { Request, Response } from "express";
import mongoose from "mongoose";
import cors from "cors";
import documentRoutes from "./routes/documentRoutes.js";

const app = express();
const PORT = process.env.PORT || 3001;

// 1. 中间件配置 (必须在前)
app.use(cors({
  origin: "http://localhost:5173", // 前端 Vite 的端口
  credentials: true,
  exposedHeaders: ["x-vercel-ai-data-stream"]
}));

app.use(express.json());

// 2. 【新增】全路径日志雷达:如果这个没打印,说明前端没发对地址
app.use((req, res, next) => {
  console.log(`📡 [Incoming] ${req.method} ${req.url}`);
  next();
});

// 3. 路由挂载 (必须在 listen 之前!)
app.use("/api/documents", documentRoutes);

// 4. 健康检查
app.get("/health", (req: Request, res: Response) => {
  res.json({ status: "ok" });
});

// 5. 数据库连接
mongoose
  .connect(process.env.MONGODB_URI!)
  .then(() => console.log("✅ MongoDB connected successfully"))
  .catch((err) => console.error("❌ MongoDB connection error:", err));

// 6. 最后启动监听
app.listen(PORT, () => {
  console.log(`🚀 Server is running at http://localhost:${PORT}`);
});

所有配置里,app.use(cors) 中间件一定要放在最前面,要匹配前端运行的端口,并且非常重要的是要把 credentialsexposedHeaders: ["x-vercel-ai-data-stream"] 响应头暴露出来,否则前端流式响应无法正常接收。

路由挂载一定要在后端服务监听端口之前完成。

后端服务配置的顺序很重要:

一定要把 dotenv 和 config 放在 import 最前面;然后是中间件配置;然后加日志打印,看前端请求地址是否正确;接着挂载路由、做健康检查,确认网络连通;再进行数据库连接;最后启动监听,看后端服务是否正常运行。

四、重点:AI对话路由与Controller核心逻辑

router.post("/chat", handleAIChat);

在 document 相关的后端路由里,配置好新增的 AI 对话相关路由,RESTful API 用 POST,这里用到了 Express 框架的接口。路由用 POST 方法,因为 AI SDK 需要发送请求,接口路径命名为 chat,对应后面要写的 Controller。

新建一个 Controller:handleAIChat,专门处理 AI 相关操作。下列是伪代码提供思路。

// 初始化阿里云 OpenAI 客户端
aliyun = createOpenAI(apiKey, baseURL)

// 主处理函数
handleAIChat(req, res):
  // 禁用 socket 超时,避免长连接被断开
  req.socket.setTimeout(0)
  
  // 1. 解析请求数据
  messages = 从 req.body 提取 messages
  toolDefs = normalizeToolDefinitions(req.body.toolDefinitions)  // 规范化工具定义
  systemPrompt = 提取系统提示词(req.body.systemPrompt)
  
  // 2. 获取文档状态(用于 ID 约束)
  docState = 从消息历史中提取最新的 documentState
  knownIds = 从 docState 中提取所有有效的块 ID
  
  // 3. 转换消息格式(适配 AI SDK 的消息结构)
  modelMessages = convertToModelMessages(messages)
  
  // 4. 准备工具集
  if 无有效工具定义:
    使用 fallback 工具定义 (applyDocumentOperations)
  
  构建工具映射:
    遍历每个工具定义
      创建 AI SDK tool 对象
      使用 jsonSchema 包装 inputSchema
  
  // 5. 构建系统提示词(合并三部分)
  finalPrompt = 合并:
    - 用户提供的系统提示词 (或默认提示)
    - ID 约束提示词 (限制只能使用 knownIds)
    - 兜底规则提示词
  
  // 6. 执行流式调用
  result = streamText({
    model: aliyun(模型名称),
    system: finalPrompt,
    messages: modelMessages,
    tools: 工具映射 (如果有),
    toolChoice: "required" (如果有工具),  // 强制要求模型调用工具
    maxSteps: 1,  // 限制工具调用轮数
    onStepFinish: 记录工具调用日志
  })
  
  // 7. 返回 UI 消息流
  result.pipeUIMessageStreamToResponse(res, {
    设置响应头: 禁用缓存, 保持连接
  })
  
  // 8. 错误处理
  捕获异常:
    记录错误
    返回 500 状态码

五、开发中遇到的关键问题与调试经验

1. 前后端协议不匹配

之前做的时候有两个比较大的问题:一个是相关类型定义没有被识别,另一个是前端 BlockNote JSON 格式没有被正确解析。

因为 BlockNote 底层编辑器基于 ProseMirror,数据格式是 JSON;而大模型默认输出 Markdown,不能直接写入编辑器。所以前后端协议不匹配的话,就会出现后端实际调用成功,但结果无法在前端应用,模型文本无法生效,达不到 AI 润色的效果。

Message 流式输出采用的是 SDK 官方 UI 协议,和前端 defaultChatTransport 保持一致,避免手写流协议导致前端不渲染。

2. 对AI幻觉的警惕

关于包导入的问题:有时因为版本更新,AI 给出的导入建议会出现“幻觉”。解决办法是 Ctrl+点击进入包源码,看实际暴露了哪些可导入方法,而不是靠猜测。

这是一个小细节,能解决很多依赖导入错误。官方示例不一定是最新版本,AI 回答也不一定准确,要自己判断。

就是因为 AI 幻觉,我之前踩了个坑:它说原生不支持流式处理,对策是手写流处理,结果出问题了...原因就是手写旧版流协议,和官方 SDK 响应不兼容;要用官方推荐的 pipeUIMessageStreamToResponse 协议,细节不匹配就不会渲染文字。

依旧强调,不要完全信 AI,不要完全信 AI,不要完全信 AI。

  • 就以配置为例,要以自己项目的实际前后端端口为准。
  • 自己去检查 Network 面板,看 F12 里 chat 请求的 Response 是否有返回:是数组、不符合预期的 JSON,还是完全没返回,以此判断大模型是否接通。
  • 调试要自己动手,用 Postman 测接口,而不是只会用自然语言描述问 AI 为什么不生效。

3. 工具调用缺失导致无法修改文档

重点还是要看懂 Controller 怎么实现,定位前端怎么消费流,检查后端返回格式和协议是否匹配。

但是 AI 扩展依旧没真正生效,是协议不支持,还是数据收到了但无法应用到文档?

根源是:后端没有把前端的编辑指令结构传给大模型,模型只返回纯文本,能看到流但不会修改文档。相当于只让模型“回答”,没给它“操作文档”的工具调用权限。

前端想改文档,但后端没把编辑指令透传给模型。所以不仅要文本流,还要工具调用流才能修改文档内容。

后端保留消息结构,强制透传给前端,让前端能应用变更;同时对照官方示例,不把 message 清洗成纯文本,避免丢失工具上下文。UI 消息结构本身就支持工具调用,不需要自己额外处理。

4. ID幻觉与解决方案

另外前后端格式要兼容,Schema 格式和 BlockNote 内部格式要一致。浏览器控制台会报相关错误。

ID 格式校验通过了,但 AI 试图更新一个编辑器里不存在的 block ID。

原因大概率是 AI 生成代码时产生幻觉,自己捏造了 ID。

之前用 fallback + 默认提示词的组合,容易让模型脱离当前文档状态。(接下来的部分会有详细的代码解析)

现在改成从 document state 里提取真实 ID,逻辑都放在 AI Controller 里,避免使用大模型幻觉出来的 ID。最后就正常了,要减少多余推理步骤,不然会引用不存在的块。

六、核心机制解析

1. Schema 约束与“工具化” (Function Calling)

这段代码最长的部分是 FALLBACK_TOOL_DEFINITIONS

const FALLBACK_TOOL_DEFINITIONS = [
  {
    name: "applyDocumentOperations",
    description:
      "Apply document operations to update the editor content. Use for add/update/delete style edits.",
    inputSchema: {
      type: "object",
      properties: {
        operations: {
          type: "array",
          items: {
            anyOf: [
              {
                type: "object",
                properties: {
                  type: { type: "string", enum: ["add"] },
                  referenceId: { type: "string" },
                  position: {
                    type: "string",
                    enum: ["before", "after", "nested"],
                  },
                  blocks: {
                    type: "array",
                    items: { type: "string" },
                    minItems: 1,
                  },
                },
                required: ["type", "referenceId", "position", "blocks"],
                additionalProperties: false,
              },
              {
                type: "object",
                properties: {
                  type: { type: "string", enum: ["update"] },
                  id: { type: "string" },
                  block: { type: "string" },
                },
                required: ["type", "id", "block"],
                additionalProperties: false,
              },
              {
                type: "object",
                properties: {
                  type: { type: "string", enum: ["delete"] },
                  id: { type: "string" },
                },
                required: ["type", "id"],
                additionalProperties: false,
              },
            ],
          },
        },
      },
      required: ["operations"],
      additionalProperties: false,
    },
  },
];
  • 本质:它不是在写逻辑,而是在定义一套 协议。它告诉模型:“你不能随便聊天,你必须调用 applyDocumentOperations 这个工具,并且参数必须符合 add/update/delete 这三种结构。”
  • 工程目的:将非结构化的 AI 文本变成结构化的 编辑器操作指令,也就是把 LLM 产生的结构化数据对齐 BlockNote 底层的 JSON 格式。

关于这里的 description,实际上就是提示词工程。之前听起来很高大上的东西没想到自己也通过 Vercel AI SDK 使用了。

description 的本质是 Prompt Engineering(提示词工程)的一部分。AI SDK 会把这个字符串发送给模型,模型通过阅读它来理解:

  • 场景(When):什么时候该用这个工具?
  • 能力(What):这个工具能改变文档的什么状态?
  • 约束(How):使用时有什么特殊注意事项?
description:
      "The primary engine for document manipulation. Use this tool whenever the user requests to create, modify, or remove content. \n" +
      "- 'add': Use to insert new blocks (text, headings, etc.) relative to an existing 'referenceId'. \n" +
      "- 'update': Use to change the content or internal data of an existing block. \n" +
      "- 'delete': Use to permanently remove a block by its ID. \n" +
      "STRICT RULES: 1. Only use block IDs provided in the latest 'documentState'. 2. Never guess or hallucinate IDs. 3. If an operation is ambiguous, prefer 'add' with a safe referenceId."

2. 上下文状态提取 (Context Extraction)

注意 getLatestDocumentStateextractKnownBlockIds 这两个函数。

const getLatestDocumentState = (
  uiMessages: any[],
): DocumentStateLike | null => {
  for (let i = uiMessages.length - 1; i >= 0; i--) {
    const candidate = uiMessages[i]?.metadata?.documentState;
    if (candidate && typeof candidate === "object") {
      return candidate as DocumentStateLike;
    }
  }
  return null;
};

const extractKnownBlockIds = (documentState: DocumentStateLike | null) => {
  if (!documentState) return [] as string[];

  const source = documentState.selection
    ? Array.isArray(documentState.selectedBlocks)
      ? documentState.selectedBlocks
      : []
    : Array.isArray(documentState.blocks)
      ? documentState.blocks
      : [];

  const ids = source
    .map((item: any) => item?.id)
    .filter((id: unknown): id is string => typeof id === "string");

  return Array.from(new Set(ids));
};
  • 逻辑流:后端从前端发来的 uiMessages(历史记录)的 metadata 中逆向查找最新的文档状态,进行上下文的状态管理。

  • ID 守卫 (ID Guard):(之前提到的幻觉问题)

const buildIdGuardPrompt = (knownIds: string[]) => {
  if (knownIds.length === 0) {
    return "Only reference ids that appear in the latest documentState. If no valid id is available for update/delete, prefer a single add operation using an existing referenceId from documentState.";
  }

  const cappedIds = knownIds.slice(0, 150);
  const idsText = cappedIds.join(", ");
  const truncatedNote =
    knownIds.length > cappedIds.length
      ? ` (truncated ${knownIds.length - cappedIds.length} more ids)`
      : "";

  return `STRICT ID GUARD: For update/delete.id and add.referenceId, you MUST use one exact id from this allowed set (including trailing '$' when present): [${idsText}]${truncatedNote}. Never invent new ids.`;
};

buildIdGuardPrompt 是非常老练的写法。它把当前编辑器里所有合法的 blockId 提取出来,塞进 System Prompt

  • 为什么要这么做?:AI 模型经常会“幻觉”出一些不存在的 ID。如果不给它一个“合法 ID 白名单”,它返回的 update 指令会因为找不到目标 ID 而在前端报错。

3. 标准化工具定义

这个函数的作用是将前端传来的工具定义格式统一化,确保后续处理时数据结构一致。

前端 BlockNote 组件在请求体中会附带 toolDefinitions,但传入的格式可能有两种情况:

  • 数组格式:每个工具包含 namedescriptioninputSchema 等字段
  • 对象格式:键名为工具名称,值为工具定义

normalizeToolDefinitions 函数负责将这两种格式统一转换为 SerializableToolDefinition[] 数组:

const normalizeToolDefinitions = (
  raw: unknown,
): SerializableToolDefinition[] => {
  // 情况1:输入是数组 → 过滤有效项,提取 name、description、inputSchema
  if (Array.isArray(raw)) {
    return raw
      .filter((item): item is SerializableToolDefinition => {
        return (
          !!item &&
          typeof item === "object" &&
          typeof (item as any).name === "string"
        );
      })
      .map((item) => {
        const normalized: SerializableToolDefinition = {
          name: item.name,
          ...(typeof item.description === "string"
            ? { description: item.description }
            : {}),
          ...(item.inputSchema && typeof item.inputSchema === "object"
            ? { inputSchema: item.inputSchema as Record<string, unknown> }
            : {}),
        };
        return normalized;
      });
  }

  // 情况2:输入是对象 → 将键名作为 name,转换每个属性
  if (!!raw && typeof raw === "object") {
    return Object.entries(raw as Record<string, any>)
      .filter(([, def]) => !!def && typeof def === "object")
      .map(([name, def]) => {
        const normalized: SerializableToolDefinition = {
          name,
          ...(typeof def.description === "string"
            ? { description: def.description }
            : {}),
          ...(def.inputSchema && typeof def.inputSchema === "object"
            ? { inputSchema: def.inputSchema as Record<string, unknown> }
            : {}),
        };
        return normalized;
      });
  }

  // 情况3:其他输入 → 返回空数组
  return [];
};

为什么需要这个函数?

BlockNote 会在请求体里附带 toolDefinitions,目的是让模型能够输出可执行的文档操作指令(如 add/update/delete)。如果不处理这部分,模型通常只会返回普通文本,前端无法将其应用到编辑器。

因此,在后续逻辑中会判断:如果前端传入了有效的工具定义,就使用它们;否则使用 fallback 工具定义作为兜底:

    // BlockNote 会在请求体里附带 toolDefinitions,用于让模型输出可执行的文档操作。
    // 若忽略这部分,模型通常只会返回普通文本,前端无法把它应用到编辑器。
    const effectiveToolDefinitions =
      toolDefinitions.length > 0 ? toolDefinitions : FALLBACK_TOOL_DEFINITIONS;

4. 流式协议对接 (Streaming Protocol)

最后的 result.pipeUIMessageStreamToResponse(res, ...) 是工程治理的关键。

    result.pipeUIMessageStreamToResponse(res, {
      headers: {
        "Cache-Control": "no-cache",
        Connection: "keep-alive",
        "X-Accel-Buffering": "no",
      },
    });
  • 协议一致性:这里使用了 Vercel AI SDK 的标准流格式,确保前端能正确解析流式响应。
  • 运维配置:注意 req.socket.setTimeout(0)(防止长连接超时断开)和 X-Accel-Buffering: "no"(禁用 Nginx 等反向代理的缓冲,保证流式数据实时输出)。

七、对Vercel AI SDK的理解与后续调整

1. 深入理解 Vercel AI SDK

这段代码深度依赖了 ai 包(Vercel AI SDK)。

Vercel AI SDK Core - streamText

2. maxSteps调整后的性能问题与官方文档解读

一开始 maxSteps 直接限制的是 1,现在我改成了 10:

    const result = streamText({
      model: aliyun(process.env.ALIBABA_CLOUD_MODEL_NAME || "qwen-plus"),
      system: finalSystemPrompt,
      messages: modelMessages,
      ...(tools && Object.keys(tools).length > 0 ? { tools } : {}),
      ...(tools && Object.keys(tools).length > 0
        ? { toolChoice: "required" as const, maxSteps: 10 }
        : {}),
      onStepFinish: ({ toolCalls }) => {
        if (toolCalls?.length) {
          console.log(
            `🛠️ step toolCalls: ${toolCalls.map((t) => t.toolName).join(", ")}`,
          );
        }
      },
    });

一开始我设置 maxSteps: 1,后来好奇调成 10,结果响应变得异常慢。

查阅 Vercel AI SDK 文档后发现:maxSteps 的含义是允许模型在单次对话中调用工具的最大轮数。设置成 10 意味着模型可以“思考 → 调用工具 → 获取结果 → 再思考”循环最多 10 轮。

在我的场景下,AI 只需要一次工具调用就能完成文档操作(因为操作指令已经通过 Schema 明确定义好了),所以 maxSteps: 1 是合理的。调成 10 后,模型会进行多轮“确认-修正”的冗余推理,导致响应变慢。

注意:我最初误引用了 useChat 的文档(该 Hook 的 maxSteps 确实已被移除),但服务端的 streamText 依然支持此参数,且行为符合预期。

  • maxSteps Removal 最大步骤移除

The maxSteps parameter has been removed from useChat. You should now use server-side stopWhen conditions for multi-step tool execution control, and manually submit tool results and trigger new messages for client-side tool calls. useChat 中已移除 maxSteps 参数。现在,您应该使用服务器端的 stopWhen 条件来控制多步骤工具的执行,并手动提交工具结果,以及为客户端工具调用触发新消息。

maxSteps 的含义是允许模型在单次对话中调用工具的最大轮数。比如设置成 5,意味着模型可以“思考 → 调用工具 → 获取结果 → 再思考 → 再调用工具”循环最多 5 轮。

在当前编辑器场景下,AI 只需要一次工具调用就能完成文档操作(因为操作指令已经通过 Schema 明确定义好了),所以原本设 1 是合理的。调成 10 后,模型可能会进行多轮“确认-修正”的冗余推理,导致响应变慢。

关于多轮对话反复验证修改还没做,有关后端 token 的内容也还没做,后面学一下 Vercel AI SDK,也去学一下 LangChain 看看这里怎么更智能一些。

八、小结

有关ai项目的初次尝试,虽然很多还停留在调用api,接数据协议,调用库,但是其实功能实现的思路并不神秘,一些名词投入到实际开发中也并不神秘。

对我而言,从调大模型api key 都是第一次投入项目的ai项目小白,这个深度集成ai sdk 的功能是一次新的探索,我需要更多的学习,更多的实践......

目前项目还有ai集成上很多的不足,譬如Controller 过重,后续如果要扩展是灾难,这个我会研究一下怎么做,现在仅仅是可以跑了而已,以及多轮对话,更多功能的prompt。

坚持学习,做好反思,主要还是后端逻辑,后端的内容要补充学习。

限于个人经验,文中若有疏漏,还请不吝赐教。

参考文档

  • Function.prototype.apply() - JavaScript | MDN
  • 展开语法(...) - JavaScript | MDN
  • React 插槽(Slot)完全指南:从基础到实战的灵活组件通信方案在组件化开发中,插槽(Slot)是实现组件内容分发与 - 掘金
  • AI SDK Core: Generating Text