把 BlockNote 的 AI 接上地端 Gemma:一场跟 Vercel AI SDK tool-calling 格式的硬仗
zh-CN · 位元築夢 · 2026-05-28
最近在做一个纯离线的桌面笔记 App,用 BlockNote 当编辑器核心。BlockNote 有一个官方 AI 套件 @blocknote/xl-ai,可以做「改善写作 / 简化 / 修正拼写 / 翻译 / 续写」这类选文 AI,还附带很漂亮的 inline diff(删除线+highlight 的就地对比)跟 Accept/Reject。官网 demo 看起来爽到不行。
我的需求很单纯:把这套 AI 接上我们地端跑的 Gemma 3 27B 模型(离线、不走云端 API)。想说官方都做好了,接个 endpoint 应该十分钟搞定吧?
结果,这又是一场排查恶梦的开始。
(其实这功能不接也不会死,笔记照样能用。只是我看 xl-ai 那个 diff 动画实在太香,就想说一定要弄出来。)
先搞懂 xl-ai 是怎么接后端的
第一步当然是查用法。翻了 xl-ai 的文件才发现,它整套 AI 流程是建在 Vercel AI SDK 上面的。它对外的接点是一个 transport——你可以塞自己的 transport 来决定「请求怎么送到 LLM 后端」。
文件给的标准作法,是用 Vercel AI SDK 的 transport 搭一个 createOpenAICompatible adapter,指向你自己的 OpenAI-compatible endpoint,再丢给 AIExtension:
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { AIExtension, createBlockNoteAIClient } from "@blocknote/xl-ai"
// 把 model 指到我们地端的 OpenAI-compatible chat completions
const client = createBlockNoteAIClient({ baseURL: "http://localhost:7501/v1", apiKey: "x" })
const model = createOpenAICompatible({ name: "local", fetch: client.fetch })
.chatModel("google/gemma-3-27b-it")
// 编辑器挂上 AIExtension,transport 走预设(内部会用 model 发 tool-calling 请求)
useCreateBlockNote({ extensions: [AIExtension({ model })] })
看起来很合理。我们后端刚好有一条 OpenAI-compatible 的 chat completions,接上去就好了吧。
于是我就一头撞上了第一道墙。
xl-ai 预设你的 LLM 会 tool-calling
把 transport 接上去之后,AI 完全不动,或是直接报内部错。深挖才搞懂 xl-ai 那条 pipeline 的本质:
transport → chat.sendMessage → tool-input-delta → parsePartialJson → operation
xl-ai 给 LLM 的不是「请改写这段文字」,而是一个叫 applyDocumentOperations 的 function calling 工具,输入是一个 operations 阵列,每个 op 形如 { type: "update"|"add"|"delete", id, block: <html> }。它期待 LLM 用 tool-calling 的格式、边生成边吐这个 tool 的 input JSON delta(一个字一个字补上),xl-ai 再用 parsePartialJson 边收边解那串还没完成的 JSON、逐步套用。
也就是说,xl-ai 假设你后面接的是一个会乖乖做 tool-calling 的云端大模型(GPT-4、Claude 那种)。
而我们地端的 Gemma 根本不稳定 tool-calling,它只会吐纯文字。 硬把 transport 接上去,等于要 xl-ai 拿纯文字去跑 parsePartialJson,直接撞它内部对「同一个 op 多次 yield、partial → complete 转换」的 race condition。我试了两条路:
- buffer 模式(等整段收完再一次送)
- append-only streaming(边收边往后append)
两条都挂,经典报错是 No matching function for add。
问题的本质就是格式对不上——xl-ai 是用 AI SDK 的 tool-input-delta 一个字一个字收 tool 的 input JSON,组起来像这样:
// xl-ai 期待的:applyDocumentOperations 的 input,partial → 逐字补完
// (实际 SSE 一行一个 tool-input-delta,inputTextDelta 累积这串 JSON)
{ "operations": [{ "type": "update", "id": "blk-1$", "block": "<p>改写中…</p>" }] }
顺带一提:每个
id后缀$是 xl-ai 的idsSuffixed: true惯例,告诉模型「这是 id 不是文字」。
但 Gemma 回的是这个:
改写中的内容。
纯文字。parsePartialJson 拿这个去解只会解出一堆 undefined,加上 partial→complete 的 race,于是 No matching function for add。
结论很清楚:xl-ai 要结构化 tool call,Gemma 只给纯文字,transport 这条路在地端模型上是死的。
那自己写个 ChatTransport 假装会 tool-call 呢?也死
既然 Gemma 不会 tool-call,那我能不能在前端写一个自家 ChatTransport、自己把纯文字包装成 xl-ai 要的 tool call 格式?建了个 lib/blocknote-ai-transport.ts 试这条:
// 大意:实作 Vercel AI SDK 的 ChatTransport,内部呼叫我们既有的 /chat/stream(纯文字),
// 收完整段、再以 applyDocumentOperations tool call 的形式吐给 xl-ai。
class BlocknoteAITransport implements ChatTransport<UIMessage> {
async *sendMessages({ messages, body }) {
// 1. 呼叫纯文字 endpoint,buffer 起整段输出
let buf = ""
for await (const delta of chatStream({ userPrompt: build(messages) })) {
buf += delta
// (中间可以顺便在 toolbar 推「打字机」假动画提供视觉回馈)
}
// 2. buffer 完,组成 xl-ai 期待的 applyDocumentOperations tool call 一次吐
const toolCallId = crypto.randomUUID()
yield { type: "tool-input-start", toolCallId, toolName: "applyDocumentOperations" }
yield { type: "tool-input-delta", toolCallId, inputTextDelta: JSON.stringify({
operations: parseTextIntoOps(buf), // 自己把纯文字切段组成 operations 阵列
})}
yield { type: "tool-input-available", toolCallId }
yield { type: "finish" }
}
}
这条干净——parsePartialJson 收到的是正规 JSON、不会撞 race,xl-ai 完全当一般 tool-call 处理。但代价是 by design 就是假流式:我得 buffer 完整段 Gemma 输出才能组 tool call、才能 emit;长回应一打就是十几二十秒空白,使用者只能对着没反应的编辑器发呆。toolbar 补打字机特效也只是哄人,editor 本体还是死的。
结论:干净但慢,达不到真流式。放弃这条,继续找有没有「不用 buffer 又能绕过 tool-call」的路。
为什么不干脆强制 Gemma 在后端吐 JSON?
写到这里可能有人会想:Ollama、vLLM、llama.cpp 不是早就支援 JSON Mode / grammar-constrained decoding 了吗(format: "json" / guided_json / GBNF grammar)?直接在后端把 Gemma 约束成输出 xl-ai 要的 tool-call JSON 不就好了?回到第一条路。
我认真考虑过,但有几个现实理由把它挡下来:
- HTML 在 JSON 字串里很脆弱:每个 op 的
block是 HTML 字串,模型要正确 escape"、平衡<p>...</p>、处理 Unicode 跳脱。grammar 只能保证 JSON 是合法 JSON,管不到字串里 HTML 的正确性。Gemma 3 27B 对 HTML 本来就普通,常见的问题是 tag 缺角或 attribute 被吃掉,grammar 救不了这层。 - Token 成本与生成速度:xl-ai 的 schema 包了一层
applyDocumentOperations.operations[].{type,id,block},光鹰架 token 就一堆,加上 JSON escape 额外的字,地端推论时间明显被拉长——对「希望使用者按下去马上看到字冒出来」的 UX 体感是负分。 - 就算过了,还是会撞同一面墙:看后面「同场加映」那段——就算 cloud 大模型用对 tool-call 格式,长文 inline diff 一样不漂亮。强制 JSON 只能解决「parser 不爆」,解不掉 diff 逻辑本身对结构乱掉的长文的天花板。
- op-forwarder 反而更干净可控:后端拿纯文字、自己 deterministic 组 op,没有 JSON parse / escape 风险,测试与 debug 都简单(这次很多 bug 就是靠后端
print+ 重放 HAR 找出来的)。
简单说:强制 JSON 是把 race / format 的责任甩给模型,Gemma 不一定接得住;就算接住,UX 还是被同样的天花板挡下来。倒不如让后端当「翻译」做这件事,又便宜又可控。
官方藏了一个 manual-execution 逃生口
不想放弃那个 diff 效果,就回头去翻 xl-ai 的 examples。一路 web fetch 翻到这份官方范例的原始码:
05-manual-execution——光名字就对了。把整份 App.tsx fetch 下来看,它示范两种完全 bypass transport / chat / parsePartialJson 的玩法:
executeOne(chunk):套用单一一个 block 修改(同步套完再延迟 accept)。- Streaming with objects:用
StreamToolExecutor,自己writer.write(operationObject)把 operation 物件一个个喂进去。
两种模式大概长这样:
import { aiDocumentFormats, StreamToolExecutor } from "@blocknote/xl-ai"
const provider = aiDocumentFormats.html.getStreamToolsProvider({ withDelays: true })
const tools = provider.getStreamTools(editor, selectionInfo)
const executor = new StreamToolExecutor(tools)
// 模式 1:executeOne — 一次套一个 op(同步改完再 accept)
await executor.executeOne({ type: "update", id: "blk-1", block: "<p>改好的</p>" })
// 模式 2:streaming with objects — 自己 write operation 物件
const writer = executor.writable.getWriter()
writer.write({ operation, isUpdateToPreviousOperation: false, isPossiblyPartial: true, metadata: {} })
// …边收边 write 多个 partial…
await writer.close()
范例还带到 getStreamToolsProvider({ withDelays, defaultStreamTools, selectionInfo }) 怎么产 streamTools。有个当下没注意、后来害我很惨的细节:官方范例对 partial / complete 的 write 都是直接 call、没 await。 这个 fire-and-forget 是真流式的关键,先记着(坑就在后面)。
关键领悟:StreamToolExecutor 才是真正把 operation 套到 ProseMirror + suggest-changes plugin 上的执行端,它跟 chat / transport 完全解耦。 我根本不需要让 Gemma 讲 tool-calling——我只要自己组出 operation 物件,直接喂 executor 就好。diff、Accept/Reject 那些都是 executor + suggest-changes 在做的,照样有。
operation 物件长这样:
{
"operation": { "type": "update", "id": "<block-id>", "block": "<p>改写后的内容</p>" },
"isUpdateToPreviousOperation": true, // 这个 op 是不是延续/取代上一个
"isPossiblyPartial": true, // 内容还没生成完(串流中)
"metadata": {}
}
初版做出来了,但效果是假流式
照着 manual-execution 的路,第一版(也是第一个 commit 的版本)是这样做的:
- 前端覆写掉 xl-ai 的
invokeAI,自己呼叫后端的纯文字串流/chat/stream。 - 收到 Gemma 的纯文字后,前端自己用
\n\n段落边界把文字切块、组成update/add的 operation 物件。 - 再
writer.write喂给StreamToolExecutor,executor 即时套用、产生 diff。
还得手动补一件事。先说一下背景:BlockNote 底层是 ProseMirror,所有状态变更都透过一个叫 Transaction 的物件、经由 view.dispatchTransaction(tr) 进去才会套到文件上。 要让 AI 的变更被当成「建议」(删除线+highlight 的 diff mark、配上 Accept/Reject),而不是直接写死到文件上,就必须在这层拦截,把 transaction 的 insert/delete/replace step 改写成对应的 suggestion mark step。
xl-ai 注册了 @handlewithcare/prosemirror-suggest-changes plugin 但原生是自己手动插 diff mark 的;我们绕掉那条,所以得自己把 view 的 dispatchTransaction 包一层 withSuggestChanges,AI 的变更才会被当「建议」、才有删除线 highlight 跟 Accept/Reject:
import { withSuggestChanges } from "@handlewithcare/prosemirror-suggest-changes"
import { AIExtension } from "@blocknote/xl-ai"
export function installManualAiRunner({ editor }) {
// 1. 把 view 的 dispatch 永久包成 suggest-changes 版(AI 变更 → 建议 mark)
const view = editor.prosemirrorView
view.setProps({ dispatchTransaction: withSuggestChanges(view.props.dispatchTransaction) })
// 2. 覆写 AIExtension 的 invokeAI / abort,改走我们的 manual 流程
const aiExt = editor.getExtension(AIExtension)
aiExt.invokeAI = async (opts) => { await runManualAi({ editor, aiExt, opts }) }
aiExt.abort = () => { cancelRef.current?.(); aiExt.closeAIMenu(); return Promise.resolve() }
}
注意:这个版本从头到尾没有任何 tool-calling 格式。 是纯文字进来、前端手工组 op。
它「能动」,但效果是假流式——editor 不会即时跟着变,每次都要等 event done(整段回完)那一瞬间才一次把文字套上去,跟官网 demo 那种一个字一个字 reveal 的体感差很多。长回应的时候画面就是定格、最后啪一下全部出现,我看了一下觉得这跟「能动」差太远,必须处理。
后来才抓到假流式的根因:我犯了官方 example 没犯的错——用了 await writer.write(...)。
// ❌ 假流式:await 把每个 write 序列化,卡在同一条 microtask 链,全部挤到最后才套
for await (const op of ops) {
await writer.write(op)
}
// ✅ 真流式:fire-and-forget,partial op 进来就即时被 executor 套用(官方范例的写法)
onOp: (op) => {
void writer.write(op).catch((e) => console.warn("write op failed:", e))
}
官方范例对 partial / complete 的 write 都是直接 call、不 await(fire-and-forget),这样每个 partial op 会即时被 executor 套用;我多加了那个 await,等于把所有 write 卡在同一条链上,结果全部挤到最后才套 → 假流式。改成 void writer.write(op) 之后才开始有逐步套用的感觉。
但就算 write 不 await 了,「前端拿纯文字自己拼 operation」这层还是很脆:要自己抓 partial / 完整的时机、自己处理 block 边界、自己决定哪段对哪个 block……edge case 一堆。与其在前端硬拼,不如把这层搬到后端。
把组 op 这层搬到后端,模拟 tool call
既然前端拿纯文字拼 op 这层太脆,就换个分工:让后端来组 operation。 而且后端就在 Gemma 旁边,能边收 token、边吐 op,真流式比前端 buffer 完再拼自然多了。
我请后端新开一条 endpoint /chat/stream/op:它把 Gemma 的纯文字接过去,在后端就边收 delta、边组装成干净的 BlockNote operation 物件,用 SSE 吐回前端。前端就退化成一个很薄的 op-forwarder:收到一个 op 就 writer.write 喂 executor,完全不用自己解析文字。
chatStreamOp(req, {
onOp: (op) => { void writer.write(op) }, // 后端吐一个 op 就 forward
onDone: async () => { await writer.close(); await executor.finish() },
})
SSE 事件:status(模型载入状态)/ op(一个 operation)/ done / error。后端组 op 的核心就是把 Gemma 的 delta 包成 xl-ai 那个物件格式:
def _op_event(block_id: str, html: str, is_update: bool, partial: bool) -> str:
data = {
"operation": {"type": "update", "id": block_id, "block": html},
"isUpdateToPreviousOperation": is_update, # 第一个 op=False,后续=True(延续同一块)
"isPossiblyPartial": partial, # 串流中=True,定稿=False
"metadata": {},
}
return f"event: op\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
# 主回圈大意:边收 Gemma 的 delta、边把累积到目前的文字当成「成长中的 block」吐出去
async for line in resp.aiter_lines():
delta = parse_openai_delta(line)
cur_text += delta
yield _op_event(cur_id, f"<p>{cur_text}</p>", not is_first_op, partial=True)
is_first_op = False
这就是整篇标题那个「tool call」的真相——不是 Gemma 真的会 tool-calling,是后端假装成那个结构化格式(把纯文字翻译成 operation 流),喂给 xl-ai 原生的 executor。op 由后端构造所以干净、后端边生成边吐所以是真流式、走 executor 所以 diff / Accept-Reject 都原生支援。
理论上很美。实际上,接下来每个 edge case 都要一个一个踩。先给个全景,再逐个拆:
- 坑 1 —
No tool can handle update:xl-ai 预设 provider 把工具集绑定到命令(continue 只开add、improve 只开update…),跟我们「后端永远吐update」对不上。 - 坑 2 —
updatevsadd+delete:diff 策略选择——就地字级 diff 还是 整块取代,两种各有雷。 - 坑 3 —
\n\nvs\n:后端切段符跟 LLM 实际输出习惯不一致 → 全挤进第一个 block。 - 坑 4 — 段落数对不齐:Simplify 并段、Improve 扩段 → LLM 输出段落数 ≠ 选取 block 数。
- 坑 5 — 流式不顺:xl-ai 工具内每 50 字才套一次 + 我把
withDelays动画关了。
坑 1:executor 不认得后端吐的工具
接后端 op-stream 后第一个测 /ai(不选文、纯续写)就喷这个。
破案点:xl-ai 的 provider 是**「命令限定」的——续写命令只开 add 工具。但我们后端对所有命令都吐 update op**,executor 手上没有 update 工具就丢「没有工具能处理 update」。
解法:不要用 xl-ai 给的限定 provider,自己建一个全工具都开的:
getStreamToolsProvider({ defaultStreamTools: { add: true, update: true, delete: true } })
坑 2:要 update 还是 add + delete?
选文改写有两种做法,各有雷:
update就地改:xl-ai 会做字级 inline diff(原文删除线、新文 highlight,就地对比),这正是 demo 那个效果。但它在串流途中(isPossiblyPartial=true)很保守——不确定后续 token 会不会又对上原文,所以不敢先删原文尾巴,要等 op 收尾(isPossiblyPartial=false)才一次 commit 删除。表现出来就是「前面几句正常 diff、串到一半卡住、剩下一整段等done才全冒出来」——真流式接假流式。add+delete:把新内容当新 block 插入、再删原 block。稳、串流顺,但它是 block 级(新内容整块出现、旧的整块删),不是字级就地对比。
来回试了两种,最后选 update(要那个就地对比的 UX)。而 /ai 续写因为是 add 纯插入、没有「要不要删原文」的犹豫,反而一直很顺,就固定走 add 插在游标后。
坑 3:一个字元搞了我一下午(\n\n vs \n)
「卡住 + 后面一堆原文没动」这症状盯着截图猜很久,最后是靠重放 HAR 里的真实 payload 打后端、看它到底吐了什么 op 才破案。
测试:选 7 段文字(7 个 block)做 Improve writing。把浏览器存的 HAR 里那笔 request body 捞出来,原样重放打后端、统计每个 block 收到几个 op:
import json, httpx, collections
body = json.loads(open("payload.txt").readline()) # HAR 捞出的 request body
per = collections.Counter()
with httpx.stream("POST", "http://127.0.0.1:7502/chat/stream/op", json=body) as r:
ev = None
for line in r.iter_lines():
if line.startswith("event:"): ev = line[6:].strip()
elif line.startswith("data:") and ev == "op":
op = json.loads(line[5:])["operation"]
per[op["id"][:8]] += 1
print(per)
输出(修之前):
block 更新顺序: [('32671a2a', 425)] ← 425 个 op 全打在第 1 个 block
定稿的 block 数: 1 ← 其余 6 个 block 一个 op 都没收到
整段输出全挤进第一个 block,后面 6 个 block 维持原文。 原因蠢到不行:后端用空行 \n\n 切段换 block,但 Gemma 的输出段落是用单一 \n 隔开的(对齐输入格式),永远不会有 \n\n → 永远不换 block → 全挤 block[0]:
# 改之前:用 \n\n 切 → Gemma 输出没 \n\n → while 永远进不去 → 不换 block
while "\n\n" in pending:
head, pending = pending.split("\n\n", 1)
...
# 改之后:用单一 \n 切,每段对到下一个 selection_id;空行略过、到最后一块就并入
while "\n" in pending:
head, rest = pending.split("\n", 1)
head = head.strip()
if not head: # 空行 → 不换 block
pending = rest; continue
if block_idx >= len(req.selection_ids) - 1: # 最后一块:剩余全并入
pending = head + " " + rest; break
yield _op_event(cur_id, f"<p>{head}</p>", not is_first_op, partial=False) # 定稿本段
written_ids.add(cur_id)
block_idx += 1
cur_id = req.selection_ids[block_idx] # 换下一个选取 block
is_first_op = True
pending = rest
把切段从 \n\n 改成单一 \n,重放同一笔:
block 出现顺序: [7 个 id 全中]
定稿 block 数: 7 / 7
一个字元的差别,搞了一个下午。
坑 4:段落数对不齐,Simplify 偷偷并段
切段修好后,短文正常了,但长文 Simplify 还是「改一半、后面一段段被删」。
重放 21 段 Simplify:
输入 21 段、21 个 selection_ids
→ 只有 7 个 block 被 update,其余 14 个没对到
这次不是切段,是段落数本身对不齐:Simplify 把 21 段浓缩成 7 段输出,1-to-1 自然只对得到前 7 个 block,后面 14 个残留原文。
解法:收尾把「从头到尾没被 update 过」的 block 全删掉。 这里还有个 off-by-one——不能用「最后写到第几个 block」算尾段(Gemma 尾端常有多余 \n 害游标多进一格、漏删一个),改成实际记录写过哪些 block id(一个 set),收尾删不在 set 里的:
written_ids = set()
# ...每 yield 一个 update op 就 written_ids.add(cur_id)...
for sid in selection_ids:
if sid not in written_ids:
yield delete_event(sid) # 没被写过的一律删,避免残留原文
验证:21 个 selection、输出 3 段 → update 3 + delete 18 = 覆盖 21,零残留。
坑 5:「顺」其实是动画,不是真的逐 token
diff 对了,但我实测下来发现字是「一块一块跳出来」的,没有官网 demo 那种一个个冒的顺畅感。
原因:xl-ai 的 update/add 工具内部有节流——block 内容每长约 50 字才套用一次:
// xl-ai update tool 的 execute 内(节流大意)
let r = 50
return { execute: async (op) => {
if (op.isPossiblyPartial) {
const len = JSON.stringify(op.block).length
if (len < r) return // 还没长到门槛 → 跳过,不套
r = len + 50 // setpoint 往上 +50
} else { r = 50 } // 非 partial(定稿)重置门槛
// …算 diff、套 suggestion mark、选择性 await 动画延迟
for (const step of diffSteps) {
if (opts.withDelays) await sleep() // ★ withDelays:false 没这行 → 50 字一次硬套
applyStep(step)
}
}}
我又把 provider 设成 withDelays: false(想说后端已经在串流了,不要再加动画)。两个加起来就是 50 字一跳、很顿。
把 withDelays 改回 true,xl-ai 会在每个 diff step 之间补微小延迟,那 50 字的 chunk 就被逐字 reveal——视觉上就跟 demo 一样字一个个冒:
const provider = aiDocumentFormats.html.getStreamToolsProvider({
withDelays: true, // ★ 开动画,demo 预设也是 true
defaultStreamTools: { add: true, update: true, delete: true },
})
原来那个「顺」是动画,不是真的逐 token。
全部接起来长这样
绕了一大圈,最后长这样:
---
config:
layout: elk
theme: neutral
---
flowchart TB
User(["使用者 / BlockNote AIMenu"]) -- "选文 + 下命令" --> Invoke["invokeAI (被我们覆写)"]
Invoke -- "组 prompt + selection_ids/anchor_id" --> Service["chatStreamOp() 前端 service"]
Service -- "POST /chat/stream/op (SSE)" --> Backend["后端 op-stream endpoint"]
Backend -- "纯文字 prompt" --> Gemma["地端 Gemma (只吐纯文字)"]
Gemma -- "token 串流" --> Backend
Backend -- "边收边组装 operation\n(模拟 tool call)" --> Service
Service -- "writer.write(op)" --> Executor["xl-ai StreamToolExecutor"]
Executor -- "套用 op" --> Suggest["withSuggestChanges (diff mark)"]
Suggest --> Editor["编辑器:删除线+highlight 就地 diff"]
Editor -- "Accept / Reject" --> Done(["定稿 / 还原"])
classDef local fill:#f9f,stroke:#333,stroke-width:2px
classDef be fill:#ffcc00,stroke:#333,stroke-width:1px
Gemma:::local
Backend:::be
最终分工:
- 选文命令(fix-spelling / improve / translate)→ 后端吐
updateop,对每个选取 block 就地字级 diff;输出段落比选取少时,多的尾端 block 收尾删掉。 /ai续写(无选文)→ 后端吐addop 插在游标后,逐字串流。- 前端 provider 三工具全开、
withDelays: true、dispatchTransaction包withSuggestChanges。
对照一开始的死路(硬接 transport 要 Gemma tool-calling),核心转念是:不要逼地端模型去讲 xl-ai 的语言,而是在后端补一层 adapter 把纯文字翻成 operation,直接喂 executor。
被公司 MAC 挡住,只能一直重打包
中间有一段很痛苦:前端的改动没办法用 pnpm dev 在本机测,被公司端点管控(MAC)挡掉了。每改一次前端就得完整重打包一份桌面安装包(约 2 分钟)+重装才能验证,迭代慢到怀疑人生。
后来想了个 workaround:后端那只 Python 服务其实是独立的本机 sidecar(跑在 localhost:7501),最后是被编成 .pyd 包进安装包的——我没办法重编 .pyd,但可以用一个轻量 venv(只装 fastapi/httpx/opencc/tiktoken,完全不碰 torch)只把那条 op-stream 路由单独跑在 7502,再把前端打包时烤成指向 7502。这样后端逻辑就能即时改、即时重启测,不用每次重编 .pyd。重放 HAR 找切段 bug 也是靠这套才做得到。
(这种「接手别人基础设施、东西又被权限/工具挡住、只能用奇技淫巧绕」的感觉,跟上次搞 PostHog pipeline 简直一模一样。)
真正的天花板,其实是模型不够力
把所有程式问题修完之后,剩一个不是 bug、是模型能力的问题:
Gemma 3 27B 在长输入下会偷懒。 重放一笔「整份文件塞在同一个 block(1647 字)」的 Improve writing,比对输入输出:
输入 1647 字 → 输出 1472 字
输入/输出「完全相同的结尾」= 1044 字
也就是说,Gemma 只认真改写了开头约 600 字,后面 1044 字一字不改、原样 echo 回来。 diff 忠实显示出来就变成「前段有变更、后段完全没动」——我一开始也以为是 bug,挖出这个数据才确定不是程式问题,是模型自己摆烂。
两个衍生状况:
- 整份文件塞在单一 block:只有一个 block,后端无法切段,只能对着巨块反复算 diff,又慢、diff 又杂。
- 段落结构被模型打乱:prompt 明明写了「保持相同段落数」,Gemma 还是会合并段落,1-to-1 对映崩掉(靠坑 4 的删尾端兜住,但形状还是丑)。
根治方向是 per-block 逐块呼叫:选取 N 个 block 就发 N 次 LLM,每块只丢自己那段——每块都小、模型会完整改写、永远 1-to-1 不会对不齐。代价是 N 次呼叫、延迟变高,且要前后端一起改。这个留作下一阶段的 enhancement。
不只是 Gemma —— 官方 demo 在类似情境下也会炸
为了确认到底是不是我们地端模型的问题,我跑去 BlockNote 官方 demo 页 把同样那段(含先前 AI 留下的重复碎片)全选按 Simplify 试试看——结果一样会炸,diff 一片混乱、看不出在改什么。
捞了一下官方 demo 发出去的 request body,那个 tool definition 证实了单一 tool + operations 阵列的设计:
"toolDefinitions": {
"applyDocumentOperations": {
"inputSchema": {
"type": "object",
"properties": {
"operations": {
"type": "array",
"items": { "anyOf": [
{ "type": "object", "description": "Update a block", "properties": {
"type": { "enum": ["update"] },
"id": { "type": "string", "description": "id of block to update" },
"block":{ "type": "string", "description": "html of block (MUST be a single HTML element)" }
}, "required": ["type", "id", "block"] }
// ...add / delete 同样 schema
]}
}
},
"required": ["operations"]
}
}
}
input 的 partial 串流长这样(每行一个 tool-input-delta,inputTextDelta 一个字一个字补上):
{"type":"tool-input-delta","toolCallId":"call_xxx","inputTextDelta":"目"}
{"type":"tool-input-delta","toolCallId":"call_xxx","inputTextDelta":"的"}
{"type":"tool-input-delta","toolCallId":"call_xxx","inputTextDelta":"与"}
// ...一字一字累积出整个 operations JSON
{"type":"tool-input-available","toolCallId":"call_xxx","toolName":"applyDocumentOperations"}
{"type":"finish-step"} / {"type":"finish","finishReason":"tool-calls"}
而我提交的 selectedBlocks 内容本身就已经包含先前 AI 留下的重复(「并根据他们的并根据他们的意见对翻译系统进行改进他们的并根据他们的意…」),cloud 模型再对着这个乱摊子做 Simplify、产生的 diff 自然不会漂亮——这已经不是模型强弱的问题,而是 inline diff 对「结构性乱掉的长文」本身就很难看。
换句话说:我们踩到的 mapping 对不齐、diff 丑的问题,有一部分是 BlockNote 这套就地 diff 路线的天花板,不是地端 Gemma 独有的锅。 换成 OpenAI/Claude 也只能赢在「不会半段就 echo」,diff 在长文上一样会挣扎。
最后想说
这趟最大的体悟:官方套件的「美好 demo」背后通常假设了一个很强的后端(这里是会 tool-calling 的云端大模型)。一旦环境不符合那个假设(地端小模型、只吐纯文字),硬接它的 transport 只会撞一堆内部 race condition;正确姿势是找它的逃生口(manual-execution / StreamToolExecutor),自己补一层 adapter 把两边格式桥接起来——然后再一个一个踩它没帮你处理的 edge case:工具集限定、partial diff 的时机、切段符号、段落数对映、串流节流动画……
最后功能做出来了,短中等文字体验很不错。剩下的长文问题,老实说不是前端或后端能再榨出来的——那是模型本身的天花板。知道天花板在哪,也算是一种收获。