一、整体实现思路
今天来写一写有关块级编辑器里面集成 AI SDK 的功能,初次接触,过程比较艰辛,以下是开发复盘。
- AI 功能实现的逻辑:
前端请求 → 路由分发 → 到达/api/documents/chat接口 → 交给 AI Controller 处理。Controller 从环境变量读取配置,调用阿里云大模型,再把流返回给前端。
给编辑器安装 AI 扩展,引入三个核心能力(都是原生开源库自带):
- AIExtension: 告诉编辑器"你有 AI 功能了"。
- DefaultChatTransport: 告诉 AI "请求往哪发"。
- 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 扩展的安装。
ts
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 判断是否为中文,不是则用英文。
ts
dictionary: {
...(lang === "zh" ? zh : en),
ai: lang === "zh" ? aiZh : aiEn,
}
后面是 Editor 部分,要禁用原生默认 UI,因为我们要用 Controller 手动挂载 AI 组件。
ts
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。
ts
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) 中间件一定要放在最前面,要匹配前端运行的端口,并且非常重要的是要把 credentials 和 exposedHeaders: ["x-vercel-ai-data-stream"] 响应头暴露出来,否则前端流式响应无法正常接收。
路由挂载一定要在后端服务监听端口之前完成。
后端服务配置的顺序很重要:
一定要把 dotenv 和 config 放在 import 最前面;然后是中间件配置;然后加日志打印,看前端请求地址是否正确;接着挂载路由、做健康检查,确认网络连通;再进行数据库连接;最后启动监听,看后端服务是否正常运行。
四、重点:AI对话路由与Controller核心逻辑
ts
router.post("/chat", handleAIChat);
在 document 相关的后端路由里,配置好新增的 AI 对话相关路由,RESTful API 用 POST,这里用到了 Express 框架的接口。路由用 POST 方法,因为 AI SDK 需要发送请求,接口路径命名为 chat,对应后面要写的 Controller。
新建一个 Controller:handleAIChat,专门处理 AI 相关操作。下列是伪代码提供思路。
ts
// 初始化阿里云 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。
ts
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):使用时有什么特殊注意事项?
cpp
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)
注意 getLatestDocumentState 和 extractKnownBlockIds 这两个函数。
ts
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;
};
ts
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):(之前提到的幻觉问题)
ts
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,但传入的格式可能有两种情况:
- 数组格式 :每个工具包含
name、description、inputSchema等字段 - 对象格式:键名为工具名称,值为工具定义
normalizeToolDefinitions 函数负责将这两种格式统一转换为 SerializableToolDefinition[] 数组:
ts
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 工具定义作为兜底:
ts
// BlockNote 会在请求体里附带 toolDefinitions,用于让模型输出可执行的文档操作。
// 若忽略这部分,模型通常只会返回普通文本,前端无法把它应用到编辑器。
const effectiveToolDefinitions =
toolDefinitions.length > 0 ? toolDefinitions : FALLBACK_TOOL_DEFINITIONS;
4. 流式协议对接 (Streaming Protocol)
最后的 result.pipeUIMessageStreamToResponse(res, ...) 是工程治理的关键。
ts
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:
cpp
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