AI实时推流markdown解析 / ai #70

Words
465
Reading
3 min
Listen
Play
5h

aistream.jpg
AI应用(来源:网络)

在AI应用中实时推流是个很实用的功能,可以优化用户体验。试想下,你是想干等个数分钟到最后结果,还是数秒后就开始有数据显示?结果显而易见。OpenAI SDK等都支持推流(stream),只需加一行参数stream: true即可。前端读取传过来的chunk就可以啰。

但是有个问题,现在AI回复的默认格式都是markdown,那要怎么样实时解析markdown格式呢?

有封装好的AI SDK(by Vercel)可以使用,看了下文档,表面简单,但集成起来还蛮复杂的。而且它支持的模型要一个个独立的包,比如@ai-sdk/openai, @ai-sdk/anthropic,每个都要单独安装,有点不便。还不如OpenAI SDK,换个model和baseUrl就可以调用市面上几乎所有的模型!

逐个接受块

OpenAI SDK在后台逐个地将token传过来,前端接受即可。代码如下:

const decoder = new TextDecoder()
let getStream = function (reader) {
  return reader.read().then(async function (result) {
    // 如果数据已经读取完毕,直接返回
    if (result.done) {
      clickFlag.value = false
      return
    }
    // 取出本段数据(二进制格式)
    let chunk = result.value
    let text = decoder.decode(chunk, { stream: true })

    // 将本段数据追加到网页之中
    messageDiv.innerHTML += text
    chatContainer.scrollTop = chatContainer.scrollHeight
 
    // 递归处理下一段数据
    return getStream(reader)
  })
}
getStream(response.body.getReader())

实时markdown解析

推流式 Markdown 的核心问题是:chunk 是随机切分的,一个 **、代码块围栏、表格行可能被拆到两段里,直接对单个 chunk 渲染会闪烁/解析错乱。正确做法是累积原始文本 + 每次渲染全文 + 对末尾未闭合片段做"保护性截断"。流结束后做最终完整解析,确保所有结构(含最后被截断的部分)都正确渲染。这里用到的markdown解析是@comark/html。以下是代码:

// —— 累积区 ——
let rawText = ''          // 累积的全部原始 markdown
let renderTimer = null    // 渲染节流
let lastSafeLength = 0    // 上次安全渲染到的长度
/**
* 保护性截断:把末尾可能"半截"的 markdown 结构留给下一段
* 返回可安全渲染的文本长度
*/
function getSafeLength(text) {
  const tail = text.slice(-200)  // 只检查末尾 200 字符,够覆盖围栏/表格/强调

  // 1. 未闭合的代码围栏 ```:找到最后一个 ``` 之后都不渲染
  const fenceIdx = tail.lastIndexOf('```')
  if (fenceIdx !== -1) {
    // 统计该围栏之前 tail 中围栏数量的奇偶,奇数 = 未闭合
    const before = tail.slice(0, fenceIdx)
    const count = (before.match(/```/g) || []).length
    if (count % 2 === 0) {
      return text.length - (tail.length - fenceIdx)
    }
  }

  // 2. 未闭合的行内代码 `(奇数个反引号)
  const backticks = (tail.match(/(?<!`)`(?!`)/g) || []).length
  if (backticks % 2 === 1) {
    const idx = tail.lastIndexOf('`')
    return text.length - (tail.length - idx)
  }

  // 3. 未闭合的表格行(| ... | 结尾没有换行,可能行还没收齐)
  const lastNl = text.lastIndexOf('\n')
  const lastLine = text.slice(lastNl + 1)
  if (lastLine.includes('|') && /^\s*\|?.*\|?\s*$/.test(lastLine) && !lastLine.endsWith('\n')) {
    // 表格行通常以 | 结尾才完整;先保守不渲染末行
    if (!lastLine.trimEnd().endsWith('|')) {
      return lastNl + 1
    }
  }

  return text.length
}

/** 节流渲染:避免每个 chunk 都触发一次完整解析 */
function scheduleRender() {
  if (renderTimer) return
  renderTimer = setTimeout(async () => {
    renderTimer = null
    const safeLen = getSafeLength(rawText)
    if (safeLen <= lastSafeLength) return   // 没有新的安全内容

    const safe = rawText.slice(0, safeLen)
    const s = await renderHtml(safe)
    // 把未渲染的尾巴以纯文本追加,避免内容"跳变"
    messageDiv.innerHTML = s + escapeHtml(rawText.slice(safeLen))
    lastSafeLength = safeLen
    chatContainer.scrollTop = chatContainer.scrollHeight
  }, 1000)  // 1s 节流
}

function escapeHtml(str) {
  return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
}

/** 结束渲染:对全文做最终解析 */
async function renderFinal() {
  if (renderTimer) { clearTimeout(renderTimer); renderTimer = null }
  const s = await renderHtml(rawText)
  messageDiv.innerHTML = s
  chatContainer.scrollTop = chatContainer.scrollHeight
}

/** 处理推流,主要处理函数 */
let decoder = new TextDecoder()  //文本编译
let getStream = function (reader) {
    return reader.read().then(async function (result) {
    // 数据读取完毕
    if (result.done) {
        await renderFinal()          // 最终完整渲染
        return
    }

    // 取出本段数据并解码
    let chunk = result.value
    let text = decoder.decode(chunk, { stream: true })

    rawText += text                // 关键:累积而不是直接 innerHTML +=
    scheduleRender()               // 节流渲染

    // 递归处理下一段数据
    return getStream(reader)
    })
}
getStream(response.body.getReader())
AI实时推流markdown解析 / ai #70 | Ecency