완전 오프라인 데스크톱 노트 App을 만들고 있고, 에디터 코어로 BlockNote를 씁니다. BlockNote에는 공식 AI 패키지 @blocknote/xl-ai가 있어서 「글 개선 / 간결화 / 맞춤법 수정 / 번역 / 이어쓰기」 같은 선택 AI를 할 수 있고, 게다가 아주 예쁜 (취소선+highlight의 제자리 대조)와 Accept/Reject가 딸려 옵니다. 공식 demo는 보기만 해도 짜릿합니다.

제 요구는 단순했습니다: 이 AI를 우리가 로컬에서 돌리는 Gemma 3 27B 모델에 연결하기(오프라인, 클라우드 API 안 씀). 공식이 다 만들어 뒀으니 endpoint 하나 붙이면 10분이면 끝나겠지?

결과는, 또 하나의 디버깅 악몽의 시작이었습니다.

먼저 xl-ai가 백엔드에 어떻게 붙는지 이해하기#

첫 단계는 당연히 사용법 조사. xl-ai 문서를 뒤져 보니, AI 흐름 전체가 Vercel AI SDK 위에 세워져 있다는 걸 알았습니다. 바깥으로 드러난 접점은 —자기 transport를 끼워 「요청을 LLM 백엔드로 어떻게 보낼지」를 정할 수 있습니다.

문서가 주는 표준 방식은, Vercel AI SDK transport로 createOpenAICompatible adapter를 짜서 자기 OpenAI-compatible endpoint를 가리키고 AIExtension에 넘기는 것:

ts
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의 본질을 알았습니다:

text
transport → chat.sendMessage → tool-input-delta → parsePartialJson → operation

xl-ai가 LLM에 주는 건 「이 글을 고쳐 써」가 아니라, applyDocumentOperations라는 이고, 입력은 operations 배열, 각 op는 { type: "update"|"add"|"delete", id, block: <html> } 형태입니다. LLM이 tool-calling 형식으로, 생성하면서 이 도구의 input JSON delta를(한 글자씩) 뱉고, xl-ai가 으로 그 미완성 JSON을 받으면서 풀어 점진 적용하기를 기대합니다.

즉 xl-ai는 뒤에 붙는 게 얌전히 tool-calling 하는 클라우드 대모델(GPT-4, Claude 류)이라고 가정합니다.

그런데 우리 로컬 Gemma는 tool-calling이 전혀 안정적이지 않고, 순수 텍스트만 뱉습니다. 억지로 transport를 붙이는 건 xl-ai에게 순수 텍스트로 parsePartialJson을 돌리게 하는 셈이라, 그 내부의 「같은 op를 여러 번 yield, partial → complete 전환」 관련 에 정면으로 박습니다. 두 길을 시도했습니다:

  • buffer 모드(다 받은 뒤 한 번에 보냄)
  • append-only streaming(받으면서 뒤로 append)

둘 다 죽고, 단골 에러는 No matching function for add.

문제의 본질은 포맷 불일치—xl-ai는 AI SDK의 tool-input-delta로 도구의 input JSON을 한 글자씩 받아, 조립하면 이렇게 됩니다:

jsonc
// xl-ai가 기대하는: applyDocumentOperations의 input, partial → 한 글자씩 채움
// (실제 SSE는 한 줄에 tool-input-delta 하나, inputTextDelta가 이 JSON을 누적)
{ "operations": [{ "type": "update", "id": "blk-1$", "block": "<p>고쳐 쓰는 중…</p>" }] }

NOTE

id 끝의 $는 xl-ai의 관례로, 「이건 id지 문자가 아니다」라고 모델에 알립니다.

하지만 Gemma가 돌려주는 건 이거:

text
고쳐 쓰는 중인 내용.

순수 텍스트. 이걸 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를 만들어 이 길을 시도했습니다:

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로 처리합니다. 하지만 대가는 설계상 이미 가짜 스트리밍: Gemma의 전 구간을 buffer 다 해야 tool call을 조립하고 emit할 수 있어서, 긴 응답이면 십수~이십 초 공백에 사용자는 반응 없는 에디터만 바라볼 뿐. toolbar 타자기 효과도 눈속임일 뿐, 에디터 본체는 죽어 있습니다.

결론: 깔끔하지만 느리다, 진짜 스트리밍엔 못 미친다. 이 길을 접고, 「buffer 안 하고도 tool-call을 우회하는」 길을 계속 찾습니다.

왜 아예 백엔드에서 Gemma에게 JSON을 뱉게 하지 않나?#

여기까지 쓰면 이렇게 생각할 수도: Ollama, vLLM, llama.cpp가 진작에 를 지원하지 않나(format: "json" / guided_json / GBNF grammar)? 백엔드에서 Gemma를 xl-ai가 원하는 tool-call JSON으로 제약하면 되잖아—첫 길로 회귀. 진지하게 고민했지만, 현실적 이유 몇 개가 그걸 막았습니다:

  1. JSON 문자열 속 HTML은 취약: 각 op의 block은 HTML 문자열이라, 모델이 "를 올바로 escape하고 <p>...</p>를 균형 맞추고 Unicode 이스케이프를 처리해야 합니다. grammar는 「JSON이 합법 JSON임」만 보장할 뿐, 문자열 속 HTML의 정확성은 못 챙깁니다. Gemma 3 27B는 HTML이 원래 그저 그렇고, tag가 깨지거나 attribute가 먹히는 게 단골이라 grammar가 이 층은 못 구합니다.
  2. 토큰 비용과 생성 속도: xl-ai의 schema는 applyDocumentOperations.operations[].{type,id,block}라는 한 겹을 씌워, 뼈대 토큰만 한가득에 JSON escape 여분 문자까지 더해져 로컬 추론 시간이 확 늘어납니다—「누르면 바로 글자가 나오길」 바라는 UX 체감엔 마이너스.
  3. 통과해도 같은 벽에 부딪힘: 뒤의 「특별 상영」 단락 참고—클라우드 대모델이 올바른 tool-call 형식을 써도 긴 글 inline diff는 여전히 안 예쁩니다. JSON 강제는 「parser가 안 터짐」만 풀 뿐, 구조가 무너진 긴 글에 대한 diff 로직 자체의 천장은 못 풉니다.
  4. op-forwarder가 오히려 더 깔끔하고 통제 가능: 백엔드가 순수 텍스트를 받아 스스로 deterministic하게 op를 조립, JSON parse / escape 위험 없음, 테스트·디버그 다 쉬움(이번 버그 상당수는 백엔드 print + HAR 재생으로 찾음).

요컨대: JSON 강제는 race / format 책임을 모델에 떠넘기는 행위라 Gemma가 받아낸다는 보장이 없고, 받아내도 UX는 같은 천장에 막힙니다. 차라리 백엔드를 「번역」 역으로 시키는 게 싸고 통제됩니다.

공식이 manual-execution이라는 비상구를 숨겨 뒀다#

그 diff 효과를 포기하기 싫어서 xl-ai의 examples를 다시 뒤졌습니다. web fetch를 따라가다 이 공식 예제의 소스에 닿았습니다:

예제

05-manual-execution—이름부터 딱. App.tsx를 통째로 fetch해 보니, transport / chat / parsePartialJson을 완전히 우회하는 두 방식을 보여 줍니다:

  1. executeOne(chunk): 단일 block 변경을 적용(동기로 적용 후 지연 accept).
  2. Streaming with objects: 를 써서 writer.write(operationObject)로 operation 객체를 하나씩 흘려 넣기.

두 모드는 대략 이렇습니다:

ts
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 — operation 객체를 직접 write
const writer = executor.writable.getWriter()
writer.write({ operation, isUpdateToPreviousOperation: false, isPossiblyPartial: true, metadata: {} })
// …받으면서 여러 partial을 write…
await writer.close()

예제는 getStreamToolsProvider({ withDelays, defaultStreamTools, selectionInfo })가 어떻게 streamTools를 만드는지도 짚습니다.

WARNING

그때 놓쳐서 나중에 나를 크게 고생시킨 디테일: 공식 예제는 partial / complete의 write를 전부 직접 call, await 안 함. 이 fire-and-forget이 진짜 스트리밍의 핵심—먼저 기억해 두세요(함정은 뒤에 옵니다).

핵심 깨달음: StreamToolExecutor야말로 operation을 + suggest-changes plugin에 실제로 적용하는 실행 끝이고, chat / transport와 완전히 분리돼 있다. Gemma에게 tool-calling을 시킬 필요가 전혀 없다—직접 operation 객체를 조립해 executor에 바로 먹이면 됩니다. diff, Accept/Reject 같은 건 executor + suggest-changes가 하니 그대로 딸려 옵니다.

operation 객체는 이렇게 생겼습니다:

jsonc
{
  "operation": { "type": "update", "id": "<block-id>", "block": "<p>고쳐 쓴 후의 내용</p>" },
  "isUpdateToPreviousOperation": true,   // 이 op가 이전 op의 연속/대체인지
  "isPossiblyPartial": true,             // 내용이 아직 다 생성 안 됨(스트리밍 중)
  "metadata": {}
}

초판은 됐다, 그런데 효과는 가짜 스트리밍#

manual-execution 길을 따라, 첫 버전(첫 commit 버전)은 이렇게 했습니다:

  • 프런트가 xl-ai의 invokeAI를 오버라이드하고, 백엔드의 순수 텍스트 스트림 /chat/stream을 직접 호출.
  • Gemma의 순수 텍스트를 받으면, 프런트가 스스로 \n\n 문단 경계로 텍스트를 잘라 update / add operation 객체로 조립.
  • 그걸 writer.writeStreamToolExecutor에 먹여, executor가 즉시 적용해 diff를 낸다.

거기에 손으로 하나 더 채워야 했습니다. 먼저 배경: BlockNote 밑바탕은 ProseMirror라, 모든 상태 변경은 이라는 객체를 view.dispatchTransaction(tr)로 통해야 문서에 적용됩니다. AI의 변경을 (문서에 바로 쓰지 않고) 「제안」(취소선+highlight의 diff mark, Accept/Reject 딸림)으로 취급하려면, 이 층에서 가로채 transaction의 insert/delete/replace step을 대응하는 suggestion mark step으로 바꿔야 합니다.

xl-ai는 plugin을 등록하지만 순정 구현은 스스로 수동으로 diff mark를 꽂는 방식; 우리는 그 길을 우회했으니, view의 dispatchTransactionwithSuggestChanges로 한 겹 직접 감싸야 합니다. 그래야 AI의 변경이 「제안」으로 취급돼 취소선 highlight와 Accept/Reject가 붙습니다:

ts
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를 조립.

「움직이긴」 하는데, 효과는 가짜 스트리밍—에디터가 즉시 따라 변하지 않고, 매번 event done(전 구간이 다 돌아온) 그 순간을 기다려 한 번에 텍스트를 적용, 공식 demo의 한 글자씩 reveal하는 체감과 딴판. 긴 응답이면 화면이 멈췄다가 마지막에 팍 다 나옵니다. 이건 「움직인다」와 너무 거리가 멀다 싶어 반드시 고치기로 했습니다.

나중에야 가짜 스트리밍의 근본 원인을 잡았습니다: 공식 example이 안 저지른 실수를 내가 저질렀다—await writer.write(...)를 썼다.

ts
// ❌ 가짜 스트리밍: 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인지 스스로 정하고… 엣지 케이스 천지. 프런트에서 억지로 조립하느니, 이 층을 백엔드로 옮기자.

op 조립 층을 백엔드로 옮겨 tool call을 흉내 내기#

프런트가 순수 텍스트로 op를 조립하는 층이 너무 취약하니, 역할 분담을 바꿉니다: op는 백엔드가 조립하게. 게다가 백엔드는 Gemma 바로 옆이라 token을 받으면서 op를 뱉을 수 있어, 진짜 스트리밍이 프런트가 buffer 다 하고 조립하는 것보다 훨씬 자연스럽습니다.

백엔드에 **새 endpoint /chat/stream/op**를 열어 달라 했습니다: Gemma의 순수 텍스트를 받아, 백엔드에서 delta를 받으면서 깔끔한 BlockNote operation 객체로 조립, 로 프런트에 뱉어 돌려줍니다. 프런트는 얇은 op-forwarder로 퇴화: op 하나 받으면 writer.write로 executor에 먹일 뿐, 텍스트 파싱은 전혀 불필요.

ts
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 객체 형식으로 감싸는 것:

python
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도 다 순정 지원.

이론상 아름답습니다. 실제론, 이 뒤의 엣지 케이스를 하나씩 밟게 됩니다. 먼저 전경, 그다음 하나씩 뜯기:

  • 함정 1 — No tool can handle update: xl-ai 기본 provider는 도구 집합을 커맨드에 묶음(continue는 add만, improve는 update만…), 우리 「백엔드는 항상 update를 뱉음」과 안 맞음.
  • 함정 2 — update vs add+delete: diff 전략 선택—글자급 제자리 diff덩이 통째 교체냐, 둘 다 지뢰 있음.
  • 함정 3 — \n\n vs \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를 쓰지 말고, 모든 도구를 연 것을 직접 만든다:

ts
getStreamToolsProvider({ defaultStreamTools: { add: true, update: true, delete: true } })

함정 2: updateadd + 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)#

「멈춤 + 뒤에 원문 잔뜩 안 변함」 이 증상은 스크린샷 보며 오래 추측했지만, 결국 속 진짜 payload를 백엔드에 때려, 실제로 어떤 op를 뱉었는지 보고서야 파훼했습니다.

테스트: 7 단락 텍스트(7개 block)를 골라 Improve writing. 브라우저 저장 HAR에서 그 request body를 꺼내, 원본 그대로 재생해 백엔드에 때리고, 각 block이 op를 몇 개 받았는지 집계:

python
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)

출력(수정 전):

text
block 업데이트 순서: [('32671a2a', 425)]   ← op 425개가 전부 1번째 block에
확정된 block 수: 1                          ← 나머지 6개 block은 op를 하나도 못 받음

전 출력이 첫 block에 몰리고, 뒤 6개 block은 원문 유지. 원인은 어이없을 만큼 단순: 백엔드는 **빈 줄 \n\n**로 단락을 잘라 block을 바꾸는데, Gemma의 출력 문단은 단일 \n로 구분돼(입력 형식에 맞춰) 영영 \n\n이 안 옴 → 영영 block을 안 바꿈 → 전부 block[0]에 몰림:

python
# 수정 전: \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로 바꾸고 같은 payload를 재생:

text
block 출현 순서: [7개 id 전부 적중]
확정 block 수: 7 / 7

한 글자 차이가 오후 하나를 잡아먹었습니다.

함정 4: 문단 수가 안 맞음, Simplify가 몰래 문단을 병합#

구분을 고치니 짧은 글은 정상이 됐지만, 긴 글 Simplify는 여전히 「절반만 고치고, 뒤는 한 단락씩 삭제」.

21 단락 Simplify 재생:

text
입력 21 단락, 21개 selection_ids
→ update된 block은 7개뿐, 나머지 14개는 대응 안 됨

이번엔 구분이 아니라 문단 수 자체가 안 맞음: Simplify가 21 단락을 7 단락 출력으로 응축하니, 1-to-1이면 자연히 앞 7개 block만 대응되고, 뒤 14개엔 원문이 남습니다.

해법: 마무리에 「처음부터 끝까지 한 번도 update 안 된」 block을 전부 삭제. 여기에도 off-by-one이 있음—「마지막에 몇 번째 block까지 썼나」로 끝단락을 계산하면 안 됨(Gemma 꼬리엔 흔히 여분의 \n이 있어 커서가 한 칸 더 나가 하나를 지워 빠뜨림), 그래서 실제로 쓴 block id를 기록(하나의 set)으로 바꿔, 마무리에 set에 없는 것을 지운다:

python
written_ids = set()
# ...update op를 yield할 때마다 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 커버, 잔류 0.

함정 5: 「매끄러움」은 사실 애니메이션, 진짜 per-token이 아니다#

diff는 맞았는데, 실측하니 글자가 「덩이째 툭툭 튀어나오는」 느낌이라 공식 demo의 하나씩 솟는 매끄러움이 없었습니다.

원인: xl-ai의 update/add 도구 내부에 스로틀이 있음—block 내용이 약 50자 자랄 때마다 한 번만 적용:

ts
// 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를 로 뒀습니다(백엔드가 이미 스트리밍하니 애니메이션을 더 넣지 말자 싶어서). 둘이 겹쳐 50자마다 한 번 튀어 아주 뚝뚝.

withDelaystrue로 되돌리면, xl-ai가 각 diff step 사이에 미세 지연을 넣어 그 50자 chunk가 한 글자씩 reveal—시각적으로 demo와 똑같이 글자가 하나씩 솟습니다:

ts
const provider = aiDocumentFormats.html.getStreamToolsProvider({
  withDelays: true,                                    // ★ 애니메이션 ON, demo 기본값도 true
  defaultStreamTools: { add: true, update: true, delete: true },
})

알고 보니 그 「매끄러움」은 애니메이션이지, 진짜 per-token이 아니었습니다.

전부 이으면 이렇게 된다#

크게 돌아, 최종은 이렇습니다:

최종 역할 분담:

  • 선택 커맨드(fix-spelling / improve / translate) → 백엔드가 update op를 뱉어, 각 선택 block에 제자리 글자급 diff; 출력 문단이 선택보다 적으면 남은 꼬리 block은 마무리에 삭제.
  • /ai 이어쓰기(선택 없음) → 백엔드가 add op를 커서 뒤에 삽입, 한 글자씩 스트림.
  • 프런트 provider는 세 도구 전부 열고, withDelays: true, dispatchTransactionwithSuggestChanges로 감쌈.

처음의 막다른 길(transport를 억지로 붙여 Gemma에 tool-calling 시키기)과 대조하면, 핵심 발상 전환은: 로컬 모델에 xl-ai의 언어를 말하게 하지 말고, 백엔드에 순수 텍스트를 operation으로 번역하는 adapter를 한 겹 더해 executor에 바로 먹여라.

회사 MAC에 막혀, 계속 재패키징만#

중간에 아주 괴로운 구간이 있었습니다: 프런트 변경을 pnpm dev로 로컬 테스트할 수 없었고, 회사의 에 막혔습니다. 프런트를 한 번 고칠 때마다 데스크톱 인스톨러를 통째로 재패키징(약 2분)+재설치해야 검증할 수 있어, 반복이 너무 느려 인생을 의심할 지경.

그래서 workaround를 생각했습니다: 백엔드의 그 Python 서비스는 사실 독립 로컬 (localhost:7501에서 돎)로, 최종적으로 로 굳혀 인스톨러에 들어갑니다—.pyd는 재컴파일 못 하지만, 가벼운 venv(fastapi/httpx/opencc/tiktoken만 설치, torch는 전혀 안 건드림)로 그 op-stream 라우트만 따로 7502에서 돌리고, 프런트를 빌드 때 7502를 가리키게 구워 넣을 수 있습니다. 이러면 백엔드 로직은 즉시 고쳐 즉시 재시작해 검증할 수 있고, 매번 .pyd를 재컴파일하지 않아도 됩니다. HAR를 재생해 구분 버그를 찾은 것도 이 셋업 덕분.

진짜 천장은, 사실 모델의 힘 부족#

모든 프로그램 문제를 다 고친 뒤, 남은 건 버그가 아니라 모델 능력의 문제:

Gemma 3 27B는 긴 입력에서 게으름을 피운다. 「문서 전체를 같은 한 block에 몰아넣은(1647자)」 Improve writing 한 건을 재생해 입출력을 대조:

text
입력 1647자 → 출력 1472자
입력/출력의 「완전히 동일한 끝부분」= 1044자

Gemma는 앞머리 약 600자만 진지하게 고쳐 쓰고, 뒤 1044자는 한 글자도 안 바꾸고 원본 그대로 echo로 돌려줬다. diff가 충실히 비추면 「앞부분은 변경, 뒷부분은 완전 그대로」가 됩니다—처음엔 나도 버그인 줄 알았지만, 이 데이터를 캐내고서야 프로그램 문제가 아니라 모델이 스스로 농땡이 부린 거라 확신했습니다.

파생 상황 둘:

  1. 문서 전체가 단일 block: block이 하나뿐이라 백엔드가 단락을 못 자르고, 거대한 덩이에 대해 반복해서 diff를 계산할 수밖에—느리고 diff도 지저분.
  2. 문단 구조를 모델이 흩뜨림: prompt에 「같은 문단 수를 유지하라」고 썼는데도 Gemma는 문단을 병합하고, 1-to-1 매핑이 무너집니다(함정 4의 꼬리 삭제로 때우지만 모양은 여전히 볼썽사나움).

근본 해결 방향per-block 덩이별 호출: N개 block을 고르면 LLM을 N번 호출, 각 덩이엔 자기 단락만 넘김—각 덩이가 작아 모델이 완전히 고쳐 쓰고, 늘 1-to-1이라 어긋나지 않음. 대가는 N번 호출, 레이턴시 증가, 게다가 프런트·백엔드를 함께 고쳐야 함. 이건 다음 단계 enhancement로 남깁니다.

Gemma만이 아니다 — 공식 demo도 비슷한 상황에서 터진다#

정말 우리 로컬 모델의 문제인지 확인하려고, BlockNote 공식 demo 페이지에 가서 같은 단락(이전 AI가 남긴 반복 조각 포함)을 전체 선택해 Simplify를 눌러 봤습니다—결과는 똑같이 터짐, diff는 엉망진창이라 뭘 고치는지 알 수 없었습니다.

공식 demo가 보내는 request body를 꺼내 보니, 그 tool definition이 「단일 tool + operations 배열」 설계를 확인해 줬습니다:

jsonc
"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가 한 글자씩 채움):

jsonc
{"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가 남긴 반복을 담고 있었고(「그리고 그들의 그리고 그들의 의견에 근거해 번역 시스템을 개선하는 그들의 그리고 그들의…」), 클라우드 모델이 이 난장판에 Simplify를 해도 나오는 diff가 예쁠 리 없습니다—이건 더 이상 모델 강약의 문제가 아니라, inline diff가 「구조적으로 무너진 긴 글」에 대해 본래 보기 흉하다는 이야기입니다.

IMPORTANT

바꿔 말하면: 우리가 밟은 매핑 어긋남, diff 추함 문제는 일부는 BlockNote 이 제자리 diff 노선의 천장이지, 로컬 Gemma만의 탓이 아닙니다. OpenAI/Claude로 바꿔도 이기는 건 「중간에 echo 안 함」뿐, diff는 긴 글에서 똑같이 헤맵니다.

마지막으로#

이 여정의 가장 큰 깨달음: 공식 패키지의 「아름다운 demo」 뒤엔 대개 아주 강한 백엔드가 전제돼 있다(여기선 tool-calling 되는 클라우드 대모델). 환경이 그 전제에 안 맞으면(로컬 소형 모델, 순수 텍스트만 뱉음), 그 transport를 억지로 붙여도 내부 race condition 더미에 박을 뿐; 올바른 자세는 그 비상구(manual-execution / StreamToolExecutor)를 찾아, 직접 adapter를 한 겹 더해 양쪽 포맷을 다리 놓는 것—그다음 상대가 챙겨 주지 않은 엣지 케이스를 하나씩 밟기: 도구 집합 한정, partial diff 타이밍, 구분자, 문단 수 매핑, 스트림 스로틀 애니메이션…

결국 기능은 됐고, 짧은~중간 텍스트 경험은 아주 좋습니다. 남은 긴 글 문제는, 솔직히 프런트나 백엔드가 더 짜낼 수 있는 게 아닙니다—그건 모델 자체의 천장이죠. 천장이 어디 있는지 아는 것도 하나의 수확입니다.